@flytedesk/app-kit 6.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 (38) 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 +244 -225
  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 +29 -1
  35. package/dist/auth/testing/fake-idp.js +142 -31
  36. package/dist/auth/testing/fake-idp.js.map +1 -1
  37. package/dist/auth/types.d.ts +26 -29
  38. package/package.json +9 -3
@@ -4,14 +4,45 @@ import fastifyCookie from "@fastify/cookie";
4
4
  import { AuthStoreError, IdpAccessRevokedError, IdpUnavailableError, classifyIdpError, } from "./errors.js";
5
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,12 +117,9 @@ 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);
@@ -124,7 +151,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
124
151
  // would just make the browser silently refuse to set the cookie.
125
152
  const useHostPrefix = options.secureCookies;
126
153
  const refreshCookieName = useHostPrefix ? "__Host-fd_refresh" : "fd_refresh";
127
- 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";
128
162
  // fastify-plugin (fp, below) keeps this plugin's routes in the SAME encapsulation
129
163
  // context as whatever it's registered under — so if a consumer nests it inside an
130
164
  // outer `{ prefix: "/api/v1" }` context, `fastify.prefix` here is already "/api/v1"
@@ -136,30 +170,28 @@ const flytedeskAuthPlugin = async (fastify, options) => {
136
170
  // scoping; Fastify's own `inject()` test helper doesn't, which is why this only
137
171
  // surfaced against a real browser (see the nested-prefix test below).
138
172
  const cookiePath = useHostPrefix ? "/" : `${fastify.prefix}${routePrefix}`;
139
- function refreshCookieOptions(maxAgeSeconds) {
140
- return {
141
- httpOnly: true,
142
- sameSite: "lax",
143
- secure: options.secureCookies,
144
- path: cookiePath,
145
- maxAge: maxAgeSeconds,
146
- };
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 };
147
186
  }
148
187
  /**
149
- * The postMessage page a silent (`prompt=none`) `{routePrefix}/callback` answers with
150
- * (AK-14, gap 2), read by `./client/silentAuth.ts`'s hidden-iframe listener. Hardened
151
- * against a hostile `detail` (AK-23, the silent-callback XSS): see
152
- * `./silentAuthPage.ts` for the allowlist, the script-context escaping, and the
153
- * hash-pinned CSP set here, whose `frame-ancestors` is the same app origin the message
154
- * 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.
155
192
  */
156
- function sendSilentResult(reply, status, detailCode) {
157
- const page = renderSilentAuthPage(status, detailCode, silentAuthTargetOrigin);
158
- return reply
159
- .header("Content-Security-Policy", page.contentSecurityPolicy)
160
- .header("X-Content-Type-Options", "nosniff")
161
- .type("text/html")
162
- .send(page.html);
193
+ function clearOwnCookie(reply, name) {
194
+ reply.clearCookie(name, cookieAttributes);
163
195
  }
164
196
  const authorizationCache = new Map();
165
197
  const EXPIRY_SKEW_MS = 5_000;
@@ -415,183 +447,150 @@ const flytedeskAuthPlugin = async (fastify, options) => {
415
447
  * cookie and mints this service's own short-lived access token for the session. */
416
448
  async function completeSession(reply, refreshToken, userId, idpSessionId) {
417
449
  const { token: accessToken, expiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
418
- reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
450
+ reply.setCookie(refreshCookieName, refreshToken, cookieOptions(refreshTokenTtlSeconds));
419
451
  return { accessToken, accessTokenExpiresAt: expiresAt.toISOString() };
420
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
+ }
421
487
  /**
422
- * GET {routePrefix}/login — starts the flytedesk-id sign-in. A plain redirect meant
423
- * to be hit via full-page navigation, not fetch/XHR (except for the `?prompt=none`
424
- * silent case below, which is driven from a hidden iframe — same route, same
425
- * redirect, just never shown). Generates a PKCE verifier/challenge (S256 —
426
- * flytedesk-id makes PKCE mandatory) and a CSRF `state`, stashes both (plus whether
427
- * this attempt is silent) in a short-lived signed cookie tied to this browser, then
428
- * 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.
429
494
  *
430
- * `?prompt=none` (AK-14, gap 2; OIDC Core 3.1.2.1) is the silent-resume flow
431
- * `./client/silentAuth.ts` drives from a hidden iframe: `prompt` is passed straight
432
- * through to flytedesk-id, which answers from its own existing end-user session
433
- * cookie with no visible UI at all — a fresh `code` if that session is live,
434
- * `error=login_required` back to redirect_uri if it's not. Everything else about this
435
- * route is identical to an interactive sign-in; only the stashed `silent` flag (which
436
- * routes the callback's response — see GET {routePrefix}/callback below) and the
437
- * `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`.
438
500
  *
439
- * `prompt` is schema-validated to the literal `"none"` (AK-20, gap 4) — the only
440
- * value this route's silent-resume handling actually understands; anything else (a
441
- * typo, or some other OIDC prompt semantics this plugin never implements) is rejected
442
- * with a 400 by Fastify's own schema validation rather than being passed straight
443
- * 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.
444
506
  */
445
- fastify.get(authRoutePath(routePrefix, "login"), {
446
- schema: {
447
- querystring: {
448
- type: "object",
449
- properties: {
450
- prompt: { type: "string", enum: ["none"] },
451
- },
452
- },
453
- },
454
- }, async (request, reply) => {
507
+ fastify.get(authRoutePath(routePrefix, "login"), async (request, reply) => {
455
508
  const { verifier, challenge } = generatePkce();
456
509
  const state = randomState();
457
- const prompt = request.query.prompt;
458
- const silent = prompt === "none";
459
- reply.setCookie(loginCookieName, JSON.stringify({ verifier, state, silent }), {
460
- httpOnly: true,
461
- sameSite: "lax",
462
- secure: options.secureCookies,
463
- path: cookiePath,
464
- maxAge: loginAttemptTtlSeconds,
465
- signed: true,
466
- });
467
- // AK-33: `prompt` here is what THIS route was asked for (only ever "none", for
468
- // the silent flow) — never what flytedesk-id itself is told. flytedesk-id's real
469
- // oidc-provider strips `offline_access` from the granted scope set whenever the
470
- // authorize request's own `prompt` doesn't include "consent" (its
471
- // check_scope.js), regardless of any previously-saved Grant — so an ordinary
472
- // interactive sign-in that never asks for consent NEVER gets a refresh token,
473
- // even though DEFAULT_OIDC_SCOPES already requests offline_access (SMS-44: every
474
- // session then dies the moment its ~1h IdP access token expires). flytedesk-id
475
- // auto-grants any consent prompt server-side with no user-visible screen at all
476
- // (every registered client is first-party flytedesk software), so asking for it
477
- // costs nothing here. Silent (`prompt=none`) is unaffected — an offline_access
478
- // grant obtained through the ordinary interactive flow that preceded it already
479
- // covers the resumed session either way.
480
- const upstreamPrompt = silent ? "none" : requestsOfflineAccess ? "consent" : undefined;
481
- return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge, prompt: upstreamPrompt }));
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;
541
+ return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge, prompt }));
482
542
  });
483
543
  /**
484
- * AK-19: where every INTERACTIVE (non-silent) callback failure below redirects, with
485
- * a normalized `SignInErrorCode` — never the raw internal failure code and never any
486
- * human-readable detail (see `fail` below) — so the app's own sign-in page can render
487
- * a specific message from one query param, without each app parsing the callback's
488
- * internal error taxonomy. `options.signInUrl` is required (see its doc comment in
489
- * ./types.ts): there is no fallback to `postLoginRedirect` or any other guessed path,
490
- * so a consumer that forgets to configure it fails registration loudly instead of
491
- * shipping this exact dead end unnoticed.
492
- */
493
- function redirectToSignIn(reply, signInErrorCode) {
494
- const target = new URL(options.signInUrl);
495
- target.searchParams.set(SIGNIN_ERROR_QUERY_PARAM, signInErrorCode);
496
- return reply.redirect(target.toString());
497
- }
498
- /**
499
- * GET {routePrefix}/callback — flytedesk-id's redirect_uri for this client. Validates
500
- * `state`, exchanges `code` + the stashed PKCE verifier at flytedesk-id's /token
501
- * (Basic auth — the one place client_secret is used), verifies the returned
502
- * id_token's signature via flytedesk-id's JWKS, fetches profile claims from
503
- * 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.
504
556
  */
505
557
  fastify.get(authRoutePath(routePrefix, "callback"), async (request, reply) => {
506
558
  const { code, state, error } = request.query;
507
- const raw = request.cookies[loginCookieName];
508
- reply.clearCookie(loginCookieName, { path: cookiePath });
509
- // Recover `silent` before any failure branch below runs, including ones that
510
- // never get as far as validating the rest of the stash — a `prompt=none` attempt
511
- // that fails (e.g. `error=login_required`, the expected outcome when the
512
- // IdP-side session is genuinely gone too) still needs to answer through
513
- // sendSilentResult, or the hidden iframe's listener would wait out its full
514
- // timeout for a postMessage that was never going to arrive (AK-14, gap 2).
515
- //
516
- // AK-19, missing-cookie decision: when the login-attempt cookie itself is gone
517
- // (expired past its loginAttemptTtlSeconds, or never arrived), `stash` stays
518
- // undefined and `silent` below is deliberately `false` — this callback has no way
519
- // to recover the `silent` flag that lived only in that cookie, so it can't answer
520
- // through sendSilentResult even for an attempt that actually was silent. That's
521
- // a deliberate, documented choice, not an oversight: a silent (`prompt=none`)
522
- // attempt runs in a hidden iframe and flytedesk-id answers it within seconds, so
523
- // in practice an EXPIRED cookie (the 10-minute loginAttemptTtlSeconds) can only
524
- // mean an interactive attempt. Treating this case as interactive is also harmless
525
- // for the vanishingly unlikely case it's wrong: a redirect delivered inside a
526
- // hidden iframe is invisible to the user, and the parent page's own listener
527
- // simply times out exactly as it would have if this callback had never answered
528
- // at all. The same reasoning — and the same fallback to `silent = false` — applies
529
- // identically below whenever `stash` can't be recovered for any other reason
530
- // (the cookie's signature fails, or its JSON payload is malformed).
531
- 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;
532
568
  if (raw) {
533
569
  const unsigned = request.unsignCookie(raw);
534
- if (unsigned.valid && unsigned.value) {
535
- try {
536
- stash = JSON.parse(unsigned.value);
537
- }
538
- catch {
539
- stash = undefined;
540
- }
541
- }
570
+ if (unsigned.valid && unsigned.value)
571
+ attempt = parseLoginAttempt(unsigned.value);
542
572
  }
543
- const silent = stash?.silent === true;
544
- /**
545
- * Answers ONE callback failure. Silent (AK-14): the hardened postMessage page
546
- * (`sendSilentResult` above), whose `detail` is `code` mapped onto the fixed
547
- * `SILENT_AUTH_DETAIL_CODES` set — never `code` itself, which for an `idp_error`
548
- * is the attacker-reachable `error` query parameter (AK-23). Interactive (AK-19):
549
- * redirects (302) to the app's own sign-in page via `redirectToSignIn`, carrying
550
- * only the normalized `signInErrorCode` — never `code`, never `message`, never a
551
- * response body at all. `message` (and `err`, when the failure came from an
552
- * upstream call) is logged server-side ONLY, exactly once, right here — the one
553
- * place every failure branch below funnels through. A silent `login_required` is the
554
- * routine "no flytedesk-id session to resume" answer, not a failure, so it alone is
555
- * not logged.
556
- */
557
- function fail(code, message, signInErrorCode, err) {
558
- if (silent) {
559
- if (code !== "login_required") {
560
- request.log.error({ err, code }, `@flytedesk/app-kit/auth: silent callback failed — ${message}`);
561
- }
562
- return sendSilentResult(reply, "error", code);
563
- }
564
- // Always .error, not .warn: every one of these is the "fail loudly" pattern
565
- // this file uses throughout (AK-16) — the ONLY place the real detail (and, for
566
- // an upstream failure, the actual err) is ever recorded, since the redirect
567
- // below carries neither.
568
- request.log.error({ err, code }, `@flytedesk/app-kit/auth: interactive callback failed — ${message}`);
569
- 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 }));
570
577
  }
571
578
  if (error) {
572
579
  // access_denied: the user declined at flytedesk-id — "cancelled". Every other
573
- // idp_error (including login_required reaching here on an INTERACTIVE attempt,
574
- // which is unexpected but not impossible) collapses to "other" — see
575
- // SignInErrorCode's doc comment on why this stays a closed, three-way set.
576
- const signInErrorCode = error === "access_denied" ? "cancelled" : "other";
577
- 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");
578
582
  }
579
583
  if (!code || !state || !raw) {
580
- // Missing cookie specifically means the 10-minute login-attempt window elapsed
581
- // before flytedesk-id redirected back — "expired" (see the missing-cookie
582
- // decision above). Missing code/state with the cookie still present is a
583
- // different, much rarer malformed-request shape — "other".
584
- const signInErrorCode = !raw ? "expired" : "other";
585
- return fail("invalid_callback", "Missing code/state, or the login attempt expired", signInErrorCode);
586
- }
587
- if (!stash) {
588
- 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");
589
588
  }
590
- if (stash.state !== state) {
591
- return fail("invalid_state", "State mismatch", "other");
589
+ if (!attempt) {
590
+ return fail("invalid_login_cookie", "Login attempt cookie failed verification", "other");
592
591
  }
593
592
  try {
594
- const tokens = await oidc.exchangeCodeForTokens(code, stash.verifier);
593
+ const tokens = await oidc.exchangeCodeForTokens(code, attempt.verifier);
595
594
  // Confirms WHO signed in (sub) via a local signature check — aud/iss are
596
595
  // verified inside verifyIdToken against options.clientId/options.issuer.
597
596
  const idToken = await oidc.verifyIdToken(tokens.id_token);
@@ -603,13 +602,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
603
602
  return fail("email_not_verified", "flytedesk-id account email is not verified", "other");
604
603
  }
605
604
  if (options.onSignIn) {
606
- await options.onSignIn(profile, request, { mode: silent ? "silent" : "interactive" });
605
+ await options.onSignIn(profile, request);
607
606
  }
608
607
  const name = profile.name ?? profile.email.split("@")[0];
609
608
  const idpSession = await store.createIdpSession({
610
609
  subject: profile.sub,
611
610
  name,
612
611
  email: profile.email,
612
+ idToken: tokens.id_token,
613
613
  idpAccessToken: tokens.access_token,
614
614
  idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(tokens),
615
615
  idpRefreshToken: tokens.refresh_token ?? null,
@@ -619,16 +619,10 @@ const flytedeskAuthPlugin = async (fastify, options) => {
619
619
  const first = newRefreshToken(request, { familyId: randomUUID(), userId: profile.sub, idpSessionId: idpSession.id }, generateRefreshToken());
620
620
  await store.createRefreshToken(first.row);
621
621
  await completeSession(reply, first.token, profile.sub, idpSession.id);
622
- if (silent) {
623
- // No top-level navigation at all — completeSession() above already set the real
624
- // refresh cookie on this (the API's) origin as a side effect of this same
625
- // response, which is the only thing that actually needed to happen. This page
626
- // just tells the hidden iframe's listener it can stop waiting; the parent page
627
- // picks the new session up via its own regular {routePrefix}/refresh + /me,
628
- // exactly like any other reload would (AK-14, gap 2).
629
- return sendSilentResult(reply, "success");
630
- }
631
- 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));
632
626
  }
633
627
  catch (err) {
634
628
  return fail("idp_exchange_failed", "Sign-in with flytedesk-id failed", "other", err);
@@ -642,14 +636,14 @@ const flytedeskAuthPlugin = async (fastify, options) => {
642
636
  * response can land after it and delete the fresh token. */
643
637
  function rejectRefresh(reply, { clearCookie }) {
644
638
  if (clearCookie)
645
- reply.clearCookie(refreshCookieName, { path: cookiePath });
639
+ clearOwnCookie(reply, refreshCookieName);
646
640
  return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
647
641
  }
648
642
  /** Revokes the family behind a refresh the authorization pipeline rejected, and 401s
649
643
  * with the cookie cleared. */
650
644
  async function rejectFamily(reply, row) {
651
645
  await storeCall("revokeFamily", () => store.revokeFamily(row.familyId, { type: "authorization_rejected" }, new Date()));
652
- reply.clearCookie(refreshCookieName, { path: cookiePath });
646
+ clearOwnCookie(reply, refreshCookieName);
653
647
  reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
654
648
  }
655
649
  /** The request's own client fields, as the reuse audit and the resume log record them. */
@@ -678,7 +672,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
678
672
  request.log.error({ err, ...context }, "@flytedesk/app-kit/auth: refresh token reuse detected but revoking the family FAILED — family still live");
679
673
  throw err;
680
674
  }
681
- reply.clearCookie(refreshCookieName, { path: cookiePath });
675
+ clearOwnCookie(reply, refreshCookieName);
682
676
  reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
683
677
  }
684
678
  /**
@@ -755,7 +749,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
755
749
  async function refreshSession(request, reply, token, now) {
756
750
  const stored = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
757
751
  if (!stored) {
758
- reply.clearCookie(refreshCookieName, { path: cookiePath });
752
+ clearOwnCookie(reply, refreshCookieName);
759
753
  reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
760
754
  return;
761
755
  }
@@ -764,7 +758,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
764
758
  return;
765
759
  }
766
760
  if (stored.expiresAt < now) {
767
- reply.clearCookie(refreshCookieName, { path: cookiePath });
761
+ clearOwnCookie(reply, refreshCookieName);
768
762
  reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
769
763
  return;
770
764
  }
@@ -858,7 +852,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
858
852
  return;
859
853
  }
860
854
  if (head.revocation || head.expiresAt < now) {
861
- reply.clearCookie(refreshCookieName, { path: cookiePath });
855
+ clearOwnCookie(reply, refreshCookieName);
862
856
  reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
863
857
  return;
864
858
  }
@@ -938,7 +932,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
938
932
  * per-request authorization check the `onRequest` hook above already ran (AK-18). Every
939
933
  * `@flytedesk/app-kit/auth` consumer was hand-writing this exact route against its own
940
934
  * `request.authUser` (sms-app's `apps/api/src/app.ts`, before this card) — extracting it
941
- * 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
942
936
  * plugin actually serves, at the path `../shared-types.js`'s route contract says it
943
937
  * does, instead of every app re-declaring the same three lines and the same response
944
938
  * shape by hand.
@@ -947,34 +941,57 @@ const flytedeskAuthPlugin = async (fastify, options) => {
947
941
  return reply.send({ user: request.authUser });
948
942
  });
949
943
  /**
950
- * POST {routePrefix}/logout — revokes the whole refresh-token family (not just the
951
- * presented cookie) and the IdpSession row, and best-effort revokes the IdP-side
952
- * refresh token if flytedesk-id's discovery document advertises a revocation endpoint
953
- * (flytedesk-id currently documents NOT having one, so that step is expected to no-op;
954
- * `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.
955
962
  *
956
- * AK-23: the refresh cookie is cleared in `finally`, so it is cleared on EVERY
957
- * outcome — 3.x cleared it only on the happy path, so a throwing store call answered a
958
- * raw 500 that left the browser holding a cookie for an unrevoked family. Each store
959
- * step is attempted independently (`endSignedInSession`): a failed family revoke does
960
- * not stop the IdpSession delete, which on its own already makes the family unusable
961
- * (`/refresh` revokes a family whose IdP session is gone). Any failed step answers
962
- * 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.
963
969
  */
964
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
+ }
965
975
  const token = request.cookies[refreshCookieName];
966
- let complete = true;
976
+ let ended = { complete: true, idToken: null };
967
977
  try {
968
978
  if (token)
969
- complete = await endSignedInSession(request, token);
979
+ ended = await endSignedInSession(request, token);
970
980
  }
971
981
  finally {
972
- reply.clearCookie(refreshCookieName, { path: cookiePath });
982
+ clearOwnCookie(reply, refreshCookieName);
983
+ reply.setCookie(signedOutCookieName, "1", cookieOptions(refreshTokenTtlSeconds));
973
984
  }
974
- if (!complete) {
975
- 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 });
976
993
  }
977
- return reply.send({ loggedOut: true });
994
+ return reply.send({ endSessionUrl });
978
995
  });
979
996
  /**
980
997
  * Runs one logout store step, logging (never throwing) its failure. Returns the step's
@@ -989,22 +1006,24 @@ const flytedeskAuthPlugin = async (fastify, options) => {
989
1006
  return { ok: false };
990
1007
  }
991
1008
  }
992
- /** Ends the sign-in behind one refresh cookie; true when every step succeeded (or
993
- * there was nothing to end). */
1009
+ /** Ends the sign-in behind one refresh cookie. */
994
1010
  async function endSignedInSession(request, token) {
995
1011
  const found = await logoutStep(request, "findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
996
1012
  if (!found.ok)
997
- return false;
1013
+ return { complete: false, idToken: null };
998
1014
  const stored = found.value;
999
1015
  if (!stored)
1000
- return true;
1016
+ return { complete: true, idToken: null };
1001
1017
  const revoked = await logoutStep(request, "revokeFamily", () => store.revokeFamily(stored.familyId, { type: "signed_out" }, new Date()));
1002
1018
  const session = await logoutStep(request, "findIdpSession", () => store.findIdpSession(stored.idpSessionId));
1003
1019
  if (session.ok && session.value?.idpRefreshToken) {
1004
1020
  await oidc.revokeIdpToken(session.value.idpRefreshToken);
1005
1021
  }
1006
1022
  const deleted = await logoutStep(request, "deleteIdpSession", () => store.deleteIdpSession(stored.idpSessionId));
1007
- 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
+ };
1008
1027
  }
1009
1028
  };
1010
1029
  export const flytedeskAuth = fp(flytedeskAuthPlugin, {