@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.
@@ -1,5 +1,5 @@
1
1
  import { logger } from "./logging/index.mjs";
2
- import { S as updateAuthSession, a as createOAuthState, d as destroyAuthSessionsBySid, f as destroyAuthSessionsForUser, g as getOAuthState, i as createAuthSession, l as destroyAuthSessionsByIdentitySession, o as deleteOAuthState, p as getAuthSession, s as destroyAuthSession, t as AUTH_ERROR_COOKIE_PREFIX, v as listAuthSessionDataForUser } from "./redis-session-storage.server-n0lWWyoh.mjs";
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", error instanceof Error ? error : void 0, {
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(`OIDC discovery failed: ${getOAuthErrorMessage(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
- const stateId = await createOAuthState(oauthState);
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, stateId, { maxAge: 600 }));
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: !!code,
757
- hasState: !!state,
1123
+ hasCode,
1124
+ hasState,
758
1125
  hasError: Boolean(error),
759
1126
  hasErrorDescription: Boolean(errorDescription),
760
- hasKcAction: Boolean(kcAction),
761
- hasKcActionStatus: Boolean(kcActionStatus),
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 && state) {
767
- const cookies = request.headers.get("Cookie");
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 <= 600 * 1e3) {
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.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, "", { maxAge: 0 }));
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, errorDescription || "You do not have access to this application.", { maxAge: 300 }));
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
- const cookies = request.headers.get("Cookie");
821
- const oauthStateRegex = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
822
- const stateId = (cookies?.match(oauthStateRegex))?.[1];
823
- if (!stateId) throw new Error("OAuth state ID not found in cookie");
824
- const oauthState = await getOAuthState(stateId);
825
- if (!oauthState) throw new Error("No OAuth state found - state may have expired");
826
- if (!state) throw new Error("Missing required callback state parameter");
827
- if (oauthState.state !== state) throw new Error(`OAuth state mismatch`);
828
- if (Date.now() - oauthState.createdAt > 600 * 1e3) throw new Error("OAuth state expired");
829
- if (!code) {
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.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, "", { maxAge: 0 }));
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 Error("Missing required callback code parameter");
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
- const sessionHeaders = await createAuthSession(request, await createSessionData(tokenResult, getClaimsFromTokenResponse(tokenResult)));
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 headers = new Headers(sessionHeaders);
856
- headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, "", { maxAge: 0 }));
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
- logger.error("OAuth callback failed", void 0, { errorType: error instanceof Error ? error.name : "UnknownError" });
861
- try {
862
- const cookies = request.headers.get("Cookie");
863
- const oauthStateRegex = new RegExp(`${OAUTH_STATE_COOKIE}=([^;]+)`);
864
- const stateId = (cookies?.match(oauthStateRegex))?.[1];
865
- if (stateId) await deleteOAuthState(stateId);
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-C3_iNZfj.mjs.map
1844
+ //# sourceMappingURL=server-BJTz5-BE.mjs.map