@tuturuuu/utils 0.29.1 → 0.30.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 +9 -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,247 @@
|
|
|
1
|
+
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
import { isMfaSessionProof, type MfaSessionProof } from './required-mfa-policy';
|
|
4
|
+
|
|
5
|
+
const TOKEN_PREFIX = 'ttr_app_';
|
|
6
|
+
const TOKEN_ISSUER = 'tuturuuu';
|
|
7
|
+
const TOKEN_AUDIENCE = 'tuturuuu-api';
|
|
8
|
+
const DEFAULT_EXPIRES_IN_SECONDS = 8 * 60 * 60;
|
|
9
|
+
const LOCAL_DEVELOPMENT_SECRET =
|
|
10
|
+
'tuturuuu-local-development-app-coordination-secret';
|
|
11
|
+
|
|
12
|
+
export type AppCoordinationTokenClaims = {
|
|
13
|
+
aud: typeof TOKEN_AUDIENCE;
|
|
14
|
+
email: string | null;
|
|
15
|
+
exp: number;
|
|
16
|
+
iat: number;
|
|
17
|
+
iss: typeof TOKEN_ISSUER;
|
|
18
|
+
jti: string;
|
|
19
|
+
mfa?: MfaSessionProof;
|
|
20
|
+
origin_app: string;
|
|
21
|
+
scopes: string[];
|
|
22
|
+
sub: string;
|
|
23
|
+
target_app: string;
|
|
24
|
+
typ: 'app_coordination';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type AppCoordinationTokenPayload = {
|
|
28
|
+
email?: string | null;
|
|
29
|
+
expiresInSeconds?: number;
|
|
30
|
+
mfa?: MfaSessionProof;
|
|
31
|
+
originApp?: string;
|
|
32
|
+
scopes?: string[];
|
|
33
|
+
targetApp: string;
|
|
34
|
+
userId: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AppCoordinationTokenVerification =
|
|
38
|
+
| {
|
|
39
|
+
claims: AppCoordinationTokenClaims;
|
|
40
|
+
ok: true;
|
|
41
|
+
}
|
|
42
|
+
| {
|
|
43
|
+
error: string;
|
|
44
|
+
ok: false;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function getSecretCandidates(explicitSecret?: string) {
|
|
48
|
+
const candidates = explicitSecret
|
|
49
|
+
? [explicitSecret]
|
|
50
|
+
: [
|
|
51
|
+
process.env.TUTURUUU_APP_COORDINATION_SECRET,
|
|
52
|
+
process.env.APP_COORDINATION_TOKEN_SECRET,
|
|
53
|
+
process.env.SUPABASE_SECRET_KEY,
|
|
54
|
+
process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
55
|
+
process.env.SUPABASE_SERVICE_KEY,
|
|
56
|
+
];
|
|
57
|
+
|
|
58
|
+
const secrets = candidates
|
|
59
|
+
.map((value) => value?.trim())
|
|
60
|
+
.filter((value): value is string => Boolean(value));
|
|
61
|
+
|
|
62
|
+
if (secrets.length === 0 && process.env.NODE_ENV !== 'production') {
|
|
63
|
+
return [LOCAL_DEVELOPMENT_SECRET];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (secrets.length === 0) {
|
|
67
|
+
throw new Error(
|
|
68
|
+
'Missing TUTURUUU_APP_COORDINATION_SECRET or SUPABASE_SECRET_KEY'
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return [...new Set(secrets)];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getSigningSecret(explicitSecret?: string) {
|
|
76
|
+
return getSecretCandidates(explicitSecret)[0]!;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function getVerificationSecrets(explicitSecret?: string) {
|
|
80
|
+
return getSecretCandidates(explicitSecret);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function encodeBase64Url(value: string | Buffer) {
|
|
84
|
+
return Buffer.from(value).toString('base64url');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function decodeBase64Url(value: string) {
|
|
88
|
+
return Buffer.from(value, 'base64url').toString('utf8');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function signContent(content: string, secret: string) {
|
|
92
|
+
return createHmac('sha256', secret).update(content).digest('base64url');
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function safeJsonParse(value: string): unknown {
|
|
96
|
+
try {
|
|
97
|
+
return JSON.parse(value) as unknown;
|
|
98
|
+
} catch {
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isClaims(value: unknown): value is AppCoordinationTokenClaims {
|
|
104
|
+
if (!value || typeof value !== 'object') {
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const claims = value as Partial<AppCoordinationTokenClaims>;
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
(claims.mfa === undefined || isMfaSessionProof(claims.mfa)) &&
|
|
112
|
+
claims.typ === 'app_coordination' &&
|
|
113
|
+
claims.iss === TOKEN_ISSUER &&
|
|
114
|
+
claims.aud === TOKEN_AUDIENCE &&
|
|
115
|
+
typeof claims.sub === 'string' &&
|
|
116
|
+
typeof claims.target_app === 'string' &&
|
|
117
|
+
typeof claims.origin_app === 'string' &&
|
|
118
|
+
typeof claims.iat === 'number' &&
|
|
119
|
+
typeof claims.exp === 'number' &&
|
|
120
|
+
typeof claims.jti === 'string' &&
|
|
121
|
+
Array.isArray(claims.scopes)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function isAppCoordinationToken(token: string | null | undefined) {
|
|
126
|
+
return Boolean(token?.startsWith(TOKEN_PREFIX));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getBearerAppCoordinationToken(
|
|
130
|
+
request: Pick<Request, 'headers'>
|
|
131
|
+
) {
|
|
132
|
+
const authorization =
|
|
133
|
+
request.headers.get('authorization') ??
|
|
134
|
+
request.headers.get('Authorization');
|
|
135
|
+
|
|
136
|
+
if (!authorization?.toLowerCase().startsWith('bearer ')) {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const token = authorization.slice(7).trim();
|
|
141
|
+
|
|
142
|
+
return isAppCoordinationToken(token) ? token : null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function createAppCoordinationToken(
|
|
146
|
+
payload: AppCoordinationTokenPayload,
|
|
147
|
+
options: {
|
|
148
|
+
now?: Date;
|
|
149
|
+
secret?: string;
|
|
150
|
+
} = {}
|
|
151
|
+
) {
|
|
152
|
+
if (payload.mfa !== undefined && !isMfaSessionProof(payload.mfa)) {
|
|
153
|
+
throw new Error('Invalid MFA session proof');
|
|
154
|
+
}
|
|
155
|
+
const secret = getSigningSecret(options.secret);
|
|
156
|
+
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
157
|
+
const expiresInSeconds =
|
|
158
|
+
payload.expiresInSeconds ?? DEFAULT_EXPIRES_IN_SECONDS;
|
|
159
|
+
const header = {
|
|
160
|
+
alg: 'HS256',
|
|
161
|
+
typ: 'JWT',
|
|
162
|
+
};
|
|
163
|
+
const claims: AppCoordinationTokenClaims = {
|
|
164
|
+
aud: TOKEN_AUDIENCE,
|
|
165
|
+
email: payload.email ?? null,
|
|
166
|
+
exp: nowSeconds + expiresInSeconds,
|
|
167
|
+
iat: nowSeconds,
|
|
168
|
+
iss: TOKEN_ISSUER,
|
|
169
|
+
jti: randomUUID(),
|
|
170
|
+
...(payload.mfa ? { mfa: payload.mfa } : {}),
|
|
171
|
+
origin_app: payload.originApp ?? 'web',
|
|
172
|
+
scopes: payload.scopes ?? [],
|
|
173
|
+
sub: payload.userId,
|
|
174
|
+
target_app: payload.targetApp,
|
|
175
|
+
typ: 'app_coordination',
|
|
176
|
+
};
|
|
177
|
+
const unsigned = `${encodeBase64Url(JSON.stringify(header))}.${encodeBase64Url(
|
|
178
|
+
JSON.stringify(claims)
|
|
179
|
+
)}`;
|
|
180
|
+
const signature = signContent(unsigned, secret);
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
claims,
|
|
184
|
+
expiresAt: new Date(claims.exp * 1000).toISOString(),
|
|
185
|
+
token: `${TOKEN_PREFIX}${unsigned}.${signature}`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function verifyAppCoordinationToken(
|
|
190
|
+
token: string,
|
|
191
|
+
options: {
|
|
192
|
+
now?: Date;
|
|
193
|
+
secret?: string;
|
|
194
|
+
} = {}
|
|
195
|
+
): AppCoordinationTokenVerification {
|
|
196
|
+
if (!isAppCoordinationToken(token)) {
|
|
197
|
+
return { error: 'Invalid token type', ok: false };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const withoutPrefix = token.slice(TOKEN_PREFIX.length);
|
|
201
|
+
const parts = withoutPrefix.split('.');
|
|
202
|
+
|
|
203
|
+
if (parts.length !== 3) {
|
|
204
|
+
return { error: 'Invalid token format', ok: false };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const [encodedHeader, encodedPayload, signature] = parts;
|
|
208
|
+
|
|
209
|
+
if (!encodedHeader || !encodedPayload || !signature) {
|
|
210
|
+
return { error: 'Invalid token format', ok: false };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const unsigned = `${encodedHeader}.${encodedPayload}`;
|
|
214
|
+
const signatureBuffer = Buffer.from(signature);
|
|
215
|
+
const signatureMatches = getVerificationSecrets(options.secret).some(
|
|
216
|
+
(secret) => {
|
|
217
|
+
const expectedSignature = signContent(unsigned, secret);
|
|
218
|
+
const expectedSignatureBuffer = Buffer.from(expectedSignature);
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
signatureBuffer.length === expectedSignatureBuffer.length &&
|
|
222
|
+
timingSafeEqual(signatureBuffer, expectedSignatureBuffer)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
if (!signatureMatches) {
|
|
228
|
+
return { error: 'Invalid token signature', ok: false };
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const claims = safeJsonParse(decodeBase64Url(encodedPayload));
|
|
232
|
+
|
|
233
|
+
if (!isClaims(claims)) {
|
|
234
|
+
return { error: 'Invalid token claims', ok: false };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const nowSeconds = Math.floor((options.now ?? new Date()).getTime() / 1000);
|
|
238
|
+
|
|
239
|
+
if (claims.exp <= nowSeconds) {
|
|
240
|
+
return { error: 'Token expired', ok: false };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
claims,
|
|
245
|
+
ok: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
@@ -9,6 +9,13 @@ import { features2 } from './commercial-comparison-2';
|
|
|
9
9
|
|
|
10
10
|
const gateOrder = ['FREE', 'PLUS', 'PRO', 'ENTERPRISE'];
|
|
11
11
|
const advanced: ComparisonFeature[] = [
|
|
12
|
+
{
|
|
13
|
+
id: 'parley_0',
|
|
14
|
+
app: 'parley',
|
|
15
|
+
category: 'internal',
|
|
16
|
+
detail: 'standard',
|
|
17
|
+
values: ['internal', 'internal', 'internal', 'internal'],
|
|
18
|
+
},
|
|
12
19
|
{
|
|
13
20
|
id: 'prioritySupport',
|
|
14
21
|
app: 'platform',
|
package/src/internal-domains.ts
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
} from './portless';
|
|
5
5
|
|
|
6
6
|
export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
7
|
+
{ name: 'parley', url: 'https://parley.tuturuuu.com' },
|
|
7
8
|
{ name: 'lettin', url: 'https://lettin.tuturuuu.com' },
|
|
8
9
|
{ name: 'colab', url: 'https://colab.tuturuuu.com' },
|
|
9
10
|
{
|
|
@@ -133,6 +134,7 @@ export const PRODUCTION_INTERNAL_APP_DOMAINS = [
|
|
|
133
134
|
] as const;
|
|
134
135
|
|
|
135
136
|
export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
137
|
+
{ name: 'parley', url: getTuturuuuPortlessAppOrigin('parley') },
|
|
136
138
|
{ name: 'lettin', url: getTuturuuuPortlessAppOrigin('lettin') },
|
|
137
139
|
{ name: 'colab', url: getTuturuuuPortlessAppOrigin('colab') },
|
|
138
140
|
{
|
|
@@ -254,6 +256,7 @@ export const PORTLESS_INTERNAL_APP_DOMAINS = [
|
|
|
254
256
|
] as const;
|
|
255
257
|
|
|
256
258
|
export const LOCALHOST_INTERNAL_APP_DOMAINS = [
|
|
259
|
+
{ name: 'parley', url: 'http://localhost:7834' },
|
|
257
260
|
{ name: 'lettin', url: 'http://localhost:7833' },
|
|
258
261
|
{ name: 'colab', url: 'http://localhost:8795' },
|
|
259
262
|
{
|
package/src/launchable-apps.ts
CHANGED
|
@@ -412,6 +412,18 @@ export const LAUNCHABLE_APPS = [
|
|
|
412
412
|
title: '(Tu)lettin',
|
|
413
413
|
workspacePathResolver: workspaceRootPath,
|
|
414
414
|
},
|
|
415
|
+
{
|
|
416
|
+
aliases: ['Scenario training', 'Roleplay'],
|
|
417
|
+
appRoot: 'apps/parley',
|
|
418
|
+
category: 'ai',
|
|
419
|
+
defaultPath: '/',
|
|
420
|
+
localhostOrigin: 'http://localhost:7834',
|
|
421
|
+
packageName: '@tuturuuu/parley',
|
|
422
|
+
portlessApp: 'parley',
|
|
423
|
+
productionUrl: 'https://parley.tuturuuu.com',
|
|
424
|
+
slug: 'parley',
|
|
425
|
+
title: 'Parley',
|
|
426
|
+
},
|
|
415
427
|
] as const satisfies readonly LaunchableApp[];
|
|
416
428
|
|
|
417
429
|
export type LaunchableAppSlug = (typeof LAUNCHABLE_APPS)[number]['slug'];
|
package/src/onboarding.ts
CHANGED
|
@@ -71,6 +71,12 @@ export const GOAL_RECOMMENDATIONS = {
|
|
|
71
71
|
} as const satisfies Record<OnboardingGoal, readonly string[]>;
|
|
72
72
|
|
|
73
73
|
const MISSIONS = [
|
|
74
|
+
[
|
|
75
|
+
'parley',
|
|
76
|
+
'learn',
|
|
77
|
+
'confirmation_required',
|
|
78
|
+
['scenario', 'practice', 'reflect'],
|
|
79
|
+
],
|
|
74
80
|
['colab', 'learn', 'reversible', ['prompt', 'simulate', 'review']],
|
|
75
81
|
['platform', 'focus', 'reversible', ['overview', 'apps', 'next_move']],
|
|
76
82
|
['calendar', 'focus', 'reversible', ['schedule', 'timebox', 'review']],
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
|
|
8
8
|
describe('platform release metadata', () => {
|
|
9
9
|
it('uses the centralized shared browser app version', () => {
|
|
10
|
-
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.
|
|
10
|
+
expect(TUTURUUU_PLATFORM_VERSION).toBe('0.54.0'); // x-release-please-version
|
|
11
11
|
});
|
|
12
12
|
|
|
13
13
|
it('preserves the source timestamp in builtAt and derives a short hash', () => {
|
package/src/platform-release.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { PLATFORM_BUILD_METADATA } from './generated/platform-build-metadata';
|
|
2
2
|
|
|
3
|
-
export const TUTURUUU_PLATFORM_VERSION = '0.
|
|
3
|
+
export const TUTURUUU_PLATFORM_VERSION = '0.54.0'; // x-release-please-version
|
|
4
4
|
|
|
5
5
|
export type PlatformBuildMetadataInput = {
|
|
6
6
|
/** ISO timestamp of the source commit used for the build, not deployment time. */
|
package/src/portless.ts
CHANGED
|
@@ -6,6 +6,7 @@ export const TUTURUUU_PORTLESS_ALLOWED_DEV_ORIGINS = [
|
|
|
6
6
|
] as const;
|
|
7
7
|
|
|
8
8
|
export const TUTURUUU_PORTLESS_APP_ORIGINS = {
|
|
9
|
+
parley: `https://parley.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
9
10
|
ai: `https://ai.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
10
11
|
apps: `https://apps.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
11
12
|
calendar: `https://calendar.${TUTURUUU_PORTLESS_ROOT_HOST}`,
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { updateSession } from '@tuturuuu/supabase/next/proxy';
|
|
2
|
+
import { type NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { discardAppCookiesForVerifiedProvider } from './required-mfa-credentials';
|
|
4
|
+
import { isAccountAssuranceRecoveryPath } from './required-mfa-proxy';
|
|
5
|
+
|
|
6
|
+
/** Refresh browser provider cookies before checking assurance, and propagate
|
|
7
|
+
* rotated cookies even when policy redirects/rejects the subsequent request.
|
|
8
|
+
* Explicit credentials and refresh/handoff handlers retain their own lifecycle.
|
|
9
|
+
*/
|
|
10
|
+
export async function guardBrowserApiSession(
|
|
11
|
+
request: NextRequest,
|
|
12
|
+
guard: () => Promise<NextResponse | null>
|
|
13
|
+
) {
|
|
14
|
+
const hasProviderCookie = request.cookies
|
|
15
|
+
.getAll()
|
|
16
|
+
.some(({ name }) => /^sb-[a-z0-9-]+-auth-token(?:\.\d+)?$/i.test(name));
|
|
17
|
+
const session =
|
|
18
|
+
!request.headers.has('authorization') &&
|
|
19
|
+
hasProviderCookie &&
|
|
20
|
+
!isAccountAssuranceRecoveryPath(request.nextUrl.pathname)
|
|
21
|
+
? await updateSession(request)
|
|
22
|
+
: null;
|
|
23
|
+
if (session?.claims) discardAppCookiesForVerifiedProvider(request);
|
|
24
|
+
const response =
|
|
25
|
+
(await guard()) ??
|
|
26
|
+
NextResponse.next({ request: { headers: request.headers } });
|
|
27
|
+
for (const cookie of session?.res.headers.getSetCookie() ?? [])
|
|
28
|
+
response.headers.append('set-cookie', cookie);
|
|
29
|
+
return response;
|
|
30
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createAdminClient } from '@tuturuuu/supabase/next/server';
|
|
2
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
3
|
+
import type { AppCoordinationTokenClaims } from './app-coordination-token';
|
|
4
|
+
import { bindCurrentMfaFactor } from './required-mfa-lineage';
|
|
5
|
+
import {
|
|
6
|
+
type MfaSessionProof,
|
|
7
|
+
readRequiredMfaPolicy,
|
|
8
|
+
satisfiesRequiredMfaPolicy,
|
|
9
|
+
} from './required-mfa-policy';
|
|
10
|
+
|
|
11
|
+
/** Claims must already be signature/scope/target verified by the caller. */
|
|
12
|
+
export async function isRequiredMfaAppSessionAllowed(
|
|
13
|
+
claims: AppCoordinationTokenClaims
|
|
14
|
+
) {
|
|
15
|
+
return isRequiredMfaProofAllowed(claims.sub, claims.mfa ?? null);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function isRequiredMfaProofAllowed(
|
|
19
|
+
userId: string,
|
|
20
|
+
proof: MfaSessionProof | null
|
|
21
|
+
) {
|
|
22
|
+
const admin = (await createAdminClient({
|
|
23
|
+
noCookie: true,
|
|
24
|
+
})) as TypedSupabaseClient;
|
|
25
|
+
const { data, error } = await admin.auth.admin.getUserById(userId);
|
|
26
|
+
if (error) throw new Error('Account security is unavailable');
|
|
27
|
+
if (!data.user || data.user.id !== userId) return false;
|
|
28
|
+
if (data.user.banned_until && Date.parse(data.user.banned_until) > Date.now())
|
|
29
|
+
return false;
|
|
30
|
+
const policy = readRequiredMfaPolicy(data.user.app_metadata);
|
|
31
|
+
if (!policy.required) return true;
|
|
32
|
+
if (!proof || !satisfiesRequiredMfaPolicy(policy, proof)) return false;
|
|
33
|
+
return Boolean(await bindCurrentMfaFactor(admin, userId, proof));
|
|
34
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import {
|
|
3
|
+
mfaProofFromVerifiedClaims,
|
|
4
|
+
readRequiredMfaPolicy,
|
|
5
|
+
satisfiesRequiredMfaPolicy,
|
|
6
|
+
} from './required-mfa-policy';
|
|
7
|
+
|
|
8
|
+
/** UX routing only; server/database gates remain authoritative. */
|
|
9
|
+
export async function requiresAccountMfa(
|
|
10
|
+
client: TypedSupabaseClient,
|
|
11
|
+
options: { acceptMobileApproval?: boolean } = {}
|
|
12
|
+
) {
|
|
13
|
+
const user = await client.auth.getUser();
|
|
14
|
+
if (user.error || !user.data.user)
|
|
15
|
+
throw new Error('Unable to verify account security');
|
|
16
|
+
const policy = readRequiredMfaPolicy(user.data.user.app_metadata);
|
|
17
|
+
if (policy.required) {
|
|
18
|
+
const claims = await client.auth.getClaims();
|
|
19
|
+
if (claims.error || claims.data?.claims.sub !== user.data.user.id)
|
|
20
|
+
return true;
|
|
21
|
+
if (
|
|
22
|
+
!satisfiesRequiredMfaPolicy(
|
|
23
|
+
policy,
|
|
24
|
+
mfaProofFromVerifiedClaims(claims.data.claims)
|
|
25
|
+
)
|
|
26
|
+
) {
|
|
27
|
+
if (options.acceptMobileApproval) {
|
|
28
|
+
// The database validates the consumed approval against this exact
|
|
29
|
+
// provider session, current policy boundary, and proof expiry.
|
|
30
|
+
const result = await client.rpc('account_required_mfa_satisfied');
|
|
31
|
+
if (!result.error && result.data === true) return false;
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
const verified = await client.rpc('account_required_mfa_satisfied');
|
|
36
|
+
return Boolean(verified.error || verified.data !== true);
|
|
37
|
+
}
|
|
38
|
+
const { data, error } =
|
|
39
|
+
await client.auth.mfa.getAuthenticatorAssuranceLevel();
|
|
40
|
+
if (error) throw error;
|
|
41
|
+
return data.currentLevel === 'aal1' && data.nextLevel === 'aal2';
|
|
42
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSupabaseAuthCookieUrls,
|
|
3
|
+
getSupabaseAuthStorageKey,
|
|
4
|
+
} from '@tuturuuu/supabase/next/common';
|
|
5
|
+
import type { NextRequest } from 'next/server';
|
|
6
|
+
|
|
7
|
+
/** Read opaque credentials only. No decoded claim is trusted here; each token
|
|
8
|
+
* is verified by the provider or our app-token verifier before policy lookup. */
|
|
9
|
+
export function accountAssuranceCredentials(request: NextRequest): string[] {
|
|
10
|
+
const bearer = request.headers
|
|
11
|
+
.get('authorization')
|
|
12
|
+
?.match(/^Bearer\s+(\S+)$/i)?.[1];
|
|
13
|
+
const tokens = new Set<string>();
|
|
14
|
+
if (bearer && /^(?:eyJ|ttr_app_)/.test(bearer)) tokens.add(bearer);
|
|
15
|
+
for (const name of ['tuturuuu_web_app_session', 'tuturuuu_app_session']) {
|
|
16
|
+
const token = request.cookies.get(name)?.value;
|
|
17
|
+
if (token?.startsWith('ttr_app_')) tokens.add(token);
|
|
18
|
+
}
|
|
19
|
+
// Browser auth uses this project key even when the server URL is internal.
|
|
20
|
+
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
|
21
|
+
if (!url) return [...tokens];
|
|
22
|
+
for (const cookieUrl of getSupabaseAuthCookieUrls(url)) {
|
|
23
|
+
const key = getSupabaseAuthStorageKey(cookieUrl);
|
|
24
|
+
let raw = request.cookies.get(key)?.value;
|
|
25
|
+
if (!raw) {
|
|
26
|
+
const chunks: string[] = [];
|
|
27
|
+
for (let index = 0; request.cookies.has(`${key}.${index}`); index += 1) {
|
|
28
|
+
chunks.push(request.cookies.get(`${key}.${index}`)!.value);
|
|
29
|
+
}
|
|
30
|
+
raw = chunks.join('');
|
|
31
|
+
}
|
|
32
|
+
if (raw) {
|
|
33
|
+
try {
|
|
34
|
+
const decoded = raw.startsWith('base64-')
|
|
35
|
+
? Buffer.from(raw.slice(7), 'base64url').toString('utf8')
|
|
36
|
+
: raw;
|
|
37
|
+
const session = JSON.parse(decoded) as { access_token?: unknown };
|
|
38
|
+
if (typeof session.access_token === 'string')
|
|
39
|
+
tokens.add(session.access_token);
|
|
40
|
+
} catch {
|
|
41
|
+
// Existing authentication/malformed-cookie guards reject these cookies.
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...tokens];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Provider-first proxies must remove superseded app credentials from the
|
|
49
|
+
* forwarded request, not merely ignore them during policy enforcement. */
|
|
50
|
+
export function discardAppCookiesForVerifiedProvider(request: NextRequest) {
|
|
51
|
+
for (const name of [
|
|
52
|
+
'tuturuuu_app_session',
|
|
53
|
+
'tuturuuu_web_app_session',
|
|
54
|
+
'tuturuuu_app_session_refresh',
|
|
55
|
+
'tuturuuu_web_app_session_refresh',
|
|
56
|
+
])
|
|
57
|
+
request.cookies.delete(name);
|
|
58
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import type { MfaSessionProof } from './required-mfa-policy';
|
|
3
|
+
|
|
4
|
+
/** The provider JWT does not contain factor_id. Resolve its verified session
|
|
5
|
+
* against current GoTrue factor state; signed handoffs retain that exact id. */
|
|
6
|
+
export async function bindCurrentMfaFactor(
|
|
7
|
+
admin: TypedSupabaseClient,
|
|
8
|
+
userId: string,
|
|
9
|
+
proof: MfaSessionProof,
|
|
10
|
+
allowInitialBinding = false
|
|
11
|
+
): Promise<MfaSessionProof | null> {
|
|
12
|
+
if (!proof.factorId && !allowInitialBinding) return null;
|
|
13
|
+
const { data, error } = await admin.rpc('account_mfa_verified_factor', {
|
|
14
|
+
p_user_id: userId,
|
|
15
|
+
p_session_id: proof.verificationSessionId ?? proof.sessionId,
|
|
16
|
+
p_verified_at: proof.verifiedAt,
|
|
17
|
+
p_primary_verified_at: proof.primaryVerifiedAt,
|
|
18
|
+
});
|
|
19
|
+
if (error) throw new Error('Account factor verification unavailable');
|
|
20
|
+
if (!data || (proof.factorId && data !== proof.factorId)) return null;
|
|
21
|
+
return { ...proof, factorId: data };
|
|
22
|
+
}
|