@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
@@ -1,14 +1,51 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import fp from "fastify-plugin";
3
3
  import fastifyCookie from "@fastify/cookie";
4
- import { AuthStoreError, IdpAccessRevokedError, classifyIdpError } from "./errors.js";
4
+ import { AuthStoreError, IdpAccessRevokedError, IdpUnavailableError, classifyIdpError, } from "./errors.js";
5
5
  import { createOidcClient } from "./oidc-client.js";
6
6
  import { generatePkce, randomState } from "./pkce.js";
7
- import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, SILENT_AUTH_MESSAGE_SOURCE, SIGNIN_ERROR_QUERY_PARAM, } from "./shared-types.js";
8
- import { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken } from "./tokens.js";
7
+ import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, SIGNIN_ERROR_QUERY_PARAM, } from "./shared-types.js";
8
+ import { requireAuth } from "./guards.js";
9
+ import { renderSilentAuthPage } from "./silentAuthPage.js";
10
+ import { sendAuthUnavailable } from "./unavailable.js";
11
+ import { generateRefreshToken, hashRefreshToken, MIN_ACCESS_TOKEN_SECRET_BYTES, refreshSuccessorKey, signAccessToken, successorRefreshToken, verifyAccessToken, } from "./tokens.js";
9
12
  const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
10
13
  const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
11
14
  const DEFAULT_LOGIN_ATTEMPT_TTL_SECONDS = 600;
15
+ /**
16
+ * Runs one `AuthStore` call, wrapping anything it throws in `AuthStoreError` named after
17
+ * the method (AK-23) — so every route can tell a storage failure apart from an IdP
18
+ * failure or a verdict by type alone, and never mistakes one for "not found".
19
+ */
20
+ async function storeCall(method, call) {
21
+ try {
22
+ return await call();
23
+ }
24
+ catch (err) {
25
+ throw new AuthStoreError(`AuthStore.${method} failed`, err);
26
+ }
27
+ }
28
+ /** A plain decimal numeral: what a token endpoint that serializes numbers as strings sends. */
29
+ const DECIMAL_NUMERAL = /^\d+(?:\.\d+)?$/;
30
+ /**
31
+ * A `/token` response's `expires_in` as seconds, or `null` when it is unusable (AK-23).
32
+ * RFC 6749 §5.1 makes it a number, but a numeric string (`"3600"`) is accepted, as 3.x's
33
+ * implicit coercion did. Anything else — a non-numeral string, zero, a negative or
34
+ * non-finite number, a missing value — is `null`, never NaN.
35
+ */
36
+ function parseExpiresInSeconds(value) {
37
+ const seconds = typeof value === "string" && DECIMAL_NUMERAL.test(value) ? Number(value) : value;
38
+ return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
39
+ }
40
+ /** When an IdP access token from `tokens` expires. An unusable `expires_in` makes it
41
+ * "already expired" — the next request refreshes it — never an Invalid Date a store
42
+ * would reject. Cannot throw. */
43
+ function idpAccessTokenExpiresAt(tokens) {
44
+ return new Date(Date.now() + (parseExpiresInSeconds(tokens.expires_in) ?? 0) * 1000);
45
+ }
46
+ function unavailableCodeFor(err) {
47
+ return err instanceof AuthStoreError ? "auth_store_unavailable" : "idp_unavailable";
48
+ }
12
49
  const REQUIRED_STRING_OPTIONS = [
13
50
  "clientId",
14
51
  "clientSecret",
@@ -26,6 +63,10 @@ function assertRequiredOptions(options) {
26
63
  throw new Error(`@flytedesk/app-kit/auth: missing required option "${key}"`);
27
64
  }
28
65
  }
66
+ if (Buffer.byteLength(options.accessTokenSecret, "utf8") < MIN_ACCESS_TOKEN_SECRET_BYTES) {
67
+ throw new Error(`@flytedesk/app-kit/auth: "accessTokenSecret" must be at least ${MIN_ACCESS_TOKEN_SECRET_BYTES} bytes — ` +
68
+ "it signs access tokens and keys refresh-token rotation; generate it randomly (e.g. openssl rand -base64 48)");
69
+ }
29
70
  if (options.cookieSecret == null ||
30
71
  (Array.isArray(options.cookieSecret) ? options.cookieSecret.length === 0 : options.cookieSecret.length === 0)) {
31
72
  throw new Error('@flytedesk/app-kit/auth: missing required option "cookieSecret"');
@@ -53,6 +94,8 @@ const flytedeskAuthPlugin = async (fastify, options) => {
53
94
  // interactive flow to land anywhere sensible.
54
95
  const silentAuthTargetOrigin = new URL(options.postLoginRedirect).origin;
55
96
  const store = options.store;
97
+ // The key refresh-token rotation derives successors with (see successorRefreshToken).
98
+ const successorKey = refreshSuccessorKey(options.accessTokenSecret);
56
99
  if (options.authorizationCacheTtlMs) {
57
100
  fastify.log.warn({ authorizationCacheTtlMs: options.authorizationCacheTtlMs }, "@flytedesk/app-kit/auth: authorizationCacheTtlMs is set — flytedesk-id authorization " +
58
101
  "checks will be cached instead of live. A role/permission revoked in flytedesk-id can " +
@@ -68,6 +111,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
68
111
  });
69
112
  await fastify.register(fastifyCookie, { secret: options.cookieSecret });
70
113
  fastify.decorateRequest("authUser", undefined);
114
+ fastify.decorateRequest("authUnavailable", undefined);
71
115
  // __Host- requires Secure, no Domain attribute, and Path=/ — so the prefix (and the
72
116
  // Path=/ it forces) is only used when options.secureCookies says this is actually an
73
117
  // HTTPS deployment. Plain, scoped cookies are used for local HTTP dev, where __Host-
@@ -96,80 +140,121 @@ const flytedeskAuthPlugin = async (fastify, options) => {
96
140
  };
97
141
  }
98
142
  /**
99
- * The postMessage contract a silent (`prompt=none`) `{routePrefix}/callback` answers
100
- * with (AK-14, gap 2) — read by `./client/silentAuth.ts`'s hidden-iframe listener.
101
- * Never rendered by a top-level navigation: only ever loaded inside the hidden iframe
102
- * `attemptSilentResume()` creates, so this is safe to keep unauthenticated/undecorated
103
- * HTML — generalized from media-planner's `routes/auth.ts`'s `sendSilentResult`.
143
+ * The postMessage page a silent (`prompt=none`) `{routePrefix}/callback` answers with
144
+ * (AK-14, gap 2), read by `./client/silentAuth.ts`'s hidden-iframe listener. Hardened
145
+ * against a hostile `detail` (AK-23, the silent-callback XSS): see
146
+ * `./silentAuthPage.ts` for the allowlist, the script-context escaping, and the
147
+ * hash-pinned CSP set here, whose `frame-ancestors` is the same app origin the message
148
+ * targets.
104
149
  */
105
- function sendSilentResult(reply, status, detail) {
106
- const message = {
107
- source: SILENT_AUTH_MESSAGE_SOURCE,
108
- status,
109
- detail: detail ?? null,
110
- };
150
+ function sendSilentResult(reply, status, detailCode) {
151
+ const page = renderSilentAuthPage(status, detailCode, silentAuthTargetOrigin);
111
152
  return reply
153
+ .header("Content-Security-Policy", page.contentSecurityPolicy)
154
+ .header("X-Content-Type-Options", "nosniff")
112
155
  .type("text/html")
113
- .send(`<!doctype html><html><body><script>` +
114
- `if (window.parent && window.parent !== window) {` +
115
- `window.parent.postMessage(${JSON.stringify(message)}, ${JSON.stringify(silentAuthTargetOrigin)});` +
116
- `}` +
117
- `</script></body></html>`);
156
+ .send(page.html);
118
157
  }
119
158
  const authorizationCache = new Map();
120
159
  const EXPIRY_SKEW_MS = 5_000;
121
160
  /**
122
161
  * Returns a live IdP access token for this session — the one already on the row if
123
- * it's not near expiry, otherwise a freshly refreshed one (persisted back via
124
- * store.updateIdpSession before returning, since flytedesk-id rotates the refresh
125
- * token on every use — see ./oidc-client.ts's refreshIdpTokens doc comment). Shared
126
- * by authorizeIdpSession (below, and therefore by both loadAuthedUser and POST
127
- * {routePrefix}/refresh) and the GET {routePrefix}/apps route, so the refresh
128
- * behaviour — and its failure classification — can't drift between call sites.
129
- * Throws IdpAccessRevokedError (nothing left to refresh with, or flytedesk-id
130
- * rejected the refresh token) or IdpUnavailableError (an outage) — never swallows;
131
- * every caller is expected to log what it decides to do with the failure (fail
132
- * loudly, AK-16).
162
+ * it's not near expiry, otherwise a freshly refreshed one (persisted back before
163
+ * returning, since flytedesk-id rotates the refresh token on every use — see
164
+ * ./oidc-client.ts's refreshIdpTokens doc comment). Shared by authorizeIdpSession
165
+ * (below, and therefore by both loadAuthedUser and POST {routePrefix}/refresh) and the
166
+ * GET {routePrefix}/apps route, so the refresh behaviour — and its failure
167
+ * classification — can't drift between call sites.
133
168
  *
134
- * AK-20, gap 2326: the actual refresh happens under `store.withIdpSessionLock`, and
135
- * RE-READS the session once the lock is held — flytedesk-id rotates its own refresh
136
- * token on every use, so two concurrent requests on the same near-expiry session
137
- * would otherwise both read "stale" before either write landed, both present the
138
- * SAME (about-to-be-consumed) refresh token to flytedesk-id, and the loser would get
139
- * an otherwise perfectly good session signed out. The re-read lets a lock loser
140
- * notice a sibling already refreshed while it waited and reuse that result instead of
141
- * refreshing again.
169
+ * AK-20, gap 2326: the refresh happens under `store.withIdpSessionLock`, and RE-READS
170
+ * the session once the lock is held, so a lock loser notices a sibling already
171
+ * refreshed while it waited and reuses that result instead of refreshing (and
172
+ * rotating flytedesk-id's refresh token) again.
173
+ *
174
+ * AK-23: every read and write inside the lock goes through `locked`, the store
175
+ * `withIdpSessionLock` binds to the lock's own transaction — never through `store`
176
+ * itself, which would need a second pooled connection per lock holder and starve the
177
+ * pool under load (see `AuthStore.withIdpSessionLock`'s doc comment).
178
+ *
179
+ * AK-23 (N1): `fn` never throws on purpose — it RETURNS its outcome
180
+ * (`LockedRefreshOutcome`), and a failure is thrown here, only after
181
+ * `withIdpSessionLock` has resolved, i.e. after the store committed the lock's
182
+ * transaction. A store rolls that transaction back when `fn` throws (node-postgres
183
+ * `BEGIN`/`ROLLBACK` and Prisma's `$transaction` both do), so throwing from inside `fn`
184
+ * after `locked.updateIdpSession` persisted flytedesk-id's rotated refresh token would
185
+ * roll the only copy of it back. The one thing left that can reject
186
+ * `withIdpSessionLock` is the lock or the store itself.
187
+ *
188
+ * Throws exactly one of three types, never anything else:
189
+ * - IdpAccessRevokedError: nothing left to refresh with, or flytedesk-id rejected the
190
+ * refresh token — a verdict, the session is over.
191
+ * - IdpUnavailableError: flytedesk-id is down, timed out, or answered unusably — not
192
+ * a verdict.
193
+ * - AuthStoreError: the lock or a store call failed (including a lock timeout) — not
194
+ * a verdict.
142
195
  */
143
196
  async function getLiveIdpAccessToken(session) {
144
197
  if (session.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
145
198
  return session.idpAccessToken;
146
199
  }
200
+ let outcome;
147
201
  try {
148
- return await store.withIdpSessionLock(session.id, async () => {
149
- const fresh = await store.findIdpSession(session.id);
150
- if (!fresh) {
151
- throw new IdpAccessRevokedError("IdP session no longer exists — re-login required");
152
- }
153
- if (fresh.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
154
- // A sibling request already refreshed this session while this one waited for
155
- // the lock — reuse its result rather than refreshing (and rotating) again.
156
- return fresh.idpAccessToken;
157
- }
158
- if (!fresh.idpRefreshToken) {
159
- throw new IdpAccessRevokedError("No IdP refresh token on this session — re-login required");
160
- }
161
- const refreshed = await oidc.refreshIdpTokens(fresh.idpRefreshToken);
162
- await store.updateIdpSession(fresh.id, {
163
- idpAccessToken: refreshed.access_token,
164
- idpAccessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000),
165
- idpRefreshToken: refreshed.refresh_token ?? fresh.idpRefreshToken,
166
- });
167
- return refreshed.access_token;
168
- });
202
+ outcome = await store.withIdpSessionLock(session.id, (locked) => refreshUnderLock(locked, session.id));
169
203
  }
170
204
  catch (err) {
171
- throw classifyIdpError(err);
205
+ // `refreshUnderLock` returns every IdP outcome instead of throwing it, so anything
206
+ // thrown here came from the lock or the locked store — including a lock timeout.
207
+ throw new AuthStoreError("AuthStore.withIdpSessionLock failed", err);
172
208
  }
209
+ if (!outcome.ok)
210
+ throw outcome.error;
211
+ return outcome.accessToken;
212
+ }
213
+ /**
214
+ * The body of `getLiveIdpAccessToken`'s lock: re-reads the session once the lock is
215
+ * held, refreshes the IdP tokens unless a sibling already did, and persists the result
216
+ * through `locked`. RETURNS every IdP outcome — never throws one (see
217
+ * `getLiveIdpAccessToken`'s N1 note); only a `locked` store call can throw.
218
+ */
219
+ async function refreshUnderLock(locked, sessionId) {
220
+ const fresh = await locked.findIdpSession(sessionId);
221
+ if (!fresh) {
222
+ return { ok: false, error: new IdpAccessRevokedError("IdP session no longer exists — re-login required") };
223
+ }
224
+ if (fresh.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
225
+ // A sibling request already refreshed this session while this one waited for the
226
+ // lock — reuse its result rather than refreshing (and rotating) again.
227
+ return { ok: true, accessToken: fresh.idpAccessToken };
228
+ }
229
+ if (!fresh.idpRefreshToken) {
230
+ return { ok: false, error: new IdpAccessRevokedError("No IdP refresh token on this session — re-login required") };
231
+ }
232
+ let refreshed;
233
+ try {
234
+ refreshed = await oidc.refreshIdpTokens(fresh.idpRefreshToken);
235
+ }
236
+ catch (err) {
237
+ return { ok: false, error: classifyIdpError(err) };
238
+ }
239
+ // AK-23 (residual risk — see AuthStore.withIdpSessionLock's doc comment): from here
240
+ // on, flytedesk-id has ALREADY rotated its refresh token, and the only copy of the
241
+ // new one is in `refreshed`. Persisting it is therefore the FIRST thing that can
242
+ // fail, and the patch is built with no step that can throw — an unusable
243
+ // `expires_in` becomes "already stale" (the next request refreshes again, with the
244
+ // token persisted here) instead of an Invalid Date the store would reject.
245
+ const accessTokenValid = typeof refreshed.access_token === "string" && refreshed.access_token.length > 0;
246
+ await locked.updateIdpSession(fresh.id, {
247
+ idpRefreshToken: refreshed.refresh_token ?? fresh.idpRefreshToken,
248
+ ...(accessTokenValid
249
+ ? { idpAccessToken: refreshed.access_token, idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(refreshed) }
250
+ : {}),
251
+ });
252
+ if (!accessTokenValid) {
253
+ // The rotated refresh token is persisted above — and committed, because this is
254
+ // returned, not thrown. Only this answer is unusable.
255
+ return { ok: false, error: new IdpUnavailableError("flytedesk-id refresh response carried no access_token") };
256
+ }
257
+ return { ok: true, accessToken: refreshed.access_token };
173
258
  }
174
259
  /**
175
260
  * The authorization pipeline shared by loadAuthedUser (below, run on every
@@ -177,16 +262,16 @@ const flytedeskAuthPlugin = async (fastify, options) => {
177
262
  * {routePrefix}/refresh (AK-20, gap 2) — refreshes the IdP token pair if it's near
178
263
  * expiry, then calls flytedesk-id's authorization endpoint fresh (see
179
264
  * FlytedeskAuthOptions.authorizationCacheTtlMs for the one opt-in exception), builds
180
- * the AuthedUser, then runs the onAuthorizeUser hook if configured. Extracted into one
181
- * function specifically so `/refresh` can never drift from what an ordinary
182
- * authenticated request would decide: a refresh must not mint a fresh access token for
183
- * a user this SAME pipeline would reject (e.g. a suspended user) — otherwise that user
184
- * could keep refreshing (rotating their family, extending its lifetime) indefinitely
185
- * even though every resource request the minted token is then used for still 401s.
186
- * Never throws: any failure just means no AuthedUser — the caller decides what that
187
- * means for its own route. Every failure that is NOT a routine "nothing to refresh
188
- * with" outcome is still logged with context (fail loudly, AK-16), distinguishing a
189
- * flytedesk-id outage from a genuinely revoked session.
265
+ * the AuthedUser, then runs the onAuthorizeUser hook if configured. One function so
266
+ * `/refresh` can never drift from what an ordinary authenticated request would decide:
267
+ * a refresh must not mint a fresh access token for a user this SAME pipeline would
268
+ * reject (e.g. a suspended user).
269
+ *
270
+ * Never throws. AK-23: it answers one of three outcomes (see `AuthorizationOutcome`),
271
+ * and only `rejected` is a verdict — 3.x collapsed an outage, a lock timeout and a
272
+ * throwing hook into the same "no user" answer as a real rejection, so an IdP outage
273
+ * during `/refresh` revoked every refreshing session. Every failure is logged here
274
+ * with context (fail loudly, AK-16), distinguishing an outage from a revocation.
190
275
  */
191
276
  async function authorizeIdpSession(request, session) {
192
277
  let idpAccessToken;
@@ -194,11 +279,15 @@ const flytedeskAuthPlugin = async (fastify, options) => {
194
279
  idpAccessToken = await getLiveIdpAccessToken(session);
195
280
  }
196
281
  catch (err) {
197
- const classified = classifyIdpError(err);
198
- request.log.error({ err: classified, sid: session.id }, classified instanceof IdpAccessRevokedError
199
- ? "@flytedesk/app-kit/auth: flytedesk-id refresh token revoked or expired — forcing re-login"
200
- : "@flytedesk/app-kit/auth: flytedesk-id token refresh failed — possible IdP outage");
201
- return undefined;
282
+ const failure = err;
283
+ if (failure instanceof IdpAccessRevokedError) {
284
+ request.log.error({ err: failure, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id refresh token revoked or expired — forcing re-login");
285
+ return { kind: "rejected" };
286
+ }
287
+ request.log.error({ err: failure, sid: session.id }, failure instanceof AuthStoreError
288
+ ? "@flytedesk/app-kit/auth: AuthStore failed while refreshing the IdP token — not a rejection"
289
+ : "@flytedesk/app-kit/auth: flytedesk-id token refresh failed — possible IdP outage, not a rejection");
290
+ return { kind: "unavailable", code: unavailableCodeFor(failure) };
202
291
  }
203
292
  let authorization;
204
293
  if (options.authorizationCacheTtlMs) {
@@ -212,10 +301,12 @@ const flytedeskAuthPlugin = async (fastify, options) => {
212
301
  }
213
302
  catch (err) {
214
303
  const classified = classifyIdpError(err);
215
- request.log.error({ err: classified, sid: session.id }, classified instanceof IdpAccessRevokedError
216
- ? "@flytedesk/app-kit/auth: flytedesk-id revoked this session's authorization"
217
- : "@flytedesk/app-kit/auth: flytedesk-id authorization lookup failed — possible IdP outage");
218
- return undefined;
304
+ if (classified instanceof IdpAccessRevokedError) {
305
+ request.log.error({ err: classified, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id revoked this session's authorization");
306
+ return { kind: "rejected" };
307
+ }
308
+ request.log.error({ err: classified, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id authorization lookup failed — possible IdP outage, not a rejection");
309
+ return { kind: "unavailable", code: "idp_unavailable" };
219
310
  }
220
311
  if (options.authorizationCacheTtlMs) {
221
312
  authorizationCache.set(session.id, {
@@ -237,80 +328,89 @@ const flytedeskAuthPlugin = async (fastify, options) => {
237
328
  permissions: authorization.permissions.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p)),
238
329
  };
239
330
  // AK-14, gap 4: the one extension point a consumer has to reject or enrich an
240
- // otherwise-valid, still-IdP-authorized session on every request (e.g. media-planner's
241
- // own local suspension flag) — see FlytedeskAuthOptions.onAuthorizeUser's doc comment.
242
- // A throwing/rejecting hook is treated exactly like a `null` return: fail loudly
243
- // (logged with context, AK-16) but never a 500 — a hook error must never be
244
- // indistinguishable from "authenticated".
331
+ // otherwise-valid, still-IdP-authorized session on every request — see
332
+ // FlytedeskAuthOptions.onAuthorizeUser's doc comment. `null` is a verdict; a throw is
333
+ // not (AK-23): the hook could not decide, so the request is unauthenticated but
334
+ // nothing is revoked.
245
335
  if (options.onAuthorizeUser) {
246
336
  let authorized;
247
337
  try {
248
338
  authorized = await options.onAuthorizeUser(user, request);
249
339
  }
250
340
  catch (err) {
251
- request.log.error({ err, sid: session.id }, "@flytedesk/app-kit/auth: onAuthorizeUser threw — treating the session as rejected");
252
- return undefined;
341
+ request.log.error({ err, sid: session.id }, "@flytedesk/app-kit/auth: onAuthorizeUser threw — request not authenticated, session not revoked");
342
+ return { kind: "unavailable", code: "authorization_hook_failed" };
253
343
  }
254
- if (!authorized)
255
- return undefined;
256
- return authorized;
344
+ return authorized ? { kind: "authorized", user: authorized } : { kind: "rejected" };
257
345
  }
258
- return user;
346
+ return { kind: "authorized", user };
259
347
  }
260
348
  /**
261
- * Live per-request by default: resolves `request.authUser` from the access token's
262
- * `sid` by looking up the IdpSession, then running it through authorizeIdpSession
263
- * above. Never throws: any failure just means no AuthedUser gets attached —
264
- * requireAuth 401s from there.
349
+ * Live per-request by default: looks up the IdpSession behind the access token's
350
+ * `sid`, then runs it through authorizeIdpSession above. Never throws. A missing
351
+ * session (or one for another subject) is `rejected`; a store failure looking it up
352
+ * is `unavailable` (AK-23) — the store being down says nothing about the session.
265
353
  */
266
- async function loadAuthedUser(request, claims) {
354
+ async function loadAuthorization(request, claims) {
267
355
  let session;
268
356
  try {
269
- session = await store.findIdpSession(claims.sid);
357
+ session = await storeCall("findIdpSession", () => store.findIdpSession(claims.sid));
270
358
  }
271
359
  catch (err) {
272
- request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), sid: claims.sid }, "@flytedesk/app-kit/auth: AuthStore error looking up IdP session");
273
- return undefined;
360
+ request.log.error({ err, sid: claims.sid }, "@flytedesk/app-kit/auth: AuthStore error looking up IdP session — not a rejection");
361
+ return { kind: "unavailable", code: "auth_store_unavailable" };
274
362
  }
275
363
  if (!session || session.subject !== claims.sub)
276
- return undefined; // no session for this claim — expected, not an error
364
+ return { kind: "rejected" }; // expected, not an error
277
365
  return authorizeIdpSession(request, session);
278
366
  }
279
367
  // onRequest, not preHandler: this needs to run before any route-level preHandler
280
- // (requireAuth/requirePermission included) so request.authUser is already resolved
281
- // by the time those run.
368
+ // (requireAuth/requirePermission included) so request.authUser — or, when the plugin
369
+ // could not decide, request.authUnavailable (AK-23: requireAuth then answers 503, not
370
+ // 401) — is already resolved by the time those run.
282
371
  fastify.addHook("onRequest", async (request) => {
283
372
  const header = request.headers.authorization;
284
373
  if (!header?.startsWith("Bearer "))
285
374
  return;
375
+ let claims;
286
376
  try {
287
- const claims = await verifyAccessToken(options.accessTokenSecret, header.slice(7));
288
- const user = await loadAuthedUser(request, claims);
289
- if (user)
290
- request.authUser = user;
377
+ claims = await verifyAccessToken(options.accessTokenSecret, header.slice(7));
291
378
  }
292
379
  catch {
293
380
  // Invalid/expired access token: leave request.authUser unset; requireAuth 401s.
381
+ return;
294
382
  }
383
+ const outcome = await loadAuthorization(request, claims);
384
+ if (outcome.kind === "authorized")
385
+ request.authUser = outcome.user;
386
+ else if (outcome.kind === "unavailable")
387
+ request.authUnavailable = outcome.code;
295
388
  });
296
- async function issueSession(request, reply, userId, idpSessionId, familyId) {
297
- const { token: refreshToken, hash } = generateRefreshToken();
298
- const expiresAt = new Date(Date.now() + refreshTokenTtlSeconds * 1000);
299
- await store.createRefreshToken({
300
- id: randomUUID(),
301
- familyId,
302
- userId,
303
- tokenHash: hash,
304
- expiresAt,
305
- idpSessionId,
306
- userAgent: request.headers["user-agent"] ?? null,
307
- ip: request.ip,
308
- // The first token of a fresh login — nothing precedes it in the family.
309
- replacesId: null,
310
- });
311
- const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
389
+ /** An opaque refresh token for one family: the raw token for the cookie, and the row
390
+ * (its hash, never the token) to persist. `issued` is the token itself — random for a
391
+ * login's first token (`generateRefreshToken`), derived from the presented row and token for
392
+ * a rotation (`successorRefreshToken`, see `resumeSuccessor`). */
393
+ function newRefreshToken(request, family, { token, hash }) {
394
+ return {
395
+ token,
396
+ row: {
397
+ id: randomUUID(),
398
+ familyId: family.familyId,
399
+ userId: family.userId,
400
+ tokenHash: hash,
401
+ expiresAt: new Date(Date.now() + refreshTokenTtlSeconds * 1000),
402
+ idpSessionId: family.idpSessionId,
403
+ userAgent: request.headers["user-agent"] ?? null,
404
+ ip: request.ip,
405
+ },
406
+ };
407
+ }
408
+ /** The success tail shared by login and `/refresh`: hands the browser its (new) refresh
409
+ * cookie and mints this service's own short-lived access token for the session. */
410
+ async function completeSession(reply, refreshToken, userId, idpSessionId) {
411
+ const { token: accessToken, expiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
312
412
  reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
313
- return { accessToken, accessTokenExpiresAt: accessExpiresAt.toISOString() };
413
+ return { accessToken, accessTokenExpiresAt: expiresAt.toISOString() };
314
414
  }
315
415
  /**
316
416
  * GET {routePrefix}/login — starts the flytedesk-id sign-in. A plain redirect meant
@@ -422,19 +522,25 @@ const flytedeskAuthPlugin = async (fastify, options) => {
422
522
  }
423
523
  const silent = stash?.silent === true;
424
524
  /**
425
- * Answers ONE callback failure. Silent (AK-14): unchanged — the postMessage page,
426
- * carrying the raw internal `code`, consumed only inside the hidden iframe
427
- * (`sendSilentResult` above); never a top-level navigation, so it was never the
428
- * raw-JSON dead end this card fixes. Interactive (AK-19): redirects (302) to the
429
- * app's own sign-in page via `redirectToSignIn`, carrying only the normalized
430
- * `signInErrorCode` — never `code`, never `message`, never a response body at all.
431
- * `message` (and `err`, when the failure came from an upstream call) is logged
432
- * server-side ONLY, exactly once, right here — the one place every failure branch
433
- * below funnels through — so no call site has to remember to log it itself.
525
+ * Answers ONE callback failure. Silent (AK-14): the hardened postMessage page
526
+ * (`sendSilentResult` above), whose `detail` is `code` mapped onto the fixed
527
+ * `SILENT_AUTH_DETAIL_CODES` set — never `code` itself, which for an `idp_error`
528
+ * is the attacker-reachable `error` query parameter (AK-23). Interactive (AK-19):
529
+ * redirects (302) to the app's own sign-in page via `redirectToSignIn`, carrying
530
+ * only the normalized `signInErrorCode` — never `code`, never `message`, never a
531
+ * response body at all. `message` (and `err`, when the failure came from an
532
+ * upstream call) is logged server-side ONLY, exactly once, right here — the one
533
+ * place every failure branch below funnels through. A silent `login_required` is the
534
+ * routine "no flytedesk-id session to resume" answer, not a failure, so it alone is
535
+ * not logged.
434
536
  */
435
537
  function fail(code, message, signInErrorCode, err) {
436
- if (silent)
538
+ if (silent) {
539
+ if (code !== "login_required") {
540
+ request.log.error({ err, code }, `@flytedesk/app-kit/auth: silent callback failed — ${message}`);
541
+ }
437
542
  return sendSilentResult(reply, "error", code);
543
+ }
438
544
  // Always .error, not .warn: every one of these is the "fail loudly" pattern
439
545
  // this file uses throughout (AK-16) — the ONLY place the real detail (and, for
440
546
  // an upstream failure, the actual err) is ever recorded, since the redirect
@@ -477,7 +583,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
477
583
  return fail("email_not_verified", "flytedesk-id account email is not verified", "other");
478
584
  }
479
585
  if (options.onSignIn) {
480
- await options.onSignIn(profile, request);
586
+ await options.onSignIn(profile, request, { mode: silent ? "silent" : "interactive" });
481
587
  }
482
588
  const name = profile.name ?? profile.email.split("@")[0];
483
589
  const idpSession = await store.createIdpSession({
@@ -485,15 +591,16 @@ const flytedeskAuthPlugin = async (fastify, options) => {
485
591
  name,
486
592
  email: profile.email,
487
593
  idpAccessToken: tokens.access_token,
488
- idpAccessTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
594
+ idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(tokens),
489
595
  idpRefreshToken: tokens.refresh_token ?? null,
490
596
  });
491
597
  // A fresh family for every login — reuse detection (see POST /refresh below)
492
598
  // only ever revokes the chain descended from one login, never sibling sessions.
493
- const familyId = randomUUID();
494
- await issueSession(request, reply, profile.sub, idpSession.id, familyId);
599
+ const first = newRefreshToken(request, { familyId: randomUUID(), userId: profile.sub, idpSessionId: idpSession.id }, generateRefreshToken());
600
+ await store.createRefreshToken(first.row);
601
+ await completeSession(reply, first.token, profile.sub, idpSession.id);
495
602
  if (silent) {
496
- // No top-level navigation at all — issueSession() above already set the real
603
+ // No top-level navigation at all — completeSession() above already set the real
497
604
  // refresh cookie on this (the API's) origin as a side effect of this same
498
605
  // response, which is the only thing that actually needed to happen. This page
499
606
  // just tells the hidden iframe's listener it can stop waiting; the parent page
@@ -509,143 +616,244 @@ const flytedeskAuthPlugin = async (fastify, options) => {
509
616
  });
510
617
  /** The one answer POST {routePrefix}/refresh gives a refresh token it will not
511
618
  * rotate. `clearCookie` only for a token that is genuinely dead (unrecognized,
512
- * expired, or reused — its whole family just ended); NOT for one that merely lost a
513
- * concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the browser's
514
- * fresh cookie at the same moment, and a clearing Set-Cookie on the loser's response
515
- * can land after it and delete the fresh token. */
619
+ * expired, revoked, or reused — its whole family just ended); NOT for one that merely
620
+ * lost a concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the
621
+ * browser's fresh cookie at the same moment, and a clearing Set-Cookie on the loser's
622
+ * response can land after it and delete the fresh token. */
516
623
  function rejectRefresh(reply, { clearCookie }) {
517
624
  if (clearCookie)
518
625
  reply.clearCookie(refreshCookieName, { path: cookiePath });
519
626
  return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
520
627
  }
628
+ /** Revokes the family behind a refresh the authorization pipeline rejected, and 401s
629
+ * with the cookie cleared. */
630
+ async function rejectFamily(reply, row) {
631
+ await storeCall("revokeFamily", () => store.revokeFamily(row.familyId, { type: "authorization_rejected" }, new Date()));
632
+ reply.clearCookie(refreshCookieName, { path: cookiePath });
633
+ reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
634
+ }
635
+ /** The request's own client fields, as the reuse audit and the resume log record them. */
636
+ function clientOf(request) {
637
+ return { ip: request.ip, userAgent: request.headers["user-agent"] ?? null };
638
+ }
639
+ /**
640
+ * Refresh-token reuse: revokes `replayed`'s whole family (`reuse_detected` — every token
641
+ * downstream of the replayed one must be assumed compromised) and answers 401
642
+ * `token_reuse_detected` with the cookie cleared.
643
+ */
644
+ async function reportReuse(request, reply, replayed, sinceRotatedMs, why) {
645
+ const client = clientOf(request);
646
+ const context = { familyId: replayed.familyId, userId: replayed.userId, sinceRotatedMs, why, ...client };
647
+ request.log.warn(context, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
648
+ try {
649
+ await storeCall("revokeFamily", () => store.revokeFamily(replayed.familyId, { type: "reuse_detected", replayedToken: replayed, sinceRotatedMs, ...client }, new Date()));
650
+ }
651
+ catch (err) {
652
+ // Reuse was DETECTED but not recorded: the family (and whichever live successor
653
+ // the thief or the victim holds) is still live. The answer stays 503 with the
654
+ // cookie kept, not 401: a 401 would end this client's retries, and each retry is
655
+ // what re-presents this replayed token and re-attempts the revocation — it is the
656
+ // only thing that ever will. The replayed token itself is already rotated, and it
657
+ // is never resumed outside the grace window.
658
+ request.log.error({ err, ...context }, "@flytedesk/app-kit/auth: refresh token reuse detected but revoking the family FAILED — family still live");
659
+ throw err;
660
+ }
661
+ reply.clearCookie(refreshCookieName, { path: cookiePath });
662
+ reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
663
+ }
521
664
  /**
522
665
  * POST {routePrefix}/refresh — no body, reads the refresh cookie. Rotates it on every
523
666
  * successful call.
524
667
  *
525
- * A presented token can be rejected two different ways (AK-14, gap 3; MP-127; RFC
526
- * 9700 §4.14.2), and telling them apart is the whole point of this route:
668
+ * ONE clock (AK-23): every time this route compares or records is the app's clock,
669
+ * never the database's `now()`. Comparisons read the clock once, at the start of the
670
+ * request (`now`); every revocation time is read from it again AT the store write
671
+ * (`new Date()` passed to `rotateRefreshToken`/`revokeFamily`), because the
672
+ * authorization step before a rotation can legitimately take seconds (a session-lock
673
+ * wait, a flytedesk-id `/token` call, the authorization hook) — a time stamped at the
674
+ * request's start would land early and shrink the grace window for the very retry it
675
+ * exists for.
676
+ *
677
+ * A presented token that is already revoked (`stored.revocation`, from
678
+ * `findRefreshTokenByHash`) is answered by WHY it was revoked (AK-23):
679
+ *
680
+ * - `"rotated"`, less than `refreshReuseGraceMs` ago: the same browser presenting it
681
+ * again — a concurrent tab whose request was in flight when it was rotated, or a
682
+ * browser whose refresh response never arrived (it timed out, the network dropped
683
+ * it) and so still holds this cookie. Tabs share one cookie jar, so only the
684
+ * IMMEDIATELY previous token can legitimately come back (the policy Auth0's reuse
685
+ * interval follows): `resumeSuccessor` re-derives its direct successor
686
+ * (`successorRefreshToken`) and, when that is the family's live token, answers with
687
+ * it — authorized through the same pipeline, rotated no further. A token two or
688
+ * more rotations back is reuse.
689
+ * - `"rotated"`, outside that window: reuse (`reportReuse`).
690
+ * - anything else (`signed_out`, `authorization_rejected`, `reuse_detected`): the
691
+ * family was already ended on purpose. 401, cookie cleared, nothing revoked or
692
+ * reported again — 3.x reported these as reuse, so a tab that fired one last refresh
693
+ * after sign-out raised a false security alarm.
694
+ *
695
+ * A token that looked live is authorized through the SAME pipeline `loadAuthedUser`
696
+ * runs on every request (AK-20, gap 2) before it is rotated. Only a definitive
697
+ * rejection revokes the family (`authorization_rejected`); an outage, a lock timeout,
698
+ * a store error or a throwing `onAuthorizeUser` answers 503 and revokes nothing
699
+ * (AK-23) — an IdP outage used to sign out every user who refreshed during it. The
700
+ * pipeline runs BEFORE the rotation, not between a claim and a mint: it can be slow
701
+ * (a real round trip to flytedesk-id), and the rotation must stay one short atomic
702
+ * step.
527
703
  *
528
- * - It was ALREADY revoked before this request even started
529
- * (`stored.revokedAt`, from `findRefreshTokenByHash`'s own read, is non-null) — a
530
- * LATE event, from this request's point of view. If it was rotated
531
- * (`stored.rotated`) and that rotation happened less than `refreshReuseGraceMs`
532
- * ago, it's still plausibly a concurrent-tab race whose loser's request simply
533
- * took a while to arrive: 401, nothing revoked. Otherwise (never rotated at all, or
534
- * outside the window) it's reuse: the whole family is revoked, since every token
535
- * downstream of the replayed one must be assumed compromised too.
536
- * - It looked live at that same read (`revokedAt: null`), but a SIBLING request
537
- * claims it first (`store.claimRefreshTokenForRotation` returns `false`) — a
538
- * genuine, same-instant concurrent-tab race, decided purely by that method's
539
- * atomicity. Treated as a race unconditionally: no rotated/timing check needed
540
- * (there's nothing to check yet — the winner's rotation is still in flight), 401,
541
- * nothing revoked. This mirrors media-planner's own shape exactly
542
- * (`updateMany({revokedAt: null}); if (count !== 1) return false`).
704
+ * Rotation is `store.rotateRefreshToken` — the revoke of the presented token and the
705
+ * insert of its successor are one transaction (AK-23), so a failure can never leave a
706
+ * revoked token without a successor. `false` means a sibling request rotated it first
707
+ * in the moment since the read above: this request is then exactly the in-grace second
708
+ * presentation above, and is answered as one.
543
709
  *
544
- * The claim happens BEFORE the expiry check and is what actually detects that second
545
- * kind of loss — not a re-read of `revokedAt`, which would leave the exact window this
546
- * method exists to close: two concurrent requests both observing "not yet revoked"
547
- * before either write lands. See `AuthStore.claimRefreshTokenForRotation`'s doc
548
- * comment in ./types.ts.
710
+ * Any store failure anywhere in this route answers 503 `auth_store_unavailable`
711
+ * without touching the cookie.
712
+ *
713
+ * Every helper below ANSWERS the request and resolves `void` (or a boolean saying
714
+ * whether it answered): a `FastifyReply` is a thenable, so an async function that
715
+ * returned one would resolve to `undefined` once it was sent, never to the reply.
549
716
  */
550
717
  fastify.post(authRoutePath(routePrefix, "refresh"), async (request, reply) => {
551
718
  const token = request.cookies[refreshCookieName];
552
719
  if (!token) {
553
720
  return reply.code(401).send({ error: "No refresh token", code: "unauthenticated" });
554
721
  }
555
- const hash = hashRefreshToken(token);
556
- const stored = await store.findRefreshTokenByHash(hash);
722
+ try {
723
+ await refreshSession(request, reply, token, new Date());
724
+ }
725
+ catch (err) {
726
+ if (!(err instanceof AuthStoreError))
727
+ throw err;
728
+ request.log.error({ err }, "@flytedesk/app-kit/auth: /refresh AuthStore failure — answering 503, nothing revoked");
729
+ sendAuthUnavailable(reply, "auth_store_unavailable");
730
+ }
731
+ return reply;
732
+ });
733
+ /** POST {routePrefix}/refresh's body — see that route's doc comment. Store calls go
734
+ * through `storeCall`, so every storage failure surfaces as `AuthStoreError`. */
735
+ async function refreshSession(request, reply, token, now) {
736
+ const stored = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
557
737
  if (!stored) {
558
738
  reply.clearCookie(refreshCookieName, { path: cookiePath });
559
- return reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
739
+ reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
740
+ return;
741
+ }
742
+ if (stored.revocation) {
743
+ await answerRevokedPresentation(request, reply, stored, stored.revocation, token, now);
744
+ return;
745
+ }
746
+ if (stored.expiresAt < now) {
747
+ reply.clearCookie(refreshCookieName, { path: cookiePath });
748
+ reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
749
+ return;
560
750
  }
561
- if (stored.revokedAt) {
562
- const sinceRevokedMs = Date.now() - stored.revokedAt.getTime();
563
- if (stored.rotated && sinceRevokedMs < refreshReuseGraceMs) {
564
- return rejectRefresh(reply, { clearCookie: false });
751
+ if (await authorizeRefresh(request, reply, stored))
752
+ return;
753
+ const next = newRefreshToken(request, stored, successorRefreshToken(successorKey, stored.id, token));
754
+ const rotated = await storeCall("rotateRefreshToken", () => store.rotateRefreshToken(stored.id, next.row, new Date()));
755
+ if (!rotated) {
756
+ // A sibling request rotated (or revoked) it in the moment since the read above:
757
+ // answer this request as the second presentation it has become.
758
+ const reread = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
759
+ if (reread?.revocation) {
760
+ await answerRevokedPresentation(request, reply, reread, reread.revocation, token, now);
565
761
  }
566
- request.log.warn({
567
- familyId: stored.familyId,
568
- userId: stored.userId,
569
- rotated: stored.rotated,
570
- sinceRevokedMs,
571
- }, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
572
- await store.revokeFamily(stored.familyId, {
573
- type: "reuse_detected",
574
- replayedToken: stored,
575
- sinceRotatedMs: sinceRevokedMs,
576
- ip: request.ip,
577
- userAgent: request.headers["user-agent"] ?? null,
578
- });
579
- reply.clearCookie(refreshCookieName, { path: cookiePath });
580
- return reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
762
+ else {
763
+ rejectRefresh(reply, { clearCookie: false });
764
+ }
765
+ return;
581
766
  }
582
- if (stored.expiresAt < new Date()) {
583
- reply.clearCookie(refreshCookieName, { path: cookiePath });
584
- return reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
585
- }
586
- // AK-20, gap 2: run the SAME authorization pipeline loadAuthedUser runs on every
587
- // other authenticated request — including onAuthorizeUser — before minting a fresh
588
- // access token. Without this, a user the app suspended (via onAuthorizeUser) could
589
- // keep refreshing forever: their resource requests with the minted token would
590
- // still 401, but nothing ever ended the session, so their family kept rotating and
591
- // its lifetime kept extending indefinitely. A rejection here revokes the whole
592
- // family, exactly like a detected reuse — the point of rotating at all is defeated
593
- // if a rejected user's family just keeps living. Deliberately BEFORE the atomic
594
- // claim below (not after): this call can be slow (a real network round trip to
595
- // flytedesk-id), and running it between the claim and the mint would widen the
596
- // window in which a genuinely concurrent sibling request's read of this same row
597
- // could land — the atomic claim and the mint that follows it must stay adjacent,
598
- // exactly as they were before this card, or the plugin's own concurrent-tab-race
599
- // handling (gap 3) becomes measurably flakier under load, not just under test.
600
- let idpSession;
601
- try {
602
- idpSession = await store.findIdpSession(stored.idpSessionId);
767
+ reply.send(await completeSession(reply, next.token, stored.userId, stored.idpSessionId));
768
+ }
769
+ /** A presented token that is already revoked — see POST {routePrefix}/refresh's doc
770
+ * comment for the three answers. */
771
+ async function answerRevokedPresentation(request, reply, presented, revocation, token, now) {
772
+ const sinceRotatedMs = now.getTime() - revocation.at.getTime();
773
+ if (revocation.reason !== "rotated") {
774
+ request.log.info({ familyId: presented.familyId, userId: presented.userId, revokedReason: revocation.reason, sinceRotatedMs }, "@flytedesk/app-kit/auth: refresh token from an already-ended family presented — not reuse, nothing revoked");
775
+ rejectRefresh(reply, { clearCookie: true });
776
+ return;
603
777
  }
604
- catch (err) {
605
- request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: /refresh AuthStore error looking up IdP session");
606
- return reply.code(502).send({ error: "Could not load the signed-in session", code: "auth_store_failed" });
778
+ if (sinceRotatedMs >= refreshReuseGraceMs) {
779
+ await reportReuse(request, reply, presented, sinceRotatedMs, "presented outside the grace window");
780
+ return;
607
781
  }
782
+ await resumeSuccessor(request, reply, presented, token, sinceRotatedMs, now);
783
+ }
784
+ /**
785
+ * Runs the authorization pipeline for the session behind `row` before `/refresh`
786
+ * issues anything for it. When the answer is not "authorized" it answers the request
787
+ * itself (a rejection revokes the family; an outage is a 503) and returns `true`;
788
+ * otherwise `false`.
789
+ */
790
+ async function authorizeRefresh(request, reply, row) {
791
+ const idpSession = await storeCall("findIdpSession", () => store.findIdpSession(row.idpSessionId));
608
792
  if (!idpSession) {
609
- request.log.warn({ familyId: stored.familyId, idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: /refresh — no IdP session left for this refresh token — revoking family");
610
- await store.revokeFamily(stored.familyId, { type: "authorization_rejected" });
611
- reply.clearCookie(refreshCookieName, { path: cookiePath });
612
- return reply.code(401).send({ error: "Session not found", code: "unauthenticated" });
793
+ request.log.warn({ familyId: row.familyId, idpSessionId: row.idpSessionId }, "@flytedesk/app-kit/auth: /refresh — no IdP session left for this refresh token — revoking family");
794
+ await rejectFamily(reply, row);
795
+ return true;
796
+ }
797
+ const outcome = await authorizeIdpSession(request, idpSession);
798
+ if (outcome.kind === "unavailable") {
799
+ // authorizeIdpSession already logged the cause.
800
+ sendAuthUnavailable(reply, outcome.code);
801
+ return true;
802
+ }
803
+ if (outcome.kind === "rejected") {
804
+ request.log.warn({ familyId: row.familyId, userId: row.userId }, "@flytedesk/app-kit/auth: /refresh rejected by the authorization check — revoking token family");
805
+ await rejectFamily(reply, row);
806
+ return true;
807
+ }
808
+ return false;
809
+ }
810
+ /**
811
+ * Answers an in-grace second presentation of the rotated token `presented` (see POST
812
+ * {routePrefix}/refresh's doc comment) with its DIRECT successor — re-derived with
813
+ * `successorRefreshToken` from the presented row's server-side id and the token — when
814
+ * that successor is the family's live token: authorized, then issued again with a fresh
815
+ * access token, rotating nothing. Exactly one hop:
816
+ *
817
+ * - the successor was itself rotated since (the token is two or more rotations back),
818
+ * or it belongs to another family: reuse.
819
+ * - the successor's family was ended on purpose: 401, cookie cleared.
820
+ * - the successor expired: 401, cookie cleared.
821
+ * - no successor row at all — the rotation was derived under a different
822
+ * `accessTokenSecret` (a rolling secret change: see the README) or predates this
823
+ * derivation: 401, cookie kept, nothing revoked.
824
+ */
825
+ async function resumeSuccessor(request, reply, presented, token, sinceRotatedMs, now) {
826
+ const successor = successorRefreshToken(successorKey, presented.id, token);
827
+ const head = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(successor.hash));
828
+ if (!head) {
829
+ rejectRefresh(reply, { clearCookie: false });
830
+ return;
831
+ }
832
+ if (head.familyId !== presented.familyId) {
833
+ await reportReuse(request, reply, presented, sinceRotatedMs, "its derived successor belongs to another family");
834
+ return;
835
+ }
836
+ if (head.revocation?.reason === "rotated") {
837
+ await reportReuse(request, reply, presented, sinceRotatedMs, "presented two or more rotations back");
838
+ return;
613
839
  }
614
- const authorized = await authorizeIdpSession(request, idpSession);
615
- if (!authorized) {
616
- request.log.warn({ familyId: stored.familyId, userId: stored.userId }, "@flytedesk/app-kit/auth: /refresh rejected by the authorization check — revoking token family");
617
- await store.revokeFamily(stored.familyId, { type: "authorization_rejected" });
840
+ if (head.revocation || head.expiresAt < now) {
618
841
  reply.clearCookie(refreshCookieName, { path: cookiePath });
619
- return reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
620
- }
621
- const claimed = await store.claimRefreshTokenForRotation(stored.id);
622
- if (!claimed) {
623
- // A sibling request claimed it first, in the tiny window since the read above —
624
- // a same-instant concurrent-tab race, not reuse (see the route's doc comment).
625
- return rejectRefresh(reply, { clearCookie: false });
626
- }
627
- // Rotate: the claim above already revoked the used token; issue a fresh one in the
628
- // same family — limits replay if a cookie leaks, and idpSessionId carries forward
629
- // unchanged so this session's link to its flytedesk-id token pair survives the
630
- // rotation. `replacesId` records the rotation so a later reuse-vs-race decision on
631
- // THIS token (`stored.id`) can find it.
632
- const next = generateRefreshToken();
633
- const expiresAt = new Date(Date.now() + refreshTokenTtlSeconds * 1000);
634
- await store.createRefreshToken({
635
- id: randomUUID(),
636
- familyId: stored.familyId,
637
- userId: stored.userId,
638
- tokenHash: next.hash,
639
- expiresAt,
640
- idpSessionId: stored.idpSessionId,
641
- userAgent: request.headers["user-agent"] ?? null,
642
- ip: request.ip,
643
- replacesId: stored.id,
644
- });
645
- const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, stored.userId, stored.idpSessionId);
646
- reply.setCookie(refreshCookieName, next.token, refreshCookieOptions(refreshTokenTtlSeconds));
647
- return reply.send({ accessToken, accessTokenExpiresAt: accessExpiresAt.toISOString() });
648
- });
842
+ reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
843
+ return;
844
+ }
845
+ if (await authorizeRefresh(request, reply, head))
846
+ return;
847
+ request.log.warn({
848
+ familyId: head.familyId,
849
+ userId: head.userId,
850
+ sinceRotatedMs,
851
+ ...clientOf(request),
852
+ successorIssuedToIp: head.ip,
853
+ successorIssuedToUserAgent: head.userAgent,
854
+ }, "@flytedesk/app-kit/auth: just-rotated refresh token presented again inside the grace window — answering with its successor");
855
+ reply.send(await completeSession(reply, successor.token, head.userId, head.idpSessionId));
856
+ }
649
857
  /**
650
858
  * GET {routePrefix}/apps — the "All flytedesk apps" switcher feed (flytedesk-id's
651
859
  * `GET /me/apps`, MP-213/MP-209), served by the plugin itself so every consumer gets
@@ -656,18 +864,31 @@ const flytedeskAuthPlugin = async (fastify, options) => {
656
864
  * oidc client — never exposed on request.authUser (AuthedUser deliberately carries
657
865
  * no token). Every upstream failure is mapped to a typed, logged error: never an
658
866
  * unmapped 500 carrying flytedesk-id's raw response text.
867
+ *
868
+ * AK-23 (N2): `requireAuth` has already verified the session for this very request,
869
+ * so nothing that fails after it says anything about the session. An outage here —
870
+ * flytedesk-id's `/me/apps`, its `/token`, or the AuthStore — is **502
871
+ * `apps_unavailable`**: the switcher feed failed, NOT one of the `AUTH_UNAVAILABLE_CODES`
872
+ * 503s that tell a client its session could not be verified (which would put
873
+ * `useAuthSession` into `"unavailable"` over a feed that is only decoration). Only
874
+ * flytedesk-id refusing the session's token is a 401 (`idp_access_revoked`).
659
875
  */
660
- fastify.get(authRoutePath(routePrefix, "apps"), async (request, reply) => {
661
- if (!request.authUser) {
662
- return reply.code(401).send({ error: "Authentication required", code: "unauthenticated" });
876
+ fastify.get(authRoutePath(routePrefix, "apps"), { preHandler: requireAuth }, async (request, reply) => {
877
+ const { sessionId } = request.authUser;
878
+ function appsUnavailable(err, cause) {
879
+ request.log.error({ err, sessionId }, `@flytedesk/app-kit/auth: /apps — ${cause}; answering 502 apps_unavailable`);
880
+ return reply.code(502).send({ error: "The app switcher feed is unavailable right now", code: "apps_unavailable" });
881
+ }
882
+ function appsAccessRevoked(err) {
883
+ request.log.error({ err, sessionId }, "@flytedesk/app-kit/auth: /apps — flytedesk-id refused this session's token");
884
+ return reply.code(401).send({ error: "Sign-in with flytedesk-id has expired", code: "idp_access_revoked" });
663
885
  }
664
886
  let session;
665
887
  try {
666
- session = await store.findIdpSession(request.authUser.sessionId);
888
+ session = await storeCall("findIdpSession", () => store.findIdpSession(sessionId));
667
889
  }
668
890
  catch (err) {
669
- request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), sessionId: request.authUser.sessionId }, "@flytedesk/app-kit/auth: /apps AuthStore error looking up IdP session");
670
- return reply.code(502).send({ error: "Could not load the signed-in session", code: "auth_store_failed" });
891
+ return appsUnavailable(err, "AuthStore error looking up the IdP session");
671
892
  }
672
893
  if (!session) {
673
894
  return reply.code(401).send({ error: "Session not found", code: "unauthenticated" });
@@ -677,14 +898,9 @@ const flytedeskAuthPlugin = async (fastify, options) => {
677
898
  idpAccessToken = await getLiveIdpAccessToken(session);
678
899
  }
679
900
  catch (err) {
680
- const classified = classifyIdpError(err);
681
- request.log.error({ err: classified, sessionId: session.id }, classified instanceof IdpAccessRevokedError
682
- ? "@flytedesk/app-kit/auth: /apps — flytedesk-id refresh token revoked or expired"
683
- : "@flytedesk/app-kit/auth: /apps — flytedesk-id token refresh failed, possible outage");
684
- if (classified instanceof IdpAccessRevokedError) {
685
- return reply.code(401).send({ error: "Sign-in with flytedesk-id has expired", code: "idp_access_revoked" });
686
- }
687
- return reply.code(502).send({ error: "flytedesk-id is unavailable", code: "idp_unavailable" });
901
+ if (err instanceof IdpAccessRevokedError)
902
+ return appsAccessRevoked(err);
903
+ return appsUnavailable(err, "refreshing the IdP access token failed");
688
904
  }
689
905
  try {
690
906
  const apps = await oidc.fetchMyApps(idpAccessToken);
@@ -692,11 +908,9 @@ const flytedeskAuthPlugin = async (fastify, options) => {
692
908
  }
693
909
  catch (err) {
694
910
  const classified = classifyIdpError(err);
695
- request.log.error({ err: classified, sessionId: session.id }, "@flytedesk/app-kit/auth: /apps — flytedesk-id /me/apps lookup failed");
696
- if (classified instanceof IdpAccessRevokedError) {
697
- return reply.code(401).send({ error: "Sign-in with flytedesk-id has expired", code: "idp_access_revoked" });
698
- }
699
- return reply.code(502).send({ error: "flytedesk-id is unavailable", code: "idp_unavailable" });
911
+ if (classified instanceof IdpAccessRevokedError)
912
+ return appsAccessRevoked(classified);
913
+ return appsUnavailable(classified, "flytedesk-id /me/apps lookup failed");
700
914
  }
701
915
  });
702
916
  /**
@@ -709,55 +923,69 @@ const flytedeskAuthPlugin = async (fastify, options) => {
709
923
  * does, instead of every app re-declaring the same three lines and the same response
710
924
  * shape by hand.
711
925
  */
712
- fastify.get(authRoutePath(routePrefix, "me"), async (request, reply) => {
713
- if (!request.authUser) {
714
- return reply.code(401).send({ error: "Authentication required", code: "unauthenticated" });
715
- }
926
+ fastify.get(authRoutePath(routePrefix, "me"), { preHandler: requireAuth }, async (request, reply) => {
716
927
  return reply.send({ user: request.authUser });
717
928
  });
718
929
  /**
719
930
  * POST {routePrefix}/logout — revokes the whole refresh-token family (not just the
720
- * presented cookie) and the IdpSession row, then best-effort revokes the IdP-side
721
- * refresh token if flytedesk-id's discovery document advertises a revocation
722
- * endpoint. flytedesk-id currently documents NOT having one, so that step is
723
- * expected to no-op — it must never throw or block local logout either way, since
724
- * the local session is what actually keeps a signed-out browser locked out.
931
+ * presented cookie) and the IdpSession row, and best-effort revokes the IdP-side
932
+ * refresh token if flytedesk-id's discovery document advertises a revocation endpoint
933
+ * (flytedesk-id currently documents NOT having one, so that step is expected to no-op;
934
+ * `revokeIdpToken` never throws either way).
935
+ *
936
+ * AK-23: the refresh cookie is cleared in `finally`, so it is cleared on EVERY
937
+ * outcome — 3.x cleared it only on the happy path, so a throwing store call answered a
938
+ * raw 500 that left the browser holding a cookie for an unrevoked family. Each store
939
+ * step is attempted independently (`endSignedInSession`): a failed family revoke does
940
+ * not stop the IdpSession delete, which on its own already makes the family unusable
941
+ * (`/refresh` revokes a family whose IdP session is gone). Any failed step answers
942
+ * 502 `logout_store_failed`, logged — never a silent, fully-clean "signed out".
725
943
  */
726
944
  fastify.post(authRoutePath(routePrefix, "logout"), async (request, reply) => {
727
945
  const token = request.cookies[refreshCookieName];
728
- if (token) {
729
- const hash = hashRefreshToken(token);
730
- const stored = await store.findRefreshTokenByHash(hash);
731
- if (stored) {
732
- // Revoke the local family FIRST and unconditionally — that's what actually
733
- // keeps a signed-out browser locked out, independent of anything below.
734
- await store.revokeFamily(stored.familyId, { type: "signed_out" });
735
- const idpSession = await store.findIdpSession(stored.idpSessionId);
736
- if (idpSession?.idpRefreshToken) {
737
- // revokeIdpToken's own contract (see oidc-client.ts) is never to throw — this
738
- // is best-effort by design (flytedesk-id currently advertises no
739
- // revocation_endpoint at all), not a swallowed failure.
740
- await oidc.revokeIdpToken(idpSession.idpRefreshToken);
741
- }
742
- try {
743
- await store.deleteIdpSession(stored.idpSessionId);
744
- }
745
- catch (err) {
746
- // Fail loudly (AK-16): the family is already revoked above, so the browser is
747
- // locked out either way — but a store failure here means the IdpSession row
748
- // (and the IdP tokens it holds) leaked, which is worth surfacing rather than
749
- // reporting a silent, fully-clean "signed out".
750
- request.log.error({ err: new AuthStoreError("AuthStore.deleteIdpSession failed", err), idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: logout failed to delete the IdP session — the refresh token family is revoked either way");
751
- reply.clearCookie(refreshCookieName, { path: cookiePath });
752
- return reply
753
- .code(502)
754
- .send({ error: "Sign-out did not fully complete", code: "logout_store_failed" });
755
- }
756
- }
946
+ let complete = true;
947
+ try {
948
+ if (token)
949
+ complete = await endSignedInSession(request, token);
950
+ }
951
+ finally {
952
+ reply.clearCookie(refreshCookieName, { path: cookiePath });
953
+ }
954
+ if (!complete) {
955
+ return reply.code(502).send({ error: "Sign-out did not fully complete", code: "logout_store_failed" });
757
956
  }
758
- reply.clearCookie(refreshCookieName, { path: cookiePath });
759
957
  return reply.send({ loggedOut: true });
760
958
  });
959
+ /**
960
+ * Runs one logout store step, logging (never throwing) its failure. Returns the step's
961
+ * result, or `failed` so the caller can carry on with the next independent step.
962
+ */
963
+ async function logoutStep(request, method, call) {
964
+ try {
965
+ return { ok: true, value: await storeCall(method, call) };
966
+ }
967
+ catch (err) {
968
+ request.log.error({ err }, `@flytedesk/app-kit/auth: logout — AuthStore.${method} failed, sign-out incomplete`);
969
+ return { ok: false };
970
+ }
971
+ }
972
+ /** Ends the sign-in behind one refresh cookie; true when every step succeeded (or
973
+ * there was nothing to end). */
974
+ async function endSignedInSession(request, token) {
975
+ const found = await logoutStep(request, "findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
976
+ if (!found.ok)
977
+ return false;
978
+ const stored = found.value;
979
+ if (!stored)
980
+ return true;
981
+ const revoked = await logoutStep(request, "revokeFamily", () => store.revokeFamily(stored.familyId, { type: "signed_out" }, new Date()));
982
+ const session = await logoutStep(request, "findIdpSession", () => store.findIdpSession(stored.idpSessionId));
983
+ if (session.ok && session.value?.idpRefreshToken) {
984
+ await oidc.revokeIdpToken(session.value.idpRefreshToken);
985
+ }
986
+ const deleted = await logoutStep(request, "deleteIdpSession", () => store.deleteIdpSession(stored.idpSessionId));
987
+ return revoked.ok && session.ok && deleted.ok;
988
+ }
761
989
  };
762
990
  export const flytedeskAuth = fp(flytedeskAuthPlugin, {
763
991
  name: "@flytedesk/app-kit/auth",