@flytedesk/app-kit 3.2.2 → 4.0.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 (70) hide show
  1. package/README.md +157 -10
  2. package/dist/auth/client/httpClient.d.ts +79 -1
  3. package/dist/auth/client/httpClient.js +185 -16
  4. package/dist/auth/client/httpClient.js.map +1 -1
  5. package/dist/auth/client/index.d.ts +4 -4
  6. package/dist/auth/client/index.js +3 -3
  7. package/dist/auth/client/index.js.map +1 -1
  8. package/dist/auth/client/useAuthSession.d.ts +61 -13
  9. package/dist/auth/client/useAuthSession.js +185 -42
  10. package/dist/auth/client/useAuthSession.js.map +1 -1
  11. package/dist/auth/errors.d.ts +30 -7
  12. package/dist/auth/errors.js +38 -8
  13. package/dist/auth/errors.js.map +1 -1
  14. package/dist/auth/guards.d.ts +12 -0
  15. package/dist/auth/guards.js +24 -6
  16. package/dist/auth/guards.js.map +1 -1
  17. package/dist/auth/index.d.ts +4 -4
  18. package/dist/auth/index.js +2 -2
  19. package/dist/auth/index.js.map +1 -1
  20. package/dist/auth/oidc-client.js +12 -2
  21. package/dist/auth/oidc-client.js.map +1 -1
  22. package/dist/auth/plugin.d.ts +6 -0
  23. package/dist/auth/plugin.js +532 -304
  24. package/dist/auth/plugin.js.map +1 -1
  25. package/dist/auth/shared-types.d.ts +28 -1
  26. package/dist/auth/shared-types.js +35 -0
  27. package/dist/auth/shared-types.js.map +1 -1
  28. package/dist/auth/silentAuthPage.d.ts +20 -0
  29. package/dist/auth/silentAuthPage.js +75 -0
  30. package/dist/auth/silentAuthPage.js.map +1 -0
  31. package/dist/auth/testing/fake-idp.d.ts +35 -0
  32. package/dist/auth/testing/fake-idp.js +86 -2
  33. package/dist/auth/testing/fake-idp.js.map +1 -1
  34. package/dist/auth/testing/index.d.ts +3 -1
  35. package/dist/auth/testing/index.js +3 -1
  36. package/dist/auth/testing/index.js.map +1 -1
  37. package/dist/auth/testing/memory-store.d.ts +44 -0
  38. package/dist/auth/testing/memory-store.js +120 -0
  39. package/dist/auth/testing/memory-store.js.map +1 -0
  40. package/dist/auth/tokens.d.ts +24 -0
  41. package/dist/auth/tokens.js +31 -1
  42. package/dist/auth/tokens.js.map +1 -1
  43. package/dist/auth/types.d.ts +231 -96
  44. package/dist/auth/unavailable.d.ts +10 -0
  45. package/dist/auth/unavailable.js +11 -0
  46. package/dist/auth/unavailable.js.map +1 -0
  47. package/dist/bigquery/errors.d.ts +21 -3
  48. package/dist/bigquery/errors.js +66 -12
  49. package/dist/bigquery/errors.js.map +1 -1
  50. package/dist/bigquery/index.d.ts +5 -3
  51. package/dist/bigquery/index.js +4 -2
  52. package/dist/bigquery/index.js.map +1 -1
  53. package/dist/bigquery/query.d.ts +9 -2
  54. package/dist/bigquery/query.js +38 -16
  55. package/dist/bigquery/query.js.map +1 -1
  56. package/dist/bigquery/types.d.ts +45 -1
  57. package/dist/flags/plugin.js +4 -20
  58. package/dist/flags/plugin.js.map +1 -1
  59. package/dist/postgres/advisoryLock.d.ts +58 -0
  60. package/dist/postgres/advisoryLock.js +71 -0
  61. package/dist/postgres/advisoryLock.js.map +1 -0
  62. package/dist/postgres/index.d.ts +6 -0
  63. package/dist/postgres/index.js +7 -0
  64. package/dist/postgres/index.js.map +1 -0
  65. package/dist/profile/plugin.js +3 -20
  66. package/dist/profile/plugin.js.map +1 -1
  67. package/package.json +8 -2
  68. package/scripts/pending-release-count.mjs +0 -37
  69. package/scripts/release.sh +0 -126
  70. package/scripts/release.test.ts +0 -205
@@ -19,6 +19,22 @@ export interface UserinfoClaims {
19
19
  * shape `GET {api}{routePrefix}/me` returns to the browser, see ./shared-types.js's
20
20
  * AuthedUserDto doc comment for why this is a single definition rather than two. */
21
21
  export type AuthedUser = AuthedUserDto;
22
+ /**
23
+ * Why a refresh-token row stopped being usable (AK-23). `"rotated"`: `rotateRefreshToken`
24
+ * replaced it with a successor — the ONLY reason a later presentation of it can be a
25
+ * concurrent-tab race rather than reuse (see `FlytedeskAuthOptions.refreshReuseGraceMs`).
26
+ * Every other value is the `RevokeFamilyReason["type"]` of the `revokeFamily` call that
27
+ * ended this row's family while the row was still live.
28
+ */
29
+ export type RefreshTokenRevocationReason = "rotated" | RevokeFamilyReason["type"];
30
+ /** When and why one refresh-token row was revoked — see `RefreshTokenRow.revocation`. */
31
+ export interface RefreshTokenRevocation {
32
+ /** EXACTLY the time the plugin passed to the `rotateRefreshToken` or `revokeFamily`
33
+ * call that revoked the row — the app's clock, never the database's (see `AuthStore`'s
34
+ * ONE-clock rule). */
35
+ at: Date;
36
+ reason: RefreshTokenRevocationReason;
37
+ }
22
38
  export interface RefreshTokenRow {
23
39
  id: string;
24
40
  /** Shared by every refresh token descended from the same login, via rotation.
@@ -27,24 +43,39 @@ export interface RefreshTokenRow {
27
43
  familyId: string;
28
44
  userId: string;
29
45
  idpSessionId: string;
30
- revokedAt: Date | null;
31
46
  expiresAt: Date;
47
+ /** The client this token was issued to — `CreateRefreshTokenInput.ip`/`userAgent`, as
48
+ * persisted. The plugin logs them beside the presenting request's own when a
49
+ * just-rotated token comes back inside the grace window (AK-23). */
50
+ ip: string;
51
+ userAgent: string | null;
32
52
  /**
33
- * AK-14 (gap 3; MP-127): whether some OTHER row's `replacesId` points at this one —
34
- * i.e. this token's presentation was, at some point, followed by a successful
35
- * rotation, as opposed to being revoked directly (a logout, or reuse detection
36
- * itself). Only a rotated token's LATE replay (`revokedAt` already set when
37
- * `findRefreshTokenByHash` read this row, outside `refreshReuseGraceMs`) can be a
38
- * concurrent-tab race; `../plugin.ts`'s `POST {routePrefix}/refresh` reads this
39
- * straight off the row it already fetched — never recomputed on any other code path —
40
- * specifically so its answer reflects a fully-committed state, not one still
41
- * in-flight in a sibling request (see that route's doc comment for why a same-instant
42
- * race is instead decided entirely by `claimRefreshTokenForRotation`'s atomicity,
43
- * not by this field).
53
+ * `null` while the token is live. Otherwise the FIRST revocation this row received —
54
+ * never overwritten by a later one (`revokeFamily` only touches still-live rows), so a
55
+ * token that was rotated and whose family was later signed out still reads
56
+ * `"rotated"`.
57
+ *
58
+ * A row whose revocation time is set but whose reason is missing (only possible if a
59
+ * store skipped the migration's CHECK constraint) MUST still read as revoked — never
60
+ * as live — with a non-`"rotated"` reason.
61
+ *
62
+ * AK-23 replaced 3.x's `revokedAt` + derived `rotated` pair with this one field:
63
+ * `POST {routePrefix}/refresh` needs to know WHY a presented token is dead, not just
64
+ * that it is. A replay of a token whose family was already ended by a sign-out or an
65
+ * authorization rejection is a dead session, not a reuse attack, and must not be
66
+ * reported (or audited) as `reuse_detected` — see that route's doc comment in
67
+ * `./plugin.ts`.
44
68
  */
45
- rotated: boolean;
69
+ revocation: RefreshTokenRevocation | null;
46
70
  }
71
+ /** One refresh-token row to insert: the first of a family (`createRefreshToken`, at
72
+ * login) or the successor of a rotated one (`rotateRefreshToken`). */
47
73
  export interface CreateRefreshTokenInput {
74
+ /** The plugin's own random UUID for this row. A store MUST persist it exactly as given
75
+ * and return it as `RefreshTokenRow.id` — never substitute a generated or sequential
76
+ * id. The successor of a rotated token is derived from this server-only id (AK-23), so
77
+ * a guessable id would let a leaked `accessTokenSecret` plus an old cookie compute the
78
+ * family's live token offline. */
48
79
  id: string;
49
80
  familyId: string;
50
81
  userId: string;
@@ -55,14 +86,6 @@ export interface CreateRefreshTokenInput {
55
86
  idpSessionId: string;
56
87
  userAgent: string | null;
57
88
  ip: string;
58
- /**
59
- * The id of the refresh token this one was rotated FROM, or `null` for the first
60
- * token of a family (issued at login). AK-14: this is what `RefreshTokenRow.rotated`
61
- * (above) answers — see that field's doc comment and `../plugin.ts`'s
62
- * `refreshReuseGraceMs` for how a late replay of a rotated token is told apart from
63
- * reuse.
64
- */
65
- replacesId: string | null;
66
89
  }
67
90
  export interface IdpSessionRow {
68
91
  id: string;
@@ -86,77 +109,158 @@ export interface UpdateIdpSessionInput {
86
109
  idpAccessTokenExpiresAt?: Date;
87
110
  idpRefreshToken?: string | null;
88
111
  }
112
+ /**
113
+ * The IdP-session reads and writes the plugin makes while holding
114
+ * `AuthStore.withIdpSessionLock` — the store `fn` receives (AK-23). A Postgres-backed
115
+ * implementation MUST run both methods on the SAME transaction (hence the same pooled
116
+ * connection) that holds the lock. See `withIdpSessionLock`'s doc comment for why.
117
+ */
118
+ export interface IdpSessionLockedStore {
119
+ findIdpSession(id: string): Promise<IdpSessionRow | null>;
120
+ updateIdpSession(id: string, patch: UpdateIdpSessionInput): Promise<void>;
121
+ }
89
122
  /**
90
123
  * Storage seam this package needs, and nothing more — kept ORM-agnostic (no Prisma
91
124
  * import here) so a consumer can back it with whatever their own DB layer already is.
92
125
  * The two "families" of rows below mirror media-planner's RefreshToken/IdpSession
93
126
  * tables, which this module generalizes from (see DEC-38).
127
+ *
128
+ * Every method may throw on a storage failure; the plugin wraps whatever it throws in
129
+ * `AuthStoreError` and never mistakes it for "not found" or "rejected" (AK-23): a store
130
+ * outage during `POST {routePrefix}/refresh` answers 503 and revokes nothing.
131
+ *
132
+ * **ONE clock (AK-23).** Every time the plugin later compares — `RefreshTokenRow.expiresAt`,
133
+ * `RefreshTokenRow.revocation.at`, `IdpSessionRow.idpAccessTokenExpiresAt` — is stamped by
134
+ * the plugin from the app's clock and handed to the store (`expiresAt` on an inserted
135
+ * row, the `rotatedAt`/`revokedAt` argument of `rotateRefreshToken`/`revokeFamily`), and
136
+ * the plugin compares it against that same clock. A store MUST persist and return these
137
+ * values exactly as given and MUST NOT stamp any of them itself — no SQL `now()`, no
138
+ * column `DEFAULT now()`, no Prisma `@default(now())` / `@updatedAt`, no `new Date()`. A
139
+ * database clock even seconds off the app's moves the refresh-token grace-window
140
+ * decision: behind, a concurrent-tab race reads as reuse and signs the user out; ahead,
141
+ * a replay reads as a race and reuse detection never fires. (A store-side `new Date()` is
142
+ * the app's clock, so it is not skewed — but it is not what the plugin compares either.)
143
+ * The plugin compares against the clock read at the start of a request and stamps each
144
+ * revocation time from the clock at the moment of the write, so a slow authorization
145
+ * step cannot make it land early. Multiple app instances are assumed to run NTP-synced
146
+ * clocks.
94
147
  */
95
- export interface AuthStore {
148
+ export interface AuthStore extends IdpSessionLockedStore {
149
+ /** Inserts the FIRST token of a fresh family, at login. Replaces nothing. */
96
150
  createRefreshToken(row: CreateRefreshTokenInput): Promise<void>;
151
+ /** The row whose `tokenHash` matches, live or revoked, or `null` when none does. */
97
152
  findRefreshTokenByHash(hash: string): Promise<RefreshTokenRow | null>;
98
153
  /**
99
- * MUST be implemented as a single atomic conditional update — e.g.
100
- * `UPDATE refresh_tokens SET revoked_at = now() WHERE id = $1 AND revoked_at IS NULL`
101
- * — returning whether THIS call was the one that actually revoked the row (true), as
102
- * opposed to finding it already revoked (false, by a prior request or a concurrent
103
- * one that won the race).
154
+ * Rotates one refresh token (AK-23): in ONE transaction, revoke `presentedId` with
155
+ * reason `"rotated"` — conditionally, only if it is still live — and, only if that
156
+ * revoke claimed the row, insert `successor` recording that it replaces
157
+ * `presentedId`. Returns whether this call rotated it (`true`), or found it already
158
+ * revoked (`false`, by an earlier request or a concurrent sibling that won the race —
159
+ * and then inserts nothing).
160
+ *
161
+ * Both halves are one atomic unit, never two calls:
162
+ * - The conditional revoke must be a single statement (e.g.
163
+ * `UPDATE refresh_tokens SET revoked_at = $2, revoked_reason = 'rotated'
164
+ * WHERE id = $1 AND revoked_at IS NULL` with `$2` = `rotatedAt`, claimed iff one
165
+ * row changed) — a separate
166
+ * read then write lets two same-instant `/refresh` calls both see the token live,
167
+ * both rotate, and the replay go undetected.
168
+ * - The insert must commit or roll back WITH that revoke. 3.x's separate
169
+ * `claimRefreshTokenForRotation` + `createRefreshToken` could fail between the two,
170
+ * leaving a revoked token with no successor: the browser's next refresh presented it
171
+ * again and raised a false `reuse_detected` alarm.
104
172
  *
105
- * This is the fix for a real race in the plugin's original find-then-revoke logic:
106
- * two /refresh calls presenting the same still-valid token at the same instant could
107
- * both read `revokedAt: null` before either write landed, both rotate successfully,
108
- * and the replay would go undetected. A plain `find -> check -> revoke` sequence in
109
- * the CALLER can't close this gap no matter how carefully it's written — the
110
- * atomicity has to live in this one storage operation. Never implement this as a
111
- * separate read followed by an unconditional write.
173
+ * `rotatedAt` is the revocation time to record (`revocation.at`), from the app's clock —
174
+ * see the ONE-clock rule above.
112
175
  *
113
- * AK-14 (gap 3; MP-127): `../plugin.ts`'s `POST {routePrefix}/refresh` only ever calls
114
- * this for a token whose `findRefreshTokenByHash` read, moments earlier in the SAME
115
- * request, showed `revokedAt: null` — i.e. as far as this request could tell, the
116
- * token still looked live. A `false` result here therefore means a SIBLING request
117
- * claimed it in the tiny window since that read: a genuine, same-instant
118
- * concurrent-tab race, decided by this method's atomicity alone — unconditionally
119
- * treated as a race (401, nothing revoked), with no timing/rotated check needed,
120
- * exactly matching media-planner's own `updateMany({revokedAt: null}); if (count !==
121
- * 1) return false` shape. A LATE replay of an already (and by now long-since)
122
- * rotated-away token is a different code path entirely: it never reaches this method
123
- * at all, because `findRefreshTokenByHash`'s OWN `revokedAt`/`rotated` answer (read
124
- * before this is ever called) already resolves it — see that field's doc comment.
176
+ * `POST {routePrefix}/refresh` only calls this for a token `findRefreshTokenByHash` had
177
+ * just read as live, so `false` always means a same-instant concurrent-tab race (the
178
+ * plugin then answers with the sibling's successor; nothing is revoked).
179
+ *
180
+ * MUST be serialized against `revokeFamily` for the same family — see that method.
125
181
  */
126
- claimRefreshTokenForRotation(id: string): Promise<boolean>;
182
+ rotateRefreshToken(presentedId: string, successor: CreateRefreshTokenInput, rotatedAt: Date): Promise<boolean>;
127
183
  /**
128
- * Revoke every row sharing this familyId — called for reuse detection, sign-out, and
129
- * an authorization rejection on refresh (see `RevokeFamilyReason`, AK-20 gap 2327).
130
- * `reason` carries WHY, so a consumer's implementation can write its own audit record
131
- * (e.g. media-planner's reuse-detection log) without the plugin knowing anything about
132
- * that consumer's audit schema — see `RevokeFamilyReason`'s doc comment for what each
133
- * variant carries.
184
+ * Revokes every still-live row sharing this familyId, recording `reason.type` as each
185
+ * row's `RefreshTokenRevocation.reason` — rows already revoked keep their original
186
+ * revocation. Called for reuse detection, sign-out, and an authorization rejection on
187
+ * refresh (see `RevokeFamilyReason`). `reason` also carries what a consumer needs to
188
+ * write its own audit record (e.g. media-planner's reuse-detection log) without the
189
+ * plugin knowing that consumer's audit schema.
190
+ *
191
+ * MUST be serialized against `rotateRefreshToken` for the same family (AK-23). Under
192
+ * Postgres's default READ COMMITTED, this method's UPDATE runs on a snapshot taken
193
+ * when it starts, so a successor a concurrent rotation has inserted but not yet
194
+ * committed is invisible to it: the rotation commits after, and its successor is a
195
+ * LIVE token of a family that was just signed out (or revoked for reuse). Take the
196
+ * same per-family lock in both methods, inside their transactions — e.g.
197
+ * `acquireAdvisoryLock(tx, { namespace: "refresh-token-family", id: familyId }, ms)`
198
+ * from `@flytedesk/app-kit/postgres` — so whichever runs second sees the other's
199
+ * committed rows.
200
+ *
201
+ * `revokedAt` is the revocation time to record on every row this revokes, from the
202
+ * app's clock — see the ONE-clock rule above.
134
203
  */
135
- revokeFamily(familyId: string, reason: RevokeFamilyReason): Promise<void>;
204
+ revokeFamily(familyId: string, reason: RevokeFamilyReason, revokedAt: Date): Promise<void>;
136
205
  createIdpSession(row: CreateIdpSessionInput): Promise<{
137
206
  id: string;
138
207
  }>;
139
- findIdpSession(id: string): Promise<IdpSessionRow | null>;
140
- updateIdpSession(id: string, patch: UpdateIdpSessionInput): Promise<void>;
208
+ /** Idempotent: an already-deleted session is success, not an error. */
141
209
  deleteIdpSession(id: string): Promise<void>;
142
210
  /**
143
- * Runs `fn` while holding an exclusive, per-session lock (AK-20, gap 2326) — a
144
- * Postgres-backed store implements this with an advisory lock scoped to `sessionId`
145
- * (e.g. `pg_advisory_xact_lock(hashtext(sessionId))`, taken and released within the
146
- * same transaction `fn` runs in), so the lock is held across concurrent requests on
147
- * different server instances, not just within one process; an in-memory store (tests,
148
- * the hello-world example) can implement it with a simple per-key mutex/queue.
211
+ * Runs `fn` while holding an exclusive, per-session lock (AK-20, gap 2326), handing it
212
+ * an `IdpSessionLockedStore` bound to that lock (AK-23).
213
+ *
214
+ * Why the lock: flytedesk-id rotates its own refresh token on every use, so two
215
+ * concurrent requests on the same near-expiry session could both read "stale", both
216
+ * refresh with the SAME refresh token, and the loser would present one flytedesk-id
217
+ * already rotated away — signing a perfectly good session out. `../plugin.ts`'s
218
+ * `getLiveIdpAccessToken` re-reads the session inside the lock and skips its own
219
+ * refresh when a sibling already did it.
220
+ *
221
+ * Why `fn` gets a store: a Postgres implementation holds the lock inside a
222
+ * transaction (one pooled connection). In 3.x, `fn` called the store's ordinary
223
+ * `findIdpSession`/`updateIdpSession`, each needing a SECOND pooled connection while
224
+ * the first sat holding the lock. With as many concurrent stale-session requests as
225
+ * the pool has connections, every connection was held by a lock transaction waiting
226
+ * for a second one: the pool starved, the transactions timed out, and (in 3.x) every
227
+ * one of those sessions was signed out. The store passed to `fn` MUST issue its
228
+ * queries on the lock's own transaction, so a lock holder never needs a second
229
+ * connection.
149
230
  *
150
- * Fixes a real race in `../plugin.ts`'s `getLiveIdpAccessToken`: flytedesk-id rotates
151
- * its own refresh token on every use, so two concurrent requests on the same
152
- * near-expiry session could both read "stale" before either write landed, both call
153
- * `oidc.refreshIdpTokens` with the SAME (about-to-be-consumed) refresh token, and the
154
- * loser's request then presents a refresh token flytedesk-id has already rotated away
155
- * — getting that otherwise perfectly legitimate session signed out. Every caller of
156
- * `getLiveIdpAccessToken` re-reads the session INSIDE the lock and skips its own
157
- * refresh if a sibling request already refreshed it while this one was waiting.
231
+ * A Postgres implementation, with `@flytedesk/app-kit/postgres`:
232
+ *
233
+ * withIdpSessionLock(sessionId, fn) {
234
+ * return prisma.$transaction(async (tx) => {
235
+ * await acquireAdvisoryLock(tx, { namespace: "idp-session", id: sessionId }, 5_000);
236
+ * return fn(lockedStoreOn(tx)); // findIdpSession/updateIdpSession via `tx`
237
+ * }, { maxWait: 5_000, timeout: 15_000 });
238
+ * }
239
+ *
240
+ * `timeout` must exceed the lock wait plus one `idpRequestTimeoutMs` IdP call plus
241
+ * the transaction's own queries. A lock timeout, a `maxWait` expiry, or any other
242
+ * throw simply propagates: the plugin treats it as a store outage (503 at `/refresh`,
243
+ * no revocation), never as a rejected session. An in-memory store (tests) can
244
+ * implement this with a per-key promise queue.
245
+ *
246
+ * **Residual risk — a lost IdP refresh token (AK-23).** flytedesk-id rotates its
247
+ * refresh token the moment it answers a refresh, so from then until this transaction
248
+ * commits, the ONLY copy of the new token is in the plugin's memory. If
249
+ * `locked.updateIdpSession` or the commit fails in that window (the database drops
250
+ * the connection, the transaction's `timeout` fires, the process dies), the new
251
+ * token is lost and the stored one is already consumed: the session's next IdP
252
+ * refresh gets `invalid_grant`, which is a revocation, and that user must sign in
253
+ * again. No code on this side can close it — the IdP call cannot join the
254
+ * transaction. The plugin keeps the window as small as it can: the patch is built
255
+ * with no step that can throw, `updateIdpSession` is the first call after the IdP
256
+ * answers, and nothing else runs before the commit. `fn` never throws an IdP outcome
257
+ * — it RETURNS it, and the plugin throws only after this method has resolved — so a
258
+ * store that rolls back when `fn` throws (as every transaction does) only ever rolls
259
+ * back a failed store call, never a persisted rotation. A store keeps its part small by
260
+ * doing nothing inside `fn`'s transaction after `fn` returns, and by sizing
261
+ * `timeout` as above so it cannot fire while an IdP response is in flight.
158
262
  */
159
- withIdpSessionLock<T>(sessionId: string, fn: () => Promise<T>): Promise<T>;
263
+ withIdpSessionLock<T>(sessionId: string, fn: (locked: IdpSessionLockedStore) => Promise<T>): Promise<T>;
160
264
  }
161
265
  /**
162
266
  * Why `AuthStore.revokeFamily` (above) is revoking a family — AK-20, gap 2327. Lets a
@@ -166,10 +270,11 @@ export interface AuthStore {
166
270
  * consumer's audit schema.
167
271
  */
168
272
  export type RevokeFamilyReason = {
169
- /** A refresh token already rotated away was presented again — either a genuine
170
- * replay/leak, or a same-cookie-jar tab's request that arrived too late to be
171
- * treated as a race (see `RefreshTokenRow.rotated`'s doc comment and
172
- * `FlytedeskAuthOptions.refreshReuseGraceMs`). */
273
+ /** A refresh token already ROTATED away was presented again outside
274
+ * `FlytedeskAuthOptions.refreshReuseGraceMs` — a genuine replay/leak, or a
275
+ * same-cookie-jar tab's request that arrived too late to be treated as a race.
276
+ * Never raised for a token whose revocation reason is anything but `"rotated"`
277
+ * (AK-23): those families were already ended on purpose. */
173
278
  type: "reuse_detected";
174
279
  /** The refresh token row that was replayed, exactly as `findRefreshTokenByHash`
175
280
  * read it. */
@@ -179,16 +284,25 @@ export type RevokeFamilyReason = {
179
284
  ip: string;
180
285
  userAgent: string | null;
181
286
  } | {
182
- /** POST {routePrefix}/logout — the family is revoked unconditionally as part of
183
- * ending the session, independent of anything else about the request. */
287
+ /** POST {routePrefix}/logout — the family is revoked as part of ending the
288
+ * session, independent of anything else about the request. */
184
289
  type: "signed_out";
185
290
  } | {
186
291
  /** POST {routePrefix}/refresh ran the SAME authorization pipeline
187
- * `loadAuthedUser` runs (live IdP authorization, `onAuthorizeUser`) and it
188
- * rejected this session — e.g. a locally-suspended user, a role flytedesk-id
189
- * since revoked, or the backing IdP session no longer existing at all. */
292
+ * `loadAuthedUser` runs and it definitively rejected this session: flytedesk-id
293
+ * answered that access is revoked (`IdpAccessRevokedError`), `onAuthorizeUser`
294
+ * returned `null`, or the backing IdP session no longer exists. An IdP outage, a
295
+ * lock timeout, a store error or a THROWING `onAuthorizeUser` is not a
296
+ * rejection and never lands here (AK-23). */
190
297
  type: "authorization_rejected";
191
298
  };
299
+ /** Whether a `/callback` completed a hidden-iframe `prompt=none` resume or a visible,
300
+ * user-driven sign-in (AK-23). */
301
+ export type SignInMode = "silent" | "interactive";
302
+ /** The third argument `FlytedeskAuthOptions.onSignIn` receives. */
303
+ export interface SignInContext {
304
+ mode: SignInMode;
305
+ }
192
306
  export interface FlytedeskAuthOptions {
193
307
  /** This app's OAuth client_id, as registered with flytedesk-id. */
194
308
  clientId: string;
@@ -224,8 +338,10 @@ export interface FlytedeskAuthOptions {
224
338
  /** Gates the __Host- cookie prefix (requires Secure — see ./plugin.ts). Set false only
225
339
  * for local HTTP dev. */
226
340
  secureCookies: boolean;
227
- /** HS256 signing secret for this service's own short-lived access token. Never shared
228
- * with flytedesk-id or any other app. */
341
+ /** HS256 signing secret for this service's own short-lived access token, and the input
342
+ * key refresh-token rotation derives its successor key from (AK-23). At least 32 bytes
343
+ * (UTF-8) — registration throws otherwise. Never shared with flytedesk-id or any other
344
+ * app. See the README on recovering from a leak. */
229
345
  accessTokenSecret: string;
230
346
  /** Default 900 (15 minutes). */
231
347
  accessTokenTtlSeconds?: number;
@@ -242,10 +358,18 @@ export interface FlytedeskAuthOptions {
242
358
  */
243
359
  authorizationCacheTtlMs?: number;
244
360
  store: AuthStore;
245
- /** Called once per successful /callback, after profile verification and before the
246
- * session is issued — the hook for a consumer's own app-specific bookkeeping (e.g.
247
- * upserting a local profile row, checking a local suspension flag). */
248
- onSignIn?: (profile: UserinfoClaims, request: FastifyRequest) => Promise<void> | void;
361
+ /**
362
+ * Called once per successful /callback, after profile verification and before the
363
+ * session is issued — the hook for a consumer's own app-specific bookkeeping (e.g.
364
+ * upserting a local profile row, checking a local suspension flag, writing a sign-in
365
+ * audit record). `context.mode` (AK-23) says whether this was a hidden-iframe
366
+ * `prompt=none` resume (`"silent"`) or a visible sign-in (`"interactive"`), so an
367
+ * audit trail can tell a user actively signing in apart from a page reload quietly
368
+ * resuming their flytedesk-id session. Throwing aborts the sign-in (no session is
369
+ * issued): an interactive attempt redirects to `signInUrl` with
370
+ * `signin_error=other`, a silent one answers `detail: "idp_exchange_failed"`.
371
+ */
372
+ onSignIn?: (profile: UserinfoClaims, request: FastifyRequest, context: SignInContext) => Promise<void> | void;
249
373
  /**
250
374
  * OAuth scopes requested at `{routePrefix}/login`. Default
251
375
  * `["openid", "email", "profile", "offline_access"]` (AK-14, gap 1) — `offline_access`
@@ -277,14 +401,19 @@ export interface FlytedeskAuthOptions {
277
401
  * §4.14.2). Two browser tabs share one cookie jar: the loser of a rotation sends the
278
402
  * now-stale cookie only if it did so before the winner's `Set-Cookie` reached the
279
403
  * browser, so a race's loser can only ever present a token that WAS rotated
280
- * (`AuthStore.claimRefreshTokenForRotation`'s `rotated: true`), and only within
404
+ * (`RefreshTokenRow.revocation.reason === "rotated"`), and only within
281
405
  * roughly two client request round-trips of that rotation. Default
282
406
  * `2 * AUTH_REQUEST_DEADLINE_MS` (`../shared-types.js`) — matching media-planner's
283
407
  * `REFRESH_REUSE_GRACE_MS`, which observed a real race's loser arriving 60ms after the
284
- * winner's rotation (MP-112) — comfortably inside this default. A presentation of a
285
- * token that was NOT rotated (no successor row references it — e.g. it was revoked
286
- * directly, by a logout or by reuse detection itself) is ALWAYS treated as reuse,
287
- * regardless of this window: only a rotation can be a race.
408
+ * winner's rotation (MP-112) — comfortably inside this default. Inside the window the
409
+ * IMMEDIATELY previous token is answered with the SAME successor its rotation issued,
410
+ * when that successor is still the family's live token (AK-23) — which is also what
411
+ * saves a browser whose refresh response was lost after the server rotated. A token two
412
+ * or more rotations back is reuse even inside the window. Outside the window a rotated
413
+ * token's replay is reuse (`reuse_detected`, the family is revoked). A token
414
+ * revoked for any OTHER reason (its family was signed out, rejected, or already
415
+ * revoked for reuse) is a dead session, not a race and not a new reuse: 401 and the
416
+ * cookie cleared, nothing revoked or reported again (AK-23).
288
417
  */
289
418
  refreshReuseGraceMs?: number;
290
419
  /**
@@ -298,11 +427,17 @@ export interface FlytedeskAuthOptions {
298
427
  * of which is otherwise reachable from outside the plugin, since `onSignIn` only ever
299
428
  * fires once, at `/callback` time.
300
429
  *
301
- * Returning `null` rejects the request exactly like an IdP-side authorization failure
302
- * (`request.authUser` stays unset; `requireAuth` 401s). Returning a user (the same one
303
- * passed in, or a modified copy) is what gets attached. Throwing, or a rejected
304
- * promise, is treated as a rejection too (fail loudly, AK-16: logged with context,
305
- * never a 500) — a hook error must never be indistinguishable from "authenticated".
430
+ * Returning `null` rejects the session exactly like an IdP-side authorization
431
+ * revocation: `request.authUser` stays unset (`requireAuth` answers 401), and at
432
+ * `POST {routePrefix}/refresh` the whole refresh-token family is revoked
433
+ * (`authorization_rejected`). Returning a user (the same one passed in, or a modified
434
+ * copy) is what gets attached.
435
+ *
436
+ * Throwing, or a rejected promise, is an OUTAGE, not a verdict (AK-23): it is logged
437
+ * with context (fail loudly, AK-16) and the request is never authenticated, but
438
+ * `requireAuth` and `/refresh` answer 503 (`authorization_hook_failed`) and nothing is
439
+ * revoked — a hook's own database blip must not
440
+ * sign users out. Return `null` to reject; throw only when you could not decide.
306
441
  */
307
442
  onAuthorizeUser?: (user: AuthedUser, request: FastifyRequest) => Promise<AuthedUser | null> | AuthedUser | null;
308
443
  }
@@ -0,0 +1,10 @@
1
+ import type { FastifyReply } from "fastify";
2
+ import type { AuthUnavailableCode } from "./shared-types.js";
3
+ /**
4
+ * The one answer for "the plugin could not decide whether this session is authorized"
5
+ * (AK-23): 503, a fixed message, and the cause as `code` (`AUTH_UNAVAILABLE_CODES`).
6
+ * Shared by `POST {routePrefix}/refresh`, `requireAuth`/`requirePermission` and the
7
+ * plugin's own authenticated routes, so every outage looks the same to a client — and
8
+ * never like the 401 that means "signed out".
9
+ */
10
+ export declare function sendAuthUnavailable(reply: FastifyReply, code: AuthUnavailableCode): FastifyReply;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The one answer for "the plugin could not decide whether this session is authorized"
3
+ * (AK-23): 503, a fixed message, and the cause as `code` (`AUTH_UNAVAILABLE_CODES`).
4
+ * Shared by `POST {routePrefix}/refresh`, `requireAuth`/`requirePermission` and the
5
+ * plugin's own authenticated routes, so every outage looks the same to a client — and
6
+ * never like the 401 that means "signed out".
7
+ */
8
+ export function sendAuthUnavailable(reply, code) {
9
+ return reply.code(503).send({ error: "Sign-in could not be verified right now — try again", code });
10
+ }
11
+ //# sourceMappingURL=unavailable.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unavailable.js","sourceRoot":"","sources":["../../src/auth/unavailable.ts"],"names":[],"mappings":"AAGA;;;;;;GAMG;AACH,MAAM,UAAU,mBAAmB,CAAC,KAAmB,EAAE,IAAyB;IAChF,OAAO,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,IAAI,CAAC,EAAE,KAAK,EAAE,qDAAqD,EAAE,IAAI,EAAE,CAAC,CAAC;AACtG,CAAC"}
@@ -10,6 +10,13 @@ export declare class BadQueryError extends BigQueryReadError {
10
10
  export declare class PermissionDeniedError extends BigQueryReadError {
11
11
  constructor(message: string, cause?: unknown);
12
12
  }
13
+ /** BigQuery's own rate-limit/quota class of error (e.g. "too many table
14
+ * update operations for this table" on a load job). Transient — distinct
15
+ * from PermissionDeniedError so a caller (or an operator reading logs)
16
+ * doesn't chase IAM for what's actually a retryable quota condition. */
17
+ export declare class RateLimitExceededError extends BigQueryReadError {
18
+ constructor(message: string, cause?: unknown);
19
+ }
13
20
  /** A dry-run estimate exceeded the caller-supplied byte budget — thrown
14
21
  * before any billable query actually runs. */
15
22
  export declare class DryRunBudgetExceededError extends BigQueryReadError {
@@ -17,6 +24,17 @@ export declare class DryRunBudgetExceededError extends BigQueryReadError {
17
24
  readonly maxBytesBilled: number;
18
25
  constructor(totalBytesProcessed: number, maxBytesBilled: number);
19
26
  }
27
+ /** BigQuery itself refused to run a real job because it would bill more than
28
+ * the `maximumBytesBilled` passed to `runQuery` — distinct from
29
+ * `DryRunBudgetExceededError`, which is this module's own client-side check
30
+ * against a *dry-run* estimate. BigQuery reports this as an `invalidQuery`-
31
+ * reasoned error whose message names "bytes billed" (see
32
+ * `MAXIMUM_BYTES_BILLED_REASON_TEXT` below); this class exists so a caller
33
+ * can branch on the billing cap specifically instead of catching a generic
34
+ * BadQueryError. */
35
+ export declare class MaximumBytesBilledExceededError extends BigQueryReadError {
36
+ constructor(message: string, cause?: unknown);
37
+ }
20
38
  /**
21
39
  * Normalizes anything a BigQueryLike call can throw/reject with (a Google API
22
40
  * client error, a job's `status.errorResult`/`status.errors`, or an already-
@@ -27,8 +45,8 @@ export declare function classifyBigQueryError(err: unknown): BigQueryReadError;
27
45
  /**
28
46
  * True only for BigQuery's own rate-limit/quota class of error. Deliberately
29
47
  * narrow: a bad schema, a permissions/auth failure, or any other error must
30
- * keep failing immediately, not get masked behind a retry loop. Checks both
31
- * the structured `errors[].reason` the client library attaches and, as a
32
- * fallback, the reason text embedded in a raw REST error's `message`.
48
+ * keep failing immediately, not get masked behind a retry loop. Retryable
49
+ * exactly when `classifyBigQueryError` would report a RateLimitExceededError
50
+ * (see `isRateLimited`).
33
51
  */
34
52
  export declare function isRetryableBigQueryError(err: unknown): boolean;
@@ -20,6 +20,16 @@ export class PermissionDeniedError extends BigQueryReadError {
20
20
  this.name = "PermissionDeniedError";
21
21
  }
22
22
  }
23
+ /** BigQuery's own rate-limit/quota class of error (e.g. "too many table
24
+ * update operations for this table" on a load job). Transient — distinct
25
+ * from PermissionDeniedError so a caller (or an operator reading logs)
26
+ * doesn't chase IAM for what's actually a retryable quota condition. */
27
+ export class RateLimitExceededError extends BigQueryReadError {
28
+ constructor(message, cause) {
29
+ super(message, cause);
30
+ this.name = "RateLimitExceededError";
31
+ }
32
+ }
23
33
  /** A dry-run estimate exceeded the caller-supplied byte budget — thrown
24
34
  * before any billable query actually runs. */
25
35
  export class DryRunBudgetExceededError extends BigQueryReadError {
@@ -32,6 +42,20 @@ export class DryRunBudgetExceededError extends BigQueryReadError {
32
42
  this.name = "DryRunBudgetExceededError";
33
43
  }
34
44
  }
45
+ /** BigQuery itself refused to run a real job because it would bill more than
46
+ * the `maximumBytesBilled` passed to `runQuery` — distinct from
47
+ * `DryRunBudgetExceededError`, which is this module's own client-side check
48
+ * against a *dry-run* estimate. BigQuery reports this as an `invalidQuery`-
49
+ * reasoned error whose message names "bytes billed" (see
50
+ * `MAXIMUM_BYTES_BILLED_REASON_TEXT` below); this class exists so a caller
51
+ * can branch on the billing cap specifically instead of catching a generic
52
+ * BadQueryError. */
53
+ export class MaximumBytesBilledExceededError extends BigQueryReadError {
54
+ constructor(message, cause) {
55
+ super(message, cause);
56
+ this.name = "MaximumBytesBilledExceededError";
57
+ }
58
+ }
35
59
  /** Reason codes the Google APIs client library attaches to a rejected job
36
60
  * promise's `.errors[]` — see
37
61
  * https://cloud.google.com/bigquery/docs/error-messages for the full list.
@@ -53,6 +77,35 @@ const BAD_QUERY_REASONS = new Set([
53
77
  * almost always succeeds seconds later, so callers (see `loadRowsWithRetry`
54
78
  * in ./load.js) retry on this and only this. */
55
79
  const RATE_LIMIT_REASONS = new Set(["rateLimitExceeded", "quotaExceeded"]);
80
+ const RATE_LIMIT_REASON_TEXT = new RegExp([...RATE_LIMIT_REASONS].join("|"));
81
+ /** BigQuery reports a `maximumBytesBilled` refusal as an `invalidQuery`-
82
+ * reasoned error whose message reads e.g. "Query exceeded limit for bytes
83
+ * billed: 123456789. Limit: 100000000." — text-matched (case-insensitively)
84
+ * since it shares its `reason` with every other bad-query case. */
85
+ const MAXIMUM_BYTES_BILLED_REASON_TEXT = /bytes billed/i;
86
+ /** The structured `errors[]` entry that carries a rate-limit reason, wherever it
87
+ * sits in the list — so a rate limit reported as a later entry is both detected
88
+ * and described by its own message, not an unrelated sibling's. */
89
+ function rateLimitCause(apiErr) {
90
+ return apiErr?.errors?.find((cause) => cause.reason !== undefined && RATE_LIMIT_REASONS.has(cause.reason));
91
+ }
92
+ /**
93
+ * The one definition of "BigQuery rate-limited this call", shared by
94
+ * `classifyBigQueryError` and `isRetryableBigQueryError` so that what gets
95
+ * retried and how a retry-exhausted failure is reported can never disagree.
96
+ * True for any structured `errors[].reason` in RATE_LIMIT_REASONS or, for a raw
97
+ * REST error with no `errors[]`, that reason text in its `message`.
98
+ */
99
+ function isRateLimited(err) {
100
+ if (err instanceof RateLimitExceededError)
101
+ return true;
102
+ if (!err || typeof err !== "object")
103
+ return false;
104
+ const apiErr = err;
105
+ if (rateLimitCause(apiErr))
106
+ return true;
107
+ return typeof apiErr.message === "string" && RATE_LIMIT_REASON_TEXT.test(apiErr.message);
108
+ }
56
109
  /**
57
110
  * Normalizes anything a BigQueryLike call can throw/reject with (a Google API
58
111
  * client error, a job's `status.errorResult`/`status.errors`, or an already-
@@ -63,17 +116,25 @@ export function classifyBigQueryError(err) {
63
116
  if (err instanceof BigQueryReadError)
64
117
  return err;
65
118
  const apiErr = err;
66
- const detail = apiErr?.errors?.[0];
119
+ // The entry that explains the classification: the rate-limit cause when there
120
+ // is one (it decides the class below), otherwise the first reported error.
121
+ const detail = rateLimitCause(apiErr) ?? apiErr?.errors?.[0];
67
122
  const reason = detail?.reason;
68
123
  const message = detail?.message ??
69
124
  apiErr?.message ??
70
125
  (err instanceof Error ? err.message : String(err));
126
+ if (isRateLimited(err)) {
127
+ return new RateLimitExceededError(message, err);
128
+ }
71
129
  if (reason && PERMISSION_DENIED_REASONS.has(reason)) {
72
130
  return new PermissionDeniedError(message, err);
73
131
  }
74
132
  if (apiErr?.code === 403) {
75
133
  return new PermissionDeniedError(message, err);
76
134
  }
135
+ if (MAXIMUM_BYTES_BILLED_REASON_TEXT.test(message)) {
136
+ return new MaximumBytesBilledExceededError(message, err);
137
+ }
77
138
  if (reason && BAD_QUERY_REASONS.has(reason)) {
78
139
  return new BadQueryError(message, err);
79
140
  }
@@ -85,18 +146,11 @@ export function classifyBigQueryError(err) {
85
146
  /**
86
147
  * True only for BigQuery's own rate-limit/quota class of error. Deliberately
87
148
  * narrow: a bad schema, a permissions/auth failure, or any other error must
88
- * keep failing immediately, not get masked behind a retry loop. Checks both
89
- * the structured `errors[].reason` the client library attaches and, as a
90
- * fallback, the reason text embedded in a raw REST error's `message`.
149
+ * keep failing immediately, not get masked behind a retry loop. Retryable
150
+ * exactly when `classifyBigQueryError` would report a RateLimitExceededError
151
+ * (see `isRateLimited`).
91
152
  */
92
153
  export function isRetryableBigQueryError(err) {
93
- if (!err || typeof err !== "object")
94
- return false;
95
- const apiErr = err;
96
- if (apiErr.errors?.some((cause) => cause.reason !== undefined && RATE_LIMIT_REASONS.has(cause.reason))) {
97
- return true;
98
- }
99
- return (typeof apiErr.message === "string" &&
100
- /rateLimitExceeded|quotaExceeded/.test(apiErr.message));
154
+ return isRateLimited(err);
101
155
  }
102
156
  //# sourceMappingURL=errors.js.map