@eventuras/fides-auth 0.9.0 → 0.11.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 (55) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +32 -0
  3. package/dist/cookies.d.ts +105 -0
  4. package/dist/cookies.d.ts.map +1 -0
  5. package/dist/cookies.js +118 -0
  6. package/dist/cookies.js.map +1 -0
  7. package/dist/heartbeat.d.ts +91 -0
  8. package/dist/heartbeat.d.ts.map +1 -0
  9. package/dist/heartbeat.js +169 -0
  10. package/dist/heartbeat.js.map +1 -0
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +3 -2
  14. package/dist/logger.js +1 -1
  15. package/dist/logger.js.map +1 -1
  16. package/dist/oauth-browser.js.map +1 -1
  17. package/dist/oauth.d.ts +52 -6
  18. package/dist/oauth.d.ts.map +1 -1
  19. package/dist/oauth.js +57 -33
  20. package/dist/oauth.js.map +1 -1
  21. package/dist/providers/vipps/index.js +2 -1
  22. package/dist/providers/vipps/index.js.map +1 -1
  23. package/dist/server/cookie-store.d.ts +10 -0
  24. package/dist/server/cookie-store.d.ts.map +1 -0
  25. package/dist/server/cookie-store.js +0 -0
  26. package/dist/server/heartbeat-handler.d.ts +21 -0
  27. package/dist/server/heartbeat-handler.d.ts.map +1 -0
  28. package/dist/server/index.d.ts +7 -0
  29. package/dist/server/index.d.ts.map +1 -0
  30. package/dist/server/index.js +293 -0
  31. package/dist/server/index.js.map +1 -0
  32. package/dist/server/oidc-callback.d.ts +35 -0
  33. package/dist/server/oidc-callback.d.ts.map +1 -0
  34. package/dist/server/oidc-login.d.ts +23 -0
  35. package/dist/server/oidc-login.d.ts.map +1 -0
  36. package/dist/server/oidc-logout.d.ts +44 -0
  37. package/dist/server/oidc-logout.d.ts.map +1 -0
  38. package/dist/server/session.d.ts +33 -0
  39. package/dist/server/session.d.ts.map +1 -0
  40. package/dist/session-cookies.d.ts +52 -0
  41. package/dist/session-cookies.d.ts.map +1 -0
  42. package/dist/session-cookies.js +110 -0
  43. package/dist/session-cookies.js.map +1 -0
  44. package/dist/session-refresh.d.ts.map +1 -1
  45. package/dist/session-refresh.js +2 -1
  46. package/dist/session-refresh.js.map +1 -1
  47. package/dist/silent-login.js.map +1 -1
  48. package/dist/types.d.ts +5 -0
  49. package/dist/types.d.ts.map +1 -1
  50. package/dist/utils.d.ts +12 -0
  51. package/dist/utils.d.ts.map +1 -1
  52. package/dist/utils.js +16 -2
  53. package/dist/utils.js.map +1 -1
  54. package/docs/rp-initiated-logout.md +139 -0
  55. package/package.json +27 -9
@@ -0,0 +1,110 @@
1
+ import { createLogger } from "./logger.js";
2
+ import { createEncryptedJWT, decryptJWT } from "./utils.js";
3
+ import { validateSessionJwt } from "./session-validation.js";
4
+ import { decodeJwt } from "jose";
5
+ //#region src/session-cookies.ts
6
+ var logger = createLogger({ namespace: "fides-auth:session-cookies" });
7
+ /**
8
+ * True only when the access token is a JWT carrying an `exp` claim that is in the
9
+ * past. Mirrors {@link validateSessionJwt}: an access token we can't decode (an
10
+ * opaque token, or one without `exp`) has unknown expiry and must NOT be treated
11
+ * as expired — otherwise opaque tokens would silently discard the whole session.
12
+ */
13
+ function accessTokenIsExpired(accessToken) {
14
+ try {
15
+ const { exp } = decodeJwt(accessToken);
16
+ if (typeof exp !== "number") return false;
17
+ return exp - Date.now() / 1e3 <= 0;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ /**
23
+ * Encrypts a session into the three cookie values, splitting the raw access and ID
24
+ * tokens out.
25
+ *
26
+ * @param session - The session to encode.
27
+ * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM).
28
+ */
29
+ async function encodeSessionCookies(session, secret) {
30
+ const { tokens, ...rest } = session;
31
+ const accessToken = tokens?.accessToken;
32
+ const idToken = tokens?.idToken;
33
+ const coreSession = {
34
+ ...rest,
35
+ tokens: tokens ? {
36
+ ...tokens,
37
+ accessToken: void 0,
38
+ idToken: void 0
39
+ } : void 0
40
+ };
41
+ return {
42
+ session: await createEncryptedJWT(coreSession, secret),
43
+ accessToken: accessToken ? await createEncryptedJWT({ accessToken }, secret) : void 0,
44
+ idToken: idToken ? await createEncryptedJWT({ idToken }, secret) : void 0
45
+ };
46
+ }
47
+ /**
48
+ * Decodes the cookie values back into a session, reattaching the access and ID tokens.
49
+ *
50
+ * Returns null when there is no session, the main value fails validation, or the
51
+ * access token has expired — preserving the contract that an expired access token
52
+ * means "no session". A corrupt access-token value is tolerated: the session is
53
+ * returned without an access token rather than discarded.
54
+ *
55
+ * @param raw - The raw cookie values read from the store.
56
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
57
+ */
58
+ async function decodeSessionCookies(raw, secret) {
59
+ if (!raw.session) return null;
60
+ const { status, session } = await validateSessionJwt(raw.session, secret);
61
+ if (status !== "VALID" || !session) {
62
+ logger.debug({ status }, "Session cookie did not validate");
63
+ return null;
64
+ }
65
+ if (raw.accessToken) try {
66
+ const payload = await decryptJWT(raw.accessToken, secret);
67
+ const accessToken = typeof payload.accessToken === "string" ? payload.accessToken : void 0;
68
+ if (accessToken) {
69
+ if (accessTokenIsExpired(accessToken)) {
70
+ logger.info("Access token has expired");
71
+ return null;
72
+ }
73
+ session.tokens = {
74
+ ...session.tokens,
75
+ accessToken
76
+ };
77
+ }
78
+ } catch (error) {
79
+ logger.warn({ error }, "Failed to decode access-token cookie");
80
+ }
81
+ if (raw.idToken) {
82
+ const idToken = await decodeIdTokenCookie(raw.idToken, secret);
83
+ if (idToken) session.tokens = {
84
+ ...session.tokens,
85
+ idToken
86
+ };
87
+ }
88
+ return session;
89
+ }
90
+ /**
91
+ * Decrypts the ID-token cookie value on its own, independent of session validity.
92
+ * Logout needs the hint precisely when the session has gone stale, so this must not
93
+ * go through {@link decodeSessionCookies}.
94
+ *
95
+ * @param raw - The ID-token cookie value.
96
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
97
+ */
98
+ async function decodeIdTokenCookie(raw, secret) {
99
+ try {
100
+ const payload = await decryptJWT(raw, secret);
101
+ return typeof payload.idToken === "string" ? payload.idToken : void 0;
102
+ } catch (error) {
103
+ logger.warn({ error }, "Failed to decode ID-token cookie");
104
+ return;
105
+ }
106
+ }
107
+ //#endregion
108
+ export { decodeIdTokenCookie, decodeSessionCookies, encodeSessionCookies };
109
+
110
+ //# sourceMappingURL=session-cookies.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-cookies.js","names":[],"sources":["../src/session-cookies.ts"],"sourcesContent":["// session-cookies.ts\n//\n// Framework-agnostic encoding/decoding of a session into cookie *values*. This\n// module performs no I/O — it never reads or writes cookies. Framework adapters\n// (Next.js, React Router, …) own the actual cookie store and call these helpers\n// to split a session across two cookie values and to reassemble it again.\n//\n// The split: the large raw JWTs — the access token and the ID token (kept for\n// `id_token_hint` on logout) — each get their own encrypted value, everything else\n// goes in the main one, so no cookie has to carry two JWTs against the ~4KB limit.\n\nimport { decodeJwt } from 'jose';\n\nimport { createEncryptedJWT, decryptJWT } from './utils';\nimport { validateSessionJwt } from './session-validation';\nimport { createLogger } from './logger';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-cookies' });\n\n/**\n * True only when the access token is a JWT carrying an `exp` claim that is in the\n * past. Mirrors {@link validateSessionJwt}: an access token we can't decode (an\n * opaque token, or one without `exp`) has unknown expiry and must NOT be treated\n * as expired — otherwise opaque tokens would silently discard the whole session.\n */\nfunction accessTokenIsExpired(accessToken: string): boolean {\n try {\n const { exp } = decodeJwt(accessToken);\n if (typeof exp !== 'number') {\n return false;\n }\n return exp - Date.now() / 1000 <= 0;\n } catch {\n return false;\n }\n}\n\n/** Encrypted cookie values produced from a session. */\nexport interface EncodedSessionCookies {\n /** Value for the main session cookie (everything except the raw access/ID tokens). */\n session: string;\n /**\n * Value for the access-token cookie, or `undefined` when the session carries\n * no access token. Adapters should delete the access-token cookie in that case.\n */\n accessToken?: string;\n /** Value for the ID-token cookie, or `undefined` when the session carries none. */\n idToken?: string;\n}\n\n/** Raw cookie values read from the store, before decoding. */\nexport interface RawSessionCookies {\n /** The main session cookie value, or null if absent. */\n session: string | null;\n /** The access-token cookie value, or null if absent. */\n accessToken: string | null;\n /** The ID-token cookie value, or null if absent. */\n idToken?: string | null;\n}\n\n/**\n * Encrypts a session into the three cookie values, splitting the raw access and ID\n * tokens out.\n *\n * @param session - The session to encode.\n * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM).\n */\nexport async function encodeSessionCookies(\n session: Session,\n secret: string | Uint8Array,\n): Promise<EncodedSessionCookies> {\n const { tokens, ...rest } = session;\n const accessToken = tokens?.accessToken;\n const idToken = tokens?.idToken;\n\n // Main value: the whole session minus the raw access and ID tokens (undefined\n // keys are dropped during JWT serialization).\n const coreSession: Session = {\n ...rest,\n tokens: tokens ? { ...tokens, accessToken: undefined, idToken: undefined } : undefined,\n };\n\n const main = await createEncryptedJWT(coreSession, secret);\n\n return {\n session: main,\n accessToken: accessToken ? await createEncryptedJWT({ accessToken }, secret) : undefined,\n idToken: idToken ? await createEncryptedJWT({ idToken }, secret) : undefined,\n };\n}\n\n/**\n * Decodes the cookie values back into a session, reattaching the access and ID tokens.\n *\n * Returns null when there is no session, the main value fails validation, or the\n * access token has expired — preserving the contract that an expired access token\n * means \"no session\". A corrupt access-token value is tolerated: the session is\n * returned without an access token rather than discarded.\n *\n * @param raw - The raw cookie values read from the store.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).\n */\nexport async function decodeSessionCookies(\n raw: RawSessionCookies,\n secret: string | Uint8Array,\n): Promise<Session | null> {\n if (!raw.session) {\n return null;\n }\n\n const { status, session } = await validateSessionJwt(raw.session, secret);\n\n // status === 'EXPIRED' only fires for legacy single-cookie sessions, where the\n // access token still lives in the main value. Split sessions are checked below.\n if (status !== 'VALID' || !session) {\n logger.debug({ status }, 'Session cookie did not validate');\n return null;\n }\n\n if (raw.accessToken) {\n try {\n const payload = await decryptJWT(raw.accessToken, secret);\n const accessToken = typeof payload.accessToken === 'string' ? payload.accessToken : undefined;\n\n if (accessToken) {\n // Preserve the contract: a verifiably-expired access token means no\n // session. Opaque/undecodable tokens are kept (unknown expiry).\n if (accessTokenIsExpired(accessToken)) {\n logger.info('Access token has expired');\n return null;\n }\n session.tokens = { ...session.tokens, accessToken };\n }\n } catch (error) {\n // A corrupt/forged access-token value shouldn't take down the session.\n logger.warn({ error }, 'Failed to decode access-token cookie');\n }\n }\n\n if (raw.idToken) {\n const idToken = await decodeIdTokenCookie(raw.idToken, secret);\n if (idToken) {\n session.tokens = { ...session.tokens, idToken };\n }\n }\n\n return session;\n}\n\n/**\n * Decrypts the ID-token cookie value on its own, independent of session validity.\n * Logout needs the hint precisely when the session has gone stale, so this must not\n * go through {@link decodeSessionCookies}.\n *\n * @param raw - The ID-token cookie value.\n * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).\n */\nexport async function decodeIdTokenCookie(\n raw: string,\n secret: string | Uint8Array,\n): Promise<string | undefined> {\n try {\n const payload = await decryptJWT(raw, secret);\n return typeof payload.idToken === 'string' ? payload.idToken : undefined;\n } catch (error) {\n logger.warn({ error }, 'Failed to decode ID-token cookie');\n return undefined;\n }\n}\n"],"mappings":";;;;;AAkBA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;AAQvE,SAAS,qBAAqB,aAA8B;CAC1D,IAAI;EACF,MAAM,EAAE,QAAQ,UAAU,WAAW;EACrC,IAAI,OAAO,QAAQ,UACjB,OAAO;EAET,OAAO,MAAM,KAAK,IAAI,IAAI,OAAQ;CACpC,QAAQ;EACN,OAAO;CACT;AACF;;;;;;;;AAgCA,eAAsB,qBACpB,SACA,QACgC;CAChC,MAAM,EAAE,QAAQ,GAAG,SAAS;CAC5B,MAAM,cAAc,QAAQ;CAC5B,MAAM,UAAU,QAAQ;CAIxB,MAAM,cAAuB;EAC3B,GAAG;EACH,QAAQ,SAAS;GAAE,GAAG;GAAQ,aAAa,KAAA;GAAW,SAAS,KAAA;EAAU,IAAI,KAAA;CAC/E;CAIA,OAAO;EACL,SAAS,MAHQ,mBAAmB,aAAa,MAAM;EAIvD,aAAa,cAAc,MAAM,mBAAmB,EAAE,YAAY,GAAG,MAAM,IAAI,KAAA;EAC/E,SAAS,UAAU,MAAM,mBAAmB,EAAE,QAAQ,GAAG,MAAM,IAAI,KAAA;CACrE;AACF;;;;;;;;;;;;AAaA,eAAsB,qBACpB,KACA,QACyB;CACzB,IAAI,CAAC,IAAI,SACP,OAAO;CAGT,MAAM,EAAE,QAAQ,YAAY,MAAM,mBAAmB,IAAI,SAAS,MAAM;CAIxE,IAAI,WAAW,WAAW,CAAC,SAAS;EAClC,OAAO,MAAM,EAAE,OAAO,GAAG,iCAAiC;EAC1D,OAAO;CACT;CAEA,IAAI,IAAI,aACN,IAAI;EACF,MAAM,UAAU,MAAM,WAAW,IAAI,aAAa,MAAM;EACxD,MAAM,cAAc,OAAO,QAAQ,gBAAgB,WAAW,QAAQ,cAAc,KAAA;EAEpF,IAAI,aAAa;GAGf,IAAI,qBAAqB,WAAW,GAAG;IACrC,OAAO,KAAK,0BAA0B;IACtC,OAAO;GACT;GACA,QAAQ,SAAS;IAAE,GAAG,QAAQ;IAAQ;GAAY;EACpD;CACF,SAAS,OAAO;EAEd,OAAO,KAAK,EAAE,MAAM,GAAG,sCAAsC;CAC/D;CAGF,IAAI,IAAI,SAAS;EACf,MAAM,UAAU,MAAM,oBAAoB,IAAI,SAAS,MAAM;EAC7D,IAAI,SACF,QAAQ,SAAS;GAAE,GAAG,QAAQ;GAAQ;EAAQ;CAElD;CAEA,OAAO;AACT;;;;;;;;;AAUA,eAAsB,oBACpB,KACA,QAC6B;CAC7B,IAAI;EACF,MAAM,UAAU,MAAM,WAAW,KAAK,MAAM;EAC5C,OAAO,OAAO,QAAQ,YAAY,WAAW,QAAQ,UAAU,KAAA;CACjE,SAAS,OAAO;EACd,OAAO,KAAK,EAAE,MAAM,GAAG,kCAAkC;EACzD;CACF;AACF"}
@@ -1 +1 @@
1
- {"version":3,"file":"session-refresh.d.ts","sourceRoot":"","sources":["../src/session-refresh.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAsB,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIxD;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,iBAAiB,OAAO,EACxB,QAAQ,WAAW,EACnB,UAAS,oBAAyB,KACjC,OAAO,CAAC,OAAO,GAAG,IAAI,CA8CxB,CAAC"}
1
+ {"version":3,"file":"session-refresh.d.ts","sourceRoot":"","sources":["../src/session-refresh.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,WAAW,EAAsB,MAAM,SAAS,CAAC;AAE1D,OAAO,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIxD;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,GACzB,iBAAiB,OAAO,EACxB,QAAQ,WAAW,EACnB,UAAS,oBAAyB,KACjC,OAAO,CAAC,OAAO,GAAG,IAAI,CAgDxB,CAAC"}
@@ -26,7 +26,8 @@ var refreshSession = async (current_session, config, options = {}) => {
26
26
  ...current_session.tokens,
27
27
  accessToken: newtokens.access_token,
28
28
  accessTokenExpiresAt: newtokens.expires_in ? new Date(Date.now() + newtokens.expires_in * 1e3).toISOString() : void 0,
29
- refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken
29
+ refreshToken: newtokens.refresh_token ?? current_session.tokens?.refreshToken,
30
+ idToken: newtokens.id_token ?? current_session.tokens?.idToken
30
31
  }
31
32
  };
32
33
  logger.info({
@@ -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 },\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,EAAE,YAAY,IAC/D,KAAA;IACJ,cAAc,UAAU,iBAAiB,gBAAgB,QAAQ;GACnE;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 { 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 +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';\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 openid.discovery(\n new URL(config.issuer),\n config.clientId,\n config.clientSecret,\n openid.ClientSecretPost(config.clientSecret)\n );\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":";;;;;;;;;;;;AAeA,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,OAAO,UAC1B,IAAI,IAAI,OAAO,MAAM,GACrB,OAAO,UACP,OAAO,cACP,OAAO,iBAAiB,OAAO,YAAY,CAC7C;EAEA,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,EAAE,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 { OAuthConfig } from './oauth';\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 openid.discovery(\n new URL(config.issuer),\n config.clientId,\n config.clientSecret,\n openid.ClientSecretPost(config.clientSecret)\n );\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":";;;;;;;;;;;;AAeA,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,OAAO,UAC1B,IAAI,IAAI,OAAO,MAAM,GACrB,OAAO,UACP,OAAO,cACP,OAAO,iBAAiB,OAAO,YAAY,CAC7C;EAEA,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"}
package/dist/types.d.ts CHANGED
@@ -6,6 +6,11 @@ export interface Tokens {
6
6
  refreshToken?: string;
7
7
  /** ISO 8601 string — see {@link accessTokenExpiresAt}. */
8
8
  refreshTokenExpiresAt?: string;
9
+ /**
10
+ * Raw (encoded) ID token, sent as `id_token_hint` on RP-initiated logout. Rides
11
+ * in the access-token cookie, not the main one — see `session-cookies.ts`.
12
+ */
13
+ idToken?: string;
9
14
  }
10
15
  /** Authenticated user session with tokens, user info, and optional custom data. */
11
16
  export interface Session<TData = Record<string, unknown>> {
@@ -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;CAChC;AAED,mFAAmF;AACnF,MAAM,WAAW,OAAO,CAAC,KAAK,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IACtD,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,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"}
package/dist/utils.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { Session } from './types';
2
+ import * as jose from 'jose';
2
3
  /**
3
4
  * Converts a hex string to a Uint8Array.
4
5
  *
@@ -14,6 +15,17 @@ export declare function hexToUint8Array(hex: string): Uint8Array;
14
15
  * @returns A promise that resolves to the encrypted JWT as a string.
15
16
  */
16
17
  export declare function createEncryptedJWT(payload: unknown, secret: string | Uint8Array): Promise<string>;
18
+ /**
19
+ * Decrypts a JWE produced by {@link createEncryptedJWT} and returns its payload.
20
+ *
21
+ * Counterpart to {@link createEncryptedJWT}. Throws if the token cannot be
22
+ * decrypted (wrong key, tampering, or malformed input).
23
+ *
24
+ * @param encryptedJwt - The encrypted JWT string.
25
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
26
+ * @returns A promise that resolves to the decrypted JWT payload.
27
+ */
28
+ export declare function decryptJWT(encryptedJwt: string, secret: string | Uint8Array): Promise<jose.JWTPayload>;
17
29
  /**
18
30
  * Encrypts the given text using AES-GCM via the Web Crypto API.
19
31
  * Returns a string in the format: iv:authTag:ciphertext, all in hex.
@@ -1 +1 @@
1
- {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAGA,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;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;;;;;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"}
package/dist/utils.js CHANGED
@@ -37,6 +37,20 @@ async function createEncryptedJWT(payload, secret) {
37
37
  }).setIssuedAt().encrypt(normalizeSecret(secret));
38
38
  }
39
39
  /**
40
+ * Decrypts a JWE produced by {@link createEncryptedJWT} and returns its payload.
41
+ *
42
+ * Counterpart to {@link createEncryptedJWT}. Throws if the token cannot be
43
+ * decrypted (wrong key, tampering, or malformed input).
44
+ *
45
+ * @param encryptedJwt - The encrypted JWT string.
46
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
47
+ * @returns A promise that resolves to the decrypted JWT payload.
48
+ */
49
+ async function decryptJWT(encryptedJwt, secret) {
50
+ const { payload } = await jose.jwtDecrypt(encryptedJwt, normalizeSecret(secret));
51
+ return payload;
52
+ }
53
+ /**
40
54
  * Encrypts the given text using AES-GCM via the Web Crypto API.
41
55
  * Returns a string in the format: iv:authTag:ciphertext, all in hex.
42
56
  *
@@ -47,7 +61,7 @@ async function createEncryptedJWT(payload, secret) {
47
61
  async function encrypt(text, secret) {
48
62
  const keyData = normalizeSecret(secret);
49
63
  const key = await crypto.subtle.importKey("raw", keyData, { name: "AES-GCM" }, false, ["encrypt"]);
50
- const iv = crypto.getRandomValues(new Uint8Array(12));
64
+ const iv = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
51
65
  const data = new TextEncoder().encode(text);
52
66
  const encryptedBuffer = await crypto.subtle.encrypt({
53
67
  name: "AES-GCM",
@@ -191,6 +205,6 @@ function hasScope(session, scope) {
191
205
  return session.scopes?.includes(scope) ?? false;
192
206
  }
193
207
  //#endregion
194
- export { accessTokenExpires, createEncryptedJWT, decrypt, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex };
208
+ export { accessTokenExpires, createEncryptedJWT, decrypt, decryptJWT, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex };
195
209
 
196
210
  //# 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/**\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 * 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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"}
@@ -0,0 +1,139 @@
1
+ # RP-Initiated Logout
2
+
3
+ Clearing your application's session cookie is not a logout. The provider's session
4
+ cookie survives, so the next `/authorize` round trip re-authenticates silently and
5
+ the user appears logged in again. Ending the provider session too requires an
6
+ RP-initiated logout request — [OpenID Connect RP-Initiated Logout 1.0][spec].
7
+
8
+ "RP" is the *relying party*: your application, the one relying on the provider for
9
+ authentication. The provider itself is the "OP", the *OpenID provider*. RP-initiated
10
+ means your application starts the logout, as opposed to OP-initiated logout, where the
11
+ provider notifies applications that a session ended elsewhere — fides-auth has no
12
+ support for that direction.
13
+
14
+ [spec]: https://openid.net/specs/openid-connect-rpinitiated-1_0.html
15
+
16
+ ## The short version
17
+
18
+ ```typescript
19
+ import { handleOidcLogout } from "@eventuras/fides-auth/server";
20
+
21
+ return handleOidcLogout(request, {
22
+ oauthConfig,
23
+ cookies,
24
+ postLogoutRedirectUri: "https://app.example.com/",
25
+ });
26
+ ```
27
+
28
+ 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`.
31
+
32
+ ## Building the URL yourself
33
+
34
+ ```typescript
35
+ import { buildOidcLogoutUrl } from "@eventuras/fides-auth/oauth";
36
+
37
+ const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {
38
+ postLogoutRedirectUri: "https://app.example.com/",
39
+ idTokenHint: session?.tokens?.idToken,
40
+ state: "correlation-123",
41
+ });
42
+ ```
43
+
44
+ | Option | Sent as | Notes |
45
+ | ---------------------- | -------------------------- | ----------------------------------------------------------- |
46
+ | `postLogoutRedirectUri` | `post_logout_redirect_uri` | Must be registered with the provider |
47
+ | `idTokenHint` | `id_token_hint` | Identifies the session to end; recommended |
48
+ | `state` | `state` | Echoed back unchanged on the redirect |
49
+ | `logoutHint` | `logout_hint` | Meaning is provider-specific |
50
+ | `includeClientId` | `client_id` | Default `true` |
51
+
52
+ Unsupplied parameters are omitted. A string second argument is shorthand for
53
+ `postLogoutRedirectUri`.
54
+
55
+ `buildOidcLogoutUrl` returns `null` when the provider advertises no
56
+ `end_session_endpoint`, when discovery fails, or when the endpoint is not https —
57
+ the URL carries a raw ID token, so it never travels in the clear. Callers fall back to
58
+ a local-only logout.
59
+
60
+ ## Why `id_token_hint` matters
61
+
62
+ The spec recommends it, and in practice it is what makes logout work smoothly:
63
+
64
+ - It tells the provider **which** session to terminate when several are active.
65
+ - Providers that can skip the "are you sure you want to sign out?" interstitial
66
+ generally do so only when the hint is present.
67
+
68
+ `session.tokens.idToken` holds the raw token. `buildSessionFromTokens` populates it,
69
+ and `refreshSession` replaces it whenever a refresh response carries a new `id_token`
70
+ — otherwise the hint would go stale and eventually fall outside the window in which
71
+ the provider still recognises it.
72
+
73
+ ## Why `client_id` is still sent
74
+
75
+ `client_id` is sent by default, including alongside `id_token_hint`. The spec allows
76
+ both:
77
+
78
+ > When both `client_id` and `id_token_hint` are present, the OP MUST verify that the
79
+ > Client Identifier matches the one used when issuing the ID Token.
80
+
81
+ It also earns its place in the degraded cases. A provider **must not** redirect to
82
+ `post_logout_redirect_uri` unless it can confirm the target is legitimate, and
83
+ `client_id` is one of the ways it does that — which is what keeps logout working when
84
+ there is no session left to take a hint from, or when the ID token is old enough that
85
+ the provider no longer accepts it.
86
+
87
+ Set `includeClientId: false` only for a provider that documents rejecting the
88
+ combination.
89
+
90
+ ## Where the ID token is stored
91
+
92
+ Sessions are split across three cookies: the access token in `session_at`, the ID
93
+ token in `session_it`, everything else in `session`. Each large JWT gets its own
94
+ per-cookie byte budget — an encoded ID token is typically 0.5–1.5 kB, and cookies at
95
+ or above 4096 bytes are silently dropped by the browser, so sharing a cookie between
96
+ two JWTs would eventually surface as a failed *login*.
97
+
98
+ The ID token is also read independently of session validity. `readSession` returns
99
+ null once the access token has expired, but the hint is needed precisely then: an
100
+ idle user coming back to a stale tab and clicking log out still has a live session at
101
+ the provider. `readIdToken` reads the `session_it` cookie directly, and
102
+ `handleOidcLogout` uses it rather than going through the session.
103
+
104
+ ## Correlating the response with `state`
105
+
106
+ `state` is passed through to the provider and echoed back on the redirect. fides-auth
107
+ does not store or verify it; generating a value, persisting it, and checking it on the
108
+ way back is the caller's job.
109
+
110
+ ## CSRF
111
+
112
+ `handleOidcLogout` accepts `POST` only and answers `405` to anything else. That alone
113
+ is not protection — a third-party page can auto-submit a form at the endpoint, and the
114
+ response clears the cookies whether or not the browser attached them — so the handler
115
+ also requires the request to come from the application itself:
116
+
117
+ - `Sec-Fetch-Site` must be `same-origin` or `none` where the browser sends it.
118
+ - Otherwise `Origin`, when present, must match `applicationUrl` (falling back to the
119
+ request's own origin). Set `applicationUrl` behind a proxy, where the request URL
120
+ carries the internal origin.
121
+ - A request with neither header is not a browser form post and is allowed through.
122
+
123
+ Cross-origin requests get `403`. Cross-*subdomain* logout is rejected too; if you need
124
+ it, call `buildOidcLogoutUrl` from your own handler with whatever origin check fits.
125
+
126
+ Use a form or a fetch rather than a plain link:
127
+
128
+ ```html
129
+ <form method="post" action="/api/auth/logout"><button>Log out</button></form>
130
+ ```
131
+
132
+ Pass `allowedMethods: ["GET", "POST"]` to opt into link-based logout — note this
133
+ drops the method check, leaving only the origin check.
134
+
135
+ ## Non-Next.js frameworks
136
+
137
+ `handleOidcLogout` takes a standard `Request` and a `CookieStore` — an object with
138
+ `get`, `set` and `delete`. Any framework that can expose its cookie jar behind those
139
+ three methods can use it, the same way `handleOidcLogin` and `handleOidcCallback` do.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eventuras/fides-auth",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "Framework-agnostic OAuth/OIDC authentication library with PKCE, session management, and pluggable logging",
5
5
  "keywords": [
6
6
  "oauth",
@@ -13,14 +13,14 @@
13
13
  "identity",
14
14
  "vipps"
15
15
  ],
16
- "homepage": "https://github.com/losol/eventuras/tree/main/libs/fides-auth#readme",
16
+ "homepage": "https://github.com/losol/fides-auth/tree/main/packages/fides-auth#readme",
17
17
  "bugs": {
18
- "url": "https://github.com/losol/eventuras/issues"
18
+ "url": "https://github.com/losol/fides-auth/issues"
19
19
  },
20
20
  "repository": {
21
21
  "type": "git",
22
- "url": "https://github.com/losol/eventuras.git",
23
- "directory": "libs/fides-auth"
22
+ "url": "https://github.com/losol/fides-auth.git",
23
+ "directory": "packages/fides-auth"
24
24
  },
25
25
  "license": "MIT",
26
26
  "author": "losol <losol@users.noreply.github.com>",
@@ -35,6 +35,14 @@
35
35
  "types": "./dist/activity-tracker.d.ts",
36
36
  "import": "./dist/activity-tracker.js"
37
37
  },
38
+ "./cookies": {
39
+ "types": "./dist/cookies.d.ts",
40
+ "import": "./dist/cookies.js"
41
+ },
42
+ "./heartbeat": {
43
+ "types": "./dist/heartbeat.d.ts",
44
+ "import": "./dist/heartbeat.js"
45
+ },
38
46
  "./logger": {
39
47
  "types": "./dist/logger.d.ts",
40
48
  "import": "./dist/logger.js"
@@ -47,6 +55,10 @@
47
55
  "types": "./dist/session-validation.d.ts",
48
56
  "import": "./dist/session-validation.js"
49
57
  },
58
+ "./session-cookies": {
59
+ "types": "./dist/session-cookies.d.ts",
60
+ "import": "./dist/session-cookies.js"
61
+ },
50
62
  "./oauth": {
51
63
  "types": "./dist/oauth.d.ts",
52
64
  "import": "./dist/oauth.js"
@@ -75,6 +87,10 @@
75
87
  "types": "./dist/rate-limit.d.ts",
76
88
  "import": "./dist/rate-limit.js"
77
89
  },
90
+ "./server": {
91
+ "types": "./dist/server/index.d.ts",
92
+ "import": "./dist/server/index.js"
93
+ },
78
94
  "./providers/vipps": {
79
95
  "types": "./dist/providers/vipps/index.d.ts",
80
96
  "import": "./dist/providers/vipps/index.js"
@@ -84,17 +100,19 @@
84
100
  "types": "./dist/index.d.ts",
85
101
  "files": [
86
102
  "dist",
103
+ "docs",
87
104
  "LICENSE",
88
105
  "README.md"
89
106
  ],
90
107
  "dependencies": {
91
- "jose": "6.2.3",
108
+ "jose": "^6.2.8",
92
109
  "openid-client": "^6.8.3"
93
110
  },
94
111
  "devDependencies": {
95
- "@types/node": "^24.12.0",
96
- "vite": "^8.0.8",
97
- "vitest": "^4.1.4",
112
+ "@types/node": "^22.20.1",
113
+ "jsdom": "29.1.1",
114
+ "vite": "^8.2.0",
115
+ "vitest": "^4.1.10",
98
116
  "@eventuras/typescript-config": "1.0.0",
99
117
  "@eventuras/vite-config": "0.2.2"
100
118
  },