@eventuras/fides-auth 0.14.1 → 0.15.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 +225 -33
- package/dist/activity-tracker.js +1 -2
- package/dist/cookies.d.ts +6 -0
- package/dist/cookies.d.ts.map +1 -1
- package/dist/cookies.js +8 -3
- package/dist/cookies.js.map +1 -1
- package/dist/heartbeat.js +1 -2
- package/dist/id-token.d.ts +31 -0
- package/dist/id-token.d.ts.map +1 -0
- package/dist/id-token.js +60 -0
- package/dist/id-token.js.map +1 -0
- package/dist/index.js +3 -3
- package/dist/logger.js +1 -2
- package/dist/oauth-browser.d.ts +13 -10
- package/dist/oauth-browser.d.ts.map +1 -1
- package/dist/oauth-browser.js +4 -2
- package/dist/oauth-browser.js.map +1 -1
- package/dist/oauth-config.d.ts +70 -0
- package/dist/oauth-config.d.ts.map +1 -0
- package/dist/oauth-logging.d.ts +4 -3
- package/dist/oauth-logging.d.ts.map +1 -1
- package/dist/oauth-logging.js +5 -5
- package/dist/oauth-logging.js.map +1 -1
- package/dist/oauth.d.ts +47 -45
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +58 -29
- package/dist/oauth.js.map +1 -1
- package/dist/oidc-discovery.d.ts +13 -4
- package/dist/oidc-discovery.d.ts.map +1 -1
- package/dist/oidc-discovery.js +82 -15
- package/dist/oidc-discovery.js.map +1 -1
- package/dist/pkce.d.ts +10 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +23 -0
- package/dist/pkce.js.map +1 -0
- package/dist/providers/vipps/client.d.ts +3 -2
- package/dist/providers/vipps/client.d.ts.map +1 -1
- package/dist/providers/vipps/index.js +10 -10
- package/dist/providers/vipps/index.js.map +1 -1
- package/dist/providers/vipps/types.d.ts +2 -2
- package/dist/providers/vipps/types.d.ts.map +1 -1
- package/dist/rate-limit.js +1 -2
- package/dist/server/authorized-fetch.d.ts +78 -0
- package/dist/server/authorized-fetch.d.ts.map +1 -0
- package/dist/server/heartbeat-handler.d.ts +6 -4
- package/dist/server/heartbeat-handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +744 -94
- package/dist/server/index.js.map +1 -1
- package/dist/server/login-transaction.d.ts +55 -0
- package/dist/server/login-transaction.d.ts.map +1 -0
- package/dist/server/oidc-callback.d.ts +13 -4
- package/dist/server/oidc-callback.d.ts.map +1 -1
- package/dist/server/oidc-login.d.ts +14 -5
- package/dist/server/oidc-login.d.ts.map +1 -1
- package/dist/server/oidc-logout.d.ts +6 -4
- package/dist/server/oidc-logout.d.ts.map +1 -1
- package/dist/server/session-persistence.d.ts +102 -0
- package/dist/server/session-persistence.d.ts.map +1 -0
- package/dist/server/session-store.d.ts +39 -0
- package/dist/server/session-store.d.ts.map +1 -0
- package/dist/server/session.d.ts +35 -28
- package/dist/server/session.d.ts.map +1 -1
- package/dist/session-cookies.d.ts +1 -0
- package/dist/session-cookies.d.ts.map +1 -1
- package/dist/session-cookies.js +4 -3
- package/dist/session-cookies.js.map +1 -1
- package/dist/session-events.d.ts +10 -3
- package/dist/session-events.d.ts.map +1 -1
- package/dist/session-events.js +4 -3
- package/dist/session-events.js.map +1 -1
- package/dist/session-refresh.d.ts +1 -1
- package/dist/session-refresh.d.ts.map +1 -1
- package/dist/session-refresh.js +1 -2
- package/dist/session-refresh.js.map +1 -1
- package/dist/session-validation.d.ts.map +1 -1
- package/dist/session-validation.js +9 -6
- package/dist/session-validation.js.map +1 -1
- package/dist/silent-login.d.ts +1 -1
- package/dist/silent-login.d.ts.map +1 -1
- package/dist/silent-login.js +3 -3
- package/dist/silent-login.js.map +1 -1
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +0 -14
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +2 -24
- package/dist/utils.js.map +1 -1
- package/docs/discovery-cache.md +7 -3
- package/docs/rp-initiated-logout.md +6 -3
- package/docs/server-sessions.md +154 -0
- package/docs/session-events.md +17 -16
- package/package.json +14 -6
package/dist/server/index.js
CHANGED
|
@@ -1,22 +1,258 @@
|
|
|
1
|
-
import { ACCESS_TOKEN_COOKIE_NAME, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "../cookies.js";
|
|
1
|
+
import { ACCESS_TOKEN_COOKIE_NAME, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, SESSION_REF_COOKIE_NAME, assertCookieWithinLimit, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "../cookies.js";
|
|
2
2
|
import { createLogger } from "../logger.js";
|
|
3
3
|
import { classifyRefreshFailure, getOAuthErrorLogContext } from "../oauth-logging.js";
|
|
4
4
|
import { SESSION_EVENT, logSessionEvent } from "../session-events.js";
|
|
5
5
|
import { buildOidcLogoutUrl, buildPKCEOptions, buildSessionFromTokens, discoverAndBuildAuthorizationUrl, exchangeAuthorizationCode, validateReturnUrl } from "../oauth.js";
|
|
6
6
|
import { refreshSession } from "../session-refresh.js";
|
|
7
|
-
import {
|
|
7
|
+
import { createEncryptedJWT, decryptJWT, generateToken, hexToUint8Array, sha256 } from "../utils.js";
|
|
8
|
+
import { isSession } from "../session-validation.js";
|
|
8
9
|
import { decodeIdTokenCookie, encodeSessionCookies, tryDecodeSessionCookies } from "../session-cookies.js";
|
|
9
|
-
|
|
10
|
-
|
|
10
|
+
// src/server/session-store.ts
|
|
11
|
+
/**
|
|
12
|
+
* In-memory store for tests and single-instance development. Sessions do not
|
|
13
|
+
* survive a restart, and expired records are dropped on read.
|
|
14
|
+
*/
|
|
15
|
+
function createMemorySessionStore() {
|
|
16
|
+
const records = /* @__PURE__ */ new Map();
|
|
17
|
+
const locks = /* @__PURE__ */ new Map();
|
|
18
|
+
return {
|
|
19
|
+
get(key) {
|
|
20
|
+
const record = records.get(key);
|
|
21
|
+
if (!record) return null;
|
|
22
|
+
if (!(Date.parse(record.expiresAt) > Date.now())) {
|
|
23
|
+
records.delete(key);
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return record;
|
|
27
|
+
},
|
|
28
|
+
set(key, record) {
|
|
29
|
+
records.set(key, record);
|
|
30
|
+
},
|
|
31
|
+
delete(key) {
|
|
32
|
+
records.delete(key);
|
|
33
|
+
},
|
|
34
|
+
withLock(key, fn) {
|
|
35
|
+
const run = (locks.get(key) ?? Promise.resolve()).then(fn);
|
|
36
|
+
const settled = run.then(() => void 0, () => void 0);
|
|
37
|
+
locks.set(key, settled);
|
|
38
|
+
settled.then(() => {
|
|
39
|
+
if (locks.get(key) === settled) locks.delete(key);
|
|
40
|
+
});
|
|
41
|
+
return run;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// src/server/session-persistence.ts
|
|
46
|
+
var logger$7 = createLogger({ namespace: "fides-auth:server:session-persistence" });
|
|
47
|
+
/** A missing or malformed session secret: a configuration error, never a bad cookie. */
|
|
48
|
+
var SessionSecretError = class extends TypeError {
|
|
49
|
+
name = "SessionSecretError";
|
|
50
|
+
};
|
|
51
|
+
/** Throws unless `secret` is the 32 bytes A256GCM needs. Never echoes the secret. */
|
|
52
|
+
function assertSessionSecret(secret) {
|
|
53
|
+
if (!secret || secret.length === 0) throw new SessionSecretError("fides-auth: session secret is missing or empty");
|
|
54
|
+
if (!(secret instanceof Uint8Array ? secret.length === 32 : typeof secret === "string" && /^[0-9a-fA-F]{64}$/.test(secret))) throw new SessionSecretError("fides-auth: session secret must be 32 bytes (64 hex characters or a Uint8Array)");
|
|
55
|
+
}
|
|
56
|
+
/** Accepts the pre-0.15 bare secret wherever a persistence is expected. */
|
|
57
|
+
function resolvePersistence(value) {
|
|
58
|
+
return typeof value === "object" && value !== null && !(value instanceof Uint8Array) ? value : cookiePersistence(value);
|
|
59
|
+
}
|
|
60
|
+
/** The configured `persistence`, else the bare `secret`. */
|
|
61
|
+
function persistenceFrom(config) {
|
|
62
|
+
if (config.secret === void 0) return config.persistence;
|
|
63
|
+
return config.persistence ?? config.secret;
|
|
64
|
+
}
|
|
65
|
+
var SPLIT_COOKIE_NAMES = [
|
|
66
|
+
SESSION_COOKIE_NAME,
|
|
67
|
+
ACCESS_TOKEN_COOKIE_NAME,
|
|
68
|
+
ID_TOKEN_COOKIE_NAME
|
|
69
|
+
];
|
|
70
|
+
/** Deletes the `session`, `session_at` and `session_it` cookies. */
|
|
71
|
+
async function clearSessionCookies(cookies) {
|
|
72
|
+
for (const name of SPLIT_COOKIE_NAMES) await cookies.delete(name);
|
|
73
|
+
}
|
|
11
74
|
/** Applies the browser size guard to one cookie value; logs as it nears the limit. */
|
|
12
75
|
function checkSize(name, value) {
|
|
13
76
|
const size = assertCookieWithinLimit(name, value);
|
|
14
|
-
if (size >= 3500) logger$
|
|
77
|
+
if (size >= 3500) logger$7.info({
|
|
15
78
|
cookieName: name,
|
|
16
79
|
size
|
|
17
80
|
}, "Cookie approaching browser size limit");
|
|
18
81
|
return size;
|
|
19
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The default: the session encrypted into the `session`, `session_at` and
|
|
85
|
+
* `session_it` cookies.
|
|
86
|
+
*/
|
|
87
|
+
function cookiePersistence(secret) {
|
|
88
|
+
return {
|
|
89
|
+
mode: "cookie",
|
|
90
|
+
async write(cookies, session) {
|
|
91
|
+
assertSessionSecret(secret);
|
|
92
|
+
const encoded = await encodeSessionCookies(session, secret);
|
|
93
|
+
const values = [
|
|
94
|
+
[SESSION_COOKIE_NAME, encoded.session],
|
|
95
|
+
[ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],
|
|
96
|
+
[ID_TOKEN_COOKIE_NAME, encoded.idToken]
|
|
97
|
+
];
|
|
98
|
+
let cookieBytes = 0;
|
|
99
|
+
for (const [name, value] of values) if (value) cookieBytes += checkSize(name, value);
|
|
100
|
+
for (const [name, value] of values) if (value) await cookies.set(name, value, defaultSessionCookieOptions);
|
|
101
|
+
else await cookies.delete(name);
|
|
102
|
+
return {
|
|
103
|
+
cookieValue: encoded.session,
|
|
104
|
+
cookieBytes
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
async read(cookies) {
|
|
108
|
+
assertSessionSecret(secret);
|
|
109
|
+
return tryDecodeSessionCookies({
|
|
110
|
+
session: await cookies.get("session") ?? null,
|
|
111
|
+
accessToken: await cookies.get("session_at") ?? null,
|
|
112
|
+
idToken: await cookies.get("session_it") ?? null
|
|
113
|
+
}, secret);
|
|
114
|
+
},
|
|
115
|
+
async readIdToken(cookies) {
|
|
116
|
+
assertSessionSecret(secret);
|
|
117
|
+
const raw = await cookies.get(ID_TOKEN_COOKIE_NAME);
|
|
118
|
+
return raw ? decodeIdTokenCookie(raw, secret) : void 0;
|
|
119
|
+
},
|
|
120
|
+
async clear(cookies) {
|
|
121
|
+
assertSessionSecret(secret);
|
|
122
|
+
await clearSessionCookies(cookies);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The session in a {@link SessionStore}; the browser holds only a random
|
|
128
|
+
* 256-bit handle. The store is keyed by `SHA-256(handle)`, so neither a store
|
|
129
|
+
* dump nor a logged key can be replayed as a cookie, and the record is
|
|
130
|
+
* encrypted under `secret`, so a dump alone yields no tokens.
|
|
131
|
+
*/
|
|
132
|
+
function serverPersistence(options) {
|
|
133
|
+
const { store, secret, cookieName = SESSION_REF_COOKIE_NAME, sessionDurationDays = 30 } = options;
|
|
134
|
+
const maxAge = sessionDurationDays * 24 * 60 * 60;
|
|
135
|
+
const inflight = /* @__PURE__ */ new Map();
|
|
136
|
+
/** The handle the request carries and the store key it maps to. */
|
|
137
|
+
async function locate(cookies) {
|
|
138
|
+
const handle = await cookies.get(cookieName);
|
|
139
|
+
return handle ? {
|
|
140
|
+
handle,
|
|
141
|
+
key: await sha256(handle)
|
|
142
|
+
} : null;
|
|
143
|
+
}
|
|
144
|
+
/** Drops split cookies left behind by cookie persistence, when there are any. */
|
|
145
|
+
async function clearSplitCookies(cookies) {
|
|
146
|
+
for (const name of SPLIT_COOKIE_NAMES) if (await cookies.get(name)) await cookies.delete(name);
|
|
147
|
+
}
|
|
148
|
+
async function readRecord(key) {
|
|
149
|
+
const record = await store.get(key);
|
|
150
|
+
if (!record) return {
|
|
151
|
+
session: null,
|
|
152
|
+
reason: "session_not_found"
|
|
153
|
+
};
|
|
154
|
+
if (!(Date.parse(record.expiresAt) > Date.now())) {
|
|
155
|
+
await store.delete(key);
|
|
156
|
+
return {
|
|
157
|
+
session: null,
|
|
158
|
+
reason: "session_not_found"
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
let payload;
|
|
162
|
+
try {
|
|
163
|
+
payload = await decryptJWT(record.data, secret);
|
|
164
|
+
} catch (error) {
|
|
165
|
+
logger$7.warn({
|
|
166
|
+
error,
|
|
167
|
+
sid: record.sid
|
|
168
|
+
}, "Stored session would not decrypt");
|
|
169
|
+
return {
|
|
170
|
+
session: null,
|
|
171
|
+
reason: "unreadable_session"
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
if (!isSession(payload)) {
|
|
175
|
+
logger$7.warn({ sid: record.sid }, "Stored session has the wrong shape");
|
|
176
|
+
return {
|
|
177
|
+
session: null,
|
|
178
|
+
reason: "unreadable_session"
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return { session: payload };
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
mode: "server",
|
|
185
|
+
async write(cookies, session, { continuation = false } = {}) {
|
|
186
|
+
assertSessionSecret(secret);
|
|
187
|
+
if (!isSession(session)) throw new TypeError("A session needs a tokens or user object");
|
|
188
|
+
const current = await locate(cookies);
|
|
189
|
+
await clearSplitCookies(cookies);
|
|
190
|
+
const data = await createEncryptedJWT(session, secret);
|
|
191
|
+
if (continuation && current) {
|
|
192
|
+
const existing = await store.get(current.key);
|
|
193
|
+
if (existing) await store.set(current.key, {
|
|
194
|
+
data,
|
|
195
|
+
expiresAt: existing.expiresAt,
|
|
196
|
+
sid: session.sid
|
|
197
|
+
});
|
|
198
|
+
else logger$7.info({ sid: session.sid }, "Session ended during refresh; not persisted");
|
|
199
|
+
return { cookieValue: current.handle };
|
|
200
|
+
}
|
|
201
|
+
if (current) await store.delete(current.key);
|
|
202
|
+
const handle = generateToken(32);
|
|
203
|
+
const key = await sha256(handle);
|
|
204
|
+
const expiresAt = new Date(Date.now() + maxAge * 1e3).toISOString();
|
|
205
|
+
await store.set(key, {
|
|
206
|
+
data,
|
|
207
|
+
expiresAt,
|
|
208
|
+
sid: session.sid
|
|
209
|
+
});
|
|
210
|
+
await cookies.set(cookieName, handle, {
|
|
211
|
+
...defaultSessionCookieOptions,
|
|
212
|
+
maxAge
|
|
213
|
+
});
|
|
214
|
+
return { cookieValue: handle };
|
|
215
|
+
},
|
|
216
|
+
async read(cookies) {
|
|
217
|
+
assertSessionSecret(secret);
|
|
218
|
+
const current = await locate(cookies);
|
|
219
|
+
if (!current) return {
|
|
220
|
+
session: null,
|
|
221
|
+
reason: await cookies.get("session") ? "stale_legacy_session" : "no_session_cookie"
|
|
222
|
+
};
|
|
223
|
+
return readRecord(current.key);
|
|
224
|
+
},
|
|
225
|
+
async readIdToken(cookies) {
|
|
226
|
+
assertSessionSecret(secret);
|
|
227
|
+
const current = await locate(cookies);
|
|
228
|
+
if (!current) return void 0;
|
|
229
|
+
return (await readRecord(current.key)).session?.tokens?.idToken;
|
|
230
|
+
},
|
|
231
|
+
async clear(cookies) {
|
|
232
|
+
assertSessionSecret(secret);
|
|
233
|
+
const current = await locate(cookies);
|
|
234
|
+
if (current) {
|
|
235
|
+
await store.delete(current.key);
|
|
236
|
+
await cookies.delete(cookieName);
|
|
237
|
+
}
|
|
238
|
+
await clearSplitCookies(cookies);
|
|
239
|
+
},
|
|
240
|
+
async exclusive(cookies, fn) {
|
|
241
|
+
assertSessionSecret(secret);
|
|
242
|
+
const current = await locate(cookies);
|
|
243
|
+
if (!current) return fn();
|
|
244
|
+
const running = inflight.get(current.key);
|
|
245
|
+
if (running) return running;
|
|
246
|
+
const run = (store.withLock ? store.withLock(current.key, fn) : fn()).finally(() => {
|
|
247
|
+
inflight.delete(current.key);
|
|
248
|
+
});
|
|
249
|
+
inflight.set(current.key, run);
|
|
250
|
+
return run;
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
// src/server/session.ts
|
|
255
|
+
var logger$6 = createLogger({ namespace: "fides-auth:server:session" });
|
|
20
256
|
/** Seconds until the access token expires, for the `expiresIn` log field. */
|
|
21
257
|
function expiresInSeconds(session) {
|
|
22
258
|
const at = session.tokens?.accessTokenExpiresAt;
|
|
@@ -25,38 +261,36 @@ function expiresInSeconds(session) {
|
|
|
25
261
|
return Number.isFinite(ms) ? Math.round((ms - Date.now()) / 1e3) : void 0;
|
|
26
262
|
}
|
|
27
263
|
/**
|
|
28
|
-
*
|
|
29
|
-
* "session_it" cookies, clearing the split-out cookies the session has no token for.
|
|
264
|
+
* Persists a session and raises its lifecycle event.
|
|
30
265
|
*
|
|
31
|
-
*
|
|
266
|
+
* With a secret, or {@link cookiePersistence}, the session is encrypted across
|
|
267
|
+
* the "session", "session_at" and "session_it" cookies. With
|
|
268
|
+
* {@link serverPersistence} it goes to the store and the browser gets a handle.
|
|
269
|
+
*
|
|
270
|
+
* @returns The value written to the main session cookie: the encrypted session,
|
|
271
|
+
* or the handle. Either is a bearer secret.
|
|
32
272
|
*/
|
|
33
|
-
async function persistSession(store, session,
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
[ACCESS_TOKEN_COOKIE_NAME, encoded.accessToken],
|
|
38
|
-
[ID_TOKEN_COOKIE_NAME, encoded.idToken]
|
|
39
|
-
];
|
|
40
|
-
let cookieBytes = 0;
|
|
41
|
-
for (const [name, value] of values) if (value) cookieBytes += checkSize(name, value);
|
|
42
|
-
for (const [name, value] of values) if (value) await store.set(name, value, defaultSessionCookieOptions);
|
|
43
|
-
else await store.delete(name);
|
|
44
|
-
if (options.event === SESSION_EVENT.CREATED) logSessionEvent(logger$4, {
|
|
273
|
+
async function persistSession(store, session, persistence, options = {}) {
|
|
274
|
+
const target = resolvePersistence(persistence);
|
|
275
|
+
const written = await target.write(store, session, { continuation: options.event === SESSION_EVENT.REFRESHED });
|
|
276
|
+
if (options.event === SESSION_EVENT.CREATED) logSessionEvent(logger$6, {
|
|
45
277
|
event: SESSION_EVENT.CREATED,
|
|
46
278
|
sid: session.sid,
|
|
47
279
|
hasRefreshToken: !!session.tokens?.refreshToken,
|
|
48
280
|
scopes: session.scopes,
|
|
49
281
|
expiresIn: expiresInSeconds(session),
|
|
50
|
-
cookieBytes
|
|
282
|
+
cookieBytes: written.cookieBytes,
|
|
283
|
+
persistence: target.mode
|
|
51
284
|
});
|
|
52
|
-
else if (options.event === SESSION_EVENT.REFRESHED) logSessionEvent(logger$
|
|
285
|
+
else if (options.event === SESSION_EVENT.REFRESHED) logSessionEvent(logger$6, {
|
|
53
286
|
event: SESSION_EVENT.REFRESHED,
|
|
54
287
|
sid: session.sid,
|
|
55
288
|
expiresIn: expiresInSeconds(session),
|
|
56
289
|
rotatedRefreshToken: options.rotatedRefreshToken ?? false,
|
|
57
|
-
cookieBytes
|
|
290
|
+
cookieBytes: written.cookieBytes,
|
|
291
|
+
persistence: target.mode
|
|
58
292
|
});
|
|
59
|
-
return
|
|
293
|
+
return written.cookieValue;
|
|
60
294
|
}
|
|
61
295
|
/**
|
|
62
296
|
* Reads and reassembles the current session, reporting why when there isn't one.
|
|
@@ -66,16 +300,13 @@ async function persistSession(store, session, secret, options = {}) {
|
|
|
66
300
|
* endpoint logs. Does not log on its own — the caller owns the request context
|
|
67
301
|
* and decides whether a missing session is worth a line.
|
|
68
302
|
*/
|
|
69
|
-
async function tryReadSession(store,
|
|
303
|
+
async function tryReadSession(store, persistence) {
|
|
70
304
|
try {
|
|
71
|
-
return await
|
|
72
|
-
session: await store.get("session") ?? null,
|
|
73
|
-
accessToken: await store.get("session_at") ?? null,
|
|
74
|
-
idToken: await store.get("session_it") ?? null
|
|
75
|
-
}, secret);
|
|
305
|
+
return await resolvePersistence(persistence).read(store);
|
|
76
306
|
} catch (error) {
|
|
77
|
-
if (error instanceof
|
|
78
|
-
|
|
307
|
+
if (error instanceof SessionSecretError) throw error;
|
|
308
|
+
if (error instanceof Error && error.message.includes("worker")) logger$6.error({ error }, "Worker thread error reading session");
|
|
309
|
+
else logger$6.error({ error }, "Unexpected error reading session");
|
|
79
310
|
return {
|
|
80
311
|
session: null,
|
|
81
312
|
reason: "unreadable_session"
|
|
@@ -83,14 +314,13 @@ async function tryReadSession(store, secret) {
|
|
|
83
314
|
}
|
|
84
315
|
}
|
|
85
316
|
/**
|
|
86
|
-
* Reads and reassembles the current session
|
|
87
|
-
*
|
|
88
|
-
* the returned session may carry an expired access token.
|
|
317
|
+
* Reads and reassembles the current session, or null when there is none. The
|
|
318
|
+
* returned session may carry an expired access token.
|
|
89
319
|
*
|
|
90
320
|
* Prefer {@link tryReadSession} when you want to log *why* there is no session.
|
|
91
321
|
*/
|
|
92
|
-
async function readSession(store,
|
|
93
|
-
return (await tryReadSession(store,
|
|
322
|
+
async function readSession(store, persistence) {
|
|
323
|
+
return (await tryReadSession(store, persistence)).session;
|
|
94
324
|
}
|
|
95
325
|
/**
|
|
96
326
|
* Refreshes the stored session and persists the result, reporting why on failure.
|
|
@@ -100,14 +330,50 @@ async function readSession(store, secret) {
|
|
|
100
330
|
* provider" into the same `null`, and a caller that logs the user out on both
|
|
101
331
|
* ends sessions that were never actually invalid. Only `invalid_grant` is
|
|
102
332
|
* terminal — see {@link classifyRefreshFailure}.
|
|
333
|
+
*
|
|
334
|
+
* Under server persistence, refreshes of one session are serialized: concurrent
|
|
335
|
+
* callers get the first refresh's result rather than each spending the refresh
|
|
336
|
+
* token, which with rotation would leave all but one holding a dead one.
|
|
103
337
|
*/
|
|
104
|
-
async function tryRefreshSessionInStore(store, config,
|
|
105
|
-
const
|
|
338
|
+
async function tryRefreshSessionInStore(store, config, persistence, options = {}) {
|
|
339
|
+
const target = resolvePersistence(persistence);
|
|
340
|
+
const read = await tryReadSession(store, target);
|
|
106
341
|
const current = read.session;
|
|
107
342
|
if (!current) return {
|
|
108
343
|
ok: false,
|
|
109
344
|
reason: read.reason
|
|
110
345
|
};
|
|
346
|
+
if (!current.tokens?.refreshToken) return {
|
|
347
|
+
ok: false,
|
|
348
|
+
reason: "no_refresh_token",
|
|
349
|
+
sid: current.sid
|
|
350
|
+
};
|
|
351
|
+
if (!target.exclusive) return refreshNow(store, target, config, current, options);
|
|
352
|
+
return target.exclusive(store, async () => {
|
|
353
|
+
const latest = await tryReadSession(store, target);
|
|
354
|
+
if (!latest.session) return {
|
|
355
|
+
ok: false,
|
|
356
|
+
reason: latest.reason,
|
|
357
|
+
sid: current.sid
|
|
358
|
+
};
|
|
359
|
+
if (refreshedSince(current, latest.session)) return alreadyRefreshed(current, latest.session);
|
|
360
|
+
return refreshNow(store, target, config, latest.session, options);
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
/** Whether `after` carries tokens `before` did not — someone refreshed in between. */
|
|
364
|
+
function refreshedSince(before, after) {
|
|
365
|
+
return after.tokens?.accessToken !== before.tokens?.accessToken || after.tokens?.refreshToken !== before.tokens?.refreshToken;
|
|
366
|
+
}
|
|
367
|
+
function alreadyRefreshed(before, after) {
|
|
368
|
+
logger$6.debug({ sid: after.sid }, "Session already refreshed by a concurrent request");
|
|
369
|
+
return {
|
|
370
|
+
ok: true,
|
|
371
|
+
session: after,
|
|
372
|
+
rotatedRefreshToken: after.tokens?.refreshToken !== before.tokens?.refreshToken
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
/** The refresh itself: exchange, persist, classify. */
|
|
376
|
+
async function refreshNow(store, target, config, current, options) {
|
|
111
377
|
if (!current.tokens?.refreshToken) return {
|
|
112
378
|
ok: false,
|
|
113
379
|
reason: "no_refresh_token",
|
|
@@ -116,7 +382,7 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
|
|
|
116
382
|
try {
|
|
117
383
|
const updated = await refreshSession(current, config, options);
|
|
118
384
|
if (!updated) {
|
|
119
|
-
logSessionEvent(logger$
|
|
385
|
+
logSessionEvent(logger$6, {
|
|
120
386
|
event: SESSION_EVENT.REFRESH_FAILED,
|
|
121
387
|
sid: current.sid,
|
|
122
388
|
cause: "idp_error",
|
|
@@ -130,7 +396,7 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
|
|
|
130
396
|
};
|
|
131
397
|
}
|
|
132
398
|
const rotatedRefreshToken = updated.tokens?.refreshToken !== current.tokens.refreshToken;
|
|
133
|
-
await persistSession(store, updated,
|
|
399
|
+
await persistSession(store, updated, target, {
|
|
134
400
|
event: SESSION_EVENT.REFRESHED,
|
|
135
401
|
rotatedRefreshToken
|
|
136
402
|
});
|
|
@@ -141,7 +407,11 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
|
|
|
141
407
|
};
|
|
142
408
|
} catch (error) {
|
|
143
409
|
const cause = classifyRefreshFailure(error);
|
|
144
|
-
|
|
410
|
+
if (cause === "invalid_grant" && target.exclusive) {
|
|
411
|
+
const latest = await tryReadSession(store, target);
|
|
412
|
+
if (latest.session && refreshedSince(current, latest.session)) return alreadyRefreshed(current, latest.session);
|
|
413
|
+
}
|
|
414
|
+
logSessionEvent(logger$6, {
|
|
145
415
|
event: SESSION_EVENT.REFRESH_FAILED,
|
|
146
416
|
sid: current.sid,
|
|
147
417
|
cause,
|
|
@@ -166,94 +436,445 @@ async function tryRefreshSessionInStore(store, config, secret, options = {}) {
|
|
|
166
436
|
* refresh token apart from an unreachable provider, and treating the two alike
|
|
167
437
|
* logs out users whose sessions are still valid.
|
|
168
438
|
*/
|
|
169
|
-
async function refreshSessionInStore(store, config,
|
|
170
|
-
const result = await tryRefreshSessionInStore(store, config,
|
|
439
|
+
async function refreshSessionInStore(store, config, persistence, options = {}) {
|
|
440
|
+
const result = await tryRefreshSessionInStore(store, config, persistence, options);
|
|
171
441
|
return result.ok ? result.session : null;
|
|
172
442
|
}
|
|
173
443
|
/**
|
|
174
|
-
* Reads the raw ID token independently of
|
|
175
|
-
*
|
|
444
|
+
* Reads the raw ID token independently of session validity — logout needs the
|
|
445
|
+
* hint even when {@link readSession} returns null.
|
|
176
446
|
*/
|
|
177
|
-
async function readIdToken(store,
|
|
447
|
+
async function readIdToken(store, persistence) {
|
|
178
448
|
try {
|
|
179
|
-
|
|
180
|
-
return raw ? await decodeIdTokenCookie(raw, secret) : void 0;
|
|
449
|
+
return await resolvePersistence(persistence).readIdToken(store);
|
|
181
450
|
} catch (error) {
|
|
182
|
-
|
|
451
|
+
if (error instanceof SessionSecretError) throw error;
|
|
452
|
+
logger$6.error({ error }, "Unexpected error reading ID token");
|
|
183
453
|
return;
|
|
184
454
|
}
|
|
185
455
|
}
|
|
186
|
-
/**
|
|
456
|
+
/** Ends the session: deletes the cookies and, under server persistence, the record. */
|
|
187
457
|
async function clearSession(store, options = {}) {
|
|
188
|
-
await
|
|
189
|
-
await store
|
|
190
|
-
|
|
191
|
-
if (options.trigger) logSessionEvent(logger$4, {
|
|
458
|
+
if (options.persistence) await resolvePersistence(options.persistence).clear(store);
|
|
459
|
+
else await clearSessionCookies(store);
|
|
460
|
+
if (options.trigger) logSessionEvent(logger$6, {
|
|
192
461
|
event: SESSION_EVENT.CLEARED,
|
|
193
462
|
sid: options.sid,
|
|
194
463
|
trigger: options.trigger
|
|
195
464
|
});
|
|
196
465
|
}
|
|
197
|
-
|
|
198
|
-
|
|
466
|
+
// src/server/login-transaction.ts
|
|
467
|
+
var logger$5 = createLogger({ namespace: "fides-auth:server:login-transaction" });
|
|
468
|
+
var COOKIE_NAME = "oauth_tx_";
|
|
469
|
+
var HOST_PREFIX = "__Host-";
|
|
470
|
+
/** Live logins per browser. Another login evicts the oldest. */
|
|
471
|
+
var MAX_TRANSACTIONS = 3;
|
|
472
|
+
/**
|
|
473
|
+
* A longer returnTo is dropped, not stored in a cookie sent with every request.
|
|
474
|
+
* Measured as serialized: JSON escaping grows a control character sixfold.
|
|
475
|
+
*/
|
|
476
|
+
var MAX_RETURN_TO_BYTES = 1024;
|
|
477
|
+
var DEFAULT_MAX_AGE_MS = 6e5;
|
|
478
|
+
var KEY_INFO = "fides-auth login-tx";
|
|
479
|
+
var CallbackValidationError = class extends Error {
|
|
480
|
+
reason;
|
|
481
|
+
idpError;
|
|
482
|
+
idpErrorDescription;
|
|
483
|
+
constructor(reason, idpError, idpErrorDescription) {
|
|
484
|
+
super(`OIDC callback rejected: ${reason}`);
|
|
485
|
+
this.reason = reason;
|
|
486
|
+
this.idpError = idpError;
|
|
487
|
+
this.idpErrorDescription = idpErrorDescription;
|
|
488
|
+
this.name = "CallbackValidationError";
|
|
489
|
+
}
|
|
490
|
+
};
|
|
491
|
+
/** Starts a login transaction from the PKCE parameters sent to the authorize endpoint. */
|
|
492
|
+
function createTransaction(pkce, { returnTo } = {}) {
|
|
493
|
+
let kept = returnTo || void 0;
|
|
494
|
+
if (kept && new TextEncoder().encode(JSON.stringify(kept)).length > MAX_RETURN_TO_BYTES) {
|
|
495
|
+
logger$5.warn({ length: kept.length }, "returnTo too long, dropped");
|
|
496
|
+
kept = void 0;
|
|
497
|
+
}
|
|
498
|
+
return {
|
|
499
|
+
state: pkce.state,
|
|
500
|
+
codeVerifier: pkce.code_verifier,
|
|
501
|
+
...kept ? { returnTo: kept } : {},
|
|
502
|
+
createdAt: Date.now()
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* Seals the transaction into a random free `__Host-oauth_tx_<n>` slot, or the
|
|
507
|
+
* oldest login's. Throws `CookieTooLargeError` if it would not fit in a cookie.
|
|
508
|
+
*/
|
|
509
|
+
async function persistTransaction(cookies, transaction, secret, { secure = true } = {}) {
|
|
510
|
+
const key = await transactionKey(secret);
|
|
511
|
+
const slots = await readSlots(cookies, key, secure);
|
|
512
|
+
const free = slots.filter((candidate) => !candidate.transaction);
|
|
513
|
+
const slot = free[Math.floor(Math.random() * free.length)] ?? slots.reduce((oldest, candidate) => (candidate.transaction?.createdAt ?? 0) < (oldest.transaction?.createdAt ?? 0) ? candidate : oldest);
|
|
514
|
+
const value = await createEncryptedJWT(transaction, key);
|
|
515
|
+
assertCookieWithinLimit(slot.name, value);
|
|
516
|
+
await cookies.set(slot.name, value, {
|
|
517
|
+
...defaultOAuthCookieOptions,
|
|
518
|
+
secure
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/** The transaction for `state`, or null when none is stored or it does not open under `secret`. */
|
|
522
|
+
async function readTransaction(cookies, state, secret, { secure = true } = {}) {
|
|
523
|
+
const slots = await readSlots(cookies, await transactionKey(secret), secure);
|
|
524
|
+
const match = findSlot(slots, state);
|
|
525
|
+
if (match) return match.transaction;
|
|
526
|
+
if (slots.some((slot) => slot.value && !slot.transaction)) logger$5.warn("Unreadable login transaction cookie");
|
|
527
|
+
return null;
|
|
528
|
+
}
|
|
529
|
+
/** Deletes the transaction cookie for `state`, leaving other logins alone. */
|
|
530
|
+
async function clearTransaction(cookies, state, secret, { secure = true } = {}) {
|
|
531
|
+
const match = findSlot(await readSlots(cookies, await transactionKey(secret), secure), state);
|
|
532
|
+
if (!match) return;
|
|
533
|
+
if (secure) await cookies.set(match.name, "", {
|
|
534
|
+
...defaultOAuthCookieOptions,
|
|
535
|
+
secure,
|
|
536
|
+
maxAge: 0
|
|
537
|
+
});
|
|
538
|
+
else await cookies.delete(match.name);
|
|
539
|
+
}
|
|
540
|
+
/** What the handlers use: Secure and `__Host-` unless the callback is plain http. */
|
|
541
|
+
function transactionCookieOptions(redirectUri) {
|
|
542
|
+
return { secure: !/^http:/i.test(redirectUri) };
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Checks the callback query against its transaction and returns the authorization
|
|
546
|
+
* code. Throws {@link CallbackValidationError}.
|
|
547
|
+
*/
|
|
548
|
+
function validateCallback(query, transaction, { maxAgeMs = DEFAULT_MAX_AGE_MS } = {}) {
|
|
549
|
+
if (!timingSafeEqual(query.get("state") ?? "", transaction.state)) throw new CallbackValidationError("state_mismatch");
|
|
550
|
+
if (Date.now() - transaction.createdAt > maxAgeMs) throw new CallbackValidationError("transaction_expired");
|
|
551
|
+
const error = query.get("error");
|
|
552
|
+
if (error !== null) throw new CallbackValidationError("idp_error", error, query.get("error_description") ?? void 0);
|
|
553
|
+
const code = query.get("code");
|
|
554
|
+
if (!code) throw new CallbackValidationError("missing_code");
|
|
555
|
+
return code;
|
|
556
|
+
}
|
|
557
|
+
/** HKDF-SHA-256 of the secret, so a transaction and a session never share a key. */
|
|
558
|
+
async function transactionKey(secret) {
|
|
559
|
+
assertSessionSecret(secret);
|
|
560
|
+
const ikm = typeof secret === "string" ? hexToUint8Array(secret) : secret;
|
|
561
|
+
const base = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveBits"]);
|
|
562
|
+
const bits = await crypto.subtle.deriveBits({
|
|
563
|
+
name: "HKDF",
|
|
564
|
+
hash: "SHA-256",
|
|
565
|
+
salt: /* @__PURE__ */ new Uint8Array(),
|
|
566
|
+
info: new TextEncoder().encode(KEY_INFO)
|
|
567
|
+
}, base, 256);
|
|
568
|
+
return new Uint8Array(bits);
|
|
569
|
+
}
|
|
570
|
+
async function readSlots(cookies, key, secure) {
|
|
571
|
+
const prefix = secure ? HOST_PREFIX : "";
|
|
572
|
+
const names = Array.from({ length: MAX_TRANSACTIONS }, (_, i) => `${prefix}${COOKIE_NAME}${i}`);
|
|
573
|
+
return Promise.all(names.map(async (name) => {
|
|
574
|
+
const value = await cookies.get(name);
|
|
575
|
+
return {
|
|
576
|
+
name,
|
|
577
|
+
value,
|
|
578
|
+
transaction: value ? await open(value, key) : null
|
|
579
|
+
};
|
|
580
|
+
}));
|
|
581
|
+
}
|
|
582
|
+
function findSlot(slots, state) {
|
|
583
|
+
for (const { name, transaction } of slots) if (transaction && timingSafeEqual(state, transaction.state)) return {
|
|
584
|
+
name,
|
|
585
|
+
transaction
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
/** The sealed transaction, or null when the value is forged, malformed or under another key. */
|
|
589
|
+
async function open(value, key) {
|
|
590
|
+
try {
|
|
591
|
+
const sealed = await decryptJWT(value, key);
|
|
592
|
+
if (typeof sealed.state === "string" && typeof sealed.codeVerifier === "string" && typeof sealed.createdAt === "number" && (sealed.returnTo === void 0 || typeof sealed.returnTo === "string")) return {
|
|
593
|
+
state: sealed.state,
|
|
594
|
+
codeVerifier: sealed.codeVerifier,
|
|
595
|
+
returnTo: sealed.returnTo,
|
|
596
|
+
createdAt: sealed.createdAt
|
|
597
|
+
};
|
|
598
|
+
} catch {}
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
/** Compares in time that depends only on the expected value's length. */
|
|
602
|
+
function timingSafeEqual(received, expected) {
|
|
603
|
+
const encoder = new TextEncoder();
|
|
604
|
+
const a = encoder.encode(received);
|
|
605
|
+
const b = encoder.encode(expected);
|
|
606
|
+
let diff = a.length ^ b.length;
|
|
607
|
+
for (let i = 0; i < b.length; i++) diff |= (a[i] ?? 0) ^ (b[i] ?? 0);
|
|
608
|
+
return diff === 0;
|
|
609
|
+
}
|
|
610
|
+
// src/server/authorized-fetch.ts
|
|
611
|
+
var logger$4 = createLogger({ namespace: "fides-auth:server:authorized-fetch" });
|
|
612
|
+
var DEFAULT_TIMEOUT_MS = 1e4;
|
|
613
|
+
/** A failed {@link authorizedFetch}. Never carries the token or the query string. */
|
|
614
|
+
var AuthorizedFetchError = class extends Error {
|
|
615
|
+
reason;
|
|
616
|
+
/** HTTP status, for `http_error` and `refresh_failed`. */
|
|
617
|
+
status;
|
|
618
|
+
method;
|
|
619
|
+
/** Origin and path only. */
|
|
620
|
+
url;
|
|
621
|
+
/** Why the refresh failed. Only `cause: 'invalid_grant'` means the user must log in again. */
|
|
622
|
+
refreshFailure;
|
|
623
|
+
constructor(reason, details = {}) {
|
|
624
|
+
const { status, method, url, refreshFailure, response, cause } = details;
|
|
625
|
+
const target = url ? ` for ${method ?? "GET"} ${url}` : "";
|
|
626
|
+
super(`authorizedFetch ${reason}${status ? ` (${status})` : ""}${target}`, cause === void 0 ? void 0 : { cause });
|
|
627
|
+
this.name = "AuthorizedFetchError";
|
|
628
|
+
this.reason = reason;
|
|
629
|
+
this.status = status;
|
|
630
|
+
this.method = method;
|
|
631
|
+
this.url = url;
|
|
632
|
+
this.refreshFailure = refreshFailure;
|
|
633
|
+
Object.defineProperty(this, "response", {
|
|
634
|
+
value: response,
|
|
635
|
+
enumerable: false
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
/**
|
|
640
|
+
* Calls an API with the session's access token.
|
|
641
|
+
*
|
|
642
|
+
* @throws {AuthorizedFetchError} on a disallowed URL, non-2xx, timeout, network error or failed refresh.
|
|
643
|
+
* @throws {TypeError} when `allowedBase` is not an http(s) URL without credentials.
|
|
644
|
+
*/
|
|
645
|
+
async function authorizedFetch(session, url, options) {
|
|
646
|
+
const { allowedBase, parse, timeoutMs = DEFAULT_TIMEOUT_MS, refresh, ...init } = options;
|
|
647
|
+
const target = resolveAllowedUrl(url, allowedBase);
|
|
648
|
+
const request = {
|
|
649
|
+
method: (init.method ?? "GET").toUpperCase(),
|
|
650
|
+
url: redactUrl(target)
|
|
651
|
+
};
|
|
652
|
+
let current = session;
|
|
653
|
+
let attempt = await send(current, target, init, timeoutMs, request);
|
|
654
|
+
let refreshed = false;
|
|
655
|
+
if (attempt.response.status === 401 && refresh) {
|
|
656
|
+
await attempt.response.body?.cancel().catch(() => void 0);
|
|
657
|
+
attempt.unlink();
|
|
658
|
+
logger$4.debug(request, "Got 401, refreshing the session once");
|
|
659
|
+
current = await refreshOnce(refresh, request);
|
|
660
|
+
refreshed = true;
|
|
661
|
+
attempt = await send(current, target, init, timeoutMs, request);
|
|
662
|
+
}
|
|
663
|
+
const { response, timeout, unlink } = attempt;
|
|
664
|
+
if (!response.ok) throw new AuthorizedFetchError("http_error", {
|
|
665
|
+
...request,
|
|
666
|
+
status: response.status,
|
|
667
|
+
response
|
|
668
|
+
});
|
|
669
|
+
try {
|
|
670
|
+
const data = parse ? await parse(response) : void 0;
|
|
671
|
+
if (!response.body || response.bodyUsed) unlink();
|
|
672
|
+
return {
|
|
673
|
+
response,
|
|
674
|
+
data,
|
|
675
|
+
session: current,
|
|
676
|
+
refreshed
|
|
677
|
+
};
|
|
678
|
+
} catch (error) {
|
|
679
|
+
unlink();
|
|
680
|
+
if (init.signal?.aborted) throw error;
|
|
681
|
+
throw new AuthorizedFetchError(error === timeout.reason ? "timeout" : "parse_failed", {
|
|
682
|
+
...request,
|
|
683
|
+
status: response.status,
|
|
684
|
+
cause: error
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
/** Resolves `url` against the base and throws unless it stays under it. */
|
|
689
|
+
function resolveAllowedUrl(url, allowedBase) {
|
|
690
|
+
const base = new URL(allowedBase);
|
|
691
|
+
if (base.protocol !== "https:" && base.protocol !== "http:") throw new TypeError("authorizedFetch: allowedBase must be an http(s) URL");
|
|
692
|
+
if (base.username || base.password) throw new TypeError("authorizedFetch: allowedBase must not carry credentials");
|
|
693
|
+
if (!base.pathname.endsWith("/")) base.pathname += "/";
|
|
694
|
+
let target;
|
|
695
|
+
try {
|
|
696
|
+
target = new URL(url, base);
|
|
697
|
+
} catch {
|
|
698
|
+
throw new AuthorizedFetchError("url_not_allowed");
|
|
699
|
+
}
|
|
700
|
+
const underBase = target.pathname.startsWith(base.pathname) || `${target.pathname}/` === base.pathname;
|
|
701
|
+
if (target.username || target.password || target.origin !== base.origin || !underBase) {
|
|
702
|
+
logger$4.warn({
|
|
703
|
+
url: redactUrl(target),
|
|
704
|
+
allowedBase: redactUrl(base)
|
|
705
|
+
}, "Refused to send the access token outside allowedBase");
|
|
706
|
+
throw new AuthorizedFetchError("url_not_allowed", { url: redactUrl(target) });
|
|
707
|
+
}
|
|
708
|
+
return target;
|
|
709
|
+
}
|
|
710
|
+
async function send(session, target, init, timeoutMs, request) {
|
|
711
|
+
const token = session.tokens?.accessToken;
|
|
712
|
+
if (!token) throw new AuthorizedFetchError("no_access_token", request);
|
|
713
|
+
const headers = new Headers(init.headers);
|
|
714
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
715
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
716
|
+
const { signal, unlink } = linkSignals(timeout, init.signal);
|
|
717
|
+
try {
|
|
718
|
+
return {
|
|
719
|
+
response: await fetch(target, {
|
|
720
|
+
...init,
|
|
721
|
+
headers,
|
|
722
|
+
signal,
|
|
723
|
+
redirect: "error"
|
|
724
|
+
}),
|
|
725
|
+
timeout,
|
|
726
|
+
unlink
|
|
727
|
+
};
|
|
728
|
+
} catch (error) {
|
|
729
|
+
unlink();
|
|
730
|
+
if (init.signal?.aborted) throw error;
|
|
731
|
+
throw new AuthorizedFetchError(timeout.aborted ? "timeout" : "network_error", {
|
|
732
|
+
...request,
|
|
733
|
+
cause: error
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
/**
|
|
738
|
+
* A signal that aborts when the timeout or the caller's signal does, with that signal's
|
|
739
|
+
* reason, and `unlink` to remove the listeners. `AbortSignal.any` would need Node 18.17.
|
|
740
|
+
*/
|
|
741
|
+
function linkSignals(timeout, caller) {
|
|
742
|
+
if (!caller) return {
|
|
743
|
+
signal: timeout,
|
|
744
|
+
unlink: () => void 0
|
|
745
|
+
};
|
|
746
|
+
const sources = [caller, timeout];
|
|
747
|
+
const controller = new AbortController();
|
|
748
|
+
const unlink = () => {
|
|
749
|
+
for (const source of sources) source.removeEventListener("abort", onAbort);
|
|
750
|
+
};
|
|
751
|
+
const onAbort = () => {
|
|
752
|
+
unlink();
|
|
753
|
+
controller.abort(sources.find((source) => source.aborted)?.reason);
|
|
754
|
+
};
|
|
755
|
+
if (sources.some((source) => source.aborted)) onAbort();
|
|
756
|
+
else for (const source of sources) source.addEventListener("abort", onAbort);
|
|
757
|
+
return {
|
|
758
|
+
signal: controller.signal,
|
|
759
|
+
unlink
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
async function refreshOnce(refresh, request) {
|
|
763
|
+
let result;
|
|
764
|
+
try {
|
|
765
|
+
result = toRefreshResult(await refresh());
|
|
766
|
+
} catch (error) {
|
|
767
|
+
throw new AuthorizedFetchError("refresh_failed", {
|
|
768
|
+
...request,
|
|
769
|
+
status: 401,
|
|
770
|
+
cause: error
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
if (!result.ok) {
|
|
774
|
+
const refreshFailure = {
|
|
775
|
+
reason: result.reason,
|
|
776
|
+
cause: result.cause
|
|
777
|
+
};
|
|
778
|
+
throw new AuthorizedFetchError("refresh_failed", {
|
|
779
|
+
...request,
|
|
780
|
+
status: 401,
|
|
781
|
+
refreshFailure
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
return result.session;
|
|
785
|
+
}
|
|
786
|
+
/** Accepts both `tryRefreshSessionInStore` and `refreshSessionInStore` results. */
|
|
787
|
+
function toRefreshResult(value) {
|
|
788
|
+
if (!value) return {
|
|
789
|
+
ok: false,
|
|
790
|
+
reason: "refresh_failed"
|
|
791
|
+
};
|
|
792
|
+
if ("ok" in value) return value;
|
|
793
|
+
return {
|
|
794
|
+
ok: true,
|
|
795
|
+
session: value,
|
|
796
|
+
rotatedRefreshToken: false
|
|
797
|
+
};
|
|
798
|
+
}
|
|
799
|
+
/** Origin and path: never credentials, query or fragment. */
|
|
800
|
+
function redactUrl(url) {
|
|
801
|
+
return `${url.origin}${url.pathname}`;
|
|
802
|
+
}
|
|
803
|
+
// src/server/oidc-login.ts
|
|
199
804
|
var logger$3 = createLogger({ namespace: "fides-auth:server:oidc-login" });
|
|
200
|
-
var defaultValidateReturnTo = (raw) => /^\/(?!\/)
|
|
805
|
+
var defaultValidateReturnTo = (raw) => /^\/(?!\/)[\x20-\x7e]*$/.test(raw) ? raw : null;
|
|
201
806
|
/**
|
|
202
|
-
* Handles OIDC login initiation: rate-limit, build PKCE,
|
|
203
|
-
* to the authorization endpoint.
|
|
807
|
+
* Handles OIDC login initiation: rate-limit, build PKCE, seal it into a login
|
|
808
|
+
* transaction, and redirect to the authorization endpoint.
|
|
204
809
|
*/
|
|
205
810
|
async function handleOidcLogin(request, config) {
|
|
206
811
|
const { oauthConfig, cookies, rateLimit, validateReturnTo = defaultValidateReturnTo } = config;
|
|
812
|
+
assertSessionSecret(config.secret);
|
|
207
813
|
if (rateLimit && !await rateLimit()) {
|
|
208
814
|
logger$3.warn("Rate limit exceeded");
|
|
209
815
|
return new Response("Too many requests", { status: 429 });
|
|
210
816
|
}
|
|
211
817
|
const rawReturnTo = new URL(request.url).searchParams.get("returnTo");
|
|
212
818
|
const returnTo = rawReturnTo ? validateReturnTo(rawReturnTo) : null;
|
|
213
|
-
const
|
|
819
|
+
const parameters = await config.authorizationParameters?.(request);
|
|
820
|
+
const pkce = await buildPKCEOptions(oauthConfig, { parameters });
|
|
214
821
|
const authorizationUrl = await discoverAndBuildAuthorizationUrl(oauthConfig, pkce);
|
|
822
|
+
const transaction = createTransaction(pkce, { returnTo });
|
|
823
|
+
const secret = config.secret;
|
|
824
|
+
const cookieOptions = transactionCookieOptions(oauthConfig.redirect_uri);
|
|
825
|
+
try {
|
|
826
|
+
await persistTransaction(cookies, transaction, secret, cookieOptions);
|
|
827
|
+
} catch (error) {
|
|
828
|
+
if (!(error instanceof CookieTooLargeError) || !transaction.returnTo) throw error;
|
|
829
|
+
logger$3.warn({ size: error.size }, "Login transaction too large; dropped returnTo");
|
|
830
|
+
await persistTransaction(cookies, {
|
|
831
|
+
...transaction,
|
|
832
|
+
returnTo: void 0
|
|
833
|
+
}, secret, cookieOptions);
|
|
834
|
+
}
|
|
215
835
|
logger$3.info("Redirecting to OIDC provider for login");
|
|
216
|
-
await cookies.set("oauth_state", pkce.state, defaultOAuthCookieOptions);
|
|
217
|
-
await cookies.set("oauth_code_verifier", pkce.code_verifier, defaultOAuthCookieOptions);
|
|
218
|
-
if (returnTo) await cookies.set("returnTo", returnTo, defaultOAuthCookieOptions);
|
|
219
|
-
else await cookies.delete("returnTo");
|
|
220
836
|
return new Response(null, {
|
|
221
837
|
status: 302,
|
|
222
838
|
headers: { Location: authorizationUrl.toString() }
|
|
223
839
|
});
|
|
224
840
|
}
|
|
225
|
-
|
|
226
|
-
//#region src/server/oidc-callback.ts
|
|
841
|
+
// src/server/oidc-callback.ts
|
|
227
842
|
var logger$2 = createLogger({ namespace: "fides-auth:server:oidc-callback" });
|
|
843
|
+
var IDP_ERROR_CODE = /^[a-z0-9_]{1,64}$/;
|
|
228
844
|
/**
|
|
229
845
|
* Handles the OIDC callback: rate-limit, exchange the code for tokens, persist the
|
|
230
846
|
* session across the cookie store, and redirect to a validated returnTo.
|
|
231
847
|
*/
|
|
232
848
|
async function handleOidcCallback(request, config) {
|
|
233
|
-
const { oauthConfig, applicationUrl, cookies,
|
|
849
|
+
const { oauthConfig, applicationUrl, cookies, secret, rateLimit, rolesClaim = "roles", mapSession, defaultRedirectPath = "/", validateReturnTo } = config;
|
|
850
|
+
assertSessionSecret(secret);
|
|
851
|
+
const persistence = config.persistence ?? secret;
|
|
852
|
+
const txCookie = transactionCookieOptions(oauthConfig.redirect_uri);
|
|
234
853
|
if (rateLimit && !await rateLimit()) {
|
|
235
854
|
logger$2.warn("Rate limit exceeded");
|
|
236
855
|
return new Response("Too many requests", { status: 429 });
|
|
237
856
|
}
|
|
857
|
+
const currentUrl = new URL(request.url);
|
|
858
|
+
const state = currentUrl.searchParams.get("state");
|
|
859
|
+
const idpError = currentUrl.searchParams.get("error") ?? void 0;
|
|
860
|
+
const loginEnded = () => idpError === void 0 ? new Response("Please restart the login process.", { status: 400 }) : loginErrorRedirect(applicationUrl, defaultRedirectPath, "login_expired");
|
|
238
861
|
try {
|
|
239
|
-
const currentUrl = new URL(request.url);
|
|
240
862
|
const publicUrl = new URL(applicationUrl);
|
|
241
863
|
publicUrl.pathname = currentUrl.pathname;
|
|
242
864
|
publicUrl.search = currentUrl.search;
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
return new Response("Please restart the login process.", { status: 400 });
|
|
865
|
+
const transaction = state ? await readTransaction(cookies, state, secret, txCookie) : null;
|
|
866
|
+
if (!transaction) {
|
|
867
|
+
logger$2.warn({ error: idpError }, "Missing login transaction");
|
|
868
|
+
return loginEnded();
|
|
248
869
|
}
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
await cookies.
|
|
256
|
-
const redirectUrl = validateReturnUrl(returnTo ?? null, applicationUrl, defaultRedirectPath, validateReturnTo);
|
|
870
|
+
validateCallback(currentUrl.searchParams, transaction);
|
|
871
|
+
const tokens = await exchangeAuthorizationCode(oauthConfig, publicUrl, transaction.codeVerifier, transaction.state);
|
|
872
|
+
const session = buildSessionFromTokens(tokens, {
|
|
873
|
+
rolesClaim,
|
|
874
|
+
mapSession
|
|
875
|
+
});
|
|
876
|
+
await persistSession(cookies, session, persistence, { event: SESSION_EVENT.CREATED });
|
|
877
|
+
const redirectUrl = validateReturnUrl(transaction.returnTo ?? null, applicationUrl, defaultRedirectPath, validateReturnTo);
|
|
257
878
|
redirectUrl.searchParams.set("login", "success");
|
|
258
879
|
logger$2.info({
|
|
259
880
|
sid: session.sid,
|
|
@@ -264,6 +885,22 @@ async function handleOidcCallback(request, config) {
|
|
|
264
885
|
headers: { Location: redirectUrl.toString() }
|
|
265
886
|
});
|
|
266
887
|
} catch (error) {
|
|
888
|
+
if (error instanceof CallbackValidationError) {
|
|
889
|
+
if (error.reason === "idp_error") {
|
|
890
|
+
logger$2.warn({
|
|
891
|
+
reason: error.reason,
|
|
892
|
+
error: error.idpError,
|
|
893
|
+
errorDescription: error.idpErrorDescription
|
|
894
|
+
}, "Identity provider returned an error");
|
|
895
|
+
const code = error.idpError ?? "";
|
|
896
|
+
return loginErrorRedirect(applicationUrl, defaultRedirectPath, IDP_ERROR_CODE.test(code) ? code : "idp_error");
|
|
897
|
+
}
|
|
898
|
+
logger$2.warn({
|
|
899
|
+
reason: error.reason,
|
|
900
|
+
error: idpError
|
|
901
|
+
}, "Rejected OIDC callback");
|
|
902
|
+
return error.reason === "transaction_expired" ? loginEnded() : new Response("Please restart the login process.", { status: 400 });
|
|
903
|
+
}
|
|
267
904
|
if (error instanceof CookieTooLargeError) {
|
|
268
905
|
logger$2.error({
|
|
269
906
|
error,
|
|
@@ -274,10 +911,21 @@ async function handleOidcCallback(request, config) {
|
|
|
274
911
|
}
|
|
275
912
|
logger$2.error({ error }, "OIDC callback error");
|
|
276
913
|
return new Response("An unexpected error occurred. Please restart the login process.", { status: 500 });
|
|
914
|
+
} finally {
|
|
915
|
+
if (state) await clearTransaction(cookies, state, secret, txCookie);
|
|
277
916
|
}
|
|
278
917
|
}
|
|
279
|
-
|
|
280
|
-
|
|
918
|
+
/** Redirects to the default path with `login=error` and a safe error code, never a description. */
|
|
919
|
+
function loginErrorRedirect(applicationUrl, defaultRedirectPath, code) {
|
|
920
|
+
const redirectUrl = new URL(defaultRedirectPath, applicationUrl);
|
|
921
|
+
redirectUrl.searchParams.set("login", "error");
|
|
922
|
+
redirectUrl.searchParams.set("error", code);
|
|
923
|
+
return new Response(null, {
|
|
924
|
+
status: 302,
|
|
925
|
+
headers: { Location: redirectUrl.toString() }
|
|
926
|
+
});
|
|
927
|
+
}
|
|
928
|
+
// src/server/oidc-logout.ts
|
|
281
929
|
var logger$1 = createLogger({ namespace: "fides-auth:server:oidc-logout" });
|
|
282
930
|
/**
|
|
283
931
|
* Whether the request came from the application itself. POST alone is no defence:
|
|
@@ -304,7 +952,8 @@ function isSameOrigin(request, applicationUrl) {
|
|
|
304
952
|
* unavailable, so the user is always logged out of this application.
|
|
305
953
|
*/
|
|
306
954
|
async function handleOidcLogout(request, config) {
|
|
307
|
-
const { oauthConfig, cookies, postLogoutRedirectUri,
|
|
955
|
+
const { oauthConfig, cookies, postLogoutRedirectUri, rateLimit, state, logoutHint, includeClientId, fallbackRedirectUri = postLogoutRedirectUri, allowedMethods = ["POST"], applicationUrl } = config;
|
|
956
|
+
const persistence = persistenceFrom(config);
|
|
308
957
|
if (!allowedMethods.includes(request.method)) return new Response(null, {
|
|
309
958
|
status: 405,
|
|
310
959
|
headers: { Allow: allowedMethods.join(", ") }
|
|
@@ -320,12 +969,14 @@ async function handleOidcLogout(request, config) {
|
|
|
320
969
|
logger$1.warn("Rate limit exceeded");
|
|
321
970
|
return new Response("Too many requests", { status: 429 });
|
|
322
971
|
}
|
|
323
|
-
const
|
|
324
|
-
const
|
|
972
|
+
const read = await tryReadSession(cookies, persistence);
|
|
973
|
+
const idTokenHint = read.session?.tokens?.idToken ?? await readIdToken(cookies, persistence);
|
|
974
|
+
const sid = read.session?.sid;
|
|
325
975
|
try {
|
|
326
976
|
await clearSession(cookies, {
|
|
327
977
|
trigger: "logout",
|
|
328
|
-
sid
|
|
978
|
+
sid,
|
|
979
|
+
persistence
|
|
329
980
|
});
|
|
330
981
|
} catch (error) {
|
|
331
982
|
logger$1.error({ error }, "Failed to clear session cookies");
|
|
@@ -351,8 +1002,7 @@ async function handleOidcLogout(request, config) {
|
|
|
351
1002
|
headers: { Location: logoutUrl.toString() }
|
|
352
1003
|
});
|
|
353
1004
|
}
|
|
354
|
-
|
|
355
|
-
//#region src/server/heartbeat-handler.ts
|
|
1005
|
+
// src/server/heartbeat-handler.ts
|
|
356
1006
|
var logger = createLogger({ namespace: "fides-auth:server:heartbeat" });
|
|
357
1007
|
/**
|
|
358
1008
|
* Handles a heartbeat request — refresh the active session if there is one.
|
|
@@ -369,7 +1019,8 @@ var logger = createLogger({ namespace: "fides-auth:server:heartbeat" });
|
|
|
369
1019
|
* failures with backoff.
|
|
370
1020
|
*/
|
|
371
1021
|
async function handleHeartbeat(request, config) {
|
|
372
|
-
const { oauthConfig, cookies, rateLimit
|
|
1022
|
+
const { oauthConfig, cookies, rateLimit } = config;
|
|
1023
|
+
const persistence = persistenceFrom(config);
|
|
373
1024
|
if (request.method !== "POST") return new Response(null, {
|
|
374
1025
|
status: 405,
|
|
375
1026
|
headers: { Allow: "POST" }
|
|
@@ -378,7 +1029,7 @@ async function handleHeartbeat(request, config) {
|
|
|
378
1029
|
logger.warn("Heartbeat rate limit exceeded");
|
|
379
1030
|
return new Response(null, { status: 429 });
|
|
380
1031
|
}
|
|
381
|
-
const result = await tryRefreshSessionInStore(cookies, oauthConfig,
|
|
1032
|
+
const result = await tryRefreshSessionInStore(cookies, oauthConfig, persistence);
|
|
382
1033
|
if (result.ok) {
|
|
383
1034
|
logger.debug({ sid: result.session.sid }, "Heartbeat refresh succeeded");
|
|
384
1035
|
return Response.json({ accessTokenExpiresAt: result.session.tokens?.accessTokenExpiresAt ?? null }, { headers: { "Cache-Control": "private, no-store" } });
|
|
@@ -415,7 +1066,6 @@ function unauthorized(reason) {
|
|
|
415
1066
|
headers: { "Cache-Control": "private, no-store" }
|
|
416
1067
|
});
|
|
417
1068
|
}
|
|
418
|
-
|
|
419
|
-
export { clearSession, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, readIdToken, readSession, refreshSessionInStore, tryReadSession, tryRefreshSessionInStore };
|
|
1069
|
+
export { AuthorizedFetchError, CallbackValidationError, authorizedFetch, clearSession, clearTransaction, cookiePersistence, createMemorySessionStore, createTransaction, handleHeartbeat, handleOidcCallback, handleOidcLogin, handleOidcLogout, persistSession, persistTransaction, readIdToken, readSession, readTransaction, refreshSessionInStore, serverPersistence, transactionCookieOptions, tryReadSession, tryRefreshSessionInStore, validateCallback };
|
|
420
1070
|
|
|
421
1071
|
//# sourceMappingURL=index.js.map
|