Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/adapters/hono/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@
* @packageDocumentation
*/

export { withSupabase } from './middleware.js'
export { withSupabase, withSupabaseUserAuth } from './middleware.js'
106 changes: 103 additions & 3 deletions src/adapters/hono/middleware.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand Down Expand Up @@ -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<string, unknown>) => Promise<string>

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<SupabaseEnv>): Partial<SupabaseEnv> {
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<UserEnv>()
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<string, unknown>),
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<UserEnv>()
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)
})
})
88 changes: 87 additions & 1 deletion src/adapters/hono/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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<Database = unknown>(
config?: WithSupabaseUserAuthConfig,
): MiddlewareHandler<{
Variables: { supabaseUserContext: SupabaseUserContext<Database> }
}> {
return createMiddleware<{
Variables: { supabaseUserContext: SupabaseUserContext<Database> }
}>(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<Database>({
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()
})
}
3 changes: 3 additions & 0 deletions src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,14 @@ 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'

export type {
ClientAuth,
CreateAdminClientOptions,
CreateContextClientOptions,
UserAuthResult,
VerifyUserAuthOptions,
} from '../types.js'
119 changes: 119 additions & 0 deletions src/core/verify-user-auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<SupabaseEnv>): Partial<SupabaseEnv> {
return {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
...overrides,
}
}

describe('verifyUserAuth', () => {
let jwks: JsonWebKeySet
let makeToken: (claims?: Record<string, unknown>) => Promise<string>

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()
})
})
Loading
Loading