@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,342 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createAppCoordinationToken } from '../app-coordination-token';
|
|
5
|
+
import { checkRequiredAccountMfa } from '../required-mfa-request';
|
|
6
|
+
|
|
7
|
+
const now = Math.floor(Date.now() / 1000);
|
|
8
|
+
const policy = {
|
|
9
|
+
tuturuuu_required_mfa: { required: true, verifiedAfter: now - 30 },
|
|
10
|
+
};
|
|
11
|
+
const user = { id: 'actor', app_metadata: policy };
|
|
12
|
+
function fixture() {
|
|
13
|
+
const query: Record<string, ReturnType<typeof vi.fn>> = {};
|
|
14
|
+
for (const method of ['select', 'eq', 'not', 'order', 'limit'])
|
|
15
|
+
query[method] = vi.fn(() => query);
|
|
16
|
+
query.maybeSingle = vi.fn().mockResolvedValue({ data: null, error: null });
|
|
17
|
+
const getUser = vi.fn().mockResolvedValue({ data: { user }, error: null });
|
|
18
|
+
const getClaims = vi.fn().mockResolvedValue({
|
|
19
|
+
data: {
|
|
20
|
+
claims: {
|
|
21
|
+
sub: 'actor',
|
|
22
|
+
aal: 'aal2',
|
|
23
|
+
session_id: 'session',
|
|
24
|
+
amr: [{ method: 'totp', timestamp: now - 10 }],
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
error: null,
|
|
28
|
+
});
|
|
29
|
+
const getUserById = vi
|
|
30
|
+
.fn()
|
|
31
|
+
.mockResolvedValue({ data: { user }, error: null });
|
|
32
|
+
const from = vi.fn(() => query);
|
|
33
|
+
const dependencies = {
|
|
34
|
+
createUserClient: vi.fn().mockResolvedValue({
|
|
35
|
+
auth: { getUser, getClaims },
|
|
36
|
+
} as unknown as TypedSupabaseClient),
|
|
37
|
+
createAdminClient: vi.fn().mockResolvedValue({
|
|
38
|
+
auth: { admin: { getUserById } },
|
|
39
|
+
from,
|
|
40
|
+
rpc: vi.fn().mockResolvedValue({ data: 'factor', error: null }),
|
|
41
|
+
} as unknown as TypedSupabaseClient),
|
|
42
|
+
nowSeconds: () => now,
|
|
43
|
+
};
|
|
44
|
+
const request = new Request('https://tuturuuu.com/api/v1/workspaces', {
|
|
45
|
+
headers: { authorization: 'Bearer supabase-session' },
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
dependencies,
|
|
49
|
+
request,
|
|
50
|
+
getUser,
|
|
51
|
+
getClaims,
|
|
52
|
+
getUserById,
|
|
53
|
+
query,
|
|
54
|
+
from,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
beforeEach(() =>
|
|
59
|
+
vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'required-mfa-test-secret')
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
describe('fresh account assurance', () => {
|
|
63
|
+
it('allows current provider-verified MFA', async () => {
|
|
64
|
+
const f = fixture();
|
|
65
|
+
expect(
|
|
66
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
67
|
+
userId: 'actor',
|
|
68
|
+
})
|
|
69
|
+
).toEqual({
|
|
70
|
+
status: 'allowed',
|
|
71
|
+
userId: 'actor',
|
|
72
|
+
proof: { sessionId: 'session', verifiedAt: now - 10, factorId: 'factor' },
|
|
73
|
+
});
|
|
74
|
+
expect(f.dependencies.createAdminClient).toHaveBeenCalled();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('rejects assurance for a different resolved route principal', async () => {
|
|
78
|
+
const f = fixture();
|
|
79
|
+
expect(
|
|
80
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
81
|
+
userId: 'another-actor',
|
|
82
|
+
})
|
|
83
|
+
).toEqual({ status: 'invalid' });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('does not fetch claims for an account without the policy', async () => {
|
|
87
|
+
const f = fixture();
|
|
88
|
+
f.getUser.mockResolvedValue({
|
|
89
|
+
data: { user: { ...user, app_metadata: {} } },
|
|
90
|
+
error: null,
|
|
91
|
+
});
|
|
92
|
+
expect(
|
|
93
|
+
(
|
|
94
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
95
|
+
userId: 'actor',
|
|
96
|
+
})
|
|
97
|
+
).status
|
|
98
|
+
).toBe('allowed');
|
|
99
|
+
expect(f.getClaims).not.toHaveBeenCalled();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('ignores user-controlled metadata', async () => {
|
|
103
|
+
const f = fixture();
|
|
104
|
+
f.getUser.mockResolvedValue({
|
|
105
|
+
data: {
|
|
106
|
+
user: {
|
|
107
|
+
...user,
|
|
108
|
+
user_metadata: { tuturuuu_required_mfa: { required: false } },
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
error: null,
|
|
112
|
+
});
|
|
113
|
+
f.getClaims.mockResolvedValue({
|
|
114
|
+
data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
|
|
115
|
+
error: null,
|
|
116
|
+
});
|
|
117
|
+
expect(
|
|
118
|
+
(
|
|
119
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
120
|
+
userId: 'actor',
|
|
121
|
+
})
|
|
122
|
+
).status
|
|
123
|
+
).toBe('required');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it.each([
|
|
127
|
+
{ aal: 'aal1', amr: [{ method: 'password', timestamp: now }] },
|
|
128
|
+
{ aal: 'aal2', amr: [{ method: 'totp', timestamp: now - 30 }] },
|
|
129
|
+
{ aal: 'aal2', amr: [{ method: 'totp', timestamp: now + 10 }] },
|
|
130
|
+
{ aal: 'aal2', amr: [{ method: 'totp', timestamp: now - 100 }], iat: now },
|
|
131
|
+
])('rejects stale, absent and future proof %j', async (claims) => {
|
|
132
|
+
const f = fixture();
|
|
133
|
+
f.getClaims.mockResolvedValue({
|
|
134
|
+
data: { claims: { sub: 'actor', session_id: 'session', ...claims } },
|
|
135
|
+
error: null,
|
|
136
|
+
});
|
|
137
|
+
expect(
|
|
138
|
+
(
|
|
139
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
140
|
+
userId: 'actor',
|
|
141
|
+
})
|
|
142
|
+
).status
|
|
143
|
+
).toBe('required');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('rejects claims for a different actor', async () => {
|
|
147
|
+
const f = fixture();
|
|
148
|
+
f.getClaims.mockResolvedValue({
|
|
149
|
+
data: { claims: { sub: 'another-user' } },
|
|
150
|
+
error: null,
|
|
151
|
+
});
|
|
152
|
+
expect(
|
|
153
|
+
(
|
|
154
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
155
|
+
userId: 'actor',
|
|
156
|
+
})
|
|
157
|
+
).status
|
|
158
|
+
).toBe('invalid');
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('fails closed when fresh identity lookup throws', async () => {
|
|
162
|
+
const f = fixture();
|
|
163
|
+
f.getUser.mockRejectedValue(new Error('offline'));
|
|
164
|
+
expect(
|
|
165
|
+
(
|
|
166
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
167
|
+
userId: 'actor',
|
|
168
|
+
})
|
|
169
|
+
).status
|
|
170
|
+
).toBe('unavailable');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('rechecks changed policy for an already signed app token', async () => {
|
|
174
|
+
const f = fixture();
|
|
175
|
+
const { token } = createAppCoordinationToken({
|
|
176
|
+
userId: 'actor',
|
|
177
|
+
targetApp: 'mail',
|
|
178
|
+
mfa: { factorId: 'factor', sessionId: 'session', verifiedAt: now - 10 },
|
|
179
|
+
});
|
|
180
|
+
const request = new Request(f.request.url, {
|
|
181
|
+
headers: { authorization: `Bearer ${token}` },
|
|
182
|
+
});
|
|
183
|
+
expect(
|
|
184
|
+
(
|
|
185
|
+
await checkRequiredAccountMfa(request, f.dependencies, {
|
|
186
|
+
userId: 'actor',
|
|
187
|
+
appToken: token,
|
|
188
|
+
})
|
|
189
|
+
).status
|
|
190
|
+
).toBe('allowed');
|
|
191
|
+
f.getUserById.mockResolvedValue({
|
|
192
|
+
data: {
|
|
193
|
+
user: {
|
|
194
|
+
...user,
|
|
195
|
+
app_metadata: {
|
|
196
|
+
tuturuuu_required_mfa: { required: true, verifiedAfter: now },
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
error: null,
|
|
201
|
+
});
|
|
202
|
+
expect(
|
|
203
|
+
(
|
|
204
|
+
await checkRequiredAccountMfa(request, f.dependencies, {
|
|
205
|
+
userId: 'actor',
|
|
206
|
+
appToken: token,
|
|
207
|
+
})
|
|
208
|
+
).status
|
|
209
|
+
).toBe('required');
|
|
210
|
+
expect(f.getUserById).toHaveBeenCalledTimes(2);
|
|
211
|
+
expect(f.from).not.toHaveBeenCalled();
|
|
212
|
+
expect(f.dependencies.createUserClient).not.toHaveBeenCalled();
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('does not accept an app token with no MFA evidence', async () => {
|
|
216
|
+
const f = fixture();
|
|
217
|
+
const { token } = createAppCoordinationToken({
|
|
218
|
+
userId: 'actor',
|
|
219
|
+
targetApp: 'mail',
|
|
220
|
+
});
|
|
221
|
+
const request = new Request(f.request.url, {
|
|
222
|
+
headers: { cookie: `tuturuuu_app_session=${token}` },
|
|
223
|
+
});
|
|
224
|
+
expect(
|
|
225
|
+
(
|
|
226
|
+
await checkRequiredAccountMfa(request, f.dependencies, {
|
|
227
|
+
userId: 'actor',
|
|
228
|
+
appToken: token,
|
|
229
|
+
})
|
|
230
|
+
).status
|
|
231
|
+
).toBe('required');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('does not trust an invalid app token', async () => {
|
|
235
|
+
const f = fixture();
|
|
236
|
+
const token = 'ttr_app_invalid';
|
|
237
|
+
const request = new Request(f.request.url, {
|
|
238
|
+
headers: { authorization: `Bearer ${token}` },
|
|
239
|
+
});
|
|
240
|
+
expect(
|
|
241
|
+
(
|
|
242
|
+
await checkRequiredAccountMfa(request, f.dependencies, {
|
|
243
|
+
userId: 'actor',
|
|
244
|
+
appToken: token,
|
|
245
|
+
})
|
|
246
|
+
).status
|
|
247
|
+
).toBe('invalid');
|
|
248
|
+
expect(f.dependencies.createAdminClient).not.toHaveBeenCalled();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('scopes mobile approval lookup to the exact actor and session', async () => {
|
|
252
|
+
const f = fixture();
|
|
253
|
+
f.getClaims.mockResolvedValue({
|
|
254
|
+
data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
|
|
255
|
+
error: null,
|
|
256
|
+
});
|
|
257
|
+
f.query.maybeSingle!.mockResolvedValue({
|
|
258
|
+
data: {
|
|
259
|
+
approved_at: new Date((now - 5) * 1000).toISOString(),
|
|
260
|
+
approval_metadata: {
|
|
261
|
+
requiredMfaProof: {
|
|
262
|
+
sessionId: 'source-session',
|
|
263
|
+
factorId: 'factor',
|
|
264
|
+
verifiedAt: now - 5,
|
|
265
|
+
},
|
|
266
|
+
mobileMfaValidUntil: new Date((now + 60) * 1000).toISOString(),
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
error: null,
|
|
270
|
+
});
|
|
271
|
+
expect(
|
|
272
|
+
(
|
|
273
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
274
|
+
userId: 'actor',
|
|
275
|
+
})
|
|
276
|
+
).status
|
|
277
|
+
).toBe('allowed');
|
|
278
|
+
expect(f.query.eq).toHaveBeenCalledWith('approver_user_id', 'actor');
|
|
279
|
+
expect(f.query.eq).toHaveBeenCalledWith(
|
|
280
|
+
'request_metadata->>requesterSessionId',
|
|
281
|
+
'session'
|
|
282
|
+
);
|
|
283
|
+
expect(f.query.eq).toHaveBeenCalledWith(
|
|
284
|
+
'approval_metadata->>approverSessionId',
|
|
285
|
+
'session'
|
|
286
|
+
);
|
|
287
|
+
expect(f.query.not).toHaveBeenCalledWith('consumed_at', 'is', null);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it.each(['invalid', new Date((now - 1) * 1000).toISOString()])(
|
|
291
|
+
'rejects unusable mobile approval expiry %s',
|
|
292
|
+
async (expiry) => {
|
|
293
|
+
const f = fixture();
|
|
294
|
+
f.getClaims.mockResolvedValue({
|
|
295
|
+
data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
|
|
296
|
+
error: null,
|
|
297
|
+
});
|
|
298
|
+
f.query.maybeSingle!.mockResolvedValue({
|
|
299
|
+
data: {
|
|
300
|
+
approved_at: new Date((now - 5) * 1000).toISOString(),
|
|
301
|
+
approval_metadata: { mobileMfaValidUntil: expiry },
|
|
302
|
+
},
|
|
303
|
+
error: null,
|
|
304
|
+
});
|
|
305
|
+
expect(
|
|
306
|
+
(
|
|
307
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
308
|
+
userId: 'actor',
|
|
309
|
+
})
|
|
310
|
+
).status
|
|
311
|
+
).toBe('required');
|
|
312
|
+
}
|
|
313
|
+
);
|
|
314
|
+
it('keeps mobile proof precision consistent with signed integer timestamps', async () => {
|
|
315
|
+
const f = fixture();
|
|
316
|
+
f.getClaims.mockResolvedValue({
|
|
317
|
+
data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
|
|
318
|
+
error: null,
|
|
319
|
+
});
|
|
320
|
+
f.query.maybeSingle!.mockResolvedValue({
|
|
321
|
+
data: {
|
|
322
|
+
approved_at: new Date((now - 30) * 1000 + 500).toISOString(),
|
|
323
|
+
approval_metadata: {
|
|
324
|
+
requiredMfaProof: {
|
|
325
|
+
sessionId: 'source-session',
|
|
326
|
+
factorId: 'factor',
|
|
327
|
+
verifiedAt: now - 5,
|
|
328
|
+
},
|
|
329
|
+
mobileMfaValidUntil: new Date((now + 60) * 1000).toISOString(),
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
error: null,
|
|
333
|
+
});
|
|
334
|
+
expect(
|
|
335
|
+
(
|
|
336
|
+
await checkRequiredAccountMfa(f.request, f.dependencies, {
|
|
337
|
+
userId: 'actor',
|
|
338
|
+
})
|
|
339
|
+
).status
|
|
340
|
+
).toBe('required');
|
|
341
|
+
});
|
|
342
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { NextRequest } from 'next/server';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { createAppCoordinationToken } from '../app-coordination-token';
|
|
5
|
+
import { enforceRequiredMfaRequest } from '../required-mfa-runtime';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({ admin: vi.fn() }));
|
|
8
|
+
vi.mock('@tuturuuu/supabase/next/server', () => ({
|
|
9
|
+
createAdminClient: mocks.admin,
|
|
10
|
+
}));
|
|
11
|
+
const now = Math.floor(Date.now() / 1000);
|
|
12
|
+
function setup() {
|
|
13
|
+
const user = {
|
|
14
|
+
id: 'actor',
|
|
15
|
+
app_metadata: {
|
|
16
|
+
tuturuuu_required_mfa: { required: true, verifiedAfter: now - 30 },
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
const getUserById = vi.fn(async (id: string) => ({
|
|
20
|
+
data: { user: id === 'actor' ? user : { id, app_metadata: {} } },
|
|
21
|
+
error: null,
|
|
22
|
+
}));
|
|
23
|
+
const getUser = vi.fn(async () => ({ data: { user }, error: null }));
|
|
24
|
+
const getClaims = vi.fn(async () => ({
|
|
25
|
+
data: { claims: { sub: 'actor', aal: 'aal1' } },
|
|
26
|
+
error: null,
|
|
27
|
+
}));
|
|
28
|
+
mocks.admin.mockResolvedValue({
|
|
29
|
+
rpc: async () => ({ data: 'factor', error: null }),
|
|
30
|
+
auth: { getUser, getClaims, admin: { getUserById } },
|
|
31
|
+
});
|
|
32
|
+
return { getUser, getClaims, getUserById };
|
|
33
|
+
}
|
|
34
|
+
function request(headers: HeadersInit, path = '/api/v1/workspaces') {
|
|
35
|
+
return new NextRequest(`https://tuturuuu.com${path}`, { headers });
|
|
36
|
+
}
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
vi.clearAllMocks();
|
|
39
|
+
vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'test-required-mfa-secret');
|
|
40
|
+
});
|
|
41
|
+
describe('connected required MFA request enforcement', () => {
|
|
42
|
+
it('denies provider-authenticated AAL1 sessions with fresh required policy', async () => {
|
|
43
|
+
setup();
|
|
44
|
+
const response = await enforceRequiredMfaRequest(
|
|
45
|
+
request({ authorization: 'Bearer eyJprovider' })
|
|
46
|
+
);
|
|
47
|
+
expect(response?.status).toBe(403);
|
|
48
|
+
expect(await response?.json()).toMatchObject({ code: 'MFA_REQUIRED' });
|
|
49
|
+
});
|
|
50
|
+
it('does not borrow assurance from another cookie identity', async () => {
|
|
51
|
+
setup();
|
|
52
|
+
const optional = createAppCoordinationToken({
|
|
53
|
+
userId: 'optional',
|
|
54
|
+
targetApp: 'web',
|
|
55
|
+
}).token;
|
|
56
|
+
const required = createAppCoordinationToken({
|
|
57
|
+
userId: 'actor',
|
|
58
|
+
targetApp: 'infra',
|
|
59
|
+
}).token;
|
|
60
|
+
const response = await enforceRequiredMfaRequest(
|
|
61
|
+
request({
|
|
62
|
+
cookie: `tuturuuu_web_app_session=${optional}; tuturuuu_app_session=${required}`,
|
|
63
|
+
})
|
|
64
|
+
);
|
|
65
|
+
expect(response?.status).toBe(403);
|
|
66
|
+
});
|
|
67
|
+
it('accepts a signed proof after the recovery boundary', async () => {
|
|
68
|
+
setup();
|
|
69
|
+
const token = createAppCoordinationToken({
|
|
70
|
+
userId: 'actor',
|
|
71
|
+
targetApp: 'infra',
|
|
72
|
+
mfa: { factorId: 'factor', sessionId: 'session', verifiedAt: now - 5 },
|
|
73
|
+
}).token;
|
|
74
|
+
expect(
|
|
75
|
+
await enforceRequiredMfaRequest(
|
|
76
|
+
request({ authorization: `Bearer ${token}` })
|
|
77
|
+
)
|
|
78
|
+
).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
it('fails closed when current policy is unavailable', async () => {
|
|
81
|
+
mocks.admin.mockRejectedValue(new Error('offline'));
|
|
82
|
+
expect(
|
|
83
|
+
(
|
|
84
|
+
await enforceRequiredMfaRequest(
|
|
85
|
+
request({ authorization: 'Bearer eyJprovider' })
|
|
86
|
+
)
|
|
87
|
+
)?.status
|
|
88
|
+
).toBe(503);
|
|
89
|
+
});
|
|
90
|
+
it('leaves independently authenticated machine requests to their own authentication', async () => {
|
|
91
|
+
expect(
|
|
92
|
+
await enforceRequiredMfaRequest(
|
|
93
|
+
request({ authorization: 'Bearer machine-key' })
|
|
94
|
+
)
|
|
95
|
+
).toBeNull();
|
|
96
|
+
expect(mocks.admin).not.toHaveBeenCalled();
|
|
97
|
+
});
|
|
98
|
+
it('leaves recovery requests reachable for their own challenge validation', async () => {
|
|
99
|
+
expect(
|
|
100
|
+
await enforceRequiredMfaRequest(
|
|
101
|
+
request(
|
|
102
|
+
{ authorization: 'Bearer eyJprovider' },
|
|
103
|
+
'/api/v1/auth/mfa/devices'
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
).toBeNull();
|
|
107
|
+
expect(mocks.admin).not.toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
|
+
import { createAppCoordinationToken } from '../app-coordination-token';
|
|
4
|
+
import { createMfaTransfer, readMfaTransfer } from '../required-mfa-transfer';
|
|
5
|
+
|
|
6
|
+
const proof = {
|
|
7
|
+
sessionId: 'session',
|
|
8
|
+
verifiedAt: Math.floor(Date.now() / 1000) - 5,
|
|
9
|
+
};
|
|
10
|
+
beforeEach(() =>
|
|
11
|
+
vi.stubEnv('TUTURUUU_APP_COORDINATION_SECRET', 'test-transfer-secret')
|
|
12
|
+
);
|
|
13
|
+
describe('MFA proof transfer through caller-controlled metadata', () => {
|
|
14
|
+
it('accepts only a signed proof for the exact user and target', () => {
|
|
15
|
+
const token = createMfaTransfer('actor', 'mail', proof);
|
|
16
|
+
expect(readMfaTransfer(token, 'actor', 'mail')).toEqual(proof);
|
|
17
|
+
expect(readMfaTransfer(token, 'victim', 'mail')).toBeNull();
|
|
18
|
+
expect(readMfaTransfer(token, 'actor', 'infra')).toBeNull();
|
|
19
|
+
});
|
|
20
|
+
it('rejects raw metadata and tokens without the transfer scope', () => {
|
|
21
|
+
expect(readMfaTransfer(proof, 'actor', 'mail')).toBeNull();
|
|
22
|
+
const token = createAppCoordinationToken({
|
|
23
|
+
userId: 'actor',
|
|
24
|
+
targetApp: 'mail',
|
|
25
|
+
mfa: proof,
|
|
26
|
+
}).token;
|
|
27
|
+
expect(readMfaTransfer(token, 'actor', 'mail')).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
});
|
package/src/ai-temp-auth.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
2
2
|
import { AI_TEMP_AUTH_HEADER } from './ai-temp-auth-constants';
|
|
3
|
+
import { isRequiredMfaProofAllowed } from './required-mfa-app-session';
|
|
3
4
|
import { getUpstashRestRedisClient } from './upstash-rest';
|
|
4
5
|
|
|
5
6
|
export { AI_TEMP_AUTH_HEADER } from './ai-temp-auth-constants';
|
|
@@ -96,6 +97,10 @@ export async function mintAiTempAuthToken({
|
|
|
96
97
|
token: string;
|
|
97
98
|
expiresAt: number;
|
|
98
99
|
} | null> {
|
|
100
|
+
// These cached optimization tokens do not carry a second-factor proof.
|
|
101
|
+
// Required accounts use the normal verified session authentication path.
|
|
102
|
+
if (!(await isRequiredMfaProofAllowed(user.id, null).catch(() => false)))
|
|
103
|
+
return null;
|
|
99
104
|
const redis = await getUpstashRestRedisClient().catch(() => null);
|
|
100
105
|
if (!redis) return null;
|
|
101
106
|
|
|
@@ -157,6 +162,12 @@ export async function validateAiTempAuthRequest(
|
|
|
157
162
|
return { status: 'revoked' };
|
|
158
163
|
}
|
|
159
164
|
|
|
165
|
+
if (
|
|
166
|
+
!(await isRequiredMfaProofAllowed(payload.user.id, null).catch(() => false))
|
|
167
|
+
) {
|
|
168
|
+
return { status: 'revoked' };
|
|
169
|
+
}
|
|
170
|
+
|
|
160
171
|
return {
|
|
161
172
|
status: 'valid',
|
|
162
173
|
context: {
|
package/src/api-proxy-guard.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from './abuse-protection/edge-trust';
|
|
14
14
|
import { DEV_MODE, MAX_PAYLOAD_SIZE } from './constants';
|
|
15
15
|
import { validateRequestEmojiLimit } from './request-emoji-limit';
|
|
16
|
+
import { enforceRequiredMfaRequest } from './required-mfa-runtime';
|
|
16
17
|
import {
|
|
17
18
|
getUpstashRatelimitRedisClient,
|
|
18
19
|
type UpstashRatelimitRedisClient,
|
|
@@ -20,10 +21,6 @@ import {
|
|
|
20
21
|
|
|
21
22
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
22
23
|
|
|
23
|
-
// Quantized trust tiers — the cached multiplier is floored to one of these
|
|
24
|
-
// before scaling read limits, so the limiter prefix (and therefore the Redis
|
|
25
|
-
// sliding-window buckets) stays bounded and stable even as a subject's exact
|
|
26
|
-
// multiplier drifts.
|
|
27
24
|
const TRUST_MULTIPLIER_TIERS = [1, 1.5, 2, 3, 5, 10, 25, 100] as const;
|
|
28
25
|
|
|
29
26
|
function isEnvFlagEnabled(name: string, defaultOn = true): boolean {
|
|
@@ -46,8 +43,7 @@ function edgeTrustEnabled(): boolean {
|
|
|
46
43
|
}
|
|
47
44
|
|
|
48
45
|
/**
|
|
49
|
-
*
|
|
50
|
-
* below 1 — trust uplift only ever raises read limits.
|
|
46
|
+
* Quantize uplift without ever reducing the base limit.
|
|
51
47
|
*/
|
|
52
48
|
function quantizeTrustMultiplier(multiplier: number): number {
|
|
53
49
|
let tier = 1;
|
|
@@ -1169,6 +1165,10 @@ export async function guardApiProxyRequest(
|
|
|
1169
1165
|
req: NextRequest,
|
|
1170
1166
|
options: GuardOptions
|
|
1171
1167
|
): Promise<NextResponse | null> {
|
|
1168
|
+
if (!isTrustedProxyBypassRequest(req.nextUrl.pathname, req.headers)) {
|
|
1169
|
+
const assurance = await enforceRequiredMfaRequest(req);
|
|
1170
|
+
if (assurance) return assurance;
|
|
1171
|
+
}
|
|
1172
1172
|
const contentLength = req.headers.get('content-length');
|
|
1173
1173
|
if (contentLength) {
|
|
1174
1174
|
const size = Number.parseInt(contentLength, 10);
|