@eventuras/fides-auth 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/README.md +225 -33
  2. package/dist/activity-tracker.d.ts +1 -1
  3. package/dist/activity-tracker.d.ts.map +1 -1
  4. package/dist/activity-tracker.js +1 -2
  5. package/dist/cookies.d.ts +6 -0
  6. package/dist/cookies.d.ts.map +1 -1
  7. package/dist/cookies.js +8 -3
  8. package/dist/cookies.js.map +1 -1
  9. package/dist/heartbeat.d.ts +1 -1
  10. package/dist/heartbeat.js +1 -2
  11. package/dist/id-token.d.ts +31 -0
  12. package/dist/id-token.d.ts.map +1 -0
  13. package/dist/id-token.js +60 -0
  14. package/dist/id-token.js.map +1 -0
  15. package/dist/index.js +3 -3
  16. package/dist/logger.js +1 -2
  17. package/dist/oauth-browser.d.ts +13 -10
  18. package/dist/oauth-browser.d.ts.map +1 -1
  19. package/dist/oauth-browser.js +4 -2
  20. package/dist/oauth-browser.js.map +1 -1
  21. package/dist/oauth-config.d.ts +70 -0
  22. package/dist/oauth-config.d.ts.map +1 -0
  23. package/dist/oauth-logging.d.ts +6 -5
  24. package/dist/oauth-logging.d.ts.map +1 -1
  25. package/dist/oauth-logging.js +5 -5
  26. package/dist/oauth-logging.js.map +1 -1
  27. package/dist/oauth.d.ts +48 -46
  28. package/dist/oauth.d.ts.map +1 -1
  29. package/dist/oauth.js +58 -29
  30. package/dist/oauth.js.map +1 -1
  31. package/dist/oidc-discovery.d.ts +13 -4
  32. package/dist/oidc-discovery.d.ts.map +1 -1
  33. package/dist/oidc-discovery.js +82 -15
  34. package/dist/oidc-discovery.js.map +1 -1
  35. package/dist/pkce.d.ts +10 -0
  36. package/dist/pkce.d.ts.map +1 -0
  37. package/dist/pkce.js +23 -0
  38. package/dist/pkce.js.map +1 -0
  39. package/dist/providers/vipps/client.d.ts +4 -3
  40. package/dist/providers/vipps/client.d.ts.map +1 -1
  41. package/dist/providers/vipps/index.js +10 -10
  42. package/dist/providers/vipps/index.js.map +1 -1
  43. package/dist/providers/vipps/types.d.ts +10 -10
  44. package/dist/providers/vipps/types.d.ts.map +1 -1
  45. package/dist/rate-limit.d.ts.map +1 -1
  46. package/dist/rate-limit.js +1 -2
  47. package/dist/server/authorized-fetch.d.ts +78 -0
  48. package/dist/server/authorized-fetch.d.ts.map +1 -0
  49. package/dist/server/cookie-store.d.ts +1 -1
  50. package/dist/server/heartbeat-handler.d.ts +7 -5
  51. package/dist/server/heartbeat-handler.d.ts.map +1 -1
  52. package/dist/server/index.d.ts +4 -0
  53. package/dist/server/index.d.ts.map +1 -1
  54. package/dist/server/index.js +744 -94
  55. package/dist/server/index.js.map +1 -1
  56. package/dist/server/login-transaction.d.ts +55 -0
  57. package/dist/server/login-transaction.d.ts.map +1 -0
  58. package/dist/server/oidc-callback.d.ts +14 -5
  59. package/dist/server/oidc-callback.d.ts.map +1 -1
  60. package/dist/server/oidc-login.d.ts +15 -6
  61. package/dist/server/oidc-login.d.ts.map +1 -1
  62. package/dist/server/oidc-logout.d.ts +7 -5
  63. package/dist/server/oidc-logout.d.ts.map +1 -1
  64. package/dist/server/session-persistence.d.ts +102 -0
  65. package/dist/server/session-persistence.d.ts.map +1 -0
  66. package/dist/server/session-store.d.ts +39 -0
  67. package/dist/server/session-store.d.ts.map +1 -0
  68. package/dist/server/session.d.ts +38 -31
  69. package/dist/server/session.d.ts.map +1 -1
  70. package/dist/session-cookies.d.ts +2 -1
  71. package/dist/session-cookies.d.ts.map +1 -1
  72. package/dist/session-cookies.js +4 -3
  73. package/dist/session-cookies.js.map +1 -1
  74. package/dist/session-events.d.ts +16 -9
  75. package/dist/session-events.d.ts.map +1 -1
  76. package/dist/session-events.js +4 -3
  77. package/dist/session-events.js.map +1 -1
  78. package/dist/session-refresh.d.ts +2 -2
  79. package/dist/session-refresh.d.ts.map +1 -1
  80. package/dist/session-refresh.js +1 -2
  81. package/dist/session-refresh.js.map +1 -1
  82. package/dist/session-validation.d.ts.map +1 -1
  83. package/dist/session-validation.js +9 -6
  84. package/dist/session-validation.js.map +1 -1
  85. package/dist/silent-login.d.ts +14 -5
  86. package/dist/silent-login.d.ts.map +1 -1
  87. package/dist/silent-login.js +3 -3
  88. package/dist/silent-login.js.map +1 -1
  89. package/dist/types.d.ts +4 -1
  90. package/dist/types.d.ts.map +1 -1
  91. package/dist/utils.d.ts +1 -15
  92. package/dist/utils.d.ts.map +1 -1
  93. package/dist/utils.js +2 -24
  94. package/dist/utils.js.map +1 -1
  95. package/docs/discovery-cache.md +7 -3
  96. package/docs/rp-initiated-logout.md +6 -3
  97. package/docs/server-sessions.md +154 -0
  98. package/docs/session-events.md +17 -16
  99. package/package.json +18 -10
@@ -1 +1 @@
1
- {"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { OAuthConfig, refreshAccessToken } from \"./oauth\";\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n // A refresh doesn't always return id_token, and a stale hint still works.\n idToken: newtokens.id_token ?? current_session.tokens?.idToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAKA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;;;AAWvE,IAAa,iBAAiB,OAC5B,iBACA,QACA,UAAgC,CAAC,MACL;CAC5B,OAAO,MAAM,0BAA0B;CAEvC,IAAI,CAAC,gBAAgB,QAAQ,cAAc;EACzC,OAAO,MAAM,uCAAuC;EACpD,MAAM,IAAI,MAAM,4BAA4B;CAC9C;CAEA,IAAI;EACF,MAAM,YAAY,MAAM,mBAAmB,QAAQ,gBAAgB,OAAO,YAAY;EAEtF,MAAM,iBAAiB;GACrB,GAAG;GACH,QAAQ;IACN,GAAG,gBAAgB;IACnB,aAAa,UAAU;IACvB,sBAAsB,UAAU,aAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,UAAU,aAAa,GAAI,CAAC,CAAC,YAAY,IAC/D,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;IAEjE,SAAS,UAAU,YAAY,gBAAgB,QAAQ;GACzD;EACF;EAEA,OAAO,KAAK;GACV,mBAAmB,CAAC,CAAC,eAAe,OAAO;GAC3C,oBAAoB,CAAC,CAAC,UAAU;GAChC,WAAW,UAAU;EACvB,GAAG,gCAAgC;EAEnC,OAAO;CACT,SAAS,OAAO;EAEd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KAAK,2DAA2D;OAGvE,OAAO,MAAM,EAAE,OAAO,aAAa,GAAG,wBAAwB;EAGhE,MAAM;CACR;AACF"}
1
+ {"version":3,"file":"session-refresh.js","names":[],"sources":["../src/session-refresh.ts"],"sourcesContent":["import { createLogger } from './logger';\nimport { refreshAccessToken } from './oauth';\nimport type { OAuthConfig } from './oauth-config';\nimport { getOAuthErrorLogContext } from './oauth-logging';\nimport { CreateSessionOptions, Session } from \"./types\";\n\nconst logger = createLogger({ namespace: 'fides-auth:session-refresh' });\n\n/**\n * Refreshes an existing session by exchanging its refresh token for new tokens.\n *\n * @param current_session - The current session containing a refresh token\n * @param config - OAuth configuration for the identity provider\n * @param options - Optional session creation options\n * @returns The updated session with new tokens, or throws on failure\n * @throws Error if no refresh token is available or the token exchange fails\n */\nexport const refreshSession = async (\n current_session: Session,\n config: OAuthConfig,\n options: CreateSessionOptions = {}\n): Promise<Session | null> => {\n logger.debug('Starting session refresh');\n\n if (!current_session.tokens?.refreshToken) {\n logger.error('No refresh token available in session');\n throw new Error('No refresh token available');\n }\n\n try {\n const newtokens = await refreshAccessToken(config, current_session.tokens.refreshToken);\n\n const updatedSession = {\n ...current_session,\n tokens: {\n ...current_session.tokens,\n accessToken: newtokens.access_token,\n accessTokenExpiresAt: newtokens.expires_in\n ? new Date(Date.now() + newtokens.expires_in * 1000).toISOString()\n : undefined,\n refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,\n // A refresh doesn't always return id_token, and a stale hint still works.\n idToken: newtokens.id_token ?? current_session.tokens?.idToken,\n },\n };\n\n logger.info({\n hasNewAccessToken: !!updatedSession.tokens.accessToken,\n hasNewRefreshToken: !!newtokens.refresh_token,\n expiresIn: newtokens.expires_in,\n }, 'Session refreshed successfully');\n\n return updatedSession;\n } catch (error) {\n // Check if this is an expected invalid_grant error\n const errorContext = getOAuthErrorLogContext(error);\n const isInvalidGrant =\n errorContext.code === 'OAUTH_RESPONSE_BODY_ERROR' && errorContext.error === 'invalid_grant';\n\n if (isInvalidGrant) {\n // Expected error during logout/session expiry - log at info level\n logger.info('Session refresh failed - refresh token expired or invalid');\n } else {\n // Unexpected error - log at error level\n logger.error({ error: errorContext }, 'Session refresh failed');\n }\n\n throw error;\n }\n};\n\n"],"mappings":";;;;AAMA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;;;AAWvE,IAAa,iBAAiB,OAC5B,iBACA,QACA,UAAgC,CAAC,MACL;CAC5B,OAAO,MAAM,0BAA0B;CAEvC,IAAI,CAAC,gBAAgB,QAAQ,cAAc;EACzC,OAAO,MAAM,uCAAuC;EACpD,MAAM,IAAI,MAAM,4BAA4B;CAC9C;CAEA,IAAI;EACF,MAAM,YAAY,MAAM,mBAAmB,QAAQ,gBAAgB,OAAO,YAAY;EAEtF,MAAM,iBAAiB;GACrB,GAAG;GACH,QAAQ;IACN,GAAG,gBAAgB;IACnB,aAAa,UAAU;IACvB,sBAAsB,UAAU,aAC5B,IAAI,KAAK,KAAK,IAAI,IAAI,UAAU,aAAa,GAAI,CAAC,CAAC,YAAY,IAC/D,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;IAEjE,SAAS,UAAU,YAAY,gBAAgB,QAAQ;GACzD;EACF;EAEA,OAAO,KAAK;GACV,mBAAmB,CAAC,CAAC,eAAe,OAAO;GAC3C,oBAAoB,CAAC,CAAC,UAAU;GAChC,WAAW,UAAU;EACvB,GAAG,gCAAgC;EAEnC,OAAO;CACT,SAAS,OAAO;EAEd,MAAM,eAAe,wBAAwB,KAAK;EAIlD,IAFE,aAAa,SAAS,+BAA+B,aAAa,UAAU,iBAI5E,OAAO,KAAK,2DAA2D;OAGvE,OAAO,MAAM,EAAE,OAAO,aAAa,GAAG,wBAAwB;EAGhE,MAAM;CACR;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"session-validation.d.ts","sourceRoot":"","sources":["../src/session-validation.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIlC,qDAAqD;AACrD,MAAM,WAAW,uBAAuB;IACtC,qCAAqC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,+EAA+E;IAC/E,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IACxC,+EAA+E;IAC/E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAgDlC;AAyBD,6FAA6F;AAC7F,wBAAgB,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,IAAI,OAAO,CAqB5D"}
1
+ {"version":3,"file":"session-validation.d.ts","sourceRoot":"","sources":["../src/session-validation.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIlC,qDAAqD;AACrD,MAAM,WAAW,uBAAuB;IACtC,qCAAqC;IACrC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,+EAA+E;IAC/E,MAAM,EAAE,OAAO,GAAG,SAAS,GAAG,SAAS,CAAC;IACxC,+EAA+E;IAC/E,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,sDAAsD;IACtD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED;;;;;;;GAOG;AACH,wBAAsB,kBAAkB,CACtC,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,uBAAuB,CAAC,CAgDlC;AAyBD,6FAA6F;AAC7F,wBAAgB,SAAS,CAAC,MAAM,EAAE,OAAO,GAAG,MAAM,IAAI,OAAO,CAW5D"}
@@ -1,7 +1,7 @@
1
1
  import { createLogger } from "./logger.js";
2
2
  import { hexToUint8Array } from "./utils.js";
3
3
  import { decodeJwt, jwtDecrypt } from "jose";
4
- //#region src/session-validation.ts
4
+ // src/session-validation.ts
5
5
  var logger = createLogger({ namespace: "fides-auth:session-validation" });
6
6
  /**
7
7
  * Validates an encrypted JWT and checks for expiration.
@@ -66,12 +66,15 @@ function computeAccessTokenExpiresIn(session) {
66
66
  }
67
67
  /** Type guard that checks whether an unknown value conforms to the {@link Session} shape. */
68
68
  function isSession(object) {
69
- if (typeof object !== "object" || object === null) return false;
70
- if ("tokens" in object && typeof object.tokens !== "object") return false;
71
- if ("user" in object && typeof object.user !== "object") return false;
72
- return true;
69
+ if (!isObject(object)) return false;
70
+ const { tokens, user } = object;
71
+ if (tokens !== void 0 && !isObject(tokens)) return false;
72
+ if (user !== void 0 && !isObject(user)) return false;
73
+ return isObject(tokens) || isObject(user);
74
+ }
75
+ function isObject(value) {
76
+ return typeof value === "object" && value !== null;
73
77
  }
74
- //#endregion
75
78
  export { isSession, validateSessionJwt };
76
79
 
77
80
  //# sourceMappingURL=session-validation.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-validation.js","names":[],"sources":["../src/session-validation.ts"],"sourcesContent":["// session-validation.ts\n\nimport { jwtDecrypt, decodeJwt } from 'jose';\n\nimport { createLogger } from './logger';\nimport { hexToUint8Array } from './utils';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-validation' });\n\n/** Result of validating an encrypted session JWT. */\nexport interface SessionValidationResult {\n /** The decoded session, if valid. */\n session?: Session;\n /** Validation status: VALID, INVALID (decryption/format error), or EXPIRED. */\n status: 'VALID' | 'INVALID' | 'EXPIRED';\n /** Seconds until the access token expires (negative means already expired). */\n accessTokenExpiresIn?: number;\n /** Human-readable reason when status is not VALID. */\n reason?: string;\n}\n\n/**\n * Validates an encrypted JWT and checks for expiration.\n *\n * @param encryptedJwt The encrypted JWT string to validate\n * @param secret The decryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns Session and SessionValidationResult indicating whether it's valid or not,\n * plus accessTokenExpiresIn if valid\n */\nexport async function validateSessionJwt(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<SessionValidationResult> {\n logger.debug('Starting session JWT validation');\n\n const secretBytes = typeof secret === 'string'\n ? hexToUint8Array(secret)\n : secret;\n\n let payload: unknown;\n try {\n ({ payload } = await jwtDecrypt(encryptedJwt, secretBytes));\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed decrypting JWT';\n logger.warn({ reason: errorMessage }, 'Session JWT decryption failed');\n return {\n status: 'INVALID',\n reason: `Decryption error: ${errorMessage}`\n };\n }\n\n // Check if payload has the shape of a Session\n if (!isSession(payload)) {\n logger.warn('JWT payload does not match Session structure');\n return {\n status: 'INVALID',\n reason: 'jwt does not seem to include a session'\n };\n }\n\n logger.debug('Session validated successfully');\n\n // Check access token expiry if available\n const expiresIn = computeAccessTokenExpiresIn(payload);\n\n if (expiresIn !== undefined && expiresIn <= 0) {\n logger.info({ accessTokenExpiresIn: expiresIn }, 'Session access token has expired');\n return {\n session: payload,\n status: 'EXPIRED',\n accessTokenExpiresIn: expiresIn,\n reason: 'Access token has expired',\n };\n }\n\n return {\n session: payload,\n status: 'VALID',\n accessTokenExpiresIn: expiresIn,\n };\n}\n\n/**\n * Computes the number of seconds until the access token expires.\n * Returns undefined if no expiry can be determined.\n */\nfunction computeAccessTokenExpiresIn(session: Session): number | undefined {\n const accessToken = session.tokens?.accessToken;\n if (!accessToken) {\n return undefined;\n }\n\n try {\n const { exp } = decodeJwt(accessToken);\n if (typeof exp !== 'number') {\n return undefined;\n }\n return Math.floor(exp - Date.now() / 1000);\n } catch {\n // Not a valid JWT or can't decode — ignore\n return undefined;\n }\n}\n\n\n/** Type guard that checks whether an unknown value conforms to the {@link Session} shape. */\nexport function isSession(object: unknown): object is Session {\n if (typeof object !== 'object' || object === null) {\n return false;\n }\n\n if (\n 'tokens' in object &&\n typeof (object as any).tokens !== 'object'\n ) {\n return false;\n }\n\n if (\n 'user' in object &&\n typeof (object as any).user !== 'object'\n ) {\n return false;\n }\n\n // Seems to be a real session\n return true;\n}\n"],"mappings":";;;;AAQA,IAAM,SAAS,aAAa,EAAE,WAAW,gCAAgC,CAAC;;;;;;;;;AAsB1E,eAAsB,mBACpB,cACA,QACkC;CAClC,OAAO,MAAM,iCAAiC;CAE9C,MAAM,cAAc,OAAO,WAAW,WAClC,gBAAgB,MAAM,IACtB;CAEJ,IAAI;CACJ,IAAI;EACF,CAAC,CAAE,WAAY,MAAM,WAAW,cAAc,WAAW;CAC3D,SAAS,OAAO;EACd,MAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU;EAC9D,OAAO,KAAK,EAAE,QAAQ,aAAa,GAAG,+BAA+B;EACrE,OAAO;GACL,QAAQ;GACR,QAAQ,qBAAqB;EAC/B;CACF;CAGA,IAAI,CAAC,UAAU,OAAO,GAAG;EACvB,OAAO,KAAK,8CAA8C;EAC1D,OAAO;GACL,QAAQ;GACR,QAAQ;EACV;CACF;CAEA,OAAO,MAAM,gCAAgC;CAG7C,MAAM,YAAY,4BAA4B,OAAO;CAErD,IAAI,cAAc,KAAA,KAAa,aAAa,GAAG;EAC7C,OAAO,KAAK,EAAE,sBAAsB,UAAU,GAAG,kCAAkC;EACnF,OAAO;GACL,SAAS;GACT,QAAQ;GACR,sBAAsB;GACtB,QAAQ;EACV;CACF;CAEA,OAAO;EACL,SAAS;EACT,QAAQ;EACR,sBAAsB;CACxB;AACF;;;;;AAMA,SAAS,4BAA4B,SAAsC;CACzE,MAAM,cAAc,QAAQ,QAAQ;CACpC,IAAI,CAAC,aACH;CAGF,IAAI;EACF,MAAM,EAAE,QAAQ,UAAU,WAAW;EACrC,IAAI,OAAO,QAAQ,UACjB;EAEF,OAAO,KAAK,MAAM,MAAM,KAAK,IAAI,IAAI,GAAI;CAC3C,QAAQ;EAEN;CACF;AACF;;AAIA,SAAgB,UAAU,QAAoC;CAC5D,IAAI,OAAO,WAAW,YAAY,WAAW,MAC3C,OAAO;CAGT,IACE,YAAY,UACZ,OAAQ,OAAe,WAAW,UAElC,OAAO;CAGT,IACE,UAAU,UACV,OAAQ,OAAe,SAAS,UAEhC,OAAO;CAIT,OAAO;AACT"}
1
+ {"version":3,"file":"session-validation.js","names":[],"sources":["../src/session-validation.ts"],"sourcesContent":["// session-validation.ts\n\nimport { jwtDecrypt, decodeJwt } from 'jose';\n\nimport { createLogger } from './logger';\nimport { hexToUint8Array } from './utils';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-validation' });\n\n/** Result of validating an encrypted session JWT. */\nexport interface SessionValidationResult {\n /** The decoded session, if valid. */\n session?: Session;\n /** Validation status: VALID, INVALID (decryption/format error), or EXPIRED. */\n status: 'VALID' | 'INVALID' | 'EXPIRED';\n /** Seconds until the access token expires (negative means already expired). */\n accessTokenExpiresIn?: number;\n /** Human-readable reason when status is not VALID. */\n reason?: string;\n}\n\n/**\n * Validates an encrypted JWT and checks for expiration.\n *\n * @param encryptedJwt The encrypted JWT string to validate\n * @param secret The decryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns Session and SessionValidationResult indicating whether it's valid or not,\n * plus accessTokenExpiresIn if valid\n */\nexport async function validateSessionJwt(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<SessionValidationResult> {\n logger.debug('Starting session JWT validation');\n\n const secretBytes = typeof secret === 'string'\n ? hexToUint8Array(secret)\n : secret;\n\n let payload: unknown;\n try {\n ({ payload } = await jwtDecrypt(encryptedJwt, secretBytes));\n } catch (error) {\n const errorMessage = error instanceof Error ? error.message : 'Failed decrypting JWT';\n logger.warn({ reason: errorMessage }, 'Session JWT decryption failed');\n return {\n status: 'INVALID',\n reason: `Decryption error: ${errorMessage}`\n };\n }\n\n // Check if payload has the shape of a Session\n if (!isSession(payload)) {\n logger.warn('JWT payload does not match Session structure');\n return {\n status: 'INVALID',\n reason: 'jwt does not seem to include a session'\n };\n }\n\n logger.debug('Session validated successfully');\n\n // Check access token expiry if available\n const expiresIn = computeAccessTokenExpiresIn(payload);\n\n if (expiresIn !== undefined && expiresIn <= 0) {\n logger.info({ accessTokenExpiresIn: expiresIn }, 'Session access token has expired');\n return {\n session: payload,\n status: 'EXPIRED',\n accessTokenExpiresIn: expiresIn,\n reason: 'Access token has expired',\n };\n }\n\n return {\n session: payload,\n status: 'VALID',\n accessTokenExpiresIn: expiresIn,\n };\n}\n\n/**\n * Computes the number of seconds until the access token expires.\n * Returns undefined if no expiry can be determined.\n */\nfunction computeAccessTokenExpiresIn(session: Session): number | undefined {\n const accessToken = session.tokens?.accessToken;\n if (!accessToken) {\n return undefined;\n }\n\n try {\n const { exp } = decodeJwt(accessToken);\n if (typeof exp !== 'number') {\n return undefined;\n }\n return Math.floor(exp - Date.now() / 1000);\n } catch {\n // Not a valid JWT or can't decode — ignore\n return undefined;\n }\n}\n\n\n/** Type guard that checks whether an unknown value conforms to the {@link Session} shape. */\nexport function isSession(object: unknown): object is Session {\n if (!isObject(object)) {\n return false;\n }\n\n const { tokens, user } = object as { tokens?: unknown; user?: unknown };\n if (tokens !== undefined && !isObject(tokens)) return false;\n if (user !== undefined && !isObject(user)) return false;\n\n // Any other encrypted payload under the same key is not a session.\n return isObject(tokens) || isObject(user);\n}\n\nfunction isObject(value: unknown): value is object {\n return typeof value === 'object' && value !== null;\n}\n"],"mappings":";;;;AAQA,IAAM,SAAS,aAAa,EAAE,WAAW,gCAAgC,CAAC;;;;;;;;;AAsB1E,eAAsB,mBACpB,cACA,QACkC;CAClC,OAAO,MAAM,iCAAiC;CAE9C,MAAM,cAAc,OAAO,WAAW,WAClC,gBAAgB,MAAM,IACtB;CAEJ,IAAI;CACJ,IAAI;EACF,CAAC,CAAE,WAAY,MAAM,WAAW,cAAc,WAAW;CAC3D,SAAS,OAAO;EACd,MAAM,eAAe,iBAAiB,QAAQ,MAAM,UAAU;EAC9D,OAAO,KAAK,EAAE,QAAQ,aAAa,GAAG,+BAA+B;EACrE,OAAO;GACL,QAAQ;GACR,QAAQ,qBAAqB;EAC/B;CACF;CAGA,IAAI,CAAC,UAAU,OAAO,GAAG;EACvB,OAAO,KAAK,8CAA8C;EAC1D,OAAO;GACL,QAAQ;GACR,QAAQ;EACV;CACF;CAEA,OAAO,MAAM,gCAAgC;CAG7C,MAAM,YAAY,4BAA4B,OAAO;CAErD,IAAI,cAAc,KAAA,KAAa,aAAa,GAAG;EAC7C,OAAO,KAAK,EAAE,sBAAsB,UAAU,GAAG,kCAAkC;EACnF,OAAO;GACL,SAAS;GACT,QAAQ;GACR,sBAAsB;GACtB,QAAQ;EACV;CACF;CAEA,OAAO;EACL,SAAS;EACT,QAAQ;EACR,sBAAsB;CACxB;AACF;;;;;AAMA,SAAS,4BAA4B,SAAsC;CACzE,MAAM,cAAc,QAAQ,QAAQ;CACpC,IAAI,CAAC,aACH;CAGF,IAAI;EACF,MAAM,EAAE,QAAQ,UAAU,WAAW;EACrC,IAAI,OAAO,QAAQ,UACjB;EAEF,OAAO,KAAK,MAAM,MAAM,KAAK,IAAI,IAAI,GAAI;CAC3C,QAAQ;EAEN;CACF;AACF;;AAIA,SAAgB,UAAU,QAAoC;CAC5D,IAAI,CAAC,SAAS,MAAM,GAClB,OAAO;CAGT,MAAM,EAAE,QAAQ,SAAS;CACzB,IAAI,WAAW,KAAA,KAAa,CAAC,SAAS,MAAM,GAAG,OAAO;CACtD,IAAI,SAAS,KAAA,KAAa,CAAC,SAAS,IAAI,GAAG,OAAO;CAGlD,OAAO,SAAS,MAAM,KAAK,SAAS,IAAI;AAC1C;AAEA,SAAS,SAAS,OAAiC;CACjD,OAAO,OAAO,UAAU,YAAY,UAAU;AAChD"}
@@ -1,4 +1,13 @@
1
- import { OAuthConfig } from './oauth';
1
+ /**
2
+ * Silent login functionality for OAuth flows.
3
+ *
4
+ * Silent login (also called "prompt=none") attempts to authenticate the user
5
+ * without any user interaction. This is useful for:
6
+ * - Checking if a user has an active SSO session
7
+ * - Refreshing authentication silently in the background
8
+ * - Implementing "keep me logged in" features
9
+ */
10
+ import type { OAuthConfig } from './oauth-config';
2
11
  export interface SilentLoginOptions {
3
12
  /** Optional hint about the user's identity (e.g., email or user ID) */
4
13
  loginHint?: string;
@@ -78,10 +87,10 @@ export declare function checkSilentLoginResult(callbackUrl: URL): SilentLoginRes
78
87
  * - account_selection_required: User must select an account
79
88
  */
80
89
  export declare const SilentLoginErrors: {
81
- readonly LOGIN_REQUIRED: "login_required";
82
- readonly INTERACTION_REQUIRED: "interaction_required";
83
- readonly CONSENT_REQUIRED: "consent_required";
84
- readonly ACCOUNT_SELECTION_REQUIRED: "account_selection_required";
90
+ readonly LOGIN_REQUIRED: 'login_required';
91
+ readonly INTERACTION_REQUIRED: 'interaction_required';
92
+ readonly CONSENT_REQUIRED: 'consent_required';
93
+ readonly ACCOUNT_SELECTION_REQUIRED: 'account_selection_required';
85
94
  };
86
95
  export type SilentLoginError = typeof SilentLoginErrors[keyof typeof SilentLoginErrors];
87
96
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"silent-login.d.ts","sourceRoot":"","sources":["../src/silent-login.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAKH,OAAO,EAAE,WAAW,EAAE,MAAM,SAAS,CAAC;AAKtC,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,GAAG,CAAC;IACvB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,GAAG,CAAC,CA+Bd;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,GAAG,GAAG,iBAAiB,CA4B1E;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB;;;;;CAKpB,CAAC;AAEX,MAAM,MAAM,gBAAgB,GAAG,OAAO,iBAAiB,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;AAExF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI3E"}
1
+ {"version":3,"file":"silent-login.d.ts","sourceRoot":"","sources":["../src/silent-login.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAMH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAKlD,MAAM,WAAW,kBAAkB;IACjC,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kEAAkE;IAClE,gBAAgB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC3C;AAED,MAAM,WAAW,iBAAiB;IAChC,0CAA0C;IAC1C,OAAO,EAAE,OAAO,CAAC;IACjB,6DAA6D;IAC7D,gBAAgB,CAAC,EAAE,GAAG,CAAC;IACvB,2CAA2C;IAC3C,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,qDAAqD;IACrD,gBAAgB,CAAC,EAAE,MAAM,CAAC;CAC3B;AAED;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAsB,mBAAmB,CACvC,MAAM,EAAE,WAAW,EACnB,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,GAAG,CAAC,CA+Bd;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,wBAAgB,sBAAsB,CAAC,WAAW,EAAE,GAAG,GAAG,iBAAiB,CA4B1E;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,iBAAiB;aAC5B,cAAc,EAAE,gBAAgB;aAChC,oBAAoB,EAAE,sBAAsB;aAC5C,gBAAgB,EAAE,kBAAkB;aACpC,0BAA0B,EAAE,4BAA4B;CAChD,CAAC;AAEX,MAAM,MAAM,gBAAgB,GAAG,OAAO,iBAAiB,CAAC,MAAM,OAAO,iBAAiB,CAAC,CAAC;AAExF;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,MAAM,GAAG,SAAS,GAAG,OAAO,CAI3E"}
@@ -1,7 +1,8 @@
1
1
  import { createLogger } from "./logger.js";
2
2
  import { discoverConfiguration } from "./oidc-discovery.js";
3
+ import { joinScope } from "./oauth.js";
3
4
  import * as openid from "openid-client";
4
- //#region src/silent-login.ts
5
+ // src/silent-login.ts
5
6
  /**
6
7
  * Silent login functionality for OAuth flows.
7
8
  *
@@ -39,7 +40,7 @@ async function buildSilentLoginUrl(config, options = {}) {
39
40
  const server = await discoverConfiguration(config);
40
41
  const parameters = {
41
42
  redirect_uri: config.redirect_uri,
42
- scope: config.scope,
43
+ scope: joinScope(config.scope),
43
44
  prompt: "none",
44
45
  response_type: "code",
45
46
  ...options.additionalParams
@@ -145,7 +146,6 @@ function requiresInteractiveLogin(error) {
145
146
  if (!error) return false;
146
147
  return Object.values(SilentLoginErrors).includes(error);
147
148
  }
148
- //#endregion
149
149
  export { SilentLoginErrors, buildSilentLoginUrl, checkSilentLoginResult, requiresInteractiveLogin };
150
150
 
151
151
  //# sourceMappingURL=silent-login.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"silent-login.js","names":[],"sources":["../src/silent-login.ts"],"sourcesContent":["/**\n * Silent login functionality for OAuth flows.\n *\n * Silent login (also called \"prompt=none\") attempts to authenticate the user\n * without any user interaction. This is useful for:\n * - Checking if a user has an active SSO session\n * - Refreshing authentication silently in the background\n * - Implementing \"keep me logged in\" features\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { OAuthConfig } from './oauth';\nimport { discoverConfiguration } from './oidc-discovery';\n\nconst logger = createLogger({ namespace: 'fides-auth:silent-login' });\n\nexport interface SilentLoginOptions {\n /** Optional hint about the user's identity (e.g., email or user ID) */\n loginHint?: string;\n /** Maximum age of authentication in seconds */\n maxAge?: number;\n /** Additional parameters to pass to the authorization endpoint */\n additionalParams?: Record<string, string>;\n}\n\nexport interface SilentLoginResult {\n /** Whether silent login was successful */\n success: boolean;\n /** The authorization URL to use (only if success is true) */\n authorizationUrl?: URL;\n /** Error message if silent login failed */\n error?: string;\n /** Detailed error description from OAuth provider */\n errorDescription?: string;\n}\n\n/**\n * Builds an authorization URL for silent authentication.\n *\n * This function creates an authorization URL with prompt=none,\n * which tells the OAuth provider to authenticate without user interaction.\n * If the user doesn't have an active session, the provider will return an error.\n *\n * @param config - OAuth configuration\n * @param options - Silent login options\n * @returns Authorization URL for silent authentication\n *\n * @example\n * ```ts\n * const silentAuthUrl = await buildSilentLoginUrl(oauthConfig, {\n * loginHint: 'user@example.com',\n * maxAge: 3600, // Require authentication within last hour\n * });\n *\n * // Use this URL in an iframe or hidden window\n * ```\n */\nexport async function buildSilentLoginUrl(\n config: OAuthConfig,\n options: SilentLoginOptions = {}\n): Promise<URL> {\n logger.debug({ issuer: config.issuer }, 'Building silent login URL');\n\n try {\n const server = await discoverConfiguration(config);\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: config.scope,\n prompt: 'none', // Critical: tells provider to not show any UI\n response_type: 'code',\n ...options.additionalParams,\n };\n\n // Add optional parameters\n if (options.loginHint) {\n parameters.login_hint = options.loginHint;\n }\n\n if (options.maxAge !== undefined) {\n parameters.max_age = options.maxAge.toString();\n }\n\n const authUrl = openid.buildAuthorizationUrl(server, parameters);\n\n logger.info('Silent login URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error, issuer: config.issuer }, 'Failed to build silent login URL');\n throw error;\n }\n}\n\n/**\n * Checks if silent login is possible by examining the callback URL.\n *\n * After redirecting to the silent login URL, the OAuth provider will redirect back\n * with either a code (success) or an error (failure). This function parses the\n * callback URL to determine the result.\n *\n * @param callbackUrl - The URL the OAuth provider redirected to\n * @returns Result indicating success or failure with details\n *\n * @example\n * ```ts\n * // In your OAuth callback handler\n * const result = checkSilentLoginResult(new URL(request.url));\n *\n * if (result.success) {\n * // User has active SSO session, proceed with token exchange\n * const code = new URL(request.url).searchParams.get('code');\n * // ... exchange code for tokens\n * } else if (result.error === 'login_required') {\n * // User needs to log in interactively\n * redirect('/login');\n * } else {\n * // Other error\n * console.error(result.errorDescription);\n * }\n * ```\n */\nexport function checkSilentLoginResult(callbackUrl: URL): SilentLoginResult {\n const error = callbackUrl.searchParams.get('error');\n const errorDescription = callbackUrl.searchParams.get('error_description');\n const code = callbackUrl.searchParams.get('code');\n\n if (error) {\n logger.info({ error, errorDescription }, 'Silent login failed with error');\n\n return {\n success: false,\n error,\n errorDescription: errorDescription ?? undefined,\n };\n }\n\n if (code) {\n logger.info('Silent login successful');\n return {\n success: true,\n };\n }\n\n logger.warn('Silent login callback missing both code and error');\n return {\n success: false,\n error: 'invalid_callback',\n errorDescription: 'Callback URL missing both code and error parameters',\n };\n}\n\n/**\n * Common OAuth errors returned from silent login attempts.\n *\n * These can be used to handle specific error cases:\n * - login_required: User must log in interactively\n * - interaction_required: User interaction is needed (e.g., consent)\n * - consent_required: User must grant consent\n * - account_selection_required: User must select an account\n */\nexport const SilentLoginErrors = {\n LOGIN_REQUIRED: 'login_required',\n INTERACTION_REQUIRED: 'interaction_required',\n CONSENT_REQUIRED: 'consent_required',\n ACCOUNT_SELECTION_REQUIRED: 'account_selection_required',\n} as const;\n\nexport type SilentLoginError = typeof SilentLoginErrors[keyof typeof SilentLoginErrors];\n\n/**\n * Helper to check if an error requires interactive login.\n *\n * @param error - The error code from the OAuth provider\n * @returns True if the user must log in interactively\n *\n * @example\n * ```ts\n * const result = checkSilentLoginResult(callbackUrl);\n * if (!result.success && requiresInteractiveLogin(result.error)) {\n * // Redirect to full login page\n * redirect('/login');\n * }\n * ```\n */\nexport function requiresInteractiveLogin(error: string | undefined): boolean {\n if (!error) return false;\n\n return Object.values(SilentLoginErrors).includes(error as SilentLoginError);\n}\n"],"mappings":";;;;;;;;;;;;;AAgBA,IAAM,SAAS,aAAa,EAAE,WAAW,0BAA0B,CAAC;;;;;;;;;;;;;;;;;;;;;;AA2CpE,eAAsB,oBACpB,QACA,UAA8B,CAAC,GACjB;CACd,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,GAAG,2BAA2B;CAEnE,IAAI;EACF,MAAM,SAAS,MAAM,sBAAsB,MAAM;EAEjD,MAAM,aAAqC;GACzC,cAAc,OAAO;GACrB,OAAO,OAAO;GACd,QAAQ;GACR,eAAe;GACf,GAAG,QAAQ;EACb;EAGA,IAAI,QAAQ,WACV,WAAW,aAAa,QAAQ;EAGlC,IAAI,QAAQ,WAAW,KAAA,GACrB,WAAW,UAAU,QAAQ,OAAO,SAAS;EAG/C,MAAM,UAAU,OAAO,sBAAsB,QAAQ,UAAU;EAE/D,OAAO,KAAK,qCAAqC;EACjD,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MAAM;GAAE;GAAO,QAAQ,OAAO;EAAO,GAAG,kCAAkC;EACjF,MAAM;CACR;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,uBAAuB,aAAqC;CAC1E,MAAM,QAAQ,YAAY,aAAa,IAAI,OAAO;CAClD,MAAM,mBAAmB,YAAY,aAAa,IAAI,mBAAmB;CACzE,MAAM,OAAO,YAAY,aAAa,IAAI,MAAM;CAEhD,IAAI,OAAO;EACT,OAAO,KAAK;GAAE;GAAO;EAAiB,GAAG,gCAAgC;EAEzE,OAAO;GACL,SAAS;GACT;GACA,kBAAkB,oBAAoB,KAAA;EACxC;CACF;CAEA,IAAI,MAAM;EACR,OAAO,KAAK,yBAAyB;EACrC,OAAO,EACL,SAAS,KACX;CACF;CAEA,OAAO,KAAK,mDAAmD;CAC/D,OAAO;EACL,SAAS;EACT,OAAO;EACP,kBAAkB;CACpB;AACF;;;;;;;;;;AAWA,IAAa,oBAAoB;CAC/B,gBAAgB;CAChB,sBAAsB;CACtB,kBAAkB;CAClB,4BAA4B;AAC9B;;;;;;;;;;;;;;;;AAmBA,SAAgB,yBAAyB,OAAoC;CAC3E,IAAI,CAAC,OAAO,OAAO;CAEnB,OAAO,OAAO,OAAO,iBAAiB,CAAC,CAAC,SAAS,KAAyB;AAC5E"}
1
+ {"version":3,"file":"silent-login.js","names":[],"sources":["../src/silent-login.ts"],"sourcesContent":["/**\n * Silent login functionality for OAuth flows.\n *\n * Silent login (also called \"prompt=none\") attempts to authenticate the user\n * without any user interaction. This is useful for:\n * - Checking if a user has an active SSO session\n * - Refreshing authentication silently in the background\n * - Implementing \"keep me logged in\" features\n */\n\nimport * as openid from 'openid-client';\n\nimport { createLogger } from './logger';\nimport { joinScope } from './oauth';\nimport type { OAuthConfig } from './oauth-config';\nimport { discoverConfiguration } from './oidc-discovery';\n\nconst logger = createLogger({ namespace: 'fides-auth:silent-login' });\n\nexport interface SilentLoginOptions {\n /** Optional hint about the user's identity (e.g., email or user ID) */\n loginHint?: string;\n /** Maximum age of authentication in seconds */\n maxAge?: number;\n /** Additional parameters to pass to the authorization endpoint */\n additionalParams?: Record<string, string>;\n}\n\nexport interface SilentLoginResult {\n /** Whether silent login was successful */\n success: boolean;\n /** The authorization URL to use (only if success is true) */\n authorizationUrl?: URL;\n /** Error message if silent login failed */\n error?: string;\n /** Detailed error description from OAuth provider */\n errorDescription?: string;\n}\n\n/**\n * Builds an authorization URL for silent authentication.\n *\n * This function creates an authorization URL with prompt=none,\n * which tells the OAuth provider to authenticate without user interaction.\n * If the user doesn't have an active session, the provider will return an error.\n *\n * @param config - OAuth configuration\n * @param options - Silent login options\n * @returns Authorization URL for silent authentication\n *\n * @example\n * ```ts\n * const silentAuthUrl = await buildSilentLoginUrl(oauthConfig, {\n * loginHint: 'user@example.com',\n * maxAge: 3600, // Require authentication within last hour\n * });\n *\n * // Use this URL in an iframe or hidden window\n * ```\n */\nexport async function buildSilentLoginUrl(\n config: OAuthConfig,\n options: SilentLoginOptions = {}\n): Promise<URL> {\n logger.debug({ issuer: config.issuer }, 'Building silent login URL');\n\n try {\n const server = await discoverConfiguration(config);\n\n const parameters: Record<string, string> = {\n redirect_uri: config.redirect_uri,\n scope: joinScope(config.scope),\n prompt: 'none', // Critical: tells provider to not show any UI\n response_type: 'code',\n ...options.additionalParams,\n };\n\n // Add optional parameters\n if (options.loginHint) {\n parameters.login_hint = options.loginHint;\n }\n\n if (options.maxAge !== undefined) {\n parameters.max_age = options.maxAge.toString();\n }\n\n const authUrl = openid.buildAuthorizationUrl(server, parameters);\n\n logger.info('Silent login URL built successfully');\n return authUrl;\n } catch (error) {\n logger.error({ error, issuer: config.issuer }, 'Failed to build silent login URL');\n throw error;\n }\n}\n\n/**\n * Checks if silent login is possible by examining the callback URL.\n *\n * After redirecting to the silent login URL, the OAuth provider will redirect back\n * with either a code (success) or an error (failure). This function parses the\n * callback URL to determine the result.\n *\n * @param callbackUrl - The URL the OAuth provider redirected to\n * @returns Result indicating success or failure with details\n *\n * @example\n * ```ts\n * // In your OAuth callback handler\n * const result = checkSilentLoginResult(new URL(request.url));\n *\n * if (result.success) {\n * // User has active SSO session, proceed with token exchange\n * const code = new URL(request.url).searchParams.get('code');\n * // ... exchange code for tokens\n * } else if (result.error === 'login_required') {\n * // User needs to log in interactively\n * redirect('/login');\n * } else {\n * // Other error\n * console.error(result.errorDescription);\n * }\n * ```\n */\nexport function checkSilentLoginResult(callbackUrl: URL): SilentLoginResult {\n const error = callbackUrl.searchParams.get('error');\n const errorDescription = callbackUrl.searchParams.get('error_description');\n const code = callbackUrl.searchParams.get('code');\n\n if (error) {\n logger.info({ error, errorDescription }, 'Silent login failed with error');\n\n return {\n success: false,\n error,\n errorDescription: errorDescription ?? undefined,\n };\n }\n\n if (code) {\n logger.info('Silent login successful');\n return {\n success: true,\n };\n }\n\n logger.warn('Silent login callback missing both code and error');\n return {\n success: false,\n error: 'invalid_callback',\n errorDescription: 'Callback URL missing both code and error parameters',\n };\n}\n\n/**\n * Common OAuth errors returned from silent login attempts.\n *\n * These can be used to handle specific error cases:\n * - login_required: User must log in interactively\n * - interaction_required: User interaction is needed (e.g., consent)\n * - consent_required: User must grant consent\n * - account_selection_required: User must select an account\n */\nexport const SilentLoginErrors = {\n LOGIN_REQUIRED: 'login_required',\n INTERACTION_REQUIRED: 'interaction_required',\n CONSENT_REQUIRED: 'consent_required',\n ACCOUNT_SELECTION_REQUIRED: 'account_selection_required',\n} as const;\n\nexport type SilentLoginError = typeof SilentLoginErrors[keyof typeof SilentLoginErrors];\n\n/**\n * Helper to check if an error requires interactive login.\n *\n * @param error - The error code from the OAuth provider\n * @returns True if the user must log in interactively\n *\n * @example\n * ```ts\n * const result = checkSilentLoginResult(callbackUrl);\n * if (!result.success && requiresInteractiveLogin(result.error)) {\n * // Redirect to full login page\n * redirect('/login');\n * }\n * ```\n */\nexport function requiresInteractiveLogin(error: string | undefined): boolean {\n if (!error) return false;\n\n return Object.values(SilentLoginErrors).includes(error as SilentLoginError);\n}\n"],"mappings":";;;;;;;;;;;;;;AAiBA,IAAM,SAAS,aAAa,EAAE,WAAW,0BAA0B,CAAC;;;;;;;;;;;;;;;;;;;;;;AA2CpE,eAAsB,oBACpB,QACA,UAA8B,CAAC,GACjB;CACd,OAAO,MAAM,EAAE,QAAQ,OAAO,OAAO,GAAG,2BAA2B;CAEnE,IAAI;EACF,MAAM,SAAS,MAAM,sBAAsB,MAAM;EAEjD,MAAM,aAAqC;GACzC,cAAc,OAAO;GACrB,OAAO,UAAU,OAAO,KAAK;GAC7B,QAAQ;GACR,eAAe;GACf,GAAG,QAAQ;EACb;EAGA,IAAI,QAAQ,WACV,WAAW,aAAa,QAAQ;EAGlC,IAAI,QAAQ,WAAW,KAAA,GACrB,WAAW,UAAU,QAAQ,OAAO,SAAS;EAG/C,MAAM,UAAU,OAAO,sBAAsB,QAAQ,UAAU;EAE/D,OAAO,KAAK,qCAAqC;EACjD,OAAO;CACT,SAAS,OAAO;EACd,OAAO,MAAM;GAAE;GAAO,QAAQ,OAAO;EAAO,GAAG,kCAAkC;EACjF,MAAM;CACR;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,SAAgB,uBAAuB,aAAqC;CAC1E,MAAM,QAAQ,YAAY,aAAa,IAAI,OAAO;CAClD,MAAM,mBAAmB,YAAY,aAAa,IAAI,mBAAmB;CACzE,MAAM,OAAO,YAAY,aAAa,IAAI,MAAM;CAEhD,IAAI,OAAO;EACT,OAAO,KAAK;GAAE;GAAO;EAAiB,GAAG,gCAAgC;EAEzE,OAAO;GACL,SAAS;GACT;GACA,kBAAkB,oBAAoB,KAAA;EACxC;CACF;CAEA,IAAI,MAAM;EACR,OAAO,KAAK,yBAAyB;EACrC,OAAO,EACL,SAAS,KACX;CACF;CAEA,OAAO,KAAK,mDAAmD;CAC/D,OAAO;EACL,SAAS;EACT,OAAO;EACP,kBAAkB;CACpB;AACF;;;;;;;;;;AAWA,IAAa,oBAAoB;CAC/B,gBAAgB;CAChB,sBAAsB;CACtB,kBAAkB;CAClB,4BAA4B;AAC9B;;;;;;;;;;;;;;;;AAmBA,SAAgB,yBAAyB,OAAoC;CAC3E,IAAI,CAAC,OAAO,OAAO;CAEnB,OAAO,OAAO,OAAO,iBAAiB,CAAC,CAAC,SAAS,KAAyB;AAC5E"}
package/dist/types.d.ts CHANGED
@@ -22,9 +22,12 @@ export interface Session<TData = Record<string, unknown>> {
22
22
  */
23
23
  sid?: string;
24
24
  tokens?: Tokens;
25
+ /** From the ID token claims. Absent when the token response has no `id_token`. */
25
26
  user?: {
27
+ /** `sub` claim. Absent on sessions created before this field existed. */
28
+ sub?: string;
26
29
  name: string;
27
- email: string;
30
+ email?: string;
28
31
  roles?: string[];
29
32
  };
30
33
  /** OAuth scopes granted to this session (e.g. ["openid", "profile", "email"]). */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE;QACL,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,EAAE,MAAM,CAAC;QACd,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,KAAK,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,MAAM;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD;;;;;OAKG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,IAAI,CAAC,EAAE;QACL,yEAAyE;QACzE,GAAG,CAAC,EAAE,MAAM,CAAC;QACb,IAAI,EAAE,MAAM,CAAC;QACb,KAAK,CAAC,EAAE,MAAM,CAAC;QACf,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;KAClB,CAAC;IACF,kFAAkF;IAClF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8DAA8D;IAC9D,IAAI,CAAC,EAAE,KAAK,CAAC;CACd;AAED,oDAAoD;AACpD,MAAM,WAAW,oBAAoB;IACnC,oEAAoE;IACpE,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B"}
package/dist/utils.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { Session } from './types';
2
1
  import * as jose from 'jose';
2
+ import type { Session } from './types';
3
3
  /**
4
4
  * Converts a hex string to a Uint8Array.
5
5
  *
@@ -56,20 +56,6 @@ export declare function sha256(data: string | Uint8Array): Promise<string>;
56
56
  * @returns A Promise that resolves to the SHA-512 hash as a hex string.
57
57
  */
58
58
  export declare function sha512(data: string | Uint8Array): Promise<string>;
59
- /**
60
- * Retrieves the session secret from environment variables.
61
- * Throws an error if the SESSION_SECRET is not defined.
62
- *
63
- * @returns {string} The session secret.
64
- */
65
- export declare function getSessionSecret(): string;
66
- /**
67
- * Retrieves the session secret from environment variables as Uint8Array .
68
- * Throws an error if the SESSION_SECRET is not defined.
69
- *
70
- * @returns {Uint8Array} The session secret as Uint8Array.
71
- */
72
- export declare function getSessionSecretUint8Array(): Uint8Array;
73
59
  /**
74
60
  * Converts a Uint8Array to a lowercase hexadecimal string.
75
61
  */
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAG7B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAKvC;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAYvD;AAaD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,MAAM,CAAC,CAKjB;AAED;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC9B,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAG1B;AAGD;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkCxF;AAED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA4CxF;AAcD;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAIvE;AAED;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAIvE;AAED;;;;;GAKG;AACH,wBAAgB,gBAAgB,IAAI,MAAM,CAMzC;AAED;;;;;GAKG;AACH,wBAAgB,0BAA0B,IAAI,UAAU,CAKvD;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAI/C;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,WAAW,GAAE,MAAW,GAAG,MAAM,CAI9D;AAGD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,GAC7B,aAAa,MAAM,EACnB,UAAS,MAAW,KACnB,OAsBF,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEjE"}
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,MAAM,CAAC;AAG7B,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAKvC;;;;;GAKG;AACH,wBAAgB,eAAe,CAAC,GAAG,EAAE,MAAM,GAAG,UAAU,CAYvD;AAaD;;;;;;GAMG;AACH,wBAAsB,kBAAkB,CACtC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,MAAM,CAAC,CAKjB;AAED;;;;;;;;;GASG;AACH,wBAAsB,UAAU,CAC9B,YAAY,EAAE,MAAM,EACpB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,IAAI,CAAC,UAAU,CAAC,CAG1B;AAGD;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAkCxF;AAED;;;;;;;GAOG;AACH,wBAAsB,OAAO,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CA4CxF;AAcD;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAIvE;AAED;;;;GAIG;AACH,wBAAsB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,UAAU,GAAG,OAAO,CAAC,MAAM,CAAC,CAIvE;AAED;;GAEG;AACH,wBAAgB,KAAK,CAAC,KAAK,EAAE,UAAU,GAAG,MAAM,CAI/C;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,WAAW,GAAE,MAAW,GAAG,MAAM,CAI9D;AAGD;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,gBAChB,MAAM,YACV,MAAM,KACd,OAsBF,CAAC;AAEF;;;;;GAKG;AACH,wBAAgB,QAAQ,CAAC,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEjE"}
package/dist/utils.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createLogger } from "./logger.js";
2
2
  import * as jose from "jose";
3
- //#region src/utils.ts
3
+ // src/utils.ts
4
4
  var logger = createLogger({ namespace: "fides-auth:utils" });
5
5
  /**
6
6
  * Converts a hex string to a Uint8Array.
@@ -136,27 +136,6 @@ async function sha512(data) {
136
136
  return toHex(new Uint8Array(hashBuffer));
137
137
  }
138
138
  /**
139
- * Retrieves the session secret from environment variables.
140
- * Throws an error if the SESSION_SECRET is not defined.
141
- *
142
- * @returns {string} The session secret.
143
- */
144
- function getSessionSecret() {
145
- const secret = process.env.SESSION_SECRET;
146
- if (!secret) throw new Error("SESSION_SECRET is not defined");
147
- return secret;
148
- }
149
- /**
150
- * Retrieves the session secret from environment variables as Uint8Array .
151
- * Throws an error if the SESSION_SECRET is not defined.
152
- *
153
- * @returns {Uint8Array} The session secret as Uint8Array.
154
- */
155
- function getSessionSecretUint8Array() {
156
- const keyData = hexToUint8Array(getSessionSecret());
157
- return new Uint8Array([...keyData]);
158
- }
159
- /**
160
139
  * Converts a Uint8Array to a lowercase hexadecimal string.
161
140
  */
162
141
  function toHex(bytes) {
@@ -204,7 +183,6 @@ var accessTokenExpires = (accessToken, seconds = 10) => {
204
183
  function hasScope(session, scope) {
205
184
  return session.scopes?.includes(scope) ?? false;
206
185
  }
207
- //#endregion
208
- export { accessTokenExpires, createEncryptedJWT, decrypt, decryptJWT, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex };
186
+ export { accessTokenExpires, createEncryptedJWT, decrypt, decryptJWT, encrypt, generateToken, hasScope, hexToUint8Array, sha256, sha512, toHex };
209
187
 
210
188
  //# sourceMappingURL=utils.js.map
package/dist/utils.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import * as jose from 'jose';\n\nimport { createLogger } from './logger';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:utils' });\n\n\n/**\n * Converts a hex string to a Uint8Array.\n *\n * @param hex - A hexadecimal string.\n * @returns A Uint8Array representing the bytes of the hex string.\n */\nexport function hexToUint8Array(hex: string): Uint8Array {\n if (hex.length % 2 !== 0) {\n throw new Error('Invalid hex string: odd length');\n }\n if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {\n throw new Error('Invalid hex string: contains non-hex characters');\n }\n const arr = new Uint8Array(hex.length / 2);\n for (let i = 0; i < arr.length; i++) {\n arr[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n return arr;\n}\n\n/**\n * Normalizes a secret to a Uint8Array.\n * Accepts either a hex-encoded string or an existing Uint8Array.\n */\nfunction normalizeSecret(secret: string | Uint8Array): Uint8Array {\n if (typeof secret === 'string') {\n return hexToUint8Array(secret);\n }\n return secret;\n}\n\n/**\n * Encrypts a payload into a JWT using the specified secret.\n *\n * @param payload - The payload to encrypt (can be any JSON-serializable value)\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns A promise that resolves to the encrypted JWT as a string.\n */\nexport async function createEncryptedJWT(\n payload: unknown,\n secret: string | Uint8Array,\n): Promise<string> {\n return new jose.EncryptJWT(payload as jose.JWTPayload)\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .encrypt(normalizeSecret(secret));\n}\n\n/**\n * Decrypts a JWE produced by {@link createEncryptedJWT} and returns its payload.\n *\n * Counterpart to {@link createEncryptedJWT}. Throws if the token cannot be\n * decrypted (wrong key, tampering, or malformed input).\n *\n * @param encryptedJwt - The encrypted JWT string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).\n * @returns A promise that resolves to the decrypted JWT payload.\n */\nexport async function decryptJWT(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<jose.JWTPayload> {\n const { payload } = await jose.jwtDecrypt(encryptedJwt, normalizeSecret(secret));\n return payload;\n}\n\n\n/**\n * Encrypts the given text using AES-GCM via the Web Crypto API.\n * Returns a string in the format: iv:authTag:ciphertext, all in hex.\n *\n * @param text - The plaintext to encrypt.\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the encrypted string.\n */\nexport async function encrypt(text: string, secret: string | Uint8Array): Promise<string> {\n const keyData = normalizeSecret(secret);\n\n // Import the key for AES-GCM encryption.\n const key = await crypto.subtle.importKey(\n 'raw',\n keyData as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['encrypt']\n );\n\n // Generate a 12-byte IV using the Web Crypto API.\n const iv = crypto.getRandomValues(new Uint8Array(12));\n\n // Encode the plaintext into a Uint8Array.\n const encoder = new TextEncoder();\n const data = encoder.encode(text);\n\n // Encrypt the data using AES-GCM.\n // Note: The Web Crypto API combines the ciphertext and authentication tag in the output.\n const encryptedBuffer = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv, tagLength: 128 },\n key,\n data\n );\n\n // Convert the encrypted data to a Uint8Array for separation.\n const encryptedArray = new Uint8Array(encryptedBuffer);\n const tagLength = 16; // AES-GCM uses a 128-bit (16-byte) tag.\n const ciphertext = encryptedArray.slice(0, encryptedArray.length - tagLength);\n const authTag = encryptedArray.slice(encryptedArray.length - tagLength);\n\n return [toHex(iv), toHex(authTag), toHex(ciphertext)].join(':');\n}\n\n/**\n * Decrypts data encrypted with AES-256-GCM (using the Web Crypto API).\n * The input is expected to be in the format: iv:authTag:ciphertext (all hex-encoded).\n *\n * @param data - The encrypted data string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the decrypted plaintext.\n */\nexport async function decrypt(data: string, secret: string | Uint8Array): Promise<string> {\n\n // Split the incoming data. We expect exactly three parts.\n const [ivHex, tagHex, ciphertextHex] = data.split(':');\n if (!ivHex || !tagHex || !ciphertextHex) {\n throw new Error('Invalid encrypted data format');\n }\n\n // Convert each hex-encoded part to a Uint8Array\n const iv = hexToUint8Array(ivHex);\n const tag = hexToUint8Array(tagHex);\n const ciphertext = hexToUint8Array(ciphertextHex);\n\n // Concatenate ciphertext and auth tag (Web Crypto expects them combined)\n const combined = new Uint8Array(ciphertext.length + tag.length);\n combined.set(ciphertext);\n combined.set(tag, ciphertext.length);\n\n // Convert the secret and import it as a CryptoKey for AES-GCM decryption.\n const keyData = normalizeSecret(secret);\n // Ensure keyData is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const keyDataArrayBuffer = new Uint8Array([...keyData]);\n\n // Ensure iv is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const ivArrayBuffer = new Uint8Array([...iv]);\n\n const key = await crypto.subtle.importKey(\n 'raw',\n keyDataArrayBuffer as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['decrypt']\n );\n\n // Decrypt the combined ciphertext+tag using the IV.\n const decryptedBuffer = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: ivArrayBuffer, tagLength: 128 },\n key,\n combined.buffer\n );\n\n // Decode the decrypted ArrayBuffer back to a string.\n const decoder = new TextDecoder();\n return decoder.decode(decryptedBuffer);\n}\n\n/**\n * Converts input data (string or Uint8Array) to a Uint8Array backed by a plain ArrayBuffer.\n * @param data - The input data.\n * @returns A Uint8Array representing the input data.\n */\nfunction toUint8Array(data: string | Uint8Array): Uint8Array {\n if (typeof data === 'string') {\n return new TextEncoder().encode(data);\n }\n return data;\n}\n\n/**\n * Hashes the input using SHA-256 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-256 hash as a hex string.\n */\nexport async function sha256(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-256', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Hashes the input using SHA-512 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-512 hash as a hex string.\n */\nexport async function sha512(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-512', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Retrieves the session secret from environment variables.\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {string} The session secret.\n */\nexport function getSessionSecret(): string {\n const secret = process.env.SESSION_SECRET;\n if (!secret) {\n throw new Error('SESSION_SECRET is not defined');\n }\n return secret;\n}\n\n/**\n * Retrieves the session secret from environment variables as Uint8Array .\n * Throws an error if the SESSION_SECRET is not defined.\n *\n * @returns {Uint8Array} The session secret as Uint8Array.\n */\nexport function getSessionSecretUint8Array(): Uint8Array {\n const secret = getSessionSecret();\n const keyData = hexToUint8Array(secret);\n // Ensure it's backed by a regular ArrayBuffer (not SharedArrayBuffer)\n return new Uint8Array([...keyData]);\n}\n\n/**\n * Converts a Uint8Array to a lowercase hexadecimal string.\n */\nexport function toHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map(b => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generates a random token of the given byte length and returns it as a hex string.\n * Default token length is 20 bytes (160 bits).\n *\n * @param tokenLength - The number of random bytes to generate (default is 20).\n * @returns A hex string representing the token.\n */\nexport function generateToken(tokenLength: number = 32): string {\n const bytes = new Uint8Array(tokenLength);\n crypto.getRandomValues(bytes);\n return toHex(bytes);\n}\n\n\n/**\n * Determines if the access token of a session will expire in less than the given threshold.\n *\n * It decodes the access token (assumed to be a JWT) and reads the `exp` claim.\n * The expiration (`exp`) claim is the UNIX timestamp (in seconds) when the token expires.\n *\n * @param accessToken - The access token string.\n * @param seconds - The threshold (in seconds) for checking expiration (default is 10 seconds).\n * @returns True if the access token expires within the threshold; otherwise, false.\n */\nexport const accessTokenExpires = (\n accessToken: string,\n seconds: number = 10\n): boolean => {\n try {\n // Decode the JWT without verifying the signature.\n const payload = jose.decodeJwt(accessToken) as { exp?: number; };\n\n // If there is no exp claim, assume the token does not expire\n if (!payload.exp) {\n return false;\n }\n\n const nowInSeconds = Date.now() / 1000;\n const remainingSeconds = payload.exp - nowInSeconds;\n\n return remainingSeconds < seconds;\n } catch (error) {\n // If decoding fails, treat the token as invalid so no refresh is triggered.\n logger.error(\n { error },\n 'Error decoding access token'\n );\n return true;\n }\n};\n\n/**\n * Returns true if the session was granted the given scope.\n *\n * @param session - The session to check\n * @param scope - The scope string to look for (e.g. \"operations.read\")\n */\nexport function hasScope(session: Session, scope: string): boolean {\n return session.scopes?.includes(scope) ?? false;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"utils.js","names":[],"sources":["../src/utils.ts"],"sourcesContent":["import * as jose from 'jose';\n\nimport { createLogger } from './logger';\nimport type { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:utils' });\n\n\n/**\n * Converts a hex string to a Uint8Array.\n *\n * @param hex - A hexadecimal string.\n * @returns A Uint8Array representing the bytes of the hex string.\n */\nexport function hexToUint8Array(hex: string): Uint8Array {\n if (hex.length % 2 !== 0) {\n throw new Error('Invalid hex string: odd length');\n }\n if (hex.length > 0 && !/^[0-9a-fA-F]+$/.test(hex)) {\n throw new Error('Invalid hex string: contains non-hex characters');\n }\n const arr = new Uint8Array(hex.length / 2);\n for (let i = 0; i < arr.length; i++) {\n arr[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);\n }\n return arr;\n}\n\n/**\n * Normalizes a secret to a Uint8Array.\n * Accepts either a hex-encoded string or an existing Uint8Array.\n */\nfunction normalizeSecret(secret: string | Uint8Array): Uint8Array {\n if (typeof secret === 'string') {\n return hexToUint8Array(secret);\n }\n return secret;\n}\n\n/**\n * Encrypts a payload into a JWT using the specified secret.\n *\n * @param payload - The payload to encrypt (can be any JSON-serializable value)\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM)\n * @returns A promise that resolves to the encrypted JWT as a string.\n */\nexport async function createEncryptedJWT(\n payload: unknown,\n secret: string | Uint8Array,\n): Promise<string> {\n return new jose.EncryptJWT(payload as jose.JWTPayload)\n .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' })\n .setIssuedAt()\n .encrypt(normalizeSecret(secret));\n}\n\n/**\n * Decrypts a JWE produced by {@link createEncryptedJWT} and returns its payload.\n *\n * Counterpart to {@link createEncryptedJWT}. Throws if the token cannot be\n * decrypted (wrong key, tampering, or malformed input).\n *\n * @param encryptedJwt - The encrypted JWT string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).\n * @returns A promise that resolves to the decrypted JWT payload.\n */\nexport async function decryptJWT(\n encryptedJwt: string,\n secret: string | Uint8Array,\n): Promise<jose.JWTPayload> {\n const { payload } = await jose.jwtDecrypt(encryptedJwt, normalizeSecret(secret));\n return payload;\n}\n\n\n/**\n * Encrypts the given text using AES-GCM via the Web Crypto API.\n * Returns a string in the format: iv:authTag:ciphertext, all in hex.\n *\n * @param text - The plaintext to encrypt.\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the encrypted string.\n */\nexport async function encrypt(text: string, secret: string | Uint8Array): Promise<string> {\n const keyData = normalizeSecret(secret);\n\n // Import the key for AES-GCM encryption.\n const key = await crypto.subtle.importKey(\n 'raw',\n keyData as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['encrypt']\n );\n\n // Generate a 12-byte IV using the Web Crypto API.\n const iv = crypto.getRandomValues(new Uint8Array(12));\n\n // Encode the plaintext into a Uint8Array.\n const encoder = new TextEncoder();\n const data = encoder.encode(text);\n\n // Encrypt the data using AES-GCM.\n // Note: The Web Crypto API combines the ciphertext and authentication tag in the output.\n const encryptedBuffer = await crypto.subtle.encrypt(\n { name: 'AES-GCM', iv, tagLength: 128 },\n key,\n data\n );\n\n // Convert the encrypted data to a Uint8Array for separation.\n const encryptedArray = new Uint8Array(encryptedBuffer);\n const tagLength = 16; // AES-GCM uses a 128-bit (16-byte) tag.\n const ciphertext = encryptedArray.slice(0, encryptedArray.length - tagLength);\n const authTag = encryptedArray.slice(encryptedArray.length - tagLength);\n\n return [toHex(iv), toHex(authTag), toHex(ciphertext)].join(':');\n}\n\n/**\n * Decrypts data encrypted with AES-256-GCM (using the Web Crypto API).\n * The input is expected to be in the format: iv:authTag:ciphertext (all hex-encoded).\n *\n * @param data - The encrypted data string.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for AES-256).\n * @returns A promise that resolves to the decrypted plaintext.\n */\nexport async function decrypt(data: string, secret: string | Uint8Array): Promise<string> {\n\n // Split the incoming data. We expect exactly three parts.\n const [ivHex, tagHex, ciphertextHex] = data.split(':');\n if (!ivHex || !tagHex || !ciphertextHex) {\n throw new Error('Invalid encrypted data format');\n }\n\n // Convert each hex-encoded part to a Uint8Array\n const iv = hexToUint8Array(ivHex);\n const tag = hexToUint8Array(tagHex);\n const ciphertext = hexToUint8Array(ciphertextHex);\n\n // Concatenate ciphertext and auth tag (Web Crypto expects them combined)\n const combined = new Uint8Array(ciphertext.length + tag.length);\n combined.set(ciphertext);\n combined.set(tag, ciphertext.length);\n\n // Convert the secret and import it as a CryptoKey for AES-GCM decryption.\n const keyData = normalizeSecret(secret);\n // Ensure keyData is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const keyDataArrayBuffer = new Uint8Array([...keyData]);\n\n // Ensure iv is backed by a real ArrayBuffer (not SharedArrayBuffer)\n const ivArrayBuffer = new Uint8Array([...iv]);\n\n const key = await crypto.subtle.importKey(\n 'raw',\n keyDataArrayBuffer as BufferSource,\n { name: 'AES-GCM' },\n false,\n ['decrypt']\n );\n\n // Decrypt the combined ciphertext+tag using the IV.\n const decryptedBuffer = await crypto.subtle.decrypt(\n { name: 'AES-GCM', iv: ivArrayBuffer, tagLength: 128 },\n key,\n combined.buffer\n );\n\n // Decode the decrypted ArrayBuffer back to a string.\n const decoder = new TextDecoder();\n return decoder.decode(decryptedBuffer);\n}\n\n/**\n * Converts input data (string or Uint8Array) to a Uint8Array backed by a plain ArrayBuffer.\n * @param data - The input data.\n * @returns A Uint8Array representing the input data.\n */\nfunction toUint8Array(data: string | Uint8Array): Uint8Array {\n if (typeof data === 'string') {\n return new TextEncoder().encode(data);\n }\n return data;\n}\n\n/**\n * Hashes the input using SHA-256 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-256 hash as a hex string.\n */\nexport async function sha256(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-256', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Hashes the input using SHA-512 with the Web Crypto API.\n * @param data - The input data (string or Uint8Array).\n * @returns A Promise that resolves to the SHA-512 hash as a hex string.\n */\nexport async function sha512(data: string | Uint8Array): Promise<string> {\n const buffer = toUint8Array(data);\n const hashBuffer = await crypto.subtle.digest('SHA-512', buffer as BufferSource);\n return toHex(new Uint8Array(hashBuffer));\n}\n\n/**\n * Converts a Uint8Array to a lowercase hexadecimal string.\n */\nexport function toHex(bytes: Uint8Array): string {\n return Array.from(bytes)\n .map(b => b.toString(16).padStart(2, '0'))\n .join('');\n}\n\n/**\n * Generates a random token of the given byte length and returns it as a hex string.\n * Default token length is 20 bytes (160 bits).\n *\n * @param tokenLength - The number of random bytes to generate (default is 20).\n * @returns A hex string representing the token.\n */\nexport function generateToken(tokenLength: number = 32): string {\n const bytes = new Uint8Array(tokenLength);\n crypto.getRandomValues(bytes);\n return toHex(bytes);\n}\n\n\n/**\n * Determines if the access token of a session will expire in less than the given threshold.\n *\n * It decodes the access token (assumed to be a JWT) and reads the `exp` claim.\n * The expiration (`exp`) claim is the UNIX timestamp (in seconds) when the token expires.\n *\n * @param accessToken - The access token string.\n * @param seconds - The threshold (in seconds) for checking expiration (default is 10 seconds).\n * @returns True if the access token expires within the threshold; otherwise, false.\n */\nexport const accessTokenExpires = (\n accessToken: string,\n seconds: number = 10\n): boolean => {\n try {\n // Decode the JWT without verifying the signature.\n const payload = jose.decodeJwt(accessToken) as { exp?: number; };\n\n // If there is no exp claim, assume the token does not expire\n if (!payload.exp) {\n return false;\n }\n\n const nowInSeconds = Date.now() / 1000;\n const remainingSeconds = payload.exp - nowInSeconds;\n\n return remainingSeconds < seconds;\n } catch (error) {\n // If decoding fails, treat the token as invalid so no refresh is triggered.\n logger.error(\n { error },\n 'Error decoding access token'\n );\n return true;\n }\n};\n\n/**\n * Returns true if the session was granted the given scope.\n *\n * @param session - The session to check\n * @param scope - The scope string to look for (e.g. \"operations.read\")\n */\nexport function hasScope(session: Session, scope: string): boolean {\n return session.scopes?.includes(scope) ?? false;\n}\n"],"mappings":"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"}
@@ -20,9 +20,13 @@ in-flight promise rather than waiting for a result to store.
20
20
 
21
21
  ## What is cached, and for how long
22
22
 
23
- Keyed by issuer and client id, for five minutes. The client secret is checked on
24
- every lookup: rotate it and the next call rediscovers rather than authenticating
25
- with a credential the provider has already retired.
23
+ Keyed by issuer, client id and `clientAuth` method, for five minutes. The client
24
+ secret or private key is checked on every lookup: rotate it and the next call
25
+ rediscovers rather than authenticating with a credential the provider has
26
+ already retired. Keys are compared by identity, so reuse one `CryptoKey` object.
27
+
28
+ With `serverMetadata` nothing is fetched. The configuration is cached the same
29
+ way, against that metadata object: pass the same one each time.
26
30
 
27
31
  Failures are never cached. A provider that was briefly unreachable is retried on
28
32
  the next call — caching the rejection would turn one blip into five minutes of
@@ -21,13 +21,14 @@ import { handleOidcLogout } from "@eventuras/fides-auth/server";
21
21
  return handleOidcLogout(request, {
22
22
  oauthConfig,
23
23
  cookies,
24
+ secret: process.env.SESSION_SECRET!,
24
25
  postLogoutRedirectUri: "https://app.example.com/",
25
26
  });
26
27
  ```
27
28
 
28
29
  That reads the session's ID token, clears the local cookies, and redirects to the
29
- provider's end-session endpoint. Next.js apps can use the wrapper at
30
- `@eventuras/fides-auth-next/oidc-logout`.
30
+ provider's end-session endpoint. Next.js apps get it from `createFidesAuth` in
31
+ `@eventuras/fides-auth-next`, with the config and secret bound.
31
32
 
32
33
  ## Building the URL yourself
33
34
 
@@ -99,7 +100,9 @@ The ID token is also read independently of session validity. `readSession` retur
99
100
  null once the access token has expired, but the hint is needed precisely then: an
100
101
  idle user coming back to a stale tab and clicking log out still has a live session at
101
102
  the provider. `readIdToken` reads the `session_it` cookie directly, and
102
- `handleOidcLogout` uses it rather than going through the session.
103
+ `handleOidcLogout` falls back to it when the session itself won't read. Under
104
+ [server persistence](./server-sessions.md) the hint comes from the stored
105
+ record, which outlives the access token the same way.
103
106
 
104
107
  ## Correlating the response with `state`
105
108
 
@@ -0,0 +1,154 @@
1
+ # Server-side sessions
2
+
3
+ By default fides-auth keeps the whole session, tokens included, encrypted in
4
+ three cookies. That stops working when one token alone is bigger than a cookie.
5
+ OpenIddict, for one, encrypts access and refresh tokens (JWE); once fides-auth
6
+ encrypts and base64-encodes them again, a split cookie crosses 4096 bytes, the
7
+ browser drops it, and the login loops.
8
+
9
+ Server persistence is the opt-in fix: the session lives in a `SessionStore` you
10
+ provide, and the browser holds one small cookie with a random handle. It also
11
+ buys three things a cookie cannot offer: real revocation, one refresh per
12
+ session under concurrency, and a cookie that is worthless without the store.
13
+
14
+ ## Wiring
15
+
16
+ ```ts
17
+ import {
18
+ handleOidcCallback,
19
+ handleHeartbeat,
20
+ handleOidcLogout,
21
+ serverPersistence,
22
+ tryReadSession,
23
+ clearSession,
24
+ } from "@eventuras/fides-auth/server";
25
+
26
+ const secret = process.env.SESSION_SECRET!;
27
+ const persistence = serverPersistence({
28
+ store: sessionStore, // your SessionStore — see below
29
+ secret,
30
+ });
31
+
32
+ // Handlers: pass `persistence` where you passed `secret`. Login and callback
33
+ // still take `secret`, which seals the login transaction.
34
+ handleOidcCallback(request, { oauthConfig, applicationUrl, cookies, secret, persistence });
35
+ handleHeartbeat(request, { oauthConfig, cookies, persistence });
36
+ handleOidcLogout(request, { oauthConfig, cookies, postLogoutRedirectUri, persistence });
37
+
38
+ // Session helpers: `persistence` goes in the secret's position.
39
+ const { session, reason } = await tryReadSession(cookies, persistence);
40
+ await clearSession(cookies, { trigger: "logout", persistence });
41
+ ```
42
+
43
+ Cookie persistence stays the default: a bare secret means
44
+ `cookiePersistence(secret)`, and nothing changes for consumers that don't opt in.
45
+
46
+ The secret is checked on first use, not when the persistence is created, so a
47
+ module can build it at import time in a build that runs without the secret.
48
+
49
+ `serverPersistence` takes two more options: `cookieName` (default
50
+ `session_ref`) and `sessionDurationDays` (default 30), the absolute lifetime of
51
+ a session from login, refreshes notwithstanding.
52
+
53
+ ## The store contract
54
+
55
+ ```ts
56
+ interface SessionStore {
57
+ get(key: string): StoredSession | null | undefined | Promise<StoredSession | null | undefined>;
58
+ set(key: string, record: StoredSession): void | Promise<void>;
59
+ delete(key: string): void | Promise<void>;
60
+ withLock?<T>(key: string, fn: () => Promise<T>): Promise<T>;
61
+ }
62
+
63
+ interface StoredSession {
64
+ data: string; // the session, encrypted under the secret
65
+ expiresAt: string; // ISO 8601, absolute
66
+ sid?: string; // correlation id, so a record can be found from a log line
67
+ }
68
+ ```
69
+
70
+ Keys are `SHA-256(handle)` as hex, never the handle itself. On the library's
71
+ side:
72
+
73
+ - `data` is encrypted before `set` and decrypted after `get`. A store dump alone
74
+ yields no tokens.
75
+ - `expiresAt` is checked on every read. An expired record is rejected and
76
+ deleted whatever the adapter returned, so a late TTL sweep never revives a
77
+ session.
78
+ - `expiresAt` is fixed at login and kept across refreshes.
79
+
80
+ `createMemorySessionStore()` ships for tests and single-instance development;
81
+ sessions do not survive a restart. Database adapters belong in your app. A
82
+ MongoDB one, with the TTL index doing the sweeping:
83
+
84
+ ```ts
85
+ import type { SessionStore, StoredSession } from "@eventuras/fides-auth/server";
86
+
87
+ type Doc = Omit<StoredSession, "expiresAt"> & { _id: string; expiresAt: Date };
88
+ const sessions = db.collection<Doc>("sessions");
89
+ await sessions.createIndex({ expiresAt: 1 }, { expireAfterSeconds: 0 });
90
+
91
+ export const sessionStore: SessionStore = {
92
+ async get(key) {
93
+ const doc = await sessions.findOne({ _id: key });
94
+ return doc && { data: doc.data, sid: doc.sid, expiresAt: doc.expiresAt.toISOString() };
95
+ },
96
+ async set(key, record) {
97
+ await sessions.replaceOne(
98
+ { _id: key },
99
+ { _id: key, ...record, expiresAt: new Date(record.expiresAt) },
100
+ { upsert: true },
101
+ );
102
+ },
103
+ async delete(key) {
104
+ await sessions.deleteOne({ _id: key });
105
+ },
106
+ };
107
+ ```
108
+
109
+ ## Refresh under concurrency
110
+
111
+ Two requests refreshing one session at once is the failure mode of rotating
112
+ refresh tokens: both spend the token, one wins, the other now holds a dead one.
113
+ OpenIddict goes further and treats the reuse as theft, revoking the whole
114
+ authorization. Under server persistence `tryRefreshSessionInStore`:
115
+
116
+ - coalesces concurrent calls within one process into a single token request,
117
+ and hands every caller the same result;
118
+ - takes `store.withLock(key)` when the adapter provides it, and re-reads the
119
+ record under the lock, so a refresh that already happened on another instance
120
+ is reused rather than repeated;
121
+ - on `invalid_grant`, re-reads once more before ending the session, in case a
122
+ concurrent refresh elsewhere spent the token first.
123
+
124
+ Implement `withLock` when you run more than one instance. Give the lock a
125
+ timeout, so a crashed holder does not wedge the session.
126
+
127
+ ## Security notes
128
+
129
+ - **Handle.** 256 bits from `crypto.getRandomValues`, hex-encoded, in
130
+ `session_ref` with `httpOnly`, `secure`, `SameSite=Lax` and a max-age equal
131
+ to the session lifetime. The library never logs it.
132
+ - **Key.** `SHA-256(handle)`. Neither a leaked store nor a logged key can be
133
+ replayed as a cookie.
134
+ - **Fixation.** Every login mints a new handle and deletes the record the old
135
+ one pointed at. Only a refresh keeps the handle.
136
+ - **Revocation.** `clearSession` deletes the record before the cookie. From
137
+ then on a copied cookie reads as `session_not_found`.
138
+ - **At rest.** Records are encrypted with the same secret as cookie sessions.
139
+ Rotating the secret ends stored sessions (`unreadable_session`), as it ends
140
+ cookie ones.
141
+
142
+ ## Switching from cookies
143
+
144
+ A browser that arrives with the old `session` / `session_at` / `session_it`
145
+ cookies and no handle reads as `stale_legacy_session`: one re-login, logged as
146
+ such rather than as corruption. The next write under server persistence, and
147
+ any logout, deletes the old cookies.
148
+
149
+ ## What the logs say
150
+
151
+ `session.created` and `session.refreshed` carry `persistence: "server"` and no
152
+ `cookieBytes`. The one new rejection reason is `session_not_found`: a handle was
153
+ sent, but no live record exists for it. Revoked, expired or evicted. See
154
+ [session-events.md](./session-events.md).
@@ -15,8 +15,8 @@ messages are for humans and may be reworded, names are the contract.
15
15
 
16
16
  | `event` | Level | Always present | May be absent |
17
17
  | ------------------------ | ----------------- | -------------------- | ----------------------------------------------- |
18
- | `session.created` | info | `hasRefreshToken` | `sid`, `scopes`, `expiresIn`, `cookieBytes` |
19
- | `session.refreshed` | info | `rotatedRefreshToken`| `sid`, `expiresIn`, `cookieBytes` |
18
+ | `session.created` | info | `hasRefreshToken` | `sid`, `scopes`, `expiresIn`, `cookieBytes`, `persistence` |
19
+ | `session.refreshed` | info | `rotatedRefreshToken`| `sid`, `expiresIn`, `cookieBytes`, `persistence` |
20
20
  | `session.rejected` | per `reason` | `reason` | `sid`, `source` |
21
21
  | `session.refresh_failed` | per `cause` | `cause` | `sid`, `status`, `accessTokenExpiresAt` |
22
22
  | `session.cleared` | info | `trigger` | `sid` |
@@ -45,7 +45,8 @@ endpoint all report the same word for the same situation.
45
45
  | `reason` | Level | Means |
46
46
  | ---------------------- | ----- | -------------------------------------------------------------- |
47
47
  | `no_session_cookie` | debug | No cookie was sent. The ordinary anonymous case. |
48
- | `stale_legacy_session` | info | A pre-split single-cookie session. Costs one re-login. |
48
+ | `stale_legacy_session` | info | A pre-split single-cookie session, or split cookies under server persistence. Costs one re-login. |
49
+ | `session_not_found` | info | A handle was sent but the server store has no live record: revoked, expired or evicted. Server persistence only. |
49
50
  | `refresh_failed` | info | The provider rejected the refresh token. See `cause`. |
50
51
  | `unreadable_session` | warn | A cookie arrived but would not decrypt. Corruption, or a rotated secret. |
51
52
  | `no_refresh_token` | warn | The session has no refresh token — usually `offline_access` was never granted. |
@@ -129,7 +130,9 @@ zero.
129
130
  ```
130
131
 
131
132
  Browsers drop a cookie over ~4096 bytes silently, producing a login that appears
132
- to work and then doesn't.
133
+ to work and then doesn't. Under [server persistence](./server-sessions.md) the
134
+ cookie holds only a handle, so `cookieBytes` is absent and `persistence` is
135
+ `server`.
133
136
 
134
137
  ## Suggested alerts
135
138
 
@@ -188,20 +191,18 @@ if (!session) {
188
191
  `logSessionEvent` picks the level from the policy above, so every caller stays
189
192
  consistent without repeating it.
190
193
 
191
- Next.js apps get the same thing without wiring a cookie store, and the read is
192
- cached per render alongside `getCurrentSession`, so asking for the reason costs
193
- no extra decrypt:
194
+ Next.js apps get the same thing from `createFidesAuth` without wiring a cookie
195
+ store, and the read is cached per render alongside `getCurrentSession`, so
196
+ asking for the reason costs no extra decrypt:
194
197
 
195
198
  ```ts
196
- import {
197
- tryGetCurrentSession,
198
- SESSION_EVENT,
199
- logSessionEvent,
200
- } from "@eventuras/fides-auth-next";
199
+ import { createFidesAuth } from "@eventuras/fides-auth-next";
201
200
 
202
- const { session, reason } = await tryGetCurrentSession();
201
+ const auth = createFidesAuth({ secret: process.env.SESSION_SECRET!, oauthConfig });
202
+
203
+ const { session, reason } = await auth.tryGetCurrentSession();
203
204
  ```
204
205
 
205
- `tryRefreshCurrentSession` is the matching refresh, returning `cause` so a route
206
- can answer 503 rather than ending a session over a provider blip. If you need the
207
- framework-agnostic helpers directly, `nextCookieStore` is exported too.
206
+ `auth.tryRefreshCurrentSession` is the matching refresh, returning `cause` so a
207
+ route can answer 503 rather than ending a session over a provider blip. If you
208
+ need the framework-agnostic helpers directly, `nextCookieStore` is exported too.