fix(account-delete): judge step-up auth by amr sign-in time, not token iat
This commit is contained in:
parent
043ef579a8
commit
83c2deb561
3 changed files with 167 additions and 19 deletions
82
server/supabase/functions/account-delete/recent-auth.ts
Normal file
82
server/supabase/functions/account-delete/recent-auth.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
// 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue