fix(account-delete): judge step-up auth by amr sign-in time, not token iat

This commit is contained in:
Yun Chan 2026-09-28 00:54:00 +09:00
parent 043ef579a8
commit 83c2deb561
3 changed files with 167 additions and 19 deletions

View 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)
}