@dereekb/oauth-resource 14.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/express/index.d.ts +1 -0
  4. package/express/index.esm.js +321 -0
  5. package/express/package.json +29 -0
  6. package/express/src/index.d.ts +1 -0
  7. package/express/src/lib/bearer.middleware.d.ts +78 -0
  8. package/express/src/lib/index.d.ts +2 -0
  9. package/express/src/lib/well-known.router.d.ts +35 -0
  10. package/firebase/index.d.ts +1 -0
  11. package/firebase/index.esm.js +1924 -0
  12. package/firebase/package.json +27 -0
  13. package/firebase/src/index.d.ts +1 -0
  14. package/firebase/src/lib/firestore/firestore.sdk-identity.d.ts +165 -0
  15. package/firebase/src/lib/firestore/index.d.ts +1 -0
  16. package/firebase/src/lib/index.d.ts +2 -0
  17. package/firebase/src/lib/session/firebase-client.config.d.ts +86 -0
  18. package/firebase/src/lib/session/firebase-user-session.d.ts +223 -0
  19. package/firebase/src/lib/session/firebase-user-session.pool.d.ts +168 -0
  20. package/firebase/src/lib/session/firestore-session.cache.d.ts +79 -0
  21. package/firebase/src/lib/session/firestore-session.client.d.ts +149 -0
  22. package/firebase/src/lib/session/index.d.ts +5 -0
  23. package/index.d.ts +1 -0
  24. package/index.esm.js +1066 -0
  25. package/package.json +53 -0
  26. package/src/index.d.ts +1 -0
  27. package/src/lib/auth/index.d.ts +1 -0
  28. package/src/lib/auth/oauth.resource.auth.d.ts +55 -0
  29. package/src/lib/challenge/bearer.challenge.d.ts +73 -0
  30. package/src/lib/challenge/index.d.ts +1 -0
  31. package/src/lib/error/index.d.ts +1 -0
  32. package/src/lib/error/oauth.resource.error.d.ts +76 -0
  33. package/src/lib/index.d.ts +6 -0
  34. package/src/lib/issuer/index.d.ts +1 -0
  35. package/src/lib/issuer/issuer.profile.d.ts +98 -0
  36. package/src/lib/metadata/index.d.ts +1 -0
  37. package/src/lib/metadata/protected-resource.metadata.d.ts +64 -0
  38. package/src/lib/verify/index.d.ts +1 -0
  39. package/src/lib/verify/verify.bearer.d.ts +95 -0
@@ -0,0 +1,168 @@
1
+ import { type FirebaseAuthUserId } from '@dereekb/firebase';
2
+ import { type Getter, type Maybe, type Milliseconds, type UnixDateTimeMillisecondsNumber, type WebsiteUrl } from '@dereekb/util';
3
+ import { type FirebaseClientConfig } from './firebase-client.config';
4
+ import { type FirestoreSessionErrorFactory } from './firestore-session.client';
5
+ import { type FirestoreSessionCredentialsCache } from './firestore-session.cache';
6
+ import { type FirebaseUserSession, type FirebaseUserSessionOpener } from './firebase-user-session';
7
+ /**
8
+ * Default {@link FirebaseUserSessionPoolConfig.maxSessions}.
9
+ *
10
+ * Bounds *concurrently distinct users*, not total users. Each entry is a full Firebase app: one
11
+ * `Firestore` with its network stack and one `Auth` with a refresh timer. 32 is deliberate — high
12
+ * enough that a normal request mix never evicts, low enough that an unbounded-uid caller cannot
13
+ * exhaust the process.
14
+ */
15
+ export declare const DEFAULT_FIREBASE_USER_SESSION_POOL_SIZE = 32;
16
+ export interface FirebaseUserSessionPoolConfig {
17
+ /**
18
+ * The teardown-sweep key shared by every app this pool registers. Required — a silent default lets
19
+ * two pools in one process collide.
20
+ */
21
+ readonly namespace: string;
22
+ readonly firebase: FirebaseClientConfig;
23
+ readonly apiBaseUrl: WebsiteUrl;
24
+ /**
25
+ * Discriminates sessions that share a uid but not a target. Defaults to `firebase.projectId`.
26
+ */
27
+ readonly scope?: Maybe<string>;
28
+ /**
29
+ * Target number of concurrently-held sessions. Defaults to
30
+ * {@link DEFAULT_FIREBASE_USER_SESSION_POOL_SIZE}.
31
+ */
32
+ readonly maxSessions?: Maybe<number>;
33
+ /**
34
+ * Hard ceiling on a session's usable age. Defaults to {@link FIRESTORE_SESSION_MAX_CACHE_MS}.
35
+ */
36
+ readonly maxSessionAgeMs?: Maybe<Milliseconds>;
37
+ /**
38
+ * How far ahead of a session's expiry it stops being handed out. Defaults to
39
+ * {@link FIRESTORE_SESSION_EXPIRY_BUFFER_MS}.
40
+ */
41
+ readonly refreshSkewMs?: Maybe<Milliseconds>;
42
+ readonly fetcher?: Maybe<typeof fetch>;
43
+ readonly errorFactory?: Maybe<FirestoreSessionErrorFactory>;
44
+ /**
45
+ * Optional credentials cache shared across opens.
46
+ *
47
+ * SECURITY: a {@link FirestoreSessionCredentials} is a bearer credential for its user. The pool's
48
+ * own state is in-memory and dies with the process; supply a cache only if you can protect it at
49
+ * least as well as a 0600 file store.
50
+ */
51
+ readonly credentialsCache?: Maybe<FirestoreSessionCredentialsCache>;
52
+ /**
53
+ * Test seam. Defaults to {@link openFirebaseUserSession}.
54
+ */
55
+ readonly openSession?: Maybe<FirebaseUserSessionOpener>;
56
+ /**
57
+ * Clock seam, so TTL/LRU behavior is deterministic in specs.
58
+ */
59
+ readonly now?: Maybe<Getter<UnixDateTimeMillisecondsNumber>>;
60
+ readonly onEvent?: Maybe<FirebaseUserSessionPoolEventHandler>;
61
+ }
62
+ /**
63
+ * What happened to a pooled session.
64
+ *
65
+ * `evicted` / `expired` / `over-capacity` are DECISIONS; `closed` is the app actually going away,
66
+ * which for an entry marked over-capacity happens later, on its last `release()`.
67
+ */
68
+ export type FirebaseUserSessionPoolEventType = 'opened' | 'reused' | 'expired' | 'evicted' | 'over-capacity' | 'closed' | 'teardown-failed';
69
+ export interface FirebaseUserSessionPoolEvent {
70
+ readonly type: FirebaseUserSessionPoolEventType;
71
+ readonly uid: FirebaseAuthUserId;
72
+ readonly appName: string;
73
+ /**
74
+ * The pool's entry count after the event.
75
+ */
76
+ readonly size: number;
77
+ readonly error?: unknown;
78
+ }
79
+ export type FirebaseUserSessionPoolEventHandler = (event: FirebaseUserSessionPoolEvent) => void;
80
+ export interface AcquireFirebaseUserSessionInput {
81
+ readonly uid: FirebaseAuthUserId;
82
+ /**
83
+ * The verified bearer access token belonging to {@link uid}.
84
+ */
85
+ readonly accessToken: string;
86
+ /**
87
+ * Skips any credentials-cache read for this acquisition.
88
+ */
89
+ readonly refreshCredentials?: boolean;
90
+ }
91
+ /**
92
+ * A borrowed session. The pool will not tear its app down while the lease is held.
93
+ */
94
+ export interface FirebaseUserSessionLease {
95
+ readonly session: FirebaseUserSession;
96
+ /**
97
+ * Returns the session to the pool. Idempotent.
98
+ */
99
+ release(): void;
100
+ }
101
+ export interface FirebaseUserSessionPoolStats {
102
+ /**
103
+ * Number of pooled entries.
104
+ */
105
+ readonly size: number;
106
+ /**
107
+ * Number of OUTSTANDING leases across all entries — not the number of leased entries.
108
+ */
109
+ readonly leased: number;
110
+ readonly maxSessions: number;
111
+ }
112
+ export interface FirebaseUserSessionPool {
113
+ /**
114
+ * Borrows a session for the uid, opening one if needed.
115
+ *
116
+ * @param input - The uid and its access token.
117
+ * @returns The lease. The caller MUST `release()` it.
118
+ */
119
+ openSession(input: AcquireFirebaseUserSessionInput): Promise<FirebaseUserSessionLease>;
120
+ /**
121
+ * Borrows a session for the duration of `fn` and releases it afterwards, however `fn` settles.
122
+ *
123
+ * The request boundary IS the lifetime, so this is what a resource server calls.
124
+ *
125
+ * @param input - The uid and its access token.
126
+ * @param fn - The work to run with the session.
127
+ * @returns Whatever `fn` resolves to.
128
+ */
129
+ useSession<T>(input: AcquireFirebaseUserSessionInput, fn: (session: FirebaseUserSession) => Promise<T>): Promise<T>;
130
+ /**
131
+ * Detaches and tears down the uid's session — immediately when idle, on its last `release()`
132
+ * otherwise.
133
+ *
134
+ * @param uid - The user whose session should be closed.
135
+ */
136
+ closeSession(uid: FirebaseAuthUserId): Promise<void>;
137
+ /**
138
+ * Tears down every session and latches the pool closed. A later `openSession` throws `unavailable`.
139
+ */
140
+ close(): Promise<void>;
141
+ stats(): FirebaseUserSessionPoolStats;
142
+ }
143
+ /**
144
+ * Creates a per-`(scope, uid)` Firebase session pool with an explicit cap and TTL/LRU eviction.
145
+ *
146
+ * Exists because {@link openFirebaseUserSession} alone does not generalize past one user per process:
147
+ * a signed-in `Auth` runs a token-refresh timer and a live `Firestore` holds handles, so a server
148
+ * holding N concurrent user sessions leaks both per user without an owner. The pool is that owner.
149
+ *
150
+ * ### Why the TTL is driven by App Check, not Auth
151
+ *
152
+ * A signed-in `Auth` refreshes its ID token indefinitely — a Firebase refresh token does not expire
153
+ * on an hourly clock. The App Check token does not refresh: it is minted once by the API and
154
+ * `isTokenAutoRefreshEnabled` is `false`, because there is no local attestation to refresh against.
155
+ * So the session's real ceiling is the envelope's `expiresAt` (documented by the endpoint as "the
156
+ * earliest expiry among its credentials"), floored by {@link FirebaseUserSessionPoolConfig.maxSessionAgeMs}.
157
+ * Tearing the whole app down on expiry and re-minting costs one round trip; keeping it alive costs a
158
+ * silently unattested connection.
159
+ *
160
+ * ### Why a lease rather than a bare getter
161
+ *
162
+ * Reference counting is what makes eviction safe. Evicting an entry someone is mid-query on turns a
163
+ * capacity event into a user-visible failure, so the pool never tears down a leased session.
164
+ *
165
+ * @param config - The pool configuration.
166
+ * @returns The pool.
167
+ */
168
+ export declare function firebaseUserSessionPool(config: FirebaseUserSessionPoolConfig): FirebaseUserSessionPool;
@@ -0,0 +1,79 @@
1
+ import { type AsyncKeyedValueCache, type Maybe, type Milliseconds, type UnixDateTimeMillisecondsNumber } from '@dereekb/util';
2
+ import { type FirebaseAuthUserId } from '@dereekb/firebase';
3
+ import { type FirestoreSessionCredentials } from './firestore-session.client';
4
+ /**
5
+ * Hard ceiling on how long a minted user-scoped Firestore session may be reused, regardless of what
6
+ * the API reported in `expiresAt`.
7
+ *
8
+ * One hour, because that is the Firebase ceiling the credentials themselves sit under: a custom
9
+ * token is exchangeable for one hour, and the ID token it mints lives one hour. Holding a session
10
+ * past that buys nothing — the sign-in would fail — and re-minting is one HTTP round-trip.
11
+ */
12
+ export declare const FIRESTORE_SESSION_MAX_CACHE_MS: Milliseconds;
13
+ /**
14
+ * Default skew/latency buffer applied when deciding whether a cached session is still usable.
15
+ */
16
+ export declare const FIRESTORE_SESSION_EXPIRY_BUFFER_MS: Milliseconds;
17
+ /**
18
+ * A cached user-scoped Firestore session.
19
+ *
20
+ * Stores the credential envelope the API minted, not the live Firebase objects — those are
21
+ * per-process and cannot be serialized. A cache hit still signs in; it just skips the
22
+ * `GET /session/firestore` round-trip.
23
+ *
24
+ * SECURITY: every entry holds a Firebase custom token, which is a bearer credential for the user it
25
+ * was minted for. A persistent store MUST protect them at least as well as a 0600 file — this
26
+ * package deliberately ships no persistent implementation, only the {@link FirestoreSessionCredentialsCache}
27
+ * port and the expiry policy; `firebaseUserSessionPool`'s own state is in-memory and dies with the
28
+ * process.
29
+ */
30
+ export interface FirestoreSessionCacheEntry {
31
+ /**
32
+ * The credential bundle returned by `GET /session/firestore`.
33
+ *
34
+ * Named `session` rather than `credentials` deliberately: it is the on-disk JSON schema of every
35
+ * existing `@dereekb/dbx-cli` `~/.<cli>/.firestore-sessions.json`, and renaming it would silently
36
+ * invalidate every user's cache on upgrade.
37
+ */
38
+ readonly session: FirestoreSessionCredentials;
39
+ /**
40
+ * Unix epoch milliseconds at which the entry was written.
41
+ */
42
+ readonly cachedAt: UnixDateTimeMillisecondsNumber;
43
+ /**
44
+ * The uid the entry was minted for, denormalized so a stale entry belonging to a different user
45
+ * can be detected without parsing the custom token.
46
+ */
47
+ readonly uid: FirebaseAuthUserId;
48
+ }
49
+ /**
50
+ * A credentials cache keyed by whatever the consumer keys sessions on (an env name for a CLI, a uid
51
+ * for a server).
52
+ *
53
+ * A PORT, not an implementation. This package ships no persistent store — see the security note on
54
+ * {@link FirestoreSessionCacheEntry}.
55
+ */
56
+ export type FirestoreSessionCredentialsCache = AsyncKeyedValueCache<FirestoreSessionCacheEntry>;
57
+ /**
58
+ * Resolves the epoch-millis instant at which a cached session stops being usable.
59
+ *
60
+ * The effective expiry is the EARLIER of the API-reported `expiresAt` and
61
+ * {@link FIRESTORE_SESSION_MAX_CACHE_MS} past the write. Taking the earlier of the two means a
62
+ * server that reports an over-long (or unparsable) window still cannot push a session past the
63
+ * Firebase credential ceiling.
64
+ *
65
+ * @param entry - The cached entry.
66
+ * @returns The effective expiry in unix epoch milliseconds.
67
+ *
68
+ * @__NO_SIDE_EFFECTS__
69
+ */
70
+ export declare function firestoreSessionEntryExpiresAt(entry: FirestoreSessionCacheEntry): UnixDateTimeMillisecondsNumber;
71
+ /**
72
+ * Returns true when the cached session is at or near its effective expiry.
73
+ *
74
+ * @param entry - The cached entry (`null`/`undefined` is treated as expired).
75
+ * @param nowMs - The current time in unix epoch milliseconds. Defaults to `Date.now()`.
76
+ * @param bufferMs - Skew/latency buffer; the entry is treated as expired this far ahead of its effective expiry.
77
+ * @returns `true` when the entry is unusable, otherwise `false`.
78
+ */
79
+ export declare function isFirestoreSessionExpired(entry: Maybe<FirestoreSessionCacheEntry>, nowMs?: UnixDateTimeMillisecondsNumber, bufferMs?: Milliseconds): boolean;
@@ -0,0 +1,149 @@
1
+ import { type CodedError, type ISO8601DateString, type Maybe, type WebsiteUrl } from '@dereekb/util';
2
+ import { type FirebaseAuthUserId } from '@dereekb/firebase';
3
+ import { type OAuthResourceErrorCode } from '@dereekb/oauth-resource';
4
+ import { BaseError } from 'make-error';
5
+ /**
6
+ * Path (relative to the API base URL) of the user-scoped Firestore session endpoint served by
7
+ * `@dereekb/firebase-server`'s `SessionApiController`.
8
+ *
9
+ * Duplicated here rather than imported because importing it from `@dereekb/firebase-server` would be
10
+ * a dependency CYCLE: `firebase-server` already depends on `@dereekb/oauth-resource`
11
+ * (`packages/firebase-server/oidc/src/lib/service/oidc.service.ts`). The source of truth is
12
+ * `packages/firebase-server/src/lib/nest/controller/session/session.api.config.ts`.
13
+ */
14
+ export declare const FIRESTORE_SESSION_API_PATH = "/session/firestore";
15
+ /**
16
+ * The credential bundle `GET <apiBaseUrl>/session/firestore` returns.
17
+ *
18
+ * Mirrors `FirestoreSessionResult` in `@dereekb/firebase-server`'s `session.api.service.ts`.
19
+ *
20
+ * SECURITY: this is a bearer credential for the user it names. The custom token is exchangeable for
21
+ * a signed-in Firebase session as that uid by anyone holding it.
22
+ */
23
+ export interface FirestoreSessionCredentials {
24
+ /**
25
+ * The uid the session was minted for.
26
+ */
27
+ readonly uid: FirebaseAuthUserId;
28
+ /**
29
+ * A Firebase Auth custom token to exchange via `signInWithCustomToken`.
30
+ */
31
+ readonly customToken: string;
32
+ /**
33
+ * An App Check attestation minted server-side for the project's registered web app. Absent when
34
+ * the API has no `appCheckAppId` configured (a project that does not enforce App Check).
35
+ */
36
+ readonly appCheckToken?: string;
37
+ /**
38
+ * ISO timestamp at which the session's shortest-lived credential expires.
39
+ */
40
+ readonly expiresAt: ISO8601DateString;
41
+ }
42
+ /**
43
+ * Stable error code raised while obtaining a user-scoped Firestore session.
44
+ *
45
+ * Widens {@link OAuthResourceErrorCode} rather than forking a parallel union (DG-2). The 401/403
46
+ * members mean exactly what they mean there; the rest describe a failure of the OUTBOUND mint call,
47
+ * which is a different domain from an inbound token check — which is why they are NOT pushed back
48
+ * into `OAuthResourceErrorCode`: `bearerChallengeErrorForCode` would silently map them to
49
+ * `invalid_token`, and `OAUTH_RESOURCE_ERROR_STATUS_CODES` is a total `Record`.
50
+ */
51
+ export type FirestoreSessionErrorCode = OAuthResourceErrorCode | 'not_found' | 'unavailable' | 'invalid_response' | 'invalid_config';
52
+ export interface FirestoreSessionErrorInput {
53
+ readonly code: FirestoreSessionErrorCode;
54
+ readonly message: string;
55
+ /**
56
+ * HTTP status of the failed mint call, when the failure came from one.
57
+ */
58
+ readonly status?: Maybe<number>;
59
+ /**
60
+ * The actionable next step for an operator.
61
+ */
62
+ readonly suggestion?: Maybe<string>;
63
+ /**
64
+ * Extra machine-readable context carried onto the error.
65
+ */
66
+ readonly details?: Maybe<Record<string, unknown>>;
67
+ }
68
+ /**
69
+ * Error raised when a user-scoped Firestore session cannot be obtained.
70
+ *
71
+ * Consumers that already have their own error type do not have to catch and re-wrap this one: pass a
72
+ * {@link FirestoreSessionErrorFactory} and the session functions throw that type instead — the same
73
+ * seam `OAuthResourceErrorFactory` provides for verification.
74
+ */
75
+ export declare class FirestoreSessionError extends BaseError implements CodedError {
76
+ readonly code: FirestoreSessionErrorCode;
77
+ readonly status?: number;
78
+ readonly suggestion?: string;
79
+ readonly details?: Record<string, unknown>;
80
+ constructor(input: FirestoreSessionErrorInput);
81
+ }
82
+ /**
83
+ * Creates the error thrown when a session cannot be obtained.
84
+ *
85
+ * The seam that keeps this package error-type agnostic.
86
+ */
87
+ export type FirestoreSessionErrorFactory = (input: FirestoreSessionErrorInput) => Error;
88
+ /**
89
+ * Default {@link FirestoreSessionErrorFactory}, producing a {@link FirestoreSessionError}.
90
+ *
91
+ * @param input - The code, message, status, suggestion, and details of the failure.
92
+ * @returns The error to throw.
93
+ */
94
+ export declare const defaultFirestoreSessionErrorFactory: FirestoreSessionErrorFactory;
95
+ /**
96
+ * Returns true when the input is a {@link FirestoreSessionError}.
97
+ *
98
+ * @param error - The value to test.
99
+ * @returns Whether the value is a {@link FirestoreSessionError}.
100
+ */
101
+ export declare function isFirestoreSessionError(error: unknown): error is FirestoreSessionError;
102
+ /**
103
+ * Maps an HTTP status from the mint endpoint onto a {@link FirestoreSessionErrorCode}.
104
+ *
105
+ * @param status - The response status.
106
+ * @returns The matching error code.
107
+ * @__NO_SIDE_EFFECTS__
108
+ */
109
+ export declare function firestoreSessionErrorCodeForStatus(status: number): FirestoreSessionErrorCode;
110
+ /**
111
+ * Composes the session endpoint's absolute URL from an API base URL.
112
+ *
113
+ * @param apiBaseUrl - The API base URL, with or without a trailing slash.
114
+ * @returns The absolute `/session/firestore` URL.
115
+ * @__NO_SIDE_EFFECTS__
116
+ */
117
+ export declare function firestoreSessionUrl(apiBaseUrl: WebsiteUrl): WebsiteUrl;
118
+ export interface FetchFirestoreSessionInput {
119
+ /**
120
+ * The API base URL — typically `<host>/<project>/us-central1/api` or `https://<domain>/api`.
121
+ *
122
+ * The `/session/firestore` path is appended automatically.
123
+ */
124
+ readonly apiBaseUrl: WebsiteUrl;
125
+ /**
126
+ * The verified bearer access token to present. Must carry the `session.firestore` scope.
127
+ */
128
+ readonly accessToken: string;
129
+ /**
130
+ * Custom fetch implementation, for tracing or for tests.
131
+ */
132
+ readonly fetcher?: Maybe<typeof fetch>;
133
+ /**
134
+ * Maps a failure onto the consumer's own error type. Defaults to
135
+ * {@link defaultFirestoreSessionErrorFactory}.
136
+ */
137
+ readonly errorFactory?: Maybe<FirestoreSessionErrorFactory>;
138
+ }
139
+ /**
140
+ * Fetches a user-scoped Firestore session from the API with a Bearer access token.
141
+ *
142
+ * The endpoint is admin-only and additionally gated on the `session.firestore` OIDC scope, so a 403
143
+ * here usually means the token's user is not an admin or the token was issued without that scope.
144
+ *
145
+ * @param input - The API target, access token, and optional fetch/error overrides.
146
+ * @returns The parsed {@link FirestoreSessionCredentials}.
147
+ * @throws {FirestoreSessionError} (or the `errorFactory`'s type) When the endpoint answers non-2xx or returns an unusable body.
148
+ */
149
+ export declare function fetchFirestoreSession(input: FetchFirestoreSessionInput): Promise<FirestoreSessionCredentials>;
@@ -0,0 +1,5 @@
1
+ export * from './firebase-client.config';
2
+ export * from './firebase-user-session';
3
+ export * from './firebase-user-session.pool';
4
+ export * from './firestore-session.cache';
5
+ export * from './firestore-session.client';
package/index.d.ts ADDED
@@ -0,0 +1 @@
1
+ export * from "./src/index";