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