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/dist/auth/index.d.ts +5 -4
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +127 -53
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/token-decoders.d.ts +13 -26
- package/dist/auth/token-decoders.d.ts.map +1 -1
- package/dist/auth/token-decoders.js +102 -122
- package/dist/auth/token-decoders.js.map +1 -1
- package/dist/auth/verify-token.d.ts +45 -9
- package/dist/auth/verify-token.d.ts.map +1 -1
- package/dist/auth/verify-token.js +63 -27
- package/dist/auth/verify-token.js.map +1 -1
- package/package.json +2 -2
- package/src/auth/auth.test.ts +221 -57
- package/src/auth/custom-token-exchange.test.ts +100 -0
- package/src/auth/index.ts +132 -53
- package/src/auth/oracle-admin-answers.test.ts +40 -4
- package/src/auth/token-decoders.ts +98 -141
- package/src/auth/verify-token.ts +78 -34
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.
|
|
9
|
-
* real JWTs — they are deterministic strings
|
|
10
|
-
*
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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 {
|
|
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 {
|
|
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
|
-
*
|
|
102
|
-
*
|
|
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
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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
|
-
*
|
|
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,
|
|
277
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
|
469
|
-
//
|
|
470
|
-
//
|
|
471
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
*
|
|
736
|
-
*
|
|
737
|
-
*
|
|
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 {
|
|
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
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
+
}
|