@flytedesk/app-kit 0.7.1 → 1.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 (37) hide show
  1. package/dist/auth/client/httpClient.d.ts +4 -2
  2. package/dist/auth/client/httpClient.js +20 -4
  3. package/dist/auth/client/httpClient.js.map +1 -1
  4. package/dist/auth/client/index.d.ts +4 -2
  5. package/dist/auth/client/index.js +3 -1
  6. package/dist/auth/client/index.js.map +1 -1
  7. package/dist/auth/client/silentAuth.d.ts +17 -0
  8. package/dist/auth/client/silentAuth.js +106 -0
  9. package/dist/auth/client/silentAuth.js.map +1 -0
  10. package/dist/auth/client/useAuthSession.d.ts +41 -14
  11. package/dist/auth/client/useAuthSession.js +56 -40
  12. package/dist/auth/client/useAuthSession.js.map +1 -1
  13. package/dist/auth/errors.d.ts +12 -0
  14. package/dist/auth/errors.js +15 -0
  15. package/dist/auth/errors.js.map +1 -1
  16. package/dist/auth/index.d.ts +22 -6
  17. package/dist/auth/index.js +20 -4
  18. package/dist/auth/index.js.map +1 -1
  19. package/dist/auth/oidc-client.d.ts +29 -0
  20. package/dist/auth/oidc-client.js +50 -11
  21. package/dist/auth/oidc-client.js.map +1 -1
  22. package/dist/auth/plugin.js +194 -62
  23. package/dist/auth/plugin.js.map +1 -1
  24. package/dist/auth/shared-types.d.ts +57 -1
  25. package/dist/auth/shared-types.js +51 -1
  26. package/dist/auth/shared-types.js.map +1 -1
  27. package/dist/auth/testing/fake-idp.d.ts +12 -0
  28. package/dist/auth/testing/fake-idp.js +43 -0
  29. package/dist/auth/testing/fake-idp.js.map +1 -1
  30. package/dist/auth/types.d.ts +90 -2
  31. package/dist/spa-server/cli.d.ts +10 -0
  32. package/dist/spa-server/cli.js +60 -0
  33. package/dist/spa-server/cli.js.map +1 -0
  34. package/dist/spa-server/index.d.ts +53 -0
  35. package/dist/spa-server/index.js +50 -0
  36. package/dist/spa-server/index.js.map +1 -0
  37. package/package.json +14 -3
@@ -5,10 +5,26 @@
5
5
  * src/{routes/auth.ts,lib/flytedeskId.ts,plugins/auth.ts} so client id/secret, issuer,
6
6
  * redirect URI, and app id are config instead of hardcoded to one app.
7
7
  *
8
+ * This module (`@flytedesk/app-kit/auth`) is the standalone surface: types, the OIDC
9
+ * client, PKCE/token/error helpers, and the `requireAuth`/`requirePermission` guards —
10
+ * everything that does NOT need Fastify's `FastifyRequest` ambient-augmented with
11
+ * `authUser`. The plugin itself (`flytedeskAuth`, and the `declare module "fastify"`
12
+ * augmentation that comes with registering it) lives at the separate subpath
13
+ * `@flytedesk/app-kit/auth/plugin` (AK-14, gap 6): TypeScript applies a `declare module`
14
+ * augmentation program-wide the instant ANY file importing it is part of the
15
+ * compilation, even transitively — so as long as `flytedeskAuth` was re-exported from
16
+ * here too, importing just `generatePkce`/`randomState`/`signAccessToken` from this
17
+ * module forced the augmentation (and its `AuthedUser` shape) onto a consumer's WHOLE
18
+ * program, with no way to opt out — including a consumer with its own, differently
19
+ * shaped `request.authUser` who never even registers this plugin (`TS2717: Subsequent
20
+ * property declarations must have the same type`). Splitting the plugin into its own
21
+ * subpath means the augmentation only applies to a program that actually imports it.
22
+ *
8
23
  * Usage:
9
24
  *
10
25
  * import Fastify from "fastify";
11
- * import { flytedeskAuth, requireAuth, requirePermission } from "@flytedesk/app-kit/auth";
26
+ * import { flytedeskAuth } from "@flytedesk/app-kit/auth/plugin";
27
+ * import { requireAuth, requirePermission } from "@flytedesk/app-kit/auth";
12
28
  *
13
29
  * const app = Fastify();
14
30
  * await app.register(flytedeskAuth, {
@@ -26,10 +42,10 @@
26
42
  *
27
43
  * app.get("/me", { preHandler: requireAuth }, async (request) => request.authUser);
28
44
  */
29
- export { flytedeskAuth } from "./plugin.js";
30
45
  export { requireAuth, requirePermission } from "./guards.js";
31
- export { createOidcClient } from "./oidc-client.js";
32
- export { IdpError, IdpUnavailableError, IdpAccessRevokedError, AuthStoreError, OidcHttpError, classifyIdpError, } from "./errors.js";
46
+ export { createOidcClient, DEFAULT_OIDC_SCOPES, DEFAULT_TOKEN_REQUEST_TIMEOUT_MS } from "./oidc-client.js";
47
+ export { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, AUTH_ROUTE_SEGMENTS, authRoutePath, SILENT_AUTH_MESSAGE_SOURCE, } from "./shared-types.js";
48
+ export { IdpError, IdpUnavailableError, IdpTimeoutError, IdpAccessRevokedError, AuthStoreError, OidcHttpError, classifyIdpError, } from "./errors.js";
33
49
  export { generatePkce, randomState } from "./pkce.js";
34
50
  export { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken, } from "./tokens.js";
35
51
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAsB7D,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAIpD,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,qBAAqB,EACrB,cAAc,EACd,aAAa,EACb,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAiB,MAAM,WAAW,CAAC;AAErE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EACf,iBAAiB,GAElB,MAAM,aAAa,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,OAAO,EAAE,WAAW,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AAuB7D,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,gCAAgC,EAAE,MAAM,kBAAkB,CAAC;AAG3G,OAAO,EACL,wBAAwB,EACxB,iBAAiB,EACjB,mBAAmB,EACnB,aAAa,EACb,0BAA0B,GAE3B,MAAM,mBAAmB,CAAC;AAE3B,OAAO,EACL,QAAQ,EACR,mBAAmB,EACnB,eAAe,EACf,qBAAqB,EACrB,cAAc,EACd,aAAa,EACb,gBAAgB,GACjB,MAAM,aAAa,CAAC;AAErB,OAAO,EAAE,YAAY,EAAE,WAAW,EAAiB,MAAM,WAAW,CAAC;AAErE,OAAO,EACL,oBAAoB,EACpB,gBAAgB,EAChB,eAAe,EACf,iBAAiB,GAElB,MAAM,aAAa,CAAC"}
@@ -1,10 +1,34 @@
1
1
  import type { AppSwitcherEntry } from "./shared-types.js";
2
2
  import type { UserinfoClaims } from "./types.js";
3
+ /**
4
+ * Requested by default (AK-14, gap 1): without `offline_access`, flytedesk-id's
5
+ * oidc-provider never issues a refresh token, and every session dies the moment its
6
+ * ~1h IdP-side access token expires — a previously-shipped production bug (see
7
+ * media-planner's now-superseded `lib/flytedeskId.ts` doc comment on `buildAuthorizeUrl`).
8
+ * `offline_access` is ON by default here because a session that dies after an hour is
9
+ * almost never what a consuming app actually wants; a consumer that genuinely needs a
10
+ * narrower grant can override via `FlytedeskAuthOptions.scopes`/`OidcClientOptions.scopes`.
11
+ */
12
+ export declare const DEFAULT_OIDC_SCOPES: readonly ["openid", "email", "profile", "offline_access"];
13
+ /** Sane default for every flytedesk-id `/token` call (AK-14, gap 5) — matches
14
+ * media-planner's `IDP_TOKEN_TIMEOUT_MS`, which `services/idpSession.ts` there sizes a
15
+ * DB transaction + advisory lock around specifically so it can never be held open while
16
+ * a `/token` call is still in flight. Configurable via
17
+ * `OidcClientOptions.tokenRequestTimeoutMs`. */
18
+ export declare const DEFAULT_TOKEN_REQUEST_TIMEOUT_MS = 5000;
3
19
  export interface OidcClientOptions {
4
20
  issuer: string;
5
21
  clientId: string;
6
22
  clientSecret: string;
7
23
  redirectUri: string;
24
+ /** OAuth scopes requested by `buildAuthorizeUrl`. Default `DEFAULT_OIDC_SCOPES`
25
+ * (includes `offline_access` — see that constant's doc comment). */
26
+ scopes?: string[];
27
+ /** Bounds every `/token` call (`exchangeCodeForTokens`, `refreshIdpTokens`) via
28
+ * `AbortSignal.timeout`. Default `DEFAULT_TOKEN_REQUEST_TIMEOUT_MS`. A hung
29
+ * flytedesk-id response rejects with `IdpTimeoutError` instead of hanging the
30
+ * caller (and, transitively, whatever lock/transaction it's inside) indefinitely. */
31
+ tokenRequestTimeoutMs?: number;
8
32
  }
9
33
  export interface IdpTokenResponse {
10
34
  access_token: string;
@@ -28,9 +52,14 @@ export interface IdpAuthorization {
28
52
  }
29
53
  export type { AppSwitcherEntry };
30
54
  export interface OidcClient {
55
+ /** `prompt` is passed straight through to flytedesk-id's `/auth` endpoint —
56
+ * `"none"` (OIDC Core 3.1.2.1) is what drives silent (hidden-iframe) re-auth; see
57
+ * `../plugin.ts`'s `GET {routePrefix}/login` and `./client/silentAuth.ts`. Omitted
58
+ * entirely (not sent as an empty string) when not given. */
31
59
  buildAuthorizeUrl(opts: {
32
60
  state: string;
33
61
  challenge: string;
62
+ prompt?: string;
34
63
  }): string;
35
64
  exchangeCodeForTokens(code: string, verifier: string): Promise<IdpTokenResponse>;
36
65
  refreshIdpTokens(refreshToken: string): Promise<IdpTokenResponse>;
@@ -1,5 +1,21 @@
1
1
  import { createRemoteJWKSet, jwtVerify } from "jose";
2
- import { OidcHttpError } from "./errors.js";
2
+ import { IdpTimeoutError, IdpUnavailableError, OidcHttpError } from "./errors.js";
3
+ /**
4
+ * Requested by default (AK-14, gap 1): without `offline_access`, flytedesk-id's
5
+ * oidc-provider never issues a refresh token, and every session dies the moment its
6
+ * ~1h IdP-side access token expires — a previously-shipped production bug (see
7
+ * media-planner's now-superseded `lib/flytedeskId.ts` doc comment on `buildAuthorizeUrl`).
8
+ * `offline_access` is ON by default here because a session that dies after an hour is
9
+ * almost never what a consuming app actually wants; a consumer that genuinely needs a
10
+ * narrower grant can override via `FlytedeskAuthOptions.scopes`/`OidcClientOptions.scopes`.
11
+ */
12
+ export const DEFAULT_OIDC_SCOPES = ["openid", "email", "profile", "offline_access"];
13
+ /** Sane default for every flytedesk-id `/token` call (AK-14, gap 5) — matches
14
+ * media-planner's `IDP_TOKEN_TIMEOUT_MS`, which `services/idpSession.ts` there sizes a
15
+ * DB transaction + advisory lock around specifically so it can never be held open while
16
+ * a `/token` call is still in flight. Configurable via
17
+ * `OidcClientOptions.tokenRequestTimeoutMs`. */
18
+ export const DEFAULT_TOKEN_REQUEST_TIMEOUT_MS = 5_000;
3
19
  /**
4
20
  * One instance per FlytedeskAuthOptions (i.e. per plugin registration), not a module
5
21
  * singleton — keeps the JWKS/discovery caches below scoped to one issuer/client pair
@@ -23,15 +39,35 @@ export function createOidcClient(opts) {
23
39
  const clientSecret = encodeURIComponent(opts.clientSecret);
24
40
  return "Basic " + Buffer.from(`${clientId}:${clientSecret}`).toString("base64");
25
41
  }
42
+ const tokenRequestTimeoutMs = opts.tokenRequestTimeoutMs ?? DEFAULT_TOKEN_REQUEST_TIMEOUT_MS;
43
+ /**
44
+ * Bounded by `AbortSignal.timeout` (AK-14, gap 5): unbounded before this, a hung
45
+ * flytedesk-id `/token` response could hold open whatever the caller wraps this in —
46
+ * for a consumer following media-planner's model (`services/idpSession.ts`), a
47
+ * Postgres transaction + advisory lock sized around exactly this bound. A timeout
48
+ * throws the more specific `IdpTimeoutError`; any other network failure (DNS, refused
49
+ * connection, ...) is classified as `IdpUnavailableError` rather than propagating an
50
+ * unclassified error — both are typed, neither is swallowed.
51
+ */
26
52
  async function tokenRequest(body) {
27
- const res = await fetch(issuerUrl("/token"), {
28
- method: "POST",
29
- headers: {
30
- "Content-Type": "application/x-www-form-urlencoded",
31
- Authorization: basicAuthHeader(),
32
- },
33
- body: new URLSearchParams(body).toString(),
34
- });
53
+ let res;
54
+ try {
55
+ res = await fetch(issuerUrl("/token"), {
56
+ method: "POST",
57
+ headers: {
58
+ "Content-Type": "application/x-www-form-urlencoded",
59
+ Authorization: basicAuthHeader(),
60
+ },
61
+ body: new URLSearchParams(body).toString(),
62
+ signal: AbortSignal.timeout(tokenRequestTimeoutMs),
63
+ });
64
+ }
65
+ catch (err) {
66
+ if (err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError")) {
67
+ throw new IdpTimeoutError(`flytedesk-id /token did not respond within ${tokenRequestTimeoutMs}ms`, err);
68
+ }
69
+ throw new IdpUnavailableError(`flytedesk-id /token request failed: ${err instanceof Error ? err.message : String(err)}`, err);
70
+ }
35
71
  if (!res.ok) {
36
72
  const text = await res.text().catch(() => "");
37
73
  throw new OidcHttpError(`flytedesk-id /token returned ${res.status}: ${text}`, res.status);
@@ -52,16 +88,19 @@ export function createOidcClient(opts) {
52
88
  });
53
89
  return discoveryCache;
54
90
  }
91
+ const scope = (opts.scopes ?? DEFAULT_OIDC_SCOPES).join(" ");
55
92
  return {
56
- buildAuthorizeUrl({ state, challenge }) {
93
+ buildAuthorizeUrl({ state, challenge, prompt }) {
57
94
  const url = new URL(issuerUrl("/auth"));
58
95
  url.searchParams.set("client_id", opts.clientId);
59
96
  url.searchParams.set("redirect_uri", opts.redirectUri);
60
97
  url.searchParams.set("response_type", "code");
61
- url.searchParams.set("scope", "openid email profile");
98
+ url.searchParams.set("scope", scope);
62
99
  url.searchParams.set("code_challenge", challenge);
63
100
  url.searchParams.set("code_challenge_method", "S256");
64
101
  url.searchParams.set("state", state);
102
+ if (prompt)
103
+ url.searchParams.set("prompt", prompt);
65
104
  return url.toString();
66
105
  },
67
106
  /** Authorization Code + PKCE exchange — the one moment this app's client_secret is used. */
@@ -1 +1 @@
1
- {"version":3,"file":"oidc-client.js","sourceRoot":"","sources":["../../src/auth/oidc-client.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"oidc-client.js","sourceRoot":"","sources":["../../src/auth/oidc-client.ts"],"names":[],"mappings":"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"}
@@ -4,8 +4,8 @@ import fastifyCookie from "@fastify/cookie";
4
4
  import { AuthStoreError, IdpAccessRevokedError, 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, } from "./shared-types.js";
7
8
  import { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken } from "./tokens.js";
8
- const DEFAULT_ROUTE_PREFIX = "/auth";
9
9
  const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
10
10
  const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
11
11
  const DEFAULT_LOGIN_ATTEMPT_TTL_SECONDS = 600;
@@ -38,10 +38,19 @@ function assertRequiredOptions(options) {
38
38
  }
39
39
  const flytedeskAuthPlugin = async (fastify, options) => {
40
40
  assertRequiredOptions(options);
41
- const routePrefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
41
+ const routePrefix = options.routePrefix ?? AUTH_ROUTE_PREFIX;
42
42
  const accessTokenTtlSeconds = options.accessTokenTtlSeconds ?? DEFAULT_ACCESS_TOKEN_TTL_SECONDS;
43
43
  const refreshTokenTtlSeconds = options.refreshTokenTtlSeconds ?? DEFAULT_REFRESH_TOKEN_TTL_SECONDS;
44
44
  const loginAttemptTtlSeconds = options.loginAttemptTtlSeconds ?? DEFAULT_LOGIN_ATTEMPT_TTL_SECONDS;
45
+ // See FlytedeskAuthOptions.refreshReuseGraceMs's doc comment (AK-14, gap 3; MP-127)
46
+ // for why 2x the client's own request deadline is the right default.
47
+ const refreshReuseGraceMs = options.refreshReuseGraceMs ?? 2 * AUTH_REQUEST_DEADLINE_MS;
48
+ // The origin a silent (`prompt=none`) callback's postMessage targets — see
49
+ // sendSilentResult below. Reuses postLoginRedirect's origin (the SPA a hidden iframe's
50
+ // parent page is running) rather than a new, redundant option: every consumer of the
51
+ // silent flow already has to set postLoginRedirect to that same origin for the
52
+ // interactive flow to land anywhere sensible.
53
+ const silentAuthTargetOrigin = new URL(options.postLoginRedirect).origin;
45
54
  const store = options.store;
46
55
  if (options.authorizationCacheTtlMs) {
47
56
  fastify.log.warn({ authorizationCacheTtlMs: options.authorizationCacheTtlMs }, "@flytedesk/app-kit/auth: authorizationCacheTtlMs is set — flytedesk-id authorization " +
@@ -53,6 +62,8 @@ const flytedeskAuthPlugin = async (fastify, options) => {
53
62
  clientId: options.clientId,
54
63
  clientSecret: options.clientSecret,
55
64
  redirectUri: options.redirectUri,
65
+ scopes: options.scopes,
66
+ tokenRequestTimeoutMs: options.tokenRequestTimeoutMs,
56
67
  });
57
68
  await fastify.register(fastifyCookie, { secret: options.cookieSecret });
58
69
  fastify.decorateRequest("authUser", undefined);
@@ -83,6 +94,27 @@ const flytedeskAuthPlugin = async (fastify, options) => {
83
94
  maxAge: maxAgeSeconds,
84
95
  };
85
96
  }
97
+ /**
98
+ * The postMessage contract a silent (`prompt=none`) `{routePrefix}/callback` answers
99
+ * with (AK-14, gap 2) — read by `./client/silentAuth.ts`'s hidden-iframe listener.
100
+ * Never rendered by a top-level navigation: only ever loaded inside the hidden iframe
101
+ * `attemptSilentResume()` creates, so this is safe to keep unauthenticated/undecorated
102
+ * HTML — generalized from media-planner's `routes/auth.ts`'s `sendSilentResult`.
103
+ */
104
+ function sendSilentResult(reply, status, detail) {
105
+ const message = {
106
+ source: SILENT_AUTH_MESSAGE_SOURCE,
107
+ status,
108
+ detail: detail ?? null,
109
+ };
110
+ return reply
111
+ .type("text/html")
112
+ .send(`<!doctype html><html><body><script>` +
113
+ `if (window.parent && window.parent !== window) {` +
114
+ `window.parent.postMessage(${JSON.stringify(message)}, ${JSON.stringify(silentAuthTargetOrigin)});` +
115
+ `}` +
116
+ `</script></body></html>`);
117
+ }
86
118
  const authorizationCache = new Map();
87
119
  const EXPIRY_SKEW_MS = 5_000;
88
120
  /**
@@ -177,7 +209,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
177
209
  // never collide across apps in its catalog — strip the prefix so the consuming
178
210
  // app's own permission checks don't have to know about it.
179
211
  const prefix = `${options.appId}:`;
180
- return {
212
+ const user = {
181
213
  id: session.subject,
182
214
  email: session.email,
183
215
  name: session.name,
@@ -185,6 +217,26 @@ const flytedeskAuthPlugin = async (fastify, options) => {
185
217
  roles: authorization.roles,
186
218
  permissions: authorization.permissions.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p)),
187
219
  };
220
+ // AK-14, gap 4: the one extension point a consumer has to reject or enrich an
221
+ // otherwise-valid, still-IdP-authorized session on every request (e.g. media-planner's
222
+ // own local suspension flag) — see FlytedeskAuthOptions.onAuthorizeUser's doc comment.
223
+ // A throwing/rejecting hook is treated exactly like a `null` return: fail loudly
224
+ // (logged with context, AK-16) but never a 500 — a hook error must never be
225
+ // indistinguishable from "authenticated".
226
+ if (options.onAuthorizeUser) {
227
+ let authorized;
228
+ try {
229
+ authorized = await options.onAuthorizeUser(user, request);
230
+ }
231
+ catch (err) {
232
+ request.log.error({ err, sid: claims.sid }, "@flytedesk/app-kit/auth: onAuthorizeUser threw — treating the session as rejected");
233
+ return undefined;
234
+ }
235
+ if (!authorized)
236
+ return undefined;
237
+ return authorized;
238
+ }
239
+ return user;
188
240
  }
189
241
  // onRequest, not preHandler: this needs to run before any route-level preHandler
190
242
  // (requireAuth/requirePermission included) so request.authUser is already resolved
@@ -215,6 +267,8 @@ const flytedeskAuthPlugin = async (fastify, options) => {
215
267
  idpSessionId,
216
268
  userAgent: request.headers["user-agent"] ?? null,
217
269
  ip: request.ip,
270
+ // The first token of a fresh login — nothing precedes it in the family.
271
+ replacesId: null,
218
272
  });
219
273
  const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
220
274
  reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
@@ -222,15 +276,28 @@ const flytedeskAuthPlugin = async (fastify, options) => {
222
276
  }
223
277
  /**
224
278
  * GET {routePrefix}/login — starts the flytedesk-id sign-in. A plain redirect meant
225
- * to be hit via full-page navigation, not fetch/XHR. Generates a PKCE verifier/
226
- * challenge (S256 — flytedesk-id makes PKCE mandatory) and a CSRF `state`, stashes
227
- * both in a short-lived signed cookie tied to this browser, then redirects to
228
- * flytedesk-id's own /auth endpoint.
279
+ * to be hit via full-page navigation, not fetch/XHR (except for the `?prompt=none`
280
+ * silent case below, which is driven from a hidden iframe — same route, same
281
+ * redirect, just never shown). Generates a PKCE verifier/challenge (S256 —
282
+ * flytedesk-id makes PKCE mandatory) and a CSRF `state`, stashes both (plus whether
283
+ * this attempt is silent) in a short-lived signed cookie tied to this browser, then
284
+ * redirects to flytedesk-id's own /auth endpoint.
285
+ *
286
+ * `?prompt=none` (AK-14, gap 2; OIDC Core 3.1.2.1) is the silent-resume flow
287
+ * `./client/silentAuth.ts` drives from a hidden iframe: `prompt` is passed straight
288
+ * through to flytedesk-id, which answers from its own existing end-user session
289
+ * cookie with no visible UI at all — a fresh `code` if that session is live,
290
+ * `error=login_required` back to redirect_uri if it's not. Everything else about this
291
+ * route is identical to an interactive sign-in; only the stashed `silent` flag (which
292
+ * routes the callback's response — see GET {routePrefix}/callback below) and the
293
+ * `prompt` passed to buildAuthorizeUrl differ.
229
294
  */
230
- fastify.get(`${routePrefix}/login`, async (request, reply) => {
295
+ fastify.get(authRoutePath(routePrefix, "login"), async (request, reply) => {
231
296
  const { verifier, challenge } = generatePkce();
232
297
  const state = randomState();
233
- reply.setCookie(loginCookieName, JSON.stringify({ verifier, state }), {
298
+ const prompt = request.query.prompt;
299
+ const silent = prompt === "none";
300
+ reply.setCookie(loginCookieName, JSON.stringify({ verifier, state, silent }), {
234
301
  httpOnly: true,
235
302
  sameSite: "lax",
236
303
  secure: options.secureCookies,
@@ -238,7 +305,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
238
305
  maxAge: loginAttemptTtlSeconds,
239
306
  signed: true,
240
307
  });
241
- return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge }));
308
+ return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge, prompt }));
242
309
  });
243
310
  /**
244
311
  * GET {routePrefix}/callback — flytedesk-id's redirect_uri for this client. Validates
@@ -247,37 +314,45 @@ const flytedeskAuthPlugin = async (fastify, options) => {
247
314
  * id_token's signature via flytedesk-id's JWKS, fetches profile claims from
248
315
  * userinfo, then mints this service's own session.
249
316
  */
250
- fastify.get(`${routePrefix}/callback`, async (request, reply) => {
317
+ fastify.get(authRoutePath(routePrefix, "callback"), async (request, reply) => {
251
318
  const { code, state, error } = request.query;
252
319
  const raw = request.cookies[loginCookieName];
253
320
  reply.clearCookie(loginCookieName, { path: cookiePath });
254
- if (error) {
255
- return reply
256
- .code(400)
257
- .send({ error: `flytedesk-id sign-in failed: ${error}`, code: "idp_error" });
321
+ // Recover `silent` before any failure branch below runs, including ones that
322
+ // never get as far as validating the rest of the stash — a `prompt=none` attempt
323
+ // that fails (e.g. `error=login_required`, the expected outcome when the
324
+ // IdP-side session is genuinely gone too) still needs to answer through
325
+ // sendSilentResult, or the hidden iframe's listener would wait out its full
326
+ // timeout for a postMessage that was never going to arrive (AK-14, gap 2).
327
+ let stash;
328
+ if (raw) {
329
+ const unsigned = request.unsignCookie(raw);
330
+ if (unsigned.valid && unsigned.value) {
331
+ try {
332
+ stash = JSON.parse(unsigned.value);
333
+ }
334
+ catch {
335
+ stash = undefined;
336
+ }
337
+ }
258
338
  }
259
- if (!code || !state || !raw) {
260
- return reply.code(400).send({
261
- error: "Missing code/state, or the login attempt expired",
262
- code: "invalid_callback",
263
- });
339
+ const silent = stash?.silent === true;
340
+ function fail(status, code, message) {
341
+ if (silent)
342
+ return sendSilentResult(reply, "error", code);
343
+ return reply.code(status).send({ error: message, code });
264
344
  }
265
- const unsigned = request.unsignCookie(raw);
266
- if (!unsigned.valid || !unsigned.value) {
267
- return reply.code(400).send({
268
- error: "Login attempt cookie failed verification",
269
- code: "invalid_login_cookie",
270
- });
345
+ if (error) {
346
+ return fail(400, error, `flytedesk-id sign-in failed: ${error}`);
271
347
  }
272
- let stash;
273
- try {
274
- stash = JSON.parse(unsigned.value);
348
+ if (!code || !state || !raw) {
349
+ return fail(400, "invalid_callback", "Missing code/state, or the login attempt expired");
275
350
  }
276
- catch {
277
- return reply.code(400).send({ error: "Corrupt login attempt", code: "invalid_login_cookie" });
351
+ if (!stash) {
352
+ return fail(400, "invalid_login_cookie", "Login attempt cookie failed verification");
278
353
  }
279
354
  if (stash.state !== state) {
280
- return reply.code(400).send({ error: "State mismatch", code: "invalid_state" });
355
+ return fail(400, "invalid_state", "State mismatch");
281
356
  }
282
357
  try {
283
358
  const tokens = await oidc.exchangeCodeForTokens(code, stash.verifier);
@@ -286,16 +361,10 @@ const flytedeskAuthPlugin = async (fastify, options) => {
286
361
  const idToken = await oidc.verifyIdToken(tokens.id_token);
287
362
  const profile = await oidc.fetchUserinfo(tokens.access_token);
288
363
  if (profile.sub !== idToken.sub) {
289
- return reply.code(401).send({
290
- error: "flytedesk-id id_token/userinfo subject mismatch",
291
- code: "subject_mismatch",
292
- });
364
+ return fail(401, "subject_mismatch", "flytedesk-id id_token/userinfo subject mismatch");
293
365
  }
294
366
  if (!profile.emailVerified) {
295
- return reply.code(401).send({
296
- error: "flytedesk-id account email is not verified",
297
- code: "email_not_verified",
298
- });
367
+ return fail(401, "email_not_verified", "flytedesk-id account email is not verified");
299
368
  }
300
369
  if (options.onSignIn) {
301
370
  await options.onSignIn(profile, request);
@@ -313,28 +382,63 @@ const flytedeskAuthPlugin = async (fastify, options) => {
313
382
  // only ever revokes the chain descended from one login, never sibling sessions.
314
383
  const familyId = randomUUID();
315
384
  await issueSession(request, reply, profile.sub, idpSession.id, familyId);
385
+ if (silent) {
386
+ // No top-level navigation at all — issueSession() above already set the real
387
+ // refresh cookie on this (the API's) origin as a side effect of this same
388
+ // response, which is the only thing that actually needed to happen. This page
389
+ // just tells the hidden iframe's listener it can stop waiting; the parent page
390
+ // picks the new session up via its own regular {routePrefix}/refresh + /me,
391
+ // exactly like any other reload would (AK-14, gap 2).
392
+ return sendSilentResult(reply, "success");
393
+ }
316
394
  return reply.redirect(options.postLoginRedirect);
317
395
  }
318
396
  catch (err) {
319
397
  request.log.error({ err }, "flytedesk-id callback failed");
320
- return reply
321
- .code(502)
322
- .send({ error: "Sign-in with flytedesk-id failed", code: "idp_exchange_failed" });
398
+ return fail(502, "idp_exchange_failed", "Sign-in with flytedesk-id failed");
323
399
  }
324
400
  });
401
+ /** The one answer POST {routePrefix}/refresh gives a refresh token it will not
402
+ * rotate. `clearCookie` only for a token that is genuinely dead (unrecognized,
403
+ * expired, or reused — its whole family just ended); NOT for one that merely lost a
404
+ * concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the browser's
405
+ * fresh cookie at the same moment, and a clearing Set-Cookie on the loser's response
406
+ * can land after it and delete the fresh token. */
407
+ function rejectRefresh(reply, { clearCookie }) {
408
+ if (clearCookie)
409
+ reply.clearCookie(refreshCookieName, { path: cookiePath });
410
+ return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
411
+ }
325
412
  /**
326
413
  * POST {routePrefix}/refresh — no body, reads the refresh cookie. Rotates it on every
327
- * successful call. A hash match on a row that's already revoked means this exact
328
- * token was replayed (its legitimate rotation already consumed it) — that's treated
329
- * as a security event: the whole family is revoked, not just this row, since every
330
- * token downstream of the replayed one must be assumed compromised too.
414
+ * successful call.
331
415
  *
332
- * The claim (store.claimRefreshTokenForRotation) happens BEFORE the expiry check and
333
- * is what actually detects reuse — not a `stored.revokedAt` read, which would leave a
334
- * window between two concurrent requests both observing "not yet revoked" before
335
- * either write lands. See that method's doc comment in ./types.ts.
416
+ * A presented token can be rejected two different ways (AK-14, gap 3; MP-127; RFC
417
+ * 9700 §4.14.2), and telling them apart is the whole point of this route:
418
+ *
419
+ * - It was ALREADY revoked before this request even started
420
+ * (`stored.revokedAt`, from `findRefreshTokenByHash`'s own read, is non-null) — a
421
+ * LATE event, from this request's point of view. If it was rotated
422
+ * (`stored.rotated`) and that rotation happened less than `refreshReuseGraceMs`
423
+ * ago, it's still plausibly a concurrent-tab race whose loser's request simply
424
+ * took a while to arrive: 401, nothing revoked. Otherwise (never rotated at all, or
425
+ * outside the window) it's reuse: the whole family is revoked, since every token
426
+ * downstream of the replayed one must be assumed compromised too.
427
+ * - It looked live at that same read (`revokedAt: null`), but a SIBLING request
428
+ * claims it first (`store.claimRefreshTokenForRotation` returns `false`) — a
429
+ * genuine, same-instant concurrent-tab race, decided purely by that method's
430
+ * atomicity. Treated as a race unconditionally: no rotated/timing check needed
431
+ * (there's nothing to check yet — the winner's rotation is still in flight), 401,
432
+ * nothing revoked. This mirrors media-planner's own shape exactly
433
+ * (`updateMany({revokedAt: null}); if (count !== 1) return false`).
434
+ *
435
+ * The claim happens BEFORE the expiry check and is what actually detects that second
436
+ * kind of loss — not a re-read of `revokedAt`, which would leave the exact window this
437
+ * method exists to close: two concurrent requests both observing "not yet revoked"
438
+ * before either write lands. See `AuthStore.claimRefreshTokenForRotation`'s doc
439
+ * comment in ./types.ts.
336
440
  */
337
- fastify.post(`${routePrefix}/refresh`, async (request, reply) => {
441
+ fastify.post(authRoutePath(routePrefix, "refresh"), async (request, reply) => {
338
442
  const token = request.cookies[refreshCookieName];
339
443
  if (!token) {
340
444
  return reply.code(401).send({ error: "No refresh token", code: "unauthenticated" });
@@ -345,25 +449,36 @@ const flytedeskAuthPlugin = async (fastify, options) => {
345
449
  reply.clearCookie(refreshCookieName, { path: cookiePath });
346
450
  return reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
347
451
  }
348
- const claimed = await store.claimRefreshTokenForRotation(stored.id);
349
- if (!claimed) {
350
- // Already revoked by the time this call's UPDATE ran — either a genuine prior
351
- // revocation or a concurrent /refresh call that won the race. Both are reuse.
352
- request.log.warn({ familyId: stored.familyId, userId: stored.userId }, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
452
+ if (stored.revokedAt) {
453
+ const sinceRevokedMs = Date.now() - stored.revokedAt.getTime();
454
+ if (stored.rotated && sinceRevokedMs < refreshReuseGraceMs) {
455
+ return rejectRefresh(reply, { clearCookie: false });
456
+ }
457
+ request.log.warn({
458
+ familyId: stored.familyId,
459
+ userId: stored.userId,
460
+ rotated: stored.rotated,
461
+ sinceRevokedMs,
462
+ }, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
353
463
  await store.revokeFamily(stored.familyId);
354
464
  reply.clearCookie(refreshCookieName, { path: cookiePath });
355
465
  return reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
356
466
  }
357
467
  if (stored.expiresAt < new Date()) {
358
- // Claimed (now revoked, so it can never be presented again) but past its TTL —
359
- // an expired token, not a reuse event.
360
468
  reply.clearCookie(refreshCookieName, { path: cookiePath });
361
469
  return reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
362
470
  }
471
+ const claimed = await store.claimRefreshTokenForRotation(stored.id);
472
+ if (!claimed) {
473
+ // A sibling request claimed it first, in the tiny window since the read above —
474
+ // a same-instant concurrent-tab race, not reuse (see the route's doc comment).
475
+ return rejectRefresh(reply, { clearCookie: false });
476
+ }
363
477
  // Rotate: the claim above already revoked the used token; issue a fresh one in the
364
478
  // same family — limits replay if a cookie leaks, and idpSessionId carries forward
365
479
  // unchanged so this session's link to its flytedesk-id token pair survives the
366
- // rotation.
480
+ // rotation. `replacesId` records the rotation so a later reuse-vs-race decision on
481
+ // THIS token (`stored.id`) can find it.
367
482
  const next = generateRefreshToken();
368
483
  const expiresAt = new Date(Date.now() + refreshTokenTtlSeconds * 1000);
369
484
  await store.createRefreshToken({
@@ -375,6 +490,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
375
490
  idpSessionId: stored.idpSessionId,
376
491
  userAgent: request.headers["user-agent"] ?? null,
377
492
  ip: request.ip,
493
+ replacesId: stored.id,
378
494
  });
379
495
  const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, stored.userId, stored.idpSessionId);
380
496
  reply.setCookie(refreshCookieName, next.token, refreshCookieOptions(refreshTokenTtlSeconds));
@@ -391,7 +507,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
391
507
  * no token). Every upstream failure is mapped to a typed, logged error: never an
392
508
  * unmapped 500 carrying flytedesk-id's raw response text.
393
509
  */
394
- fastify.get(`${routePrefix}/apps`, async (request, reply) => {
510
+ fastify.get(authRoutePath(routePrefix, "apps"), async (request, reply) => {
395
511
  if (!request.authUser) {
396
512
  return reply.code(401).send({ error: "Authentication required", code: "unauthenticated" });
397
513
  }
@@ -433,6 +549,22 @@ const flytedeskAuthPlugin = async (fastify, options) => {
433
549
  return reply.code(502).send({ error: "flytedesk-id is unavailable", code: "idp_unavailable" });
434
550
  }
435
551
  });
552
+ /**
553
+ * GET {routePrefix}/me — the signed-in user, with roles/permissions from the SAME live
554
+ * per-request authorization check the `onRequest` hook above already ran (AK-18). Every
555
+ * `@flytedesk/app-kit/auth` consumer was hand-writing this exact route against its own
556
+ * `request.authUser` (sms-app's `apps/api/src/app.ts`, before this card) — extracting it
557
+ * here means the client (`./client/useAuthSession.js`) can bootstrap from ONE route the
558
+ * plugin actually serves, at the path `../shared-types.js`'s route contract says it
559
+ * does, instead of every app re-declaring the same three lines and the same response
560
+ * shape by hand.
561
+ */
562
+ fastify.get(authRoutePath(routePrefix, "me"), async (request, reply) => {
563
+ if (!request.authUser) {
564
+ return reply.code(401).send({ error: "Authentication required", code: "unauthenticated" });
565
+ }
566
+ return reply.send({ user: request.authUser });
567
+ });
436
568
  /**
437
569
  * POST {routePrefix}/logout — revokes the whole refresh-token family (not just the
438
570
  * presented cookie) and the IdpSession row, then best-effort revokes the IdP-side
@@ -441,7 +573,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
441
573
  * expected to no-op — it must never throw or block local logout either way, since
442
574
  * the local session is what actually keeps a signed-out browser locked out.
443
575
  */
444
- fastify.post(`${routePrefix}/logout`, async (request, reply) => {
576
+ fastify.post(authRoutePath(routePrefix, "logout"), async (request, reply) => {
445
577
  const token = request.cookies[refreshCookieName];
446
578
  if (token) {
447
579
  const hash = hashRefreshToken(token);