Skip to content
Merged
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
1 change: 1 addition & 0 deletions supabase/functions/import_map.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"@supabase/supabase-js": "https://esm.sh/@supabase/[email protected]",
"openai": "npm:[email protected]",
"oak": "https://deno.land/x/[email protected]/mod.ts",
"jose": "https://deno.land/x/[email protected]/index.ts",
"std/path": "https://deno.land/[email protected]/path/mod.ts",
"std/flags": "https://deno.land/[email protected]/flags/mod.ts",
"std/dotenv": "https://deno.land/[email protected]/dotenv/mod.ts",
Expand Down
101 changes: 67 additions & 34 deletions supabase/functions/shared/auth.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
import { Context } from 'https://deno.land/x/[email protected]/mod.ts'
import { createRemoteJWKSet, jwtVerify, decodeJwt } from 'jose'

const SUPABASE_URL = Deno.env.get('SUPABASE_URL') ?? ''

// Cache the JWKS client (handles internal caching with 5min TTL)
let _jwks: ReturnType<typeof createRemoteJWKSet> | null = null

function getJwks() {
if (!_jwks && SUPABASE_URL) {
_jwks = createRemoteJWKSet(
new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`)
)
}
return _jwks
}

function parseAuthorizationHeader(ctx: Context): string | null {
const authHeader = ctx.request.headers.get('authorization') ?? ''
Expand All @@ -19,52 +34,70 @@ export async function decodeUserIdFromRequest(ctx: Context): Promise<string> {
throw new Error('Missing authorization header')
}

// In production, Supabase's edge runtime already verifies the JWT before
// our code runs. We decode the payload without re-verifying the signature.
// In local development, tokens come from the local Supabase auth service
// and the database is local, so signature verification adds no security value.
const userId = decodeJwtPayload(token)
if (userId) {
const jwks = getJwks()
if (!jwks) {
throw new Error('SUPABASE_URL not configured')
}

try {
// Try JWKS verification (production uses asymmetric keys)
const { payload } = await jwtVerify(token, jwks, {
issuer: `${SUPABASE_URL}/auth/v1`,
audience: 'authenticated',
})

const userId = payload.sub
if (!userId || typeof userId !== 'string') {
throw new Error('Invalid token: missing sub claim')
}

ctx.state.userId = userId
return userId

} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)

// For local development, fall back to decode-only mode for ANY JWKS error.
// Local Supabase uses HS256 (symmetric keys) which aren't exposed via JWKS,
// so JWKS verification will always fail locally (empty keyset, fetch errors, etc.)
if (isLocalDevelopment()) {
return decodeTokenWithoutVerification(token, ctx)
}

throw new Error(`Unauthorized: ${errorMessage}`)
}
}

throw new Error('Unauthorized: Could not extract user ID from token')
function isLocalDevelopment(): boolean {
// In Docker, SUPABASE_URL is set to kong:8000, check for that too
// Also check LOCAL_SUPABASE_URL which explicitly indicates local dev
const localUrl = Deno.env.get('LOCAL_SUPABASE_URL') ?? ''
return SUPABASE_URL.includes('127.0.0.1') ||
SUPABASE_URL.includes('localhost') ||
SUPABASE_URL.includes('kong:') ||
localUrl.includes('127.0.0.1') ||
localUrl.includes('localhost')
}

function decodeJwtPayload(token: string): string | null {
function decodeTokenWithoutVerification(token: string, ctx: Context): string {
try {
const parts = token.split('.')
if (parts.length !== 3) return null

const payloadB64 = parts[1]
const payloadJson = new TextDecoder().decode(base64UrlToUint8Array(payloadB64))
const payload = JSON.parse(payloadJson) as Record<string, unknown>
const payload = decodeJwt(token)

// Check expiration
const now = Math.floor(Date.now() / 1000)
const exp = payload?.exp
if (typeof exp === 'number' && now >= exp) {
return null // Token expired
if (typeof payload.exp === 'number' && now >= payload.exp) {
throw new Error('Token expired')
}

const sub = payload?.sub
return typeof sub === 'string' ? sub : null
} catch {
return null
}
}
const userId = payload.sub
if (!userId || typeof userId !== 'string') {
throw new Error('Invalid token: missing sub claim')
}

function base64UrlToUint8Array(input: string): Uint8Array {
let normalized = input.replace(/-/g, '+').replace(/_/g, '/')
const padding = normalized.length % 4
if (padding) {
normalized += '='.repeat(4 - padding)
}
const binary = atob(normalized)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
ctx.state.userId = userId
return userId
} catch (error) {
const message = error instanceof Error ? error.message : 'Token decode failed'
throw new Error(`Unauthorized: ${message}`)
}
return bytes
}