@flytedesk/app-kit 5.0.0 → 7.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 (71) hide show
  1. package/README.md +184 -41
  2. package/dist/auth/client/authSession.d.ts +168 -0
  3. package/dist/auth/client/authSession.js +581 -0
  4. package/dist/auth/client/authSession.js.map +1 -0
  5. package/dist/auth/client/browserEnvironment.d.ts +22 -0
  6. package/dist/auth/client/browserEnvironment.js +87 -0
  7. package/dist/auth/client/browserEnvironment.js.map +1 -0
  8. package/dist/auth/client/httpClient.d.ts +22 -25
  9. package/dist/auth/client/httpClient.js +49 -51
  10. package/dist/auth/client/httpClient.js.map +1 -1
  11. package/dist/auth/client/index.d.ts +15 -8
  12. package/dist/auth/client/index.js +13 -7
  13. package/dist/auth/client/index.js.map +1 -1
  14. package/dist/auth/client/testing.d.ts +6 -0
  15. package/dist/auth/client/testing.js +20 -0
  16. package/dist/auth/client/testing.js.map +1 -0
  17. package/dist/auth/client/useAuthSession.d.ts +54 -91
  18. package/dist/auth/client/useAuthSession.js +65 -252
  19. package/dist/auth/client/useAuthSession.js.map +1 -1
  20. package/dist/auth/index.d.ts +3 -4
  21. package/dist/auth/index.js +1 -2
  22. package/dist/auth/index.js.map +1 -1
  23. package/dist/auth/oidc-client.d.ts +18 -4
  24. package/dist/auth/oidc-client.js +18 -1
  25. package/dist/auth/oidc-client.js.map +1 -1
  26. package/dist/auth/plugin.js +250 -211
  27. package/dist/auth/plugin.js.map +1 -1
  28. package/dist/auth/returnTarget.d.ts +30 -0
  29. package/dist/auth/returnTarget.js +48 -0
  30. package/dist/auth/returnTarget.js.map +1 -0
  31. package/dist/auth/shared-types.d.ts +18 -41
  32. package/dist/auth/shared-types.js +18 -45
  33. package/dist/auth/shared-types.js.map +1 -1
  34. package/dist/auth/testing/fake-idp.d.ts +38 -1
  35. package/dist/auth/testing/fake-idp.js +166 -27
  36. package/dist/auth/testing/fake-idp.js.map +1 -1
  37. package/dist/auth/types.d.ts +26 -29
  38. package/dist/bigquery/errors.d.ts +19 -0
  39. package/dist/bigquery/errors.js +41 -0
  40. package/dist/bigquery/errors.js.map +1 -1
  41. package/dist/bigquery/index.d.ts +2 -2
  42. package/dist/bigquery/index.js +1 -1
  43. package/dist/bigquery/index.js.map +1 -1
  44. package/dist/bigquery/query.js +14 -1
  45. package/dist/bigquery/query.js.map +1 -1
  46. package/dist/bigquery/schema.d.ts +5 -0
  47. package/dist/bigquery/schema.js +35 -0
  48. package/dist/bigquery/schema.js.map +1 -0
  49. package/dist/bigquery/types.d.ts +47 -2
  50. package/dist/chat/index.d.ts +52 -21
  51. package/dist/chat/index.js +50 -20
  52. package/dist/chat/index.js.map +1 -1
  53. package/dist/chat/mcp/entry.d.ts +37 -0
  54. package/dist/chat/mcp/entry.js +135 -0
  55. package/dist/chat/mcp/entry.js.map +1 -0
  56. package/dist/cli/is-running-as-main.d.ts +1 -0
  57. package/dist/cli/is-running-as-main.js +54 -0
  58. package/dist/cli/is-running-as-main.js.map +1 -0
  59. package/dist/cli/sync-engine.d.ts +1 -32
  60. package/dist/cli/sync-engine.js +7 -45
  61. package/dist/cli/sync-engine.js.map +1 -1
  62. package/dist/flags/evaluate.d.ts +43 -0
  63. package/dist/flags/evaluate.js +64 -0
  64. package/dist/flags/evaluate.js.map +1 -0
  65. package/dist/flags/index.d.ts +22 -6
  66. package/dist/flags/index.js +21 -6
  67. package/dist/flags/index.js.map +1 -1
  68. package/dist/flags/plugin.js +73 -7
  69. package/dist/flags/plugin.js.map +1 -1
  70. package/dist/flags/types.d.ts +25 -0
  71. package/package.json +11 -4
@@ -2,16 +2,47 @@ import { randomUUID } from "node:crypto";
2
2
  import fp from "fastify-plugin";
3
3
  import fastifyCookie from "@fastify/cookie";
4
4
  import { AuthStoreError, IdpAccessRevokedError, IdpUnavailableError, classifyIdpError, } from "./errors.js";
5
- import { createOidcClient } from "./oidc-client.js";
5
+ import { createOidcClient, DEFAULT_OIDC_SCOPES } from "./oidc-client.js";
6
6
  import { generatePkce, randomState } from "./pkce.js";
7
- import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, SIGNIN_ERROR_QUERY_PARAM, } from "./shared-types.js";
7
+ import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, RETURN_TO_QUERY_PARAM, SIGNIN_ERROR_QUERY_PARAM, } from "./shared-types.js";
8
8
  import { requireAuth } from "./guards.js";
9
- import { renderSilentAuthPage } from "./silentAuthPage.js";
9
+ import { safeReturnTarget } from "./returnTarget.js";
10
10
  import { sendAuthUnavailable } from "./unavailable.js";
11
11
  import { generateRefreshToken, hashRefreshToken, MIN_ACCESS_TOKEN_SECRET_BYTES, refreshSuccessorKey, signAccessToken, successorRefreshToken, verifyAccessToken, } from "./tokens.js";
12
12
  const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
13
13
  const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
14
14
  const DEFAULT_LOGIN_ATTEMPT_TTL_SECONDS = 600;
15
+ /**
16
+ * The largest `Set-Cookie` a sign-in attempt may produce (AK-35). A browser keeps at
17
+ * most 4096 bytes per cookie and silently drops a larger one, which would fail that
18
+ * sign-in's callback as "expired"; this leaves headroom under that for the attributes.
19
+ * `safeReturnTarget` already bounds the target, but JSON-escaping inside the cookie value
20
+ * can grow it again (a backslash doubles), so the serialized cookie itself is checked.
21
+ */
22
+ const MAX_LOGIN_ATTEMPT_COOKIE_BYTES = 3800;
23
+ /**
24
+ * A login-attempt `state` as `randomState()` produces it (24 random bytes, base64url)
25
+ * — checked before a callback builds a cookie NAME from the `state` it was handed, so an
26
+ * attacker-chosen `state` can never shape a cookie name.
27
+ */
28
+ const LOGIN_STATE_PATTERN = /^[A-Za-z0-9_-]{32}$/;
29
+ function parseLoginAttempt(value) {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(value);
33
+ }
34
+ catch {
35
+ return null;
36
+ }
37
+ if (typeof parsed !== "object" || parsed === null)
38
+ return null;
39
+ const { verifier, returnTo, issuedAt } = parsed;
40
+ if (typeof verifier !== "string" || typeof issuedAt !== "number")
41
+ return null;
42
+ if (returnTo !== null && typeof returnTo !== "string")
43
+ return null;
44
+ return { verifier, returnTo, issuedAt };
45
+ }
15
46
  /**
16
47
  * Runs one `AuthStore` call, wrapping anything it throws in `AuthStoreError` named after
17
48
  * the method (AK-23) — so every route can tell a storage failure apart from an IdP
@@ -53,7 +84,6 @@ const REQUIRED_STRING_OPTIONS = [
53
84
  "redirectUri",
54
85
  "appId",
55
86
  "postLoginRedirect",
56
- "signInUrl",
57
87
  "accessTokenSecret",
58
88
  ];
59
89
  function assertRequiredOptions(options) {
@@ -87,15 +117,18 @@ const flytedeskAuthPlugin = async (fastify, options) => {
87
117
  // See FlytedeskAuthOptions.refreshReuseGraceMs's doc comment (AK-14, gap 3; MP-127)
88
118
  // for why 2x the client's own request deadline is the right default.
89
119
  const refreshReuseGraceMs = options.refreshReuseGraceMs ?? 2 * AUTH_REQUEST_DEADLINE_MS;
90
- // The origin a silent (`prompt=none`) callback's postMessage targets — see
91
- // sendSilentResult below. Reuses postLoginRedirect's origin (the SPA a hidden iframe's
92
- // parent page is running) rather than a new, redundant option: every consumer of the
93
- // silent flow already has to set postLoginRedirect to that same origin for the
94
- // interactive flow to land anywhere sensible.
95
- const silentAuthTargetOrigin = new URL(options.postLoginRedirect).origin;
120
+ // The app's own web origin (AK-35): every return target resolves against it, and it is
121
+ // the only Origin POST {routePrefix}/logout accepts.
122
+ const appOrigin = new URL(options.postLoginRedirect).origin;
96
123
  const store = options.store;
97
124
  // The key refresh-token rotation derives successors with (see successorRefreshToken).
98
125
  const successorKey = refreshSuccessorKey(options.accessTokenSecret);
126
+ // AK-33: whether this registration is actually requesting offline_access (the
127
+ // DEFAULT_OIDC_SCOPES default) — see the `/login` route below, which must send
128
+ // `prompt=consent` upstream whenever it is, or flytedesk-id's real oidc-provider
129
+ // silently never issues a refresh token and every session dies the moment its
130
+ // short-lived IdP access token expires (SMS-44).
131
+ const requestsOfflineAccess = (options.scopes ?? DEFAULT_OIDC_SCOPES).includes("offline_access");
99
132
  if (options.authorizationCacheTtlMs) {
100
133
  fastify.log.warn({ authorizationCacheTtlMs: options.authorizationCacheTtlMs }, "@flytedesk/app-kit/auth: authorizationCacheTtlMs is set — flytedesk-id authorization " +
101
134
  "checks will be cached instead of live. A role/permission revoked in flytedesk-id can " +
@@ -118,7 +151,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
118
151
  // would just make the browser silently refuse to set the cookie.
119
152
  const useHostPrefix = options.secureCookies;
120
153
  const refreshCookieName = useHostPrefix ? "__Host-fd_refresh" : "fd_refresh";
121
- const loginCookieName = useHostPrefix ? "__Host-fd_login_attempt" : "fd_login_attempt";
154
+ // AK-35: one login-attempt cookie PER ATTEMPT, named after its `state`, so two tabs
155
+ // signing in at once never overwrite each other's PKCE verifier (one shared name made
156
+ // the second `/login` fail the first callback's state check).
157
+ const loginCookiePrefix = useHostPrefix ? "__Host-fd_login_" : "fd_login_";
158
+ // AK-35: set by an explicit sign-out, cleared by the next successful sign-in. While it
159
+ // is present, `/login` asks flytedesk-id for `prompt=login`, so a signed-out browser
160
+ // can never be signed straight back in by a still-live flytedesk-id session.
161
+ const signedOutCookieName = useHostPrefix ? "__Host-fd_signed_out" : "fd_signed_out";
122
162
  // fastify-plugin (fp, below) keeps this plugin's routes in the SAME encapsulation
123
163
  // context as whatever it's registered under — so if a consumer nests it inside an
124
164
  // outer `{ prefix: "/api/v1" }` context, `fastify.prefix` here is already "/api/v1"
@@ -130,30 +170,28 @@ const flytedeskAuthPlugin = async (fastify, options) => {
130
170
  // scoping; Fastify's own `inject()` test helper doesn't, which is why this only
131
171
  // surfaced against a real browser (see the nested-prefix test below).
132
172
  const cookiePath = useHostPrefix ? "/" : `${fastify.prefix}${routePrefix}`;
133
- function refreshCookieOptions(maxAgeSeconds) {
134
- return {
135
- httpOnly: true,
136
- sameSite: "lax",
137
- secure: options.secureCookies,
138
- path: cookiePath,
139
- maxAge: maxAgeSeconds,
140
- };
173
+ /**
174
+ * The attributes every cookie this plugin sets carries: httpOnly, SameSite=Lax, Secure
175
+ * per `secureCookies`, scoped to `cookiePath`. The ONE definition, so a set and its
176
+ * later clear can never disagree.
177
+ */
178
+ const cookieAttributes = {
179
+ httpOnly: true,
180
+ sameSite: "lax",
181
+ secure: options.secureCookies,
182
+ path: cookiePath,
183
+ };
184
+ function cookieOptions(maxAgeSeconds, signed = false) {
185
+ return { ...cookieAttributes, maxAge: maxAgeSeconds, signed };
141
186
  }
142
187
  /**
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.
188
+ * Deletes one of this plugin's cookies with the SAME attributes it was set with —
189
+ * `Secure` above all: a browser refuses any `__Host-` Set-Cookie without `Secure`, the
190
+ * expiring one that deletes it included, so a bare `clearCookie(name, { path })` would
191
+ * leave the cookie in place on every HTTPS deployment.
149
192
  */
150
- function sendSilentResult(reply, status, detailCode) {
151
- const page = renderSilentAuthPage(status, detailCode, silentAuthTargetOrigin);
152
- return reply
153
- .header("Content-Security-Policy", page.contentSecurityPolicy)
154
- .header("X-Content-Type-Options", "nosniff")
155
- .type("text/html")
156
- .send(page.html);
193
+ function clearOwnCookie(reply, name) {
194
+ reply.clearCookie(name, cookieAttributes);
157
195
  }
158
196
  const authorizationCache = new Map();
159
197
  const EXPIRY_SKEW_MS = 5_000;
@@ -409,169 +447,150 @@ const flytedeskAuthPlugin = async (fastify, options) => {
409
447
  * cookie and mints this service's own short-lived access token for the session. */
410
448
  async function completeSession(reply, refreshToken, userId, idpSessionId) {
411
449
  const { token: accessToken, expiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
412
- reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
450
+ reply.setCookie(refreshCookieName, refreshToken, cookieOptions(refreshTokenTtlSeconds));
413
451
  return { accessToken, accessTokenExpiresAt: expiresAt.toISOString() };
414
452
  }
453
+ /** Where the browser lands for `returnTo` (null: `postLoginRedirect` itself), plus any
454
+ * extra query parameters. Re-validates the target and asserts the result stays on the
455
+ * app's origin — the stored target was validated at `/login` already, so a mismatch
456
+ * here means a bug, and it throws rather than redirect anywhere else. */
457
+ function appUrl(returnTo, params = {}) {
458
+ const target = returnTo === null ? null : safeReturnTarget(returnTo);
459
+ const url = new URL(target ?? options.postLoginRedirect, options.postLoginRedirect);
460
+ if (url.origin !== appOrigin) {
461
+ throw new Error(`@flytedesk/app-kit/auth: return target resolved off the app origin (${url.origin})`);
462
+ }
463
+ for (const [key, value] of Object.entries(params))
464
+ url.searchParams.set(key, value);
465
+ return url.toString();
466
+ }
467
+ /** The names of every login-attempt cookie this request carries. */
468
+ function loginAttemptCookieNames(request) {
469
+ return Object.keys(request.cookies).filter((name) => name.startsWith(loginCookiePrefix));
470
+ }
471
+ /** Clears every login-attempt cookie on this request except the newest one (by its
472
+ * `issuedAt`), and every one that fails verification — see `/login`'s doc comment. */
473
+ function clearStaleLoginAttempts(request, reply) {
474
+ const attempts = loginAttemptCookieNames(request).map((name) => {
475
+ const unsigned = request.unsignCookie(request.cookies[name]);
476
+ const attempt = unsigned.valid && unsigned.value ? parseLoginAttempt(unsigned.value) : null;
477
+ return { name, issuedAt: attempt?.issuedAt ?? null };
478
+ });
479
+ const newest = attempts
480
+ .filter((a) => a.issuedAt !== null)
481
+ .sort((a, b) => b.issuedAt - a.issuedAt)[0];
482
+ for (const { name } of attempts) {
483
+ if (name !== newest?.name)
484
+ clearOwnCookie(reply, name);
485
+ }
486
+ }
415
487
  /**
416
- * GET {routePrefix}/login — starts the flytedesk-id sign-in. A plain redirect meant
417
- * to be hit via full-page navigation, not fetch/XHR (except for the `?prompt=none`
418
- * silent case below, which is driven from a hidden iframe — same route, same
419
- * redirect, just never shown). Generates a PKCE verifier/challenge (S256 —
420
- * flytedesk-id makes PKCE mandatory) and a CSRF `state`, stashes both (plus whether
421
- * this attempt is silent) in a short-lived signed cookie tied to this browser, then
422
- * redirects to flytedesk-id's own /auth endpoint.
488
+ * GET {routePrefix}/login — starts a flytedesk-id sign-in. A plain redirect meant to
489
+ * be hit by a full-page navigation (the browser client's `authSession` does that;
490
+ * never fetch/XHR). Generates a PKCE verifier/challenge (S256 — flytedesk-id makes
491
+ * PKCE mandatory) and a CSRF `state`, stashes the verifier and the return target in a
492
+ * short-lived signed cookie NAMED AFTER that `state`, then redirects to flytedesk-id's
493
+ * own /auth endpoint.
423
494
  *
424
- * `?prompt=none` (AK-14, gap 2; OIDC Core 3.1.2.1) is the silent-resume flow
425
- * `./client/silentAuth.ts` drives from a hidden iframe: `prompt` is passed straight
426
- * through to flytedesk-id, which answers from its own existing end-user session
427
- * cookie with no visible UI at all — a fresh `code` if that session is live,
428
- * `error=login_required` back to redirect_uri if it's not. Everything else about this
429
- * route is identical to an interactive sign-in; only the stashed `silent` flag (which
430
- * routes the callback's response — see GET {routePrefix}/callback below) and the
431
- * `prompt` passed to buildAuthorizeUrl differ.
495
+ * `returnTo` (AK-35) is where the visitor was — `path?query#hash` on the app's origin.
496
+ * It is kept only when `safeReturnTarget` accepts it and the resulting cookie fits
497
+ * (`MAX_LOGIN_ATTEMPT_COOKIE_BYTES`). An unsafe or oversize target is DROPPED and
498
+ * logged, never answered with a 400: a raw error page on a top-level navigation is a
499
+ * dead end, and the visitor still signs in, landing on `postLoginRedirect`.
432
500
  *
433
- * `prompt` is schema-validated to the literal `"none"` (AK-20, gap 4) — the only
434
- * value this route's silent-resume handling actually understands; anything else (a
435
- * typo, or some other OIDC prompt semantics this plugin never implements) is rejected
436
- * with a 400 by Fastify's own schema validation rather than being passed straight
437
- * through to flytedesk-id's `/auth` endpoint unexamined.
501
+ * At most two attempt cookies exist at once (AK-35): every older attempt cookie this
502
+ * browser still carries is cleared except the newest, before the new one is set. With
503
+ * the `__Host-` prefix these cookies ride on every API request (`Path=/`), so
504
+ * abandoned attempts (a closed tab, Back at flytedesk-id, Try again, repeated) would
505
+ * otherwise pile up past the server's header limit and fail every request with a 431.
438
506
  */
439
- fastify.get(authRoutePath(routePrefix, "login"), {
440
- schema: {
441
- querystring: {
442
- type: "object",
443
- properties: {
444
- prompt: { type: "string", enum: ["none"] },
445
- },
446
- },
447
- },
448
- }, async (request, reply) => {
507
+ fastify.get(authRoutePath(routePrefix, "login"), async (request, reply) => {
449
508
  const { verifier, challenge } = generatePkce();
450
509
  const state = randomState();
451
- const prompt = request.query.prompt;
452
- const silent = prompt === "none";
453
- reply.setCookie(loginCookieName, JSON.stringify({ verifier, state, silent }), {
454
- httpOnly: true,
455
- sameSite: "lax",
456
- secure: options.secureCookies,
457
- path: cookiePath,
458
- maxAge: loginAttemptTtlSeconds,
459
- signed: true,
460
- });
510
+ const requested = request.query[RETURN_TO_QUERY_PARAM];
511
+ let returnTo = requested === undefined ? null : safeReturnTarget(requested);
512
+ if (requested !== undefined && returnTo === null) {
513
+ request.log.warn({ returnToLength: requested.length }, "@flytedesk/app-kit/auth: /login — unsafe or oversize returnTo dropped; landing on postLoginRedirect");
514
+ }
515
+ const cookieName = `${loginCookiePrefix}${state}`;
516
+ const attemptCookie = cookieOptions(loginAttemptTtlSeconds, true);
517
+ const issuedAt = Date.now();
518
+ const attemptValue = (target) => JSON.stringify({ verifier, returnTo: target, issuedAt });
519
+ if (returnTo !== null &&
520
+ fastify.serializeCookie(cookieName, fastify.signCookie(attemptValue(returnTo)), attemptCookie).length >
521
+ MAX_LOGIN_ATTEMPT_COOKIE_BYTES) {
522
+ request.log.warn({ returnToLength: returnTo.length }, "@flytedesk/app-kit/auth: /login — returnTo too large for the attempt cookie, dropped; landing on postLoginRedirect");
523
+ returnTo = null;
524
+ }
525
+ clearStaleLoginAttempts(request, reply);
526
+ reply.setCookie(cookieName, attemptValue(returnTo), attemptCookie);
527
+ // AK-33: flytedesk-id's real oidc-provider strips `offline_access` from the granted
528
+ // scope set whenever the authorize request's own `prompt` doesn't include
529
+ // "consent" (its check_scope.js), regardless of any previously-saved Grant — so a
530
+ // sign-in that never asks for consent NEVER gets a refresh token and the session
531
+ // dies with its ~1h IdP access token (SMS-44). flytedesk-id auto-grants consent
532
+ // server-side with no screen (every registered client is first-party).
533
+ // AK-35: after an explicit sign-out (the signed-out marker), `login` is added so
534
+ // flytedesk-id must interact even if its own session is somehow still alive — an
535
+ // explicit sign-out never bounces straight back in.
536
+ const prompts = [
537
+ ...(request.cookies[signedOutCookieName] ? ["login"] : []),
538
+ ...(requestsOfflineAccess ? ["consent"] : []),
539
+ ];
540
+ const prompt = prompts.length > 0 ? prompts.join(" ") : undefined;
461
541
  return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge, prompt }));
462
542
  });
463
543
  /**
464
- * AK-19: where every INTERACTIVE (non-silent) callback failure below redirects, with
465
- * a normalized `SignInErrorCode` — never the raw internal failure code and never any
466
- * human-readable detail (see `fail` below) — so the app's own sign-in page can render
467
- * a specific message from one query param, without each app parsing the callback's
468
- * internal error taxonomy. `options.signInUrl` is required (see its doc comment in
469
- * ./types.ts): there is no fallback to `postLoginRedirect` or any other guessed path,
470
- * so a consumer that forgets to configure it fails registration loudly instead of
471
- * shipping this exact dead end unnoticed.
472
- */
473
- function redirectToSignIn(reply, signInErrorCode) {
474
- const target = new URL(options.signInUrl);
475
- target.searchParams.set(SIGNIN_ERROR_QUERY_PARAM, signInErrorCode);
476
- return reply.redirect(target.toString());
477
- }
478
- /**
479
- * GET {routePrefix}/callback — flytedesk-id's redirect_uri for this client. Validates
480
- * `state`, exchanges `code` + the stashed PKCE verifier at flytedesk-id's /token
481
- * (Basic auth — the one place client_secret is used), verifies the returned
482
- * id_token's signature via flytedesk-id's JWKS, fetches profile claims from
483
- * userinfo, then mints this service's own session.
544
+ * GET {routePrefix}/callback — flytedesk-id's redirect_uri for this client. Reads the
545
+ * ONE login-attempt cookie named after the returned `state` (so a sibling tab's
546
+ * attempt is never touched), exchanges `code` + its PKCE verifier at flytedesk-id's
547
+ * /token (Basic auth — the one place client_secret is used), verifies the returned
548
+ * id_token's signature via flytedesk-id's JWKS, fetches profile claims from userinfo,
549
+ * then mints this service's own session and sends the browser to its return target.
550
+ *
551
+ * Every failure is logged here, server-side only, and the browser is redirected to its
552
+ * return target (or `postLoginRedirect`, when the attempt cookie is gone) with
553
+ * `signin_error=<SignInErrorCode>` — never the raw failure code or any detail. The
554
+ * browser client shows that as its sign-in-failed state with a manual retry, never
555
+ * another automatic redirect, which is what keeps a failing sign-in from looping.
484
556
  */
485
557
  fastify.get(authRoutePath(routePrefix, "callback"), async (request, reply) => {
486
558
  const { code, state, error } = request.query;
487
- const raw = request.cookies[loginCookieName];
488
- reply.clearCookie(loginCookieName, { path: cookiePath });
489
- // Recover `silent` before any failure branch below runs, including ones that
490
- // never get as far as validating the rest of the stash — a `prompt=none` attempt
491
- // that fails (e.g. `error=login_required`, the expected outcome when the
492
- // IdP-side session is genuinely gone too) still needs to answer through
493
- // sendSilentResult, or the hidden iframe's listener would wait out its full
494
- // timeout for a postMessage that was never going to arrive (AK-14, gap 2).
495
- //
496
- // AK-19, missing-cookie decision: when the login-attempt cookie itself is gone
497
- // (expired past its loginAttemptTtlSeconds, or never arrived), `stash` stays
498
- // undefined and `silent` below is deliberately `false` — this callback has no way
499
- // to recover the `silent` flag that lived only in that cookie, so it can't answer
500
- // through sendSilentResult even for an attempt that actually was silent. That's
501
- // a deliberate, documented choice, not an oversight: a silent (`prompt=none`)
502
- // attempt runs in a hidden iframe and flytedesk-id answers it within seconds, so
503
- // in practice an EXPIRED cookie (the 10-minute loginAttemptTtlSeconds) can only
504
- // mean an interactive attempt. Treating this case as interactive is also harmless
505
- // for the vanishingly unlikely case it's wrong: a redirect delivered inside a
506
- // hidden iframe is invisible to the user, and the parent page's own listener
507
- // simply times out exactly as it would have if this callback had never answered
508
- // at all. The same reasoning — and the same fallback to `silent = false` — applies
509
- // identically below whenever `stash` can't be recovered for any other reason
510
- // (the cookie's signature fails, or its JSON payload is malformed).
511
- let stash;
559
+ // The CSRF `state` check IS this lookup: the only attempt cookie that can be read
560
+ // is the one this browser was given for exactly this `state` at `/login`. A
561
+ // callback carrying any other `state` (a forged or replayed one) finds no cookie
562
+ // and fails as an expired attempt.
563
+ const cookieName = state && LOGIN_STATE_PATTERN.test(state) ? `${loginCookiePrefix}${state}` : null;
564
+ const raw = cookieName ? request.cookies[cookieName] : undefined;
565
+ if (cookieName)
566
+ clearOwnCookie(reply, cookieName);
567
+ let attempt = null;
512
568
  if (raw) {
513
569
  const unsigned = request.unsignCookie(raw);
514
- if (unsigned.valid && unsigned.value) {
515
- try {
516
- stash = JSON.parse(unsigned.value);
517
- }
518
- catch {
519
- stash = undefined;
520
- }
521
- }
570
+ if (unsigned.valid && unsigned.value)
571
+ attempt = parseLoginAttempt(unsigned.value);
522
572
  }
523
- const silent = stash?.silent === true;
524
- /**
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.
536
- */
537
- function fail(code, message, signInErrorCode, err) {
538
- if (silent) {
539
- if (code !== "login_required") {
540
- request.log.error({ err, code }, `@flytedesk/app-kit/auth: silent callback failed — ${message}`);
541
- }
542
- return sendSilentResult(reply, "error", code);
543
- }
544
- // Always .error, not .warn: every one of these is the "fail loudly" pattern
545
- // this file uses throughout (AK-16) — the ONLY place the real detail (and, for
546
- // an upstream failure, the actual err) is ever recorded, since the redirect
547
- // below carries neither.
548
- request.log.error({ err, code }, `@flytedesk/app-kit/auth: interactive callback failed — ${message}`);
549
- return redirectToSignIn(reply, signInErrorCode);
573
+ const returnTo = attempt?.returnTo ?? null;
574
+ function fail(failureCode, message, signInErrorCode, err) {
575
+ request.log.error({ err, code: failureCode }, `@flytedesk/app-kit/auth: callback failed — ${message}`);
576
+ return reply.redirect(appUrl(returnTo, { [SIGNIN_ERROR_QUERY_PARAM]: signInErrorCode }));
550
577
  }
551
578
  if (error) {
552
579
  // access_denied: the user declined at flytedesk-id — "cancelled". Every other
553
- // idp_error (including login_required reaching here on an INTERACTIVE attempt,
554
- // which is unexpected but not impossible) collapses to "other" — see
555
- // SignInErrorCode's doc comment on why this stays a closed, three-way set.
556
- const signInErrorCode = error === "access_denied" ? "cancelled" : "other";
557
- return fail(error, `flytedesk-id sign-in failed: ${error}`, signInErrorCode);
580
+ // idp_error collapses to "other" (see SignInErrorCode's doc comment).
581
+ return fail(error, `flytedesk-id sign-in failed: ${error}`, error === "access_denied" ? "cancelled" : "other");
558
582
  }
559
583
  if (!code || !state || !raw) {
560
- // Missing cookie specifically means the 10-minute login-attempt window elapsed
561
- // before flytedesk-id redirected back — "expired" (see the missing-cookie
562
- // decision above). Missing code/state with the cookie still present is a
563
- // different, much rarer malformed-request shape — "other".
564
- const signInErrorCode = !raw ? "expired" : "other";
565
- return fail("invalid_callback", "Missing code/state, or the login attempt expired", signInErrorCode);
566
- }
567
- if (!stash) {
568
- return fail("invalid_login_cookie", "Login attempt cookie failed verification", "other");
584
+ // A missing attempt cookie means the attempt window (loginAttemptTtlSeconds)
585
+ // elapsed before flytedesk-id redirected back, or the cookie never arrived —
586
+ // "expired". Missing code/state with the cookie present is a malformed request.
587
+ return fail("invalid_callback", "Missing code/state, or the login attempt expired", !raw ? "expired" : "other");
569
588
  }
570
- if (stash.state !== state) {
571
- return fail("invalid_state", "State mismatch", "other");
589
+ if (!attempt) {
590
+ return fail("invalid_login_cookie", "Login attempt cookie failed verification", "other");
572
591
  }
573
592
  try {
574
- const tokens = await oidc.exchangeCodeForTokens(code, stash.verifier);
593
+ const tokens = await oidc.exchangeCodeForTokens(code, attempt.verifier);
575
594
  // Confirms WHO signed in (sub) via a local signature check — aud/iss are
576
595
  // verified inside verifyIdToken against options.clientId/options.issuer.
577
596
  const idToken = await oidc.verifyIdToken(tokens.id_token);
@@ -583,13 +602,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
583
602
  return fail("email_not_verified", "flytedesk-id account email is not verified", "other");
584
603
  }
585
604
  if (options.onSignIn) {
586
- await options.onSignIn(profile, request, { mode: silent ? "silent" : "interactive" });
605
+ await options.onSignIn(profile, request);
587
606
  }
588
607
  const name = profile.name ?? profile.email.split("@")[0];
589
608
  const idpSession = await store.createIdpSession({
590
609
  subject: profile.sub,
591
610
  name,
592
611
  email: profile.email,
612
+ idToken: tokens.id_token,
593
613
  idpAccessToken: tokens.access_token,
594
614
  idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(tokens),
595
615
  idpRefreshToken: tokens.refresh_token ?? null,
@@ -599,16 +619,10 @@ const flytedeskAuthPlugin = async (fastify, options) => {
599
619
  const first = newRefreshToken(request, { familyId: randomUUID(), userId: profile.sub, idpSessionId: idpSession.id }, generateRefreshToken());
600
620
  await store.createRefreshToken(first.row);
601
621
  await completeSession(reply, first.token, profile.sub, idpSession.id);
602
- if (silent) {
603
- // No top-level navigation at all — completeSession() above already set the real
604
- // refresh cookie on this (the API's) origin as a side effect of this same
605
- // response, which is the only thing that actually needed to happen. This page
606
- // just tells the hidden iframe's listener it can stop waiting; the parent page
607
- // picks the new session up via its own regular {routePrefix}/refresh + /me,
608
- // exactly like any other reload would (AK-14, gap 2).
609
- return sendSilentResult(reply, "success");
610
- }
611
- return reply.redirect(options.postLoginRedirect);
622
+ // A completed sign-in ends the signed-out state (AK-35).
623
+ if (request.cookies[signedOutCookieName])
624
+ clearOwnCookie(reply, signedOutCookieName);
625
+ return reply.redirect(appUrl(returnTo));
612
626
  }
613
627
  catch (err) {
614
628
  return fail("idp_exchange_failed", "Sign-in with flytedesk-id failed", "other", err);
@@ -622,14 +636,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
622
636
  * response can land after it and delete the fresh token. */
623
637
  function rejectRefresh(reply, { clearCookie }) {
624
638
  if (clearCookie)
625
- reply.clearCookie(refreshCookieName, { path: cookiePath });
639
+ clearOwnCookie(reply, refreshCookieName);
626
640
  return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
627
641
  }
628
642
  /** Revokes the family behind a refresh the authorization pipeline rejected, and 401s
629
643
  * with the cookie cleared. */
630
644
  async function rejectFamily(reply, row) {
631
645
  await storeCall("revokeFamily", () => store.revokeFamily(row.familyId, { type: "authorization_rejected" }, new Date()));
632
- reply.clearCookie(refreshCookieName, { path: cookiePath });
646
+ clearOwnCookie(reply, refreshCookieName);
633
647
  reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
634
648
  }
635
649
  /** The request's own client fields, as the reuse audit and the resume log record them. */
@@ -658,7 +672,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
658
672
  request.log.error({ err, ...context }, "@flytedesk/app-kit/auth: refresh token reuse detected but revoking the family FAILED — family still live");
659
673
  throw err;
660
674
  }
661
- reply.clearCookie(refreshCookieName, { path: cookiePath });
675
+ clearOwnCookie(reply, refreshCookieName);
662
676
  reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
663
677
  }
664
678
  /**
@@ -735,7 +749,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
735
749
  async function refreshSession(request, reply, token, now) {
736
750
  const stored = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
737
751
  if (!stored) {
738
- reply.clearCookie(refreshCookieName, { path: cookiePath });
752
+ clearOwnCookie(reply, refreshCookieName);
739
753
  reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
740
754
  return;
741
755
  }
@@ -744,7 +758,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
744
758
  return;
745
759
  }
746
760
  if (stored.expiresAt < now) {
747
- reply.clearCookie(refreshCookieName, { path: cookiePath });
761
+ clearOwnCookie(reply, refreshCookieName);
748
762
  reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
749
763
  return;
750
764
  }
@@ -838,7 +852,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
838
852
  return;
839
853
  }
840
854
  if (head.revocation || head.expiresAt < now) {
841
- reply.clearCookie(refreshCookieName, { path: cookiePath });
855
+ clearOwnCookie(reply, refreshCookieName);
842
856
  reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
843
857
  return;
844
858
  }
@@ -918,7 +932,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
918
932
  * per-request authorization check the `onRequest` hook above already ran (AK-18). Every
919
933
  * `@flytedesk/app-kit/auth` consumer was hand-writing this exact route against its own
920
934
  * `request.authUser` (sms-app's `apps/api/src/app.ts`, before this card) — extracting it
921
- * here means the client (`./client/useAuthSession.js`) can bootstrap from ONE route the
935
+ * here means the client (`./client/authSession.js`) can bootstrap from ONE route the
922
936
  * plugin actually serves, at the path `../shared-types.js`'s route contract says it
923
937
  * does, instead of every app re-declaring the same three lines and the same response
924
938
  * shape by hand.
@@ -927,34 +941,57 @@ const flytedeskAuthPlugin = async (fastify, options) => {
927
941
  return reply.send({ user: request.authUser });
928
942
  });
929
943
  /**
930
- * POST {routePrefix}/logout — revokes the whole refresh-token family (not just the
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).
944
+ * POST {routePrefix}/logout — an explicit sign-out (AK-35). Same-origin only: the
945
+ * request's `Origin` must be the app's own (`postLoginRedirect`'s origin), or it is
946
+ * answered 403 `forbidden_origin` and nothing is touched — otherwise any site could
947
+ * sign a visitor out with a cross-site form POST.
948
+ *
949
+ * Ends this app's session: revokes the whole refresh-token family
950
+ * (not just the presented cookie) and the IdpSession row, and best-effort revokes the
951
+ * IdP-side refresh token if flytedesk-id's discovery document advertises a revocation
952
+ * endpoint (flytedesk-id currently has none, so that step is expected to no-op;
953
+ * `revokeIdpToken` never throws either way). Each store step is attempted
954
+ * independently (`endSignedInSession`): a failed family revoke does not stop the
955
+ * IdpSession delete, which on its own already makes the family unusable (`/refresh`
956
+ * revokes a family whose IdP session is gone).
957
+ *
958
+ * In a `finally`, on EVERY outcome past the Origin check: the refresh cookie is cleared
959
+ * (AK-23) and the signed-out marker is set (AK-35), so the next `/login` from this
960
+ * browser asks flytedesk-id for `prompt=login` — an explicit sign-out never bounces
961
+ * straight back in, however the rest of the sign-out went.
935
962
  *
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".
963
+ * Answers `{ endSessionUrl }` — flytedesk-id's RP-initiated logout, carrying the
964
+ * session's id_token as `id_token_hint` and `postLoginRedirect` as the
965
+ * `post_logout_redirect_uri` — which the browser client navigates to so flytedesk-id
966
+ * ends ITS session too. 200 when every step succeeded; 502 `logout_store_failed`
967
+ * (logged, and still carrying `endSessionUrl`, since this browser's session is over
968
+ * either way) when a store step failed.
943
969
  */
944
970
  fastify.post(authRoutePath(routePrefix, "logout"), async (request, reply) => {
971
+ if (request.headers.origin !== appOrigin) {
972
+ request.log.warn({ origin: request.headers.origin ?? null }, "@flytedesk/app-kit/auth: /logout refused — Origin is not the app's own");
973
+ return reply.code(403).send({ error: "Sign-out must come from the app itself", code: "forbidden_origin" });
974
+ }
945
975
  const token = request.cookies[refreshCookieName];
946
- let complete = true;
976
+ let ended = { complete: true, idToken: null };
947
977
  try {
948
978
  if (token)
949
- complete = await endSignedInSession(request, token);
979
+ ended = await endSignedInSession(request, token);
950
980
  }
951
981
  finally {
952
- reply.clearCookie(refreshCookieName, { path: cookiePath });
982
+ clearOwnCookie(reply, refreshCookieName);
983
+ reply.setCookie(signedOutCookieName, "1", cookieOptions(refreshTokenTtlSeconds));
953
984
  }
954
- if (!complete) {
955
- return reply.code(502).send({ error: "Sign-out did not fully complete", code: "logout_store_failed" });
985
+ const endSessionUrl = oidc.buildEndSessionUrl({
986
+ idTokenHint: ended.idToken,
987
+ postLogoutRedirectUri: options.postLoginRedirect,
988
+ });
989
+ if (!ended.complete) {
990
+ return reply
991
+ .code(502)
992
+ .send({ error: "Sign-out did not fully complete", code: "logout_store_failed", endSessionUrl });
956
993
  }
957
- return reply.send({ loggedOut: true });
994
+ return reply.send({ endSessionUrl });
958
995
  });
959
996
  /**
960
997
  * Runs one logout store step, logging (never throwing) its failure. Returns the step's
@@ -969,22 +1006,24 @@ const flytedeskAuthPlugin = async (fastify, options) => {
969
1006
  return { ok: false };
970
1007
  }
971
1008
  }
972
- /** Ends the sign-in behind one refresh cookie; true when every step succeeded (or
973
- * there was nothing to end). */
1009
+ /** Ends the sign-in behind one refresh cookie. */
974
1010
  async function endSignedInSession(request, token) {
975
1011
  const found = await logoutStep(request, "findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
976
1012
  if (!found.ok)
977
- return false;
1013
+ return { complete: false, idToken: null };
978
1014
  const stored = found.value;
979
1015
  if (!stored)
980
- return true;
1016
+ return { complete: true, idToken: null };
981
1017
  const revoked = await logoutStep(request, "revokeFamily", () => store.revokeFamily(stored.familyId, { type: "signed_out" }, new Date()));
982
1018
  const session = await logoutStep(request, "findIdpSession", () => store.findIdpSession(stored.idpSessionId));
983
1019
  if (session.ok && session.value?.idpRefreshToken) {
984
1020
  await oidc.revokeIdpToken(session.value.idpRefreshToken);
985
1021
  }
986
1022
  const deleted = await logoutStep(request, "deleteIdpSession", () => store.deleteIdpSession(stored.idpSessionId));
987
- return revoked.ok && session.ok && deleted.ok;
1023
+ return {
1024
+ complete: revoked.ok && session.ok && deleted.ok,
1025
+ idToken: session.ok ? (session.value?.idToken ?? null) : null,
1026
+ };
988
1027
  }
989
1028
  };
990
1029
  export const flytedeskAuth = fp(flytedeskAuthPlugin, {