@tuturuuu/utils 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/package.json +3 -3
  3. package/src/__tests__/ai-temp-auth.test.ts +32 -0
  4. package/src/__tests__/api-proxy-guard.test.ts +4 -5
  5. package/src/__tests__/app-url.test.ts +1 -0
  6. package/src/__tests__/required-mfa-api-session.test.ts +75 -0
  7. package/src/__tests__/required-mfa-browser.test.ts +73 -0
  8. package/src/__tests__/required-mfa-lineage.test.ts +40 -0
  9. package/src/__tests__/required-mfa-proxy.test.ts +105 -0
  10. package/src/__tests__/required-mfa-request.test.ts +342 -0
  11. package/src/__tests__/required-mfa-runtime.test.ts +109 -0
  12. package/src/__tests__/required-mfa-transfer.test.ts +29 -0
  13. package/src/ai-temp-auth.ts +11 -0
  14. package/src/api-proxy-guard.ts +6 -6
  15. package/src/app-coordination-token.ts +247 -0
  16. package/src/commercial-comparison.ts +7 -0
  17. package/src/internal-domains.ts +3 -0
  18. package/src/launchable-apps.ts +12 -0
  19. package/src/onboarding.ts +6 -0
  20. package/src/platform-release.test.ts +1 -1
  21. package/src/platform-release.ts +1 -1
  22. package/src/portless.ts +1 -0
  23. package/src/required-mfa-api-session.ts +30 -0
  24. package/src/required-mfa-app-session.ts +34 -0
  25. package/src/required-mfa-browser.ts +42 -0
  26. package/src/required-mfa-credentials.ts +58 -0
  27. package/src/required-mfa-lineage.ts +22 -0
  28. package/src/required-mfa-policy.ts +188 -0
  29. package/src/required-mfa-proxy.ts +87 -0
  30. package/src/required-mfa-request.ts +147 -0
  31. package/src/required-mfa-runtime.ts +103 -0
  32. package/src/required-mfa-supabase-session.ts +33 -0
  33. package/src/required-mfa-transfer.ts +41 -0
@@ -0,0 +1,188 @@
1
+ /** Server-owned app metadata; never read this policy from user_metadata. */
2
+ export const REQUIRED_MFA_POLICY_KEY = 'tuturuuu_required_mfa';
3
+
4
+ export type RequiredMfaPolicy = {
5
+ required: boolean;
6
+ /** A new policy or recovery reset invalidates earlier MFA proofs. */
7
+ verifiedAfter: number;
8
+ primaryVerifiedAfter?: number;
9
+ };
10
+
11
+ export type MfaSessionProof = {
12
+ sessionId: string;
13
+ verifiedAt: number;
14
+ factorId?: string;
15
+ verificationSessionId?: string;
16
+ primaryVerifiedAt?: number;
17
+ /** Mobile approval remains time-bounded across access/refresh tokens. */
18
+ expiresAt?: number;
19
+ };
20
+
21
+ /** Validate proof carried by a verified, server-signed app session. */
22
+ export function isMfaSessionProof(value: unknown): value is MfaSessionProof {
23
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
24
+ const proof = value as Record<string, unknown>;
25
+ return (
26
+ typeof proof.sessionId === 'string' &&
27
+ proof.sessionId.length > 0 &&
28
+ typeof proof.verifiedAt === 'number' &&
29
+ Number.isSafeInteger(proof.verifiedAt) &&
30
+ proof.verifiedAt >= 0 &&
31
+ (proof.factorId === undefined || typeof proof.factorId === 'string') &&
32
+ (proof.verificationSessionId === undefined ||
33
+ typeof proof.verificationSessionId === 'string') &&
34
+ (proof.primaryVerifiedAt === undefined ||
35
+ (typeof proof.primaryVerifiedAt === 'number' &&
36
+ Number.isSafeInteger(proof.primaryVerifiedAt) &&
37
+ proof.primaryVerifiedAt >= 0)) &&
38
+ (proof.expiresAt === undefined ||
39
+ (typeof proof.expiresAt === 'number' &&
40
+ Number.isSafeInteger(proof.expiresAt) &&
41
+ proof.expiresAt > proof.verifiedAt))
42
+ );
43
+ }
44
+
45
+ /** Missing policy preserves existing accounts; malformed policy fails closed. */
46
+ export function readRequiredMfaPolicy(
47
+ appMetadata: Record<string, unknown> | null | undefined
48
+ ): RequiredMfaPolicy {
49
+ if (!appMetadata || !Object.hasOwn(appMetadata, REQUIRED_MFA_POLICY_KEY)) {
50
+ return { required: false, verifiedAfter: 0 };
51
+ }
52
+ const raw = appMetadata[REQUIRED_MFA_POLICY_KEY];
53
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
54
+ return { required: true, verifiedAfter: Number.POSITIVE_INFINITY };
55
+ }
56
+ const policy = raw as Record<string, unknown>;
57
+ if (policy.required === false) return { required: false, verifiedAfter: 0 };
58
+ const verifiedAfter =
59
+ Object.hasOwn(policy, 'recoveryInProgress') &&
60
+ policy.recoveryInProgress !== false
61
+ ? null
62
+ : policy.verifiedAfter;
63
+ const primary = policy.primaryVerifiedAfter;
64
+ return {
65
+ required: true,
66
+ ...(primary === undefined
67
+ ? {}
68
+ : {
69
+ primaryVerifiedAfter:
70
+ typeof primary === 'number' &&
71
+ Number.isSafeInteger(primary) &&
72
+ primary >= 0
73
+ ? primary
74
+ : Number.POSITIVE_INFINITY,
75
+ }),
76
+ verifiedAfter:
77
+ policy.required === true &&
78
+ typeof verifiedAfter === 'number' &&
79
+ Number.isSafeInteger(verifiedAfter) &&
80
+ verifiedAfter >= 0
81
+ ? verifiedAfter
82
+ : Number.POSITIVE_INFINITY,
83
+ };
84
+ }
85
+
86
+ /** Call only with cryptographically verified Supabase claims. Token iat alone
87
+ * is not MFA evidence: a refresh must not turn an old proof into a new one. */
88
+ export function mfaProofFromVerifiedClaims(
89
+ claims: Record<string, unknown>,
90
+ nowSeconds = Math.floor(Date.now() / 1000)
91
+ ): MfaSessionProof | null {
92
+ if (
93
+ claims.aal !== 'aal2' ||
94
+ typeof claims.session_id !== 'string' ||
95
+ !claims.session_id ||
96
+ !Array.isArray(claims.amr)
97
+ ) {
98
+ return null;
99
+ }
100
+ const verifiedAt = claims.amr.reduce<number>((latest, raw) => {
101
+ if (!raw || typeof raw !== 'object') return latest;
102
+ const entry = raw as Record<string, unknown>;
103
+ const timestamp = entry.timestamp;
104
+ if (
105
+ !['totp', 'mfa/phone', 'mfa/webauthn', 'mfa/recovery_code'].includes(
106
+ typeof entry.method === 'string' ? entry.method : ''
107
+ ) ||
108
+ typeof timestamp !== 'number' ||
109
+ !Number.isSafeInteger(timestamp) ||
110
+ timestamp < 0 ||
111
+ timestamp > nowSeconds
112
+ ) {
113
+ return latest;
114
+ }
115
+ return Math.max(latest, timestamp);
116
+ }, -1);
117
+ const primaryVerifiedAt = primaryProofTime(claims, nowSeconds);
118
+ return verifiedAt < 0
119
+ ? null
120
+ : {
121
+ sessionId: claims.session_id,
122
+ verifiedAt,
123
+ ...(primaryVerifiedAt === null ? {} : { primaryVerifiedAt }),
124
+ };
125
+ }
126
+
127
+ export function satisfiesRequiredMfaPolicy(
128
+ policy: RequiredMfaPolicy,
129
+ proof: MfaSessionProof | null,
130
+ nowSeconds = Math.floor(Date.now() / 1000)
131
+ ): boolean {
132
+ if (!policy.required) return true;
133
+ return Boolean(
134
+ isMfaSessionProof(proof) &&
135
+ proof.verifiedAt > policy.verifiedAfter &&
136
+ proof.verifiedAt <= nowSeconds &&
137
+ (policy.primaryVerifiedAfter === undefined ||
138
+ (proof.primaryVerifiedAt !== undefined &&
139
+ proof.primaryVerifiedAt > policy.primaryVerifiedAfter &&
140
+ proof.primaryVerifiedAt <= nowSeconds)) &&
141
+ (proof.expiresAt === undefined || proof.expiresAt > nowSeconds)
142
+ );
143
+ }
144
+
145
+ /** Refresh is never a new primary authentication. */
146
+ export function primaryProofTime(
147
+ claims: Record<string, unknown>,
148
+ now = Math.floor(Date.now() / 1000)
149
+ ): number | null {
150
+ if (!Array.isArray(claims.amr)) return null;
151
+ const methods = new Set([
152
+ 'password',
153
+ 'otp',
154
+ 'oauth',
155
+ 'sso/saml',
156
+ 'sso',
157
+ 'magiclink',
158
+ 'recovery',
159
+ 'invite',
160
+ 'passkey',
161
+ 'web3',
162
+ 'email/signup',
163
+ 'phone/signup',
164
+ ]);
165
+ const times = claims.amr.flatMap((entry: unknown) => {
166
+ if (!entry || typeof entry !== 'object') return [];
167
+ const value = entry as Record<string, unknown>;
168
+ return methods.has(String(value.method)) &&
169
+ typeof value.timestamp === 'number' &&
170
+ Number.isSafeInteger(value.timestamp) &&
171
+ value.timestamp >= 0 &&
172
+ value.timestamp <= now
173
+ ? [value.timestamp]
174
+ : [];
175
+ });
176
+ return times.length ? Math.max(...times) : null;
177
+ }
178
+ export function requiresFreshPrimaryForMfa(
179
+ metadata: Record<string, unknown> | null | undefined,
180
+ claims: Record<string, unknown>
181
+ ) {
182
+ const policy = readRequiredMfaPolicy(metadata);
183
+ return (
184
+ policy.required &&
185
+ policy.primaryVerifiedAfter !== undefined &&
186
+ (primaryProofTime(claims) ?? -1) <= policy.primaryVerifiedAfter
187
+ );
188
+ }
@@ -0,0 +1,87 @@
1
+ import { type NextRequest, NextResponse } from 'next/server';
2
+ import {
3
+ type AccountAssuranceDependencies,
4
+ checkRequiredAccountMfa,
5
+ } from './required-mfa-request';
6
+
7
+ // These endpoints establish/recover assurance and still perform their own
8
+ // authentication, challenge validation and rate limiting. Never exempt general
9
+ // user, workspace, or administrative APIs from the account policy.
10
+ const RECOVERY_PATHS = new Set([
11
+ // These exact handoff handlers enforce current policy on the verified body
12
+ // token. Expired ambient cookies must not prevent obtaining a new session.
13
+ '/api/auth/verify-app-token',
14
+ '/api/auth/refresh-app-session',
15
+ '/api/v1/auth/cross-app-token/verify',
16
+ '/api/v1/auth/cross-app-session/refresh',
17
+ '/api/cli/auth/refresh',
18
+ '/api/v1/auth/mfa/devices',
19
+ '/api/v1/auth/mfa/mobile/challenges',
20
+ '/api/v1/auth/mfa/mobile/approvals',
21
+ '/api/v1/auth/accounts/logout',
22
+ '/api/v1/auth/accounts/logout-all',
23
+ ]);
24
+
25
+ export function isAccountAssuranceRecoveryPath(pathname: string): boolean {
26
+ return (
27
+ RECOVERY_PATHS.has(pathname) ||
28
+ /^\/api\/v1\/auth\/mfa\/mobile\/challenges\/[0-9a-f-]{36}(?:\/approve)?$/i.test(
29
+ pathname
30
+ )
31
+ );
32
+ }
33
+
34
+ /** Additional interactive-session assurance check; the caller must first
35
+ * distinguish independently authenticated machine/webhook credentials and
36
+ * supply a request-bound auth client appropriate for its runtime. The proxy
37
+ * must not use next/headers or rotate refresh cookies without propagating them.
38
+ */
39
+ export async function guardRequiredAccountMfa(
40
+ request: NextRequest,
41
+ dependencies: AccountAssuranceDependencies,
42
+ principal: { userId: string; appToken?: string }
43
+ ): Promise<NextResponse | null> {
44
+ if (
45
+ request.method === 'OPTIONS' ||
46
+ isAccountAssuranceRecoveryPath(request.nextUrl.pathname)
47
+ )
48
+ return null;
49
+ const authorization = request.headers.get('authorization');
50
+ const cookies = request.headers.get('cookie') ?? '';
51
+ const hasInteractiveCredential = Boolean(
52
+ authorization?.match(/^Bearer\s+(?:eyJ|ttr_app_)/i) ||
53
+ /(?:^|;\s*)(?:sb-[^=]+-auth-token(?:\.\d+)?|tuturuuu_(?:web_)?app_session)=/.test(
54
+ cookies
55
+ )
56
+ );
57
+ if (!hasInteractiveCredential) return null;
58
+ const result = await checkRequiredAccountMfa(
59
+ request,
60
+ dependencies,
61
+ principal
62
+ );
63
+ if (result.status === 'allowed') return null;
64
+ const status =
65
+ result.status === 'unavailable'
66
+ ? 503
67
+ : result.status === 'invalid'
68
+ ? 401
69
+ : 403;
70
+ return NextResponse.json(
71
+ {
72
+ code:
73
+ result.status === 'required'
74
+ ? 'MFA_REQUIRED'
75
+ : result.status === 'invalid'
76
+ ? 'UNAUTHENTICATED'
77
+ : 'AUTH_UNAVAILABLE',
78
+ error:
79
+ result.status === 'required'
80
+ ? 'MFA verification required'
81
+ : result.status === 'invalid'
82
+ ? 'Authentication required'
83
+ : 'Unable to verify account security',
84
+ },
85
+ { status, headers: { 'Cache-Control': 'no-store' } }
86
+ );
87
+ }
@@ -0,0 +1,147 @@
1
+ import type { SupabaseUser } from '@tuturuuu/supabase/next/user';
2
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
3
+ import { verifyAppCoordinationToken } from './app-coordination-token';
4
+ import { bindCurrentMfaFactor } from './required-mfa-lineage';
5
+ import {
6
+ isMfaSessionProof,
7
+ type MfaSessionProof,
8
+ mfaProofFromVerifiedClaims,
9
+ readRequiredMfaPolicy,
10
+ requiresFreshPrimaryForMfa,
11
+ satisfiesRequiredMfaPolicy,
12
+ } from './required-mfa-policy';
13
+
14
+ type RequestLike = Pick<Request, 'headers'> & Partial<Pick<Request, 'url'>>;
15
+ export type AccountAssuranceResult =
16
+ | { status: 'allowed'; userId: string; proof: MfaSessionProof | null }
17
+ | { status: 'required'; userId: string }
18
+ | { status: 'unavailable' }
19
+ | { status: 'invalid' };
20
+
21
+ export interface AccountAssuranceDependencies {
22
+ createUserClient(request: RequestLike): Promise<TypedSupabaseClient>;
23
+ createAdminClient(): Promise<TypedSupabaseClient>;
24
+ nowSeconds(): number;
25
+ }
26
+
27
+ /** Enforces fresh server-owned policy, never user metadata or unverified JWTs.
28
+ * Authentication/authorization remains the responsibility of each route.
29
+ * This helper intentionally has no policy cache: recovery takes effect on the
30
+ * next request, including for an already-issued app access/refresh token.
31
+ */
32
+ export async function checkRequiredAccountMfa(
33
+ request: RequestLike,
34
+ dependencies: AccountAssuranceDependencies,
35
+ /** Bind to the principal resolved by the route, using its selected credential.
36
+ * Never approve one cookie while the route authorizes another identity. */
37
+ principal: { userId: string; appToken?: string }
38
+ ): Promise<AccountAssuranceResult> {
39
+ try {
40
+ const token = principal.appToken;
41
+ let user: SupabaseUser;
42
+ let proof: MfaSessionProof | null;
43
+ let sessionId: string | undefined;
44
+ let admin: TypedSupabaseClient | undefined;
45
+ const now = dependencies.nowSeconds();
46
+ if (token) {
47
+ const verification = verifyAppCoordinationToken(token);
48
+ if (!verification.ok) return { status: 'invalid' };
49
+ admin = await dependencies.createAdminClient();
50
+ const result = await admin.auth.admin.getUserById(
51
+ verification.claims.sub
52
+ );
53
+ if (result.error || !result.data.user) return { status: 'unavailable' };
54
+ user = result.data.user;
55
+ if (user.id !== verification.claims.sub || user.id !== principal.userId) {
56
+ return { status: 'invalid' };
57
+ }
58
+ proof = verification.claims.mfa ?? null;
59
+ sessionId = proof?.sessionId;
60
+ } else {
61
+ const client = await dependencies.createUserClient(request);
62
+ const result = await client.auth.getUser();
63
+ if (result.error || !result.data.user) return { status: 'invalid' };
64
+ user = result.data.user;
65
+ if (user.id !== principal.userId) return { status: 'invalid' };
66
+ if (user.banned_until && Date.parse(user.banned_until) / 1000 > now)
67
+ return { status: 'invalid' };
68
+ // No extra claims request is needed for accounts without the policy.
69
+ if (!readRequiredMfaPolicy(user.app_metadata).required) {
70
+ return { status: 'allowed', userId: user.id, proof: null };
71
+ }
72
+ const claims = await client.auth.getClaims();
73
+ if (claims.error || claims.data?.claims?.sub !== user.id) {
74
+ return { status: 'invalid' };
75
+ }
76
+ if (requiresFreshPrimaryForMfa(user.app_metadata, claims.data.claims))
77
+ return { status: 'required', userId: user.id };
78
+ proof = mfaProofFromVerifiedClaims(claims.data.claims, now);
79
+ sessionId =
80
+ typeof claims.data.claims.session_id === 'string'
81
+ ? claims.data.claims.session_id
82
+ : undefined;
83
+ }
84
+ if (user.banned_until && Date.parse(user.banned_until) / 1000 > now)
85
+ return { status: 'invalid' };
86
+ const policy = readRequiredMfaPolicy(user.app_metadata);
87
+ if (!policy.required) return { status: 'allowed', userId: user.id, proof };
88
+ if (proof && satisfiesRequiredMfaPolicy(policy, proof, now)) {
89
+ admin ??= await dependencies.createAdminClient();
90
+ const bound = await bindCurrentMfaFactor(admin, user.id, proof, !token);
91
+ if (bound) return { status: 'allowed', userId: user.id, proof: bound };
92
+ }
93
+ // A signed app proof is immutable: only its original provider requester
94
+ // may consume a new mobile approval and mint a replacement handoff.
95
+ if (!token && sessionId && Number.isFinite(policy.verifiedAfter)) {
96
+ admin ??= await dependencies.createAdminClient();
97
+ const result = await admin
98
+ .from('qr_login_challenges')
99
+ .select('approved_at, approval_metadata, request_metadata')
100
+ .eq('approver_user_id', user.id)
101
+ .eq('status', 'consumed')
102
+ .eq('request_metadata->>kind', 'mfa_mobile_approval')
103
+ .eq('request_metadata->>requesterSessionId', sessionId)
104
+ .eq('approval_metadata->>approverSessionId', sessionId)
105
+ .not('consumed_at', 'is', null)
106
+ .order('approved_at', { ascending: false })
107
+ .limit(1)
108
+ .maybeSingle();
109
+ if (result.error) return { status: 'unavailable' };
110
+ const row = result.data;
111
+ if (row) {
112
+ const approvedAt = Date.parse(row.approved_at ?? '') / 1000;
113
+ const metadata = row.approval_metadata as Record<
114
+ string,
115
+ unknown
116
+ > | null;
117
+ const until = metadata?.mobileMfaValidUntil;
118
+ if (
119
+ Math.floor(approvedAt) > policy.verifiedAfter &&
120
+ approvedAt <= now &&
121
+ typeof until === 'string' &&
122
+ Date.parse(until) / 1000 > now
123
+ ) {
124
+ const source = metadata?.requiredMfaProof;
125
+ if (
126
+ isMfaSessionProof(source) &&
127
+ satisfiesRequiredMfaPolicy(policy, source, now)
128
+ ) {
129
+ const bound = await bindCurrentMfaFactor(admin, user.id, source);
130
+ if (bound)
131
+ return {
132
+ status: 'allowed',
133
+ userId: user.id,
134
+ proof: {
135
+ ...bound,
136
+ expiresAt: Math.floor(Date.parse(until) / 1000),
137
+ },
138
+ };
139
+ }
140
+ }
141
+ }
142
+ }
143
+ return { status: 'required', userId: user.id };
144
+ } catch {
145
+ return { status: 'unavailable' };
146
+ }
147
+ }
@@ -0,0 +1,103 @@
1
+ import { createAdminClient } from '@tuturuuu/supabase/next/server';
2
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
3
+ import { type NextRequest, NextResponse } from 'next/server';
4
+ import { verifyAppCoordinationToken } from './app-coordination-token';
5
+ import { accountAssuranceCredentials } from './required-mfa-credentials';
6
+ import { isAccountAssuranceRecoveryPath } from './required-mfa-proxy';
7
+ import { checkRequiredAccountMfa } from './required-mfa-request';
8
+
9
+ /** Check every usable cookie identity, so downstream target-app fallback cannot
10
+ * borrow another session's assurance. Cookie-only legacy handlers can ignore a
11
+ * Bearer header, so ambient credentials must be checked as well.
12
+ * Uses explicit access tokens and never rotates browser refresh cookies. */
13
+ export async function enforceRequiredMfaRequest(request: NextRequest) {
14
+ if (
15
+ request.method === 'OPTIONS' ||
16
+ isAccountAssuranceRecoveryPath(request.nextUrl.pathname)
17
+ )
18
+ return null;
19
+ const credentials = accountAssuranceCredentials(request);
20
+ if (!credentials.length) return null;
21
+ try {
22
+ const admin = (await createAdminClient({
23
+ noCookie: true,
24
+ })) as TypedSupabaseClient;
25
+ for (const token of credentials) {
26
+ let userId: string;
27
+ const appToken = token.startsWith('ttr_app_');
28
+ if (appToken) {
29
+ const verified = verifyAppCoordinationToken(token);
30
+ if (!verified.ok) {
31
+ if (
32
+ request.headers
33
+ .get('authorization')
34
+ ?.match(/^Bearer\s+(\S+)$/i)?.[1] === token
35
+ )
36
+ return unauthenticated();
37
+ continue; // An expired ambient app cookie cannot authenticate a route.
38
+ }
39
+ userId = verified.claims.sub;
40
+ } else {
41
+ const result = await admin.auth.getUser(token);
42
+ if (result.error || !result.data.user) return unauthenticated();
43
+ userId = result.data.user.id;
44
+ }
45
+ const result = await checkRequiredAccountMfa(
46
+ request,
47
+ {
48
+ createAdminClient: async () => admin,
49
+ createUserClient: async () =>
50
+ ({
51
+ auth: {
52
+ getUser: () => admin.auth.getUser(token),
53
+ getClaims: () => admin.auth.getClaims(token),
54
+ },
55
+ }) as unknown as TypedSupabaseClient,
56
+ nowSeconds: () => Math.floor(Date.now() / 1000),
57
+ },
58
+ { userId, appToken: appToken ? token : undefined }
59
+ );
60
+ if (result.status === 'invalid') return unauthenticated();
61
+ if (result.status !== 'allowed') {
62
+ return NextResponse.json(
63
+ {
64
+ code:
65
+ result.status === 'required'
66
+ ? 'MFA_REQUIRED'
67
+ : 'AUTH_UNAVAILABLE',
68
+ error:
69
+ result.status === 'required'
70
+ ? 'MFA verification required'
71
+ : 'Unable to verify account security',
72
+ },
73
+ {
74
+ status: result.status === 'required' ? 403 : 503,
75
+ headers: {
76
+ 'Cache-Control': 'no-store',
77
+ 'X-Tuturuuu-Auth-Assurance': result.status,
78
+ },
79
+ }
80
+ );
81
+ }
82
+ }
83
+ return null;
84
+ } catch {
85
+ return NextResponse.json(
86
+ { code: 'AUTH_UNAVAILABLE', error: 'Unable to verify account security' },
87
+ {
88
+ status: 503,
89
+ headers: {
90
+ 'Cache-Control': 'no-store',
91
+ 'X-Tuturuuu-Auth-Assurance': 'unavailable',
92
+ },
93
+ }
94
+ );
95
+ }
96
+ }
97
+
98
+ function unauthenticated() {
99
+ return NextResponse.json(
100
+ { code: 'UNAUTHENTICATED', error: 'Authentication required' },
101
+ { status: 401, headers: { 'Cache-Control': 'no-store' } }
102
+ );
103
+ }
@@ -0,0 +1,33 @@
1
+ import { createAdminClient } from '@tuturuuu/supabase/next/server';
2
+ import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
3
+ import { checkRequiredAccountMfa } from './required-mfa-request';
4
+
5
+ /** Only call after the provider has cryptographically verified these claims.
6
+ * Retain a consumed mobile approval's original boundary and expiry in handoffs.
7
+ */
8
+ export async function resolveVerifiedSupabaseMfa(
9
+ claims: Record<string, unknown>
10
+ ) {
11
+ if (typeof claims.sub !== 'string') throw new Error('Invalid session');
12
+ const admin = (await createAdminClient({
13
+ noCookie: true,
14
+ })) as TypedSupabaseClient;
15
+ const result = await checkRequiredAccountMfa(
16
+ { headers: new Headers() },
17
+ {
18
+ createAdminClient: async () => admin,
19
+ createUserClient: async () =>
20
+ ({
21
+ auth: {
22
+ getUser: () => admin.auth.admin.getUserById(claims.sub as string),
23
+ getClaims: async () => ({ data: { claims }, error: null }),
24
+ },
25
+ }) as unknown as TypedSupabaseClient,
26
+ nowSeconds: () => Math.floor(Date.now() / 1000),
27
+ },
28
+ { userId: claims.sub }
29
+ );
30
+ if (result.status === 'unavailable' || result.status === 'invalid')
31
+ throw new Error('Account assurance unavailable');
32
+ return result;
33
+ }
@@ -0,0 +1,41 @@
1
+ import {
2
+ createAppCoordinationToken,
3
+ verifyAppCoordinationToken,
4
+ } from './app-coordination-token';
5
+ import type { MfaSessionProof } from './required-mfa-policy';
6
+
7
+ const TRANSFER_SCOPE = 'mfa:transfer';
8
+ /** session_data is caller writable. Carry only an independently signed, short
9
+ * lived proof bound to the handoff identity and target app. */
10
+ export function createMfaTransfer(
11
+ userId: string,
12
+ targetApp: string,
13
+ mfa: MfaSessionProof | null
14
+ ) {
15
+ return mfa
16
+ ? createAppCoordinationToken({
17
+ userId,
18
+ targetApp,
19
+ mfa,
20
+ scopes: [TRANSFER_SCOPE],
21
+ expiresInSeconds: 300,
22
+ }).token
23
+ : undefined;
24
+ }
25
+
26
+ export function readMfaTransfer(
27
+ value: unknown,
28
+ userId: string,
29
+ targetApp: string
30
+ ): MfaSessionProof | null {
31
+ if (typeof value !== 'string') return null;
32
+ const verified = verifyAppCoordinationToken(value);
33
+ if (
34
+ !verified.ok ||
35
+ verified.claims.sub !== userId ||
36
+ verified.claims.target_app !== targetApp ||
37
+ !verified.claims.scopes.includes(TRANSFER_SCOPE)
38
+ )
39
+ return null;
40
+ return verified.claims.mfa ?? null;
41
+ }