pyric-admin 0.1.0-alpha.32 → 0.1.0-alpha.34

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.
@@ -17,9 +17,10 @@
17
17
  import { afterEach, describe, it, expect } from 'bun:test';
18
18
 
19
19
  import { initializeSandbox } from 'pyric/sandbox';
20
+ import { getClock, idTokenAudience, mintIdToken, SANDBOX_PROJECT_ID } from 'pyric/sandbox/internal';
20
21
 
21
22
  import { initializeApp, deleteApp, getApps, ADMIN_APP_TARGET } from '../app/index.js';
22
- import { getAuth, SANDBOX_TOKEN_PREFIX } from './index.js';
23
+ import { getAuth } from './index.js';
23
24
 
24
25
  // The app registry is module-global (mirror of firebase-admin's
25
26
  // defaultAppStore) — deregister every app after each test so unnamed
@@ -78,63 +79,234 @@ describe('getAuth — Phase 3 dispatch', () => {
78
79
  });
79
80
  });
80
81
 
81
- describe('sandbox backend — createCustomToken / verifyIdToken roundtrip', () => {
82
- it('mints a deterministic token with the documented prefix', async () => {
82
+ describe('sandbox backend — createCustomToken', () => {
83
+ // The token's shape and its exchange: custom-token-exchange.test.ts (#1952).
84
+ it('mints the same token for the same uid, claims and sandbox time', async () => {
83
85
  const sandbox = initializeSandbox();
84
86
  const auth = getAuth(initializeApp({ sandbox }));
85
- const token = await auth.createCustomToken('alice');
86
- expect(token.startsWith(`${SANDBOX_TOKEN_PREFIX}:`)).toBe(true);
87
- expect(token).toBe(`${SANDBOX_TOKEN_PREFIX}:alice:{}`);
87
+ expect(await auth.createCustomToken('alice')).toBe(await auth.createCustomToken('alice'));
88
88
  });
89
+ });
89
90
 
90
- it('round-trips uid + claims through verifyIdToken', async () => {
91
- const sandbox = initializeSandbox();
92
- const auth = getAuth(initializeApp({ sandbox }));
93
- const token = await auth.createCustomToken('alice', { role: 'admin', tier: 2 });
94
- const decoded = await auth.verifyIdToken(token);
91
+ // #1949: verifyIdToken checks a token as firebase-admin 13.10.0 does
92
+ // (lib/auth/token-verifier.js verifyJWT, verifyContent, verifySignature):
93
+ // the argument's type synchronously, then decoding, `kid`, `alg`, `aud`,
94
+ // `iss`, `sub`, the signature, and last `nbf` and `exp`.
95
+ const DOCS = ' See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.';
96
+ const PROJECT_MATCH = ' Make sure the ID token comes from the same Firebase project as the service account used to authenticate this SDK.';
97
+ const DECODE_FAILED = `Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.${DOCS}`;
98
+ const BAD_SIGNATURE = `Firebase ID token has invalid signature.${DOCS}`;
99
+
100
+ function nowSeconds(): number {
101
+ return Math.floor(Date.now() / 1000);
102
+ }
103
+
104
+ /** The claims of an ID token pyric's Auth mints for `projectId`, with `overrides` on top. */
105
+ function idTokenClaims(overrides: Record<string, unknown> = {}, projectId = SANDBOX_PROJECT_ID): Record<string, unknown> {
106
+ const now = nowSeconds();
107
+ return {
108
+ ...idTokenAudience(projectId),
109
+ sub: 'alice',
110
+ user_id: 'alice',
111
+ iat: now,
112
+ auth_time: now,
113
+ exp: now + 3600,
114
+ firebase: { identities: {}, sign_in_provider: 'custom' },
115
+ ...overrides,
116
+ };
117
+ }
118
+
119
+ function segment(value: unknown): string {
120
+ return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url');
121
+ }
122
+
123
+ /** A JWT with any header and claims and no valid signature. */
124
+ function unsignedJwt(header: Record<string, unknown>, claims: Record<string, unknown>): string {
125
+ return `${segment(header)}.${segment(claims)}.c2lnbmF0dXJl`;
126
+ }
127
+
128
+ function sandboxAuth(projectId?: string) {
129
+ const sandbox = initializeSandbox();
130
+ const options = projectId === undefined ? { sandbox } : { sandbox, projectId };
131
+ return { sandbox, auth: getAuth(initializeApp(options)) };
132
+ }
133
+
134
+ function argumentError(message: string) {
135
+ return { code: 'auth/argument-error', message };
136
+ }
137
+
138
+ describe('sandbox backend — verifyIdToken verifies ID tokens only', () => {
139
+ it('verifies the ID token a client gets from a custom-token sign-in, with the claims', async () => {
140
+ const { getAuth: getClientAuth, signInWithCustomToken } = await import('pyric/auth');
141
+ const { sandbox, auth } = sandboxAuth();
142
+ const customToken = await auth.createCustomToken('alice', { role: 'admin', tier: 2 });
143
+ const { user } = await signInWithCustomToken(getClientAuth(sandbox), customToken);
144
+ const decoded = await auth.verifyIdToken(await user.getIdToken());
95
145
  expect(decoded.uid).toBe('alice');
96
146
  expect(decoded.sub).toBe('alice');
97
147
  expect(decoded.role).toBe('admin');
98
148
  expect(decoded.tier).toBe(2);
99
- // Required DecodedIdToken fields are populated with sandbox placeholders.
100
- expect(decoded.iss).toBe('pyric-sandbox');
149
+ expect(decoded.iss).toBe('https://securetoken.google.com/pyric-sandbox');
101
150
  expect(decoded.aud).toBe('pyric-sandbox');
102
151
  expect(decoded.firebase.sign_in_provider).toBe('custom');
103
152
  });
104
153
 
105
- it('rejects tokens that do not carry the sandbox prefix', async () => {
106
- const sandbox = initializeSandbox();
107
- const auth = getAuth(initializeApp({ sandbox }));
108
- // A real-looking JWT — three base64 segments joined by '.'. The
109
- // sandbox backend doesn't try to parse JWTs; it only accepts its
110
- // own minted tokens.
111
- const realJwt = 'eyJhbGciOi.eyJzdWIiOi.signature';
112
- await expect(auth.verifyIdToken(realJwt)).rejects.toThrow(
113
- /sandbox.*createCustomToken/,
154
+ it("verifies an ID token minted for the admin app's own project", async () => {
155
+ const { auth } = sandboxAuth('demo-verify');
156
+ const decoded = await auth.verifyIdToken(mintIdToken(idTokenClaims({}, 'demo-verify')));
157
+ expect(decoded.uid).toBe('alice');
158
+ expect(decoded.aud).toBe('demo-verify');
159
+ });
160
+
161
+ it('rejects a custom token with auth/argument-error, as production does', async () => {
162
+ const { auth } = sandboxAuth();
163
+ const customToken = await auth.createCustomToken('victim', { admin: true });
164
+ await expect(auth.verifyIdToken(customToken)).rejects.toMatchObject(
165
+ argumentError(`verifyIdToken() expects an ID token, but was given a custom token.${DOCS}`),
114
166
  );
115
167
  });
116
168
 
117
- it('rejects tokens with malformed claim JSON', async () => {
118
- const sandbox = initializeSandbox();
119
- const auth = getAuth(initializeApp({ sandbox }));
120
- const badToken = `${SANDBOX_TOKEN_PREFIX}:alice:not-json`;
121
- await expect(auth.verifyIdToken(badToken)).rejects.toThrow(/JSON/);
169
+ it('rejects a plain string with auth/argument-error', async () => {
170
+ const { auth } = sandboxAuth();
171
+ for (const token of ['not a token', 'pyric-sandbox-custom:victim-uid:{"admin":true}', 'sandbox-id-token-bob-1:{"sub":"bob"}', '']) {
172
+ await expect(auth.verifyIdToken(token)).rejects.toMatchObject(argumentError(DECODE_FAILED));
173
+ }
122
174
  });
123
175
 
124
- it('verifies client ID tokens minted with sandbox-id-token prefix', async () => {
125
- const sandbox = initializeSandbox();
126
- const auth = getAuth(initializeApp({ sandbox }));
127
- const clientClaims = { sub: 'bob', role: 'editor', firebase: { sign_in_provider: 'google.com' } };
128
- const clientToken = `sandbox-id-token-bob-1:${JSON.stringify(clientClaims)}`;
129
- const decoded = await auth.verifyIdToken(clientToken);
130
- expect(decoded.uid).toBe('bob');
131
- expect(decoded.sub).toBe('bob');
132
- expect(decoded.role).toBe('editor');
133
- expect(decoded.firebase.sign_in_provider).toBe('google.com');
134
- expect(decoded.iss).toBe('https://sandbox.pyric.dev');
135
- expect(decoded.aud).toBe('pyric-sandbox');
176
+ it('rejects a JWT whose header and payload do not decode as JSON', async () => {
177
+ const { auth } = sandboxAuth();
178
+ await expect(auth.verifyIdToken('eyJhbGciOi.eyJzdWIiOi.signature')).rejects.toMatchObject(argumentError(DECODE_FAILED));
179
+ });
180
+
181
+ it('throws auth/argument-error synchronously when the token is not a string', () => {
182
+ const { auth } = sandboxAuth();
183
+ let thrown: unknown;
184
+ try {
185
+ void auth.verifyIdToken(42 as never);
186
+ } catch (error) {
187
+ thrown = error;
188
+ }
189
+ expect(thrown).toMatchObject(argumentError('First argument to verifyIdToken() must be a Firebase ID token string.'));
190
+ });
191
+
192
+ it('rejects a forged token, whose claims were edited after signing', async () => {
193
+ const { auth } = sandboxAuth();
194
+ const [header, , signature] = mintIdToken(idTokenClaims()).split('.');
195
+ const forged = `${header}.${segment(idTokenClaims({ sub: 'victim', user_id: 'victim', admin: true }))}.${signature}`;
196
+ await expect(auth.verifyIdToken(forged)).rejects.toMatchObject(argumentError(BAD_SIGNATURE));
197
+ });
198
+
199
+ it('rejects a token with no signature', async () => {
200
+ const { auth } = sandboxAuth();
201
+ const [header, payload] = mintIdToken(idTokenClaims()).split('.');
202
+ await expect(auth.verifyIdToken(`${header}.${payload}.`)).rejects.toMatchObject(argumentError(BAD_SIGNATURE));
203
+ });
204
+
205
+ it('rejects an expired token with auth/id-token-expired', async () => {
206
+ const { auth } = sandboxAuth();
207
+ const expired = mintIdToken(idTokenClaims({ iat: nowSeconds() - 7200, exp: nowSeconds() - 3600 }));
208
+ await expect(auth.verifyIdToken(expired)).rejects.toMatchObject({
209
+ code: 'auth/id-token-expired',
210
+ message: `Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).${DOCS}`,
211
+ });
212
+ });
213
+
214
+ it('reads expiry on the sandbox clock', async () => {
215
+ const { sandbox, auth } = sandboxAuth();
216
+ const token = mintIdToken(idTokenClaims());
217
+ await expect(auth.verifyIdToken(token)).resolves.toMatchObject({ uid: 'alice' });
218
+ getClock(sandbox).advance(61 * 60 * 1000);
219
+ await expect(auth.verifyIdToken(token)).rejects.toMatchObject({ code: 'auth/id-token-expired' });
220
+ });
221
+
222
+ it('rejects a token that is not yet valid', async () => {
223
+ const { auth } = sandboxAuth();
224
+ const early = mintIdToken(idTokenClaims({ nbf: nowSeconds() + 3600 }));
225
+ await expect(auth.verifyIdToken(early)).rejects.toMatchObject(argumentError(BAD_SIGNATURE));
136
226
  });
137
227
 
228
+ it('rejects a token for another project', async () => {
229
+ const { auth } = sandboxAuth('demo-verify');
230
+ const other = mintIdToken(idTokenClaims({}, 'demo-other'));
231
+ await expect(auth.verifyIdToken(other)).rejects.toMatchObject(
232
+ argumentError(`Firebase ID token has incorrect "aud" (audience) claim. Expected "demo-verify" but got "demo-other".${PROJECT_MATCH}${DOCS}`),
233
+ );
234
+ });
235
+
236
+ it('checks the project against pyric-sandbox when the admin app names none', async () => {
237
+ const { auth } = sandboxAuth();
238
+ const other = mintIdToken(idTokenClaims({}, 'demo-other'));
239
+ await expect(auth.verifyIdToken(other)).rejects.toMatchObject({ code: 'auth/argument-error' });
240
+ });
241
+
242
+ it('rejects a token whose issuer names another project', async () => {
243
+ const { auth } = sandboxAuth('demo-verify');
244
+ const token = mintIdToken(idTokenClaims({ iss: 'https://securetoken.google.com/demo-other' }, 'demo-verify'));
245
+ await expect(auth.verifyIdToken(token)).rejects.toMatchObject(
246
+ argumentError(`Firebase ID token has incorrect "iss" (issuer) claim. Expected "https://securetoken.google.com/demo-verify" but got "https://securetoken.google.com/demo-other".${PROJECT_MATCH}${DOCS}`),
247
+ );
248
+ });
249
+
250
+ it('rejects a token with a missing, empty or over-long subject', async () => {
251
+ const { auth } = sandboxAuth();
252
+ const cases: Array<[unknown, string]> = [
253
+ [undefined, `Firebase ID token has no "sub" (subject) claim.${DOCS}`],
254
+ ['', `Firebase ID token has an empty "sub" (subject) claim.${DOCS}`],
255
+ ['x'.repeat(129), `Firebase ID token has a "sub" (subject) claim longer than 128 characters.${DOCS}`],
256
+ ];
257
+ for (const [sub, message] of cases) {
258
+ await expect(auth.verifyIdToken(mintIdToken(idTokenClaims({ sub })))).rejects.toMatchObject(argumentError(message));
259
+ }
260
+ await expect(auth.verifyIdToken(mintIdToken(idTokenClaims({ sub: 'x'.repeat(128) })))).resolves.toMatchObject({ uid: 'x'.repeat(128) });
261
+ });
262
+
263
+ it('rejects a token whose algorithm is not RS256', async () => {
264
+ const { auth } = sandboxAuth();
265
+ for (const alg of ['none', 'HS256']) {
266
+ const token = unsignedJwt({ alg, kid: 'pyric-sandbox', typ: 'JWT' }, idTokenClaims());
267
+ await expect(auth.verifyIdToken(token)).rejects.toMatchObject(
268
+ argumentError(`Firebase ID token has incorrect algorithm. Expected "RS256" but got "${alg}".${DOCS}`),
269
+ );
270
+ }
271
+ });
272
+
273
+ it('rejects a token whose kid names no sandbox key', async () => {
274
+ const { auth } = sandboxAuth();
275
+ const token = unsignedJwt({ alg: 'RS256', kid: 'another-key', typ: 'JWT' }, idTokenClaims());
276
+ await expect(auth.verifyIdToken(token)).rejects.toMatchObject(
277
+ argumentError('Firebase ID token has "kid" claim which does not correspond to a known public key. Most likely the ID token is expired, so get a fresh token from your client app and try again.'),
278
+ );
279
+ });
280
+
281
+ it('rejects a token with no kid, naming a custom token when it is one', async () => {
282
+ const { auth } = sandboxAuth();
283
+ const noKid = unsignedJwt({ alg: 'RS256', typ: 'JWT' }, idTokenClaims());
284
+ await expect(auth.verifyIdToken(noKid)).rejects.toMatchObject(argumentError(`Firebase ID token has no "kid" claim.${DOCS}`));
285
+ const customAudience = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';
286
+ const jwtCustomToken = unsignedJwt({ alg: 'RS256', typ: 'JWT' }, { aud: customAudience, uid: 'victim', sub: 'svc', iss: 'svc' });
287
+ await expect(auth.verifyIdToken(jwtCustomToken)).rejects.toMatchObject(
288
+ argumentError(`verifyIdToken() expects an ID token, but was given a custom token.${DOCS}`),
289
+ );
290
+ });
291
+
292
+ // #1836: a display name with a space or outside Latin-1 used to reach the token text.
293
+ for (const displayName of ['Builder One', '李']) {
294
+ it(`verifies the JWT a user named ${displayName} gets, as minted, through a Bearer header`, async () => {
295
+ const { getAuth: getClientAuth, signInWithEmailAndPassword, updateProfile, sandbox: clientAuthSandbox } = await import('pyric/auth');
296
+ const sandbox = initializeSandbox();
297
+ const clientAuth = getClientAuth(sandbox);
298
+ clientAuthSandbox.createUser(clientAuth, { email: 'builder@example.com', password: 'secret-pw' });
299
+ const { user } = await signInWithEmailAndPassword(clientAuth, 'builder@example.com', 'secret-pw');
300
+ await updateProfile(user, { displayName });
301
+ const idToken = await user.getIdToken(true);
302
+ const header = new Headers({ Authorization: `Bearer ${idToken}` }).get('Authorization') ?? '';
303
+ const decoded = await getAuth(initializeApp({ sandbox })).verifyIdToken(header.split(' ')[1] ?? '');
304
+ const { claims } = await user.getIdTokenResult();
305
+ expect(decoded).toEqual({ ...claims, uid: user.uid } as typeof decoded);
306
+ expect(decoded.name).toBe(displayName);
307
+ });
308
+ }
309
+
138
310
  it('verifies a signed-in client token with the account standard claims', async () => {
139
311
  const { getAuth: getClientAuth, signInWithEmailAndPassword, sandbox: clientAuthSandbox } = await import('pyric/auth');
140
312
  const sandbox = initializeSandbox();
@@ -161,13 +333,6 @@ describe('sandbox backend — createCustomToken / verifyIdToken roundtrip', () =
161
333
  });
162
334
  await expect(auth.setCustomUserClaims(user.uid, { name: 'allowed' })).resolves.toBeUndefined();
163
335
  });
164
-
165
- it('rejects client ID tokens with malformed claim JSON', async () => {
166
- const sandbox = initializeSandbox();
167
- const auth = getAuth(initializeApp({ sandbox }));
168
- const badToken = 'sandbox-id-token-bob-1:not-json';
169
- await expect(auth.verifyIdToken(badToken)).rejects.toThrow(/JSON/);
170
- });
171
336
  });
172
337
 
173
338
 
@@ -266,21 +431,21 @@ describe('sandbox backend — setCustomUserClaims persistence', () => {
266
431
  expect(fetched.customClaims).toBeUndefined();
267
432
  });
268
433
 
269
- it('claims minted via setCustomUserClaims surface through createCustomToken + verifyIdToken', async () => {
270
- // The token format is stateless (claims are baked in at mint time
271
- // by the caller, not pulled from the store), so this test is
272
- // really asserting the caller's pattern: read claims off the
273
- // UserRecord, pass them through to createCustomToken.
434
+ it('claims read off the UserRecord reach verifyIdToken through the ID token a custom-token sign-in gets', async () => {
435
+ // As in production, a custom token is exchanged for an ID token first,
436
+ // and only the ID token verifies (#1949). The caller's pattern: read
437
+ // claims off the UserRecord, put them in the custom token, sign in.
438
+ const { getAuth: getClientAuth, signInWithCustomToken } = await import('pyric/auth');
274
439
  const sandbox = initializeSandbox();
275
440
  const auth = getAuth(initializeApp({ sandbox }));
276
441
  await auth.createUser({ uid: 'alice' });
277
442
  await auth.setCustomUserClaims('alice', { role: 'admin' });
278
443
  const stored = await auth.getUser('alice');
279
- const token = await auth.createCustomToken(
280
- stored.uid,
281
- stored.customClaims ?? {},
282
- );
283
- const decoded = await auth.verifyIdToken(token);
444
+ const customToken = await auth.createCustomToken(stored.uid, stored.customClaims ?? {});
445
+ await expect(auth.verifyIdToken(customToken)).rejects.toMatchObject({ code: 'auth/argument-error' });
446
+ const { user } = await signInWithCustomToken(getClientAuth(sandbox), customToken);
447
+ const decoded = await auth.verifyIdToken(await user.getIdToken());
448
+ expect(decoded.uid).toBe('alice');
284
449
  expect(decoded.role).toBe('admin');
285
450
  });
286
451
 
@@ -339,7 +504,6 @@ describe('sandbox backend — explicitly-not-implemented surface', () => {
339
504
  'deleteUsers',
340
505
  'listUsers',
341
506
  'importUsers',
342
- 'revokeRefreshTokens',
343
507
  'createSessionCookie',
344
508
  'verifySessionCookie',
345
509
  'generatePasswordResetLink',
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A server mints a custom token with `createCustomToken`, the client exchanges
3
+ * it with `signInWithCustomToken`, and the server verifies the ID token the
4
+ * client then holds (#1952). firebase-admin 13.10.0 mints the token as an
5
+ * RS256 JWT for the identity toolkit audience, issued by the project's
6
+ * service account, with `uid` and the developer claims under `claims`
7
+ * (lib/auth/token-generator.js `createCustomToken`).
8
+ */
9
+ import { afterEach, describe, expect, it } from 'bun:test';
10
+ import { initializeSandbox } from 'pyric/sandbox';
11
+ import { getClock } from 'pyric/sandbox/internal';
12
+ import { getAuth as getClientAuth, signInWithCustomToken } from 'pyric/auth';
13
+ import { deleteApp, getApps, initializeApp } from '../app/index.js';
14
+ import { getAuth } from './index.js';
15
+
16
+ const IDENTITY_TOOLKIT = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit';
17
+
18
+ afterEach(async () => {
19
+ await Promise.all(getApps().map((app) => deleteApp(app)));
20
+ });
21
+
22
+ function payloadOf(token: string): Record<string, unknown> {
23
+ return JSON.parse(Buffer.from(token.split('.')[1] ?? '', 'base64url').toString('utf8')) as Record<string, unknown>;
24
+ }
25
+
26
+ describe('createCustomToken then signInWithCustomToken then verifyIdToken', () => {
27
+ it('mints a custom token in production shape', async () => {
28
+ const sandbox = initializeSandbox();
29
+ const admin = getAuth(initializeApp({ sandbox }, 'custom-token-shape'));
30
+ const token = await admin.createCustomToken('ada', { role: 'editor' });
31
+ const [header] = token.split('.');
32
+ expect(JSON.parse(Buffer.from(header ?? '', 'base64url').toString('utf8'))).toEqual({ alg: 'RS256', typ: 'JWT' });
33
+ const payload = payloadOf(token);
34
+ expect(payload).toMatchObject({ aud: IDENTITY_TOOLKIT, uid: 'ada', claims: { role: 'editor' } });
35
+ expect(payload.iss).toBe(payload.sub);
36
+ expect(Number(payload.exp) - Number(payload.iat)).toBe(3600);
37
+ });
38
+
39
+ it('signs the client in as the uid, with the developer claims in the ID token', async () => {
40
+ const sandbox = initializeSandbox();
41
+ const admin = getAuth(initializeApp({ sandbox }, 'custom-token-round-trip'));
42
+ const client = getClientAuth(sandbox);
43
+
44
+ const customToken = await admin.createCustomToken('ada', { role: 'editor' });
45
+ const { user } = await signInWithCustomToken(client, customToken);
46
+ const decoded = await admin.verifyIdToken(await user.getIdToken());
47
+
48
+ expect(user.uid).toBe('ada');
49
+ expect(decoded.uid).toBe('ada');
50
+ expect(decoded.role).toBe('editor');
51
+ expect(decoded.firebase.sign_in_provider).toBe('custom');
52
+ });
53
+
54
+ it('refuses the custom token itself at verifyIdToken, as production does', async () => {
55
+ const sandbox = initializeSandbox();
56
+ const admin = getAuth(initializeApp({ sandbox }, 'custom-token-not-id-token'));
57
+ const customToken = await admin.createCustomToken('ada');
58
+ await expect(admin.verifyIdToken(customToken)).rejects.toMatchObject({
59
+ code: 'auth/argument-error',
60
+ message: expect.stringContaining('was given a custom token'),
61
+ });
62
+ });
63
+
64
+ it('refuses a custom token whose claims were edited after signing', async () => {
65
+ const sandbox = initializeSandbox();
66
+ const admin = getAuth(initializeApp({ sandbox }, 'custom-token-forged'));
67
+ const [header, , signature] = (await admin.createCustomToken('ada')).split('.');
68
+ const forged = { ...payloadOf(await admin.createCustomToken('ada')), uid: 'victim', claims: { admin: true } };
69
+ const segment = Buffer.from(JSON.stringify(forged), 'utf8').toString('base64url');
70
+ await expect(signInWithCustomToken(getClientAuth(sandbox), `${header}.${segment}.${signature}`)).rejects.toMatchObject({
71
+ code: 'auth/invalid-custom-token',
72
+ });
73
+ });
74
+
75
+ it("refuses a custom token minted for another project with production's code", async () => {
76
+ const sandbox = initializeSandbox();
77
+ const admin = getAuth(initializeApp({ sandbox, projectId: 'demo-other-project' }, 'custom-token-other-project'));
78
+ const customToken = await admin.createCustomToken('ada');
79
+ await expect(signInWithCustomToken(getClientAuth(sandbox), customToken)).rejects.toMatchObject({
80
+ code: 'auth/custom-token-mismatch',
81
+ });
82
+ });
83
+
84
+ it('refuses a custom token an hour after it was minted, with the code the client SDK maps INVALID_CUSTOM_TOKEN to', async () => {
85
+ const sandbox = initializeSandbox();
86
+ const admin = getAuth(initializeApp({ sandbox }, 'custom-token-expired'));
87
+ const customToken = await admin.createCustomToken('ada');
88
+ getClock(sandbox).advance(60 * 60 * 1000);
89
+ await expect(signInWithCustomToken(getClientAuth(sandbox), customToken)).rejects.toMatchObject({
90
+ code: 'auth/invalid-custom-token',
91
+ });
92
+ });
93
+
94
+ it('refuses a malformed custom token', async () => {
95
+ const sandbox = initializeSandbox();
96
+ await expect(signInWithCustomToken(getClientAuth(sandbox), 'not-a-token')).rejects.toMatchObject({
97
+ code: 'auth/invalid-custom-token',
98
+ });
99
+ });
100
+ });