@eventuras/fides-auth 0.12.0 → 0.14.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 (49) hide show
  1. package/dist/heartbeat.d.ts +20 -2
  2. package/dist/heartbeat.d.ts.map +1 -1
  3. package/dist/heartbeat.js +37 -3
  4. package/dist/heartbeat.js.map +1 -1
  5. package/dist/index.d.ts +1 -0
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +3 -2
  8. package/dist/oauth-logging.d.ts +15 -0
  9. package/dist/oauth-logging.d.ts.map +1 -1
  10. package/dist/oauth-logging.js +63 -1
  11. package/dist/oauth-logging.js.map +1 -1
  12. package/dist/oauth.d.ts +2 -2
  13. package/dist/oauth.d.ts.map +1 -1
  14. package/dist/oauth.js +9 -6
  15. package/dist/oauth.js.map +1 -1
  16. package/dist/oidc-discovery.d.ts +42 -0
  17. package/dist/oidc-discovery.d.ts.map +1 -0
  18. package/dist/oidc-discovery.js +82 -0
  19. package/dist/oidc-discovery.js.map +1 -0
  20. package/dist/providers/vipps/client.d.ts +6 -3
  21. package/dist/providers/vipps/client.d.ts.map +1 -1
  22. package/dist/providers/vipps/index.js +8 -5
  23. package/dist/providers/vipps/index.js.map +1 -1
  24. package/dist/server/heartbeat-handler.d.ts +10 -3
  25. package/dist/server/heartbeat-handler.d.ts.map +1 -1
  26. package/dist/server/index.d.ts +1 -1
  27. package/dist/server/index.d.ts.map +1 -1
  28. package/dist/server/index.js +182 -53
  29. package/dist/server/index.js.map +1 -1
  30. package/dist/server/oidc-callback.d.ts.map +1 -1
  31. package/dist/server/oidc-logout.d.ts.map +1 -1
  32. package/dist/server/session.d.ts +75 -3
  33. package/dist/server/session.d.ts.map +1 -1
  34. package/dist/session-cookies.d.ts +25 -0
  35. package/dist/session-cookies.d.ts.map +1 -1
  36. package/dist/session-cookies.js +35 -14
  37. package/dist/session-cookies.js.map +1 -1
  38. package/dist/session-events.d.ts +149 -0
  39. package/dist/session-events.d.ts.map +1 -0
  40. package/dist/session-events.js +96 -0
  41. package/dist/session-events.js.map +1 -0
  42. package/dist/silent-login.d.ts.map +1 -1
  43. package/dist/silent-login.js +2 -1
  44. package/dist/silent-login.js.map +1 -1
  45. package/dist/types.d.ts +7 -0
  46. package/dist/types.d.ts.map +1 -1
  47. package/docs/discovery-cache.md +53 -0
  48. package/docs/session-events.md +207 -0
  49. package/package.json +15 -5
@@ -1,3 +1,4 @@
1
+ import { SessionRejectedReason } from './session-events';
1
2
  import { Session } from './types';
2
3
  /** Encrypted cookie values produced from a session. */
3
4
  export interface EncodedSessionCookies {
@@ -28,6 +29,27 @@ export interface RawSessionCookies {
28
29
  * @param secret - The encryption key as a hex string or Uint8Array (32 bytes for A256GCM).
29
30
  */
30
31
  export declare function encodeSessionCookies(session: Session, secret: string | Uint8Array): Promise<EncodedSessionCookies>;
32
+ /** Outcome of decoding the cookie values: a session, or why there isn't one. */
33
+ export type DecodedSessionCookies = {
34
+ session: Session;
35
+ reason?: undefined;
36
+ } | {
37
+ session: null;
38
+ reason: SessionRejectedReason;
39
+ };
40
+ /**
41
+ * Decodes the cookie values back into a session, reporting why when it can't.
42
+ *
43
+ * Same rules as {@link decodeSessionCookies}, but the failure carries a
44
+ * {@link SessionRejectedReason} from the package-wide vocabulary so a proxy or a
45
+ * status route logs the same word for the same situation as the heartbeat
46
+ * endpoint does. Deliberately does not log: the caller knows the request
47
+ * context, and logging here too would double every rejection.
48
+ *
49
+ * @param raw - The raw cookie values read from the store.
50
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
51
+ */
52
+ export declare function tryDecodeSessionCookies(raw: RawSessionCookies, secret: string | Uint8Array): Promise<DecodedSessionCookies>;
31
53
  /**
32
54
  * Decodes the cookie values back into a session, reattaching the access and ID tokens.
33
55
  *
@@ -37,6 +59,9 @@ export declare function encodeSessionCookies(session: Session, secret: string |
37
59
  * single-cookie session (one re-login). A corrupt access-token value is
38
60
  * tolerated: the session is returned without it.
39
61
  *
62
+ * Prefer {@link tryDecodeSessionCookies} when you want to log *why* there is no
63
+ * session.
64
+ *
40
65
  * @param raw - The raw cookie values read from the store.
41
66
  * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
42
67
  */
@@ -1 +1 @@
1
- {"version":3,"file":"session-cookies.d.ts","sourceRoot":"","sources":["../src/session-cookies.ts"],"names":[],"mappings":"AAcA,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIlC,uDAAuD;AACvD,MAAM,WAAW,qBAAqB;IACpC,sFAAsF;IACtF,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,wDAAwD;IACxD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CAmBhC;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,iBAAiB,EACtB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAoCzB;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ7B"}
1
+ {"version":3,"file":"session-cookies.d.ts","sourceRoot":"","sources":["../src/session-cookies.ts"],"names":[],"mappings":"AAcA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,kBAAkB,CAAC;AAC9D,OAAO,EAAE,OAAO,EAAE,MAAM,SAAS,CAAC;AAIlC,uDAAuD;AACvD,MAAM,WAAW,qBAAqB;IACpC,sFAAsF;IACtF,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mFAAmF;IACnF,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,8DAA8D;AAC9D,MAAM,WAAW,iBAAiB;IAChC,wDAAwD;IACxD,OAAO,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,wDAAwD;IACxD,WAAW,EAAE,MAAM,GAAG,IAAI,CAAC;IAC3B,oDAAoD;IACpD,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CAmBhC;AAED,gFAAgF;AAChF,MAAM,MAAM,qBAAqB,GAC7B;IAAE,OAAO,EAAE,OAAO,CAAC;IAAC,MAAM,CAAC,EAAE,SAAS,CAAA;CAAE,GACxC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,MAAM,EAAE,qBAAqB,CAAA;CAAE,CAAC;AAErD;;;;;;;;;;;GAWG;AACH,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,iBAAiB,EACtB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,qBAAqB,CAAC,CAyChC;AAED;;;;;;;;;;;;;;GAcG;AACH,wBAAsB,oBAAoB,CACxC,GAAG,EAAE,iBAAiB,EACtB,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAEzB;AAED;;;;;;;GAOG;AACH,wBAAsB,mBAAmB,CACvC,GAAG,EAAE,MAAM,EACX,MAAM,EAAE,MAAM,GAAG,UAAU,GAC1B,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQ7B"}
@@ -29,24 +29,27 @@ async function encodeSessionCookies(session, secret) {
29
29
  };
30
30
  }
31
31
  /**
32
- * Decodes the cookie values back into a session, reattaching the access and ID tokens.
32
+ * Decodes the cookie values back into a session, reporting why when it can't.
33
33
  *
34
- * Returns the session whenever the main value decrypts and validates — an
35
- * expired access token does not hide it; token freshness is the caller's
36
- * concern. Returns null for a missing/invalid main value, or a stale legacy
37
- * single-cookie session (one re-login). A corrupt access-token value is
38
- * tolerated: the session is returned without it.
34
+ * Same rules as {@link decodeSessionCookies}, but the failure carries a
35
+ * {@link SessionRejectedReason} from the package-wide vocabulary so a proxy or a
36
+ * status route logs the same word for the same situation as the heartbeat
37
+ * endpoint does. Deliberately does not log: the caller knows the request
38
+ * context, and logging here too would double every rejection.
39
39
  *
40
40
  * @param raw - The raw cookie values read from the store.
41
41
  * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
42
42
  */
43
- async function decodeSessionCookies(raw, secret) {
44
- if (!raw.session) return null;
43
+ async function tryDecodeSessionCookies(raw, secret) {
44
+ if (!raw.session) return {
45
+ session: null,
46
+ reason: "no_session_cookie"
47
+ };
45
48
  const { status, session } = await validateSessionJwt(raw.session, secret);
46
- if (status !== "VALID" || !session) {
47
- logger.debug({ status }, "Session cookie did not validate");
48
- return null;
49
- }
49
+ if (status !== "VALID" || !session) return {
50
+ session: null,
51
+ reason: status === "EXPIRED" ? "stale_legacy_session" : "unreadable_session"
52
+ };
50
53
  if (raw.accessToken) try {
51
54
  const payload = await decryptJWT(raw.accessToken, secret);
52
55
  const accessToken = typeof payload.accessToken === "string" ? payload.accessToken : void 0;
@@ -64,7 +67,25 @@ async function decodeSessionCookies(raw, secret) {
64
67
  idToken
65
68
  };
66
69
  }
67
- return session;
70
+ return { session };
71
+ }
72
+ /**
73
+ * Decodes the cookie values back into a session, reattaching the access and ID tokens.
74
+ *
75
+ * Returns the session whenever the main value decrypts and validates — an
76
+ * expired access token does not hide it; token freshness is the caller's
77
+ * concern. Returns null for a missing/invalid main value, or a stale legacy
78
+ * single-cookie session (one re-login). A corrupt access-token value is
79
+ * tolerated: the session is returned without it.
80
+ *
81
+ * Prefer {@link tryDecodeSessionCookies} when you want to log *why* there is no
82
+ * session.
83
+ *
84
+ * @param raw - The raw cookie values read from the store.
85
+ * @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
86
+ */
87
+ async function decodeSessionCookies(raw, secret) {
88
+ return (await tryDecodeSessionCookies(raw, secret)).session;
68
89
  }
69
90
  /**
70
91
  * Decrypts the ID-token cookie value on its own, independent of session validity.
@@ -84,6 +105,6 @@ async function decodeIdTokenCookie(raw, secret) {
84
105
  }
85
106
  }
86
107
  //#endregion
87
- export { decodeIdTokenCookie, decodeSessionCookies, encodeSessionCookies };
108
+ export { decodeIdTokenCookie, decodeSessionCookies, encodeSessionCookies, tryDecodeSessionCookies };
88
109
 
89
110
  //# sourceMappingURL=session-cookies.js.map
@@ -1 +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 { 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/** 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 the session whenever the main value decrypts and validates — an\n * expired access token does not hide it; token freshness is the caller's\n * concern. Returns null for a missing/invalid main value, or a stale legacy\n * single-cookie session (one re-login). A corrupt access-token value is\n * tolerated: the session is returned without it.\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 // EXPIRED only fires for legacy single-cookie sessions — dropped, not migrated.\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 // Attached as stored, expired or not — freshness is the caller's concern.\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":";;;;AAgBA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;AAgCvE,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;;;;;;;;;;;;;AAcA,eAAsB,qBACpB,KACA,QACyB;CACzB,IAAI,CAAC,IAAI,SACP,OAAO;CAGT,MAAM,EAAE,QAAQ,YAAY,MAAM,mBAAmB,IAAI,SAAS,MAAM;CAGxE,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,aAEF,QAAQ,SAAS;GAAE,GAAG,QAAQ;GAAQ;EAAY;CAEtD,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
+ {"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 { createEncryptedJWT, decryptJWT } from './utils';\nimport { validateSessionJwt } from './session-validation';\nimport { createLogger } from './logger';\nimport type { SessionRejectedReason } from './session-events';\nimport { Session } from './types';\n\nconst logger = createLogger({ namespace: 'fides-auth:session-cookies' });\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/** Outcome of decoding the cookie values: a session, or why there isn't one. */\nexport type DecodedSessionCookies =\n | { session: Session; reason?: undefined }\n | { session: null; reason: SessionRejectedReason };\n\n/**\n * Decodes the cookie values back into a session, reporting why when it can't.\n *\n * Same rules as {@link decodeSessionCookies}, but the failure carries a\n * {@link SessionRejectedReason} from the package-wide vocabulary so a proxy or a\n * status route logs the same word for the same situation as the heartbeat\n * endpoint does. Deliberately does not log: the caller knows the request\n * context, and logging here too would double every rejection.\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 tryDecodeSessionCookies(\n raw: RawSessionCookies,\n secret: string | Uint8Array,\n): Promise<DecodedSessionCookies> {\n if (!raw.session) {\n return { session: null, reason: 'no_session_cookie' };\n }\n\n const { status, session } = await validateSessionJwt(raw.session, secret);\n\n // EXPIRED only fires for legacy single-cookie sessions — dropped, not migrated,\n // and worth separating from corruption because it costs exactly one re-login.\n // validateSessionJwt already logs why it rejected the value; this layer only\n // translates that into the shared vocabulary.\n if (status !== 'VALID' || !session) {\n return {\n session: null,\n reason: status === 'EXPIRED' ? 'stale_legacy_session' : 'unreadable_session',\n };\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 // Attached as stored, expired or not — freshness is the caller's concern.\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 * Decodes the cookie values back into a session, reattaching the access and ID tokens.\n *\n * Returns the session whenever the main value decrypts and validates — an\n * expired access token does not hide it; token freshness is the caller's\n * concern. Returns null for a missing/invalid main value, or a stale legacy\n * single-cookie session (one re-login). A corrupt access-token value is\n * tolerated: the session is returned without it.\n *\n * Prefer {@link tryDecodeSessionCookies} when you want to log *why* there is no\n * session.\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 return (await tryDecodeSessionCookies(raw, secret)).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":";;;;AAiBA,IAAM,SAAS,aAAa,EAAE,WAAW,6BAA6B,CAAC;;;;;;;;AAgCvE,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;;;;;;;;;;;;;AAmBA,eAAsB,wBACpB,KACA,QACgC;CAChC,IAAI,CAAC,IAAI,SACP,OAAO;EAAE,SAAS;EAAM,QAAQ;CAAoB;CAGtD,MAAM,EAAE,QAAQ,YAAY,MAAM,mBAAmB,IAAI,SAAS,MAAM;CAMxE,IAAI,WAAW,WAAW,CAAC,SACzB,OAAO;EACL,SAAS;EACT,QAAQ,WAAW,YAAY,yBAAyB;CAC1D;CAGF,IAAI,IAAI,aACN,IAAI;EACF,MAAM,UAAU,MAAM,WAAW,IAAI,aAAa,MAAM;EACxD,MAAM,cAAc,OAAO,QAAQ,gBAAgB,WAAW,QAAQ,cAAc,KAAA;EAEpF,IAAI,aAEF,QAAQ,SAAS;GAAE,GAAG,QAAQ;GAAQ;EAAY;CAEtD,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,EAAE,QAAQ;AACnB;;;;;;;;;;;;;;;;AAiBA,eAAsB,qBACpB,KACA,QACyB;CACzB,QAAQ,MAAM,wBAAwB,KAAK,MAAM,EAAA,CAAG;AACtD;;;;;;;;;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"}
@@ -0,0 +1,149 @@
1
+ import { FidesLogger } from './logger';
2
+ /**
3
+ * Event names. Use these constants rather than string literals so a rename is a
4
+ * compile error at every call site instead of a silently broken dashboard.
5
+ */
6
+ export declare const SESSION_EVENT: {
7
+ /** A session was created from a token response (login, or client-credentials grant). */
8
+ readonly CREATED: "session.created";
9
+ /** An existing session was renewed with a fresh access token. */
10
+ readonly REFRESHED: "session.refreshed";
11
+ /** A request could not proceed with the session it had. Carries a {@link SessionRejectedReason}. */
12
+ readonly REJECTED: "session.rejected";
13
+ /** A refresh attempt failed. Carries a {@link RefreshFailureCause}. */
14
+ readonly REFRESH_FAILED: "session.refresh_failed";
15
+ /** Session cookies were deleted. Carries a {@link SessionClearedTrigger}. */
16
+ readonly CLEARED: "session.cleared";
17
+ };
18
+ export type SessionEventName = (typeof SESSION_EVENT)[keyof typeof SESSION_EVENT];
19
+ /**
20
+ * Why a session could not be used for a request.
21
+ *
22
+ * One vocabulary for the whole package, so a proxy, a status route and the
23
+ * heartbeat endpoint all report the same word for the same situation.
24
+ */
25
+ export type SessionRejectedReason =
26
+ /** No session cookie was sent. The ordinary anonymous case. */
27
+ 'no_session_cookie'
28
+ /** A session cookie was sent but did not decrypt or validate — corruption, or a rotated secret. */
29
+ | 'unreadable_session'
30
+ /** A pre-split single-cookie session. Dropped rather than migrated; costs one re-login. */
31
+ | 'stale_legacy_session'
32
+ /** The session decoded but carries no refresh token — usually `offline_access` was never granted. */
33
+ | 'no_refresh_token'
34
+ /** The refresh token was rejected by the provider. See {@link RefreshFailureCause}. */
35
+ | 'refresh_failed';
36
+ /**
37
+ * Why a refresh attempt failed.
38
+ *
39
+ * The distinction that matters operationally: only `invalid_grant` means the
40
+ * user must log in again. The other two are infrastructure faults where the
41
+ * session is probably still fine, and logging the user out would be wrong.
42
+ */
43
+ export type RefreshFailureCause =
44
+ /** The provider rejected the refresh token: expired, revoked, or already used. Terminal. */
45
+ 'invalid_grant'
46
+ /** The provider was never reached — DNS, TCP, TLS, timeout, abort. Retryable. */
47
+ | 'transport'
48
+ /** The provider was reached but answered with an error or something unparseable. Retryable. */
49
+ | 'idp_error';
50
+ /** What caused the session cookies to be deleted. */
51
+ export type SessionClearedTrigger =
52
+ /** The user (or the app) logged out deliberately. */
53
+ 'logout'
54
+ /** The session was unusable and cleaned up. */
55
+ | 'rejected';
56
+ /** Structured payload for one lifecycle event. */
57
+ export type SessionEvent = {
58
+ event: typeof SESSION_EVENT.CREATED;
59
+ sid?: string;
60
+ /** False here guarantees logouts at access-token TTL — the single most useful login-time signal. */
61
+ hasRefreshToken: boolean;
62
+ /** Scopes the provider actually granted, which is not necessarily what was requested. */
63
+ scopes?: string[];
64
+ /** Seconds until the access token expires. */
65
+ expiresIn?: number;
66
+ /** Total bytes written across the session cookies. Near ~4096 means a session about to break. */
67
+ cookieBytes?: number;
68
+ } | {
69
+ event: typeof SESSION_EVENT.REFRESHED;
70
+ sid?: string;
71
+ expiresIn?: number;
72
+ /** True when the provider issued a new refresh token. Rotation turns a lost cookie write into a dead session. */
73
+ rotatedRefreshToken: boolean;
74
+ cookieBytes?: number;
75
+ } | {
76
+ event: typeof SESSION_EVENT.REJECTED;
77
+ sid?: string;
78
+ reason: SessionRejectedReason;
79
+ /** Which component made the call, e.g. 'heartbeat'. */
80
+ source?: string;
81
+ } | {
82
+ event: typeof SESSION_EVENT.REFRESH_FAILED;
83
+ sid?: string;
84
+ cause: RefreshFailureCause;
85
+ /** HTTP status from the provider, when it answered at all. */
86
+ status?: number;
87
+ /** Expiry of the access token we were trying to replace — how long the session had left. */
88
+ accessTokenExpiresAt?: string;
89
+ error?: unknown;
90
+ } | {
91
+ event: typeof SESSION_EVENT.CLEARED;
92
+ sid?: string;
93
+ trigger: SessionClearedTrigger;
94
+ };
95
+ type Level = 'debug' | 'info' | 'warn' | 'error';
96
+ /** Resolves the level for an event, applying the per-reason and per-cause policy. */
97
+ export declare function sessionEventLevel(payload: SessionEvent): Level;
98
+ /**
99
+ * Emits one lifecycle event at the level the policy dictates.
100
+ *
101
+ * Call sites pass data, never a level or a message — that is what keeps the
102
+ * vocabulary consistent across modules and makes a dashboard built on `event`
103
+ * and `reason` keep working.
104
+ */
105
+ export declare function logSessionEvent(logger: FidesLogger, payload: SessionEvent): void;
106
+ /** The reasons, as a runtime list — for validating values that arrive over the wire. */
107
+ export declare const SESSION_REJECTED_REASONS: readonly ["no_session_cookie", "unreadable_session", "stale_legacy_session", "no_refresh_token", "refresh_failed"];
108
+ /** Narrows an untrusted value (a response body, a query param) to a known reason. */
109
+ export declare function isSessionRejectedReason(value: unknown): value is SessionRejectedReason;
110
+ /**
111
+ * Browser-side counterpart of {@link SessionEvent}.
112
+ *
113
+ * Same names and same `reason` vocabulary as the server events, so a consumer
114
+ * that beacons these to its own endpoint gets one queryable stream across both
115
+ * halves of a session. The shapes differ where the browser genuinely knows
116
+ * less: it never learns *why* a refresh failed server-side, only the status it
117
+ * got back, so there is no `cause` here to invent.
118
+ */
119
+ export type SessionClientEvent = {
120
+ event: typeof SESSION_EVENT.REFRESHED;
121
+ source: 'heartbeat';
122
+ /** Epoch ms of the new access-token expiry, or null when the server didn't say. */
123
+ expiresAt: number | null;
124
+ } | {
125
+ event: typeof SESSION_EVENT.REJECTED;
126
+ source: 'heartbeat' | 'session-monitor';
127
+ /** The server's own reason when it sent one; absent when it didn't. */
128
+ reason?: SessionRejectedReason;
129
+ } | {
130
+ event: typeof SESSION_EVENT.REFRESH_FAILED;
131
+ source: 'heartbeat';
132
+ /** HTTP status, when there was a response at all. Absent means the request never landed. */
133
+ status?: number;
134
+ /** 1 for the first consecutive failure, 2 for the next, and so on. */
135
+ attempt: number;
136
+ error?: string;
137
+ };
138
+ /**
139
+ * Mints a session correlation id: 16 hex characters (64 bits).
140
+ *
141
+ * Deliberately random rather than derived from the refresh token. A hash of the
142
+ * refresh token would change every time the provider rotates it — which is on
143
+ * every refresh for a default Keycloak — and correlating a session's whole life
144
+ * is the entire point. Rotation stays visible through
145
+ * `rotatedRefreshToken` on {@link SESSION_EVENT.REFRESHED} instead.
146
+ */
147
+ export declare function newSessionId(): string;
148
+ export {};
149
+ //# sourceMappingURL=session-events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-events.d.ts","sourceRoot":"","sources":["../src/session-events.ts"],"names":[],"mappings":"AAeA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAE5C;;;GAGG;AACH,eAAO,MAAM,aAAa;IACxB,wFAAwF;;IAExF,iEAAiE;;IAEjE,oGAAoG;;IAEpG,uEAAuE;;IAEvE,6EAA6E;;CAErE,CAAC;AAEX,MAAM,MAAM,gBAAgB,GAAG,CAAC,OAAO,aAAa,CAAC,CAAC,MAAM,OAAO,aAAa,CAAC,CAAC;AAElF;;;;;GAKG;AACH,MAAM,MAAM,qBAAqB;AAC/B,+DAA+D;AAC7D,mBAAmB;AACrB,mGAAmG;GACjG,oBAAoB;AACtB,2FAA2F;GACzF,sBAAsB;AACxB,qGAAqG;GACnG,kBAAkB;AACpB,uFAAuF;GACrF,gBAAgB,CAAC;AAErB;;;;;;GAMG;AACH,MAAM,MAAM,mBAAmB;AAC7B,4FAA4F;AAC1F,eAAe;AACjB,iFAAiF;GAC/E,WAAW;AACb,+FAA+F;GAC7F,WAAW,CAAC;AAEhB,qDAAqD;AACrD,MAAM,MAAM,qBAAqB;AAC/B,qDAAqD;AACnD,QAAQ;AACV,+CAA+C;GAC7C,UAAU,CAAC;AAEf,kDAAkD;AAClD,MAAM,MAAM,YAAY,GACpB;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,OAAO,CAAC;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,oGAAoG;IACpG,eAAe,EAAE,OAAO,CAAC;IACzB,yFAAyF;IACzF,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAClB,8CAA8C;IAC9C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iGAAiG;IACjG,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,SAAS,CAAC;IACtC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iHAAiH;IACjH,mBAAmB,EAAE,OAAO,CAAC;IAC7B,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,QAAQ,CAAC;IACrC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,MAAM,EAAE,qBAAqB,CAAC;IAC9B,uDAAuD;IACvD,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,cAAc,CAAC;IAC3C,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,mBAAmB,CAAC;IAC3B,8DAA8D;IAC9D,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,OAAO,CAAC;IACpC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,qBAAqB,CAAC;CAChC,CAAC;AAEJ,KAAK,KAAK,GAAG,OAAO,GAAG,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;AAkCjD,qFAAqF;AACrF,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,YAAY,GAAG,KAAK,CAS9D;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,WAAW,EAAE,OAAO,EAAE,YAAY,GAAG,IAAI,CAQhF;AAGD,wFAAwF;AACxF,eAAO,MAAM,wBAAwB,oHAMgB,CAAC;AAEtD,qFAAqF;AACrF,wBAAgB,uBAAuB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,qBAAqB,CAKtF;AAED;;;;;;;;GAQG;AACH,MAAM,MAAM,kBAAkB,GAC1B;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,SAAS,CAAC;IACtC,MAAM,EAAE,WAAW,CAAC;IACpB,mFAAmF;IACnF,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,QAAQ,CAAC;IACrC,MAAM,EAAE,WAAW,GAAG,iBAAiB,CAAC;IACxC,uEAAuE;IACvE,MAAM,CAAC,EAAE,qBAAqB,CAAC;CAChC,GACC;IACA,KAAK,EAAE,OAAO,aAAa,CAAC,cAAc,CAAC;IAC3C,MAAM,EAAE,WAAW,CAAC;IACpB,4FAA4F;IAC5F,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEJ;;;;;;;;GAQG;AACH,wBAAgB,YAAY,IAAI,MAAM,CAIrC"}
@@ -0,0 +1,96 @@
1
+ //#region src/session-events.ts
2
+ /**
3
+ * Event names. Use these constants rather than string literals so a rename is a
4
+ * compile error at every call site instead of a silently broken dashboard.
5
+ */
6
+ var SESSION_EVENT = {
7
+ /** A session was created from a token response (login, or client-credentials grant). */
8
+ CREATED: "session.created",
9
+ /** An existing session was renewed with a fresh access token. */
10
+ REFRESHED: "session.refreshed",
11
+ /** A request could not proceed with the session it had. Carries a {@link SessionRejectedReason}. */
12
+ REJECTED: "session.rejected",
13
+ /** A refresh attempt failed. Carries a {@link RefreshFailureCause}. */
14
+ REFRESH_FAILED: "session.refresh_failed",
15
+ /** Session cookies were deleted. Carries a {@link SessionClearedTrigger}. */
16
+ CLEARED: "session.cleared"
17
+ };
18
+ /**
19
+ * Level per reason rather than per event.
20
+ *
21
+ * A single level for the whole event would be wrong in both directions: an
22
+ * anonymous request hitting a protected route is not a warning, and a session
23
+ * cookie that won't decrypt is not routine. Getting this wrong is how a warn
24
+ * stream becomes unreadable and operators conclude there is "nothing in the logs".
25
+ */
26
+ var REJECTED_LEVEL = {
27
+ no_session_cookie: "debug",
28
+ stale_legacy_session: "info",
29
+ refresh_failed: "info",
30
+ unreadable_session: "warn",
31
+ no_refresh_token: "warn"
32
+ };
33
+ /** Only `invalid_grant` is business as usual; the rest are someone's infrastructure failing. */
34
+ var REFRESH_FAILED_LEVEL = {
35
+ invalid_grant: "info",
36
+ transport: "warn",
37
+ idp_error: "error"
38
+ };
39
+ /** Human-readable message per event; the machine-readable truth is in the fields. */
40
+ var MESSAGE = {
41
+ [SESSION_EVENT.CREATED]: "Session created",
42
+ [SESSION_EVENT.REFRESHED]: "Session refreshed",
43
+ [SESSION_EVENT.REJECTED]: "Session rejected",
44
+ [SESSION_EVENT.REFRESH_FAILED]: "Session refresh failed",
45
+ [SESSION_EVENT.CLEARED]: "Session cleared"
46
+ };
47
+ /** Resolves the level for an event, applying the per-reason and per-cause policy. */
48
+ function sessionEventLevel(payload) {
49
+ switch (payload.event) {
50
+ case SESSION_EVENT.REJECTED: return REJECTED_LEVEL[payload.reason] ?? "warn";
51
+ case SESSION_EVENT.REFRESH_FAILED: return REFRESH_FAILED_LEVEL[payload.cause] ?? "error";
52
+ default: return "info";
53
+ }
54
+ }
55
+ /**
56
+ * Emits one lifecycle event at the level the policy dictates.
57
+ *
58
+ * Call sites pass data, never a level or a message — that is what keeps the
59
+ * vocabulary consistent across modules and makes a dashboard built on `event`
60
+ * and `reason` keep working.
61
+ */
62
+ function logSessionEvent(logger, payload) {
63
+ const level = sessionEventLevel(payload);
64
+ const fields = Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== void 0));
65
+ logger[level](fields, MESSAGE[payload.event]);
66
+ }
67
+ /** The reasons, as a runtime list — for validating values that arrive over the wire. */
68
+ var SESSION_REJECTED_REASONS = [
69
+ "no_session_cookie",
70
+ "unreadable_session",
71
+ "stale_legacy_session",
72
+ "no_refresh_token",
73
+ "refresh_failed"
74
+ ];
75
+ /** Narrows an untrusted value (a response body, a query param) to a known reason. */
76
+ function isSessionRejectedReason(value) {
77
+ return typeof value === "string" && SESSION_REJECTED_REASONS.includes(value);
78
+ }
79
+ /**
80
+ * Mints a session correlation id: 16 hex characters (64 bits).
81
+ *
82
+ * Deliberately random rather than derived from the refresh token. A hash of the
83
+ * refresh token would change every time the provider rotates it — which is on
84
+ * every refresh for a default Keycloak — and correlating a session's whole life
85
+ * is the entire point. Rotation stays visible through
86
+ * `rotatedRefreshToken` on {@link SESSION_EVENT.REFRESHED} instead.
87
+ */
88
+ function newSessionId() {
89
+ const bytes = /* @__PURE__ */ new Uint8Array(8);
90
+ crypto.getRandomValues(bytes);
91
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
92
+ }
93
+ //#endregion
94
+ export { SESSION_EVENT, SESSION_REJECTED_REASONS, isSessionRejectedReason, logSessionEvent, newSessionId, sessionEventLevel };
95
+
96
+ //# sourceMappingURL=session-events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-events.js","names":[],"sources":["../src/session-events.ts"],"sourcesContent":["// session-events.ts\n//\n// The log vocabulary fides-auth emits for the session lifecycle. Every point\n// where the library creates a session, renews one, or decides one cannot\n// proceed emits exactly one event from this module — so a production operator\n// can answer \"which path ended this session?\" from logs alone, without reading\n// library source.\n//\n// Naming: `<area>.<outcome>`, dotted and past tense. Dotted so a dashboard can\n// match a whole area with a prefix (`event=~\"session\\..*\"`), past tense because\n// every event records something that already happened.\n//\n// The fields are deliberately low-cardinality except `sid` — see docs/session-events.md\n// for the dashboard/alerting contract.\n\nimport type { FidesLogger } from './logger';\n\n/**\n * Event names. Use these constants rather than string literals so a rename is a\n * compile error at every call site instead of a silently broken dashboard.\n */\nexport const SESSION_EVENT = {\n /** A session was created from a token response (login, or client-credentials grant). */\n CREATED: 'session.created',\n /** An existing session was renewed with a fresh access token. */\n REFRESHED: 'session.refreshed',\n /** A request could not proceed with the session it had. Carries a {@link SessionRejectedReason}. */\n REJECTED: 'session.rejected',\n /** A refresh attempt failed. Carries a {@link RefreshFailureCause}. */\n REFRESH_FAILED: 'session.refresh_failed',\n /** Session cookies were deleted. Carries a {@link SessionClearedTrigger}. */\n CLEARED: 'session.cleared',\n} as const;\n\nexport type SessionEventName = (typeof SESSION_EVENT)[keyof typeof SESSION_EVENT];\n\n/**\n * Why a session could not be used for a request.\n *\n * One vocabulary for the whole package, so a proxy, a status route and the\n * heartbeat endpoint all report the same word for the same situation.\n */\nexport type SessionRejectedReason =\n /** No session cookie was sent. The ordinary anonymous case. */\n | 'no_session_cookie'\n /** A session cookie was sent but did not decrypt or validate — corruption, or a rotated secret. */\n | 'unreadable_session'\n /** A pre-split single-cookie session. Dropped rather than migrated; costs one re-login. */\n | 'stale_legacy_session'\n /** The session decoded but carries no refresh token — usually `offline_access` was never granted. */\n | 'no_refresh_token'\n /** The refresh token was rejected by the provider. See {@link RefreshFailureCause}. */\n | 'refresh_failed';\n\n/**\n * Why a refresh attempt failed.\n *\n * The distinction that matters operationally: only `invalid_grant` means the\n * user must log in again. The other two are infrastructure faults where the\n * session is probably still fine, and logging the user out would be wrong.\n */\nexport type RefreshFailureCause =\n /** The provider rejected the refresh token: expired, revoked, or already used. Terminal. */\n | 'invalid_grant'\n /** The provider was never reached — DNS, TCP, TLS, timeout, abort. Retryable. */\n | 'transport'\n /** The provider was reached but answered with an error or something unparseable. Retryable. */\n | 'idp_error';\n\n/** What caused the session cookies to be deleted. */\nexport type SessionClearedTrigger =\n /** The user (or the app) logged out deliberately. */\n | 'logout'\n /** The session was unusable and cleaned up. */\n | 'rejected';\n\n/** Structured payload for one lifecycle event. */\nexport type SessionEvent =\n | {\n event: typeof SESSION_EVENT.CREATED;\n sid?: string;\n /** False here guarantees logouts at access-token TTL — the single most useful login-time signal. */\n hasRefreshToken: boolean;\n /** Scopes the provider actually granted, which is not necessarily what was requested. */\n scopes?: string[];\n /** Seconds until the access token expires. */\n expiresIn?: number;\n /** Total bytes written across the session cookies. Near ~4096 means a session about to break. */\n cookieBytes?: number;\n }\n | {\n event: typeof SESSION_EVENT.REFRESHED;\n sid?: string;\n expiresIn?: number;\n /** True when the provider issued a new refresh token. Rotation turns a lost cookie write into a dead session. */\n rotatedRefreshToken: boolean;\n cookieBytes?: number;\n }\n | {\n event: typeof SESSION_EVENT.REJECTED;\n sid?: string;\n reason: SessionRejectedReason;\n /** Which component made the call, e.g. 'heartbeat'. */\n source?: string;\n }\n | {\n event: typeof SESSION_EVENT.REFRESH_FAILED;\n sid?: string;\n cause: RefreshFailureCause;\n /** HTTP status from the provider, when it answered at all. */\n status?: number;\n /** Expiry of the access token we were trying to replace — how long the session had left. */\n accessTokenExpiresAt?: string;\n error?: unknown;\n }\n | {\n event: typeof SESSION_EVENT.CLEARED;\n sid?: string;\n trigger: SessionClearedTrigger;\n };\n\ntype Level = 'debug' | 'info' | 'warn' | 'error';\n\n/**\n * Level per reason rather than per event.\n *\n * A single level for the whole event would be wrong in both directions: an\n * anonymous request hitting a protected route is not a warning, and a session\n * cookie that won't decrypt is not routine. Getting this wrong is how a warn\n * stream becomes unreadable and operators conclude there is \"nothing in the logs\".\n */\nconst REJECTED_LEVEL: Record<SessionRejectedReason, Level> = {\n no_session_cookie: 'debug',\n stale_legacy_session: 'info',\n refresh_failed: 'info',\n unreadable_session: 'warn',\n no_refresh_token: 'warn',\n};\n\n/** Only `invalid_grant` is business as usual; the rest are someone's infrastructure failing. */\nconst REFRESH_FAILED_LEVEL: Record<RefreshFailureCause, Level> = {\n invalid_grant: 'info',\n transport: 'warn',\n idp_error: 'error',\n};\n\n/** Human-readable message per event; the machine-readable truth is in the fields. */\nconst MESSAGE: Record<SessionEventName, string> = {\n [SESSION_EVENT.CREATED]: 'Session created',\n [SESSION_EVENT.REFRESHED]: 'Session refreshed',\n [SESSION_EVENT.REJECTED]: 'Session rejected',\n [SESSION_EVENT.REFRESH_FAILED]: 'Session refresh failed',\n [SESSION_EVENT.CLEARED]: 'Session cleared',\n};\n\n/** Resolves the level for an event, applying the per-reason and per-cause policy. */\nexport function sessionEventLevel(payload: SessionEvent): Level {\n switch (payload.event) {\n case SESSION_EVENT.REJECTED:\n return REJECTED_LEVEL[payload.reason] ?? 'warn';\n case SESSION_EVENT.REFRESH_FAILED:\n return REFRESH_FAILED_LEVEL[payload.cause] ?? 'error';\n default:\n return 'info';\n }\n}\n\n/**\n * Emits one lifecycle event at the level the policy dictates.\n *\n * Call sites pass data, never a level or a message — that is what keeps the\n * vocabulary consistent across modules and makes a dashboard built on `event`\n * and `reason` keep working.\n */\nexport function logSessionEvent(logger: FidesLogger, payload: SessionEvent): void {\n const level = sessionEventLevel(payload);\n // Undefined fields are dropped so absent data doesn't render as `\"sid\":null`\n // and pollute label sets in the log backend.\n const fields = Object.fromEntries(\n Object.entries(payload).filter(([, value]) => value !== undefined),\n );\n logger[level](fields, MESSAGE[payload.event]);\n}\n\n\n/** The reasons, as a runtime list — for validating values that arrive over the wire. */\nexport const SESSION_REJECTED_REASONS = [\n 'no_session_cookie',\n 'unreadable_session',\n 'stale_legacy_session',\n 'no_refresh_token',\n 'refresh_failed',\n] as const satisfies readonly SessionRejectedReason[];\n\n/** Narrows an untrusted value (a response body, a query param) to a known reason. */\nexport function isSessionRejectedReason(value: unknown): value is SessionRejectedReason {\n return (\n typeof value === 'string' &&\n (SESSION_REJECTED_REASONS as readonly string[]).includes(value)\n );\n}\n\n/**\n * Browser-side counterpart of {@link SessionEvent}.\n *\n * Same names and same `reason` vocabulary as the server events, so a consumer\n * that beacons these to its own endpoint gets one queryable stream across both\n * halves of a session. The shapes differ where the browser genuinely knows\n * less: it never learns *why* a refresh failed server-side, only the status it\n * got back, so there is no `cause` here to invent.\n */\nexport type SessionClientEvent =\n | {\n event: typeof SESSION_EVENT.REFRESHED;\n source: 'heartbeat';\n /** Epoch ms of the new access-token expiry, or null when the server didn't say. */\n expiresAt: number | null;\n }\n | {\n event: typeof SESSION_EVENT.REJECTED;\n source: 'heartbeat' | 'session-monitor';\n /** The server's own reason when it sent one; absent when it didn't. */\n reason?: SessionRejectedReason;\n }\n | {\n event: typeof SESSION_EVENT.REFRESH_FAILED;\n source: 'heartbeat';\n /** HTTP status, when there was a response at all. Absent means the request never landed. */\n status?: number;\n /** 1 for the first consecutive failure, 2 for the next, and so on. */\n attempt: number;\n error?: string;\n };\n\n/**\n * Mints a session correlation id: 16 hex characters (64 bits).\n *\n * Deliberately random rather than derived from the refresh token. A hash of the\n * refresh token would change every time the provider rotates it — which is on\n * every refresh for a default Keycloak — and correlating a session's whole life\n * is the entire point. Rotation stays visible through\n * `rotatedRefreshToken` on {@link SESSION_EVENT.REFRESHED} instead.\n */\nexport function newSessionId(): string {\n const bytes = new Uint8Array(8);\n crypto.getRandomValues(bytes);\n return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');\n}\n"],"mappings":";;;;;AAqBA,IAAa,gBAAgB;;CAE3B,SAAS;;CAET,WAAW;;CAEX,UAAU;;CAEV,gBAAgB;;CAEhB,SAAS;AACX;;;;;;;;;AAmGA,IAAM,iBAAuD;CAC3D,mBAAmB;CACnB,sBAAsB;CACtB,gBAAgB;CAChB,oBAAoB;CACpB,kBAAkB;AACpB;;AAGA,IAAM,uBAA2D;CAC/D,eAAe;CACf,WAAW;CACX,WAAW;AACb;;AAGA,IAAM,UAA4C;EAC/C,cAAc,UAAU;EACxB,cAAc,YAAY;EAC1B,cAAc,WAAW;EACzB,cAAc,iBAAiB;EAC/B,cAAc,UAAU;AAC3B;;AAGA,SAAgB,kBAAkB,SAA8B;CAC9D,QAAQ,QAAQ,OAAhB;EACE,KAAK,cAAc,UACjB,OAAO,eAAe,QAAQ,WAAW;EAC3C,KAAK,cAAc,gBACjB,OAAO,qBAAqB,QAAQ,UAAU;EAChD,SACE,OAAO;CACX;AACF;;;;;;;;AASA,SAAgB,gBAAgB,QAAqB,SAA6B;CAChF,MAAM,QAAQ,kBAAkB,OAAO;CAGvC,MAAM,SAAS,OAAO,YACpB,OAAO,QAAQ,OAAO,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,KAAA,CAAS,CACnE;CACA,OAAO,MAAM,CAAC,QAAQ,QAAQ,QAAQ,MAAM;AAC9C;;AAIA,IAAa,2BAA2B;CACtC;CACA;CACA;CACA;CACA;AACF;;AAGA,SAAgB,wBAAwB,OAAgD;CACtF,OACE,OAAO,UAAU,YAChB,yBAA+C,SAAS,KAAK;AAElE;;;;;;;;;;AA2CA,SAAgB,eAAuB;CACrC,MAAM,wBAAQ,IAAI,WAAW,CAAC;CAC9B,OAAO,gBAAgB,KAAK;CAC5B,OAAO,MAAM,KAAK,QAAQ,MAAM,EAAE,SAAS,EAAE,CAAC,CAAC,SAAS,GAAG,GAAG,CAAC,CAAC,CAAC,KAAK,EAAE;AAC1E"}
@@ -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;AAItC,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,CAoCd;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;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,4 +1,5 @@
1
1
  import { createLogger } from "./logger.js";
2
+ import { discoverConfiguration } from "./oidc-discovery.js";
2
3
  import * as openid from "openid-client";
3
4
  //#region src/silent-login.ts
4
5
  /**
@@ -35,7 +36,7 @@ var logger = createLogger({ namespace: "fides-auth:silent-login" });
35
36
  async function buildSilentLoginUrl(config, options = {}) {
36
37
  logger.debug({ issuer: config.issuer }, "Building silent login URL");
37
38
  try {
38
- const server = await openid.discovery(new URL(config.issuer), config.clientId, config.clientSecret, openid.ClientSecretPost(config.clientSecret));
39
+ const server = await discoverConfiguration(config);
39
40
  const parameters = {
40
41
  redirect_uri: config.redirect_uri,
41
42
  scope: config.scope,
@@ -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,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 { 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"}
package/dist/types.d.ts CHANGED
@@ -14,6 +14,13 @@ export interface Tokens {
14
14
  }
15
15
  /** Authenticated user session with tokens, user info, and optional custom data. */
16
16
  export interface Session<TData = Record<string, unknown>> {
17
+ /**
18
+ * Correlation id for this session, minted at creation and carried across
19
+ * refreshes. Logged as `sid` on every session lifecycle event so one user's
20
+ * login, heartbeats and eventual logout can be tied together. Absent on
21
+ * sessions created before this field existed.
22
+ */
23
+ sid?: string;
17
24
  tokens?: Tokens;
18
25
  user?: {
19
26
  name: string;
@@ -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,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,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"}
@@ -0,0 +1,53 @@
1
+ # Discovery cache
2
+
3
+ Every OIDC flow needs the provider's metadata — token endpoint, authorization
4
+ endpoint, end-session endpoint. fides-auth fetches it once per provider and
5
+ reuses it for five minutes.
6
+
7
+ ## Why it matters
8
+
9
+ Without a cache, a refresh is two round-trips: a GET to
10
+ `/.well-known/openid-configuration`, then the token request. On the heartbeat
11
+ path that happens per user every few minutes. It doubles refresh latency,
12
+ doubles the load your provider sees, and doubles the surface on which a refresh
13
+ can fail — a discovery blip and a token-endpoint blip are indistinguishable to
14
+ the caller, and both end up as a `session.refresh_failed` your operators have to
15
+ explain.
16
+
17
+ Concurrent callers share one round-trip. A burst of heartbeats arriving together
18
+ does not become a burst of discovery requests, because the cache holds the
19
+ in-flight promise rather than waiting for a result to store.
20
+
21
+ ## What is cached, and for how long
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.
26
+
27
+ Failures are never cached. A provider that was briefly unreachable is retried on
28
+ the next call — caching the rejection would turn one blip into five minutes of
29
+ downtime.
30
+
31
+ The TTL bounds only how long a *metadata* change takes to be noticed. Signing
32
+ keys are not covered by it: openid-client fetches JWKS on its own schedule, so a
33
+ key rollover does not wait for this cache.
34
+
35
+ ## Changing it
36
+
37
+ ```ts
38
+ import { configureDiscoveryCache, clearDiscoveryCache } from "@eventuras/fides-auth/oidc-discovery";
39
+
40
+ // Shorten the window, or pass 0 to discover on every call.
41
+ configureDiscoveryCache({ ttlMs: 60_000 });
42
+
43
+ // Drop everything now — after changing provider configuration you need picked
44
+ // up immediately.
45
+ clearDiscoveryCache();
46
+ ```
47
+
48
+ Both are process-local: the cache lives in memory, so each instance of your app
49
+ holds its own, and a deploy starts empty.
50
+
51
+ If you write tests that mock discovery, call `clearDiscoveryCache()` between
52
+ cases. The cache is module state and will otherwise serve one test's provider
53
+ metadata to the next.