@flytedesk/app-kit 0.8.0 → 2.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 +27 -0
- package/dist/auth/client/httpClient.d.ts +3 -1
- package/dist/auth/client/httpClient.js +5 -3
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +2 -1
- package/dist/auth/client/index.js +1 -0
- 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/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 +23 -8
- package/dist/auth/index.js +20 -5
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.d.ts +40 -0
- package/dist/auth/oidc-client.js +61 -17
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.js +303 -94
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/shared-types.d.ts +24 -0
- package/dist/auth/shared-types.js +19 -0
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/testing/fake-idp.d.ts +35 -0
- package/dist/auth/testing/fake-idp.js +101 -6
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/types.d.ts +153 -2
- package/package.json +5 -1
|
@@ -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,7 +4,7 @@ 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_ROUTE_PREFIX, authRoutePath } from "./shared-types.js";
|
|
7
|
+
import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, SILENT_AUTH_MESSAGE_SOURCE, } from "./shared-types.js";
|
|
8
8
|
import { generateRefreshToken, hashRefreshToken, signAccessToken, verifyAccessToken } from "./tokens.js";
|
|
9
9
|
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
|
|
10
10
|
const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
|
|
@@ -42,6 +42,15 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
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
|
+
idpRequestTimeoutMs: options.idpRequestTimeoutMs,
|
|
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
|
/**
|
|
@@ -90,68 +122,86 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
90
122
|
* it's not near expiry, otherwise a freshly refreshed one (persisted back via
|
|
91
123
|
* store.updateIdpSession before returning, since flytedesk-id rotates the refresh
|
|
92
124
|
* token on every use — see ./oidc-client.ts's refreshIdpTokens doc comment). Shared
|
|
93
|
-
* by
|
|
94
|
-
*
|
|
95
|
-
*
|
|
125
|
+
* by authorizeIdpSession (below, and therefore by both loadAuthedUser and POST
|
|
126
|
+
* {routePrefix}/refresh) and the GET {routePrefix}/apps route, so the refresh
|
|
127
|
+
* behaviour — and its failure classification — can't drift between call sites.
|
|
128
|
+
* Throws IdpAccessRevokedError (nothing left to refresh with, or flytedesk-id
|
|
96
129
|
* rejected the refresh token) or IdpUnavailableError (an outage) — never swallows;
|
|
97
130
|
* every caller is expected to log what it decides to do with the failure (fail
|
|
98
131
|
* loudly, AK-16).
|
|
132
|
+
*
|
|
133
|
+
* AK-20, gap 2326: the actual refresh happens under `store.withIdpSessionLock`, and
|
|
134
|
+
* RE-READS the session once the lock is held — flytedesk-id rotates its own refresh
|
|
135
|
+
* token on every use, so two concurrent requests on the same near-expiry session
|
|
136
|
+
* would otherwise both read "stale" before either write landed, both present the
|
|
137
|
+
* SAME (about-to-be-consumed) refresh token to flytedesk-id, and the loser would get
|
|
138
|
+
* an otherwise perfectly good session signed out. The re-read lets a lock loser
|
|
139
|
+
* notice a sibling already refreshed while it waited and reuse that result instead of
|
|
140
|
+
* refreshing again.
|
|
99
141
|
*/
|
|
100
142
|
async function getLiveIdpAccessToken(session) {
|
|
101
143
|
if (session.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
|
|
102
144
|
return session.idpAccessToken;
|
|
103
145
|
}
|
|
104
|
-
if (!session.idpRefreshToken) {
|
|
105
|
-
throw new IdpAccessRevokedError("No IdP refresh token on this session — re-login required");
|
|
106
|
-
}
|
|
107
146
|
try {
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
147
|
+
return await store.withIdpSessionLock(session.id, async () => {
|
|
148
|
+
const fresh = await store.findIdpSession(session.id);
|
|
149
|
+
if (!fresh) {
|
|
150
|
+
throw new IdpAccessRevokedError("IdP session no longer exists — re-login required");
|
|
151
|
+
}
|
|
152
|
+
if (fresh.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
|
|
153
|
+
// A sibling request already refreshed this session while this one waited for
|
|
154
|
+
// the lock — reuse its result rather than refreshing (and rotating) again.
|
|
155
|
+
return fresh.idpAccessToken;
|
|
156
|
+
}
|
|
157
|
+
if (!fresh.idpRefreshToken) {
|
|
158
|
+
throw new IdpAccessRevokedError("No IdP refresh token on this session — re-login required");
|
|
159
|
+
}
|
|
160
|
+
const refreshed = await oidc.refreshIdpTokens(fresh.idpRefreshToken);
|
|
161
|
+
await store.updateIdpSession(fresh.id, {
|
|
162
|
+
idpAccessToken: refreshed.access_token,
|
|
163
|
+
idpAccessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000),
|
|
164
|
+
idpRefreshToken: refreshed.refresh_token ?? fresh.idpRefreshToken,
|
|
165
|
+
});
|
|
166
|
+
return refreshed.access_token;
|
|
113
167
|
});
|
|
114
|
-
return refreshed.access_token;
|
|
115
168
|
}
|
|
116
169
|
catch (err) {
|
|
117
170
|
throw classifyIdpError(err);
|
|
118
171
|
}
|
|
119
172
|
}
|
|
120
173
|
/**
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
174
|
+
* The authorization pipeline shared by loadAuthedUser (below, run on every
|
|
175
|
+
* authenticated request from an access token's already-verified claims) and POST
|
|
176
|
+
* {routePrefix}/refresh (AK-20, gap 2) — refreshes the IdP token pair if it's near
|
|
177
|
+
* expiry, then calls flytedesk-id's authorization endpoint fresh (see
|
|
178
|
+
* FlytedeskAuthOptions.authorizationCacheTtlMs for the one opt-in exception), builds
|
|
179
|
+
* the AuthedUser, then runs the onAuthorizeUser hook if configured. Extracted into one
|
|
180
|
+
* function specifically so `/refresh` can never drift from what an ordinary
|
|
181
|
+
* authenticated request would decide: a refresh must not mint a fresh access token for
|
|
182
|
+
* a user this SAME pipeline would reject (e.g. a suspended user) — otherwise that user
|
|
183
|
+
* could keep refreshing (rotating their family, extending its lifetime) indefinitely
|
|
184
|
+
* even though every resource request the minted token is then used for still 401s.
|
|
185
|
+
* Never throws: any failure just means no AuthedUser — the caller decides what that
|
|
186
|
+
* means for its own route. Every failure that is NOT a routine "nothing to refresh
|
|
187
|
+
* with" outcome is still logged with context (fail loudly, AK-16), distinguishing a
|
|
188
|
+
* flytedesk-id outage from a genuinely revoked session.
|
|
129
189
|
*/
|
|
130
|
-
async function
|
|
131
|
-
let session;
|
|
132
|
-
try {
|
|
133
|
-
session = await store.findIdpSession(claims.sid);
|
|
134
|
-
}
|
|
135
|
-
catch (err) {
|
|
136
|
-
request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), sid: claims.sid }, "@flytedesk/app-kit/auth: AuthStore error looking up IdP session");
|
|
137
|
-
return undefined;
|
|
138
|
-
}
|
|
139
|
-
if (!session || session.subject !== claims.sub)
|
|
140
|
-
return undefined; // no session for this claim — expected, not an error
|
|
190
|
+
async function authorizeIdpSession(request, session) {
|
|
141
191
|
let idpAccessToken;
|
|
142
192
|
try {
|
|
143
193
|
idpAccessToken = await getLiveIdpAccessToken(session);
|
|
144
194
|
}
|
|
145
195
|
catch (err) {
|
|
146
196
|
const classified = classifyIdpError(err);
|
|
147
|
-
request.log.error({ err: classified, sid:
|
|
197
|
+
request.log.error({ err: classified, sid: session.id }, classified instanceof IdpAccessRevokedError
|
|
148
198
|
? "@flytedesk/app-kit/auth: flytedesk-id refresh token revoked or expired — forcing re-login"
|
|
149
199
|
: "@flytedesk/app-kit/auth: flytedesk-id token refresh failed — possible IdP outage");
|
|
150
200
|
return undefined;
|
|
151
201
|
}
|
|
152
202
|
let authorization;
|
|
153
203
|
if (options.authorizationCacheTtlMs) {
|
|
154
|
-
const cached = authorizationCache.get(
|
|
204
|
+
const cached = authorizationCache.get(session.id);
|
|
155
205
|
if (cached && cached.expiresAt > Date.now())
|
|
156
206
|
authorization = cached.value;
|
|
157
207
|
}
|
|
@@ -161,13 +211,13 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
161
211
|
}
|
|
162
212
|
catch (err) {
|
|
163
213
|
const classified = classifyIdpError(err);
|
|
164
|
-
request.log.error({ err: classified, sid:
|
|
214
|
+
request.log.error({ err: classified, sid: session.id }, classified instanceof IdpAccessRevokedError
|
|
165
215
|
? "@flytedesk/app-kit/auth: flytedesk-id revoked this session's authorization"
|
|
166
216
|
: "@flytedesk/app-kit/auth: flytedesk-id authorization lookup failed — possible IdP outage");
|
|
167
217
|
return undefined;
|
|
168
218
|
}
|
|
169
219
|
if (options.authorizationCacheTtlMs) {
|
|
170
|
-
authorizationCache.set(
|
|
220
|
+
authorizationCache.set(session.id, {
|
|
171
221
|
value: authorization,
|
|
172
222
|
expiresAt: Date.now() + options.authorizationCacheTtlMs,
|
|
173
223
|
});
|
|
@@ -177,7 +227,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
177
227
|
// never collide across apps in its catalog — strip the prefix so the consuming
|
|
178
228
|
// app's own permission checks don't have to know about it.
|
|
179
229
|
const prefix = `${options.appId}:`;
|
|
180
|
-
|
|
230
|
+
const user = {
|
|
181
231
|
id: session.subject,
|
|
182
232
|
email: session.email,
|
|
183
233
|
name: session.name,
|
|
@@ -185,6 +235,45 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
185
235
|
roles: authorization.roles,
|
|
186
236
|
permissions: authorization.permissions.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p)),
|
|
187
237
|
};
|
|
238
|
+
// AK-14, gap 4: the one extension point a consumer has to reject or enrich an
|
|
239
|
+
// otherwise-valid, still-IdP-authorized session on every request (e.g. media-planner's
|
|
240
|
+
// own local suspension flag) — see FlytedeskAuthOptions.onAuthorizeUser's doc comment.
|
|
241
|
+
// A throwing/rejecting hook is treated exactly like a `null` return: fail loudly
|
|
242
|
+
// (logged with context, AK-16) but never a 500 — a hook error must never be
|
|
243
|
+
// indistinguishable from "authenticated".
|
|
244
|
+
if (options.onAuthorizeUser) {
|
|
245
|
+
let authorized;
|
|
246
|
+
try {
|
|
247
|
+
authorized = await options.onAuthorizeUser(user, request);
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
request.log.error({ err, sid: session.id }, "@flytedesk/app-kit/auth: onAuthorizeUser threw — treating the session as rejected");
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
if (!authorized)
|
|
254
|
+
return undefined;
|
|
255
|
+
return authorized;
|
|
256
|
+
}
|
|
257
|
+
return user;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Live per-request by default: resolves `request.authUser` from the access token's
|
|
261
|
+
* `sid` by looking up the IdpSession, then running it through authorizeIdpSession
|
|
262
|
+
* above. Never throws: any failure just means no AuthedUser gets attached —
|
|
263
|
+
* requireAuth 401s from there.
|
|
264
|
+
*/
|
|
265
|
+
async function loadAuthedUser(request, claims) {
|
|
266
|
+
let session;
|
|
267
|
+
try {
|
|
268
|
+
session = await store.findIdpSession(claims.sid);
|
|
269
|
+
}
|
|
270
|
+
catch (err) {
|
|
271
|
+
request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), sid: claims.sid }, "@flytedesk/app-kit/auth: AuthStore error looking up IdP session");
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
if (!session || session.subject !== claims.sub)
|
|
275
|
+
return undefined; // no session for this claim — expected, not an error
|
|
276
|
+
return authorizeIdpSession(request, session);
|
|
188
277
|
}
|
|
189
278
|
// onRequest, not preHandler: this needs to run before any route-level preHandler
|
|
190
279
|
// (requireAuth/requirePermission included) so request.authUser is already resolved
|
|
@@ -215,6 +304,8 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
215
304
|
idpSessionId,
|
|
216
305
|
userAgent: request.headers["user-agent"] ?? null,
|
|
217
306
|
ip: request.ip,
|
|
307
|
+
// The first token of a fresh login — nothing precedes it in the family.
|
|
308
|
+
replacesId: null,
|
|
218
309
|
});
|
|
219
310
|
const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
|
|
220
311
|
reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
|
|
@@ -222,15 +313,43 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
222
313
|
}
|
|
223
314
|
/**
|
|
224
315
|
* 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
|
|
316
|
+
* to be hit via full-page navigation, not fetch/XHR (except for the `?prompt=none`
|
|
317
|
+
* silent case below, which is driven from a hidden iframe — same route, same
|
|
318
|
+
* redirect, just never shown). Generates a PKCE verifier/challenge (S256 —
|
|
319
|
+
* flytedesk-id makes PKCE mandatory) and a CSRF `state`, stashes both (plus whether
|
|
320
|
+
* this attempt is silent) in a short-lived signed cookie tied to this browser, then
|
|
321
|
+
* redirects to flytedesk-id's own /auth endpoint.
|
|
322
|
+
*
|
|
323
|
+
* `?prompt=none` (AK-14, gap 2; OIDC Core 3.1.2.1) is the silent-resume flow
|
|
324
|
+
* `./client/silentAuth.ts` drives from a hidden iframe: `prompt` is passed straight
|
|
325
|
+
* through to flytedesk-id, which answers from its own existing end-user session
|
|
326
|
+
* cookie with no visible UI at all — a fresh `code` if that session is live,
|
|
327
|
+
* `error=login_required` back to redirect_uri if it's not. Everything else about this
|
|
328
|
+
* route is identical to an interactive sign-in; only the stashed `silent` flag (which
|
|
329
|
+
* routes the callback's response — see GET {routePrefix}/callback below) and the
|
|
330
|
+
* `prompt` passed to buildAuthorizeUrl differ.
|
|
331
|
+
*
|
|
332
|
+
* `prompt` is schema-validated to the literal `"none"` (AK-20, gap 4) — the only
|
|
333
|
+
* value this route's silent-resume handling actually understands; anything else (a
|
|
334
|
+
* typo, or some other OIDC prompt semantics this plugin never implements) is rejected
|
|
335
|
+
* with a 400 by Fastify's own schema validation rather than being passed straight
|
|
336
|
+
* through to flytedesk-id's `/auth` endpoint unexamined.
|
|
229
337
|
*/
|
|
230
|
-
fastify.get(authRoutePath(routePrefix, "login"),
|
|
338
|
+
fastify.get(authRoutePath(routePrefix, "login"), {
|
|
339
|
+
schema: {
|
|
340
|
+
querystring: {
|
|
341
|
+
type: "object",
|
|
342
|
+
properties: {
|
|
343
|
+
prompt: { type: "string", enum: ["none"] },
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
}, async (request, reply) => {
|
|
231
348
|
const { verifier, challenge } = generatePkce();
|
|
232
349
|
const state = randomState();
|
|
233
|
-
|
|
350
|
+
const prompt = request.query.prompt;
|
|
351
|
+
const silent = prompt === "none";
|
|
352
|
+
reply.setCookie(loginCookieName, JSON.stringify({ verifier, state, silent }), {
|
|
234
353
|
httpOnly: true,
|
|
235
354
|
sameSite: "lax",
|
|
236
355
|
secure: options.secureCookies,
|
|
@@ -238,7 +357,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
238
357
|
maxAge: loginAttemptTtlSeconds,
|
|
239
358
|
signed: true,
|
|
240
359
|
});
|
|
241
|
-
return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge }));
|
|
360
|
+
return reply.redirect(oidc.buildAuthorizeUrl({ state, challenge, prompt }));
|
|
242
361
|
});
|
|
243
362
|
/**
|
|
244
363
|
* GET {routePrefix}/callback — flytedesk-id's redirect_uri for this client. Validates
|
|
@@ -251,33 +370,41 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
251
370
|
const { code, state, error } = request.query;
|
|
252
371
|
const raw = request.cookies[loginCookieName];
|
|
253
372
|
reply.clearCookie(loginCookieName, { path: cookiePath });
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
373
|
+
// Recover `silent` before any failure branch below runs, including ones that
|
|
374
|
+
// never get as far as validating the rest of the stash — a `prompt=none` attempt
|
|
375
|
+
// that fails (e.g. `error=login_required`, the expected outcome when the
|
|
376
|
+
// IdP-side session is genuinely gone too) still needs to answer through
|
|
377
|
+
// sendSilentResult, or the hidden iframe's listener would wait out its full
|
|
378
|
+
// timeout for a postMessage that was never going to arrive (AK-14, gap 2).
|
|
379
|
+
let stash;
|
|
380
|
+
if (raw) {
|
|
381
|
+
const unsigned = request.unsignCookie(raw);
|
|
382
|
+
if (unsigned.valid && unsigned.value) {
|
|
383
|
+
try {
|
|
384
|
+
stash = JSON.parse(unsigned.value);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
stash = undefined;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
258
390
|
}
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
});
|
|
391
|
+
const silent = stash?.silent === true;
|
|
392
|
+
function fail(status, code, message) {
|
|
393
|
+
if (silent)
|
|
394
|
+
return sendSilentResult(reply, "error", code);
|
|
395
|
+
return reply.code(status).send({ error: message, code });
|
|
264
396
|
}
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
return reply.code(400).send({
|
|
268
|
-
error: "Login attempt cookie failed verification",
|
|
269
|
-
code: "invalid_login_cookie",
|
|
270
|
-
});
|
|
397
|
+
if (error) {
|
|
398
|
+
return fail(400, error, `flytedesk-id sign-in failed: ${error}`);
|
|
271
399
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
stash = JSON.parse(unsigned.value);
|
|
400
|
+
if (!code || !state || !raw) {
|
|
401
|
+
return fail(400, "invalid_callback", "Missing code/state, or the login attempt expired");
|
|
275
402
|
}
|
|
276
|
-
|
|
277
|
-
return
|
|
403
|
+
if (!stash) {
|
|
404
|
+
return fail(400, "invalid_login_cookie", "Login attempt cookie failed verification");
|
|
278
405
|
}
|
|
279
406
|
if (stash.state !== state) {
|
|
280
|
-
return
|
|
407
|
+
return fail(400, "invalid_state", "State mismatch");
|
|
281
408
|
}
|
|
282
409
|
try {
|
|
283
410
|
const tokens = await oidc.exchangeCodeForTokens(code, stash.verifier);
|
|
@@ -286,16 +413,10 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
286
413
|
const idToken = await oidc.verifyIdToken(tokens.id_token);
|
|
287
414
|
const profile = await oidc.fetchUserinfo(tokens.access_token);
|
|
288
415
|
if (profile.sub !== idToken.sub) {
|
|
289
|
-
return
|
|
290
|
-
error: "flytedesk-id id_token/userinfo subject mismatch",
|
|
291
|
-
code: "subject_mismatch",
|
|
292
|
-
});
|
|
416
|
+
return fail(401, "subject_mismatch", "flytedesk-id id_token/userinfo subject mismatch");
|
|
293
417
|
}
|
|
294
418
|
if (!profile.emailVerified) {
|
|
295
|
-
return
|
|
296
|
-
error: "flytedesk-id account email is not verified",
|
|
297
|
-
code: "email_not_verified",
|
|
298
|
-
});
|
|
419
|
+
return fail(401, "email_not_verified", "flytedesk-id account email is not verified");
|
|
299
420
|
}
|
|
300
421
|
if (options.onSignIn) {
|
|
301
422
|
await options.onSignIn(profile, request);
|
|
@@ -313,26 +434,61 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
313
434
|
// only ever revokes the chain descended from one login, never sibling sessions.
|
|
314
435
|
const familyId = randomUUID();
|
|
315
436
|
await issueSession(request, reply, profile.sub, idpSession.id, familyId);
|
|
437
|
+
if (silent) {
|
|
438
|
+
// No top-level navigation at all — issueSession() above already set the real
|
|
439
|
+
// refresh cookie on this (the API's) origin as a side effect of this same
|
|
440
|
+
// response, which is the only thing that actually needed to happen. This page
|
|
441
|
+
// just tells the hidden iframe's listener it can stop waiting; the parent page
|
|
442
|
+
// picks the new session up via its own regular {routePrefix}/refresh + /me,
|
|
443
|
+
// exactly like any other reload would (AK-14, gap 2).
|
|
444
|
+
return sendSilentResult(reply, "success");
|
|
445
|
+
}
|
|
316
446
|
return reply.redirect(options.postLoginRedirect);
|
|
317
447
|
}
|
|
318
448
|
catch (err) {
|
|
319
449
|
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" });
|
|
450
|
+
return fail(502, "idp_exchange_failed", "Sign-in with flytedesk-id failed");
|
|
323
451
|
}
|
|
324
452
|
});
|
|
453
|
+
/** The one answer POST {routePrefix}/refresh gives a refresh token it will not
|
|
454
|
+
* rotate. `clearCookie` only for a token that is genuinely dead (unrecognized,
|
|
455
|
+
* expired, or reused — its whole family just ended); NOT for one that merely lost a
|
|
456
|
+
* concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the browser's
|
|
457
|
+
* fresh cookie at the same moment, and a clearing Set-Cookie on the loser's response
|
|
458
|
+
* can land after it and delete the fresh token. */
|
|
459
|
+
function rejectRefresh(reply, { clearCookie }) {
|
|
460
|
+
if (clearCookie)
|
|
461
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
462
|
+
return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
|
|
463
|
+
}
|
|
325
464
|
/**
|
|
326
465
|
* POST {routePrefix}/refresh — no body, reads the refresh cookie. Rotates it on every
|
|
327
|
-
* successful call.
|
|
328
|
-
*
|
|
329
|
-
*
|
|
330
|
-
*
|
|
466
|
+
* successful call.
|
|
467
|
+
*
|
|
468
|
+
* A presented token can be rejected two different ways (AK-14, gap 3; MP-127; RFC
|
|
469
|
+
* 9700 §4.14.2), and telling them apart is the whole point of this route:
|
|
331
470
|
*
|
|
332
|
-
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
*
|
|
471
|
+
* - It was ALREADY revoked before this request even started
|
|
472
|
+
* (`stored.revokedAt`, from `findRefreshTokenByHash`'s own read, is non-null) — a
|
|
473
|
+
* LATE event, from this request's point of view. If it was rotated
|
|
474
|
+
* (`stored.rotated`) and that rotation happened less than `refreshReuseGraceMs`
|
|
475
|
+
* ago, it's still plausibly a concurrent-tab race whose loser's request simply
|
|
476
|
+
* took a while to arrive: 401, nothing revoked. Otherwise (never rotated at all, or
|
|
477
|
+
* outside the window) it's reuse: the whole family is revoked, since every token
|
|
478
|
+
* downstream of the replayed one must be assumed compromised too.
|
|
479
|
+
* - It looked live at that same read (`revokedAt: null`), but a SIBLING request
|
|
480
|
+
* claims it first (`store.claimRefreshTokenForRotation` returns `false`) — a
|
|
481
|
+
* genuine, same-instant concurrent-tab race, decided purely by that method's
|
|
482
|
+
* atomicity. Treated as a race unconditionally: no rotated/timing check needed
|
|
483
|
+
* (there's nothing to check yet — the winner's rotation is still in flight), 401,
|
|
484
|
+
* nothing revoked. This mirrors media-planner's own shape exactly
|
|
485
|
+
* (`updateMany({revokedAt: null}); if (count !== 1) return false`).
|
|
486
|
+
*
|
|
487
|
+
* The claim happens BEFORE the expiry check and is what actually detects that second
|
|
488
|
+
* kind of loss — not a re-read of `revokedAt`, which would leave the exact window this
|
|
489
|
+
* method exists to close: two concurrent requests both observing "not yet revoked"
|
|
490
|
+
* before either write lands. See `AuthStore.claimRefreshTokenForRotation`'s doc
|
|
491
|
+
* comment in ./types.ts.
|
|
336
492
|
*/
|
|
337
493
|
fastify.post(authRoutePath(routePrefix, "refresh"), async (request, reply) => {
|
|
338
494
|
const token = request.cookies[refreshCookieName];
|
|
@@ -345,25 +501,77 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
345
501
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
346
502
|
return reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
|
|
347
503
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
504
|
+
if (stored.revokedAt) {
|
|
505
|
+
const sinceRevokedMs = Date.now() - stored.revokedAt.getTime();
|
|
506
|
+
if (stored.rotated && sinceRevokedMs < refreshReuseGraceMs) {
|
|
507
|
+
return rejectRefresh(reply, { clearCookie: false });
|
|
508
|
+
}
|
|
509
|
+
request.log.warn({
|
|
510
|
+
familyId: stored.familyId,
|
|
511
|
+
userId: stored.userId,
|
|
512
|
+
rotated: stored.rotated,
|
|
513
|
+
sinceRevokedMs,
|
|
514
|
+
}, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
|
|
515
|
+
await store.revokeFamily(stored.familyId, {
|
|
516
|
+
type: "reuse_detected",
|
|
517
|
+
replayedToken: stored,
|
|
518
|
+
sinceRotatedMs: sinceRevokedMs,
|
|
519
|
+
ip: request.ip,
|
|
520
|
+
userAgent: request.headers["user-agent"] ?? null,
|
|
521
|
+
});
|
|
354
522
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
355
523
|
return reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
|
|
356
524
|
}
|
|
357
525
|
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
526
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
361
527
|
return reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
|
|
362
528
|
}
|
|
529
|
+
// AK-20, gap 2: run the SAME authorization pipeline loadAuthedUser runs on every
|
|
530
|
+
// other authenticated request — including onAuthorizeUser — before minting a fresh
|
|
531
|
+
// access token. Without this, a user the app suspended (via onAuthorizeUser) could
|
|
532
|
+
// keep refreshing forever: their resource requests with the minted token would
|
|
533
|
+
// still 401, but nothing ever ended the session, so their family kept rotating and
|
|
534
|
+
// its lifetime kept extending indefinitely. A rejection here revokes the whole
|
|
535
|
+
// family, exactly like a detected reuse — the point of rotating at all is defeated
|
|
536
|
+
// if a rejected user's family just keeps living. Deliberately BEFORE the atomic
|
|
537
|
+
// claim below (not after): this call can be slow (a real network round trip to
|
|
538
|
+
// flytedesk-id), and running it between the claim and the mint would widen the
|
|
539
|
+
// window in which a genuinely concurrent sibling request's read of this same row
|
|
540
|
+
// could land — the atomic claim and the mint that follows it must stay adjacent,
|
|
541
|
+
// exactly as they were before this card, or the plugin's own concurrent-tab-race
|
|
542
|
+
// handling (gap 3) becomes measurably flakier under load, not just under test.
|
|
543
|
+
let idpSession;
|
|
544
|
+
try {
|
|
545
|
+
idpSession = await store.findIdpSession(stored.idpSessionId);
|
|
546
|
+
}
|
|
547
|
+
catch (err) {
|
|
548
|
+
request.log.error({ err: new AuthStoreError("AuthStore.findIdpSession failed", err), idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: /refresh AuthStore error looking up IdP session");
|
|
549
|
+
return reply.code(502).send({ error: "Could not load the signed-in session", code: "auth_store_failed" });
|
|
550
|
+
}
|
|
551
|
+
if (!idpSession) {
|
|
552
|
+
request.log.warn({ familyId: stored.familyId, idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: /refresh — no IdP session left for this refresh token — revoking family");
|
|
553
|
+
await store.revokeFamily(stored.familyId, { type: "authorization_rejected" });
|
|
554
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
555
|
+
return reply.code(401).send({ error: "Session not found", code: "unauthenticated" });
|
|
556
|
+
}
|
|
557
|
+
const authorized = await authorizeIdpSession(request, idpSession);
|
|
558
|
+
if (!authorized) {
|
|
559
|
+
request.log.warn({ familyId: stored.familyId, userId: stored.userId }, "@flytedesk/app-kit/auth: /refresh rejected by the authorization check — revoking token family");
|
|
560
|
+
await store.revokeFamily(stored.familyId, { type: "authorization_rejected" });
|
|
561
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
562
|
+
return reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
|
|
563
|
+
}
|
|
564
|
+
const claimed = await store.claimRefreshTokenForRotation(stored.id);
|
|
565
|
+
if (!claimed) {
|
|
566
|
+
// A sibling request claimed it first, in the tiny window since the read above —
|
|
567
|
+
// a same-instant concurrent-tab race, not reuse (see the route's doc comment).
|
|
568
|
+
return rejectRefresh(reply, { clearCookie: false });
|
|
569
|
+
}
|
|
363
570
|
// Rotate: the claim above already revoked the used token; issue a fresh one in the
|
|
364
571
|
// same family — limits replay if a cookie leaks, and idpSessionId carries forward
|
|
365
572
|
// unchanged so this session's link to its flytedesk-id token pair survives the
|
|
366
|
-
// rotation.
|
|
573
|
+
// rotation. `replacesId` records the rotation so a later reuse-vs-race decision on
|
|
574
|
+
// THIS token (`stored.id`) can find it.
|
|
367
575
|
const next = generateRefreshToken();
|
|
368
576
|
const expiresAt = new Date(Date.now() + refreshTokenTtlSeconds * 1000);
|
|
369
577
|
await store.createRefreshToken({
|
|
@@ -375,6 +583,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
375
583
|
idpSessionId: stored.idpSessionId,
|
|
376
584
|
userAgent: request.headers["user-agent"] ?? null,
|
|
377
585
|
ip: request.ip,
|
|
586
|
+
replacesId: stored.id,
|
|
378
587
|
});
|
|
379
588
|
const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, stored.userId, stored.idpSessionId);
|
|
380
589
|
reply.setCookie(refreshCookieName, next.token, refreshCookieOptions(refreshTokenTtlSeconds));
|
|
@@ -465,7 +674,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
465
674
|
if (stored) {
|
|
466
675
|
// Revoke the local family FIRST and unconditionally — that's what actually
|
|
467
676
|
// keeps a signed-out browser locked out, independent of anything below.
|
|
468
|
-
await store.revokeFamily(stored.familyId);
|
|
677
|
+
await store.revokeFamily(stored.familyId, { type: "signed_out" });
|
|
469
678
|
const idpSession = await store.findIdpSession(stored.idpSessionId);
|
|
470
679
|
if (idpSession?.idpRefreshToken) {
|
|
471
680
|
// revokeIdpToken's own contract (see oidc-client.ts) is never to throw — this
|