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.
package/src/auth/index.ts CHANGED
@@ -5,9 +5,10 @@
5
5
  * `app` argument is the branded handle from `pyric-admin/app`
6
6
  * ({@link PyricAdminApp}); only sandbox-branded apps enter this package.
7
7
  * The local sandbox path uses an in-memory store keyed off `app.sandbox`
8
- * and implements the core user-management subset below. Tokens are NOT
9
- * real JWTs — they are deterministic strings parsed by the same sandbox
10
- * backend. This is enough to exercise agent code paths that use
8
+ * and implements the core user-management subset below. Custom tokens are
9
+ * NOT real JWTs — they are deterministic strings. `verifyIdToken` checks the
10
+ * ID token JWTs pyric's Auth mints, as firebase-admin checks an ID token.
11
+ * This is enough to exercise agent code paths that use
11
12
  * `createUser` / `getUser` / `setCustomUserClaims` /
12
13
  * `createCustomToken` / `verifyIdToken`, not enough to model a real
13
14
  * identity platform.
@@ -24,11 +25,20 @@
24
25
  * Surface scope on the sandbox backend (what works):
25
26
  *
26
27
  * - {@link getAuth}
27
- * - `Auth.createCustomToken(uid, claims?)` — returns a deterministic
28
- * `pyric-sandbox-custom:${uid}:${json}` string; no signing.
29
- * - `Auth.verifyIdToken(token)` — parses tokens minted by
30
- * `createCustomToken`; returns a {@link DecodedIdToken}-shaped
31
- * object.
28
+ * - `Auth.createCustomToken(uid, claims?)` — returns a custom token in
29
+ * firebase-admin's JWT shape for the app's project, signed by the
30
+ * sandbox, which pyric's `signInWithCustomToken` exchanges (#1952).
31
+ * - `Auth.verifyIdToken(token, checkRevoked?)` — verifies an ID token
32
+ * pyric's Auth minted for the app's project (the app's project ID, or
33
+ * `pyric-sandbox` when it names none), with production's error codes;
34
+ * returns a {@link DecodedIdToken}. A custom token is refused with
35
+ * `auth/argument-error`, as in production (#1949). With
36
+ * `checkRevoked`, the client account behind the token must exist
37
+ * (`auth/user-not-found`), be enabled (`auth/user-disabled`), and have
38
+ * no revocation after the token's `auth_time` (`auth/id-token-revoked`).
39
+ * - `Auth.revokeRefreshTokens(uid)` — revokes the client account's
40
+ * refresh tokens: its held sessions fail their next refresh, and
41
+ * `verifyIdToken(token, true)` refuses tokens authenticated before it.
32
42
  * - `Auth.createUser(properties)` — stores a {@link UserRecord}
33
43
  * in an in-memory `Map<uid, UserRecord>`. Auto-generates a `uid`
34
44
  * when one is not supplied.
@@ -54,7 +64,6 @@
54
64
  * `generateSignInWithEmailLink`, `generateVerifyAndChangeEmailLink`.
55
65
  * - Bulk operations: `listUsers`, `getUsers`, `deleteUsers`,
56
66
  * `importUsers`.
57
- * - Revocation: `revokeRefreshTokens`.
58
67
  * - `getUserByPhoneNumber`, `getUserByProviderUid`.
59
68
  * - `updateUser` — not required by the brief.
60
69
  *
@@ -67,7 +76,14 @@ import {
67
76
  type Sandbox,
68
77
  type SandboxEvent,
69
78
  } from 'pyric/sandbox';
70
- import { assertNoReservedCustomClaims } from 'pyric/sandbox/internal';
79
+ import {
80
+ assertNoReservedCustomClaims,
81
+ authAccountsFor,
82
+ getClock,
83
+ mintCustomToken,
84
+ SANDBOX_PROJECT_ID,
85
+ type AuthAccountStanding,
86
+ } from 'pyric/sandbox/internal';
71
87
  import type {
72
88
  AuthUserRecord,
73
89
  CreateUserRequest as SandboxCreateUserRequest,
@@ -79,6 +95,7 @@ import {
79
95
  type PyricAdminApp,
80
96
  } from '../app/index.js';
81
97
  import { assertAdminAppActive } from '../app/lifecycle.js';
98
+ import { explicitProjectId } from '../app/project-id.js';
82
99
  import type {
83
100
  Auth,
84
101
  CreateRequest,
@@ -87,19 +104,17 @@ import type {
87
104
  UpdateRequest,
88
105
  UserRecord,
89
106
  } from './types.js';
90
- import { SANDBOX_TOKEN_PREFIX } from './token-decoders.js';
91
- import { verifySandboxIdToken } from './verify-token.js';
107
+ import { assertIdTokenArgument, assertNotRevokedOrDisabled, invalidUid, userNotFound, verifySandboxIdToken } from './verify-token.js';
92
108
 
93
109
  export * from './types.js';
94
- export { SANDBOX_TOKEN_PREFIX } from './token-decoders.js';
95
110
 
96
111
  // ─── Sandbox backend ────────────────────────────────────────────────────
97
112
 
98
113
  /**
99
114
  * Per-sandbox in-memory store. One instance per `Sandbox` (tracked in
100
115
  * the {@link sandboxStores} WeakMap below). Holds the user table; the
101
- * token format is stateless (`createCustomToken` mints, `verifyIdToken`
102
- * parses) so it doesn't need to live here.
116
+ * tokens are stateless (`createCustomToken` mints, `verifyIdToken`
117
+ * checks) so they don't need to live here.
103
118
  *
104
119
  * `usersByUid` is the canonical index. `getUserByEmail` does a linear
105
120
  * scan over its values — the sandbox is for development and agent test
@@ -174,15 +189,14 @@ function storeFor(sandbox: Sandbox): AuthStore {
174
189
  }
175
190
 
176
191
  /**
177
- * Mint a deterministic sandbox token (the {@link SANDBOX_TOKEN_PREFIX}
178
- * format). Stateless — shared verbatim by the local and remote sandbox
179
- * arms, so a token minted against either round-trips through
180
- * verifySandboxIdToken on the other.
192
+ * Mint a sandbox custom token for the app's project at `nowMs`, in
193
+ * firebase-admin's shape (`pyric/sandbox/internal` `mintCustomToken`).
194
+ * Stateless, shared by the local and remote sandbox arms. Like production's
195
+ * custom token, it is not an ID token, and `verifyIdToken` refuses it.
181
196
  */
182
- function mintSandboxCustomToken(uid: string, developerClaims?: object): Promise<string> {
183
- const claims = developerClaims ?? {};
184
- const token = `${SANDBOX_TOKEN_PREFIX}:${uid}:${JSON.stringify(claims)}`;
185
- return Promise.resolve(token);
197
+ function mintSandboxCustomToken(app: PyricAdminApp, nowMs: number, uid: string, developerClaims?: object): Promise<string> {
198
+ const claims = developerClaims as Record<string, unknown> | undefined;
199
+ return Promise.resolve(mintCustomToken({ uid, claims, projectId: idTokenProjectId(app), nowMs }));
186
200
  }
187
201
 
188
202
  /**
@@ -199,7 +213,26 @@ function mintSandboxCustomToken(uid: string, developerClaims?: object): Promise<
199
213
  * cast acknowledges the divergence in one place and lets the rest of
200
214
  * the file focus on the methods that actually work.
201
215
  */
202
- function makeSandboxAuth(sandbox: Sandbox): Auth {
216
+ /**
217
+ * The project an ID token must name for `app`: firebase-admin's explicit
218
+ * project ID, or `pyric-sandbox`, which pyric's Auth mints for when it knows
219
+ * no project. Production throws `auth/invalid-credential` with no project;
220
+ * the sandbox checks against `pyric-sandbox` instead, by design.
221
+ */
222
+ function idTokenProjectId(app: PyricAdminApp): string {
223
+ return explicitProjectId(app) ?? SANDBOX_PROJECT_ID;
224
+ }
225
+
226
+ /**
227
+ * Where the client account with `uid` stands, from the account table the
228
+ * sandbox's client sign-ins write to. A sandbox no client Auth has touched
229
+ * has no accounts.
230
+ */
231
+ function clientAccountStanding(sandbox: Sandbox, uid: string): AuthAccountStanding {
232
+ return authAccountsFor(sandbox)?.standing(uid) ?? { kind: 'unrecorded' };
233
+ }
234
+
235
+ function makeSandboxAuth(app: PyricAdminApp, sandbox: Sandbox): Auth {
203
236
  const store = storeFor(sandbox);
204
237
 
205
238
  /** Canonical "not implemented" error for surface that the sandbox
@@ -259,22 +292,22 @@ function makeSandboxAuth(sandbox: Sandbox): Auth {
259
292
  },
260
293
 
261
294
  /**
262
- * Mint a deterministic sandbox token. Format is fixed at
263
- * `${SANDBOX_TOKEN_PREFIX}:${uid}:${JSON.stringify(claims ?? {})}`.
264
- * Round-trips through {@link verifyIdToken} on the same sandbox
265
- * backend; rejected by every other token verifier. Shared
266
- * implementation: {@link mintSandboxCustomToken}.
295
+ * Mint a custom token for the app's project on the sandbox clock. Not
296
+ * an ID token: {@link verifyIdToken} refuses it, as production does.
297
+ * Shared implementation: {@link mintSandboxCustomToken}.
267
298
  */
268
299
  createCustomToken(uid: string, developerClaims?: object): Promise<string> {
269
- return mintSandboxCustomToken(uid, developerClaims);
300
+ return mintSandboxCustomToken(app, getClock(sandbox).now(), uid, developerClaims);
270
301
  },
271
302
 
272
303
  /**
273
- * Parse a token minted by {@link createCustomToken}. Shared
274
- * implementation: {@link verifySandboxIdToken}.
304
+ * Verify an ID token for the app's project, reading `exp` and `nbf` on
305
+ * the sandbox clock. Shared implementation: {@link verifySandboxIdToken}.
275
306
  */
276
- verifyIdToken(idToken: string, _checkRevoked?: boolean): Promise<DecodedIdToken> {
277
- return verifySandboxIdToken(idToken);
307
+ verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
308
+ const verified = verifySandboxIdToken(idToken, { projectId: idTokenProjectId(app), nowMs: getClock(sandbox).now() });
309
+ if (!checkRevoked) return verified;
310
+ return verified.then((decoded) => assertNotRevokedOrDisabled(decoded, clientAccountStanding(sandbox, decoded.sub)));
278
311
  },
279
312
 
280
313
  /**
@@ -402,8 +435,20 @@ function makeSandboxAuth(sandbox: Sandbox): Auth {
402
435
  importUsers(): Promise<unknown> {
403
436
  return Promise.reject(notImplemented('importUsers'));
404
437
  },
405
- revokeRefreshTokens(): Promise<void> {
406
- return Promise.reject(notImplemented('revokeRefreshTokens'));
438
+ /**
439
+ * Revoke the client account's refresh tokens at the sandbox clock's now,
440
+ * through the shared foundation's account table. A uid that is not a
441
+ * non-empty string of at most 128 characters rejects `auth/invalid-uid`,
442
+ * and a uid with no client record `auth/user-not-found`, as production's do.
443
+ */
444
+ revokeRefreshTokens(uid: string): Promise<void> {
445
+ const uidError = invalidUid(uid);
446
+ if (uidError !== null) return Promise.reject(uidError);
447
+ const standing = clientAccountStanding(sandbox, uid);
448
+ const hasRecord = standing.kind === 'active' || standing.kind === 'disabled';
449
+ if (!hasRecord) return Promise.reject(userNotFound());
450
+ authAccountsFor(sandbox)?.revokeRefreshTokens(uid);
451
+ return Promise.resolve();
407
452
  },
408
453
  createSessionCookie(): Promise<string> {
409
454
  return Promise.reject(notImplemented('createSessionCookie'));
@@ -465,10 +510,14 @@ function makeSandboxAuth(sandbox: Sandbox): Auth {
465
510
  // dedicated single-lookup op, and O(n) over the wire is fine at sandbox
466
511
  // scale (per the design spike — add an op if it ever matters).
467
512
  //
468
- // Tokens stay stateless and local: `createCustomToken` / `verifyIdToken`
469
- // are the same string transforms as the local arm
470
- // ({@link mintSandboxCustomToken} / {@link verifySandboxIdToken}), so a
471
- // token minted server-side verifies against any pyric-admin backend.
513
+ // Tokens are minted and checked in this process with the local arm's
514
+ // transforms ({@link mintSandboxCustomToken} / {@link verifySandboxIdToken}),
515
+ // at the instant the worker's sandbox clock reads (`sandbox.clock`), so after
516
+ // `advanceClock` both arms agree on `iat`, `exp` and expiry (#2010). The
517
+ // clock is read on every call rather than cached: Studio, the CLI, an agent
518
+ // or the page can move it at any moment, and the bridge relays no clock
519
+ // updates to this process, so a cached offset would go stale at exactly the
520
+ // advance a test makes.
472
521
 
473
522
  /** Map a firebase-admin `CreateRequest` onto the worker's sandbox
474
523
  * create-user request. `null`s (upstream "clear") become "unset" — a
@@ -535,7 +584,7 @@ function fromAuthUserRecord(r: AuthUserRecord): UserRecord {
535
584
  * - User mutations emit auth `SandboxEvent`s in the worker (visible to
536
585
  * Studio/agents) and are visible to the browser app immediately.
537
586
  */
538
- function makeRemoteAuth(sandbox: RemoteSandbox): Auth {
587
+ function makeRemoteAuth(app: PyricAdminApp, sandbox: RemoteSandbox): Auth {
539
588
  const channel = sandbox.channel;
540
589
 
541
590
  const notImplemented = (method: string): Error =>
@@ -546,20 +595,46 @@ function makeRemoteAuth(sandbox: RemoteSandbox): Auth {
546
595
  const listRecords = async (): Promise<AuthUserRecord[]> =>
547
596
  (await channel.op({ method: 'auth.listUsers' })) as AuthUserRecord[];
548
597
 
598
+ /** The worker's sandbox clock now, in epoch milliseconds. */
599
+ const sandboxNow = async (): Promise<number> =>
600
+ ((await channel.op({ method: 'sandbox.clock' })) as { now: number }).now;
601
+
602
+ /**
603
+ * Where the account stands, from the worker's user pool. The worker relays
604
+ * no revocation time (this arm has no `revokeRefreshTokens`), so a token is
605
+ * refused here only for a missing or disabled account.
606
+ */
607
+ const relayedStanding = async (uid: string): Promise<AuthAccountStanding> => {
608
+ const record = (await listRecords()).find((u) => u.uid === uid);
609
+ if (record === undefined) return { kind: 'unrecorded' };
610
+ if (record.disabled) return { kind: 'disabled' };
611
+ return { kind: 'active', tokensValidAfterMs: null };
612
+ };
613
+
549
614
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
550
615
  const handle: any = {
551
616
  get app() {
552
617
  throw notImplemented('auth.app');
553
618
  },
554
619
 
555
- /** Stateless mint — identical to the local arm; needs no relay. */
556
- createCustomToken(uid: string, developerClaims?: object): Promise<string> {
557
- return mintSandboxCustomToken(uid, developerClaims);
620
+ /** The local arm's mint for the app's project, at the worker's sandbox clock. */
621
+ async createCustomToken(uid: string, developerClaims?: object): Promise<string> {
622
+ return mintSandboxCustomToken(app, await sandboxNow(), uid, developerClaims);
558
623
  },
559
624
 
560
- /** Stateless parse — identical to the local arm; needs no relay. */
561
- verifyIdToken(idToken: string, _checkRevoked?: boolean): Promise<DecodedIdToken> {
562
- return verifySandboxIdToken(idToken);
625
+ /**
626
+ * The local arm's token check, for the app's project, with expiry read on
627
+ * the worker's sandbox clock. A token that is not a string throws before
628
+ * the clock is read, as the local arm throws. With `checkRevoked`, the
629
+ * account's standing is relayed from the worker's user pool
630
+ * ({@link relayedStanding}).
631
+ */
632
+ verifyIdToken(idToken: string, checkRevoked = false): Promise<DecodedIdToken> {
633
+ assertIdTokenArgument(idToken);
634
+ const projectId = idTokenProjectId(app);
635
+ const verified = sandboxNow().then((nowMs) => verifySandboxIdToken(idToken, { projectId, nowMs }));
636
+ if (!checkRevoked) return verified;
637
+ return verified.then(async (decoded) => assertNotRevokedOrDisabled(decoded, await relayedStanding(decoded.sub)));
563
638
  },
564
639
 
565
640
  /** Relays `auth.adminCreateUser`. Uid conflicts / invalid emails /
@@ -666,7 +741,9 @@ function makeRemoteAuth(sandbox: RemoteSandbox): Auth {
666
741
  importUsers(): Promise<unknown> {
667
742
  return Promise.reject(notImplemented('importUsers'));
668
743
  },
669
- revokeRefreshTokens(): Promise<void> {
744
+ revokeRefreshTokens(uid: string): Promise<void> {
745
+ const uidError = invalidUid(uid);
746
+ if (uidError !== null) return Promise.reject(uidError);
670
747
  return Promise.reject(notImplemented('revokeRefreshTokens'));
671
748
  },
672
749
  createSessionCookie(): Promise<string> {
@@ -732,9 +809,11 @@ function makeRemoteAuth(sandbox: RemoteSandbox): Auth {
732
809
  * const auth = getAuth(app);
733
810
  *
734
811
  * const user = await auth.createUser({ uid: 'alice', email: 'a@e.com' });
735
- * const token = await auth.createCustomToken(user.uid, { role: 'admin' });
736
- * const decoded = await auth.verifyIdToken(token);
737
- * console.log(decoded.uid, decoded.role); // 'alice' 'admin'
812
+ * await auth.setCustomUserClaims(user.uid, { role: 'admin' });
813
+ *
814
+ * // An ID token from a client signed in on the same sandbox.
815
+ * const decoded = await auth.verifyIdToken(idTokenFromTheClient);
816
+ * console.log(decoded.uid);
738
817
  * ```
739
818
  */
740
819
  export function getAuth(app?: PyricAdminApp): Auth {
@@ -755,6 +834,6 @@ export function getAuth(app?: PyricAdminApp): Auth {
755
834
  throw new TypeError('pyric-admin/auth: getAuth expected a sandbox admin app.');
756
835
  }
757
836
  return isRemoteSandbox(app.sandbox)
758
- ? makeRemoteAuth(app.sandbox)
759
- : makeSandboxAuth(app.sandbox);
837
+ ? makeRemoteAuth(app, app.sandbox)
838
+ : makeSandboxAuth(app, app.sandbox);
760
839
  }
@@ -11,7 +11,8 @@
11
11
  import { afterEach, describe, expect, it } from 'bun:test';
12
12
  import { readFileSync } from 'node:fs';
13
13
  import { initializeSandbox } from 'pyric/sandbox';
14
- import { createUserWithEmailAndPassword, getAuth as getClientAuth, sandbox as clientSandbox } from 'pyric/auth';
14
+ import { getClock } from 'pyric/sandbox/internal';
15
+ import { createUserWithEmailAndPassword, getAuth as getClientAuth, sandbox as clientSandbox, signInWithEmailAndPassword } from 'pyric/auth';
15
16
  import { deleteApp, getApps, initializeApp } from '../app/index.js';
16
17
  import { getAuth } from './index.js';
17
18
 
@@ -59,9 +60,6 @@ const PINS: Record<string, { pyric: string; production: string; finding: string
59
60
  createCustomToken129Uid: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
60
61
  createCustomTokenReservedAud: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
61
62
  createCustomTokenNullClaims: { pyric: 'resolved', production: 'throws auth/argument-error', finding: 'auth-claims AU-4' },
62
- verifyIdTokenOfCustomToken: { pyric: 'resolved', production: 'rejects auth/argument-error', finding: 'auth-claims AU-9: verifyIdToken accepts a custom token' },
63
- verifyIdTokenCheckRevokedDisabled: { pyric: 'resolved', production: 'rejects auth/user-disabled', finding: 'auth-claims AU-10: checkRevoked ignores disabled and deleted users' },
64
- verifyIdTokenCheckRevokedDeleted: { pyric: 'resolved', production: 'rejects auth/user-not-found', finding: 'auth-claims AU-10' },
65
63
  };
66
64
 
67
65
  describe('pyric-admin auth answers as the production Admin SDK does', () => {
@@ -111,4 +109,42 @@ describe('pyric-admin auth answers as the production Admin SDK does', () => {
111
109
  const pinned = Object.fromEntries(Object.entries(PINS).map(([call, pin]) => [call, { pyric: pin.pyric, production: pin.production }]));
112
110
  expect(found).toEqual(pinned);
113
111
  });
112
+
113
+ it('verifyIdToken with checkRevoked refuses a token issued before revokeRefreshTokens', async () => {
114
+ const sandbox = initializeSandbox();
115
+ const admin = getAuth(initializeApp({ sandbox }, 'oracle-admin-revoked'));
116
+ const client = getClientAuth(sandbox);
117
+ const { user } = await createUserWithEmailAndPassword(client, 'revoked@example.com', 'secret-pw');
118
+ const idToken = await user.getIdToken();
119
+ // firebase-admin compares whole seconds: a token authenticated in the
120
+ // revocation's second is still valid.
121
+ getClock(sandbox).advance(2000);
122
+ await admin.revokeRefreshTokens(user.uid);
123
+
124
+ expect(await outcome(() => admin.verifyIdToken(idToken))).toBe('resolved');
125
+ expect(await outcome(() => admin.verifyIdToken(idToken, true))).toBe('rejects auth/id-token-revoked');
126
+ // The held client session's next refresh is refused, and it signs out.
127
+ expect(await outcome(() => user.getIdToken(true))).toBe('rejects auth/user-token-expired');
128
+ expect(client.currentUser).toBeNull();
129
+ // A new sign-in authenticates after the revocation, so its token is valid.
130
+ const { user: again } = await signInWithEmailAndPassword(client, 'revoked@example.com', 'secret-pw');
131
+ expect(await outcome(async () => admin.verifyIdToken(await again.getIdToken(), true))).toBe('resolved');
132
+ });
133
+
134
+ it('revokeRefreshTokens rejects auth/user-not-found for a uid with no account', async () => {
135
+ const sandbox = initializeSandbox();
136
+ const admin = getAuth(initializeApp({ sandbox }, 'oracle-admin-revoke-missing'));
137
+ getClientAuth(sandbox);
138
+ expect(await outcome(() => admin.revokeRefreshTokens('oracle-missing'))).toBe('rejects auth/user-not-found');
139
+ });
140
+
141
+ it('revokeRefreshTokens rejects auth/invalid-uid for an empty or overlong uid before looking it up', async () => {
142
+ const sandbox = initializeSandbox();
143
+ const admin = getAuth(initializeApp({ sandbox }, 'oracle-admin-revoke-invalid-uid'));
144
+ getClientAuth(sandbox);
145
+ expect(await outcome(() => admin.revokeRefreshTokens(''))).toBe('rejects auth/invalid-uid');
146
+ expect(await outcome(() => admin.revokeRefreshTokens('u'.repeat(129)))).toBe('rejects auth/invalid-uid');
147
+ const message = await admin.revokeRefreshTokens('').catch((e: Error) => e.message);
148
+ expect(message).toBe('The uid must be a non-empty string with at most 128 characters.');
149
+ });
114
150
  });
@@ -1,158 +1,115 @@
1
1
  /**
2
- * Modular token decoders for `pyric-admin/auth` verification.
3
- * Each decoder strategy isolates prefix detection and claim decoding
4
- * for one recognized token format.
2
+ * The ID token check behind `pyric-admin/auth` `verifyIdToken`.
3
+ *
4
+ * The only token that verifies is the JWT pyric's Auth mints
5
+ * (`pyric/sandbox/internal` `mintIdToken`), checked by the shared
6
+ * {@link checkIdToken} and then by firebase-admin 13.10.0's rules, in its
7
+ * order (lib/auth/token-verifier.js `verifyContent` and `verifySignature`,
8
+ * lib/utils/jwt.js, and jsonwebtoken's `nbf` and `exp`). A plain string, or
9
+ * the client token shape from before #1836, fails to decode, and a custom
10
+ * token is refused as one, as in production (#1949, #1952).
5
11
  */
12
+ import { checkIdToken, CUSTOM_TOKEN_AUDIENCE } from 'pyric/sandbox/internal';
6
13
  import type { DecodedIdToken } from './types.js';
7
14
 
8
- /**
9
- * Token format minted by `createCustomToken` and parsed by
10
- * `verifyIdToken`. Exported as a constant so tests can lock the shape.
11
- *
12
- * Layout: `pyric-sandbox-custom:${uid}:${jsonClaims}`
13
- *
14
- * - The prefix lets `verifyIdToken` reject foreign tokens with a clear
15
- * "not a sandbox token" error rather than NaN'ing out.
16
- * - `uid` is colon-free per the auto-uid format above.
17
- * - `jsonClaims` is the JSON-stringified developer claims (or `{}` when
18
- * none were provided). Round-trips losslessly through `JSON.parse`.
19
- *
20
- * NOT a JWT. NOT signed. Do not use this token format to talk to any
21
- * real Firebase service — it only round-trips through this same
22
- * sandbox backend.
23
- */
24
- export const SANDBOX_TOKEN_PREFIX = 'pyric-sandbox-custom';
15
+ const ID_TOKEN_ISSUER = 'https://securetoken.google.com/';
16
+ const MAX_SUBJECT_LENGTH = 128;
25
17
 
26
- export interface TokenDecoder {
27
- readonly name: string;
28
- readonly prefix: string;
29
- decode(token: string, nowSec: number): DecodedIdToken;
30
- }
18
+ const DOCS = ' See https://firebase.google.com/docs/auth/admin/verify-id-tokens for details on how to retrieve an ID token.';
19
+ const PROJECT_MATCH = ' Make sure the ID token comes from the same Firebase project as the service account used to authenticate this SDK.';
20
+
21
+ const MESSAGES = {
22
+ decode: `Decoding Firebase ID token failed. Make sure you passed the entire string JWT which represents an ID token.${DOCS}`,
23
+ customToken: `verifyIdToken() expects an ID token, but was given a custom token.${DOCS}`,
24
+ legacyCustomToken: `verifyIdToken() expects an ID token, but was given a legacy custom token.${DOCS}`,
25
+ noKid: `Firebase ID token has no "kid" claim.${DOCS}`,
26
+ algorithm: (alg: unknown) => `Firebase ID token has incorrect algorithm. Expected "RS256" but got "${String(alg)}".${DOCS}`,
27
+ audience: (projectId: string, aud: unknown) =>
28
+ `Firebase ID token has incorrect "aud" (audience) claim. Expected "${projectId}" but got "${String(aud)}".${PROJECT_MATCH}${DOCS}`,
29
+ issuer: (projectId: string, iss: unknown) =>
30
+ `Firebase ID token has incorrect "iss" (issuer) claim. Expected "${ID_TOKEN_ISSUER}${projectId}" but got "${String(iss)}".${PROJECT_MATCH}${DOCS}`,
31
+ noSub: `Firebase ID token has no "sub" (subject) claim.${DOCS}`,
32
+ emptySub: `Firebase ID token has an empty "sub" (subject) claim.${DOCS}`,
33
+ longSub: `Firebase ID token has a "sub" (subject) claim longer than 128 characters.${DOCS}`,
34
+ unknownKid:
35
+ '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.',
36
+ signature: `Firebase ID token has invalid signature.${DOCS}`,
37
+ expired: `Firebase ID token has expired. Get a fresh ID token from your client app and try again (auth/id-token-expired).${DOCS}`,
38
+ };
39
+
40
+ /** The two codes an ID token check fails with. */
41
+ export type IdTokenFailureCode = 'argument-error' | 'id-token-expired';
31
42
 
32
- function resolveStringClaim(claims: Record<string, unknown>, key: string, fallback: string): string {
33
- const val = claims[key];
34
- const hasStringValue = typeof val === 'string';
35
- return hasStringValue ? val : fallback;
43
+ export type IdTokenVerdict =
44
+ | { ok: true; token: DecodedIdToken }
45
+ | { ok: false; code: IdTokenFailureCode; message: string };
46
+
47
+ type Claims = Readonly<Record<string, unknown>>;
48
+
49
+ function refused(message: string): IdTokenVerdict {
50
+ return { ok: false, code: 'argument-error', message };
36
51
  }
37
52
 
38
- function resolveNumberClaim(claims: Record<string, unknown>, key: string, fallback: number): number {
39
- const val = claims[key];
40
- const hasNumberValue = typeof val === 'number';
41
- return hasNumberValue ? val : fallback;
53
+ /** token-verifier.js: a missing `kid` names a custom token, a legacy custom token, or neither. */
54
+ function missingKidMessage(header: Claims, claims: Claims): string {
55
+ if (claims.aud === CUSTOM_TOKEN_AUDIENCE) return MESSAGES.customToken;
56
+ const legacyData = claims.d;
57
+ const isLegacyCustomToken =
58
+ header.alg === 'HS256' && claims.v === 0 && typeof legacyData === 'object' && legacyData !== null && 'uid' in legacyData;
59
+ if (isLegacyCustomToken) return MESSAGES.legacyCustomToken;
60
+ return MESSAGES.noKid;
42
61
  }
43
62
 
44
- function resolveUid(idToken: string, firstColon: number, claims: Record<string, unknown>): string {
45
- const rawSub = claims['sub'];
46
- const hasExplicitSubjectClaim = typeof rawSub === 'string';
47
- if (hasExplicitSubjectClaim) {
48
- return rawSub;
49
- }
50
- const prefixSegment = idToken.slice('sandbox-id-token-'.length, firstColon);
51
- const lastHyphen = prefixSegment.lastIndexOf('-');
52
- const hasHyphenatedSerialSuffix = lastHyphen > 0;
53
- if (hasHyphenatedSerialSuffix) {
54
- return prefixSegment.slice(0, lastHyphen);
55
- }
56
- return prefixSegment;
63
+ /** Auth blocking events with no user record, whose `sub` firebase-admin leaves unchecked. */
64
+ function skipsSubjectCheck(claims: Claims): boolean {
65
+ return claims.event_type === 'beforeSendSms' || claims.event_type === 'beforeSendEmail';
57
66
  }
58
67
 
59
- function resolveFirebaseClaim(
60
- claims: Record<string, unknown>,
61
- ): { identities: Record<string, unknown>; sign_in_provider: string } {
62
- const rawFirebase = claims['firebase'];
63
- const isMissingFirebaseClaim = !rawFirebase || typeof rawFirebase !== 'object';
64
- if (isMissingFirebaseClaim) {
65
- return { identities: {}, sign_in_provider: 'custom' };
66
- }
67
- const fb = rawFirebase as Record<string, unknown>;
68
- const signInProvider = resolveStringClaim(fb, 'sign_in_provider', 'custom');
69
- const rawIdentities = fb['identities'];
70
- const isMissingIdentitiesMap = !rawIdentities || typeof rawIdentities !== 'object';
71
- if (isMissingIdentitiesMap) {
72
- return { identities: {}, sign_in_provider: signInProvider };
73
- }
74
- return { identities: rawIdentities as Record<string, unknown>, sign_in_provider: signInProvider };
68
+ function subjectMessage(sub: unknown): string | undefined {
69
+ if (typeof sub !== 'string') return MESSAGES.noSub;
70
+ if (sub === '') return MESSAGES.emptySub;
71
+ if (sub.length > MAX_SUBJECT_LENGTH) return MESSAGES.longSub;
72
+ return undefined;
75
73
  }
76
74
 
77
- const customTokenDecoder: TokenDecoder = {
78
- name: 'custom-token',
79
- prefix: `${SANDBOX_TOKEN_PREFIX}:`,
80
- decode(idToken: string, nowSec: number): DecodedIdToken {
81
- const firstColon = idToken.indexOf(':');
82
- const secondColon = idToken.indexOf(':', firstColon + 1);
83
- const isMissingClaimsSegment = secondColon < 0;
84
- if (isMissingClaimsSegment) {
85
- throw new Error(
86
- `pyric-admin/auth: verifyIdToken received a malformed sandbox token (missing claims segment): ${idToken}`,
87
- );
88
- }
89
- const uid = idToken.slice(firstColon + 1, secondColon);
90
- const jsonClaims = idToken.slice(secondColon + 1);
91
- let claims: Record<string, unknown>;
92
- try {
93
- claims = JSON.parse(jsonClaims) as Record<string, unknown>;
94
- } catch (e) {
95
- throw new Error(
96
- `pyric-admin/auth: verifyIdToken failed to parse sandbox token claims as JSON: ${(e as Error).message}`,
97
- );
98
- }
99
- return {
100
- aud: 'pyric-sandbox',
101
- auth_time: nowSec,
102
- exp: nowSec + 3600,
103
- firebase: {
104
- identities: {},
105
- sign_in_provider: 'custom',
106
- },
107
- iat: nowSec,
108
- iss: 'pyric-sandbox',
109
- sub: uid,
110
- uid,
111
- ...claims,
112
- };
113
- },
114
- };
75
+ /** token-verifier.js `verifyContent`: `kid`, `alg`, `aud`, `iss`, then `sub`; the first failure wins. */
76
+ function contentMessage(header: Claims, claims: Claims, projectId: string): string | undefined {
77
+ if (header.kid === undefined) return missingKidMessage(header, claims);
78
+ if (header.alg !== 'RS256') return MESSAGES.algorithm(header.alg);
79
+ if (claims.aud !== projectId) return MESSAGES.audience(projectId, claims.aud);
80
+ if (claims.iss !== `${ID_TOKEN_ISSUER}${projectId}`) return MESSAGES.issuer(projectId, claims.iss);
81
+ if (skipsSubjectCheck(claims)) return undefined;
82
+ return subjectMessage(claims.sub);
83
+ }
115
84
 
116
- const clientIdTokenDecoder: TokenDecoder = {
117
- name: 'client-id-token',
118
- prefix: 'sandbox-id-token-',
119
- decode(idToken: string, nowSec: number): DecodedIdToken {
120
- const firstColon = idToken.indexOf(':');
121
- const isMissingClaimsSegment = firstColon < 0;
122
- if (isMissingClaimsSegment) {
123
- throw new Error(
124
- `pyric-admin/auth: verifyIdToken received a malformed client ID token (missing claims segment after colon): ${idToken}`,
125
- );
126
- }
127
- const jsonClaims = idToken.slice(firstColon + 1);
128
- let claims: Record<string, unknown>;
129
- try {
130
- claims = JSON.parse(jsonClaims) as Record<string, unknown>;
131
- } catch (e) {
132
- throw new Error(
133
- `pyric-admin/auth: verifyIdToken failed to parse client ID token claims as JSON: ${(e as Error).message}`,
134
- );
135
- }
136
- const uid = resolveUid(idToken, firstColon, claims);
137
- const aud = resolveStringClaim(claims, 'aud', 'pyric-sandbox');
138
- const iss = resolveStringClaim(claims, 'iss', 'https://sandbox.pyric.dev');
139
- const exp = resolveNumberClaim(claims, 'exp', nowSec + 3600);
140
- const iat = resolveNumberClaim(claims, 'iat', nowSec);
141
- const authTime = resolveNumberClaim(claims, 'auth_time', nowSec);
142
- const firebase = resolveFirebaseClaim(claims);
85
+ /** jsonwebtoken's `nbf` rule, which runs before `exp`; firebase-admin reports a failure as an invalid signature. */
86
+ function isNotYetValid(nbf: unknown, nowSec: number): boolean {
87
+ if (nbf === undefined) return false;
88
+ return typeof nbf !== 'number' || nbf > nowSec;
89
+ }
143
90
 
144
- return {
145
- ...claims,
146
- aud,
147
- auth_time: authTime,
148
- exp,
149
- firebase,
150
- iat,
151
- iss,
152
- sub: uid,
153
- uid,
154
- };
155
- },
156
- };
91
+ /** jsonwebtoken's `exp` rule: an absent `exp` never expires, and a non-number is an invalid signature. */
92
+ function expiryVerdict(exp: unknown, nowSec: number): IdTokenVerdict | undefined {
93
+ if (exp === undefined) return undefined;
94
+ if (typeof exp !== 'number') return refused(MESSAGES.signature);
95
+ const hasExpired = nowSec >= exp;
96
+ if (hasExpired) return { ok: false, code: 'id-token-expired', message: MESSAGES.expired };
97
+ return undefined;
98
+ }
157
99
 
158
- export const TOKEN_DECODERS: TokenDecoder[] = [customTokenDecoder, clientIdTokenDecoder];
100
+ /**
101
+ * Checks an ID token for `projectId` at `nowSec`, in firebase-admin's order:
102
+ * decoding, the content rules, the key and signature, then `nbf` and `exp`.
103
+ */
104
+ export async function checkClientIdToken(idToken: string, projectId: string, nowSec: number): Promise<IdTokenVerdict> {
105
+ const check = await checkIdToken(idToken);
106
+ if (!check.ok && check.reason === 'malformed') return refused(MESSAGES.decode);
107
+ const contentFailure = contentMessage(check.header, check.claims, projectId);
108
+ if (contentFailure !== undefined) return refused(contentFailure);
109
+ if (!check.ok) return refused(check.reason === 'kid' ? MESSAGES.unknownKid : MESSAGES.signature);
110
+ if (isNotYetValid(check.claims.nbf, nowSec)) return refused(MESSAGES.signature);
111
+ const expired = expiryVerdict(check.claims.exp, nowSec);
112
+ if (expired !== undefined) return expired;
113
+ const token = { ...check.claims, uid: check.claims.sub } as DecodedIdToken;
114
+ return { ok: true, token };
115
+ }