@flytedesk/app-kit 3.2.2 → 4.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 +157 -10
- package/dist/auth/client/httpClient.d.ts +79 -1
- package/dist/auth/client/httpClient.js +185 -16
- package/dist/auth/client/httpClient.js.map +1 -1
- package/dist/auth/client/index.d.ts +4 -4
- package/dist/auth/client/index.js +3 -3
- package/dist/auth/client/index.js.map +1 -1
- package/dist/auth/client/useAuthSession.d.ts +61 -13
- package/dist/auth/client/useAuthSession.js +185 -42
- package/dist/auth/client/useAuthSession.js.map +1 -1
- package/dist/auth/errors.d.ts +30 -7
- package/dist/auth/errors.js +38 -8
- package/dist/auth/errors.js.map +1 -1
- package/dist/auth/guards.d.ts +12 -0
- package/dist/auth/guards.js +24 -6
- package/dist/auth/guards.js.map +1 -1
- package/dist/auth/index.d.ts +4 -4
- package/dist/auth/index.js +2 -2
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/oidc-client.js +12 -2
- package/dist/auth/oidc-client.js.map +1 -1
- package/dist/auth/plugin.d.ts +6 -0
- package/dist/auth/plugin.js +532 -304
- package/dist/auth/plugin.js.map +1 -1
- package/dist/auth/shared-types.d.ts +28 -1
- package/dist/auth/shared-types.js +35 -0
- package/dist/auth/shared-types.js.map +1 -1
- package/dist/auth/silentAuthPage.d.ts +20 -0
- package/dist/auth/silentAuthPage.js +75 -0
- package/dist/auth/silentAuthPage.js.map +1 -0
- package/dist/auth/testing/fake-idp.d.ts +35 -0
- package/dist/auth/testing/fake-idp.js +86 -2
- package/dist/auth/testing/fake-idp.js.map +1 -1
- package/dist/auth/testing/index.d.ts +3 -1
- package/dist/auth/testing/index.js +3 -1
- package/dist/auth/testing/index.js.map +1 -1
- package/dist/auth/testing/memory-store.d.ts +44 -0
- package/dist/auth/testing/memory-store.js +120 -0
- package/dist/auth/testing/memory-store.js.map +1 -0
- package/dist/auth/tokens.d.ts +24 -0
- package/dist/auth/tokens.js +31 -1
- package/dist/auth/tokens.js.map +1 -1
- package/dist/auth/types.d.ts +231 -96
- package/dist/auth/unavailable.d.ts +10 -0
- package/dist/auth/unavailable.js +11 -0
- package/dist/auth/unavailable.js.map +1 -0
- package/dist/bigquery/errors.d.ts +21 -3
- package/dist/bigquery/errors.js +66 -12
- package/dist/bigquery/errors.js.map +1 -1
- package/dist/bigquery/index.d.ts +5 -3
- package/dist/bigquery/index.js +4 -2
- package/dist/bigquery/index.js.map +1 -1
- package/dist/bigquery/query.d.ts +9 -2
- package/dist/bigquery/query.js +38 -16
- package/dist/bigquery/query.js.map +1 -1
- package/dist/bigquery/types.d.ts +45 -1
- package/dist/flags/plugin.js +4 -20
- package/dist/flags/plugin.js.map +1 -1
- package/dist/postgres/advisoryLock.d.ts +58 -0
- package/dist/postgres/advisoryLock.js +71 -0
- package/dist/postgres/advisoryLock.js.map +1 -0
- package/dist/postgres/index.d.ts +6 -0
- package/dist/postgres/index.js +7 -0
- package/dist/postgres/index.js.map +1 -0
- package/dist/profile/plugin.js +3 -20
- package/dist/profile/plugin.js.map +1 -1
- package/package.json +8 -2
- package/scripts/pending-release-count.mjs +0 -37
- package/scripts/release.sh +0 -126
- package/scripts/release.test.ts +0 -205
package/dist/auth/plugin.js
CHANGED
|
@@ -1,14 +1,51 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import fp from "fastify-plugin";
|
|
3
3
|
import fastifyCookie from "@fastify/cookie";
|
|
4
|
-
import { AuthStoreError, IdpAccessRevokedError, classifyIdpError } from "./errors.js";
|
|
4
|
+
import { AuthStoreError, IdpAccessRevokedError, IdpUnavailableError, 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,
|
|
8
|
-
import {
|
|
7
|
+
import { AUTH_REQUEST_DEADLINE_MS, AUTH_ROUTE_PREFIX, authRoutePath, SIGNIN_ERROR_QUERY_PARAM, } from "./shared-types.js";
|
|
8
|
+
import { requireAuth } from "./guards.js";
|
|
9
|
+
import { renderSilentAuthPage } from "./silentAuthPage.js";
|
|
10
|
+
import { sendAuthUnavailable } from "./unavailable.js";
|
|
11
|
+
import { generateRefreshToken, hashRefreshToken, MIN_ACCESS_TOKEN_SECRET_BYTES, refreshSuccessorKey, signAccessToken, successorRefreshToken, verifyAccessToken, } from "./tokens.js";
|
|
9
12
|
const DEFAULT_ACCESS_TOKEN_TTL_SECONDS = 900;
|
|
10
13
|
const DEFAULT_REFRESH_TOKEN_TTL_SECONDS = 2_592_000;
|
|
11
14
|
const DEFAULT_LOGIN_ATTEMPT_TTL_SECONDS = 600;
|
|
15
|
+
/**
|
|
16
|
+
* Runs one `AuthStore` call, wrapping anything it throws in `AuthStoreError` named after
|
|
17
|
+
* the method (AK-23) — so every route can tell a storage failure apart from an IdP
|
|
18
|
+
* failure or a verdict by type alone, and never mistakes one for "not found".
|
|
19
|
+
*/
|
|
20
|
+
async function storeCall(method, call) {
|
|
21
|
+
try {
|
|
22
|
+
return await call();
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
throw new AuthStoreError(`AuthStore.${method} failed`, err);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** A plain decimal numeral: what a token endpoint that serializes numbers as strings sends. */
|
|
29
|
+
const DECIMAL_NUMERAL = /^\d+(?:\.\d+)?$/;
|
|
30
|
+
/**
|
|
31
|
+
* A `/token` response's `expires_in` as seconds, or `null` when it is unusable (AK-23).
|
|
32
|
+
* RFC 6749 §5.1 makes it a number, but a numeric string (`"3600"`) is accepted, as 3.x's
|
|
33
|
+
* implicit coercion did. Anything else — a non-numeral string, zero, a negative or
|
|
34
|
+
* non-finite number, a missing value — is `null`, never NaN.
|
|
35
|
+
*/
|
|
36
|
+
function parseExpiresInSeconds(value) {
|
|
37
|
+
const seconds = typeof value === "string" && DECIMAL_NUMERAL.test(value) ? Number(value) : value;
|
|
38
|
+
return typeof seconds === "number" && Number.isFinite(seconds) && seconds > 0 ? seconds : null;
|
|
39
|
+
}
|
|
40
|
+
/** When an IdP access token from `tokens` expires. An unusable `expires_in` makes it
|
|
41
|
+
* "already expired" — the next request refreshes it — never an Invalid Date a store
|
|
42
|
+
* would reject. Cannot throw. */
|
|
43
|
+
function idpAccessTokenExpiresAt(tokens) {
|
|
44
|
+
return new Date(Date.now() + (parseExpiresInSeconds(tokens.expires_in) ?? 0) * 1000);
|
|
45
|
+
}
|
|
46
|
+
function unavailableCodeFor(err) {
|
|
47
|
+
return err instanceof AuthStoreError ? "auth_store_unavailable" : "idp_unavailable";
|
|
48
|
+
}
|
|
12
49
|
const REQUIRED_STRING_OPTIONS = [
|
|
13
50
|
"clientId",
|
|
14
51
|
"clientSecret",
|
|
@@ -26,6 +63,10 @@ function assertRequiredOptions(options) {
|
|
|
26
63
|
throw new Error(`@flytedesk/app-kit/auth: missing required option "${key}"`);
|
|
27
64
|
}
|
|
28
65
|
}
|
|
66
|
+
if (Buffer.byteLength(options.accessTokenSecret, "utf8") < MIN_ACCESS_TOKEN_SECRET_BYTES) {
|
|
67
|
+
throw new Error(`@flytedesk/app-kit/auth: "accessTokenSecret" must be at least ${MIN_ACCESS_TOKEN_SECRET_BYTES} bytes — ` +
|
|
68
|
+
"it signs access tokens and keys refresh-token rotation; generate it randomly (e.g. openssl rand -base64 48)");
|
|
69
|
+
}
|
|
29
70
|
if (options.cookieSecret == null ||
|
|
30
71
|
(Array.isArray(options.cookieSecret) ? options.cookieSecret.length === 0 : options.cookieSecret.length === 0)) {
|
|
31
72
|
throw new Error('@flytedesk/app-kit/auth: missing required option "cookieSecret"');
|
|
@@ -53,6 +94,8 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
53
94
|
// interactive flow to land anywhere sensible.
|
|
54
95
|
const silentAuthTargetOrigin = new URL(options.postLoginRedirect).origin;
|
|
55
96
|
const store = options.store;
|
|
97
|
+
// The key refresh-token rotation derives successors with (see successorRefreshToken).
|
|
98
|
+
const successorKey = refreshSuccessorKey(options.accessTokenSecret);
|
|
56
99
|
if (options.authorizationCacheTtlMs) {
|
|
57
100
|
fastify.log.warn({ authorizationCacheTtlMs: options.authorizationCacheTtlMs }, "@flytedesk/app-kit/auth: authorizationCacheTtlMs is set — flytedesk-id authorization " +
|
|
58
101
|
"checks will be cached instead of live. A role/permission revoked in flytedesk-id can " +
|
|
@@ -68,6 +111,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
68
111
|
});
|
|
69
112
|
await fastify.register(fastifyCookie, { secret: options.cookieSecret });
|
|
70
113
|
fastify.decorateRequest("authUser", undefined);
|
|
114
|
+
fastify.decorateRequest("authUnavailable", undefined);
|
|
71
115
|
// __Host- requires Secure, no Domain attribute, and Path=/ — so the prefix (and the
|
|
72
116
|
// Path=/ it forces) is only used when options.secureCookies says this is actually an
|
|
73
117
|
// HTTPS deployment. Plain, scoped cookies are used for local HTTP dev, where __Host-
|
|
@@ -96,80 +140,121 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
96
140
|
};
|
|
97
141
|
}
|
|
98
142
|
/**
|
|
99
|
-
* The postMessage
|
|
100
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* `
|
|
103
|
-
*
|
|
143
|
+
* The postMessage page a silent (`prompt=none`) `{routePrefix}/callback` answers with
|
|
144
|
+
* (AK-14, gap 2), read by `./client/silentAuth.ts`'s hidden-iframe listener. Hardened
|
|
145
|
+
* against a hostile `detail` (AK-23, the silent-callback XSS): see
|
|
146
|
+
* `./silentAuthPage.ts` for the allowlist, the script-context escaping, and the
|
|
147
|
+
* hash-pinned CSP set here, whose `frame-ancestors` is the same app origin the message
|
|
148
|
+
* targets.
|
|
104
149
|
*/
|
|
105
|
-
function sendSilentResult(reply, status,
|
|
106
|
-
const
|
|
107
|
-
source: SILENT_AUTH_MESSAGE_SOURCE,
|
|
108
|
-
status,
|
|
109
|
-
detail: detail ?? null,
|
|
110
|
-
};
|
|
150
|
+
function sendSilentResult(reply, status, detailCode) {
|
|
151
|
+
const page = renderSilentAuthPage(status, detailCode, silentAuthTargetOrigin);
|
|
111
152
|
return reply
|
|
153
|
+
.header("Content-Security-Policy", page.contentSecurityPolicy)
|
|
154
|
+
.header("X-Content-Type-Options", "nosniff")
|
|
112
155
|
.type("text/html")
|
|
113
|
-
.send(
|
|
114
|
-
`if (window.parent && window.parent !== window) {` +
|
|
115
|
-
`window.parent.postMessage(${JSON.stringify(message)}, ${JSON.stringify(silentAuthTargetOrigin)});` +
|
|
116
|
-
`}` +
|
|
117
|
-
`</script></body></html>`);
|
|
156
|
+
.send(page.html);
|
|
118
157
|
}
|
|
119
158
|
const authorizationCache = new Map();
|
|
120
159
|
const EXPIRY_SKEW_MS = 5_000;
|
|
121
160
|
/**
|
|
122
161
|
* Returns a live IdP access token for this session — the one already on the row if
|
|
123
|
-
* it's not near expiry, otherwise a freshly refreshed one (persisted back
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* Throws IdpAccessRevokedError (nothing left to refresh with, or flytedesk-id
|
|
130
|
-
* rejected the refresh token) or IdpUnavailableError (an outage) — never swallows;
|
|
131
|
-
* every caller is expected to log what it decides to do with the failure (fail
|
|
132
|
-
* loudly, AK-16).
|
|
162
|
+
* it's not near expiry, otherwise a freshly refreshed one (persisted back before
|
|
163
|
+
* returning, since flytedesk-id rotates the refresh token on every use — see
|
|
164
|
+
* ./oidc-client.ts's refreshIdpTokens doc comment). Shared by authorizeIdpSession
|
|
165
|
+
* (below, and therefore by both loadAuthedUser and POST {routePrefix}/refresh) and the
|
|
166
|
+
* GET {routePrefix}/apps route, so the refresh behaviour — and its failure
|
|
167
|
+
* classification — can't drift between call sites.
|
|
133
168
|
*
|
|
134
|
-
* AK-20, gap 2326: the
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
169
|
+
* AK-20, gap 2326: the refresh happens under `store.withIdpSessionLock`, and RE-READS
|
|
170
|
+
* the session once the lock is held, so a lock loser notices a sibling already
|
|
171
|
+
* refreshed while it waited and reuses that result instead of refreshing (and
|
|
172
|
+
* rotating flytedesk-id's refresh token) again.
|
|
173
|
+
*
|
|
174
|
+
* AK-23: every read and write inside the lock goes through `locked`, the store
|
|
175
|
+
* `withIdpSessionLock` binds to the lock's own transaction — never through `store`
|
|
176
|
+
* itself, which would need a second pooled connection per lock holder and starve the
|
|
177
|
+
* pool under load (see `AuthStore.withIdpSessionLock`'s doc comment).
|
|
178
|
+
*
|
|
179
|
+
* AK-23 (N1): `fn` never throws on purpose — it RETURNS its outcome
|
|
180
|
+
* (`LockedRefreshOutcome`), and a failure is thrown here, only after
|
|
181
|
+
* `withIdpSessionLock` has resolved, i.e. after the store committed the lock's
|
|
182
|
+
* transaction. A store rolls that transaction back when `fn` throws (node-postgres
|
|
183
|
+
* `BEGIN`/`ROLLBACK` and Prisma's `$transaction` both do), so throwing from inside `fn`
|
|
184
|
+
* after `locked.updateIdpSession` persisted flytedesk-id's rotated refresh token would
|
|
185
|
+
* roll the only copy of it back. The one thing left that can reject
|
|
186
|
+
* `withIdpSessionLock` is the lock or the store itself.
|
|
187
|
+
*
|
|
188
|
+
* Throws exactly one of three types, never anything else:
|
|
189
|
+
* - IdpAccessRevokedError: nothing left to refresh with, or flytedesk-id rejected the
|
|
190
|
+
* refresh token — a verdict, the session is over.
|
|
191
|
+
* - IdpUnavailableError: flytedesk-id is down, timed out, or answered unusably — not
|
|
192
|
+
* a verdict.
|
|
193
|
+
* - AuthStoreError: the lock or a store call failed (including a lock timeout) — not
|
|
194
|
+
* a verdict.
|
|
142
195
|
*/
|
|
143
196
|
async function getLiveIdpAccessToken(session) {
|
|
144
197
|
if (session.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
|
|
145
198
|
return session.idpAccessToken;
|
|
146
199
|
}
|
|
200
|
+
let outcome;
|
|
147
201
|
try {
|
|
148
|
-
|
|
149
|
-
const fresh = await store.findIdpSession(session.id);
|
|
150
|
-
if (!fresh) {
|
|
151
|
-
throw new IdpAccessRevokedError("IdP session no longer exists — re-login required");
|
|
152
|
-
}
|
|
153
|
-
if (fresh.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
|
|
154
|
-
// A sibling request already refreshed this session while this one waited for
|
|
155
|
-
// the lock — reuse its result rather than refreshing (and rotating) again.
|
|
156
|
-
return fresh.idpAccessToken;
|
|
157
|
-
}
|
|
158
|
-
if (!fresh.idpRefreshToken) {
|
|
159
|
-
throw new IdpAccessRevokedError("No IdP refresh token on this session — re-login required");
|
|
160
|
-
}
|
|
161
|
-
const refreshed = await oidc.refreshIdpTokens(fresh.idpRefreshToken);
|
|
162
|
-
await store.updateIdpSession(fresh.id, {
|
|
163
|
-
idpAccessToken: refreshed.access_token,
|
|
164
|
-
idpAccessTokenExpiresAt: new Date(Date.now() + refreshed.expires_in * 1000),
|
|
165
|
-
idpRefreshToken: refreshed.refresh_token ?? fresh.idpRefreshToken,
|
|
166
|
-
});
|
|
167
|
-
return refreshed.access_token;
|
|
168
|
-
});
|
|
202
|
+
outcome = await store.withIdpSessionLock(session.id, (locked) => refreshUnderLock(locked, session.id));
|
|
169
203
|
}
|
|
170
204
|
catch (err) {
|
|
171
|
-
|
|
205
|
+
// `refreshUnderLock` returns every IdP outcome instead of throwing it, so anything
|
|
206
|
+
// thrown here came from the lock or the locked store — including a lock timeout.
|
|
207
|
+
throw new AuthStoreError("AuthStore.withIdpSessionLock failed", err);
|
|
172
208
|
}
|
|
209
|
+
if (!outcome.ok)
|
|
210
|
+
throw outcome.error;
|
|
211
|
+
return outcome.accessToken;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* The body of `getLiveIdpAccessToken`'s lock: re-reads the session once the lock is
|
|
215
|
+
* held, refreshes the IdP tokens unless a sibling already did, and persists the result
|
|
216
|
+
* through `locked`. RETURNS every IdP outcome — never throws one (see
|
|
217
|
+
* `getLiveIdpAccessToken`'s N1 note); only a `locked` store call can throw.
|
|
218
|
+
*/
|
|
219
|
+
async function refreshUnderLock(locked, sessionId) {
|
|
220
|
+
const fresh = await locked.findIdpSession(sessionId);
|
|
221
|
+
if (!fresh) {
|
|
222
|
+
return { ok: false, error: new IdpAccessRevokedError("IdP session no longer exists — re-login required") };
|
|
223
|
+
}
|
|
224
|
+
if (fresh.idpAccessTokenExpiresAt.getTime() - EXPIRY_SKEW_MS >= Date.now()) {
|
|
225
|
+
// A sibling request already refreshed this session while this one waited for the
|
|
226
|
+
// lock — reuse its result rather than refreshing (and rotating) again.
|
|
227
|
+
return { ok: true, accessToken: fresh.idpAccessToken };
|
|
228
|
+
}
|
|
229
|
+
if (!fresh.idpRefreshToken) {
|
|
230
|
+
return { ok: false, error: new IdpAccessRevokedError("No IdP refresh token on this session — re-login required") };
|
|
231
|
+
}
|
|
232
|
+
let refreshed;
|
|
233
|
+
try {
|
|
234
|
+
refreshed = await oidc.refreshIdpTokens(fresh.idpRefreshToken);
|
|
235
|
+
}
|
|
236
|
+
catch (err) {
|
|
237
|
+
return { ok: false, error: classifyIdpError(err) };
|
|
238
|
+
}
|
|
239
|
+
// AK-23 (residual risk — see AuthStore.withIdpSessionLock's doc comment): from here
|
|
240
|
+
// on, flytedesk-id has ALREADY rotated its refresh token, and the only copy of the
|
|
241
|
+
// new one is in `refreshed`. Persisting it is therefore the FIRST thing that can
|
|
242
|
+
// fail, and the patch is built with no step that can throw — an unusable
|
|
243
|
+
// `expires_in` becomes "already stale" (the next request refreshes again, with the
|
|
244
|
+
// token persisted here) instead of an Invalid Date the store would reject.
|
|
245
|
+
const accessTokenValid = typeof refreshed.access_token === "string" && refreshed.access_token.length > 0;
|
|
246
|
+
await locked.updateIdpSession(fresh.id, {
|
|
247
|
+
idpRefreshToken: refreshed.refresh_token ?? fresh.idpRefreshToken,
|
|
248
|
+
...(accessTokenValid
|
|
249
|
+
? { idpAccessToken: refreshed.access_token, idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(refreshed) }
|
|
250
|
+
: {}),
|
|
251
|
+
});
|
|
252
|
+
if (!accessTokenValid) {
|
|
253
|
+
// The rotated refresh token is persisted above — and committed, because this is
|
|
254
|
+
// returned, not thrown. Only this answer is unusable.
|
|
255
|
+
return { ok: false, error: new IdpUnavailableError("flytedesk-id refresh response carried no access_token") };
|
|
256
|
+
}
|
|
257
|
+
return { ok: true, accessToken: refreshed.access_token };
|
|
173
258
|
}
|
|
174
259
|
/**
|
|
175
260
|
* The authorization pipeline shared by loadAuthedUser (below, run on every
|
|
@@ -177,16 +262,16 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
177
262
|
* {routePrefix}/refresh (AK-20, gap 2) — refreshes the IdP token pair if it's near
|
|
178
263
|
* expiry, then calls flytedesk-id's authorization endpoint fresh (see
|
|
179
264
|
* FlytedeskAuthOptions.authorizationCacheTtlMs for the one opt-in exception), builds
|
|
180
|
-
* the AuthedUser, then runs the onAuthorizeUser hook if configured.
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
265
|
+
* the AuthedUser, then runs the onAuthorizeUser hook if configured. One function so
|
|
266
|
+
* `/refresh` can never drift from what an ordinary authenticated request would decide:
|
|
267
|
+
* a refresh must not mint a fresh access token for a user this SAME pipeline would
|
|
268
|
+
* reject (e.g. a suspended user).
|
|
269
|
+
*
|
|
270
|
+
* Never throws. AK-23: it answers one of three outcomes (see `AuthorizationOutcome`),
|
|
271
|
+
* and only `rejected` is a verdict — 3.x collapsed an outage, a lock timeout and a
|
|
272
|
+
* throwing hook into the same "no user" answer as a real rejection, so an IdP outage
|
|
273
|
+
* during `/refresh` revoked every refreshing session. Every failure is logged here
|
|
274
|
+
* with context (fail loudly, AK-16), distinguishing an outage from a revocation.
|
|
190
275
|
*/
|
|
191
276
|
async function authorizeIdpSession(request, session) {
|
|
192
277
|
let idpAccessToken;
|
|
@@ -194,11 +279,15 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
194
279
|
idpAccessToken = await getLiveIdpAccessToken(session);
|
|
195
280
|
}
|
|
196
281
|
catch (err) {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
: "
|
|
201
|
-
|
|
282
|
+
const failure = err;
|
|
283
|
+
if (failure instanceof IdpAccessRevokedError) {
|
|
284
|
+
request.log.error({ err: failure, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id refresh token revoked or expired — forcing re-login");
|
|
285
|
+
return { kind: "rejected" };
|
|
286
|
+
}
|
|
287
|
+
request.log.error({ err: failure, sid: session.id }, failure instanceof AuthStoreError
|
|
288
|
+
? "@flytedesk/app-kit/auth: AuthStore failed while refreshing the IdP token — not a rejection"
|
|
289
|
+
: "@flytedesk/app-kit/auth: flytedesk-id token refresh failed — possible IdP outage, not a rejection");
|
|
290
|
+
return { kind: "unavailable", code: unavailableCodeFor(failure) };
|
|
202
291
|
}
|
|
203
292
|
let authorization;
|
|
204
293
|
if (options.authorizationCacheTtlMs) {
|
|
@@ -212,10 +301,12 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
212
301
|
}
|
|
213
302
|
catch (err) {
|
|
214
303
|
const classified = classifyIdpError(err);
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
: "
|
|
218
|
-
|
|
304
|
+
if (classified instanceof IdpAccessRevokedError) {
|
|
305
|
+
request.log.error({ err: classified, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id revoked this session's authorization");
|
|
306
|
+
return { kind: "rejected" };
|
|
307
|
+
}
|
|
308
|
+
request.log.error({ err: classified, sid: session.id }, "@flytedesk/app-kit/auth: flytedesk-id authorization lookup failed — possible IdP outage, not a rejection");
|
|
309
|
+
return { kind: "unavailable", code: "idp_unavailable" };
|
|
219
310
|
}
|
|
220
311
|
if (options.authorizationCacheTtlMs) {
|
|
221
312
|
authorizationCache.set(session.id, {
|
|
@@ -237,80 +328,89 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
237
328
|
permissions: authorization.permissions.map((p) => (p.startsWith(prefix) ? p.slice(prefix.length) : p)),
|
|
238
329
|
};
|
|
239
330
|
// AK-14, gap 4: the one extension point a consumer has to reject or enrich an
|
|
240
|
-
// otherwise-valid, still-IdP-authorized session on every request
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// indistinguishable from "authenticated".
|
|
331
|
+
// otherwise-valid, still-IdP-authorized session on every request — see
|
|
332
|
+
// FlytedeskAuthOptions.onAuthorizeUser's doc comment. `null` is a verdict; a throw is
|
|
333
|
+
// not (AK-23): the hook could not decide, so the request is unauthenticated but
|
|
334
|
+
// nothing is revoked.
|
|
245
335
|
if (options.onAuthorizeUser) {
|
|
246
336
|
let authorized;
|
|
247
337
|
try {
|
|
248
338
|
authorized = await options.onAuthorizeUser(user, request);
|
|
249
339
|
}
|
|
250
340
|
catch (err) {
|
|
251
|
-
request.log.error({ err, sid: session.id }, "@flytedesk/app-kit/auth: onAuthorizeUser threw —
|
|
252
|
-
return
|
|
341
|
+
request.log.error({ err, sid: session.id }, "@flytedesk/app-kit/auth: onAuthorizeUser threw — request not authenticated, session not revoked");
|
|
342
|
+
return { kind: "unavailable", code: "authorization_hook_failed" };
|
|
253
343
|
}
|
|
254
|
-
|
|
255
|
-
return undefined;
|
|
256
|
-
return authorized;
|
|
344
|
+
return authorized ? { kind: "authorized", user: authorized } : { kind: "rejected" };
|
|
257
345
|
}
|
|
258
|
-
return user;
|
|
346
|
+
return { kind: "authorized", user };
|
|
259
347
|
}
|
|
260
348
|
/**
|
|
261
|
-
* Live per-request by default:
|
|
262
|
-
* `sid
|
|
263
|
-
*
|
|
264
|
-
*
|
|
349
|
+
* Live per-request by default: looks up the IdpSession behind the access token's
|
|
350
|
+
* `sid`, then runs it through authorizeIdpSession above. Never throws. A missing
|
|
351
|
+
* session (or one for another subject) is `rejected`; a store failure looking it up
|
|
352
|
+
* is `unavailable` (AK-23) — the store being down says nothing about the session.
|
|
265
353
|
*/
|
|
266
|
-
async function
|
|
354
|
+
async function loadAuthorization(request, claims) {
|
|
267
355
|
let session;
|
|
268
356
|
try {
|
|
269
|
-
session = await store.findIdpSession(claims.sid);
|
|
357
|
+
session = await storeCall("findIdpSession", () => store.findIdpSession(claims.sid));
|
|
270
358
|
}
|
|
271
359
|
catch (err) {
|
|
272
|
-
request.log.error({ err
|
|
273
|
-
return
|
|
360
|
+
request.log.error({ err, sid: claims.sid }, "@flytedesk/app-kit/auth: AuthStore error looking up IdP session — not a rejection");
|
|
361
|
+
return { kind: "unavailable", code: "auth_store_unavailable" };
|
|
274
362
|
}
|
|
275
363
|
if (!session || session.subject !== claims.sub)
|
|
276
|
-
return
|
|
364
|
+
return { kind: "rejected" }; // expected, not an error
|
|
277
365
|
return authorizeIdpSession(request, session);
|
|
278
366
|
}
|
|
279
367
|
// onRequest, not preHandler: this needs to run before any route-level preHandler
|
|
280
|
-
// (requireAuth/requirePermission included) so request.authUser
|
|
281
|
-
//
|
|
368
|
+
// (requireAuth/requirePermission included) so request.authUser — or, when the plugin
|
|
369
|
+
// could not decide, request.authUnavailable (AK-23: requireAuth then answers 503, not
|
|
370
|
+
// 401) — is already resolved by the time those run.
|
|
282
371
|
fastify.addHook("onRequest", async (request) => {
|
|
283
372
|
const header = request.headers.authorization;
|
|
284
373
|
if (!header?.startsWith("Bearer "))
|
|
285
374
|
return;
|
|
375
|
+
let claims;
|
|
286
376
|
try {
|
|
287
|
-
|
|
288
|
-
const user = await loadAuthedUser(request, claims);
|
|
289
|
-
if (user)
|
|
290
|
-
request.authUser = user;
|
|
377
|
+
claims = await verifyAccessToken(options.accessTokenSecret, header.slice(7));
|
|
291
378
|
}
|
|
292
379
|
catch {
|
|
293
380
|
// Invalid/expired access token: leave request.authUser unset; requireAuth 401s.
|
|
381
|
+
return;
|
|
294
382
|
}
|
|
383
|
+
const outcome = await loadAuthorization(request, claims);
|
|
384
|
+
if (outcome.kind === "authorized")
|
|
385
|
+
request.authUser = outcome.user;
|
|
386
|
+
else if (outcome.kind === "unavailable")
|
|
387
|
+
request.authUnavailable = outcome.code;
|
|
295
388
|
});
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
389
|
+
/** An opaque refresh token for one family: the raw token for the cookie, and the row
|
|
390
|
+
* (its hash, never the token) to persist. `issued` is the token itself — random for a
|
|
391
|
+
* login's first token (`generateRefreshToken`), derived from the presented row and token for
|
|
392
|
+
* a rotation (`successorRefreshToken`, see `resumeSuccessor`). */
|
|
393
|
+
function newRefreshToken(request, family, { token, hash }) {
|
|
394
|
+
return {
|
|
395
|
+
token,
|
|
396
|
+
row: {
|
|
397
|
+
id: randomUUID(),
|
|
398
|
+
familyId: family.familyId,
|
|
399
|
+
userId: family.userId,
|
|
400
|
+
tokenHash: hash,
|
|
401
|
+
expiresAt: new Date(Date.now() + refreshTokenTtlSeconds * 1000),
|
|
402
|
+
idpSessionId: family.idpSessionId,
|
|
403
|
+
userAgent: request.headers["user-agent"] ?? null,
|
|
404
|
+
ip: request.ip,
|
|
405
|
+
},
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
/** The success tail shared by login and `/refresh`: hands the browser its (new) refresh
|
|
409
|
+
* cookie and mints this service's own short-lived access token for the session. */
|
|
410
|
+
async function completeSession(reply, refreshToken, userId, idpSessionId) {
|
|
411
|
+
const { token: accessToken, expiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, userId, idpSessionId);
|
|
312
412
|
reply.setCookie(refreshCookieName, refreshToken, refreshCookieOptions(refreshTokenTtlSeconds));
|
|
313
|
-
return { accessToken, accessTokenExpiresAt:
|
|
413
|
+
return { accessToken, accessTokenExpiresAt: expiresAt.toISOString() };
|
|
314
414
|
}
|
|
315
415
|
/**
|
|
316
416
|
* GET {routePrefix}/login — starts the flytedesk-id sign-in. A plain redirect meant
|
|
@@ -422,19 +522,25 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
422
522
|
}
|
|
423
523
|
const silent = stash?.silent === true;
|
|
424
524
|
/**
|
|
425
|
-
* Answers ONE callback failure. Silent (AK-14):
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
*
|
|
429
|
-
* app's own sign-in page via `redirectToSignIn`, carrying
|
|
430
|
-
* `signInErrorCode` — never `code`, never `message`, never a
|
|
431
|
-
* `message` (and `err`, when the failure came from an
|
|
432
|
-
* server-side ONLY, exactly once, right here — the one
|
|
433
|
-
*
|
|
525
|
+
* Answers ONE callback failure. Silent (AK-14): the hardened postMessage page
|
|
526
|
+
* (`sendSilentResult` above), whose `detail` is `code` mapped onto the fixed
|
|
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.
|
|
434
536
|
*/
|
|
435
537
|
function fail(code, message, signInErrorCode, err) {
|
|
436
|
-
if (silent)
|
|
538
|
+
if (silent) {
|
|
539
|
+
if (code !== "login_required") {
|
|
540
|
+
request.log.error({ err, code }, `@flytedesk/app-kit/auth: silent callback failed — ${message}`);
|
|
541
|
+
}
|
|
437
542
|
return sendSilentResult(reply, "error", code);
|
|
543
|
+
}
|
|
438
544
|
// Always .error, not .warn: every one of these is the "fail loudly" pattern
|
|
439
545
|
// this file uses throughout (AK-16) — the ONLY place the real detail (and, for
|
|
440
546
|
// an upstream failure, the actual err) is ever recorded, since the redirect
|
|
@@ -477,7 +583,7 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
477
583
|
return fail("email_not_verified", "flytedesk-id account email is not verified", "other");
|
|
478
584
|
}
|
|
479
585
|
if (options.onSignIn) {
|
|
480
|
-
await options.onSignIn(profile, request);
|
|
586
|
+
await options.onSignIn(profile, request, { mode: silent ? "silent" : "interactive" });
|
|
481
587
|
}
|
|
482
588
|
const name = profile.name ?? profile.email.split("@")[0];
|
|
483
589
|
const idpSession = await store.createIdpSession({
|
|
@@ -485,15 +591,16 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
485
591
|
name,
|
|
486
592
|
email: profile.email,
|
|
487
593
|
idpAccessToken: tokens.access_token,
|
|
488
|
-
idpAccessTokenExpiresAt:
|
|
594
|
+
idpAccessTokenExpiresAt: idpAccessTokenExpiresAt(tokens),
|
|
489
595
|
idpRefreshToken: tokens.refresh_token ?? null,
|
|
490
596
|
});
|
|
491
597
|
// A fresh family for every login — reuse detection (see POST /refresh below)
|
|
492
598
|
// only ever revokes the chain descended from one login, never sibling sessions.
|
|
493
|
-
const
|
|
494
|
-
await
|
|
599
|
+
const first = newRefreshToken(request, { familyId: randomUUID(), userId: profile.sub, idpSessionId: idpSession.id }, generateRefreshToken());
|
|
600
|
+
await store.createRefreshToken(first.row);
|
|
601
|
+
await completeSession(reply, first.token, profile.sub, idpSession.id);
|
|
495
602
|
if (silent) {
|
|
496
|
-
// No top-level navigation at all —
|
|
603
|
+
// No top-level navigation at all — completeSession() above already set the real
|
|
497
604
|
// refresh cookie on this (the API's) origin as a side effect of this same
|
|
498
605
|
// response, which is the only thing that actually needed to happen. This page
|
|
499
606
|
// just tells the hidden iframe's listener it can stop waiting; the parent page
|
|
@@ -509,143 +616,244 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
509
616
|
});
|
|
510
617
|
/** The one answer POST {routePrefix}/refresh gives a refresh token it will not
|
|
511
618
|
* rotate. `clearCookie` only for a token that is genuinely dead (unrecognized,
|
|
512
|
-
* expired, or reused — its whole family just ended); NOT for one that merely
|
|
513
|
-
* concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the
|
|
514
|
-
* fresh cookie at the same moment, and a clearing Set-Cookie on the loser's
|
|
515
|
-
* can land after it and delete the fresh token. */
|
|
619
|
+
* expired, revoked, or reused — its whole family just ended); NOT for one that merely
|
|
620
|
+
* lost a concurrent-tab race (AK-14, gap 3; MP-127): the winner is setting the
|
|
621
|
+
* browser's fresh cookie at the same moment, and a clearing Set-Cookie on the loser's
|
|
622
|
+
* response can land after it and delete the fresh token. */
|
|
516
623
|
function rejectRefresh(reply, { clearCookie }) {
|
|
517
624
|
if (clearCookie)
|
|
518
625
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
519
626
|
return reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
|
|
520
627
|
}
|
|
628
|
+
/** Revokes the family behind a refresh the authorization pipeline rejected, and 401s
|
|
629
|
+
* with the cookie cleared. */
|
|
630
|
+
async function rejectFamily(reply, row) {
|
|
631
|
+
await storeCall("revokeFamily", () => store.revokeFamily(row.familyId, { type: "authorization_rejected" }, new Date()));
|
|
632
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
633
|
+
reply.code(401).send({ error: "Authorization rejected", code: "unauthenticated" });
|
|
634
|
+
}
|
|
635
|
+
/** The request's own client fields, as the reuse audit and the resume log record them. */
|
|
636
|
+
function clientOf(request) {
|
|
637
|
+
return { ip: request.ip, userAgent: request.headers["user-agent"] ?? null };
|
|
638
|
+
}
|
|
639
|
+
/**
|
|
640
|
+
* Refresh-token reuse: revokes `replayed`'s whole family (`reuse_detected` — every token
|
|
641
|
+
* downstream of the replayed one must be assumed compromised) and answers 401
|
|
642
|
+
* `token_reuse_detected` with the cookie cleared.
|
|
643
|
+
*/
|
|
644
|
+
async function reportReuse(request, reply, replayed, sinceRotatedMs, why) {
|
|
645
|
+
const client = clientOf(request);
|
|
646
|
+
const context = { familyId: replayed.familyId, userId: replayed.userId, sinceRotatedMs, why, ...client };
|
|
647
|
+
request.log.warn(context, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
|
|
648
|
+
try {
|
|
649
|
+
await storeCall("revokeFamily", () => store.revokeFamily(replayed.familyId, { type: "reuse_detected", replayedToken: replayed, sinceRotatedMs, ...client }, new Date()));
|
|
650
|
+
}
|
|
651
|
+
catch (err) {
|
|
652
|
+
// Reuse was DETECTED but not recorded: the family (and whichever live successor
|
|
653
|
+
// the thief or the victim holds) is still live. The answer stays 503 with the
|
|
654
|
+
// cookie kept, not 401: a 401 would end this client's retries, and each retry is
|
|
655
|
+
// what re-presents this replayed token and re-attempts the revocation — it is the
|
|
656
|
+
// only thing that ever will. The replayed token itself is already rotated, and it
|
|
657
|
+
// is never resumed outside the grace window.
|
|
658
|
+
request.log.error({ err, ...context }, "@flytedesk/app-kit/auth: refresh token reuse detected but revoking the family FAILED — family still live");
|
|
659
|
+
throw err;
|
|
660
|
+
}
|
|
661
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
662
|
+
reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
|
|
663
|
+
}
|
|
521
664
|
/**
|
|
522
665
|
* POST {routePrefix}/refresh — no body, reads the refresh cookie. Rotates it on every
|
|
523
666
|
* successful call.
|
|
524
667
|
*
|
|
525
|
-
*
|
|
526
|
-
*
|
|
668
|
+
* ONE clock (AK-23): every time this route compares or records is the app's clock,
|
|
669
|
+
* never the database's `now()`. Comparisons read the clock once, at the start of the
|
|
670
|
+
* request (`now`); every revocation time is read from it again AT the store write
|
|
671
|
+
* (`new Date()` passed to `rotateRefreshToken`/`revokeFamily`), because the
|
|
672
|
+
* authorization step before a rotation can legitimately take seconds (a session-lock
|
|
673
|
+
* wait, a flytedesk-id `/token` call, the authorization hook) — a time stamped at the
|
|
674
|
+
* request's start would land early and shrink the grace window for the very retry it
|
|
675
|
+
* exists for.
|
|
676
|
+
*
|
|
677
|
+
* A presented token that is already revoked (`stored.revocation`, from
|
|
678
|
+
* `findRefreshTokenByHash`) is answered by WHY it was revoked (AK-23):
|
|
679
|
+
*
|
|
680
|
+
* - `"rotated"`, less than `refreshReuseGraceMs` ago: the same browser presenting it
|
|
681
|
+
* again — a concurrent tab whose request was in flight when it was rotated, or a
|
|
682
|
+
* browser whose refresh response never arrived (it timed out, the network dropped
|
|
683
|
+
* it) and so still holds this cookie. Tabs share one cookie jar, so only the
|
|
684
|
+
* IMMEDIATELY previous token can legitimately come back (the policy Auth0's reuse
|
|
685
|
+
* interval follows): `resumeSuccessor` re-derives its direct successor
|
|
686
|
+
* (`successorRefreshToken`) and, when that is the family's live token, answers with
|
|
687
|
+
* it — authorized through the same pipeline, rotated no further. A token two or
|
|
688
|
+
* more rotations back is reuse.
|
|
689
|
+
* - `"rotated"`, outside that window: reuse (`reportReuse`).
|
|
690
|
+
* - anything else (`signed_out`, `authorization_rejected`, `reuse_detected`): the
|
|
691
|
+
* family was already ended on purpose. 401, cookie cleared, nothing revoked or
|
|
692
|
+
* reported again — 3.x reported these as reuse, so a tab that fired one last refresh
|
|
693
|
+
* after sign-out raised a false security alarm.
|
|
694
|
+
*
|
|
695
|
+
* A token that looked live is authorized through the SAME pipeline `loadAuthedUser`
|
|
696
|
+
* runs on every request (AK-20, gap 2) before it is rotated. Only a definitive
|
|
697
|
+
* rejection revokes the family (`authorization_rejected`); an outage, a lock timeout,
|
|
698
|
+
* a store error or a throwing `onAuthorizeUser` answers 503 and revokes nothing
|
|
699
|
+
* (AK-23) — an IdP outage used to sign out every user who refreshed during it. The
|
|
700
|
+
* pipeline runs BEFORE the rotation, not between a claim and a mint: it can be slow
|
|
701
|
+
* (a real round trip to flytedesk-id), and the rotation must stay one short atomic
|
|
702
|
+
* step.
|
|
527
703
|
*
|
|
528
|
-
*
|
|
529
|
-
*
|
|
530
|
-
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
533
|
-
* took a while to arrive: 401, nothing revoked. Otherwise (never rotated at all, or
|
|
534
|
-
* outside the window) it's reuse: the whole family is revoked, since every token
|
|
535
|
-
* downstream of the replayed one must be assumed compromised too.
|
|
536
|
-
* - It looked live at that same read (`revokedAt: null`), but a SIBLING request
|
|
537
|
-
* claims it first (`store.claimRefreshTokenForRotation` returns `false`) — a
|
|
538
|
-
* genuine, same-instant concurrent-tab race, decided purely by that method's
|
|
539
|
-
* atomicity. Treated as a race unconditionally: no rotated/timing check needed
|
|
540
|
-
* (there's nothing to check yet — the winner's rotation is still in flight), 401,
|
|
541
|
-
* nothing revoked. This mirrors media-planner's own shape exactly
|
|
542
|
-
* (`updateMany({revokedAt: null}); if (count !== 1) return false`).
|
|
704
|
+
* Rotation is `store.rotateRefreshToken` — the revoke of the presented token and the
|
|
705
|
+
* insert of its successor are one transaction (AK-23), so a failure can never leave a
|
|
706
|
+
* revoked token without a successor. `false` means a sibling request rotated it first
|
|
707
|
+
* in the moment since the read above: this request is then exactly the in-grace second
|
|
708
|
+
* presentation above, and is answered as one.
|
|
543
709
|
*
|
|
544
|
-
*
|
|
545
|
-
*
|
|
546
|
-
*
|
|
547
|
-
*
|
|
548
|
-
*
|
|
710
|
+
* Any store failure anywhere in this route answers 503 `auth_store_unavailable`
|
|
711
|
+
* without touching the cookie.
|
|
712
|
+
*
|
|
713
|
+
* Every helper below ANSWERS the request and resolves `void` (or a boolean saying
|
|
714
|
+
* whether it answered): a `FastifyReply` is a thenable, so an async function that
|
|
715
|
+
* returned one would resolve to `undefined` once it was sent, never to the reply.
|
|
549
716
|
*/
|
|
550
717
|
fastify.post(authRoutePath(routePrefix, "refresh"), async (request, reply) => {
|
|
551
718
|
const token = request.cookies[refreshCookieName];
|
|
552
719
|
if (!token) {
|
|
553
720
|
return reply.code(401).send({ error: "No refresh token", code: "unauthenticated" });
|
|
554
721
|
}
|
|
555
|
-
|
|
556
|
-
|
|
722
|
+
try {
|
|
723
|
+
await refreshSession(request, reply, token, new Date());
|
|
724
|
+
}
|
|
725
|
+
catch (err) {
|
|
726
|
+
if (!(err instanceof AuthStoreError))
|
|
727
|
+
throw err;
|
|
728
|
+
request.log.error({ err }, "@flytedesk/app-kit/auth: /refresh AuthStore failure — answering 503, nothing revoked");
|
|
729
|
+
sendAuthUnavailable(reply, "auth_store_unavailable");
|
|
730
|
+
}
|
|
731
|
+
return reply;
|
|
732
|
+
});
|
|
733
|
+
/** POST {routePrefix}/refresh's body — see that route's doc comment. Store calls go
|
|
734
|
+
* through `storeCall`, so every storage failure surfaces as `AuthStoreError`. */
|
|
735
|
+
async function refreshSession(request, reply, token, now) {
|
|
736
|
+
const stored = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
|
|
557
737
|
if (!stored) {
|
|
558
738
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
559
|
-
|
|
739
|
+
reply.code(401).send({ error: "Refresh token not recognized", code: "unauthenticated" });
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
if (stored.revocation) {
|
|
743
|
+
await answerRevokedPresentation(request, reply, stored, stored.revocation, token, now);
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (stored.expiresAt < now) {
|
|
747
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
748
|
+
reply.code(401).send({ error: "Refresh token expired", code: "unauthenticated" });
|
|
749
|
+
return;
|
|
560
750
|
}
|
|
561
|
-
if (stored
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
751
|
+
if (await authorizeRefresh(request, reply, stored))
|
|
752
|
+
return;
|
|
753
|
+
const next = newRefreshToken(request, stored, successorRefreshToken(successorKey, stored.id, token));
|
|
754
|
+
const rotated = await storeCall("rotateRefreshToken", () => store.rotateRefreshToken(stored.id, next.row, new Date()));
|
|
755
|
+
if (!rotated) {
|
|
756
|
+
// A sibling request rotated (or revoked) it in the moment since the read above:
|
|
757
|
+
// answer this request as the second presentation it has become.
|
|
758
|
+
const reread = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
|
|
759
|
+
if (reread?.revocation) {
|
|
760
|
+
await answerRevokedPresentation(request, reply, reread, reread.revocation, token, now);
|
|
565
761
|
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
sinceRevokedMs,
|
|
571
|
-
}, "@flytedesk/app-kit/auth: refresh token reuse detected — revoking token family");
|
|
572
|
-
await store.revokeFamily(stored.familyId, {
|
|
573
|
-
type: "reuse_detected",
|
|
574
|
-
replayedToken: stored,
|
|
575
|
-
sinceRotatedMs: sinceRevokedMs,
|
|
576
|
-
ip: request.ip,
|
|
577
|
-
userAgent: request.headers["user-agent"] ?? null,
|
|
578
|
-
});
|
|
579
|
-
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
580
|
-
return reply.code(401).send({ error: "Refresh token reuse detected", code: "token_reuse_detected" });
|
|
762
|
+
else {
|
|
763
|
+
rejectRefresh(reply, { clearCookie: false });
|
|
764
|
+
}
|
|
765
|
+
return;
|
|
581
766
|
}
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
// family, exactly like a detected reuse — the point of rotating at all is defeated
|
|
593
|
-
// if a rejected user's family just keeps living. Deliberately BEFORE the atomic
|
|
594
|
-
// claim below (not after): this call can be slow (a real network round trip to
|
|
595
|
-
// flytedesk-id), and running it between the claim and the mint would widen the
|
|
596
|
-
// window in which a genuinely concurrent sibling request's read of this same row
|
|
597
|
-
// could land — the atomic claim and the mint that follows it must stay adjacent,
|
|
598
|
-
// exactly as they were before this card, or the plugin's own concurrent-tab-race
|
|
599
|
-
// handling (gap 3) becomes measurably flakier under load, not just under test.
|
|
600
|
-
let idpSession;
|
|
601
|
-
try {
|
|
602
|
-
idpSession = await store.findIdpSession(stored.idpSessionId);
|
|
767
|
+
reply.send(await completeSession(reply, next.token, stored.userId, stored.idpSessionId));
|
|
768
|
+
}
|
|
769
|
+
/** A presented token that is already revoked — see POST {routePrefix}/refresh's doc
|
|
770
|
+
* comment for the three answers. */
|
|
771
|
+
async function answerRevokedPresentation(request, reply, presented, revocation, token, now) {
|
|
772
|
+
const sinceRotatedMs = now.getTime() - revocation.at.getTime();
|
|
773
|
+
if (revocation.reason !== "rotated") {
|
|
774
|
+
request.log.info({ familyId: presented.familyId, userId: presented.userId, revokedReason: revocation.reason, sinceRotatedMs }, "@flytedesk/app-kit/auth: refresh token from an already-ended family presented — not reuse, nothing revoked");
|
|
775
|
+
rejectRefresh(reply, { clearCookie: true });
|
|
776
|
+
return;
|
|
603
777
|
}
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
return
|
|
778
|
+
if (sinceRotatedMs >= refreshReuseGraceMs) {
|
|
779
|
+
await reportReuse(request, reply, presented, sinceRotatedMs, "presented outside the grace window");
|
|
780
|
+
return;
|
|
607
781
|
}
|
|
782
|
+
await resumeSuccessor(request, reply, presented, token, sinceRotatedMs, now);
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Runs the authorization pipeline for the session behind `row` before `/refresh`
|
|
786
|
+
* issues anything for it. When the answer is not "authorized" it answers the request
|
|
787
|
+
* itself (a rejection revokes the family; an outage is a 503) and returns `true`;
|
|
788
|
+
* otherwise `false`.
|
|
789
|
+
*/
|
|
790
|
+
async function authorizeRefresh(request, reply, row) {
|
|
791
|
+
const idpSession = await storeCall("findIdpSession", () => store.findIdpSession(row.idpSessionId));
|
|
608
792
|
if (!idpSession) {
|
|
609
|
-
request.log.warn({ familyId:
|
|
610
|
-
await
|
|
611
|
-
|
|
612
|
-
|
|
793
|
+
request.log.warn({ familyId: row.familyId, idpSessionId: row.idpSessionId }, "@flytedesk/app-kit/auth: /refresh — no IdP session left for this refresh token — revoking family");
|
|
794
|
+
await rejectFamily(reply, row);
|
|
795
|
+
return true;
|
|
796
|
+
}
|
|
797
|
+
const outcome = await authorizeIdpSession(request, idpSession);
|
|
798
|
+
if (outcome.kind === "unavailable") {
|
|
799
|
+
// authorizeIdpSession already logged the cause.
|
|
800
|
+
sendAuthUnavailable(reply, outcome.code);
|
|
801
|
+
return true;
|
|
802
|
+
}
|
|
803
|
+
if (outcome.kind === "rejected") {
|
|
804
|
+
request.log.warn({ familyId: row.familyId, userId: row.userId }, "@flytedesk/app-kit/auth: /refresh rejected by the authorization check — revoking token family");
|
|
805
|
+
await rejectFamily(reply, row);
|
|
806
|
+
return true;
|
|
807
|
+
}
|
|
808
|
+
return false;
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Answers an in-grace second presentation of the rotated token `presented` (see POST
|
|
812
|
+
* {routePrefix}/refresh's doc comment) with its DIRECT successor — re-derived with
|
|
813
|
+
* `successorRefreshToken` from the presented row's server-side id and the token — when
|
|
814
|
+
* that successor is the family's live token: authorized, then issued again with a fresh
|
|
815
|
+
* access token, rotating nothing. Exactly one hop:
|
|
816
|
+
*
|
|
817
|
+
* - the successor was itself rotated since (the token is two or more rotations back),
|
|
818
|
+
* or it belongs to another family: reuse.
|
|
819
|
+
* - the successor's family was ended on purpose: 401, cookie cleared.
|
|
820
|
+
* - the successor expired: 401, cookie cleared.
|
|
821
|
+
* - no successor row at all — the rotation was derived under a different
|
|
822
|
+
* `accessTokenSecret` (a rolling secret change: see the README) or predates this
|
|
823
|
+
* derivation: 401, cookie kept, nothing revoked.
|
|
824
|
+
*/
|
|
825
|
+
async function resumeSuccessor(request, reply, presented, token, sinceRotatedMs, now) {
|
|
826
|
+
const successor = successorRefreshToken(successorKey, presented.id, token);
|
|
827
|
+
const head = await storeCall("findRefreshTokenByHash", () => store.findRefreshTokenByHash(successor.hash));
|
|
828
|
+
if (!head) {
|
|
829
|
+
rejectRefresh(reply, { clearCookie: false });
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
if (head.familyId !== presented.familyId) {
|
|
833
|
+
await reportReuse(request, reply, presented, sinceRotatedMs, "its derived successor belongs to another family");
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
if (head.revocation?.reason === "rotated") {
|
|
837
|
+
await reportReuse(request, reply, presented, sinceRotatedMs, "presented two or more rotations back");
|
|
838
|
+
return;
|
|
613
839
|
}
|
|
614
|
-
|
|
615
|
-
if (!authorized) {
|
|
616
|
-
request.log.warn({ familyId: stored.familyId, userId: stored.userId }, "@flytedesk/app-kit/auth: /refresh rejected by the authorization check — revoking token family");
|
|
617
|
-
await store.revokeFamily(stored.familyId, { type: "authorization_rejected" });
|
|
840
|
+
if (head.revocation || head.expiresAt < now) {
|
|
618
841
|
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
if (
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
await store.createRefreshToken({
|
|
635
|
-
id: randomUUID(),
|
|
636
|
-
familyId: stored.familyId,
|
|
637
|
-
userId: stored.userId,
|
|
638
|
-
tokenHash: next.hash,
|
|
639
|
-
expiresAt,
|
|
640
|
-
idpSessionId: stored.idpSessionId,
|
|
641
|
-
userAgent: request.headers["user-agent"] ?? null,
|
|
642
|
-
ip: request.ip,
|
|
643
|
-
replacesId: stored.id,
|
|
644
|
-
});
|
|
645
|
-
const { token: accessToken, expiresAt: accessExpiresAt } = await signAccessToken(options.accessTokenSecret, accessTokenTtlSeconds, stored.userId, stored.idpSessionId);
|
|
646
|
-
reply.setCookie(refreshCookieName, next.token, refreshCookieOptions(refreshTokenTtlSeconds));
|
|
647
|
-
return reply.send({ accessToken, accessTokenExpiresAt: accessExpiresAt.toISOString() });
|
|
648
|
-
});
|
|
842
|
+
reply.code(401).send({ error: "Refresh token expired or revoked", code: "unauthenticated" });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
if (await authorizeRefresh(request, reply, head))
|
|
846
|
+
return;
|
|
847
|
+
request.log.warn({
|
|
848
|
+
familyId: head.familyId,
|
|
849
|
+
userId: head.userId,
|
|
850
|
+
sinceRotatedMs,
|
|
851
|
+
...clientOf(request),
|
|
852
|
+
successorIssuedToIp: head.ip,
|
|
853
|
+
successorIssuedToUserAgent: head.userAgent,
|
|
854
|
+
}, "@flytedesk/app-kit/auth: just-rotated refresh token presented again inside the grace window — answering with its successor");
|
|
855
|
+
reply.send(await completeSession(reply, successor.token, head.userId, head.idpSessionId));
|
|
856
|
+
}
|
|
649
857
|
/**
|
|
650
858
|
* GET {routePrefix}/apps — the "All flytedesk apps" switcher feed (flytedesk-id's
|
|
651
859
|
* `GET /me/apps`, MP-213/MP-209), served by the plugin itself so every consumer gets
|
|
@@ -656,18 +864,31 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
656
864
|
* oidc client — never exposed on request.authUser (AuthedUser deliberately carries
|
|
657
865
|
* no token). Every upstream failure is mapped to a typed, logged error: never an
|
|
658
866
|
* unmapped 500 carrying flytedesk-id's raw response text.
|
|
867
|
+
*
|
|
868
|
+
* AK-23 (N2): `requireAuth` has already verified the session for this very request,
|
|
869
|
+
* so nothing that fails after it says anything about the session. An outage here —
|
|
870
|
+
* flytedesk-id's `/me/apps`, its `/token`, or the AuthStore — is **502
|
|
871
|
+
* `apps_unavailable`**: the switcher feed failed, NOT one of the `AUTH_UNAVAILABLE_CODES`
|
|
872
|
+
* 503s that tell a client its session could not be verified (which would put
|
|
873
|
+
* `useAuthSession` into `"unavailable"` over a feed that is only decoration). Only
|
|
874
|
+
* flytedesk-id refusing the session's token is a 401 (`idp_access_revoked`).
|
|
659
875
|
*/
|
|
660
|
-
fastify.get(authRoutePath(routePrefix, "apps"), async (request, reply) => {
|
|
661
|
-
|
|
662
|
-
|
|
876
|
+
fastify.get(authRoutePath(routePrefix, "apps"), { preHandler: requireAuth }, async (request, reply) => {
|
|
877
|
+
const { sessionId } = request.authUser;
|
|
878
|
+
function appsUnavailable(err, cause) {
|
|
879
|
+
request.log.error({ err, sessionId }, `@flytedesk/app-kit/auth: /apps — ${cause}; answering 502 apps_unavailable`);
|
|
880
|
+
return reply.code(502).send({ error: "The app switcher feed is unavailable right now", code: "apps_unavailable" });
|
|
881
|
+
}
|
|
882
|
+
function appsAccessRevoked(err) {
|
|
883
|
+
request.log.error({ err, sessionId }, "@flytedesk/app-kit/auth: /apps — flytedesk-id refused this session's token");
|
|
884
|
+
return reply.code(401).send({ error: "Sign-in with flytedesk-id has expired", code: "idp_access_revoked" });
|
|
663
885
|
}
|
|
664
886
|
let session;
|
|
665
887
|
try {
|
|
666
|
-
session = await store.findIdpSession(
|
|
888
|
+
session = await storeCall("findIdpSession", () => store.findIdpSession(sessionId));
|
|
667
889
|
}
|
|
668
890
|
catch (err) {
|
|
669
|
-
|
|
670
|
-
return reply.code(502).send({ error: "Could not load the signed-in session", code: "auth_store_failed" });
|
|
891
|
+
return appsUnavailable(err, "AuthStore error looking up the IdP session");
|
|
671
892
|
}
|
|
672
893
|
if (!session) {
|
|
673
894
|
return reply.code(401).send({ error: "Session not found", code: "unauthenticated" });
|
|
@@ -677,14 +898,9 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
677
898
|
idpAccessToken = await getLiveIdpAccessToken(session);
|
|
678
899
|
}
|
|
679
900
|
catch (err) {
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
: "@flytedesk/app-kit/auth: /apps — flytedesk-id token refresh failed, possible outage");
|
|
684
|
-
if (classified instanceof IdpAccessRevokedError) {
|
|
685
|
-
return reply.code(401).send({ error: "Sign-in with flytedesk-id has expired", code: "idp_access_revoked" });
|
|
686
|
-
}
|
|
687
|
-
return reply.code(502).send({ error: "flytedesk-id is unavailable", code: "idp_unavailable" });
|
|
901
|
+
if (err instanceof IdpAccessRevokedError)
|
|
902
|
+
return appsAccessRevoked(err);
|
|
903
|
+
return appsUnavailable(err, "refreshing the IdP access token failed");
|
|
688
904
|
}
|
|
689
905
|
try {
|
|
690
906
|
const apps = await oidc.fetchMyApps(idpAccessToken);
|
|
@@ -692,11 +908,9 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
692
908
|
}
|
|
693
909
|
catch (err) {
|
|
694
910
|
const classified = classifyIdpError(err);
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
}
|
|
699
|
-
return reply.code(502).send({ error: "flytedesk-id is unavailable", code: "idp_unavailable" });
|
|
911
|
+
if (classified instanceof IdpAccessRevokedError)
|
|
912
|
+
return appsAccessRevoked(classified);
|
|
913
|
+
return appsUnavailable(classified, "flytedesk-id /me/apps lookup failed");
|
|
700
914
|
}
|
|
701
915
|
});
|
|
702
916
|
/**
|
|
@@ -709,55 +923,69 @@ const flytedeskAuthPlugin = async (fastify, options) => {
|
|
|
709
923
|
* does, instead of every app re-declaring the same three lines and the same response
|
|
710
924
|
* shape by hand.
|
|
711
925
|
*/
|
|
712
|
-
fastify.get(authRoutePath(routePrefix, "me"), async (request, reply) => {
|
|
713
|
-
if (!request.authUser) {
|
|
714
|
-
return reply.code(401).send({ error: "Authentication required", code: "unauthenticated" });
|
|
715
|
-
}
|
|
926
|
+
fastify.get(authRoutePath(routePrefix, "me"), { preHandler: requireAuth }, async (request, reply) => {
|
|
716
927
|
return reply.send({ user: request.authUser });
|
|
717
928
|
});
|
|
718
929
|
/**
|
|
719
930
|
* POST {routePrefix}/logout — revokes the whole refresh-token family (not just the
|
|
720
|
-
* presented cookie) and the IdpSession row,
|
|
721
|
-
* refresh token if flytedesk-id's discovery document advertises a revocation
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
931
|
+
* presented cookie) and the IdpSession row, and best-effort revokes the IdP-side
|
|
932
|
+
* refresh token if flytedesk-id's discovery document advertises a revocation endpoint
|
|
933
|
+
* (flytedesk-id currently documents NOT having one, so that step is expected to no-op;
|
|
934
|
+
* `revokeIdpToken` never throws either way).
|
|
935
|
+
*
|
|
936
|
+
* AK-23: the refresh cookie is cleared in `finally`, so it is cleared on EVERY
|
|
937
|
+
* outcome — 3.x cleared it only on the happy path, so a throwing store call answered a
|
|
938
|
+
* raw 500 that left the browser holding a cookie for an unrevoked family. Each store
|
|
939
|
+
* step is attempted independently (`endSignedInSession`): a failed family revoke does
|
|
940
|
+
* not stop the IdpSession delete, which on its own already makes the family unusable
|
|
941
|
+
* (`/refresh` revokes a family whose IdP session is gone). Any failed step answers
|
|
942
|
+
* 502 `logout_store_failed`, logged — never a silent, fully-clean "signed out".
|
|
725
943
|
*/
|
|
726
944
|
fastify.post(authRoutePath(routePrefix, "logout"), async (request, reply) => {
|
|
727
945
|
const token = request.cookies[refreshCookieName];
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
// is best-effort by design (flytedesk-id currently advertises no
|
|
739
|
-
// revocation_endpoint at all), not a swallowed failure.
|
|
740
|
-
await oidc.revokeIdpToken(idpSession.idpRefreshToken);
|
|
741
|
-
}
|
|
742
|
-
try {
|
|
743
|
-
await store.deleteIdpSession(stored.idpSessionId);
|
|
744
|
-
}
|
|
745
|
-
catch (err) {
|
|
746
|
-
// Fail loudly (AK-16): the family is already revoked above, so the browser is
|
|
747
|
-
// locked out either way — but a store failure here means the IdpSession row
|
|
748
|
-
// (and the IdP tokens it holds) leaked, which is worth surfacing rather than
|
|
749
|
-
// reporting a silent, fully-clean "signed out".
|
|
750
|
-
request.log.error({ err: new AuthStoreError("AuthStore.deleteIdpSession failed", err), idpSessionId: stored.idpSessionId }, "@flytedesk/app-kit/auth: logout failed to delete the IdP session — the refresh token family is revoked either way");
|
|
751
|
-
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
752
|
-
return reply
|
|
753
|
-
.code(502)
|
|
754
|
-
.send({ error: "Sign-out did not fully complete", code: "logout_store_failed" });
|
|
755
|
-
}
|
|
756
|
-
}
|
|
946
|
+
let complete = true;
|
|
947
|
+
try {
|
|
948
|
+
if (token)
|
|
949
|
+
complete = await endSignedInSession(request, token);
|
|
950
|
+
}
|
|
951
|
+
finally {
|
|
952
|
+
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
953
|
+
}
|
|
954
|
+
if (!complete) {
|
|
955
|
+
return reply.code(502).send({ error: "Sign-out did not fully complete", code: "logout_store_failed" });
|
|
757
956
|
}
|
|
758
|
-
reply.clearCookie(refreshCookieName, { path: cookiePath });
|
|
759
957
|
return reply.send({ loggedOut: true });
|
|
760
958
|
});
|
|
959
|
+
/**
|
|
960
|
+
* Runs one logout store step, logging (never throwing) its failure. Returns the step's
|
|
961
|
+
* result, or `failed` so the caller can carry on with the next independent step.
|
|
962
|
+
*/
|
|
963
|
+
async function logoutStep(request, method, call) {
|
|
964
|
+
try {
|
|
965
|
+
return { ok: true, value: await storeCall(method, call) };
|
|
966
|
+
}
|
|
967
|
+
catch (err) {
|
|
968
|
+
request.log.error({ err }, `@flytedesk/app-kit/auth: logout — AuthStore.${method} failed, sign-out incomplete`);
|
|
969
|
+
return { ok: false };
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
/** Ends the sign-in behind one refresh cookie; true when every step succeeded (or
|
|
973
|
+
* there was nothing to end). */
|
|
974
|
+
async function endSignedInSession(request, token) {
|
|
975
|
+
const found = await logoutStep(request, "findRefreshTokenByHash", () => store.findRefreshTokenByHash(hashRefreshToken(token)));
|
|
976
|
+
if (!found.ok)
|
|
977
|
+
return false;
|
|
978
|
+
const stored = found.value;
|
|
979
|
+
if (!stored)
|
|
980
|
+
return true;
|
|
981
|
+
const revoked = await logoutStep(request, "revokeFamily", () => store.revokeFamily(stored.familyId, { type: "signed_out" }, new Date()));
|
|
982
|
+
const session = await logoutStep(request, "findIdpSession", () => store.findIdpSession(stored.idpSessionId));
|
|
983
|
+
if (session.ok && session.value?.idpRefreshToken) {
|
|
984
|
+
await oidc.revokeIdpToken(session.value.idpRefreshToken);
|
|
985
|
+
}
|
|
986
|
+
const deleted = await logoutStep(request, "deleteIdpSession", () => store.deleteIdpSession(stored.idpSessionId));
|
|
987
|
+
return revoked.ok && session.ok && deleted.ok;
|
|
988
|
+
}
|
|
761
989
|
};
|
|
762
990
|
export const flytedeskAuth = fp(flytedeskAuthPlugin, {
|
|
763
991
|
name: "@flytedesk/app-kit/auth",
|