82 lines
3.2 KiB
TypeScript
82 lines
3.2 KiB
TypeScript
// server/supabase/functions/account-delete/recent-auth.ts
|
|
// Step-up authentication policy for destructive account operations.
|
|
//
|
|
// The access token `iat` is NOT an authentication time: GoTrue mints a fresh
|
|
// access token (new `iat`) on every refresh_token grant, so anyone holding a
|
|
// refresh token can make `iat` "recent" without re-entering credentials.
|
|
// The `amr` claim entries carry the time each authentication method was last
|
|
// completed for the session and survive refresh, so the most recent `amr`
|
|
// timestamp is the session's real authentication time.
|
|
|
|
export const RECENT_AUTH_SECONDS = 10 * 60
|
|
/** Tolerated clock skew for timestamps slightly in the future. */
|
|
export const CLOCK_SKEW_SECONDS = 60
|
|
|
|
export interface AmrEntry {
|
|
method: string
|
|
timestamp: number
|
|
}
|
|
|
|
export interface SessionAuthClaims {
|
|
amr: AmrEntry[]
|
|
}
|
|
|
|
export function extractBearerToken(authorization: string | null): string | null {
|
|
const token = authorization?.replace(/^Bearer\s+/i, '').trim()
|
|
return token ? token : null
|
|
}
|
|
|
|
/**
|
|
* Decodes (without verifying) the JWT payload. Callers must verify the token
|
|
* first (requireUser -> auth.getUser()) before trusting these claims.
|
|
*/
|
|
export function decodeJwtPayload(token: string | null): Record<string, unknown> | null {
|
|
if (!token) return null
|
|
const payloadPart = token.split('.')[1]
|
|
if (!payloadPart) return null
|
|
|
|
try {
|
|
const normalized = payloadPart.replace(/-/g, '+').replace(/_/g, '/')
|
|
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
|
const payload: unknown = JSON.parse(atob(padded))
|
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return null
|
|
return payload as Record<string, unknown>
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
export function parseSessionAuthClaims(payload: Record<string, unknown> | null): SessionAuthClaims | null {
|
|
if (!payload || !Array.isArray(payload.amr)) return null
|
|
|
|
const amr: AmrEntry[] = []
|
|
for (const entry of payload.amr as unknown[]) {
|
|
if (!entry || typeof entry !== 'object') continue
|
|
const { method, timestamp } = entry as { method?: unknown; timestamp?: unknown }
|
|
if (typeof method !== 'string' || typeof timestamp !== 'number' || !Number.isFinite(timestamp)) continue
|
|
amr.push({ method, timestamp })
|
|
}
|
|
return { amr }
|
|
}
|
|
|
|
/** The most recent time the session completed any authentication method, or null. */
|
|
export function resolveAuthenticatedAt(claims: SessionAuthClaims | null): number | null {
|
|
if (!claims || claims.amr.length === 0) return null
|
|
return Math.max(...claims.amr.map((entry) => entry.timestamp))
|
|
}
|
|
|
|
export function isRecentlyAuthenticated(
|
|
authenticatedAt: number | null,
|
|
nowSeconds: number,
|
|
windowSeconds: number = RECENT_AUTH_SECONDS,
|
|
): boolean {
|
|
if (authenticatedAt === null) return false
|
|
if (authenticatedAt > nowSeconds + CLOCK_SKEW_SECONDS) return false
|
|
return nowSeconds - authenticatedAt <= windowSeconds
|
|
}
|
|
|
|
/** Composes the policy from a raw Authorization header. */
|
|
export function hasRecentAuthentication(authorization: string | null, nowSeconds: number): boolean {
|
|
const claims = parseSessionAuthClaims(decodeJwtPayload(extractBearerToken(authorization)))
|
|
return isRecentlyAuthenticated(resolveAuthenticatedAt(claims), nowSeconds)
|
|
}
|