@eventuras/fides-auth 0.11.0 → 0.13.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.
- package/dist/heartbeat.d.ts +20 -2
- package/dist/heartbeat.d.ts.map +1 -1
- package/dist/heartbeat.js +37 -3
- package/dist/heartbeat.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/oauth-logging.d.ts +15 -0
- package/dist/oauth-logging.d.ts.map +1 -1
- package/dist/oauth-logging.js +63 -1
- package/dist/oauth-logging.js.map +1 -1
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +2 -0
- package/dist/oauth.js.map +1 -1
- package/dist/server/heartbeat-handler.d.ts +10 -3
- package/dist/server/heartbeat-handler.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +184 -56
- package/dist/server/index.js.map +1 -1
- package/dist/server/oidc-callback.d.ts.map +1 -1
- package/dist/server/oidc-logout.d.ts.map +1 -1
- package/dist/server/session.d.ts +78 -7
- package/dist/server/session.d.ts.map +1 -1
- package/dist/session-cookies.d.ts +30 -4
- package/dist/session-cookies.d.ts.map +1 -1
- package/dist/session-cookies.js +39 -39
- package/dist/session-cookies.js.map +1 -1
- package/dist/session-events.d.ts +149 -0
- package/dist/session-events.d.ts.map +1 -0
- package/dist/session-events.js +96 -0
- package/dist/session-events.js.map +1 -0
- package/dist/types.d.ts +7 -0
- package/dist/types.d.ts.map +1 -1
- package/docs/session-events.md +189 -0
- package/package.json +7 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/server/session.ts"],"names":[],"mappings":"AAqBA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AAG5C,OAAO,EACL,aAAa,EAEb,KAAK,mBAAmB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC3B,MAAM,mBAAmB,CAAC;AAM3B,OAAO,KAAK,EAAE,oBAAoB,EAAE,OAAO,EAAE,MAAM,UAAU,CAAC;AAC9D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAIlD,KAAK,MAAM,GAAG,MAAM,GAAG,UAAU,CAAC;AAqBlC,mEAAmE;AACnE,MAAM,WAAW,qBAAqB;IACpC;;;;OAIG;IACH,KAAK,CAAC,EAAE,OAAO,aAAa,CAAC,OAAO,GAAG,OAAO,aAAa,CAAC,SAAS,CAAC;IACtE,uGAAuG;IACvG,mBAAmB,CAAC,EAAE,OAAO,CAAC;CAC/B;AAED;;;;;GAKG;AACH,wBAAsB,cAAc,CAClC,KAAK,EAAE,WAAW,EAClB,OAAO,EAAE,OAAO,EAChB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,qBAA0B,GAClC,OAAO,CAAC,MAAM,CAAC,CA6CjB;AAED,4DAA4D;AAC5D,MAAM,MAAM,iBAAiB,GACzB;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;;;;;;;GAOG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAmBnG;AAED;;;;;;GAMG;AACH,wBAAsB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAE7F;AAED,oCAAoC;AACpC,MAAM,MAAM,oBAAoB,GAC5B;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,OAAO,EAAE,OAAO,CAAC;IAAC,mBAAmB,EAAE,OAAO,CAAA;CAAE,GAC5D;IACA,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,qBAAqB,CAAC;IAC9B;;;OAGG;IACH,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,KAAK,CAAC,EAAE,mBAAmB,CAAC;CAC7B,CAAC;AAEJ;;;;;;;;GAQG;AACH,wBAAsB,wBAAwB,CAC5C,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,oBAAoB,CAAC,CA8C/B;AAED;;;;;;;;GAQG;AACH,wBAAsB,qBAAqB,CACzC,KAAK,EAAE,WAAW,EAClB,MAAM,EAAE,WAAW,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,GAAE,oBAAyB,GACjC,OAAO,CAAC,OAAO,GAAG,IAAI,CAAC,CAGzB;AAED;;;GAGG;AACH,wBAAsB,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAAC,CAQjG;AAED,0FAA0F;AAC1F,MAAM,WAAW,mBAAmB;IAClC,8EAA8E;IAC9E,OAAO,CAAC,EAAE,qBAAqB,CAAC;IAChC,2EAA2E;IAC3E,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAED,mCAAmC;AACnC,wBAAsB,YAAY,CAChC,KAAK,EAAE,WAAW,EAClB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,IAAI,CAAC,CAYf"}
|
|
@@ -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,13 +29,38 @@ 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
|
*
|
|
34
|
-
* Returns
|
|
35
|
-
* access token
|
|
36
|
-
*
|
|
37
|
-
*
|
|
56
|
+
* Returns the session whenever the main value decrypts and validates — an
|
|
57
|
+
* expired access token does not hide it; token freshness is the caller's
|
|
58
|
+
* concern. Returns null for a missing/invalid main value, or a stale legacy
|
|
59
|
+
* single-cookie session (one re-login). A corrupt access-token value is
|
|
60
|
+
* tolerated: the session is returned without it.
|
|
61
|
+
*
|
|
62
|
+
* Prefer {@link tryDecodeSessionCookies} when you want to log *why* there is no
|
|
63
|
+
* session.
|
|
38
64
|
*
|
|
39
65
|
* @param raw - The raw cookie values read from the store.
|
|
40
66
|
* @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session-cookies.d.ts","sourceRoot":"","sources":["../src/session-cookies.ts"],"names":[],"mappings":"
|
|
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"}
|
package/dist/session-cookies.js
CHANGED
|
@@ -1,25 +1,9 @@
|
|
|
1
1
|
import { createLogger } from "./logger.js";
|
|
2
2
|
import { createEncryptedJWT, decryptJWT } from "./utils.js";
|
|
3
3
|
import { validateSessionJwt } from "./session-validation.js";
|
|
4
|
-
import { decodeJwt } from "jose";
|
|
5
4
|
//#region src/session-cookies.ts
|
|
6
5
|
var logger = createLogger({ namespace: "fides-auth:session-cookies" });
|
|
7
6
|
/**
|
|
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
7
|
* Encrypts a session into the three cookie values, splitting the raw access and ID
|
|
24
8
|
* tokens out.
|
|
25
9
|
*
|
|
@@ -45,36 +29,34 @@ async function encodeSessionCookies(session, secret) {
|
|
|
45
29
|
};
|
|
46
30
|
}
|
|
47
31
|
/**
|
|
48
|
-
* Decodes the cookie values back into a session,
|
|
32
|
+
* Decodes the cookie values back into a session, reporting why when it can't.
|
|
49
33
|
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
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.
|
|
54
39
|
*
|
|
55
40
|
* @param raw - The raw cookie values read from the store.
|
|
56
41
|
* @param secret - The decryption key as a hex string or Uint8Array (32 bytes for A256GCM).
|
|
57
42
|
*/
|
|
58
|
-
async function
|
|
59
|
-
if (!raw.session) return
|
|
43
|
+
async function tryDecodeSessionCookies(raw, secret) {
|
|
44
|
+
if (!raw.session) return {
|
|
45
|
+
session: null,
|
|
46
|
+
reason: "no_session_cookie"
|
|
47
|
+
};
|
|
60
48
|
const { status, session } = await validateSessionJwt(raw.session, secret);
|
|
61
|
-
if (status !== "VALID" || !session) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
49
|
+
if (status !== "VALID" || !session) return {
|
|
50
|
+
session: null,
|
|
51
|
+
reason: status === "EXPIRED" ? "stale_legacy_session" : "unreadable_session"
|
|
52
|
+
};
|
|
65
53
|
if (raw.accessToken) try {
|
|
66
54
|
const payload = await decryptJWT(raw.accessToken, secret);
|
|
67
55
|
const accessToken = typeof payload.accessToken === "string" ? payload.accessToken : void 0;
|
|
68
|
-
if (accessToken) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
}
|
|
73
|
-
session.tokens = {
|
|
74
|
-
...session.tokens,
|
|
75
|
-
accessToken
|
|
76
|
-
};
|
|
77
|
-
}
|
|
56
|
+
if (accessToken) session.tokens = {
|
|
57
|
+
...session.tokens,
|
|
58
|
+
accessToken
|
|
59
|
+
};
|
|
78
60
|
} catch (error) {
|
|
79
61
|
logger.warn({ error }, "Failed to decode access-token cookie");
|
|
80
62
|
}
|
|
@@ -85,7 +67,25 @@ async function decodeSessionCookies(raw, secret) {
|
|
|
85
67
|
idToken
|
|
86
68
|
};
|
|
87
69
|
}
|
|
88
|
-
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;
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
91
|
* Decrypts the ID-token cookie value on its own, independent of session validity.
|
|
@@ -105,6 +105,6 @@ async function decodeIdTokenCookie(raw, secret) {
|
|
|
105
105
|
}
|
|
106
106
|
}
|
|
107
107
|
//#endregion
|
|
108
|
-
export { decodeIdTokenCookie, decodeSessionCookies, encodeSessionCookies };
|
|
108
|
+
export { decodeIdTokenCookie, decodeSessionCookies, encodeSessionCookies, tryDecodeSessionCookies };
|
|
109
109
|
|
|
110
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 {
|
|
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":"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"}
|
|
@@ -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"}
|
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;
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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"}
|