diff --git a/src/adapters/hono/index.ts b/src/adapters/hono/index.ts index 67f420a..6a7d597 100644 --- a/src/adapters/hono/index.ts +++ b/src/adapters/hono/index.ts @@ -4,4 +4,4 @@ * @packageDocumentation */ -export { withSupabase } from './middleware.js' +export { withSupabase, withSupabaseUserAuth } from './middleware.js' diff --git a/src/adapters/hono/middleware.test.ts b/src/adapters/hono/middleware.test.ts index e37fcdc..5e5938f 100644 --- a/src/adapters/hono/middleware.test.ts +++ b/src/adapters/hono/middleware.test.ts @@ -1,10 +1,17 @@ +import { exportJWK, generateKeyPair, SignJWT } from 'jose' import { Hono } from 'hono' -import { describe, expect, it } from 'vitest' +import { beforeAll, describe, expect, it } from 'vitest' -import type { SupabaseContext } from '../../types.js' -import { withSupabase } from './middleware.js' +import type { + JsonWebKeySet, + SupabaseContext, + SupabaseEnv, + SupabaseUserContext, +} from '../../types.js' +import { withSupabase, withSupabaseUserAuth } from './middleware.js' type Env = { Variables: { supabaseContext: SupabaseContext } } +type UserEnv = { Variables: { supabaseUserContext: SupabaseUserContext } } describe('hono supabase middleware', () => { const env = { @@ -96,3 +103,96 @@ describe('hono supabase middleware', () => { expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull() }) }) + +describe('hono supabase user auth middleware', () => { + let jwks: JsonWebKeySet + let makeToken: (claims?: Record) => Promise + + beforeAll(async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + jwks = { keys: [publicJwk] } + + makeToken = async (claims = {}) => { + let jwt = new SignJWT({ + sub: 'user-123', + role: 'authenticated', + email: 'test@example.com', + ...claims, + }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime('1h') + if (!('aud' in claims)) { + jwt = jwt.setAudience('authenticated') + } + return jwt.sign(privateKey) + } + }) + + function makeEnv(overrides?: Partial): Partial { + return { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: {}, + jwks, + ...overrides, + } + } + + it('sets a user-scoped context without a secret key', async () => { + const token = await makeToken() + const app = new Hono() + app.use( + '*', + withSupabaseUserAuth({ + userId: 'user-123', + env: makeEnv(), + }), + ) + app.get('/', (c) => { + const ctx = c.get('supabaseUserContext') + return c.json({ + hasSupabase: !!ctx.supabase, + hasAdmin: + 'supabaseAdmin' in (ctx as unknown as Record), + tokenMatches: ctx.token === token, + userId: ctx.userClaims.id, + }) + }) + + const res = await app.request('/', { + headers: { Authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(200) + const body = await res.json() + expect(body).toEqual({ + hasSupabase: true, + hasAdmin: false, + tokenMatches: true, + userId: 'user-123', + }) + }) + + it('rejects a token for a different user ID', async () => { + const token = await makeToken() + const app = new Hono() + app.use( + '*', + withSupabaseUserAuth({ + userId: 'user-456', + env: makeEnv(), + }), + ) + app.get('/', (c) => c.json({ ok: true })) + + const res = await app.request('/', { + headers: { Authorization: `Bearer ${token}` }, + }) + + expect(res.status).toBe(401) + }) +}) diff --git a/src/adapters/hono/middleware.ts b/src/adapters/hono/middleware.ts index 33581d4..f19dde0 100644 --- a/src/adapters/hono/middleware.ts +++ b/src/adapters/hono/middleware.ts @@ -3,7 +3,20 @@ import { HTTPException } from 'hono/http-exception' import { createMiddleware } from 'hono/factory' import { createSupabaseContext } from '../../create-supabase-context.js' -import type { SupabaseContext, WithSupabaseConfig } from '../../types.js' +import { + AuthError, + CreateSupabaseClientError, + EnvError, + Errors, +} from '../../errors.js' +import { createContextClient } from '../../core/create-context-client.js' +import { verifyUserAuth } from '../../core/verify-user-auth.js' +import type { + SupabaseContext, + SupabaseUserContext, + WithSupabaseConfig, + WithSupabaseUserAuthConfig, +} from '../../types.js' /** * Hono middleware that creates a {@link SupabaseContext} and stores it in `c.var.supabaseContext`. @@ -58,3 +71,76 @@ export function withSupabase( await next() }) } + +/** + * Hono middleware that verifies a Supabase user JWT and stores a user-scoped context. + * + * The context contains a Supabase client configured with the verified user's + * bearer token, plus non-null user claims. It intentionally does not create an + * admin client, so it does not require `SUPABASE_SECRET_KEY`. + * + * @param config - User auth verification and Supabase client options. + * @returns A Hono middleware that sets `c.var.supabaseUserContext`. + * + * @example + * ```ts + * import { Hono } from 'hono' + * import { withSupabaseUserAuth } from '@supabase/server/adapters/hono' + * + * const app = new Hono() + * app.use('/api/*', withSupabaseUserAuth({ userId: expectedUserId })) + * + * app.get('/api/profile', async (c) => { + * const { supabase, userClaims } = c.var.supabaseUserContext + * const { data } = await supabase.from('profiles').select().eq('id', userClaims.id) + * return c.json(data) + * }) + * ``` + */ +export function withSupabaseUserAuth( + config?: WithSupabaseUserAuthConfig, +): MiddlewareHandler<{ + Variables: { supabaseUserContext: SupabaseUserContext } +}> { + return createMiddleware<{ + Variables: { supabaseUserContext: SupabaseUserContext } + }>(async (c, next) => { + if (c.var.supabaseUserContext) { + await next() + return + } + + const { data: auth, error } = await verifyUserAuth(c.req.raw, config) + if (error) { + throw new HTTPException(error.status as 401 | 500, { + message: error.message, + cause: error, + }) + } + + try { + const supabase = createContextClient({ + auth: { token: auth.token }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + c.set('supabaseUserContext', { + supabase, + token: auth.token, + userClaims: auth.userClaims, + jwtClaims: auth.jwtClaims, + }) + } catch (e) { + const error = + e instanceof EnvError + ? new AuthError(e.message, e.code, 500) + : Errors[CreateSupabaseClientError]() + throw new HTTPException(error.status as 401 | 500, { + message: error.message, + cause: error, + }) + } + + await next() + }) +} diff --git a/src/core/index.ts b/src/core/index.ts index 14ab1ba..b55d4e9 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -7,6 +7,7 @@ export { resolveEnv } from './resolve-env.js' export { extractCredentials } from './extract-credentials.js' export { verifyCredentials } from './verify-credentials.js' export { verifyAuth } from './verify-auth.js' +export { verifyUserAuth } from './verify-user-auth.js' export { createContextClient } from './create-context-client.js' export { createAdminClient } from './create-admin-client.js' @@ -14,4 +15,6 @@ export type { ClientAuth, CreateAdminClientOptions, CreateContextClientOptions, + UserAuthResult, + VerifyUserAuthOptions, } from '../types.js' diff --git a/src/core/verify-user-auth.test.ts b/src/core/verify-user-auth.test.ts new file mode 100644 index 0000000..d2c0721 --- /dev/null +++ b/src/core/verify-user-auth.test.ts @@ -0,0 +1,119 @@ +import { exportJWK, generateKeyPair, SignJWT } from 'jose' +import { beforeAll, describe, expect, it } from 'vitest' + +import { InvalidCredentialsError } from '../errors.js' +import type { JsonWebKeySet, SupabaseEnv } from '../types.js' +import { verifyUserAuth } from './verify-user-auth.js' + +function makeEnv(overrides?: Partial): Partial { + return { + url: 'https://test.supabase.co', + publishableKeys: { default: 'sb_publishable_xyz' }, + secretKeys: {}, + jwks: null, + ...overrides, + } +} + +describe('verifyUserAuth', () => { + let jwks: JsonWebKeySet + let makeToken: (claims?: Record) => Promise + + beforeAll(async () => { + const { privateKey, publicKey } = await generateKeyPair('RS256') + const publicJwk = await exportJWK(publicKey) + publicJwk.alg = 'RS256' + publicJwk.use = 'sig' + jwks = { keys: [publicJwk] } + + makeToken = async (claims = {}) => { + let jwt = new SignJWT({ + sub: 'user-123', + role: 'authenticated', + email: 'test@example.com', + ...claims, + }) + .setProtectedHeader({ alg: 'RS256' }) + .setIssuedAt() + .setExpirationTime('1h') + if (!('aud' in claims)) { + jwt = jwt.setAudience('authenticated') + } + return jwt.sign(privateKey) + } + }) + + it('succeeds with an authenticated user token without secret keys', async () => { + const token = await makeToken() + const req = new Request('http://localhost', { + headers: { Authorization: `Bearer ${token}` }, + }) + + const result = await verifyUserAuth(req, { + env: makeEnv({ jwks }), + }) + + expect(result.error).toBeNull() + expect(result.data!.token).toBe(token) + expect(result.data!.userClaims.id).toBe('user-123') + expect(result.data!.jwtClaims.aud).toBe('authenticated') + }) + + it('validates the expected user ID', async () => { + const token = await makeToken() + const req = new Request('http://localhost', { + headers: { Authorization: `Bearer ${token}` }, + }) + + const result = await verifyUserAuth(req, { + userId: 'user-123', + env: makeEnv({ jwks }), + }) + + expect(result.error).toBeNull() + expect(result.data!.userClaims.id).toBe('user-123') + }) + + it('rejects a token for a different user ID', async () => { + const token = await makeToken() + const req = new Request('http://localhost', { + headers: { Authorization: `Bearer ${token}` }, + }) + + const result = await verifyUserAuth(req, { + userId: 'user-456', + env: makeEnv({ jwks }), + }) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('requires authenticated audience by default', async () => { + const token = await makeToken({ aud: 'anon' }) + const req = new Request('http://localhost', { + headers: { Authorization: `Bearer ${token}` }, + }) + + const result = await verifyUserAuth(req, { + env: makeEnv({ jwks }), + }) + + expect(result.error).not.toBeNull() + expect(result.error!.code).toBe(InvalidCredentialsError) + }) + + it('allows custom expected audiences', async () => { + const token = await makeToken({ aud: 'custom' }) + const req = new Request('http://localhost', { + headers: { Authorization: `Bearer ${token}` }, + }) + + const result = await verifyUserAuth(req, { + audience: 'custom', + env: makeEnv({ jwks }), + }) + + expect(result.error).toBeNull() + }) +}) diff --git a/src/core/verify-user-auth.ts b/src/core/verify-user-auth.ts new file mode 100644 index 0000000..332a0ac --- /dev/null +++ b/src/core/verify-user-auth.ts @@ -0,0 +1,80 @@ +import { Errors, InvalidCredentialsError } from '../errors.js' +import type { AuthError } from '../errors.js' +import type { UserAuthResult, VerifyUserAuthOptions } from '../types.js' +import { verifyAuth } from './verify-auth.js' + +const DEFAULT_AUDIENCE = 'authenticated' + +function includesExpected( + actual: string | string[] | undefined, + expected: string | string[], +): boolean { + if (!actual) return false + const actualValues = Array.isArray(actual) ? actual : [actual] + const expectedValues = Array.isArray(expected) ? expected : [expected] + return expectedValues.some((value) => actualValues.includes(value)) +} + +/** + * Verifies a Supabase user JWT and optionally checks its user ID. + * + * This is a narrower user-token API on top of {@link verifyAuth}. It requires + * `auth: "user"`, defaults JWT audience validation to `"authenticated"`, and + * returns non-null user claims on success. + * + * @param request - The incoming HTTP request. + * @param options - Optional environment overrides, expected user ID, and audience. + * + * @returns A result tuple: `{ data, error }`. + * - On success: `{ data: UserAuthResult, error: null }` + * - On failure: `{ data: null, error: AuthError }` + * + * @example + * ```ts + * import { verifyUserAuth } from '@supabase/server/core' + * + * const { data: auth, error } = await verifyUserAuth(request, { + * userId: 'd0f1a2b3-...', + * }) + * + * if (error) { + * return Response.json({ message: error.message }, { status: error.status }) + * } + * + * console.log(auth.userClaims.id) + * ``` + */ +export async function verifyUserAuth( + request: Request, + options: VerifyUserAuthOptions = {}, +): Promise< + { data: UserAuthResult; error: null } | { data: null; error: AuthError } +> { + const { data: auth, error } = await verifyAuth(request, { + auth: 'user', + env: options.env, + }) + if (error) return { data: null, error } + + if (!auth.token || !auth.userClaims || !auth.jwtClaims) { + return { data: null, error: Errors[InvalidCredentialsError]() } + } + + const audience = options.audience ?? DEFAULT_AUDIENCE + if (!includesExpected(auth.jwtClaims.aud, audience)) { + return { data: null, error: Errors[InvalidCredentialsError]() } + } + + if (options.userId && !includesExpected(auth.userClaims.id, options.userId)) { + return { data: null, error: Errors[InvalidCredentialsError]() } + } + + return { + data: { + token: auth.token, + userClaims: auth.userClaims, + jwtClaims: auth.jwtClaims, + }, + error: null, + } +} diff --git a/src/index.ts b/src/index.ts index 675dec3..7c47259 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,8 +19,12 @@ export type { JWTClaims, SupabaseContext, SupabaseEnv, + SupabaseUserContext, + UserAuthResult, UserClaims, + VerifyUserAuthOptions, WithSupabaseConfig, + WithSupabaseUserAuthConfig, } from './types.js' export { diff --git a/src/types.ts b/src/types.ts index 1b8371e..31a8ed3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -143,6 +143,24 @@ export interface AuthResult { keyName?: string | null } +/** + * Verified Supabase user auth result. + * + * Contains only user-token data and non-null JWT-derived identity. + * + * @see {@link verifyUserAuth} + */ +export interface UserAuthResult { + /** The verified JWT. */ + token: string + + /** Normalized user identity derived from the JWT. */ + userClaims: UserClaims + + /** Raw JWT payload. */ + jwtClaims: JWTClaims +} + /** * Standard JWT claims as defined by RFC 7519, extended with Supabase-specific fields. * @@ -280,6 +298,39 @@ export interface WithSupabaseConfig { supabaseOptions?: SupabaseClientOptions } +/** + * Configuration for {@link verifyUserAuth}. + * + * Verifies a Supabase user JWT without creating Supabase clients. + */ +export interface VerifyUserAuthOptions { + /** + * Override auto-detected environment variables. Useful for testing + * or when running in environments without standard env var support. + */ + env?: Partial + + /** + * Expected Supabase Auth user ID(s). When provided, the verified JWT `sub` + * must match one of these values. + */ + userId?: string | string[] + + /** + * Expected JWT audience(s). Defaults to `"authenticated"`. + */ + audience?: string | string[] +} + +/** Options for Hono's {@link withSupabaseUserAuth} middleware. */ +export interface WithSupabaseUserAuthConfig extends VerifyUserAuthOptions { + /** + * Options forwarded to `createClient()`. `accessToken` is stripped, and auth + * settings are force-overwritten to server-safe values. + */ + supabaseOptions?: SupabaseClientOptions +} + /** * Auth identity for client creation functions. * @@ -345,3 +396,23 @@ export interface SupabaseContext { */ authKeyName?: string } + +/** + * User-scoped Supabase context created after user JWT authentication. + * + * Contains only the RLS-scoped client and non-null JWT-derived identity. It + * intentionally omits `supabaseAdmin`, so it does not require a secret key. + */ +export interface SupabaseUserContext extends Pick< + SupabaseContext, + 'supabase' +> { + /** Verified bearer token from the request. */ + token: string + + /** JWT-derived identity. */ + userClaims: UserClaims + + /** Raw JWT payload. */ + jwtClaims: JWTClaims +}