@eventuras/fides-auth 0.11.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/heartbeat.d.ts +20 -2
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +37 -3
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/oauth-logging.d.ts +15 -0
- package/dist/oauth-logging.d.ts.map +1 -1
- package/dist/oauth-logging.js +63 -1
- package/dist/oauth-logging.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +2 -0
- package/dist/oauth.js.map +1 -1
- package/dist/server/heartbeat-handler.d.ts +10 -3
- package/dist/server/heartbeat-handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +184 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/oidc-callback.d.ts.map +1 -1
- package/dist/server/oidc-logout.d.ts.map +1 -1
- package/dist/server/session.d.ts +78 -7
- package/dist/server/session.d.ts.map +1 -1
- package/dist/session-cookies.d.ts +30 -4
- package/dist/session-cookies.d.ts.map +1 -1
- package/dist/session-cookies.js +39 -39
- package/dist/session-cookies.js.map +1 -1
- package/dist/session-events.d.ts +149 -0
- package/dist/session-events.d.ts.map +1 -0
- package/dist/session-events.js +96 -0
- package/dist/session-events.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/session-events.md +189 -0
- package/package.json +7 -1
package/dist/server/index.js
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { ACCESS_TOKEN_COOKIE_NAME, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "../cookies.js";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
|
-
import { getOAuthErrorLogContext } from "../oauth-logging.js";
|
|
3
|
+
import { classifyRefreshFailure, getOAuthErrorLogContext } from "../oauth-logging.js";
|
|
4
|
+
import { SESSION_EVENT, logSessionEvent } from "../session-events.js";
|
|
4
5
|
import { buildOidcLogoutUrl, buildPKCEOptions, buildSessionFromTokens, discoverAndBuildAuthorizationUrl, exchangeAuthorizationCode, validateReturnUrl } from "../oauth.js";
|
|
5
6
|
import { refreshSession } from "../session-refresh.js";
|
|
6
7
|
import { getSessionSecret } from "../utils.js";
|
|
7
|
-
import { decodeIdTokenCookie,
|
|
8
|
+
import { decodeIdTokenCookie, encodeSessionCookies, tryDecodeSessionCookies } from "../session-cookies.js";
|
|
8
9
|
//#region src/server/session.ts
|
|
9
10
|
var logger$4 = createLogger({ namespace: "fides-auth:server:session" });
|
|
10
11
|
/** Applies the browser size guard to one cookie value; logs as it nears the limit. */
|
|
@@ -14,6 +15,14 @@ function checkSize(name, value) {
|
|
|
14
15
|
cookieName: name,
|
|
15
16
|
size
|
|
16
17
|
}, "Cookie approaching browser size limit");
|
|
18
|
+
return size;
|
|
19
|
+
}
|
|
20
|
+
/** Seconds until the access token expires, for the `expiresIn` log field. */
|
|
21
|
+
function expiresInSeconds(session) {
|
|
22
|
+
const at = session.tokens?.accessTokenExpiresAt;
|
|
23
|
+
if (!at) return void 0;
|
|
24
|
+
const ms = Date.parse(at);
|
|
25
|
+
return Number.isFinite(ms) ? Math.round((ms - Date.now()) / 1e3) : void 0;
|
|
17
26
|
}
|
|
18
27
|
/**
|
|
19
28
|
* Encrypts a session and writes it across the "session", "session_at" and
|
|
@@ -21,26 +30,45 @@ function checkSize(name, value) {
|
|
|
21
30
|
*
|
|
22
31
|
* @returns The encrypted JWT stored in the main "session" cookie.
|
|
23
32
|
*/
|
|
24
|
-
async function persistSession(store, session, secret) {
|
|
33
|
+
async function persistSession(store, session, secret, options = {}) {
|
|
25
34
|
const encoded = await encodeSessionCookies(session, secret);
|
|
26
35
|
const values = [
|
|
27
36
|
[SESSION_COOKIE_NAME, encoded.session],
|
|
28
37
|
[ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],
|
|
29
38
|
[ID_TOKEN_COOKIE_NAME, encoded.idToken]
|
|
30
39
|
];
|
|
31
|
-
|
|
40
|
+
let cookieBytes = 0;
|
|
41
|
+
for (const [name, value] of values) if (value) cookieBytes += checkSize(name, value);
|
|
32
42
|
for (const [name, value] of values) if (value) await store.set(name, value, defaultSessionCookieOptions);
|
|
33
43
|
else await store.delete(name);
|
|
44
|
+
if (options.event === SESSION_EVENT.CREATED) logSessionEvent(logger$4, {
|
|
45
|
+
event: SESSION_EVENT.CREATED,
|
|
46
|
+
sid: session.sid,
|
|
47
|
+
hasRefreshToken: !!session.tokens?.refreshToken,
|
|
48
|
+
scopes: session.scopes,
|
|
49
|
+
expiresIn: expiresInSeconds(session),
|
|
50
|
+
cookieBytes
|
|
51
|
+
});
|
|
52
|
+
else if (options.event === SESSION_EVENT.REFRESHED) logSessionEvent(logger$4, {
|
|
53
|
+
event: SESSION_EVENT.REFRESHED,
|
|
54
|
+
sid: session.sid,
|
|
55
|
+
expiresIn: expiresInSeconds(session),
|
|
56
|
+
rotatedRefreshToken: options.rotatedRefreshToken ?? false,
|
|
57
|
+
cookieBytes
|
|
58
|
+
});
|
|
34
59
|
return encoded.session;
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
37
|
-
* Reads and reassembles the current session
|
|
38
|
-
*
|
|
39
|
-
*
|
|
62
|
+
* Reads and reassembles the current session, reporting why when there isn't one.
|
|
63
|
+
*
|
|
64
|
+
* The reason comes from the package-wide {@link SessionRejectedReason}
|
|
65
|
+
* vocabulary, so a proxy or status route can log the same word the heartbeat
|
|
66
|
+
* endpoint logs. Does not log on its own — the caller owns the request context
|
|
67
|
+
* and decides whether a missing session is worth a line.
|
|
40
68
|
*/
|
|
41
|
-
async function
|
|
69
|
+
async function tryReadSession(store, secret) {
|
|
42
70
|
try {
|
|
43
|
-
return await
|
|
71
|
+
return await tryDecodeSessionCookies({
|
|
44
72
|
session: await store.get("session") ?? null,
|
|
45
73
|
accessToken: await store.get("session_at") ?? null,
|
|
46
74
|
idToken: await store.get("session_it") ?? null
|
|
@@ -48,43 +76,103 @@ async function readSession(store, secret) {
|
|
|
48
76
|
} catch (error) {
|
|
49
77
|
if (error instanceof Error && error.message.includes("worker")) logger$4.error({ error }, "Worker thread error reading session");
|
|
50
78
|
else logger$4.error({ error }, "Unexpected error reading session");
|
|
51
|
-
return
|
|
79
|
+
return {
|
|
80
|
+
session: null,
|
|
81
|
+
reason: "unreadable_session"
|
|
82
|
+
};
|
|
52
83
|
}
|
|
53
84
|
}
|
|
54
85
|
/**
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
86
|
+
* Reads and reassembles the current session from the cookie store, or null when
|
|
87
|
+
* there is none. Decode/validation rules live in `decodeSessionCookies`;
|
|
88
|
+
* the returned session may carry an expired access token.
|
|
89
|
+
*
|
|
90
|
+
* Prefer {@link tryReadSession} when you want to log *why* there is no session.
|
|
58
91
|
*/
|
|
59
|
-
async function
|
|
92
|
+
async function readSession(store, secret) {
|
|
93
|
+
return (await tryReadSession(store, secret)).session;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Refreshes the stored session and persists the result, reporting why on failure.
|
|
97
|
+
*
|
|
98
|
+
* The `cause` is the point of this function: `refreshSessionInStore` collapses
|
|
99
|
+
* "the provider says this refresh token is dead" and "we could not reach the
|
|
100
|
+
* provider" into the same `null`, and a caller that logs the user out on both
|
|
101
|
+
* ends sessions that were never actually invalid. Only `invalid_grant` is
|
|
102
|
+
* terminal — see {@link classifyRefreshFailure}.
|
|
103
|
+
*/
|
|
104
|
+
async function tryRefreshSessionInStore(store, config, secret, options = {}) {
|
|
105
|
+
const read = await tryReadSession(store, secret);
|
|
106
|
+
const current = read.session;
|
|
107
|
+
if (!current) return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: read.reason
|
|
110
|
+
};
|
|
111
|
+
if (!current.tokens?.refreshToken) return {
|
|
112
|
+
ok: false,
|
|
113
|
+
reason: "no_refresh_token",
|
|
114
|
+
sid: current.sid
|
|
115
|
+
};
|
|
60
116
|
try {
|
|
61
|
-
const current = await readSession(store, secret);
|
|
62
|
-
if (!current) {
|
|
63
|
-
logger$4.warn("No current session to refresh");
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
if (!current.tokens?.refreshToken) {
|
|
67
|
-
logger$4.error("Current session has no refresh token");
|
|
68
|
-
return null;
|
|
69
|
-
}
|
|
70
117
|
const updated = await refreshSession(current, config, options);
|
|
71
118
|
if (!updated) {
|
|
72
|
-
logger$4
|
|
73
|
-
|
|
119
|
+
logSessionEvent(logger$4, {
|
|
120
|
+
event: SESSION_EVENT.REFRESH_FAILED,
|
|
121
|
+
sid: current.sid,
|
|
122
|
+
cause: "idp_error",
|
|
123
|
+
accessTokenExpiresAt: current.tokens.accessTokenExpiresAt
|
|
124
|
+
});
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: "refresh_failed",
|
|
128
|
+
cause: "idp_error",
|
|
129
|
+
sid: current.sid
|
|
130
|
+
};
|
|
74
131
|
}
|
|
75
|
-
|
|
76
|
-
|
|
132
|
+
const rotatedRefreshToken = updated.tokens?.refreshToken !== current.tokens.refreshToken;
|
|
133
|
+
await persistSession(store, updated, secret, {
|
|
134
|
+
event: SESSION_EVENT.REFRESHED,
|
|
135
|
+
rotatedRefreshToken
|
|
136
|
+
});
|
|
137
|
+
return {
|
|
138
|
+
ok: true,
|
|
139
|
+
session: updated,
|
|
140
|
+
rotatedRefreshToken
|
|
141
|
+
};
|
|
77
142
|
} catch (error) {
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
143
|
+
const cause = classifyRefreshFailure(error);
|
|
144
|
+
logSessionEvent(logger$4, {
|
|
145
|
+
event: SESSION_EVENT.REFRESH_FAILED,
|
|
146
|
+
sid: current.sid,
|
|
147
|
+
cause,
|
|
148
|
+
status: getOAuthErrorLogContext(error).status,
|
|
149
|
+
accessTokenExpiresAt: current.tokens.accessTokenExpiresAt,
|
|
150
|
+
error: getOAuthErrorLogContext(error)
|
|
151
|
+
});
|
|
152
|
+
return {
|
|
153
|
+
ok: false,
|
|
154
|
+
reason: "refresh_failed",
|
|
155
|
+
cause,
|
|
156
|
+
sid: current.sid
|
|
157
|
+
};
|
|
82
158
|
}
|
|
83
159
|
}
|
|
84
160
|
/**
|
|
85
|
-
*
|
|
86
|
-
* the
|
|
87
|
-
*
|
|
161
|
+
* Refreshes the stored session using its refresh token and persists the result.
|
|
162
|
+
* Returns the updated session, or null when there is nothing to refresh or the
|
|
163
|
+
* refresh token is no longer valid.
|
|
164
|
+
*
|
|
165
|
+
* Prefer {@link tryRefreshSessionInStore}: this signature cannot tell a dead
|
|
166
|
+
* refresh token apart from an unreachable provider, and treating the two alike
|
|
167
|
+
* logs out users whose sessions are still valid.
|
|
168
|
+
*/
|
|
169
|
+
async function refreshSessionInStore(store, config, secret, options = {}) {
|
|
170
|
+
const result = await tryRefreshSessionInStore(store, config, secret, options);
|
|
171
|
+
return result.ok ? result.session : null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Reads the raw ID token independently of the session cookies — logout needs
|
|
175
|
+
* the hint even when {@link readSession} returns null.
|
|
88
176
|
*/
|
|
89
177
|
async function readIdToken(store, secret) {
|
|
90
178
|
try {
|
|
@@ -96,10 +184,15 @@ async function readIdToken(store, secret) {
|
|
|
96
184
|
}
|
|
97
185
|
}
|
|
98
186
|
/** Deletes all session cookies. */
|
|
99
|
-
async function clearSession(store) {
|
|
187
|
+
async function clearSession(store, options = {}) {
|
|
100
188
|
await store.delete(SESSION_COOKIE_NAME);
|
|
101
189
|
await store.delete(ACCESS_TOKEN_COOKIE_NAME);
|
|
102
190
|
await store.delete(ID_TOKEN_COOKIE_NAME);
|
|
191
|
+
if (options.trigger) logSessionEvent(logger$4, {
|
|
192
|
+
event: SESSION_EVENT.CLEARED,
|
|
193
|
+
sid: options.sid,
|
|
194
|
+
trigger: options.trigger
|
|
195
|
+
});
|
|
103
196
|
}
|
|
104
197
|
//#endregion
|
|
105
198
|
//#region src/server/oidc-login.ts
|
|
@@ -154,14 +247,18 @@ async function handleOidcCallback(request, config) {
|
|
|
154
247
|
return new Response("Please restart the login process.", { status: 400 });
|
|
155
248
|
}
|
|
156
249
|
const tokens = await exchangeAuthorizationCode(oauthConfig, publicUrl, storedCodeVerifier, storedState);
|
|
157
|
-
|
|
250
|
+
const session = buildSessionFromTokens(tokens, rolesClaim);
|
|
251
|
+
await persistSession(cookies, session, secret, { event: SESSION_EVENT.CREATED });
|
|
158
252
|
const returnTo = await cookies.get("returnTo");
|
|
159
253
|
await cookies.delete("oauth_state");
|
|
160
254
|
await cookies.delete("oauth_code_verifier");
|
|
161
255
|
await cookies.delete("returnTo");
|
|
162
256
|
const redirectUrl = validateReturnUrl(returnTo ?? null, applicationUrl, defaultRedirectPath, validateReturnTo);
|
|
163
257
|
redirectUrl.searchParams.set("login", "success");
|
|
164
|
-
logger$2.info({
|
|
258
|
+
logger$2.info({
|
|
259
|
+
sid: session.sid,
|
|
260
|
+
redirectUrl: redirectUrl.toString()
|
|
261
|
+
}, "Login successful, redirecting");
|
|
165
262
|
return new Response(null, {
|
|
166
263
|
status: 302,
|
|
167
264
|
headers: { Location: redirectUrl.toString() }
|
|
@@ -224,8 +321,12 @@ async function handleOidcLogout(request, config) {
|
|
|
224
321
|
return new Response("Too many requests", { status: 429 });
|
|
225
322
|
}
|
|
226
323
|
const idTokenHint = await readIdToken(cookies, secret);
|
|
324
|
+
const sid = (await tryReadSession(cookies, secret)).session?.sid;
|
|
227
325
|
try {
|
|
228
|
-
await clearSession(cookies
|
|
326
|
+
await clearSession(cookies, {
|
|
327
|
+
trigger: "logout",
|
|
328
|
+
sid
|
|
329
|
+
});
|
|
229
330
|
} catch (error) {
|
|
230
331
|
logger$1.error({ error }, "Failed to clear session cookies");
|
|
231
332
|
return new Response("Logout failed", { status: 500 });
|
|
@@ -256,9 +357,16 @@ var logger = createLogger({ namespace: "fides-auth:server:heartbeat" });
|
|
|
256
357
|
/**
|
|
257
358
|
* Handles a heartbeat request — refresh the active session if there is one.
|
|
258
359
|
*
|
|
259
|
-
* Returns 200 with `{ accessTokenExpiresAt }` on success, 401
|
|
260
|
-
*
|
|
261
|
-
* rate-limited.
|
|
360
|
+
* Returns 200 with `{ accessTokenExpiresAt }` on success, 401 with `{ reason }`
|
|
361
|
+
* when the session is genuinely over, 503 when the provider could not be
|
|
362
|
+
* reached, 405 for non-POST methods, and 429 when rate-limited.
|
|
363
|
+
*
|
|
364
|
+
* The 401/503 split matters: a 401 tells the client to log the user out, so it
|
|
365
|
+
* must be reserved for the cases where the session really is finished. A network
|
|
366
|
+
* blip or a provider 5xx says nothing about the session's validity — answering
|
|
367
|
+
* 401 there logs out users whose sessions were fine, which is precisely the
|
|
368
|
+
* "logged out every few minutes" symptom. The client already retries non-401
|
|
369
|
+
* failures with backoff.
|
|
262
370
|
*/
|
|
263
371
|
async function handleHeartbeat(request, config) {
|
|
264
372
|
const { oauthConfig, cookies, rateLimit, secret = getSessionSecret() } = config;
|
|
@@ -270,24 +378,44 @@ async function handleHeartbeat(request, config) {
|
|
|
270
378
|
logger.warn("Heartbeat rate limit exceeded");
|
|
271
379
|
return new Response(null, { status: 429 });
|
|
272
380
|
}
|
|
273
|
-
const
|
|
274
|
-
if (
|
|
275
|
-
logger.debug("Heartbeat
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
if (!session.tokens?.refreshToken) {
|
|
279
|
-
logger.warn("Heartbeat with session but no refresh token");
|
|
280
|
-
return new Response(null, { status: 401 });
|
|
381
|
+
const result = await tryRefreshSessionInStore(cookies, oauthConfig, secret);
|
|
382
|
+
if (result.ok) {
|
|
383
|
+
logger.debug({ sid: result.session.sid }, "Heartbeat refresh succeeded");
|
|
384
|
+
return Response.json({ accessTokenExpiresAt: result.session.tokens?.accessTokenExpiresAt ?? null }, { headers: { "Cache-Control": "private, no-store" } });
|
|
281
385
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
386
|
+
if (result.cause === "transport" || result.cause === "idp_error") {
|
|
387
|
+
logger.warn({
|
|
388
|
+
sid: result.sid,
|
|
389
|
+
cause: result.cause
|
|
390
|
+
}, "Heartbeat refresh could not be completed — keeping the session");
|
|
391
|
+
return new Response(null, {
|
|
392
|
+
status: 503,
|
|
393
|
+
headers: { "Cache-Control": "private, no-store" }
|
|
394
|
+
});
|
|
286
395
|
}
|
|
287
|
-
logger
|
|
288
|
-
|
|
396
|
+
logSessionEvent(logger, {
|
|
397
|
+
event: SESSION_EVENT.REJECTED,
|
|
398
|
+
sid: result.sid,
|
|
399
|
+
reason: result.reason,
|
|
400
|
+
source: "heartbeat"
|
|
401
|
+
});
|
|
402
|
+
return unauthorized(result.reason);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* 401 carrying the reason in the body.
|
|
406
|
+
*
|
|
407
|
+
* The browser is where the logout becomes visible, so letting the client name
|
|
408
|
+
* the reason lets a consumer beacon it back with the same vocabulary the server
|
|
409
|
+
* used. The values are session state, not secrets — and clients that ignore the
|
|
410
|
+
* body are unaffected.
|
|
411
|
+
*/
|
|
412
|
+
function unauthorized(reason) {
|
|
413
|
+
return Response.json({ reason }, {
|
|
414
|
+
status: 401,
|
|
415
|
+
headers: { "Cache-Control": "private, no-store" }
|
|
416
|
+
});
|
|
289
417
|
}
|
|
290
418
|
//#endregion
|
|
291
|
-
export { clearSession, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, readIdToken, readSession, refreshSessionInStore };
|
|
419
|
+
export { clearSession, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, readIdToken, readSession, refreshSessionInStore, tryReadSession, tryRefreshSessionInStore };
|
|
292
420
|
|
|
293
421
|
//# sourceMappingURL=index.js.map
|
package/dist/server/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/server/session.ts","../../src/server/oidc-login.ts","../../src/server/oidc-callback.ts","../../src/server/oidc-logout.ts","../../src/server/heartbeat-handler.ts"],"sourcesContent":["// server/session.ts\n//\n// Framework-agnostic session persistence over a {@link CookieStore}. The split\n// encode/decode lives in `../session-cookies`; these helpers wire it to a cookie\n// store, apply the per-cookie size guard, and own the refresh flow — with no\n// dependency on any framework's cookie API.\n\nimport {\n ACCESS_TOKEN_COOKIE_NAME,\n COOKIE_INFO_BYTES,\n ID_TOKEN_COOKIE_NAME,\n SESSION_COOKIE_NAME,\n assertCookieWithinLimit,\n defaultSessionCookieOptions,\n} from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { getOAuthErrorLogContext } from '../oauth-logging';\nimport { refreshSession } from '../session-refresh';\nimport {\n decodeIdTokenCookie,\n decodeSessionCookies,\n encodeSessionCookies,\n} from '../session-cookies';\nimport type { CreateSessionOptions, Session } from '../types';\nimport type { CookieStore } from './cookie-store';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:session' });\n\ntype Secret = string | Uint8Array;\n\n/** Applies the browser size guard to one cookie value; logs as it nears the limit. */\nfunction checkSize(name: string, value: string): void {\n // Fail loudly above the browser per-cookie limit (the browser would otherwise\n // drop the cookie silently, producing a broken login).\n const size = assertCookieWithinLimit(name, value);\n if (size >= COOKIE_INFO_BYTES) {\n logger.info({ cookieName: name, size }, 'Cookie approaching browser size limit');\n }\n}\n\n/**\n * Encrypts a session and writes it across the \"session\", \"session_at\" and\n * \"session_it\" cookies, clearing the split-out cookies the session has no token for.\n *\n * @returns The encrypted JWT stored in the main \"session\" cookie.\n */\nexport async function persistSession(\n store: CookieStore,\n session: Session,\n secret: Secret,\n): Promise<string> {\n const encoded = await encodeSessionCookies(session, secret);\n\n const values: Array<[string, string | undefined]> = [\n [SESSION_COOKIE_NAME, encoded.session],\n [ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],\n [ID_TOKEN_COOKIE_NAME, encoded.idToken],\n ];\n\n // Size-check everything before touching the store. Throwing part-way through\n // would leave one user's session cookie next to another user's tokens — the\n // caller sees an error while the browser holds a working, mixed-up session.\n for (const [name, value] of values) {\n if (value) checkSize(name, value);\n }\n\n for (const [name, value] of values) {\n if (value) {\n await store.set(name, value, defaultSessionCookieOptions);\n } else {\n await store.delete(name);\n }\n }\n\n return encoded.session;\n}\n\n/**\n * Reads and reassembles the current session from the cookie store, or null when\n * there is no valid session. All the split/expiry/legacy handling lives in\n * {@link decodeSessionCookies}; this just supplies the cookie values.\n */\nexport async function readSession(store: CookieStore, secret: Secret): Promise<Session | null> {\n try {\n return await decodeSessionCookies(\n {\n session: (await store.get(SESSION_COOKIE_NAME)) ?? null,\n accessToken: (await store.get(ACCESS_TOKEN_COOKIE_NAME)) ?? null,\n idToken: (await store.get(ID_TOKEN_COOKIE_NAME)) ?? null,\n },\n secret,\n );\n } catch (error) {\n // Worker thread errors (e.g. from the crypto worker) should not crash the app.\n if (error instanceof Error && error.message.includes('worker')) {\n logger.error({ error }, 'Worker thread error reading session');\n } else {\n logger.error({ error }, 'Unexpected error reading session');\n }\n return null;\n }\n}\n\n/**\n * Refreshes the stored session using its refresh token and persists the result.\n * Returns the updated session, or null when there is nothing to refresh or the\n * refresh token is no longer valid.\n */\nexport async function refreshSessionInStore(\n store: CookieStore,\n config: OAuthConfig,\n secret: Secret,\n options: CreateSessionOptions = {},\n): Promise<Session | null> {\n try {\n const current = await readSession(store, secret);\n\n if (!current) {\n logger.warn('No current session to refresh');\n return null;\n }\n if (!current.tokens?.refreshToken) {\n logger.error('Current session has no refresh token');\n return null;\n }\n\n const updated = await refreshSession(current, config, options);\n if (!updated) {\n logger.error('Session refresh returned null');\n return null;\n }\n\n await persistSession(store, updated, secret);\n return updated;\n } catch (error) {\n // invalid_grant is expected during logout/session expiry — log it quietly.\n // getOAuthErrorLogContext unwraps errors (fields may live on `cause`), which a\n // direct property read would miss and misclassify.\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n logger.error({ error: errorContext }, 'Failed to refresh session');\n }\n return null;\n }\n}\n\n/**\n * Reads the raw ID token, whether or not the session is still valid. Logout needs\n * the hint after the access token has expired, which is exactly when\n * {@link readSession} returns null.\n */\nexport async function readIdToken(store: CookieStore, secret: Secret): Promise<string | undefined> {\n try {\n const raw = await store.get(ID_TOKEN_COOKIE_NAME);\n return raw ? await decodeIdTokenCookie(raw, secret) : undefined;\n } catch (error) {\n logger.error({ error }, 'Unexpected error reading ID token');\n return undefined;\n }\n}\n\n/** Deletes all session cookies. */\nexport async function clearSession(store: CookieStore): Promise<void> {\n await store.delete(SESSION_COOKIE_NAME);\n await store.delete(ACCESS_TOKEN_COOKIE_NAME);\n await store.delete(ID_TOKEN_COOKIE_NAME);\n}\n","// server/oidc-login.ts\n//\n// Framework-agnostic OIDC login initiation. Builds PKCE parameters, stores them\n// in the cookie store, and redirects to the provider's authorization endpoint.\n// Uses the standard Request/Response, a CookieStore, and an optional rate-limit\n// callback, so any server runtime can call it.\n\nimport { defaultOAuthCookieOptions } from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { buildPKCEOptions, discoverAndBuildAuthorizationUrl } from '../oauth';\nimport type { CookieStore } from './cookie-store';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-login' });\n\nexport interface OidcLoginConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store used to persist the PKCE state. */\n cookies: CookieStore;\n\n /**\n * Optional rate-limit gate. When it resolves false, the handler responds 429.\n */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /**\n * Validates and sanitizes the returnTo parameter, returning a safe path or null.\n * @default Only allows relative paths starting with a single `/`.\n */\n validateReturnTo?: (rawReturnTo: string) => string | null;\n}\n\nconst defaultValidateReturnTo = (raw: string): string | null =>\n /^\\/(?!\\/)/.test(raw) ? raw : null;\n\n/**\n * Handles OIDC login initiation: rate-limit, build PKCE, persist it, and redirect\n * to the authorization endpoint.\n */\nexport async function handleOidcLogin(\n request: Request,\n config: OidcLoginConfig,\n): Promise<Response> {\n const { oauthConfig, cookies, rateLimit, validateReturnTo = defaultValidateReturnTo } = config;\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n const url = new URL(request.url);\n const rawReturnTo = url.searchParams.get('returnTo');\n const returnTo = rawReturnTo ? validateReturnTo(rawReturnTo) : null;\n\n const pkce = await buildPKCEOptions(oauthConfig);\n const authorizationUrl = await discoverAndBuildAuthorizationUrl(oauthConfig, pkce);\n\n logger.info('Redirecting to OIDC provider for login');\n\n await cookies.set('oauth_state', pkce.state, defaultOAuthCookieOptions);\n await cookies.set('oauth_code_verifier', pkce.code_verifier, defaultOAuthCookieOptions);\n if (returnTo) {\n await cookies.set('returnTo', returnTo, defaultOAuthCookieOptions);\n } else {\n // Clear any stale returnTo from an earlier, abandoned login so this one's\n // post-login redirect is deterministic rather than inheriting a past path.\n await cookies.delete('returnTo');\n }\n\n return new Response(null, {\n status: 302,\n headers: { Location: authorizationUrl.toString() },\n });\n}\n","// server/oidc-callback.ts\n//\n// Framework-agnostic OIDC callback handler. Orchestrates the OIDC concerns —\n// rate limit, PKCE cookie read, code exchange, session persistence, safe redirect\n// — over the standard Request/Response and a CookieStore, delegating the protocol\n// to the core oauth module and persistence to the server session helpers.\n\nimport { CookieTooLargeError } from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport {\n buildSessionFromTokens,\n exchangeAuthorizationCode,\n validateReturnUrl,\n} from '../oauth';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { persistSession } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-callback' });\n\nexport interface OidcCallbackConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Public application URL (used to reconstruct the callback URL and validate redirects). */\n applicationUrl: string;\n\n /** Cookie store holding the PKCE state and receiving the session cookies. */\n cookies: CookieStore;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n\n /**\n * Name of the ID token claim that contains user roles.\n * @default 'roles'\n */\n rolesClaim?: string;\n\n /**\n * Default path to redirect to after login if no returnTo cookie is set.\n * @default '/'\n */\n defaultRedirectPath?: string;\n\n /**\n * Optional validator for the returnTo path. If omitted, only same-origin paths\n * are allowed.\n */\n validateReturnTo?: (path: string) => boolean;\n}\n\n/**\n * Handles the OIDC callback: rate-limit, exchange the code for tokens, persist the\n * session across the cookie store, and redirect to a validated returnTo.\n */\nexport async function handleOidcCallback(\n request: Request,\n config: OidcCallbackConfig,\n): Promise<Response> {\n const {\n oauthConfig,\n applicationUrl,\n cookies,\n rateLimit,\n secret = getSessionSecret(),\n rolesClaim = 'roles',\n defaultRedirectPath = '/',\n validateReturnTo,\n } = config;\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n try {\n // Reconstruct the public callback URL. Behind a TLS-terminating proxy\n // applicationUrl is just the origin (\"/\"), and the token-exchange redirect_uri\n // must match the callback path used at authorize — so keep the request path.\n const currentUrl = new URL(request.url);\n const publicUrl = new URL(applicationUrl);\n publicUrl.pathname = currentUrl.pathname;\n publicUrl.search = currentUrl.search;\n\n const storedState = await cookies.get('oauth_state');\n const storedCodeVerifier = await cookies.get('oauth_code_verifier');\n\n if (!storedState || !storedCodeVerifier) {\n logger.warn('Missing state or code verifier');\n return new Response('Please restart the login process.', { status: 400 });\n }\n\n const tokens = await exchangeAuthorizationCode(\n oauthConfig,\n publicUrl,\n storedCodeVerifier,\n storedState,\n );\n\n // Build the session and persist it (split across session/session_at).\n const session = buildSessionFromTokens(tokens, rolesClaim);\n await persistSession(cookies, session, secret);\n\n // Clean up PKCE & returnTo cookies (read returnTo first).\n const returnTo = await cookies.get('returnTo');\n await cookies.delete('oauth_state');\n await cookies.delete('oauth_code_verifier');\n await cookies.delete('returnTo');\n\n const redirectUrl = validateReturnUrl(\n returnTo ?? null,\n applicationUrl,\n defaultRedirectPath,\n validateReturnTo,\n );\n redirectUrl.searchParams.set('login', 'success');\n\n logger.info({ redirectUrl: redirectUrl.toString() }, 'Login successful, redirecting');\n\n return new Response(null, {\n status: 302,\n headers: { Location: redirectUrl.toString() },\n });\n } catch (error) {\n // The session was too large for a cookie — surface this distinctly instead of\n // hiding it in a generic 500, so it's diagnosable in production.\n if (error instanceof CookieTooLargeError) {\n logger.error(\n { error, cookieName: error.cookieName, size: error.size },\n 'Session cookie too large to store',\n );\n return new Response('The session is too large to store. Please contact support.', {\n status: 431,\n });\n }\n\n logger.error({ error }, 'OIDC callback error');\n return new Response('An unexpected error occurred. Please restart the login process.', {\n status: 500,\n });\n }\n}\n","// server/oidc-logout.ts\n//\n// Framework-agnostic RP-initiated logout. Clearing only the local session cookie\n// leaves the provider's session alive, so the next /authorize silently\n// re-authenticates — this handler ends both.\n\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { buildOidcLogoutUrl } from '../oauth';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { clearSession, readIdToken } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-logout' });\n\n/**\n * Whether the request came from the application itself. POST alone is no defence:\n * a third-party page can auto-submit a form at this endpoint, and the response\n * clears the cookies whether or not the browser attached them.\n *\n * `Sec-Fetch-Site` is authoritative where the browser sends it. Otherwise fall back\n * to `Origin`. A request with neither is not a browser form post, so it is allowed.\n */\nfunction isSameOrigin(request: Request, applicationUrl?: string): boolean {\n const site = request.headers.get('sec-fetch-site');\n if (site) {\n return site === 'same-origin' || site === 'none';\n }\n\n const origin = request.headers.get('origin');\n if (!origin) return true;\n\n try {\n return new URL(origin).origin === new URL(applicationUrl ?? request.url).origin;\n } catch {\n return false;\n }\n}\n\nexport interface OidcLogoutConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store holding the session cookies to read and then clear. */\n cookies: CookieStore;\n\n /** Post-logout redirect URI. Must be registered with the provider. */\n postLogoutRedirectUri: string;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Passed through as `state`; verifying it on the way back is the caller's job. */\n state?: string;\n\n /** `logout_hint`, when the provider documents one. */\n logoutHint?: string;\n\n /** Whether to send `client_id`. Default true — see `OidcLogoutOptions`. */\n includeClientId?: boolean;\n\n /**\n * Request methods that may trigger a logout. Defaults to POST only: on GET, any\n * third-party page can force a logout by navigating the browser.\n * @default ['POST']\n */\n allowedMethods?: string[];\n\n /**\n * Public application URL, used to check the `Origin` header behind a proxy where\n * the request URL carries the internal origin. Defaults to the request's own\n * origin.\n */\n applicationUrl?: string;\n\n /**\n * Where to send the user when logout is local-only (no end_session_endpoint).\n * @default postLogoutRedirectUri\n */\n fallbackRedirectUri?: string;\n}\n\n/**\n * Clears the session cookies and redirects to the provider's end-session endpoint\n * with `id_token_hint`. Local cookies are cleared even when the provider hop is\n * unavailable, so the user is always logged out of this application.\n */\nexport async function handleOidcLogout(\n request: Request,\n config: OidcLogoutConfig,\n): Promise<Response> {\n const {\n oauthConfig,\n cookies,\n postLogoutRedirectUri,\n secret = getSessionSecret(),\n rateLimit,\n state,\n logoutHint,\n includeClientId,\n fallbackRedirectUri = postLogoutRedirectUri,\n allowedMethods = ['POST'],\n applicationUrl,\n } = config;\n\n if (!allowedMethods.includes(request.method)) {\n return new Response(null, {\n status: 405,\n headers: { Allow: allowedMethods.join(', ') },\n });\n }\n\n if (!isSameOrigin(request, applicationUrl)) {\n logger.warn(\n { origin: request.headers.get('origin'), site: request.headers.get('sec-fetch-site') },\n 'Cross-origin logout request rejected',\n );\n return new Response('Forbidden', { status: 403 });\n }\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n // Read before clearing. Deliberately not readSession: that returns null once the\n // access token has expired, which is exactly when the hint still matters.\n const idTokenHint = await readIdToken(cookies, secret);\n\n try {\n await clearSession(cookies);\n } catch (error) {\n // Never redirect to the provider on a half-cleared local session — the user\n // would come back looking logged in.\n logger.error({ error }, 'Failed to clear session cookies');\n return new Response('Logout failed', { status: 500 });\n }\n\n const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {\n postLogoutRedirectUri,\n idTokenHint,\n state,\n logoutHint,\n includeClientId,\n });\n\n if (!logoutUrl) {\n // Either the provider advertises no end_session_endpoint or discovery failed;\n // buildOidcLogoutUrl logs which. Local logout stands either way.\n logger.info(\n { issuer: oauthConfig.issuer },\n 'No logout URL — local session cleared, provider session may persist',\n );\n return new Response(null, { status: 302, headers: { Location: fallbackRedirectUri } });\n }\n\n logger.info({ hasIdTokenHint: !!idTokenHint }, 'Redirecting to provider for logout');\n\n return new Response(null, { status: 302, headers: { Location: logoutUrl.toString() } });\n}\n","// server/heartbeat-handler.ts\n//\n// Framework-agnostic heartbeat endpoint. Pairs with the client-side heartbeat:\n// when the user is active the client POSTs here, and this handler exchanges the\n// current refresh token for a fresh access token. Glue only — rate limit, load\n// session, refresh, respond — over the standard Request/Response and a CookieStore.\n\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { readSession, refreshSessionInStore } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:heartbeat' });\n\nexport interface HeartbeatHandlerConfig {\n /** OAuth/OIDC configuration used to refresh the access token. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store holding the session. */\n cookies: CookieStore;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n}\n\n/**\n * Handles a heartbeat request — refresh the active session if there is one.\n *\n * Returns 200 with `{ accessTokenExpiresAt }` on success, 401 when there is no\n * session or the refresh token is dead, 405 for non-POST methods, and 429 when\n * rate-limited.\n */\nexport async function handleHeartbeat(\n request: Request,\n config: HeartbeatHandlerConfig,\n): Promise<Response> {\n const { oauthConfig, cookies, rateLimit, secret = getSessionSecret() } = config;\n\n if (request.method !== 'POST') {\n return new Response(null, { status: 405, headers: { Allow: 'POST' } });\n }\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Heartbeat rate limit exceeded');\n return new Response(null, { status: 429 });\n }\n\n const session = await readSession(cookies, secret);\n if (!session) {\n logger.debug('Heartbeat with no session');\n return new Response(null, { status: 401 });\n }\n\n if (!session.tokens?.refreshToken) {\n logger.warn('Heartbeat with session but no refresh token');\n return new Response(null, { status: 401 });\n }\n\n const updated = await refreshSessionInStore(cookies, oauthConfig, secret);\n if (!updated) {\n // Refresh failed — most likely invalid_grant (refresh token expired).\n logger.info('Heartbeat refresh failed');\n return new Response(null, { status: 401 });\n }\n\n logger.debug('Heartbeat refresh succeeded');\n return Response.json(\n { accessTokenExpiresAt: updated.tokens?.accessTokenExpiresAt ?? null },\n {\n // Auth/session endpoint — must never be cached by the browser or any\n // intermediary, or a stale 200 could fool the client into thinking a refresh\n // succeeded without actually hitting the server.\n headers: { 'Cache-Control': 'private, no-store' },\n },\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/server/session.ts","../../src/server/oidc-login.ts","../../src/server/oidc-callback.ts","../../src/server/oidc-logout.ts","../../src/server/heartbeat-handler.ts"],"sourcesContent":["// server/session.ts\n//\n// Framework-agnostic session persistence over a {@link CookieStore}. The split\n// encode/decode lives in `../session-cookies`; these helpers wire it to a cookie\n// store, apply the per-cookie size guard, and own the refresh flow — with no\n// dependency on any framework's cookie API.\n//\n// This is also where the session lifecycle events in `../session-events` are\n// emitted: it is the one layer that sees both the session and the bytes it\n// costs, so `session.created` / `session.refreshed` are raised from here rather\n// than from each caller.\n\nimport {\n ACCESS_TOKEN_COOKIE_NAME,\n COOKIE_INFO_BYTES,\n ID_TOKEN_COOKIE_NAME,\n SESSION_COOKIE_NAME,\n assertCookieWithinLimit,\n defaultSessionCookieOptions,\n} from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { classifyRefreshFailure, getOAuthErrorLogContext } from '../oauth-logging';\nimport { refreshSession } from '../session-refresh';\nimport {\n SESSION_EVENT,\n logSessionEvent,\n type RefreshFailureCause,\n type SessionClearedTrigger,\n type SessionRejectedReason,\n} from '../session-events';\nimport {\n decodeIdTokenCookie,\n encodeSessionCookies,\n tryDecodeSessionCookies,\n} from '../session-cookies';\nimport type { CreateSessionOptions, Session } from '../types';\nimport type { CookieStore } from './cookie-store';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:session' });\n\ntype Secret = string | Uint8Array;\n\n/** Applies the browser size guard to one cookie value; logs as it nears the limit. */\nfunction checkSize(name: string, value: string): number {\n // Fail loudly above the browser per-cookie limit (the browser would otherwise\n // drop the cookie silently, producing a broken login).\n const size = assertCookieWithinLimit(name, value);\n if (size >= COOKIE_INFO_BYTES) {\n logger.info({ cookieName: name, size }, 'Cookie approaching browser size limit');\n }\n return size;\n}\n\n/** Seconds until the access token expires, for the `expiresIn` log field. */\nfunction expiresInSeconds(session: Session): number | undefined {\n const at = session.tokens?.accessTokenExpiresAt;\n if (!at) return undefined;\n const ms = Date.parse(at);\n return Number.isFinite(ms) ? Math.round((ms - Date.now()) / 1000) : undefined;\n}\n\n/** How {@link persistSession} should report what it just wrote. */\nexport interface PersistSessionOptions {\n /**\n * Which lifecycle event this write represents. Omit to write silently — for\n * callers that emit their own event, or that persist for reasons the session\n * vocabulary doesn't cover.\n */\n event?: typeof SESSION_EVENT.CREATED | typeof SESSION_EVENT.REFRESHED;\n /** Only meaningful for {@link SESSION_EVENT.REFRESHED}: did the provider issue a new refresh token? */\n rotatedRefreshToken?: boolean;\n}\n\n/**\n * Encrypts a session and writes it across the \"session\", \"session_at\" and\n * \"session_it\" cookies, clearing the split-out cookies the session has no token for.\n *\n * @returns The encrypted JWT stored in the main \"session\" cookie.\n */\nexport async function persistSession(\n store: CookieStore,\n session: Session,\n secret: Secret,\n options: PersistSessionOptions = {},\n): Promise<string> {\n const encoded = await encodeSessionCookies(session, secret);\n\n const values: Array<[string, string | undefined]> = [\n [SESSION_COOKIE_NAME, encoded.session],\n [ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],\n [ID_TOKEN_COOKIE_NAME, encoded.idToken],\n ];\n\n // Size-check everything before touching the store. Throwing part-way through\n // would leave one user's session cookie next to another user's tokens — the\n // caller sees an error while the browser holds a working, mixed-up session.\n let cookieBytes = 0;\n for (const [name, value] of values) {\n if (value) cookieBytes += checkSize(name, value);\n }\n\n for (const [name, value] of values) {\n if (value) {\n await store.set(name, value, defaultSessionCookieOptions);\n } else {\n await store.delete(name);\n }\n }\n\n if (options.event === SESSION_EVENT.CREATED) {\n logSessionEvent(logger, {\n event: SESSION_EVENT.CREATED,\n sid: session.sid,\n hasRefreshToken: !!session.tokens?.refreshToken,\n scopes: session.scopes,\n expiresIn: expiresInSeconds(session),\n cookieBytes,\n });\n } else if (options.event === SESSION_EVENT.REFRESHED) {\n logSessionEvent(logger, {\n event: SESSION_EVENT.REFRESHED,\n sid: session.sid,\n expiresIn: expiresInSeconds(session),\n rotatedRefreshToken: options.rotatedRefreshToken ?? false,\n cookieBytes,\n });\n }\n\n return encoded.session;\n}\n\n/** Outcome of reading the session from the cookie store. */\nexport type ReadSessionResult =\n | { session: Session; reason?: undefined }\n | { session: null; reason: SessionRejectedReason };\n\n/**\n * Reads and reassembles the current session, reporting why when there isn't one.\n *\n * The reason comes from the package-wide {@link SessionRejectedReason}\n * vocabulary, so a proxy or status route can log the same word the heartbeat\n * endpoint logs. Does not log on its own — the caller owns the request context\n * and decides whether a missing session is worth a line.\n */\nexport async function tryReadSession(store: CookieStore, secret: Secret): Promise<ReadSessionResult> {\n try {\n return await tryDecodeSessionCookies(\n {\n session: (await store.get(SESSION_COOKIE_NAME)) ?? null,\n accessToken: (await store.get(ACCESS_TOKEN_COOKIE_NAME)) ?? null,\n idToken: (await store.get(ID_TOKEN_COOKIE_NAME)) ?? null,\n },\n secret,\n );\n } catch (error) {\n // Worker thread errors (e.g. from the crypto worker) should not crash the app.\n if (error instanceof Error && error.message.includes('worker')) {\n logger.error({ error }, 'Worker thread error reading session');\n } else {\n logger.error({ error }, 'Unexpected error reading session');\n }\n return { session: null, reason: 'unreadable_session' };\n }\n}\n\n/**\n * Reads and reassembles the current session from the cookie store, or null when\n * there is none. Decode/validation rules live in `decodeSessionCookies`;\n * the returned session may carry an expired access token.\n *\n * Prefer {@link tryReadSession} when you want to log *why* there is no session.\n */\nexport async function readSession(store: CookieStore, secret: Secret): Promise<Session | null> {\n return (await tryReadSession(store, secret)).session;\n}\n\n/** Outcome of a refresh attempt. */\nexport type RefreshSessionResult =\n | { ok: true; session: Session; rotatedRefreshToken: boolean }\n | {\n ok: false;\n reason: SessionRejectedReason;\n /**\n * Correlation id of the session that failed, when we got far enough to read\n * one. The line that *ends* a session is the one most worth correlating.\n */\n sid?: string;\n /**\n * Present only when a refresh was actually attempted, i.e. when `reason` is\n * `refresh_failed`. Absent means we never got as far as talking to the\n * provider, so there is no provider verdict to report.\n */\n cause?: RefreshFailureCause;\n };\n\n/**\n * Refreshes the stored session and persists the result, reporting why on failure.\n *\n * The `cause` is the point of this function: `refreshSessionInStore` collapses\n * \"the provider says this refresh token is dead\" and \"we could not reach the\n * provider\" into the same `null`, and a caller that logs the user out on both\n * ends sessions that were never actually invalid. Only `invalid_grant` is\n * terminal — see {@link classifyRefreshFailure}.\n */\nexport async function tryRefreshSessionInStore(\n store: CookieStore,\n config: OAuthConfig,\n secret: Secret,\n options: CreateSessionOptions = {},\n): Promise<RefreshSessionResult> {\n // readSession also returns sessions with expired access tokens — exactly\n // what refresh is for.\n const read = await tryReadSession(store, secret);\n const current = read.session;\n\n if (!current) {\n return { ok: false, reason: read.reason };\n }\n if (!current.tokens?.refreshToken) {\n return { ok: false, reason: 'no_refresh_token', sid: current.sid };\n }\n\n try {\n const updated = await refreshSession(current, config, options);\n if (!updated) {\n // refreshSession resolves to a session or throws; a null here means the\n // provider answered with something we could not build a session from.\n logSessionEvent(logger, {\n event: SESSION_EVENT.REFRESH_FAILED,\n sid: current.sid,\n cause: 'idp_error',\n accessTokenExpiresAt: current.tokens.accessTokenExpiresAt,\n });\n return { ok: false, reason: 'refresh_failed', cause: 'idp_error', sid: current.sid };\n }\n\n const rotatedRefreshToken = updated.tokens?.refreshToken !== current.tokens.refreshToken;\n await persistSession(store, updated, secret, {\n event: SESSION_EVENT.REFRESHED,\n rotatedRefreshToken,\n });\n\n return { ok: true, session: updated, rotatedRefreshToken };\n } catch (error) {\n const cause = classifyRefreshFailure(error);\n logSessionEvent(logger, {\n event: SESSION_EVENT.REFRESH_FAILED,\n sid: current.sid,\n cause,\n status: getOAuthErrorLogContext(error).status,\n accessTokenExpiresAt: current.tokens.accessTokenExpiresAt,\n error: getOAuthErrorLogContext(error),\n });\n return { ok: false, reason: 'refresh_failed', cause, sid: current.sid };\n }\n}\n\n/**\n * Refreshes the stored session using its refresh token and persists the result.\n * Returns the updated session, or null when there is nothing to refresh or the\n * refresh token is no longer valid.\n *\n * Prefer {@link tryRefreshSessionInStore}: this signature cannot tell a dead\n * refresh token apart from an unreachable provider, and treating the two alike\n * logs out users whose sessions are still valid.\n */\nexport async function refreshSessionInStore(\n store: CookieStore,\n config: OAuthConfig,\n secret: Secret,\n options: CreateSessionOptions = {},\n): Promise<Session | null> {\n const result = await tryRefreshSessionInStore(store, config, secret, options);\n return result.ok ? result.session : null;\n}\n\n/**\n * Reads the raw ID token independently of the session cookies — logout needs\n * the hint even when {@link readSession} returns null.\n */\nexport async function readIdToken(store: CookieStore, secret: Secret): Promise<string | undefined> {\n try {\n const raw = await store.get(ID_TOKEN_COOKIE_NAME);\n return raw ? await decodeIdTokenCookie(raw, secret) : undefined;\n } catch (error) {\n logger.error({ error }, 'Unexpected error reading ID token');\n return undefined;\n }\n}\n\n/** Context for the {@link SESSION_EVENT.CLEARED} event raised by {@link clearSession}. */\nexport interface ClearSessionOptions {\n /** Why the cookies are going away. Omit to clear without raising an event. */\n trigger?: SessionClearedTrigger;\n /** Correlation id of the session being cleared, when the caller has it. */\n sid?: string;\n}\n\n/** Deletes all session cookies. */\nexport async function clearSession(\n store: CookieStore,\n options: ClearSessionOptions = {},\n): Promise<void> {\n await store.delete(SESSION_COOKIE_NAME);\n await store.delete(ACCESS_TOKEN_COOKIE_NAME);\n await store.delete(ID_TOKEN_COOKIE_NAME);\n\n if (options.trigger) {\n logSessionEvent(logger, {\n event: SESSION_EVENT.CLEARED,\n sid: options.sid,\n trigger: options.trigger,\n });\n }\n}\n","// server/oidc-login.ts\n//\n// Framework-agnostic OIDC login initiation. Builds PKCE parameters, stores them\n// in the cookie store, and redirects to the provider's authorization endpoint.\n// Uses the standard Request/Response, a CookieStore, and an optional rate-limit\n// callback, so any server runtime can call it.\n\nimport { defaultOAuthCookieOptions } from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { buildPKCEOptions, discoverAndBuildAuthorizationUrl } from '../oauth';\nimport type { CookieStore } from './cookie-store';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-login' });\n\nexport interface OidcLoginConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store used to persist the PKCE state. */\n cookies: CookieStore;\n\n /**\n * Optional rate-limit gate. When it resolves false, the handler responds 429.\n */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /**\n * Validates and sanitizes the returnTo parameter, returning a safe path or null.\n * @default Only allows relative paths starting with a single `/`.\n */\n validateReturnTo?: (rawReturnTo: string) => string | null;\n}\n\nconst defaultValidateReturnTo = (raw: string): string | null =>\n /^\\/(?!\\/)/.test(raw) ? raw : null;\n\n/**\n * Handles OIDC login initiation: rate-limit, build PKCE, persist it, and redirect\n * to the authorization endpoint.\n */\nexport async function handleOidcLogin(\n request: Request,\n config: OidcLoginConfig,\n): Promise<Response> {\n const { oauthConfig, cookies, rateLimit, validateReturnTo = defaultValidateReturnTo } = config;\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n const url = new URL(request.url);\n const rawReturnTo = url.searchParams.get('returnTo');\n const returnTo = rawReturnTo ? validateReturnTo(rawReturnTo) : null;\n\n const pkce = await buildPKCEOptions(oauthConfig);\n const authorizationUrl = await discoverAndBuildAuthorizationUrl(oauthConfig, pkce);\n\n logger.info('Redirecting to OIDC provider for login');\n\n await cookies.set('oauth_state', pkce.state, defaultOAuthCookieOptions);\n await cookies.set('oauth_code_verifier', pkce.code_verifier, defaultOAuthCookieOptions);\n if (returnTo) {\n await cookies.set('returnTo', returnTo, defaultOAuthCookieOptions);\n } else {\n // Clear any stale returnTo from an earlier, abandoned login so this one's\n // post-login redirect is deterministic rather than inheriting a past path.\n await cookies.delete('returnTo');\n }\n\n return new Response(null, {\n status: 302,\n headers: { Location: authorizationUrl.toString() },\n });\n}\n","// server/oidc-callback.ts\n//\n// Framework-agnostic OIDC callback handler. Orchestrates the OIDC concerns —\n// rate limit, PKCE cookie read, code exchange, session persistence, safe redirect\n// — over the standard Request/Response and a CookieStore, delegating the protocol\n// to the core oauth module and persistence to the server session helpers.\n\nimport { CookieTooLargeError } from '../cookies';\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport {\n buildSessionFromTokens,\n exchangeAuthorizationCode,\n validateReturnUrl,\n} from '../oauth';\nimport { SESSION_EVENT } from '../session-events';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { persistSession } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-callback' });\n\nexport interface OidcCallbackConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Public application URL (used to reconstruct the callback URL and validate redirects). */\n applicationUrl: string;\n\n /** Cookie store holding the PKCE state and receiving the session cookies. */\n cookies: CookieStore;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n\n /**\n * Name of the ID token claim that contains user roles.\n * @default 'roles'\n */\n rolesClaim?: string;\n\n /**\n * Default path to redirect to after login if no returnTo cookie is set.\n * @default '/'\n */\n defaultRedirectPath?: string;\n\n /**\n * Optional validator for the returnTo path. If omitted, only same-origin paths\n * are allowed.\n */\n validateReturnTo?: (path: string) => boolean;\n}\n\n/**\n * Handles the OIDC callback: rate-limit, exchange the code for tokens, persist the\n * session across the cookie store, and redirect to a validated returnTo.\n */\nexport async function handleOidcCallback(\n request: Request,\n config: OidcCallbackConfig,\n): Promise<Response> {\n const {\n oauthConfig,\n applicationUrl,\n cookies,\n rateLimit,\n secret = getSessionSecret(),\n rolesClaim = 'roles',\n defaultRedirectPath = '/',\n validateReturnTo,\n } = config;\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n try {\n // Reconstruct the public callback URL. Behind a TLS-terminating proxy\n // applicationUrl is just the origin (\"/\"), and the token-exchange redirect_uri\n // must match the callback path used at authorize — so keep the request path.\n const currentUrl = new URL(request.url);\n const publicUrl = new URL(applicationUrl);\n publicUrl.pathname = currentUrl.pathname;\n publicUrl.search = currentUrl.search;\n\n const storedState = await cookies.get('oauth_state');\n const storedCodeVerifier = await cookies.get('oauth_code_verifier');\n\n if (!storedState || !storedCodeVerifier) {\n logger.warn('Missing state or code verifier');\n return new Response('Please restart the login process.', { status: 400 });\n }\n\n const tokens = await exchangeAuthorizationCode(\n oauthConfig,\n publicUrl,\n storedCodeVerifier,\n storedState,\n );\n\n // Build the session and persist it (split across session/session_at).\n // persistSession raises session.created — it is the only layer that knows\n // what the session cost in cookie bytes, and a login that comes back without\n // a refresh token (no offline_access) is visible there on day one.\n const session = buildSessionFromTokens(tokens, rolesClaim);\n await persistSession(cookies, session, secret, { event: SESSION_EVENT.CREATED });\n\n // Clean up PKCE & returnTo cookies (read returnTo first).\n const returnTo = await cookies.get('returnTo');\n await cookies.delete('oauth_state');\n await cookies.delete('oauth_code_verifier');\n await cookies.delete('returnTo');\n\n const redirectUrl = validateReturnUrl(\n returnTo ?? null,\n applicationUrl,\n defaultRedirectPath,\n validateReturnTo,\n );\n redirectUrl.searchParams.set('login', 'success');\n\n logger.info(\n { sid: session.sid, redirectUrl: redirectUrl.toString() },\n 'Login successful, redirecting',\n );\n\n return new Response(null, {\n status: 302,\n headers: { Location: redirectUrl.toString() },\n });\n } catch (error) {\n // The session was too large for a cookie — surface this distinctly instead of\n // hiding it in a generic 500, so it's diagnosable in production.\n if (error instanceof CookieTooLargeError) {\n logger.error(\n { error, cookieName: error.cookieName, size: error.size },\n 'Session cookie too large to store',\n );\n return new Response('The session is too large to store. Please contact support.', {\n status: 431,\n });\n }\n\n logger.error({ error }, 'OIDC callback error');\n return new Response('An unexpected error occurred. Please restart the login process.', {\n status: 500,\n });\n }\n}\n","// server/oidc-logout.ts\n//\n// Framework-agnostic RP-initiated logout. Clearing only the local session cookie\n// leaves the provider's session alive, so the next /authorize silently\n// re-authenticates — this handler ends both.\n\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { buildOidcLogoutUrl } from '../oauth';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { clearSession, readIdToken, tryReadSession } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:oidc-logout' });\n\n/**\n * Whether the request came from the application itself. POST alone is no defence:\n * a third-party page can auto-submit a form at this endpoint, and the response\n * clears the cookies whether or not the browser attached them.\n *\n * `Sec-Fetch-Site` is authoritative where the browser sends it. Otherwise fall back\n * to `Origin`. A request with neither is not a browser form post, so it is allowed.\n */\nfunction isSameOrigin(request: Request, applicationUrl?: string): boolean {\n const site = request.headers.get('sec-fetch-site');\n if (site) {\n return site === 'same-origin' || site === 'none';\n }\n\n const origin = request.headers.get('origin');\n if (!origin) return true;\n\n try {\n return new URL(origin).origin === new URL(applicationUrl ?? request.url).origin;\n } catch {\n return false;\n }\n}\n\nexport interface OidcLogoutConfig {\n /** OAuth/OIDC configuration. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store holding the session cookies to read and then clear. */\n cookies: CookieStore;\n\n /** Post-logout redirect URI. Must be registered with the provider. */\n postLogoutRedirectUri: string;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Passed through as `state`; verifying it on the way back is the caller's job. */\n state?: string;\n\n /** `logout_hint`, when the provider documents one. */\n logoutHint?: string;\n\n /** Whether to send `client_id`. Default true — see `OidcLogoutOptions`. */\n includeClientId?: boolean;\n\n /**\n * Request methods that may trigger a logout. Defaults to POST only: on GET, any\n * third-party page can force a logout by navigating the browser.\n * @default ['POST']\n */\n allowedMethods?: string[];\n\n /**\n * Public application URL, used to check the `Origin` header behind a proxy where\n * the request URL carries the internal origin. Defaults to the request's own\n * origin.\n */\n applicationUrl?: string;\n\n /**\n * Where to send the user when logout is local-only (no end_session_endpoint).\n * @default postLogoutRedirectUri\n */\n fallbackRedirectUri?: string;\n}\n\n/**\n * Clears the session cookies and redirects to the provider's end-session endpoint\n * with `id_token_hint`. Local cookies are cleared even when the provider hop is\n * unavailable, so the user is always logged out of this application.\n */\nexport async function handleOidcLogout(\n request: Request,\n config: OidcLogoutConfig,\n): Promise<Response> {\n const {\n oauthConfig,\n cookies,\n postLogoutRedirectUri,\n secret = getSessionSecret(),\n rateLimit,\n state,\n logoutHint,\n includeClientId,\n fallbackRedirectUri = postLogoutRedirectUri,\n allowedMethods = ['POST'],\n applicationUrl,\n } = config;\n\n if (!allowedMethods.includes(request.method)) {\n return new Response(null, {\n status: 405,\n headers: { Allow: allowedMethods.join(', ') },\n });\n }\n\n if (!isSameOrigin(request, applicationUrl)) {\n logger.warn(\n { origin: request.headers.get('origin'), site: request.headers.get('sec-fetch-site') },\n 'Cross-origin logout request rejected',\n );\n return new Response('Forbidden', { status: 403 });\n }\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Rate limit exceeded');\n return new Response('Too many requests', { status: 429 });\n }\n\n // Read before clearing. Deliberately not readSession: that returns null once the\n // access token has expired, which is exactly when the hint still matters.\n const idTokenHint = await readIdToken(cookies, secret);\n\n // Best-effort correlation id, so the logout line joins the rest of this\n // session's events. A session too broken to read still logs out fine.\n const sid = (await tryReadSession(cookies, secret)).session?.sid;\n\n try {\n await clearSession(cookies, { trigger: 'logout', sid });\n } catch (error) {\n // Never redirect to the provider on a half-cleared local session — the user\n // would come back looking logged in.\n logger.error({ error }, 'Failed to clear session cookies');\n return new Response('Logout failed', { status: 500 });\n }\n\n const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {\n postLogoutRedirectUri,\n idTokenHint,\n state,\n logoutHint,\n includeClientId,\n });\n\n if (!logoutUrl) {\n // Either the provider advertises no end_session_endpoint or discovery failed;\n // buildOidcLogoutUrl logs which. Local logout stands either way.\n logger.info(\n { issuer: oauthConfig.issuer },\n 'No logout URL — local session cleared, provider session may persist',\n );\n return new Response(null, { status: 302, headers: { Location: fallbackRedirectUri } });\n }\n\n logger.info({ hasIdTokenHint: !!idTokenHint }, 'Redirecting to provider for logout');\n\n return new Response(null, { status: 302, headers: { Location: logoutUrl.toString() } });\n}\n","// server/heartbeat-handler.ts\n//\n// Framework-agnostic heartbeat endpoint. Pairs with the client-side heartbeat:\n// when the user is active the client POSTs here, and this handler exchanges the\n// current refresh token for a fresh access token. Glue only — rate limit, load\n// session, refresh, respond — over the standard Request/Response and a CookieStore.\n\nimport { createLogger } from '../logger';\nimport type { OAuthConfig } from '../oauth';\nimport { SESSION_EVENT, logSessionEvent, type SessionRejectedReason } from '../session-events';\nimport { getSessionSecret } from '../utils';\nimport type { CookieStore } from './cookie-store';\nimport { tryRefreshSessionInStore } from './session';\n\nconst logger = createLogger({ namespace: 'fides-auth:server:heartbeat' });\n\nexport interface HeartbeatHandlerConfig {\n /** OAuth/OIDC configuration used to refresh the access token. */\n oauthConfig: OAuthConfig;\n\n /** Cookie store holding the session. */\n cookies: CookieStore;\n\n /** Optional rate-limit gate. When it resolves false, the handler responds 429. */\n rateLimit?: () => boolean | Promise<boolean>;\n\n /** Session encryption secret. Defaults to {@link getSessionSecret}. */\n secret?: string | Uint8Array;\n}\n\n/**\n * Handles a heartbeat request — refresh the active session if there is one.\n *\n * Returns 200 with `{ accessTokenExpiresAt }` on success, 401 with `{ reason }`\n * when the session is genuinely over, 503 when the provider could not be\n * reached, 405 for non-POST methods, and 429 when rate-limited.\n *\n * The 401/503 split matters: a 401 tells the client to log the user out, so it\n * must be reserved for the cases where the session really is finished. A network\n * blip or a provider 5xx says nothing about the session's validity — answering\n * 401 there logs out users whose sessions were fine, which is precisely the\n * \"logged out every few minutes\" symptom. The client already retries non-401\n * failures with backoff.\n */\nexport async function handleHeartbeat(\n request: Request,\n config: HeartbeatHandlerConfig,\n): Promise<Response> {\n const { oauthConfig, cookies, rateLimit, secret = getSessionSecret() } = config;\n\n if (request.method !== 'POST') {\n return new Response(null, { status: 405, headers: { Allow: 'POST' } });\n }\n\n if (rateLimit && !(await rateLimit())) {\n logger.warn('Heartbeat rate limit exceeded');\n return new Response(null, { status: 429 });\n }\n\n // An expired access token is what the heartbeat recovers from, not 401 on.\n // tryRefreshSessionInStore reads the session, refreshes it, and reports which\n // of the two it failed at — so this handler never has to re-read cookies to\n // work out why.\n const result = await tryRefreshSessionInStore(cookies, oauthConfig, secret);\n\n if (result.ok) {\n logger.debug({ sid: result.session.sid }, 'Heartbeat refresh succeeded');\n return Response.json(\n { accessTokenExpiresAt: result.session.tokens?.accessTokenExpiresAt ?? null },\n {\n // Auth/session endpoint — must never be cached by the browser or any\n // intermediary, or a stale 200 could fool the client into thinking a refresh\n // succeeded without actually hitting the server.\n headers: { 'Cache-Control': 'private, no-store' },\n },\n );\n }\n\n // The provider was unreachable or broken. The session is probably still fine,\n // so keep it and let the client retry rather than ending it.\n if (result.cause === 'transport' || result.cause === 'idp_error') {\n logger.warn(\n { sid: result.sid, cause: result.cause },\n 'Heartbeat refresh could not be completed — keeping the session',\n );\n return new Response(null, { status: 503, headers: { 'Cache-Control': 'private, no-store' } });\n }\n\n // Everything left is a session that genuinely cannot continue.\n logSessionEvent(logger, {\n event: SESSION_EVENT.REJECTED,\n sid: result.sid,\n reason: result.reason,\n source: 'heartbeat',\n });\n\n return unauthorized(result.reason);\n}\n\n/**\n * 401 carrying the reason in the body.\n *\n * The browser is where the logout becomes visible, so letting the client name\n * the reason lets a consumer beacon it back with the same vocabulary the server\n * used. The values are session state, not secrets — and clients that ignore the\n * body are unaffected.\n */\nfunction unauthorized(reason: SessionRejectedReason): Response {\n return Response.json(\n { reason },\n { status: 401, headers: { 'Cache-Control': 'private, no-store' } },\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidc-callback.d.ts","sourceRoot":"","sources":["../../src/server/oidc-callback.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;
|
|
1
|
+
{"version":3,"file":"oidc-callback.d.ts","sourceRoot":"","sources":["../../src/server/oidc-callback.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAQ5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAKlD,MAAM,WAAW,kBAAkB;IACjC,gCAAgC;IAChC,WAAW,EAAE,WAAW,CAAC;IAEzB,4FAA4F;IAC5F,cAAc,EAAE,MAAM,CAAC;IAEvB,6EAA6E;IAC7E,OAAO,EAAE,WAAW,CAAC;IAErB,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE7C,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAE7B;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAE7B;;;OAGG;IACH,gBAAgB,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,OAAO,CAAC;CAC9C;AAED;;;GAGG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,kBAAkB,GACzB,OAAO,CAAC,QAAQ,CAAC,CAyFnB"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"oidc-logout.d.ts","sourceRoot":"","sources":["../../src/server/oidc-logout.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AA6BlD,MAAM,WAAW,gBAAgB;IAC/B,gCAAgC;IAChC,WAAW,EAAE,WAAW,CAAC;IAEzB,uEAAuE;IACvE,OAAO,EAAE,WAAW,CAAC;IAErB,sEAAsE;IACtE,qBAAqB,EAAE,MAAM,CAAC;IAE9B,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAE7B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE7C,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,2EAA2E;IAC3E,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,QAAQ,CAAC,
|
|
1
|
+
{"version":3,"file":"oidc-logout.d.ts","sourceRoot":"","sources":["../../src/server/oidc-logout.ts"],"names":[],"mappings":"AAOA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AA6BlD,MAAM,WAAW,gBAAgB;IAC/B,gCAAgC;IAChC,WAAW,EAAE,WAAW,CAAC;IAEzB,uEAAuE;IACvE,OAAO,EAAE,WAAW,CAAC;IAErB,sEAAsE;IACtE,qBAAqB,EAAE,MAAM,CAAC;IAE9B,uEAAuE;IACvE,MAAM,CAAC,EAAE,MAAM,GAAG,UAAU,CAAC;IAE7B,kFAAkF;IAClF,SAAS,CAAC,EAAE,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;IAE7C,mFAAmF;IACnF,KAAK,CAAC,EAAE,MAAM,CAAC;IAEf,sDAAsD;IACtD,UAAU,CAAC,EAAE,MAAM,CAAC;IAEpB,2EAA2E;IAC3E,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,EAAE,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB;;;OAGG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,CACpC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,gBAAgB,GACvB,OAAO,CAAC,QAAQ,CAAC,CAyEnB"}
|
package/dist/server/session.d.ts
CHANGED
|
@@ -1,33 +1,104 @@
|
|
|
1
1
|
import { OAuthConfig } from '../oauth';
|
|
2
|
+
import { SESSION_EVENT, RefreshFailureCause, SessionClearedTrigger, SessionRejectedReason } from '../session-events';
|
|
2
3
|
import { CreateSessionOptions, Session } from '../types';
|
|
3
4
|
import { CookieStore } from './cookie-store';
|
|
4
5
|
type Secret = string | Uint8Array;
|
|
6
|
+
/** How {@link persistSession} should report what it just wrote. */
|
|
7
|
+
export interface PersistSessionOptions {
|
|
8
|
+
/**
|
|
9
|
+
* Which lifecycle event this write represents. Omit to write silently — for
|
|
10
|
+
* callers that emit their own event, or that persist for reasons the session
|
|
11
|
+
* vocabulary doesn't cover.
|
|
12
|
+
*/
|
|
13
|
+
event?: typeof SESSION_EVENT.CREATED | typeof SESSION_EVENT.REFRESHED;
|
|
14
|
+
/** Only meaningful for {@link SESSION_EVENT.REFRESHED}: did the provider issue a new refresh token? */
|
|
15
|
+
rotatedRefreshToken?: boolean;
|
|
16
|
+
}
|
|
5
17
|
/**
|
|
6
18
|
* Encrypts a session and writes it across the "session", "session_at" and
|
|
7
19
|
* "session_it" cookies, clearing the split-out cookies the session has no token for.
|
|
8
20
|
*
|
|
9
21
|
* @returns The encrypted JWT stored in the main "session" cookie.
|
|
10
22
|
*/
|
|
11
|
-
export declare function persistSession(store: CookieStore, session: Session, secret: Secret): Promise<string>;
|
|
23
|
+
export declare function persistSession(store: CookieStore, session: Session, secret: Secret, options?: PersistSessionOptions): Promise<string>;
|
|
24
|
+
/** Outcome of reading the session from the cookie store. */
|
|
25
|
+
export type ReadSessionResult = {
|
|
26
|
+
session: Session;
|
|
27
|
+
reason?: undefined;
|
|
28
|
+
} | {
|
|
29
|
+
session: null;
|
|
30
|
+
reason: SessionRejectedReason;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Reads and reassembles the current session, reporting why when there isn't one.
|
|
34
|
+
*
|
|
35
|
+
* The reason comes from the package-wide {@link SessionRejectedReason}
|
|
36
|
+
* vocabulary, so a proxy or status route can log the same word the heartbeat
|
|
37
|
+
* endpoint logs. Does not log on its own — the caller owns the request context
|
|
38
|
+
* and decides whether a missing session is worth a line.
|
|
39
|
+
*/
|
|
40
|
+
export declare function tryReadSession(store: CookieStore, secret: Secret): Promise<ReadSessionResult>;
|
|
12
41
|
/**
|
|
13
42
|
* Reads and reassembles the current session from the cookie store, or null when
|
|
14
|
-
* there is
|
|
15
|
-
*
|
|
43
|
+
* there is none. Decode/validation rules live in `decodeSessionCookies`;
|
|
44
|
+
* the returned session may carry an expired access token.
|
|
45
|
+
*
|
|
46
|
+
* Prefer {@link tryReadSession} when you want to log *why* there is no session.
|
|
16
47
|
*/
|
|
17
48
|
export declare function readSession(store: CookieStore, secret: Secret): Promise<Session | null>;
|
|
49
|
+
/** Outcome of a refresh attempt. */
|
|
50
|
+
export type RefreshSessionResult = {
|
|
51
|
+
ok: true;
|
|
52
|
+
session: Session;
|
|
53
|
+
rotatedRefreshToken: boolean;
|
|
54
|
+
} | {
|
|
55
|
+
ok: false;
|
|
56
|
+
reason: SessionRejectedReason;
|
|
57
|
+
/**
|
|
58
|
+
* Correlation id of the session that failed, when we got far enough to read
|
|
59
|
+
* one. The line that *ends* a session is the one most worth correlating.
|
|
60
|
+
*/
|
|
61
|
+
sid?: string;
|
|
62
|
+
/**
|
|
63
|
+
* Present only when a refresh was actually attempted, i.e. when `reason` is
|
|
64
|
+
* `refresh_failed`. Absent means we never got as far as talking to the
|
|
65
|
+
* provider, so there is no provider verdict to report.
|
|
66
|
+
*/
|
|
67
|
+
cause?: RefreshFailureCause;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Refreshes the stored session and persists the result, reporting why on failure.
|
|
71
|
+
*
|
|
72
|
+
* The `cause` is the point of this function: `refreshSessionInStore` collapses
|
|
73
|
+
* "the provider says this refresh token is dead" and "we could not reach the
|
|
74
|
+
* provider" into the same `null`, and a caller that logs the user out on both
|
|
75
|
+
* ends sessions that were never actually invalid. Only `invalid_grant` is
|
|
76
|
+
* terminal — see {@link classifyRefreshFailure}.
|
|
77
|
+
*/
|
|
78
|
+
export declare function tryRefreshSessionInStore(store: CookieStore, config: OAuthConfig, secret: Secret, options?: CreateSessionOptions): Promise<RefreshSessionResult>;
|
|
18
79
|
/**
|
|
19
80
|
* Refreshes the stored session using its refresh token and persists the result.
|
|
20
81
|
* Returns the updated session, or null when there is nothing to refresh or the
|
|
21
82
|
* refresh token is no longer valid.
|
|
83
|
+
*
|
|
84
|
+
* Prefer {@link tryRefreshSessionInStore}: this signature cannot tell a dead
|
|
85
|
+
* refresh token apart from an unreachable provider, and treating the two alike
|
|
86
|
+
* logs out users whose sessions are still valid.
|
|
22
87
|
*/
|
|
23
88
|
export declare function refreshSessionInStore(store: CookieStore, config: OAuthConfig, secret: Secret, options?: CreateSessionOptions): Promise<Session | null>;
|
|
24
89
|
/**
|
|
25
|
-
* Reads the raw ID token
|
|
26
|
-
* the hint
|
|
27
|
-
* {@link readSession} returns null.
|
|
90
|
+
* Reads the raw ID token independently of the session cookies — logout needs
|
|
91
|
+
* the hint even when {@link readSession} returns null.
|
|
28
92
|
*/
|
|
29
93
|
export declare function readIdToken(store: CookieStore, secret: Secret): Promise<string | undefined>;
|
|
94
|
+
/** Context for the {@link SESSION_EVENT.CLEARED} event raised by {@link clearSession}. */
|
|
95
|
+
export interface ClearSessionOptions {
|
|
96
|
+
/** Why the cookies are going away. Omit to clear without raising an event. */
|
|
97
|
+
trigger?: SessionClearedTrigger;
|
|
98
|
+
/** Correlation id of the session being cleared, when the caller has it. */
|
|
99
|
+
sid?: string;
|
|
100
|
+
}
|
|
30
101
|
/** Deletes all session cookies. */
|
|
31
|
-
export declare function clearSession(store: CookieStore): Promise<void>;
|
|
102
|
+
export declare function clearSession(store: CookieStore, options?: ClearSessionOptions): Promise<void>;
|
|
32
103
|
export {};
|
|
33
104
|
//# sourceMappingURL=session.d.ts.map
|