@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.
- package/dist/auth/client/httpClient.d.ts +4 -2
- package/dist/auth/client/httpClient.js +20 -4
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +4 -2
- package/dist/auth/client/index.js +3 -1
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/silentAuth.d.ts +17 -0
- package/dist/auth/client/silentAuth.js +106 -0
- package/dist/auth/client/silentAuth.js.map +1 -0
- package/dist/auth/client/useAuthSession.d.ts +41 -14
- package/dist/auth/client/useAuthSession.js +56 -40
- package/dist/auth/client/useAuthSession.js.map +1 -1
- package/dist/auth/errors.d.ts +12 -0
- package/dist/auth/errors.js +15 -0
- package/dist/auth/errors.js.map +1 -1
- package/dist/auth/index.d.ts +22 -6
- package/dist/auth/index.js +20 -4
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.d.ts +29 -0
- package/dist/auth/oidc-client.js +50 -11
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.js +194 -62
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/shared-types.d.ts +57 -1
- package/dist/auth/shared-types.js +51 -1
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/testing/fake-idp.d.ts +12 -0
- package/dist/auth/testing/fake-idp.js +43 -0
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/types.d.ts +90 -2
- package/dist/spa-server/cli.d.ts +10 -0
- package/dist/spa-server/cli.js +60 -0
- package/dist/spa-server/cli.js.map +1 -0
- package/dist/spa-server/index.d.ts +53 -0
- package/dist/spa-server/index.js +50 -0
- package/dist/spa-server/index.js.map +1 -0
- package/package.json +14 -3
package/dist/auth/index.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
package/dist/auth/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/auth/index.ts"],"names":[],"mappings":"AAAA
|
|
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>;
|
package/dist/auth/oidc-client.js
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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",
|
|
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":"AAAA,OAAO,EAAE,kBAAkB,EAAE,SAAS,EAAwB,MAAM,MAAM,CAAC;AAC3E,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"oidc-client.js","sourceRoot":"","sources":["../../src/auth/oidc-client.ts"],"names":[],"mappings":"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"}
|
package/dist/auth/plugin.js
CHANGED
|
@@ -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 ??
|
|
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
|
-
|
|
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
|
|
226
|
-
*
|
|
227
|
-
*
|
|
228
|
-
* flytedesk-id
|
|
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(
|
|
295
|
+
fastify.get(authRoutePath(routePrefix, "login"), async (request, reply) => {
|
|
231
296
|
const { verifier, challenge } = generatePkce();
|
|
232
297
|
const state = randomState();
|
|
233
|
-
|
|
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(
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
277
|
-
return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
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(
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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(
|
|
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(
|
|
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);
|