@eminuckan/spine 0.3.9 → 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 { C as OAUTH_STATE_TTL_MS, 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, w as OAUTH_STATE_TTL_SECONDS } from "./redis-session-storage.server-CI_IYIn8.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
@@ -150,7 +150,7 @@ function isOAuthCallbackFailureCode(value) {
150
150
  function isHeadersLike(value) {
151
151
  return value !== null && typeof value === "object" && typeof value.get === "function" && typeof value.append === "function";
152
152
  }
153
- function getSetCookieValues(headers) {
153
+ function getSetCookieValues$1(headers) {
154
154
  const getSetCookie = headers.getSetCookie;
155
155
  if (getSetCookie) return getSetCookie.call(headers);
156
156
  return Array.from(headers).reduce((cookies, [name, value]) => {
@@ -175,7 +175,7 @@ var OAuthCallbackError = class extends Error {
175
175
  this.name = "OAuthCallbackError";
176
176
  this.code = code;
177
177
  this.cleanupHeaders = new Headers(cleanupHeaders);
178
- this.cleanupSetCookies = getSetCookieValues(this.cleanupHeaders);
178
+ this.cleanupSetCookies = getSetCookieValues$1(this.cleanupHeaders);
179
179
  Object.defineProperty(this, OAUTH_CALLBACK_ERROR_BRAND, {
180
180
  configurable: false,
181
181
  enumerable: false,
@@ -201,6 +201,199 @@ function isRecoverableOAuthCallbackError(error) {
201
201
  return isOAuthCallbackError(error) && error.code === "stale_transaction";
202
202
  }
203
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
204
397
  //#region src/auth/auth.server.ts
205
398
  /**
206
399
  * OpenID Connect Authentication Server Module
@@ -781,6 +974,7 @@ function getCallbackFailureCode(error, stage) {
781
974
  async function createOAuthCallbackFailure(request, stateId, failureCode) {
782
975
  const cleanupHeaders = new Headers();
783
976
  let cleanupError;
977
+ const preserveRecoveryTicket = failureCode === OAuthCallbackFailureCodes.StaleTransaction && isOAuthRecoveryEligibleCallback(request);
784
978
  if (stateId) try {
785
979
  await deleteOAuthState(stateId);
786
980
  } catch (error) {
@@ -791,6 +985,12 @@ async function createOAuthCallbackFailure(request, stateId, failureCode) {
791
985
  } catch (error) {
792
986
  cleanupError ??= error;
793
987
  }
988
+ if (!preserveRecoveryTicket || cleanupError) try {
989
+ appendHeadersPreservingSetCookie(cleanupHeaders, await discardOAuthRecoveryForRequest(request));
990
+ } catch (error) {
991
+ cleanupError ??= error;
992
+ appendHeadersPreservingSetCookie(cleanupHeaders, clearOAuthRecoveryIntentHeaders(request));
993
+ }
794
994
  appendOAuthStateCookieClear(cleanupHeaders);
795
995
  const effectiveFailureCode = cleanupError ? OAuthCallbackFailureCodes.StorageError : failureCode;
796
996
  if (cleanupError) logger.warn("OAuth callback cleanup failed", {
@@ -806,6 +1006,9 @@ async function createOAuthCallbackFailure(request, stateId, failureCode) {
806
1006
  * @param returnUrlOrOptions - Either a return URL string or LoginOptions object
807
1007
  */
808
1008
  async function login(request, returnUrlOrOptions) {
1009
+ let oauthStateId = null;
1010
+ let recoveryTicket = null;
1011
+ let recoveryCookie = null;
809
1012
  try {
810
1013
  const config = getAuthConfig();
811
1014
  const options = typeof returnUrlOrOptions === "string" ? { returnUrl: returnUrlOrOptions } : returnUrlOrOptions || {};
@@ -837,7 +1040,15 @@ async function login(request, returnUrlOrOptions) {
837
1040
  hasKcAction: Boolean(options.kcAction),
838
1041
  extraAuthParamKeys: Object.keys(extraAuthParams)
839
1042
  });
840
- 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
+ }
841
1052
  const authorizationParameters = {
842
1053
  redirect_uri: config.redirectUri,
843
1054
  scope: config.scope,
@@ -851,7 +1062,8 @@ async function login(request, returnUrlOrOptions) {
851
1062
  if (options.kcAction) authorizationParameters.kc_action = options.kcAction;
852
1063
  const authorizationUrl = oidc.buildAuthorizationUrl(oidcConfig, authorizationParameters);
853
1064
  const headers = new Headers();
854
- headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, stateId, { maxAge: OAUTH_STATE_TTL_SECONDS }));
1065
+ headers.append("Set-Cookie", serializeTemporaryAuthCookie(OAUTH_STATE_COOKIE, oauthStateId, { maxAge: OAUTH_STATE_TTL_SECONDS }));
1066
+ if (recoveryCookie) headers.append("Set-Cookie", recoveryCookie);
855
1067
  logger.info("OIDC authorization flow initiated", {
856
1068
  clientId: config.clientId,
857
1069
  redirectUri: config.redirectUri,
@@ -863,6 +1075,16 @@ async function login(request, returnUrlOrOptions) {
863
1075
  });
864
1076
  return createRedirectResponse(authorizationUrl.toString(), { headers });
865
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
+ }
866
1088
  logger.error("Failed to initiate OAuth login", void 0, { errorType: error instanceof Error ? error.name : "UnknownError" });
867
1089
  throw new Error("Login failed");
868
1090
  }
@@ -916,9 +1138,11 @@ async function handleCallback(request) {
916
1138
  if (oauthState && isUsableOAuthState(oauthState, requiresNonce) && oauthState.state === state && stateAge <= OAUTH_STATE_TTL_MS) {
917
1139
  callbackStage = "storage";
918
1140
  await deleteOAuthState(stateId);
1141
+ const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
919
1142
  callbackStage = "application_action";
920
1143
  const headers = new Headers();
921
1144
  appendOAuthStateCookieClear(headers);
1145
+ appendHeadersPreservingSetCookie(headers, recoveryHeaders);
922
1146
  const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
923
1147
  logger.info("Application initiated action returned with OAuth error", {
924
1148
  hasError: Boolean(error),
@@ -943,6 +1167,7 @@ async function handleCallback(request) {
943
1167
  if (stateId) await deleteOAuthState(stateId);
944
1168
  const headers = await destroyAuthSession(request);
945
1169
  appendOAuthStateCookieClear(headers);
1170
+ appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
946
1171
  headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, "access_denied", { maxAge: 300 }));
947
1172
  headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_DESC_COOKIE, "You do not have access to this application.", { maxAge: 300 }));
948
1173
  const baseUrl = new URL(config.redirectUri).origin;
@@ -959,6 +1184,7 @@ async function handleCallback(request) {
959
1184
  if (stateId) await deleteOAuthState(stateId);
960
1185
  const headers = await destroyAuthSession(request);
961
1186
  appendOAuthStateCookieClear(headers);
1187
+ appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
962
1188
  const baseUrl = new URL(config.redirectUri).origin;
963
1189
  return createRedirectResponse(baseUrl, { headers });
964
1190
  }
@@ -966,6 +1192,7 @@ async function handleCallback(request) {
966
1192
  if (stateId) await deleteOAuthState(stateId);
967
1193
  const headers = await destroyAuthSession(request);
968
1194
  appendOAuthStateCookieClear(headers);
1195
+ appendHeadersPreservingSetCookie(headers, await discardOAuthRecoveryForRequest(request));
969
1196
  headers.append("Set-Cookie", serializeTemporaryAuthCookie(AUTH_ERROR_COOKIE, error, { maxAge: 300 }));
970
1197
  const baseUrl = new URL(config.redirectUri).origin;
971
1198
  return createRedirectResponse(baseUrl, { headers });
@@ -993,9 +1220,11 @@ async function handleCallback(request) {
993
1220
  if (kcActionStatus) {
994
1221
  callbackStage = "storage";
995
1222
  await deleteOAuthState(stateId);
1223
+ const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
996
1224
  callbackStage = "application_action";
997
1225
  const headers = new Headers();
998
1226
  appendOAuthStateCookieClear(headers);
1227
+ appendHeadersPreservingSetCookie(headers, recoveryHeaders);
999
1228
  const redirectUrl = appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus);
1000
1229
  logger.info("Application initiated action returned without authorization code", {
1001
1230
  hasKcAction: Boolean(kcAction || oauthState.kcAction),
@@ -1023,9 +1252,11 @@ async function handleCallback(request) {
1023
1252
  const sessionHeaders = await createAuthSession(request, newSessionData);
1024
1253
  callbackStage = "storage";
1025
1254
  await deleteOAuthState(stateId);
1255
+ const recoveryHeaders = await discardOAuthRecoveryForRequest(request);
1026
1256
  const headers = new Headers();
1027
1257
  appendHeadersPreservingSetCookie(headers, sessionHeaders);
1028
1258
  appendOAuthStateCookieClear(headers);
1259
+ appendHeadersPreservingSetCookie(headers, recoveryHeaders);
1029
1260
  logger.info("OAuth callback processed successfully");
1030
1261
  return createRedirectResponse(appendApplicationInitiatedActionResult(sanitizeOAuthReturnUrl(oauthState.returnUrl, request) ?? "/", kcAction || oauthState.kcAction, kcActionStatus), { headers });
1031
1262
  } catch (error) {
@@ -1608,6 +1839,6 @@ function shouldAttemptTokenRefresh(response, hasAuth) {
1608
1839
  return hasAuth && isTokenExpiredResponse(response);
1609
1840
  }
1610
1841
  //#endregion
1611
- export { resetAuthClaimMapping as A, handleCallback as C, logout as D, login as E, OAuthCallbackFailureCodes as M, isOAuthCallbackError as N, refreshTokens as O, isRecoverableOAuthCallbackError as P, 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, OAuthCallbackError 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 };
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 };
1612
1843
 
1613
- //# sourceMappingURL=server-Cw86FdT-.mjs.map
1844
+ //# sourceMappingURL=server-BJTz5-BE.mjs.map