@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.
- package/CHANGELOG.md +28 -0
- package/package.json +3 -3
- package/src/__tests__/ai-temp-auth.test.ts +32 -0
- package/src/__tests__/api-proxy-guard.test.ts +4 -5
- package/src/__tests__/app-url.test.ts +1 -0
- package/src/__tests__/required-mfa-api-session.test.ts +75 -0
- package/src/__tests__/required-mfa-browser.test.ts +73 -0
- package/src/__tests__/required-mfa-lineage.test.ts +40 -0
- package/src/__tests__/required-mfa-proxy.test.ts +105 -0
- package/src/__tests__/required-mfa-request.test.ts +342 -0
- package/src/__tests__/required-mfa-runtime.test.ts +109 -0
- package/src/__tests__/required-mfa-transfer.test.ts +29 -0
- package/src/ai-temp-auth.ts +11 -0
- package/src/api-proxy-guard.ts +6 -6
- package/src/app-coordination-token.ts +247 -0
- package/src/commercial-comparison.ts +7 -0
- package/src/internal-domains.ts +3 -0
- package/src/launchable-apps.ts +12 -0
- package/src/onboarding.ts +6 -0
- package/src/platform-release.test.ts +1 -1
- package/src/platform-release.ts +1 -1
- package/src/portless.ts +1 -0
- package/src/required-mfa-api-session.ts +30 -0
- package/src/required-mfa-app-session.ts +34 -0
- package/src/required-mfa-browser.ts +42 -0
- package/src/required-mfa-credentials.ts +58 -0
- package/src/required-mfa-lineage.ts +22 -0
- package/src/required-mfa-policy.ts +188 -0
- package/src/required-mfa-proxy.ts +87 -0
- package/src/required-mfa-request.ts +147 -0
- package/src/required-mfa-runtime.ts +103 -0
- package/src/required-mfa-supabase-session.ts +33 -0
- 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
|
+
}
|