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
81
server/supabase/functions/account-delete/recent-auth.test.ts
Normal file
81
server/supabase/functions/account-delete/recent-auth.test.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import {
|
||||
decodeJwtPayload,
|
||||
extractBearerToken,
|
||||
hasRecentAuthentication,
|
||||
isRecentlyAuthenticated,
|
||||
parseSessionAuthClaims,
|
||||
RECENT_AUTH_SECONDS,
|
||||
resolveAuthenticatedAt,
|
||||
} from './recent-auth.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
function base64UrlJson(value: unknown): string {
|
||||
return btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '')
|
||||
}
|
||||
|
||||
function bearer(payload: Record<string, unknown>): string {
|
||||
return `Bearer ${base64UrlJson({ alg: 'HS256', typ: 'JWT' })}.${base64UrlJson(payload)}.signature`
|
||||
}
|
||||
|
||||
const NOW = 1_800_000_000
|
||||
const MONTHS_AGO = NOW - 90 * 24 * 60 * 60
|
||||
|
||||
Deno.test('redteam r1-20: a refreshed token (fresh iat) from an old login is NOT recent authentication', () => {
|
||||
const refreshed = bearer({
|
||||
sub: 'user-1',
|
||||
iat: NOW - 5,
|
||||
exp: NOW + 3600,
|
||||
amr: [{ method: 'password', timestamp: MONTHS_AGO }],
|
||||
})
|
||||
assert(!hasRecentAuthentication(refreshed, NOW), 'refresh_token grant must not satisfy step-up auth')
|
||||
})
|
||||
|
||||
Deno.test('redteam r1-20: a token without amr claims is rejected even with a fresh iat', () => {
|
||||
assert(!hasRecentAuthentication(bearer({ sub: 'user-1', iat: NOW }), NOW), 'missing amr must fail closed')
|
||||
assert(!hasRecentAuthentication(bearer({ sub: 'user-1', iat: NOW, amr: [] }), NOW), 'empty amr must fail closed')
|
||||
assert(
|
||||
!hasRecentAuthentication(bearer({ sub: 'user-1', iat: NOW, amr: [{ method: 'password' }] }), NOW),
|
||||
'amr entries without timestamps must fail closed',
|
||||
)
|
||||
})
|
||||
|
||||
Deno.test('recent sign-in (amr timestamp inside the window) is accepted', () => {
|
||||
const fresh = bearer({ sub: 'user-1', iat: NOW - 30, amr: [{ method: 'password', timestamp: NOW - 30 }] })
|
||||
assert(hasRecentAuthentication(fresh, NOW), 'fresh login must pass')
|
||||
const edge = bearer({ sub: 'user-1', iat: NOW, amr: [{ method: 'otp', timestamp: NOW - RECENT_AUTH_SECONDS }] })
|
||||
assert(hasRecentAuthentication(edge, NOW), 'exactly at the window boundary must pass')
|
||||
const stale = bearer({ sub: 'user-1', iat: NOW, amr: [{ method: 'otp', timestamp: NOW - RECENT_AUTH_SECONDS - 1 }] })
|
||||
assert(!hasRecentAuthentication(stale, NOW), 'one second past the window must fail')
|
||||
})
|
||||
|
||||
Deno.test('the most recent amr method counts (e.g. a fresh TOTP step-up on an old password session)', () => {
|
||||
const claims = parseSessionAuthClaims({
|
||||
amr: [
|
||||
{ method: 'password', timestamp: MONTHS_AGO },
|
||||
{ method: 'totp', timestamp: NOW - 60 },
|
||||
'garbage',
|
||||
{ method: 'oauth', timestamp: 'nope' },
|
||||
],
|
||||
})
|
||||
assert(claims !== null && claims.amr.length === 2, 'invalid amr entries are skipped')
|
||||
assert(resolveAuthenticatedAt(claims) === NOW - 60, 'latest timestamp wins')
|
||||
assert(isRecentlyAuthenticated(resolveAuthenticatedAt(claims), NOW), 'step-up within window passes')
|
||||
})
|
||||
|
||||
Deno.test('future authentication times beyond clock skew are rejected', () => {
|
||||
assert(isRecentlyAuthenticated(NOW + 30, NOW), 'small skew tolerated')
|
||||
assert(!isRecentlyAuthenticated(NOW + 3600, NOW), 'far-future timestamp rejected')
|
||||
assert(!isRecentlyAuthenticated(null, NOW), 'null rejected')
|
||||
})
|
||||
|
||||
Deno.test('malformed Authorization headers fail closed', () => {
|
||||
assert(extractBearerToken(null) === null, 'null header')
|
||||
assert(extractBearerToken('Bearer ') === null, 'blank token')
|
||||
assert(decodeJwtPayload('not-a-jwt') === null, 'no payload segment')
|
||||
assert(decodeJwtPayload('a.!!!.c') === null, 'invalid base64')
|
||||
assert(decodeJwtPayload(`a.${base64UrlJson([1, 2])}.c`) === null, 'array payload')
|
||||
assert(!hasRecentAuthentication('Bearer a.!!!.c', NOW), 'malformed token rejected')
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue