@eminuckan/spine 0.3.8 → 0.3.10
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/CHANGELOG.md +33 -0
- package/README.md +20 -0
- package/dist/auth/auth.server.d.ts.map +1 -1
- package/dist/auth/callback-errors.d.ts +42 -0
- package/dist/auth/callback-errors.d.ts.map +1 -0
- package/dist/auth/oauth-recovery.server.d.ts +66 -0
- package/dist/auth/oauth-recovery.server.d.ts.map +1 -0
- package/dist/auth/oauth-state-config.d.ts +14 -0
- package/dist/auth/oauth-state-config.d.ts.map +1 -0
- package/dist/auth/redis-session-storage.server.d.ts +21 -1
- package/dist/auth/redis-session-storage.server.d.ts.map +1 -1
- package/dist/auth/server.d.ts +3 -0
- package/dist/auth/server.d.ts.map +1 -1
- package/dist/auth/server.mjs +3 -3
- package/dist/react-router/server.mjs +3 -3
- package/dist/{redis-session-storage.server-n0lWWyoh.mjs → redis-session-storage.server-DLJ0B7-C.mjs} +92 -12
- package/dist/redis-session-storage.server-DLJ0B7-C.mjs.map +1 -0
- package/dist/{server-C3_iNZfj.mjs → server-BJTz5-BE.mjs} +445 -45
- package/dist/server-BJTz5-BE.mjs.map +1 -0
- package/dist/server.mjs +3 -3
- package/dist/tanstack-start/server.mjs +3 -3
- package/dist/tenant/server.mjs +1 -1
- package/docs/adapters.md +20 -0
- package/docs/module-reference.md +9 -0
- package/package.json +1 -1
- package/dist/redis-session-storage.server-n0lWWyoh.mjs.map +0 -1
- package/dist/server-C3_iNZfj.mjs.map +0 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { logger } from "./logging/index.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { D as OAUTH_RECOVERY_TTL_SECONDS, E as OAUTH_RECOVERY_TTL_MS, O as OAUTH_STATE_TTL_MS, T as updateAuthSession, a as createAuthSession, c as deleteOAuthRecoveryRecord, f as destroyAuthSessionsByIdentitySession, g as getAuthSession, h as destroyAuthSessionsForUser, i as consumeOAuthRecoveryRecord, k as OAUTH_STATE_TTL_SECONDS, l as deleteOAuthState, m as destroyAuthSessionsBySid, o as createOAuthRecoveryRecord, s as createOAuthState, t as AUTH_ERROR_COOKIE_PREFIX, u as destroyAuthSession, x as listAuthSessionDataForUser, y as getOAuthState } from "./redis-session-storage.server-DLJ0B7-C.mjs";
|
|
3
3
|
import * as oidc from "openid-client";
|
|
4
4
|
import { createRemoteJWKSet, errors, jwtVerify } from "jose";
|
|
5
5
|
//#region src/http/response.ts
|
|
@@ -132,6 +132,268 @@ function serializeTemporaryAuthCookie(name, value, options) {
|
|
|
132
132
|
return segments.join("; ");
|
|
133
133
|
}
|
|
134
134
|
//#endregion
|
|
135
|
+
//#region src/auth/callback-errors.ts
|
|
136
|
+
const OAuthCallbackFailureCodes = {
|
|
137
|
+
StaleTransaction: "stale_transaction",
|
|
138
|
+
StateMismatch: "state_mismatch",
|
|
139
|
+
MalformedCallback: "malformed_callback",
|
|
140
|
+
ApplicationActionFailed: "application_action_failed",
|
|
141
|
+
TokenExchangeFailed: "token_exchange_failed",
|
|
142
|
+
SessionCreationFailed: "session_creation_failed",
|
|
143
|
+
StorageError: "storage_error",
|
|
144
|
+
ConfigurationError: "configuration_error"
|
|
145
|
+
};
|
|
146
|
+
const OAUTH_CALLBACK_ERROR_BRAND = Symbol.for("@eminuckan/spine/OAuthCallbackError");
|
|
147
|
+
function isOAuthCallbackFailureCode(value) {
|
|
148
|
+
return typeof value === "string" && Object.values(OAuthCallbackFailureCodes).includes(value);
|
|
149
|
+
}
|
|
150
|
+
function isHeadersLike(value) {
|
|
151
|
+
return value !== null && typeof value === "object" && typeof value.get === "function" && typeof value.append === "function";
|
|
152
|
+
}
|
|
153
|
+
function getSetCookieValues$1(headers) {
|
|
154
|
+
const getSetCookie = headers.getSetCookie;
|
|
155
|
+
if (getSetCookie) return getSetCookie.call(headers);
|
|
156
|
+
return Array.from(headers).reduce((cookies, [name, value]) => {
|
|
157
|
+
if (name.toLowerCase() === "set-cookie") cookies.push(value);
|
|
158
|
+
return cookies;
|
|
159
|
+
}, []);
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* A callback failure that consumers can classify without parsing messages.
|
|
163
|
+
*
|
|
164
|
+
* The message is intentionally constant. Consumers should branch on
|
|
165
|
+
* `code`, not on `message`, and should merge `cleanupHeaders` (or the
|
|
166
|
+
* lossless `cleanupSetCookies` values) with any response they create using
|
|
167
|
+
* repeated `Set-Cookie` appends.
|
|
168
|
+
*/
|
|
169
|
+
var OAuthCallbackError = class extends Error {
|
|
170
|
+
code;
|
|
171
|
+
cleanupHeaders;
|
|
172
|
+
cleanupSetCookies;
|
|
173
|
+
constructor(code, cleanupHeaders = {}) {
|
|
174
|
+
super("Authentication failed");
|
|
175
|
+
this.name = "OAuthCallbackError";
|
|
176
|
+
this.code = code;
|
|
177
|
+
this.cleanupHeaders = new Headers(cleanupHeaders);
|
|
178
|
+
this.cleanupSetCookies = getSetCookieValues$1(this.cleanupHeaders);
|
|
179
|
+
Object.defineProperty(this, OAUTH_CALLBACK_ERROR_BRAND, {
|
|
180
|
+
configurable: false,
|
|
181
|
+
enumerable: false,
|
|
182
|
+
value: true,
|
|
183
|
+
writable: false
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* Cross-package-copy safe type guard for callback failures.
|
|
189
|
+
*/
|
|
190
|
+
function isOAuthCallbackError(error) {
|
|
191
|
+
if (error instanceof OAuthCallbackError) return true;
|
|
192
|
+
if (error === null || typeof error !== "object") return false;
|
|
193
|
+
const candidate = error;
|
|
194
|
+
return candidate[OAUTH_CALLBACK_ERROR_BRAND] === true && isOAuthCallbackFailureCode(candidate.code) && isHeadersLike(candidate.cleanupHeaders);
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Only a normal authorization callback whose local transaction is absent or
|
|
198
|
+
* expired may be retried. All other callback failures fail closed.
|
|
199
|
+
*/
|
|
200
|
+
function isRecoverableOAuthCallbackError(error) {
|
|
201
|
+
return isOAuthCallbackError(error) && error.code === "stale_transaction";
|
|
202
|
+
}
|
|
203
|
+
//#endregion
|
|
204
|
+
//#region src/auth/oauth-recovery.server.ts
|
|
205
|
+
/**
|
|
206
|
+
* Server-only recovery intent for stale OAuth callbacks.
|
|
207
|
+
*
|
|
208
|
+
* A recovery intent is an opaque, HttpOnly cookie backed by a separate Redis
|
|
209
|
+
* record. Consumers must atomically consume it with the typed stale callback
|
|
210
|
+
* error; the client never supplies a boolean budget or a return URL.
|
|
211
|
+
*/
|
|
212
|
+
const OAUTH_RECOVERY_COOKIE_NAME = `${AUTH_ERROR_COOKIE_PREFIX}_oauth_recovery`;
|
|
213
|
+
function escapeRegExp$1(value) {
|
|
214
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
215
|
+
}
|
|
216
|
+
function getCookieValue(request) {
|
|
217
|
+
const cookies = request.headers.get("Cookie");
|
|
218
|
+
if (!cookies) return null;
|
|
219
|
+
const cookieRegex = new RegExp(`(?:^|;\\s*)${escapeRegExp$1(OAUTH_RECOVERY_COOKIE_NAME)}=([^;]*)`);
|
|
220
|
+
const rawValue = cookies.match(cookieRegex)?.[1];
|
|
221
|
+
if (!rawValue) return null;
|
|
222
|
+
try {
|
|
223
|
+
const value = decodeURIComponent(rawValue);
|
|
224
|
+
return value.length > 0 ? value : null;
|
|
225
|
+
} catch {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function hasCookie(request) {
|
|
230
|
+
const cookies = request.headers.get("Cookie");
|
|
231
|
+
if (!cookies) return false;
|
|
232
|
+
return cookies.split(";").some((part) => {
|
|
233
|
+
const separator = part.indexOf("=");
|
|
234
|
+
return separator > 0 && part.slice(0, separator).trim() === OAUTH_RECOVERY_COOKIE_NAME;
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const SAFE_FALLBACK_COOKIE_PATH = "/";
|
|
238
|
+
function normalizeCookiePath(pathname) {
|
|
239
|
+
const candidate = pathname.trim();
|
|
240
|
+
const hasUnsafeCharacter = Array.from(candidate).some((character) => {
|
|
241
|
+
const code = character.charCodeAt(0);
|
|
242
|
+
return code <= 31 || code === 127 || character === ";";
|
|
243
|
+
});
|
|
244
|
+
if (candidate.length === 0 || !candidate.startsWith("/") || candidate.startsWith("//") || hasUnsafeCharacter) return SAFE_FALLBACK_COOKIE_PATH;
|
|
245
|
+
return candidate;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Resolve the callback cookie path from a configured redirect URI or the
|
|
249
|
+
* actual callback request. Unsafe path values fall back to `/` rather than
|
|
250
|
+
* being interpolated into a Set-Cookie header.
|
|
251
|
+
*/
|
|
252
|
+
function getOAuthRecoveryCookiePath(source) {
|
|
253
|
+
try {
|
|
254
|
+
return normalizeCookiePath((source instanceof Request ? new URL(source.url) : source instanceof URL ? source : new URL(source, source.startsWith("/") ? "http://spine.local" : void 0)).pathname);
|
|
255
|
+
} catch {
|
|
256
|
+
return SAFE_FALLBACK_COOKIE_PATH;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
function serializeRecoveryCookie(ticket, maxAge, callbackPath) {
|
|
260
|
+
const segments = [
|
|
261
|
+
`${OAUTH_RECOVERY_COOKIE_NAME}=${encodeURIComponent(ticket)}`,
|
|
262
|
+
`Path=${callbackPath}`,
|
|
263
|
+
"HttpOnly",
|
|
264
|
+
"SameSite=Lax",
|
|
265
|
+
`Max-Age=${maxAge}`
|
|
266
|
+
];
|
|
267
|
+
if (process.env.NODE_ENV === "production") segments.push("Secure");
|
|
268
|
+
return segments.join("; ");
|
|
269
|
+
}
|
|
270
|
+
function appendRecoveryCookieClear(headers, callbackPath) {
|
|
271
|
+
headers.append("Set-Cookie", serializeRecoveryCookie("", 0, callbackPath));
|
|
272
|
+
}
|
|
273
|
+
function getSetCookieValues(headers) {
|
|
274
|
+
const getSetCookie = headers.getSetCookie;
|
|
275
|
+
if (getSetCookie) return getSetCookie.call(headers);
|
|
276
|
+
return Array.from(headers).filter(([name]) => name.toLowerCase() === "set-cookie").map(([, value]) => value);
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Build a lossless ticket-clear header using a callback request, URL, or
|
|
280
|
+
* configured redirect URI as the cookie-path source.
|
|
281
|
+
*/
|
|
282
|
+
function clearOAuthRecoveryIntentHeaders(source) {
|
|
283
|
+
const headers = new Headers();
|
|
284
|
+
appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(source));
|
|
285
|
+
return headers;
|
|
286
|
+
}
|
|
287
|
+
function hasNonEmptyParam(url, name) {
|
|
288
|
+
return url.searchParams.get(name)?.trim().length ? true : false;
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* This check is intentionally stricter than the callback error code. It
|
|
292
|
+
* prevents recovery for provider errors, malformed callbacks, and account
|
|
293
|
+
* actions even if an adapter accidentally calls the consume helper there.
|
|
294
|
+
*/
|
|
295
|
+
function isOAuthRecoveryEligibleCallback(request) {
|
|
296
|
+
const url = new URL(request.url);
|
|
297
|
+
return hasNonEmptyParam(url, "code") && hasNonEmptyParam(url, "state") && !url.searchParams.has("error") && !url.searchParams.has("kc_action") && !url.searchParams.has("kc_action_status");
|
|
298
|
+
}
|
|
299
|
+
function isRecoveryRecord(value) {
|
|
300
|
+
if (value === null || typeof value !== "object") return false;
|
|
301
|
+
const record = value;
|
|
302
|
+
return typeof record.state === "string" && record.state.length > 0 && typeof record.returnUrl === "string" && typeof record.createdAt === "number" && Number.isFinite(record.createdAt);
|
|
303
|
+
}
|
|
304
|
+
function getCallbackState(request) {
|
|
305
|
+
const value = new URL(request.url).searchParams.get("state");
|
|
306
|
+
return value && value.trim().length > 0 ? value : null;
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Create the opaque ticket cookie for an ordinary login transaction.
|
|
310
|
+
* Application-action transactions deliberately do not receive a recovery
|
|
311
|
+
* ticket and therefore cannot enter the automatic recovery path.
|
|
312
|
+
*/
|
|
313
|
+
async function createOAuthRecoveryForLogin(request, state, redirectUri) {
|
|
314
|
+
const returnUrl = sanitizeOAuthReturnUrl(state.returnUrl, request) ?? "/";
|
|
315
|
+
const callbackPath = getOAuthRecoveryCookiePath(redirectUri);
|
|
316
|
+
const ticket = await createOAuthRecoveryRecord({
|
|
317
|
+
state: state.state,
|
|
318
|
+
returnUrl,
|
|
319
|
+
createdAt: Date.now()
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
ticket,
|
|
323
|
+
cookie: serializeRecoveryCookie(ticket, OAUTH_RECOVERY_TTL_SECONDS, callbackPath)
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Delete an unconsumed ticket during terminal/successful callback cleanup.
|
|
328
|
+
* The caller owns error classification and should still clear the browser
|
|
329
|
+
* cookie if deletion fails.
|
|
330
|
+
*/
|
|
331
|
+
async function discardOAuthRecoveryForRequest(request) {
|
|
332
|
+
const ticket = getCookieValue(request);
|
|
333
|
+
const headers = new Headers();
|
|
334
|
+
if (!hasCookie(request)) return headers;
|
|
335
|
+
if (ticket) await deleteOAuthRecoveryRecord(ticket);
|
|
336
|
+
appendRecoveryCookieClear(headers, getOAuthRecoveryCookiePath(request));
|
|
337
|
+
return headers;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Atomically consume a stale-flow recovery intent.
|
|
341
|
+
*
|
|
342
|
+
* The typed error and the callback shape are both checked server-side. Redis
|
|
343
|
+
* compares the callback state and deletes only on an exact match, so a
|
|
344
|
+
* mismatch cannot burn a valid ticket while a replay or concurrent loser
|
|
345
|
+
* cannot retry.
|
|
346
|
+
*/
|
|
347
|
+
async function consumeOAuthRecoveryIntent(request, callbackError) {
|
|
348
|
+
const cleanupHeaders = clearOAuthRecoveryIntentHeaders(request);
|
|
349
|
+
const cleanupSetCookies = getSetCookieValues(cleanupHeaders);
|
|
350
|
+
if (!isRecoverableOAuthCallbackError(callbackError) || !isOAuthRecoveryEligibleCallback(request)) return {
|
|
351
|
+
intent: null,
|
|
352
|
+
cleanupHeaders,
|
|
353
|
+
cleanupSetCookies
|
|
354
|
+
};
|
|
355
|
+
const ticket = getCookieValue(request);
|
|
356
|
+
const callbackState = getCallbackState(request);
|
|
357
|
+
if (!ticket || !callbackState) return {
|
|
358
|
+
intent: null,
|
|
359
|
+
cleanupHeaders,
|
|
360
|
+
cleanupSetCookies
|
|
361
|
+
};
|
|
362
|
+
let record;
|
|
363
|
+
try {
|
|
364
|
+
record = await consumeOAuthRecoveryRecord(ticket, callbackState);
|
|
365
|
+
} catch (error) {
|
|
366
|
+
logger.warn("OAuth recovery record could not be consumed", { errorType: error instanceof Error ? error.name : "UnknownError" });
|
|
367
|
+
return {
|
|
368
|
+
intent: null,
|
|
369
|
+
cleanupHeaders,
|
|
370
|
+
cleanupSetCookies
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
if (!isRecoveryRecord(record)) return {
|
|
374
|
+
intent: null,
|
|
375
|
+
cleanupHeaders,
|
|
376
|
+
cleanupSetCookies
|
|
377
|
+
};
|
|
378
|
+
const age = Date.now() - record.createdAt;
|
|
379
|
+
if (age < 0 || age > OAUTH_RECOVERY_TTL_MS) return {
|
|
380
|
+
intent: null,
|
|
381
|
+
cleanupHeaders,
|
|
382
|
+
cleanupSetCookies
|
|
383
|
+
};
|
|
384
|
+
const returnUrl = sanitizeOAuthReturnUrl(record.returnUrl, request);
|
|
385
|
+
if (!returnUrl) return {
|
|
386
|
+
intent: null,
|
|
387
|
+
cleanupHeaders,
|
|
388
|
+
cleanupSetCookies
|
|
389
|
+
};
|
|
390
|
+
return {
|
|
391
|
+
intent: { returnUrl },
|
|
392
|
+
cleanupHeaders,
|
|
393
|
+
cleanupSetCookies
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
//#endregion
|
|
135
397
|
//#region src/auth/auth.server.ts
|
|
136
398
|
/**
|
|
137
399
|
* OpenID Connect Authentication Server Module
|
|
@@ -384,11 +646,12 @@ async function getOAuthConfig() {
|
|
|
384
646
|
});
|
|
385
647
|
return configuration;
|
|
386
648
|
} catch (error) {
|
|
387
|
-
logger.error("OIDC discovery failed",
|
|
649
|
+
logger.error("OIDC discovery failed", void 0, {
|
|
388
650
|
authority: config.authority,
|
|
389
|
-
clientId: config.clientId
|
|
651
|
+
clientId: config.clientId,
|
|
652
|
+
errorType: error instanceof Error ? error.name : "UnknownError"
|
|
390
653
|
});
|
|
391
|
-
throw new Error(
|
|
654
|
+
throw new Error("OIDC discovery failed");
|
|
392
655
|
}
|
|
393
656
|
}
|
|
394
657
|
function getJwks(jwksUri) {
|
|
@@ -661,6 +924,81 @@ function clearAuthErrorHeaders() {
|
|
|
661
924
|
function hasAuthError(request) {
|
|
662
925
|
return request.headers.get("Cookie")?.includes(`${AUTH_ERROR_COOKIE}=`) ?? false;
|
|
663
926
|
}
|
|
927
|
+
function escapeRegExp(value) {
|
|
928
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
929
|
+
}
|
|
930
|
+
function getOAuthStateIdFromRequest(request) {
|
|
931
|
+
const cookies = request.headers.get("Cookie");
|
|
932
|
+
if (!cookies) return null;
|
|
933
|
+
const cookieRegex = new RegExp(`(?:^|;\\s*)${escapeRegExp(OAUTH_STATE_COOKIE)}=([^;]*)`);
|
|
934
|
+
const cookieValue = cookies.match(cookieRegex)?.[1];
|
|
935
|
+
if (!cookieValue) return null;
|
|
936
|
+
try {
|
|
937
|
+
const stateId = decodeURIComponent(cookieValue);
|
|
938
|
+
return stateId.length > 0 ? stateId : null;
|
|
939
|
+
} catch {
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function hasNonEmptyCallbackValue(value) {
|
|
944
|
+
return value !== null && value.trim().length > 0;
|
|
945
|
+
}
|
|
946
|
+
function appendHeadersPreservingSetCookie(target, source) {
|
|
947
|
+
const sourceHeaders = new Headers(source);
|
|
948
|
+
const setCookies = sourceHeaders.getSetCookie?.call(sourceHeaders) ?? Array.from(sourceHeaders).filter(([name]) => name.toLowerCase() === "set-cookie").map(([, value]) => value);
|
|
949
|
+
if (setCookies.length > 0) for (const cookie of setCookies) target.append("Set-Cookie", cookie);
|
|
950
|
+
else {
|
|
951
|
+
const setCookie = sourceHeaders.get("Set-Cookie");
|
|
952
|
+
if (setCookie) target.append("Set-Cookie", setCookie);
|
|
953
|
+
}
|
|
954
|
+
for (const [name, value] of sourceHeaders) if (name.toLowerCase() !== "set-cookie") target.set(name, value);
|
|
955
|
+
}
|
|
956
|
+
function appendOAuthStateCookieClear(headers) {
|
|
957
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, "", { maxAge: 0 }));
|
|
958
|
+
}
|
|
959
|
+
function isUsableOAuthState(state, requireNonce) {
|
|
960
|
+
return state !== null && typeof state.state === "string" && state.state.length > 0 && typeof state.codeVerifier === "string" && state.codeVerifier.length > 0 && (state.nonce === void 0 ? !requireNonce : typeof state.nonce === "string" && state.nonce.length > 0) && typeof state.createdAt === "number" && Number.isFinite(state.createdAt) && (state.returnUrl === void 0 || typeof state.returnUrl === "string") && (state.kcAction === void 0 || typeof state.kcAction === "string");
|
|
961
|
+
}
|
|
962
|
+
function getCallbackFailureCode(error, stage) {
|
|
963
|
+
if (isOAuthCallbackError(error)) return error.code;
|
|
964
|
+
switch (stage) {
|
|
965
|
+
case "configuration": return OAuthCallbackFailureCodes.ConfigurationError;
|
|
966
|
+
case "state":
|
|
967
|
+
case "storage": return OAuthCallbackFailureCodes.StorageError;
|
|
968
|
+
case "application_action": return OAuthCallbackFailureCodes.ApplicationActionFailed;
|
|
969
|
+
case "token_exchange": return OAuthCallbackFailureCodes.TokenExchangeFailed;
|
|
970
|
+
case "session_creation": return OAuthCallbackFailureCodes.SessionCreationFailed;
|
|
971
|
+
default: return OAuthCallbackFailureCodes.StorageError;
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
async function createOAuthCallbackFailure(request, stateId, failureCode) {
|
|
975
|
+
const cleanupHeaders = new Headers();
|
|
976
|
+
let cleanupError;
|
|
977
|
+
const preserveRecoveryTicket = failureCode === OAuthCallbackFailureCodes.StaleTransaction && isOAuthRecoveryEligibleCallback(request);
|
|
978
|
+
if (stateId) try {
|
|
979
|
+
await deleteOAuthState(stateId);
|
|
980
|
+
} catch (error) {
|
|
981
|
+
cleanupError = error;
|
|
982
|
+
}
|
|
983
|
+
try {
|
|
984
|
+
appendHeadersPreservingSetCookie(cleanupHeaders, await destroyAuthSession(request));
|
|
985
|
+
} catch (error) {
|
|
986
|
+
cleanupError ??= error;
|
|
987
|
+
}
|
|
988
|
+
if (!preserveRecoveryTicket || cleanupError) try {
|
|
989
|
+
appendHeadersPreservingSetCookie(cleanupHeaders, await discardOAuthRecoveryForRequest(request));
|
|
990
|
+
} catch (error) {
|
|
991
|
+
cleanupError ??= error;
|
|
992
|
+
appendHeadersPreservingSetCookie(cleanupHeaders, clearOAuthRecoveryIntentHeaders(request));
|
|
993
|
+
}
|
|
994
|
+
appendOAuthStateCookieClear(cleanupHeaders);
|
|
995
|
+
const effectiveFailureCode = cleanupError ? OAuthCallbackFailureCodes.StorageError : failureCode;
|
|
996
|
+
if (cleanupError) logger.warn("OAuth callback cleanup failed", {
|
|
997
|
+
failureCode: effectiveFailureCode,
|
|
998
|
+
errorType: cleanupError instanceof Error ? cleanupError.name : "UnknownError"
|
|
999
|
+
});
|
|
1000
|
+
return new OAuthCallbackError(effectiveFailureCode, cleanupHeaders);
|
|
1001
|
+
}
|
|
664
1002
|
/**
|
|
665
1003
|
* Initiate OAuth login flow
|
|
666
1004
|
*
|
|
@@ -668,6 +1006,9 @@ function hasAuthError(request) {
|
|
|
668
1006
|
* @param returnUrlOrOptions - Either a return URL string or LoginOptions object
|
|
669
1007
|
*/
|
|
670
1008
|
async function login(request, returnUrlOrOptions) {
|
|
1009
|
+
let oauthStateId = null;
|
|
1010
|
+
let recoveryTicket = null;
|
|
1011
|
+
let recoveryCookie = null;
|
|
671
1012
|
try {
|
|
672
1013
|
const config = getAuthConfig();
|
|
673
1014
|
const options = typeof returnUrlOrOptions === "string" ? { returnUrl: returnUrlOrOptions } : returnUrlOrOptions || {};
|
|
@@ -699,7 +1040,15 @@ async function login(request, returnUrlOrOptions) {
|
|
|
699
1040
|
hasKcAction: Boolean(options.kcAction),
|
|
700
1041
|
extraAuthParamKeys: Object.keys(extraAuthParams)
|
|
701
1042
|
});
|
|
702
|
-
|
|
1043
|
+
oauthStateId = await createOAuthState(oauthState);
|
|
1044
|
+
if (!options.kcAction) {
|
|
1045
|
+
const recovery = await createOAuthRecoveryForLogin(request, {
|
|
1046
|
+
state,
|
|
1047
|
+
returnUrl
|
|
1048
|
+
}, config.redirectUri);
|
|
1049
|
+
recoveryTicket = recovery.ticket;
|
|
1050
|
+
recoveryCookie = recovery.cookie;
|
|
1051
|
+
}
|
|
703
1052
|
const authorizationParameters = {
|
|
704
1053
|
redirect_uri: config.redirectUri,
|
|
705
1054
|
scope: config.scope,
|
|
@@ -713,7 +1062,8 @@ async function login(request, returnUrlOrOptions) {
|
|
|
713
1062
|
if (options.kcAction) authorizationParameters.kc_action = options.kcAction;
|
|
714
1063
|
const authorizationUrl = oidc.buildAuthorizationUrl(oidcConfig, authorizationParameters);
|
|
715
1064
|
const headers = new Headers();
|
|
716
|
-
headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE,
|
|
1065
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, oauthStateId, { maxAge: OAUTH_STATE_TTL_SECONDS }));
|
|
1066
|
+
if (recoveryCookie) headers.append("Set-Cookie", recoveryCookie);
|
|
717
1067
|
logger.info("OIDC authorization flow initiated", {
|
|
718
1068
|
clientId: config.clientId,
|
|
719
1069
|
redirectUri: config.redirectUri,
|
|
@@ -725,6 +1075,16 @@ async function login(request, returnUrlOrOptions) {
|
|
|
725
1075
|
});
|
|
726
1076
|
return createRedirectResponse(authorizationUrl.toString(), { headers });
|
|
727
1077
|
} catch (error) {
|
|
1078
|
+
if (oauthStateId) try {
|
|
1079
|
+
await deleteOAuthState(oauthStateId);
|
|
1080
|
+
} catch (cleanupError) {
|
|
1081
|
+
logger.warn("OAuth login state cleanup failed", { errorType: cleanupError instanceof Error ? cleanupError.name : "UnknownError" });
|
|
1082
|
+
}
|
|
1083
|
+
if (recoveryTicket) try {
|
|
1084
|
+
await deleteOAuthRecoveryRecord(recoveryTicket);
|
|
1085
|
+
} catch (cleanupError) {
|
|
1086
|
+
logger.warn("OAuth recovery intent cleanup failed", { errorType: cleanupError instanceof Error ? cleanupError.name : "UnknownError" });
|
|
1087
|
+
}
|
|
728
1088
|
logger.error("Failed to initiate OAuth login", void 0, { errorType: error instanceof Error ? error.name : "UnknownError" });
|
|
729
1089
|
throw new Error("Login failed");
|
|
730
1090
|
}
|
|
@@ -742,9 +1102,13 @@ async function login(request, returnUrlOrOptions) {
|
|
|
742
1102
|
* - Redirect to home to start fresh login
|
|
743
1103
|
*/
|
|
744
1104
|
async function handleCallback(request) {
|
|
1105
|
+
let stateId = null;
|
|
1106
|
+
let callbackStage = "configuration";
|
|
745
1107
|
try {
|
|
1108
|
+
stateId = getOAuthStateIdFromRequest(request);
|
|
746
1109
|
const config = getAuthConfig();
|
|
747
1110
|
const hasLandingPage = getEffectiveHasLandingPage(config);
|
|
1111
|
+
const requiresNonce = config.scope.split(/\s+/).includes("openid");
|
|
748
1112
|
const url = new URL(request.url);
|
|
749
1113
|
const code = url.searchParams.get("code");
|
|
750
1114
|
const state = url.searchParams.get("state");
|
|
@@ -752,28 +1116,33 @@ async function handleCallback(request) {
|
|
|
752
1116
|
const errorDescription = url.searchParams.get("error_description");
|
|
753
1117
|
const kcAction = url.searchParams.get("kc_action");
|
|
754
1118
|
const kcActionStatus = url.searchParams.get("kc_action_status");
|
|
1119
|
+
const hasCode = hasNonEmptyCallbackValue(code);
|
|
1120
|
+
const hasState = hasNonEmptyCallbackValue(state);
|
|
1121
|
+
const hasApplicationAction = Boolean(hasNonEmptyCallbackValue(kcAction) || hasNonEmptyCallbackValue(kcActionStatus));
|
|
755
1122
|
logger.info("OIDC callback received", {
|
|
756
|
-
hasCode
|
|
757
|
-
hasState
|
|
1123
|
+
hasCode,
|
|
1124
|
+
hasState,
|
|
758
1125
|
hasError: Boolean(error),
|
|
759
1126
|
hasErrorDescription: Boolean(errorDescription),
|
|
760
|
-
hasKcAction:
|
|
761
|
-
hasKcActionStatus:
|
|
1127
|
+
hasKcAction: hasNonEmptyCallbackValue(kcAction),
|
|
1128
|
+
hasKcActionStatus: hasNonEmptyCallbackValue(kcActionStatus),
|
|
762
1129
|
clientId: config.clientId,
|
|
763
1130
|
redirectUri: config.redirectUri
|
|
764
1131
|
});
|
|
765
1132
|
if (error) {
|
|
766
|
-
if (kcActionStatus &&
|
|
767
|
-
|
|
768
|
-
const oauthStateRegex = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
|
|
769
|
-
const stateId = (cookies?.match(oauthStateRegex))?.[1];
|
|
1133
|
+
if (kcActionStatus && hasState) {
|
|
1134
|
+
callbackStage = "state";
|
|
770
1135
|
if (stateId) {
|
|
771
|
-
const oauthState = await getOAuthState(stateId);
|
|
1136
|
+
const oauthState = await getOAuthState(stateId, { throwOnMalformed: true });
|
|
772
1137
|
const stateAge = oauthState ? Date.now() - oauthState.createdAt : Number.POSITIVE_INFINITY;
|
|
773
|
-
if (oauthState && oauthState.state === state && stateAge <=
|
|
1138
|
+
if (oauthState && isUsableOAuthState(oauthState, requiresNonce) && oauthState.state === state && stateAge <= OAUTH_STATE_TTL_MS) {
|
|
1139
|
+
callbackStage = "storage";
|
|
774
1140
|
await deleteOAuthState(stateId);
|
|
1141
|
+
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1142
|
+
callbackStage = "application_action";
|
|
775
1143
|
const headers = new Headers();
|
|
776
|
-
headers
|
|
1144
|
+
appendOAuthStateCookieClear(headers);
|
|
1145
|
+
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
777
1146
|
const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
778
1147
|
logger.info("Application initiated action returned with OAuth error", {
|
|
779
1148
|
hasError: Boolean(error),
|
|
@@ -794,9 +1163,13 @@ async function handleCallback(request) {
|
|
|
794
1163
|
});
|
|
795
1164
|
if (error === "access_denied") {
|
|
796
1165
|
logger.warn("Access denied for this application", { clientId: config.clientId });
|
|
1166
|
+
callbackStage = "storage";
|
|
1167
|
+
if (stateId) await deleteOAuthState(stateId);
|
|
797
1168
|
const headers = await destroyAuthSession(request);
|
|
1169
|
+
appendOAuthStateCookieClear(headers);
|
|
1170
|
+
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
798
1171
|
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, "access_denied", { maxAge: 300 }));
|
|
799
|
-
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_DESC_COOKIE,
|
|
1172
|
+
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_DESC_COOKIE, "You do not have access to this application.", { maxAge: 300 }));
|
|
800
1173
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
801
1174
|
logger.info("Redirecting to /auth/login to show access_denied error", {
|
|
802
1175
|
hasLandingPage,
|
|
@@ -807,30 +1180,51 @@ async function handleCallback(request) {
|
|
|
807
1180
|
}
|
|
808
1181
|
if (error === "login_required") {
|
|
809
1182
|
logger.info("Login required, user session may have expired");
|
|
1183
|
+
callbackStage = "storage";
|
|
1184
|
+
if (stateId) await deleteOAuthState(stateId);
|
|
810
1185
|
const headers = await destroyAuthSession(request);
|
|
1186
|
+
appendOAuthStateCookieClear(headers);
|
|
1187
|
+
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
811
1188
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
812
1189
|
return createRedirectResponse(baseUrl, { headers });
|
|
813
1190
|
}
|
|
1191
|
+
callbackStage = "storage";
|
|
1192
|
+
if (stateId) await deleteOAuthState(stateId);
|
|
814
1193
|
const headers = await destroyAuthSession(request);
|
|
1194
|
+
appendOAuthStateCookieClear(headers);
|
|
1195
|
+
appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
|
|
815
1196
|
headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, error, { maxAge: 300 }));
|
|
816
|
-
if (errorDescription) headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_DESC_COOKIE, errorDescription, { maxAge: 300 }));
|
|
817
1197
|
const baseUrl = new URL(config.redirectUri).origin;
|
|
818
1198
|
return createRedirectResponse(baseUrl, { headers });
|
|
819
1199
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
if (
|
|
829
|
-
|
|
1200
|
+
if (!hasState) {
|
|
1201
|
+
callbackStage = hasApplicationAction ? "application_action" : "state";
|
|
1202
|
+
throw new OAuthCallbackError(hasApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.MalformedCallback);
|
|
1203
|
+
}
|
|
1204
|
+
if (!hasCode && !hasApplicationAction) {
|
|
1205
|
+
callbackStage = "state";
|
|
1206
|
+
throw new OAuthCallbackError(OAuthCallbackFailureCodes.MalformedCallback);
|
|
1207
|
+
}
|
|
1208
|
+
if (!stateId) {
|
|
1209
|
+
callbackStage = hasApplicationAction ? "application_action" : "state";
|
|
1210
|
+
throw new OAuthCallbackError(hasApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.StaleTransaction);
|
|
1211
|
+
}
|
|
1212
|
+
callbackStage = "state";
|
|
1213
|
+
const oauthState = await getOAuthState(stateId, { throwOnMalformed: true });
|
|
1214
|
+
if (!oauthState) throw new OAuthCallbackError(hasApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.StaleTransaction);
|
|
1215
|
+
if (!isUsableOAuthState(oauthState, requiresNonce)) throw new OAuthCallbackError(OAuthCallbackFailureCodes.StorageError);
|
|
1216
|
+
if (oauthState.state !== state) throw new OAuthCallbackError(OAuthCallbackFailureCodes.StateMismatch);
|
|
1217
|
+
const isApplicationAction = Boolean(hasApplicationAction || oauthState.kcAction);
|
|
1218
|
+
if (Date.now() - oauthState.createdAt > OAUTH_STATE_TTL_MS) throw new OAuthCallbackError(isApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.StaleTransaction);
|
|
1219
|
+
if (!hasCode) {
|
|
830
1220
|
if (kcActionStatus) {
|
|
1221
|
+
callbackStage = "storage";
|
|
831
1222
|
await deleteOAuthState(stateId);
|
|
1223
|
+
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1224
|
+
callbackStage = "application_action";
|
|
832
1225
|
const headers = new Headers();
|
|
833
|
-
headers
|
|
1226
|
+
appendOAuthStateCookieClear(headers);
|
|
1227
|
+
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
834
1228
|
const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
|
|
835
1229
|
logger.info("Application initiated action returned without authorization code", {
|
|
836
1230
|
hasKcAction: Boolean(kcAction || oauthState.kcAction),
|
|
@@ -838,11 +1232,13 @@ async function handleCallback(request) {
|
|
|
838
1232
|
});
|
|
839
1233
|
return createRedirectResponse(redirectUrl, { headers });
|
|
840
1234
|
}
|
|
841
|
-
throw new
|
|
1235
|
+
throw new OAuthCallbackError(isApplicationAction ? OAuthCallbackFailureCodes.ApplicationActionFailed : OAuthCallbackFailureCodes.MalformedCallback);
|
|
842
1236
|
}
|
|
843
1237
|
logger.info("Initiating token exchange");
|
|
1238
|
+
callbackStage = "configuration";
|
|
844
1239
|
const oidcConfig = await getOAuthConfig();
|
|
845
1240
|
const authorizationResponseUrl = createAuthorizationResponseUrl(request.url, config.redirectUri);
|
|
1241
|
+
callbackStage = "token_exchange";
|
|
846
1242
|
const tokenResult = await oidc.authorizationCodeGrant(oidcConfig, authorizationResponseUrl, {
|
|
847
1243
|
pkceCodeVerifier: oauthState.codeVerifier,
|
|
848
1244
|
expectedState: oauthState.state,
|
|
@@ -850,22 +1246,26 @@ async function handleCallback(request) {
|
|
|
850
1246
|
idTokenExpected: config.scope.split(/\s+/).includes("openid")
|
|
851
1247
|
});
|
|
852
1248
|
logger.info("Token exchange completed successfully");
|
|
853
|
-
|
|
1249
|
+
callbackStage = "session_creation";
|
|
1250
|
+
const newSessionData = await createSessionData(tokenResult, getClaimsFromTokenResponse(tokenResult));
|
|
1251
|
+
callbackStage = "session_creation";
|
|
1252
|
+
const sessionHeaders = await createAuthSession(request, newSessionData);
|
|
1253
|
+
callbackStage = "storage";
|
|
854
1254
|
await deleteOAuthState(stateId);
|
|
855
|
-
const
|
|
856
|
-
headers
|
|
1255
|
+
const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
|
|
1256
|
+
const headers = new Headers();
|
|
1257
|
+
appendHeadersPreservingSetCookie(headers, sessionHeaders);
|
|
1258
|
+
appendOAuthStateCookieClear(headers);
|
|
1259
|
+
appendHeadersPreservingSetCookie(headers, recoveryHeaders);
|
|
857
1260
|
logger.info("OAuth callback processed successfully");
|
|
858
1261
|
return createRedirectResponse(appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus), { headers });
|
|
859
1262
|
} catch (error) {
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
} catch {}
|
|
867
|
-
await destroyAuthSession(request);
|
|
868
|
-
throw new Error("Authentication failed");
|
|
1263
|
+
const failureCode = getCallbackFailureCode(error, callbackStage);
|
|
1264
|
+
logger.error("OAuth callback failed", void 0, {
|
|
1265
|
+
failureCode,
|
|
1266
|
+
errorType: error instanceof Error ? error.name : "UnknownError"
|
|
1267
|
+
});
|
|
1268
|
+
throw await createOAuthCallbackFailure(request, stateId, failureCode);
|
|
869
1269
|
}
|
|
870
1270
|
}
|
|
871
1271
|
/**
|
|
@@ -1439,6 +1839,6 @@ function shouldAttemptTokenRefresh(response, hasAuth) {
|
|
|
1439
1839
|
return hasAuth && isTokenExpiredResponse(response);
|
|
1440
1840
|
}
|
|
1441
1841
|
//#endregion
|
|
1442
|
-
export { resetAuthClaimMapping as A, handleCallback as C, logout as D, login as E, refreshTokens as O, handleBackChannelLogout as S, hasAuthError as T, createAuthorizationResponseUrl as _, configureRouteProtection as a, getAuthError as b, protectRoute as c, resetRouteProtectionConfig as d, shouldRefreshToken as f, configureAuthClaimMapping as g, clearAuthServerCache as h, authRoute as i, requireAuth as k, protectedRoute as l, clearAuthErrorHeaders as m, isTokenExpiredResponse as n, onboardingRoute as o, subscriptionRoute as p, shouldAttemptTokenRefresh as r, policyRoute as s, attemptTokenRefresh as t, publicRoute as u, extractUserInfo as v, handleFrontChannelLogout as w, getUser as x, getAccessToken as y };
|
|
1842
|
+
export { resetAuthClaimMapping as A, handleCallback as C, logout as D, login as E, OAuthCallbackError as F, OAuthCallbackFailureCodes as I, isOAuthCallbackError as L, clearOAuthRecoveryIntentHeaders as M, consumeOAuthRecoveryIntent as N, refreshTokens as O, getOAuthRecoveryCookiePath as P, isRecoverableOAuthCallbackError as R, handleBackChannelLogout as S, hasAuthError as T, createAuthorizationResponseUrl as _, configureRouteProtection as a, getAuthError as b, protectRoute as c, resetRouteProtectionConfig as d, shouldRefreshToken as f, configureAuthClaimMapping as g, clearAuthServerCache as h, authRoute as i, OAUTH_RECOVERY_COOKIE_NAME as j, requireAuth as k, protectedRoute as l, clearAuthErrorHeaders as m, isTokenExpiredResponse as n, onboardingRoute as o, subscriptionRoute as p, shouldAttemptTokenRefresh as r, policyRoute as s, attemptTokenRefresh as t, publicRoute as u, extractUserInfo as v, handleFrontChannelLogout as w, getUser as x, getAccessToken as y };
|
|
1443
1843
|
|
|
1444
|
-
//# sourceMappingURL=server-
|
|
1844
|
+
//# sourceMappingURL=server-BJTz5-BE.mjs.map
|