@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
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.0](https://github.com/tutur3u/platform/compare/utils-v0.29.1...utils-v0.30.0) (2026-09-25)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Features
|
|
7
|
+
|
|
8
|
+
* **auth:** enforce required account MFA lifecycle ([e08f27d](https://github.com/tutur3u/platform/commit/e08f27dbc082f7d9c951bb0792357638f3dcfb0a)) ([#5448](https://github.com/tutur3u/platform/issues/5448)) ([d7a5ed4](https://github.com/tutur3u/platform/commit/d7a5ed453963e619bd1dc3bd8c8a18c5e027317c))
|
|
9
|
+
* **parley:** add private multiplayer training and shared AI Hub billing ([#5505](https://github.com/tutur3u/platform/issues/5505)) ([038e05c](https://github.com/tutur3u/platform/commit/038e05c69a0e4a080f62d6441ae4dd2b15721dba))
|
|
10
|
+
* **parley:** share Meet runtime and integrate private training with AI Hub billing ([66d40e2](https://github.com/tutur3u/platform/commit/66d40e23a8110bd43be652e1181205cf50e506ab))
|
|
11
|
+
|
|
3
12
|
## [0.29.1](https://github.com/tutur3u/platform/compare/utils-v0.29.0...utils-v0.29.1) (2026-09-23)
|
|
4
13
|
|
|
5
14
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuturuuu/utils",
|
|
3
3
|
"license": "MIT",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.30.0",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "https://github.com/tutur3u/platform",
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
"@tiptap/react": "3.31.3",
|
|
25
25
|
"@tuturuuu/google": "0.1.1",
|
|
26
26
|
"@tuturuuu/icons": "0.1.1",
|
|
27
|
-
"@tuturuuu/internal-api": "0.
|
|
27
|
+
"@tuturuuu/internal-api": "0.41.0",
|
|
28
28
|
"@tuturuuu/supabase": "0.5.2",
|
|
29
29
|
"@upstash/ratelimit": "^2.0.8",
|
|
30
30
|
"@upstash/redis": "^1.38.4",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"zod": "^4.6.2"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@tuturuuu/types": "0.
|
|
48
|
+
"@tuturuuu/types": "0.32.0",
|
|
49
49
|
"@tuturuuu/typescript-config": "0.1.2",
|
|
50
50
|
"@types/diff": "^8.0.0",
|
|
51
51
|
"@types/node": "^26.5.1",
|
|
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
2
2
|
|
|
3
3
|
const mocks = vi.hoisted(() => ({
|
|
4
4
|
getRedis: vi.fn(),
|
|
5
|
+
assurance: vi.fn(),
|
|
5
6
|
redis: {
|
|
6
7
|
decr: vi.fn(),
|
|
7
8
|
del: vi.fn(),
|
|
@@ -13,6 +14,10 @@ const mocks = vi.hoisted(() => ({
|
|
|
13
14
|
},
|
|
14
15
|
}));
|
|
15
16
|
|
|
17
|
+
vi.mock('../required-mfa-app-session', () => ({
|
|
18
|
+
isRequiredMfaProofAllowed: (...args: unknown[]) => mocks.assurance(...args),
|
|
19
|
+
}));
|
|
20
|
+
|
|
16
21
|
vi.mock('../upstash-rest.js', () => ({
|
|
17
22
|
getUpstashRestRedisClient: () => mocks.getRedis(),
|
|
18
23
|
}));
|
|
@@ -20,6 +25,7 @@ vi.mock('../upstash-rest.js', () => ({
|
|
|
20
25
|
describe('ai temp auth', () => {
|
|
21
26
|
beforeEach(() => {
|
|
22
27
|
vi.clearAllMocks();
|
|
28
|
+
mocks.assurance.mockResolvedValue(true);
|
|
23
29
|
mocks.getRedis.mockResolvedValue(mocks.redis);
|
|
24
30
|
mocks.redis.get.mockResolvedValue('0');
|
|
25
31
|
mocks.redis.set.mockResolvedValue('OK');
|
|
@@ -29,6 +35,31 @@ describe('ai temp auth', () => {
|
|
|
29
35
|
mocks.redis.del.mockResolvedValue(1);
|
|
30
36
|
});
|
|
31
37
|
|
|
38
|
+
it('uses normal session auth instead of cached tokens for required accounts', async () => {
|
|
39
|
+
const { mintAiTempAuthToken } = await import('../ai-temp-auth.js');
|
|
40
|
+
mocks.assurance.mockResolvedValue(false);
|
|
41
|
+
expect(await mintAiTempAuthToken({ user: { id: 'required' } })).toBeNull();
|
|
42
|
+
expect(mocks.redis.set).not.toHaveBeenCalled();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('invalidates an existing cached identity immediately after policy changes', async () => {
|
|
46
|
+
const { mintAiTempAuthToken, validateAiTempAuthRequest } = await import(
|
|
47
|
+
'../ai-temp-auth.js'
|
|
48
|
+
);
|
|
49
|
+
const minted = await mintAiTempAuthToken({ user: { id: 'actor' } });
|
|
50
|
+
const payload = mocks.redis.set.mock.calls[0]![1];
|
|
51
|
+
mocks.redis.get.mockImplementation(async (key: string) =>
|
|
52
|
+
key.startsWith('ai:temp-auth:token:') ? payload : '0'
|
|
53
|
+
);
|
|
54
|
+
mocks.assurance.mockResolvedValue(false);
|
|
55
|
+
const request = { headers: new Headers() };
|
|
56
|
+
const { AI_TEMP_AUTH_HEADER } = await import('../ai-temp-auth.js');
|
|
57
|
+
request.headers.set(AI_TEMP_AUTH_HEADER, minted!.token);
|
|
58
|
+
expect(await validateAiTempAuthRequest(request)).toEqual({
|
|
59
|
+
status: 'revoked',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
32
63
|
it('mints a 60-second token and stores only its digest in Redis', async () => {
|
|
33
64
|
const { mintAiTempAuthToken } = await import('../ai-temp-auth.js');
|
|
34
65
|
|
|
@@ -159,6 +190,7 @@ describe('ai temp auth', () => {
|
|
|
159
190
|
describe('ai credit snapshots', () => {
|
|
160
191
|
beforeEach(() => {
|
|
161
192
|
vi.clearAllMocks();
|
|
193
|
+
mocks.assurance.mockResolvedValue(true);
|
|
162
194
|
mocks.getRedis.mockResolvedValue(mocks.redis);
|
|
163
195
|
mocks.redis.get.mockResolvedValue(null);
|
|
164
196
|
mocks.redis.set.mockResolvedValue('OK');
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
vi.mock('../required-mfa-runtime', () => ({
|
|
2
|
+
enforceRequiredMfaRequest: async () => null,
|
|
3
|
+
}));
|
|
4
|
+
|
|
1
5
|
import { NextRequest } from 'next/server';
|
|
2
6
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
7
|
|
|
@@ -134,7 +138,6 @@ describe('guardApiProxyRequest', () => {
|
|
|
134
138
|
}),
|
|
135
139
|
{ prefixBase: 'proxy:test:api' }
|
|
136
140
|
);
|
|
137
|
-
|
|
138
141
|
expect(response?.status).toBe(413);
|
|
139
142
|
expect(mocks.extractIp).not.toHaveBeenCalled();
|
|
140
143
|
});
|
|
@@ -147,13 +150,11 @@ describe('guardApiProxyRequest', () => {
|
|
|
147
150
|
{ 'content-type': 'application/json' },
|
|
148
151
|
JSON.stringify({ value: 'x'.repeat(1024 * 1024) })
|
|
149
152
|
);
|
|
150
|
-
|
|
151
153
|
expect(request.headers.get('content-length')).toBeNull();
|
|
152
154
|
|
|
153
155
|
const response = await guardApiProxyRequest(request, {
|
|
154
156
|
prefixBase: 'proxy:test:api',
|
|
155
157
|
});
|
|
156
|
-
|
|
157
158
|
expect(response?.status).toBe(413);
|
|
158
159
|
expect(mocks.extractIp).not.toHaveBeenCalled();
|
|
159
160
|
expect(mocks.validateEmoji).not.toHaveBeenCalled();
|
|
@@ -172,7 +173,6 @@ describe('guardApiProxyRequest', () => {
|
|
|
172
173
|
skipContentValidation: true,
|
|
173
174
|
}
|
|
174
175
|
);
|
|
175
|
-
|
|
176
176
|
expect(response).toBeNull();
|
|
177
177
|
expect(mocks.validateEmoji).not.toHaveBeenCalled();
|
|
178
178
|
|
|
@@ -207,7 +207,6 @@ describe('guardApiProxyRequest', () => {
|
|
|
207
207
|
prefixBase: 'proxy:test:api',
|
|
208
208
|
}
|
|
209
209
|
);
|
|
210
|
-
|
|
211
210
|
expect(response?.status).toBe(429);
|
|
212
211
|
expect(response?.headers.get('X-Proxy-Block-Reason')).toBe(
|
|
213
212
|
'ip-already-blocked'
|
|
@@ -287,6 +287,7 @@ describe('Portless app origin registry', () => {
|
|
|
287
287
|
'*.tuturuuu.localhost',
|
|
288
288
|
]);
|
|
289
289
|
expect(Object.values(TUTURUUU_PORTLESS_APP_ORIGINS)).toEqual([
|
|
290
|
+
'https://parley.tuturuuu.localhost',
|
|
290
291
|
'https://ai.tuturuuu.localhost',
|
|
291
292
|
'https://apps.tuturuuu.localhost',
|
|
292
293
|
'https://calendar.tuturuuu.localhost',
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
3
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
4
|
+
import { guardBrowserApiSession } from '../required-mfa-api-session';
|
|
5
|
+
|
|
6
|
+
const update = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock('@tuturuuu/supabase/next/proxy', () => ({ updateSession: update }));
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
update.mockReset();
|
|
10
|
+
});
|
|
11
|
+
it('checks refreshed cookies and propagates rotation on a policy rejection', async () => {
|
|
12
|
+
const request = new NextRequest('https://example.com/api/v1/users/me', {
|
|
13
|
+
headers: { cookie: 'sb-project-auth-token=old' },
|
|
14
|
+
});
|
|
15
|
+
update.mockImplementation(async (req: NextRequest) => {
|
|
16
|
+
req.cookies.set('sb-project-auth-token', 'new');
|
|
17
|
+
const res = NextResponse.next();
|
|
18
|
+
res.cookies.set('sb-project-auth-token', 'new');
|
|
19
|
+
return { res, claims: { sub: 'actor' } };
|
|
20
|
+
});
|
|
21
|
+
const response = await guardBrowserApiSession(request, async () => {
|
|
22
|
+
expect(request.cookies.get('sb-project-auth-token')?.value).toBe('new');
|
|
23
|
+
return NextResponse.json({ code: 'MFA_REQUIRED' }, { status: 403 });
|
|
24
|
+
});
|
|
25
|
+
expect(response.status).toBe(403);
|
|
26
|
+
expect(response.headers.get('set-cookie')).toContain(
|
|
27
|
+
'sb-project-auth-token=new'
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
it.each([
|
|
31
|
+
'/api/auth/refresh-app-session',
|
|
32
|
+
'/api/v1/auth/cross-app-session/refresh',
|
|
33
|
+
])('does not recursively refresh the handoff handler %s', async (path) => {
|
|
34
|
+
const request = new NextRequest(`https://example.com${path}`, {
|
|
35
|
+
headers: { cookie: 'sb-project-auth-token=old' },
|
|
36
|
+
});
|
|
37
|
+
expect((await guardBrowserApiSession(request, async () => null)).status).toBe(
|
|
38
|
+
200
|
|
39
|
+
);
|
|
40
|
+
expect(update).not.toHaveBeenCalled();
|
|
41
|
+
});
|
|
42
|
+
it('never replaces explicit bearer credentials with ambient browser cookies', async () => {
|
|
43
|
+
const request = new NextRequest('https://example.com/api/v1/users/me', {
|
|
44
|
+
headers: {
|
|
45
|
+
authorization: 'Bearer explicit',
|
|
46
|
+
cookie: 'sb-project-auth-token=old',
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
await guardBrowserApiSession(request, async () => null);
|
|
50
|
+
expect(update).not.toHaveBeenCalled();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('removes superseded app credentials from the actual forwarded provider request', async () => {
|
|
54
|
+
const request = new NextRequest('https://example.com/api/v1/users/me', {
|
|
55
|
+
headers: {
|
|
56
|
+
cookie:
|
|
57
|
+
'sb-project-auth-token=provider; tuturuuu_app_session=stale-app; tuturuuu_web_app_session=other-user',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
update.mockResolvedValue({
|
|
61
|
+
res: NextResponse.next(),
|
|
62
|
+
claims: { sub: 'provider-user' },
|
|
63
|
+
});
|
|
64
|
+
const response = await guardBrowserApiSession(request, async () => {
|
|
65
|
+
expect(request.cookies.has('tuturuuu_app_session')).toBe(false);
|
|
66
|
+
expect(request.cookies.has('tuturuuu_web_app_session')).toBe(false);
|
|
67
|
+
return null;
|
|
68
|
+
});
|
|
69
|
+
expect(response.headers.get('x-middleware-request-cookie')).toContain(
|
|
70
|
+
'sb-project-auth-token=provider'
|
|
71
|
+
);
|
|
72
|
+
expect(response.headers.get('x-middleware-request-cookie')).not.toContain(
|
|
73
|
+
'tuturuuu_'
|
|
74
|
+
);
|
|
75
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import { beforeEach, expect, it, vi } from 'vitest';
|
|
3
|
+
import { requiresAccountMfa } from '../required-mfa-browser';
|
|
4
|
+
|
|
5
|
+
const rpc = vi.fn();
|
|
6
|
+
const assurance = vi.fn();
|
|
7
|
+
const client = {
|
|
8
|
+
auth: {
|
|
9
|
+
getUser: async () => ({
|
|
10
|
+
data: {
|
|
11
|
+
user: {
|
|
12
|
+
id: 'actor',
|
|
13
|
+
app_metadata: {
|
|
14
|
+
tuturuuu_required_mfa: { required: true, verifiedAfter: 100 },
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
error: null,
|
|
19
|
+
}),
|
|
20
|
+
getClaims: async () => ({
|
|
21
|
+
data: { claims: { sub: 'actor', aal: 'aal1', session_id: 'session' } },
|
|
22
|
+
error: null,
|
|
23
|
+
}),
|
|
24
|
+
mfa: { getAuthenticatorAssuranceLevel: assurance },
|
|
25
|
+
},
|
|
26
|
+
rpc,
|
|
27
|
+
} as unknown as TypedSupabaseClient;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
it('keeps a mobile-approved browser on its completed sign-in flow', async () => {
|
|
32
|
+
rpc.mockResolvedValue({ data: true, error: null });
|
|
33
|
+
expect(await requiresAccountMfa(client, { acceptMobileApproval: true })).toBe(
|
|
34
|
+
false
|
|
35
|
+
);
|
|
36
|
+
expect(rpc).toHaveBeenCalledWith('account_required_mfa_satisfied');
|
|
37
|
+
expect(assurance).not.toHaveBeenCalled();
|
|
38
|
+
});
|
|
39
|
+
it.each([
|
|
40
|
+
{ data: false, error: null },
|
|
41
|
+
{ data: null, error: { code: '42501' } },
|
|
42
|
+
])(
|
|
43
|
+
'requires a new challenge after mobile proof expiry or reset',
|
|
44
|
+
async (result) => {
|
|
45
|
+
rpc.mockResolvedValue(result);
|
|
46
|
+
expect(
|
|
47
|
+
await requiresAccountMfa(client, { acceptMobileApproval: true })
|
|
48
|
+
).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
);
|
|
51
|
+
it('does not let a mobile proof approve another device challenge', async () => {
|
|
52
|
+
expect(await requiresAccountMfa(client)).toBe(true);
|
|
53
|
+
expect(rpc).not.toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('a cached AAL2 claim still requires current database factor lineage', async () => {
|
|
57
|
+
const claims = vi.spyOn(client.auth, 'getClaims').mockResolvedValue({
|
|
58
|
+
data: {
|
|
59
|
+
claims: {
|
|
60
|
+
sub: 'actor',
|
|
61
|
+
aal: 'aal2',
|
|
62
|
+
session_id: 'session',
|
|
63
|
+
amr: [{ method: 'totp', timestamp: 101 }],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
error: null,
|
|
67
|
+
} as never);
|
|
68
|
+
rpc.mockResolvedValue({ data: false, error: null });
|
|
69
|
+
expect(await requiresAccountMfa(client)).toBe(true);
|
|
70
|
+
rpc.mockResolvedValue({ data: true, error: null });
|
|
71
|
+
expect(await requiresAccountMfa(client)).toBe(false);
|
|
72
|
+
claims.mockRestore();
|
|
73
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/types';
|
|
2
|
+
import { expect, it, vi } from 'vitest';
|
|
3
|
+
import { bindCurrentMfaFactor } from '../required-mfa-lineage';
|
|
4
|
+
|
|
5
|
+
const proof = {
|
|
6
|
+
sessionId: 'session',
|
|
7
|
+
verifiedAt: 104,
|
|
8
|
+
primaryVerifiedAt: 103,
|
|
9
|
+
factorId: 'removed-factor',
|
|
10
|
+
};
|
|
11
|
+
it('rejects late verification whose original factor was deleted', async () => {
|
|
12
|
+
const rpc = vi.fn().mockResolvedValue({ data: null, error: null });
|
|
13
|
+
expect(
|
|
14
|
+
await bindCurrentMfaFactor(
|
|
15
|
+
{ rpc } as unknown as TypedSupabaseClient,
|
|
16
|
+
'actor',
|
|
17
|
+
proof
|
|
18
|
+
)
|
|
19
|
+
).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
it('a newly enrolled factor cannot revive an old signed proof', async () => {
|
|
22
|
+
const rpc = vi.fn().mockResolvedValue({ data: 'new-factor', error: null });
|
|
23
|
+
expect(
|
|
24
|
+
await bindCurrentMfaFactor(
|
|
25
|
+
{ rpc } as unknown as TypedSupabaseClient,
|
|
26
|
+
'actor',
|
|
27
|
+
proof
|
|
28
|
+
)
|
|
29
|
+
).toBeNull();
|
|
30
|
+
});
|
|
31
|
+
it('binds a provider-verified session once, then retains its exact factor id', async () => {
|
|
32
|
+
const rpc = vi.fn().mockResolvedValue({ data: 'new-factor', error: null });
|
|
33
|
+
const admin = { rpc } as unknown as TypedSupabaseClient;
|
|
34
|
+
const raw = { sessionId: 'session', verifiedAt: 104, primaryVerifiedAt: 103 };
|
|
35
|
+
expect(await bindCurrentMfaFactor(admin, 'actor', raw)).toBeNull();
|
|
36
|
+
expect(await bindCurrentMfaFactor(admin, 'actor', raw, true)).toEqual({
|
|
37
|
+
...raw,
|
|
38
|
+
factorId: 'new-factor',
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// @vitest-environment node
|
|
2
|
+
import { NextRequest } from 'next/server';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import type { AccountAssuranceDependencies } from '../required-mfa-request';
|
|
5
|
+
|
|
6
|
+
const check = vi.hoisted(() => vi.fn());
|
|
7
|
+
vi.mock('../required-mfa-request', () => ({ checkRequiredAccountMfa: check }));
|
|
8
|
+
|
|
9
|
+
import {
|
|
10
|
+
guardRequiredAccountMfa,
|
|
11
|
+
isAccountAssuranceRecoveryPath,
|
|
12
|
+
} from '../required-mfa-proxy';
|
|
13
|
+
|
|
14
|
+
const dependencies = {} as AccountAssuranceDependencies;
|
|
15
|
+
function request(
|
|
16
|
+
path = '/api/v1/workspaces',
|
|
17
|
+
headers: HeadersInit = { authorization: 'Bearer eyJtest' }
|
|
18
|
+
) {
|
|
19
|
+
return new NextRequest(`https://tuturuuu.com${path}`, { headers });
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
check.mockReset();
|
|
24
|
+
check.mockResolvedValue({ status: 'allowed', userId: 'actor' });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('required MFA proxy responses', () => {
|
|
28
|
+
it('leaves anonymous requests to route authentication', async () => {
|
|
29
|
+
expect(
|
|
30
|
+
await guardRequiredAccountMfa(
|
|
31
|
+
request('/api/v1/public', {}),
|
|
32
|
+
dependencies,
|
|
33
|
+
{ userId: 'actor' }
|
|
34
|
+
)
|
|
35
|
+
).toBeNull();
|
|
36
|
+
expect(check).not.toHaveBeenCalled();
|
|
37
|
+
});
|
|
38
|
+
it.each([
|
|
39
|
+
['authorization', 'Bearer eyJtest'],
|
|
40
|
+
['authorization', 'Bearer ttr_app_test'],
|
|
41
|
+
['cookie', 'sb-project-auth-token.0=token'],
|
|
42
|
+
['cookie', 'tuturuuu_web_app_session=token'],
|
|
43
|
+
['cookie', 'tuturuuu_app_session=token'],
|
|
44
|
+
])('checks interactive credential %s', async (name, value) => {
|
|
45
|
+
expect(
|
|
46
|
+
await guardRequiredAccountMfa(
|
|
47
|
+
request(undefined, { [name]: value }),
|
|
48
|
+
dependencies,
|
|
49
|
+
{ userId: 'actor' }
|
|
50
|
+
)
|
|
51
|
+
).toBeNull();
|
|
52
|
+
expect(check).toHaveBeenCalledOnce();
|
|
53
|
+
});
|
|
54
|
+
it.each([
|
|
55
|
+
['required', 403, 'MFA_REQUIRED'],
|
|
56
|
+
['invalid', 401, 'UNAUTHENTICATED'],
|
|
57
|
+
['unavailable', 503, 'AUTH_UNAVAILABLE'],
|
|
58
|
+
])(
|
|
59
|
+
'returns a bounded no-store response for %s',
|
|
60
|
+
async (status, httpStatus, code) => {
|
|
61
|
+
check.mockResolvedValue({ status, userId: 'private-actor-id' });
|
|
62
|
+
const result = await guardRequiredAccountMfa(request(), dependencies, {
|
|
63
|
+
userId: 'actor',
|
|
64
|
+
});
|
|
65
|
+
expect(result?.status).toBe(httpStatus);
|
|
66
|
+
expect(result?.headers.get('Cache-Control')).toBe('no-store');
|
|
67
|
+
const body = await result!.json();
|
|
68
|
+
expect(body.code).toBe(code);
|
|
69
|
+
expect(JSON.stringify(body)).not.toContain('private-actor-id');
|
|
70
|
+
}
|
|
71
|
+
);
|
|
72
|
+
it.each([
|
|
73
|
+
'/api/auth/verify-app-token',
|
|
74
|
+
'/api/auth/refresh-app-session',
|
|
75
|
+
'/api/v1/auth/cross-app-token/verify',
|
|
76
|
+
'/api/v1/auth/cross-app-session/refresh',
|
|
77
|
+
'/api/cli/auth/refresh',
|
|
78
|
+
'/api/v1/auth/mfa/devices',
|
|
79
|
+
'/api/v1/auth/mfa/mobile/challenges',
|
|
80
|
+
'/api/v1/auth/mfa/mobile/approvals',
|
|
81
|
+
'/api/v1/auth/mfa/mobile/challenges/00000000-0000-4000-8000-000000000001',
|
|
82
|
+
'/api/v1/auth/mfa/mobile/challenges/00000000-0000-4000-8000-000000000001/approve',
|
|
83
|
+
'/api/v1/auth/accounts/logout',
|
|
84
|
+
'/api/v1/auth/accounts/logout-all',
|
|
85
|
+
])('allows assurance establishment/recovery route %s', async (path) => {
|
|
86
|
+
expect(
|
|
87
|
+
await guardRequiredAccountMfa(request(path), dependencies, {
|
|
88
|
+
userId: 'actor',
|
|
89
|
+
})
|
|
90
|
+
).toBeNull();
|
|
91
|
+
expect(check).not.toHaveBeenCalled();
|
|
92
|
+
});
|
|
93
|
+
it.each([
|
|
94
|
+
'/api/auth/refresh-app-session/anything',
|
|
95
|
+
'/api/v1/auth/cross-app-session/refresh/anything',
|
|
96
|
+
'/api/v1/auth/mfa/devices/anything',
|
|
97
|
+
'/api/v1/auth/mfa/mobile/challenges/invalid',
|
|
98
|
+
'/api/v1/auth/mfa/mobile/challenges/00000000-0000-4000-8000-000000000001/approve/anything',
|
|
99
|
+
'/api/v1/infrastructure/internal-accounts',
|
|
100
|
+
'/api/v1/users/me',
|
|
101
|
+
'/api/v1/workspaces',
|
|
102
|
+
])('does not exempt protected or unknown route %s', (path) => {
|
|
103
|
+
expect(isAccountAssuranceRecoveryPath(path)).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
});
|