@eventuras/fides-auth 0.8.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +32 -0
- package/dist/cookies.d.ts +105 -0
- package/dist/cookies.d.ts.map +1 -0
- package/dist/cookies.js +118 -0
- package/dist/cookies.js.map +1 -0
- package/dist/heartbeat.d.ts +91 -0
- package/dist/heartbeat.d.ts.map +1 -0
- package/dist/heartbeat.js +169 -0
- package/dist/heartbeat.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/logger.js +1 -1
- package/dist/logger.js.map +1 -1
- package/dist/oauth-browser.js.map +1 -1
- package/dist/oauth.d.ts +52 -6
- package/dist/oauth.d.ts.map +1 -1
- package/dist/oauth.js +58 -34
- package/dist/oauth.js.map +1 -1
- package/dist/providers/vipps/index.js +2 -1
- package/dist/providers/vipps/index.js.map +1 -1
- package/dist/server/cookie-store.d.ts +10 -0
- package/dist/server/cookie-store.d.ts.map +1 -0
- package/dist/server/cookie-store.js +0 -0
- package/dist/server/heartbeat-handler.d.ts +21 -0
- package/dist/server/heartbeat-handler.d.ts.map +1 -0
- package/dist/server/index.d.ts +7 -0
- package/dist/server/index.d.ts.map +1 -0
- package/dist/server/index.js +293 -0
- package/dist/server/index.js.map +1 -0
- package/dist/server/oidc-callback.d.ts +35 -0
- package/dist/server/oidc-callback.d.ts.map +1 -0
- package/dist/server/oidc-login.d.ts +23 -0
- package/dist/server/oidc-login.d.ts.map +1 -0
- package/dist/server/oidc-logout.d.ts +44 -0
- package/dist/server/oidc-logout.d.ts.map +1 -0
- package/dist/server/session.d.ts +33 -0
- package/dist/server/session.d.ts.map +1 -0
- package/dist/session-cookies.d.ts +52 -0
- package/dist/session-cookies.d.ts.map +1 -0
- package/dist/session-cookies.js +110 -0
- package/dist/session-cookies.js.map +1 -0
- package/dist/session-refresh.d.ts.map +1 -1
- package/dist/session-refresh.js +3 -2
- package/dist/session-refresh.js.map +1 -1
- package/dist/silent-login.js.map +1 -1
- package/dist/types.d.ts +9 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +12 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +16 -2
- package/dist/utils.js.map +1 -1
- package/docs/rp-initiated-logout.md +139 -0
- package/package.json +27 -9
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -9,6 +9,7 @@ Framework-agnostic OAuth 2.0 / OpenID Connect library with PKCE, encrypted sessi
|
|
|
9
9
|
- **Dual environment** — Node.js (`openid-client`) and browser (Web Crypto) entry points
|
|
10
10
|
- **Encrypted sessions** — AES-256-GCM encrypted JWTs for secure session storage
|
|
11
11
|
- **Rate limiting** — Generic token-bucket algorithm, ready for login protection
|
|
12
|
+
- **RP-initiated logout** — End the provider's session too, with `id_token_hint` (OIDC RP-Initiated Logout 1.0)
|
|
12
13
|
- **Silent login** — Detect `prompt=none` results without user interaction
|
|
13
14
|
- **Pluggable logging** — Bring your own logger (pino, winston, etc.) or use the built-in console logger
|
|
14
15
|
- **Tree-shakeable** — Each module is a separate entry point; import only what you need
|
|
@@ -114,6 +115,7 @@ Each module is available as a separate entry point for tree-shaking:
|
|
|
114
115
|
| `@eventuras/fides-auth/rate-limit` | Universal | Token-bucket rate limiter |
|
|
115
116
|
| `@eventuras/fides-auth/logger` | Universal | Pluggable logger interface and configuration |
|
|
116
117
|
| `@eventuras/fides-auth/types` | Universal | TypeScript types (Session, Tokens, etc.) |
|
|
118
|
+
| `@eventuras/fides-auth/server` | Node.js | Request handlers (login, callback, logout, heartbeat) |
|
|
117
119
|
| `@eventuras/fides-auth/providers/vipps` | Node.js | Vipps Login identity provider |
|
|
118
120
|
|
|
119
121
|
### OAuth — Node.js (`/oauth`)
|
|
@@ -173,6 +175,36 @@ low-level wrapper directly:
|
|
|
173
175
|
const authUrl = await buildAuthorizationUrlWithPAR(config, pkce);
|
|
174
176
|
```
|
|
175
177
|
|
|
178
|
+
#### RP-Initiated Logout
|
|
179
|
+
|
|
180
|
+
Clearing your own session cookie is not a logout — the provider's session survives,
|
|
181
|
+
so the next `/authorize` re-authenticates silently. `buildOidcLogoutUrl` builds the
|
|
182
|
+
request that ends the provider session too, and returns `null` when the provider
|
|
183
|
+
advertises no `end_session_endpoint`:
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
const logoutUrl = await buildOidcLogoutUrl(oauthConfig, {
|
|
187
|
+
postLogoutRedirectUri: "https://app.example.com/",
|
|
188
|
+
idTokenHint: session?.tokens?.idToken,
|
|
189
|
+
});
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
The `/server` entry point wraps the whole dance — read the hint, clear the cookies,
|
|
193
|
+
redirect:
|
|
194
|
+
|
|
195
|
+
```typescript
|
|
196
|
+
import { handleOidcLogout } from "@eventuras/fides-auth/server";
|
|
197
|
+
|
|
198
|
+
return handleOidcLogout(request, {
|
|
199
|
+
oauthConfig,
|
|
200
|
+
cookies,
|
|
201
|
+
postLogoutRedirectUri: "https://app.example.com/",
|
|
202
|
+
});
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
See [docs/rp-initiated-logout.md](./docs/rp-initiated-logout.md) for the full
|
|
206
|
+
parameter set, where the ID token is stored, and why `client_id` is still sent.
|
|
207
|
+
|
|
176
208
|
### OAuth — Browser (`/oauth-browser`)
|
|
177
209
|
|
|
178
210
|
Lightweight browser-only flows using Web Crypto:
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser per-cookie size limit (RFC 6265: browsers must support at least
|
|
3
|
+
* 4096 bytes for the sum of the cookie's name and value). Cookies at or above
|
|
4
|
+
* this size are silently dropped by the browser, which manifests as a broken
|
|
5
|
+
* login (the cookie is never stored, so the user appears unauthenticated).
|
|
6
|
+
*/
|
|
7
|
+
export declare const COOKIE_MAX_BYTES = 4096;
|
|
8
|
+
/**
|
|
9
|
+
* Threshold at which an adapter should emit an informational log that a cookie is
|
|
10
|
+
* getting large. This is not an error — a session with many scopes/roles can
|
|
11
|
+
* legitimately approach this size — it just gives visibility before the hard limit.
|
|
12
|
+
*/
|
|
13
|
+
export declare const COOKIE_INFO_BYTES = 3500;
|
|
14
|
+
/**
|
|
15
|
+
* Thrown when a cookie's name + value would meet or exceed the browser's
|
|
16
|
+
* per-cookie size limit. Setting such a cookie would be silently dropped by the
|
|
17
|
+
* browser, so adapters should fail loudly instead. Callers (e.g. an OIDC callback)
|
|
18
|
+
* can catch this to surface a clear error rather than producing a broken login.
|
|
19
|
+
*/
|
|
20
|
+
export declare class CookieTooLargeError extends Error {
|
|
21
|
+
readonly cookieName: string;
|
|
22
|
+
readonly size: number;
|
|
23
|
+
readonly limit: number;
|
|
24
|
+
constructor(cookieName: string, size: number, limit?: number);
|
|
25
|
+
}
|
|
26
|
+
export interface CookieOptions {
|
|
27
|
+
/** Cookie path (default: '/') */
|
|
28
|
+
path?: string;
|
|
29
|
+
/** Max age in seconds */
|
|
30
|
+
maxAge?: number;
|
|
31
|
+
/** SameSite policy (default: 'lax') */
|
|
32
|
+
sameSite?: 'strict' | 'lax' | 'none';
|
|
33
|
+
/** HTTP only flag (default: true) */
|
|
34
|
+
httpOnly?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Secure flag. `true` in {@link defaultSessionCookieOptions} and
|
|
37
|
+
* {@link defaultOAuthCookieOptions}; when omitted from a hand-built options
|
|
38
|
+
* object, the adapter's own default applies.
|
|
39
|
+
*/
|
|
40
|
+
secure?: boolean;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Default cookie options for session cookies.
|
|
44
|
+
* - Secure
|
|
45
|
+
* - HTTP only
|
|
46
|
+
* - Lax same-site policy
|
|
47
|
+
* - 30 days max age
|
|
48
|
+
*
|
|
49
|
+
* `secure` is unconditional rather than derived from NODE_ENV: a deployment that
|
|
50
|
+
* doesn't set it — staging, a container, any non-Next server — would otherwise put
|
|
51
|
+
* the session cookie on the wire in the clear, silently.
|
|
52
|
+
*
|
|
53
|
+
* The cookie spec exempts localhost from the https requirement, so a `Secure`
|
|
54
|
+
* cookie is still set and sent over `http://localhost` and local development is
|
|
55
|
+
* unaffected. Plain http on any other host — a LAN address, a custom dev hostname —
|
|
56
|
+
* needs an explicit `secure: false`.
|
|
57
|
+
*/
|
|
58
|
+
export declare const defaultSessionCookieOptions: CookieOptions;
|
|
59
|
+
/**
|
|
60
|
+
* Default cookie options for OAuth state/PKCE cookies.
|
|
61
|
+
* - Secure — see {@link defaultSessionCookieOptions}
|
|
62
|
+
* - HTTP only
|
|
63
|
+
* - Lax same-site policy
|
|
64
|
+
* - 10 minutes max age (short-lived for security)
|
|
65
|
+
*/
|
|
66
|
+
export declare const defaultOAuthCookieOptions: CookieOptions;
|
|
67
|
+
/**
|
|
68
|
+
* Name of the cookie holding the (split-out) access token.
|
|
69
|
+
*
|
|
70
|
+
* The access token — typically a large JWT carrying scopes/roles — is stored in
|
|
71
|
+
* its own cookie so it gets a full per-cookie byte budget, instead of competing
|
|
72
|
+
* for space with the rest of the session inside a single "session" cookie. See
|
|
73
|
+
* {@link CookieTooLargeError} for the limit this works around.
|
|
74
|
+
*/
|
|
75
|
+
export declare const ACCESS_TOKEN_COOKIE_NAME = "session_at";
|
|
76
|
+
/**
|
|
77
|
+
* Name of the cookie holding the (split-out) ID token, kept for `id_token_hint`
|
|
78
|
+
* on RP-initiated logout.
|
|
79
|
+
*
|
|
80
|
+
* It gets its own cookie for two reasons: it is a second large JWT, which would
|
|
81
|
+
* eat into the access token's byte budget, and it must outlive access-token
|
|
82
|
+
* expiry — a logout after the session has gone stale is exactly when the hint is
|
|
83
|
+
* needed.
|
|
84
|
+
*/
|
|
85
|
+
export declare const ID_TOKEN_COOKIE_NAME = "session_it";
|
|
86
|
+
/**
|
|
87
|
+
* Name of the main session cookie (everything except the split-out access and ID
|
|
88
|
+
* tokens). See {@link ACCESS_TOKEN_COOKIE_NAME} for why the raw tokens are stored
|
|
89
|
+
* apart.
|
|
90
|
+
*/
|
|
91
|
+
export declare const SESSION_COOKIE_NAME = "session";
|
|
92
|
+
/**
|
|
93
|
+
* Serialized byte size of a `name=value` cookie pair. The browser per-cookie
|
|
94
|
+
* limit applies to the serialized pair, so the `=` separator is counted too —
|
|
95
|
+
* otherwise a borderline pair (name+value === 4095) would slip through only to be
|
|
96
|
+
* dropped at 4096 once serialized.
|
|
97
|
+
*/
|
|
98
|
+
export declare function cookieByteSize(name: string, value: string): number;
|
|
99
|
+
/**
|
|
100
|
+
* Asserts that a `name=value` cookie pair is within the browser's per-cookie
|
|
101
|
+
* limit, throwing {@link CookieTooLargeError} if not. Returns the measured byte
|
|
102
|
+
* size so callers can additionally log when it crosses {@link COOKIE_INFO_BYTES}.
|
|
103
|
+
*/
|
|
104
|
+
export declare function assertCookieWithinLimit(name: string, value: string): number;
|
|
105
|
+
//# sourceMappingURL=cookies.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.d.ts","sourceRoot":"","sources":["../src/cookies.ts"],"names":[],"mappings":"AAUA;;;;;GAKG;AACH,eAAO,MAAM,gBAAgB,OAAO,CAAC;AAErC;;;;GAIG;AACH,eAAO,MAAM,iBAAiB,OAAO,CAAC;AAEtC;;;;;GAKG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;aAE1B,UAAU,EAAE,MAAM;aAClB,IAAI,EAAE,MAAM;aACZ,KAAK,EAAE,MAAM;gBAFb,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,EACZ,KAAK,GAAE,MAAyB;CAOnD;AAED,MAAM,WAAW,aAAa;IAC5B,iCAAiC;IACjC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,yBAAyB;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,uCAAuC;IACvC,QAAQ,CAAC,EAAE,QAAQ,GAAG,KAAK,GAAG,MAAM,CAAC;IACrC,qCAAqC;IACrC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB;AAED;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,2BAA2B,EAAE,aAMzC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB,EAAE,aAMvC,CAAC;AAEF;;;;;;;GAOG;AACH,eAAO,MAAM,wBAAwB,eAAe,CAAC;AAErD;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,eAAe,CAAC;AAEjD;;;;GAIG;AACH,eAAO,MAAM,mBAAmB,YAAY,CAAC;AAE7C;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAGlE;AAED;;;;GAIG;AACH,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAM3E"}
|
package/dist/cookies.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
//#region src/cookies.ts
|
|
2
|
+
/**
|
|
3
|
+
* Browser per-cookie size limit (RFC 6265: browsers must support at least
|
|
4
|
+
* 4096 bytes for the sum of the cookie's name and value). Cookies at or above
|
|
5
|
+
* this size are silently dropped by the browser, which manifests as a broken
|
|
6
|
+
* login (the cookie is never stored, so the user appears unauthenticated).
|
|
7
|
+
*/
|
|
8
|
+
var COOKIE_MAX_BYTES = 4096;
|
|
9
|
+
/**
|
|
10
|
+
* Threshold at which an adapter should emit an informational log that a cookie is
|
|
11
|
+
* getting large. This is not an error — a session with many scopes/roles can
|
|
12
|
+
* legitimately approach this size — it just gives visibility before the hard limit.
|
|
13
|
+
*/
|
|
14
|
+
var COOKIE_INFO_BYTES = 3500;
|
|
15
|
+
/**
|
|
16
|
+
* Thrown when a cookie's name + value would meet or exceed the browser's
|
|
17
|
+
* per-cookie size limit. Setting such a cookie would be silently dropped by the
|
|
18
|
+
* browser, so adapters should fail loudly instead. Callers (e.g. an OIDC callback)
|
|
19
|
+
* can catch this to surface a clear error rather than producing a broken login.
|
|
20
|
+
*/
|
|
21
|
+
var CookieTooLargeError = class extends Error {
|
|
22
|
+
cookieName;
|
|
23
|
+
size;
|
|
24
|
+
limit;
|
|
25
|
+
constructor(cookieName, size, limit = COOKIE_MAX_BYTES) {
|
|
26
|
+
super(`Cookie "${cookieName}" is ${size} bytes, which meets or exceeds the browser limit of ${limit} bytes and would be silently dropped`);
|
|
27
|
+
this.cookieName = cookieName;
|
|
28
|
+
this.size = size;
|
|
29
|
+
this.limit = limit;
|
|
30
|
+
this.name = "CookieTooLargeError";
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Default cookie options for session cookies.
|
|
35
|
+
* - Secure
|
|
36
|
+
* - HTTP only
|
|
37
|
+
* - Lax same-site policy
|
|
38
|
+
* - 30 days max age
|
|
39
|
+
*
|
|
40
|
+
* `secure` is unconditional rather than derived from NODE_ENV: a deployment that
|
|
41
|
+
* doesn't set it — staging, a container, any non-Next server — would otherwise put
|
|
42
|
+
* the session cookie on the wire in the clear, silently.
|
|
43
|
+
*
|
|
44
|
+
* The cookie spec exempts localhost from the https requirement, so a `Secure`
|
|
45
|
+
* cookie is still set and sent over `http://localhost` and local development is
|
|
46
|
+
* unaffected. Plain http on any other host — a LAN address, a custom dev hostname —
|
|
47
|
+
* needs an explicit `secure: false`.
|
|
48
|
+
*/
|
|
49
|
+
var defaultSessionCookieOptions = {
|
|
50
|
+
path: "/",
|
|
51
|
+
maxAge: 2592e3,
|
|
52
|
+
sameSite: "lax",
|
|
53
|
+
httpOnly: true,
|
|
54
|
+
secure: true
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Default cookie options for OAuth state/PKCE cookies.
|
|
58
|
+
* - Secure — see {@link defaultSessionCookieOptions}
|
|
59
|
+
* - HTTP only
|
|
60
|
+
* - Lax same-site policy
|
|
61
|
+
* - 10 minutes max age (short-lived for security)
|
|
62
|
+
*/
|
|
63
|
+
var defaultOAuthCookieOptions = {
|
|
64
|
+
path: "/",
|
|
65
|
+
maxAge: 600,
|
|
66
|
+
sameSite: "lax",
|
|
67
|
+
httpOnly: true,
|
|
68
|
+
secure: true
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Name of the cookie holding the (split-out) access token.
|
|
72
|
+
*
|
|
73
|
+
* The access token — typically a large JWT carrying scopes/roles — is stored in
|
|
74
|
+
* its own cookie so it gets a full per-cookie byte budget, instead of competing
|
|
75
|
+
* for space with the rest of the session inside a single "session" cookie. See
|
|
76
|
+
* {@link CookieTooLargeError} for the limit this works around.
|
|
77
|
+
*/
|
|
78
|
+
var ACCESS_TOKEN_COOKIE_NAME = "session_at";
|
|
79
|
+
/**
|
|
80
|
+
* Name of the cookie holding the (split-out) ID token, kept for `id_token_hint`
|
|
81
|
+
* on RP-initiated logout.
|
|
82
|
+
*
|
|
83
|
+
* It gets its own cookie for two reasons: it is a second large JWT, which would
|
|
84
|
+
* eat into the access token's byte budget, and it must outlive access-token
|
|
85
|
+
* expiry — a logout after the session has gone stale is exactly when the hint is
|
|
86
|
+
* needed.
|
|
87
|
+
*/
|
|
88
|
+
var ID_TOKEN_COOKIE_NAME = "session_it";
|
|
89
|
+
/**
|
|
90
|
+
* Name of the main session cookie (everything except the split-out access and ID
|
|
91
|
+
* tokens). See {@link ACCESS_TOKEN_COOKIE_NAME} for why the raw tokens are stored
|
|
92
|
+
* apart.
|
|
93
|
+
*/
|
|
94
|
+
var SESSION_COOKIE_NAME = "session";
|
|
95
|
+
/**
|
|
96
|
+
* Serialized byte size of a `name=value` cookie pair. The browser per-cookie
|
|
97
|
+
* limit applies to the serialized pair, so the `=` separator is counted too —
|
|
98
|
+
* otherwise a borderline pair (name+value === 4095) would slip through only to be
|
|
99
|
+
* dropped at 4096 once serialized.
|
|
100
|
+
*/
|
|
101
|
+
function cookieByteSize(name, value) {
|
|
102
|
+
const encoder = new TextEncoder();
|
|
103
|
+
return encoder.encode(name).length + 1 + encoder.encode(value).length;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Asserts that a `name=value` cookie pair is within the browser's per-cookie
|
|
107
|
+
* limit, throwing {@link CookieTooLargeError} if not. Returns the measured byte
|
|
108
|
+
* size so callers can additionally log when it crosses {@link COOKIE_INFO_BYTES}.
|
|
109
|
+
*/
|
|
110
|
+
function assertCookieWithinLimit(name, value) {
|
|
111
|
+
const size = cookieByteSize(name, value);
|
|
112
|
+
if (size >= 4096) throw new CookieTooLargeError(name, size);
|
|
113
|
+
return size;
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, cookieByteSize, defaultOAuthCookieOptions, defaultSessionCookieOptions };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=cookies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cookies.js","names":[],"sources":["../src/cookies.ts"],"sourcesContent":["// cookies.ts\n//\n// Framework-agnostic cookie attributes, size limits, and the access-token cookie\n// name. This module performs no I/O — it never reads or writes cookies. Framework\n// adapters (Next.js, React Router, …) own the actual cookie store and use these\n// values and helpers to configure and size-check the cookies they set.\n//\n// The companion module `session-cookies.ts` encodes/decodes a session into cookie\n// *values*; this module is about the cookie *attributes* and *limits*.\n\n/**\n * Browser per-cookie size limit (RFC 6265: browsers must support at least\n * 4096 bytes for the sum of the cookie's name and value). Cookies at or above\n * this size are silently dropped by the browser, which manifests as a broken\n * login (the cookie is never stored, so the user appears unauthenticated).\n */\nexport const COOKIE_MAX_BYTES = 4096;\n\n/**\n * Threshold at which an adapter should emit an informational log that a cookie is\n * getting large. This is not an error — a session with many scopes/roles can\n * legitimately approach this size — it just gives visibility before the hard limit.\n */\nexport const COOKIE_INFO_BYTES = 3500;\n\n/**\n * Thrown when a cookie's name + value would meet or exceed the browser's\n * per-cookie size limit. Setting such a cookie would be silently dropped by the\n * browser, so adapters should fail loudly instead. Callers (e.g. an OIDC callback)\n * can catch this to surface a clear error rather than producing a broken login.\n */\nexport class CookieTooLargeError extends Error {\n constructor(\n public readonly cookieName: string,\n public readonly size: number,\n public readonly limit: number = COOKIE_MAX_BYTES,\n ) {\n super(\n `Cookie \"${cookieName}\" is ${size} bytes, which meets or exceeds the browser limit of ${limit} bytes and would be silently dropped`,\n );\n this.name = 'CookieTooLargeError';\n }\n}\n\nexport interface CookieOptions {\n /** Cookie path (default: '/') */\n path?: string;\n /** Max age in seconds */\n maxAge?: number;\n /** SameSite policy (default: 'lax') */\n sameSite?: 'strict' | 'lax' | 'none';\n /** HTTP only flag (default: true) */\n httpOnly?: boolean;\n /**\n * Secure flag. `true` in {@link defaultSessionCookieOptions} and\n * {@link defaultOAuthCookieOptions}; when omitted from a hand-built options\n * object, the adapter's own default applies.\n */\n secure?: boolean;\n}\n\n/**\n * Default cookie options for session cookies.\n * - Secure\n * - HTTP only\n * - Lax same-site policy\n * - 30 days max age\n *\n * `secure` is unconditional rather than derived from NODE_ENV: a deployment that\n * doesn't set it — staging, a container, any non-Next server — would otherwise put\n * the session cookie on the wire in the clear, silently.\n *\n * The cookie spec exempts localhost from the https requirement, so a `Secure`\n * cookie is still set and sent over `http://localhost` and local development is\n * unaffected. Plain http on any other host — a LAN address, a custom dev hostname —\n * needs an explicit `secure: false`.\n */\nexport const defaultSessionCookieOptions: CookieOptions = {\n path: '/',\n maxAge: 60 * 60 * 24 * 30, // 30 days\n sameSite: 'lax',\n httpOnly: true,\n secure: true,\n};\n\n/**\n * Default cookie options for OAuth state/PKCE cookies.\n * - Secure — see {@link defaultSessionCookieOptions}\n * - HTTP only\n * - Lax same-site policy\n * - 10 minutes max age (short-lived for security)\n */\nexport const defaultOAuthCookieOptions: CookieOptions = {\n path: '/',\n maxAge: 60 * 10, // 10 minutes\n sameSite: 'lax',\n httpOnly: true,\n secure: true,\n};\n\n/**\n * Name of the cookie holding the (split-out) access token.\n *\n * The access token — typically a large JWT carrying scopes/roles — is stored in\n * its own cookie so it gets a full per-cookie byte budget, instead of competing\n * for space with the rest of the session inside a single \"session\" cookie. See\n * {@link CookieTooLargeError} for the limit this works around.\n */\nexport const ACCESS_TOKEN_COOKIE_NAME = 'session_at';\n\n/**\n * Name of the cookie holding the (split-out) ID token, kept for `id_token_hint`\n * on RP-initiated logout.\n *\n * It gets its own cookie for two reasons: it is a second large JWT, which would\n * eat into the access token's byte budget, and it must outlive access-token\n * expiry — a logout after the session has gone stale is exactly when the hint is\n * needed.\n */\nexport const ID_TOKEN_COOKIE_NAME = 'session_it';\n\n/**\n * Name of the main session cookie (everything except the split-out access and ID\n * tokens). See {@link ACCESS_TOKEN_COOKIE_NAME} for why the raw tokens are stored\n * apart.\n */\nexport const SESSION_COOKIE_NAME = 'session';\n\n/**\n * Serialized byte size of a `name=value` cookie pair. The browser per-cookie\n * limit applies to the serialized pair, so the `=` separator is counted too —\n * otherwise a borderline pair (name+value === 4095) would slip through only to be\n * dropped at 4096 once serialized.\n */\nexport function cookieByteSize(name: string, value: string): number {\n const encoder = new TextEncoder();\n return encoder.encode(name).length + 1 + encoder.encode(value).length;\n}\n\n/**\n * Asserts that a `name=value` cookie pair is within the browser's per-cookie\n * limit, throwing {@link CookieTooLargeError} if not. Returns the measured byte\n * size so callers can additionally log when it crosses {@link COOKIE_INFO_BYTES}.\n */\nexport function assertCookieWithinLimit(name: string, value: string): number {\n const size = cookieByteSize(name, value);\n if (size >= COOKIE_MAX_BYTES) {\n throw new CookieTooLargeError(name, size);\n }\n return size;\n}\n"],"mappings":";;;;;;;AAgBA,IAAa,mBAAmB;;;;;;AAOhC,IAAa,oBAAoB;;;;;;;AAQjC,IAAa,sBAAb,cAAyC,MAAM;CAE3B;CACA;CACA;CAHlB,YACE,YACA,MACA,QAAgC,kBAChC;EACA,MACE,WAAW,WAAW,OAAO,KAAK,sDAAsD,MAAM,qCAChG;EANgB,KAAA,aAAA;EACA,KAAA,OAAA;EACA,KAAA,QAAA;EAKhB,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;AAmCA,IAAa,8BAA6C;CACxD,MAAM;CACN,QAAQ;CACR,UAAU;CACV,UAAU;CACV,QAAQ;AACV;;;;;;;;AASA,IAAa,4BAA2C;CACtD,MAAM;CACN,QAAQ;CACR,UAAU;CACV,UAAU;CACV,QAAQ;AACV;;;;;;;;;AAUA,IAAa,2BAA2B;;;;;;;;;;AAWxC,IAAa,uBAAuB;;;;;;AAOpC,IAAa,sBAAsB;;;;;;;AAQnC,SAAgB,eAAe,MAAc,OAAuB;CAClE,MAAM,UAAU,IAAI,YAAY;CAChC,OAAO,QAAQ,OAAO,IAAI,CAAC,CAAC,SAAS,IAAI,QAAQ,OAAO,KAAK,CAAC,CAAC;AACjE;;;;;;AAOA,SAAgB,wBAAwB,MAAc,OAAuB;CAC3E,MAAM,OAAO,eAAe,MAAM,KAAK;CACvC,IAAI,QAAA,MACF,MAAM,IAAI,oBAAoB,MAAM,IAAI;CAE1C,OAAO;AACT"}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration for {@link createHeartbeat}.
|
|
3
|
+
*/
|
|
4
|
+
export interface HeartbeatConfig {
|
|
5
|
+
/**
|
|
6
|
+
* Endpoint to POST to in order to refresh the session.
|
|
7
|
+
* The endpoint must run a server-side refresh and return 2xx with
|
|
8
|
+
* `{ accessTokenExpiresAt }` (ISO 8601) on success, or 401 when the refresh
|
|
9
|
+
* token is no longer valid.
|
|
10
|
+
* @default '/api/auth/heartbeat'
|
|
11
|
+
*/
|
|
12
|
+
endpoint?: string;
|
|
13
|
+
/**
|
|
14
|
+
* How much of the access-token lifetime to keep as headroom: the refresh is
|
|
15
|
+
* scheduled when `fraction` of the TTL remains. Governs long tokens (a 1 h
|
|
16
|
+
* token at 0.3 refreshes ~42 min in); short tokens are dominated by
|
|
17
|
+
* {@link minSkewMs} instead.
|
|
18
|
+
* @default 0.3
|
|
19
|
+
*/
|
|
20
|
+
fraction?: number;
|
|
21
|
+
/**
|
|
22
|
+
* Absolute floor for the refresh lead time (network round-trip + clock skew).
|
|
23
|
+
* Dominates for short-lived tokens: a 10 s token still refreshes ~5 s in.
|
|
24
|
+
* @default 5_000 (5 seconds)
|
|
25
|
+
*/
|
|
26
|
+
minSkewMs?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Floor on how often a refresh may fire, so ultra-short tokens don't hammer
|
|
29
|
+
* the endpoint.
|
|
30
|
+
* @default 5_000 (5 seconds)
|
|
31
|
+
*/
|
|
32
|
+
minRefreshIntervalMs?: number;
|
|
33
|
+
/**
|
|
34
|
+
* How recently the user must have interacted for a due refresh to fire.
|
|
35
|
+
* Decoupled from the access-token TTL on purpose — this only suppresses
|
|
36
|
+
* refreshes in genuinely abandoned tabs, so keep it generous (tie it to the
|
|
37
|
+
* IdP's SSO/session idle, not the access token). When a refresh is suppressed
|
|
38
|
+
* the engine waits for the next interaction (or tab focus) and refreshes then.
|
|
39
|
+
* @default 1_800_000 (30 minutes)
|
|
40
|
+
*/
|
|
41
|
+
idleThresholdMs?: number;
|
|
42
|
+
/**
|
|
43
|
+
* Known access-token expiry at start (ISO 8601 string or epoch ms). When
|
|
44
|
+
* provided, the first refresh is scheduled from it instead of priming with an
|
|
45
|
+
* immediate refresh to discover the expiry.
|
|
46
|
+
*/
|
|
47
|
+
initialExpiresAt?: string | number | null;
|
|
48
|
+
/**
|
|
49
|
+
* Called when the heartbeat endpoint returns 401 — refresh token is gone.
|
|
50
|
+
*/
|
|
51
|
+
onSessionExpired?: () => void;
|
|
52
|
+
/**
|
|
53
|
+
* Called after a successful refresh.
|
|
54
|
+
*/
|
|
55
|
+
onRefreshed?: () => void;
|
|
56
|
+
/**
|
|
57
|
+
* Called on transient errors (network failure, 5xx). The engine retries — this
|
|
58
|
+
* is just an observability hook.
|
|
59
|
+
*/
|
|
60
|
+
onError?: (error: Error) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Namespace for the logger.
|
|
63
|
+
* @default 'fides:heartbeat'
|
|
64
|
+
*/
|
|
65
|
+
loggerNamespace?: string;
|
|
66
|
+
}
|
|
67
|
+
/** Handle returned by {@link createHeartbeat}. */
|
|
68
|
+
export interface HeartbeatHandle {
|
|
69
|
+
/** Stops the heartbeat: clears timers, removes listeners, aborts in-flight fetches. */
|
|
70
|
+
stop: () => void;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Starts an expiry-driven session keepalive and returns a handle to stop it.
|
|
74
|
+
*
|
|
75
|
+
* Schedules each refresh from the access token's actual expiry rather than a
|
|
76
|
+
* fixed interval, so the cadence self-adjusts to whatever TTL the IdP issues: a
|
|
77
|
+
* 5-minute token refreshes ~3.5 min in; a 10-second token refreshes ~5 s in
|
|
78
|
+
* (skew floor). It primes with one refresh on start to learn the current expiry
|
|
79
|
+
* (pass {@link HeartbeatConfig.initialExpiresAt} to skip that), retries on
|
|
80
|
+
* transient failure, and — for hidden or idle tabs — defers the refresh until
|
|
81
|
+
* the tab is focused or the user interacts again.
|
|
82
|
+
*
|
|
83
|
+
* A 401 from the endpoint (refresh token / SSO session gone) is the logout
|
|
84
|
+
* trigger via {@link HeartbeatConfig.onSessionExpired}; the idle gate is only an
|
|
85
|
+
* optimization for abandoned tabs, never a gate on access-token freshness.
|
|
86
|
+
*
|
|
87
|
+
* No-ops (returns a handle whose `stop` does nothing) when there is no `window`,
|
|
88
|
+
* e.g. during server-side rendering.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createHeartbeat(config?: HeartbeatConfig): HeartbeatHandle;
|
|
91
|
+
//# sourceMappingURL=heartbeat.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"heartbeat.d.ts","sourceRoot":"","sources":["../src/heartbeat.ts"],"names":[],"mappings":"AAeA;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;;;OAMG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB;;;;OAIG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB;;;;OAIG;IACH,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAE9B;;;;;;;OAOG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IAEzB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAAC;IAE1C;;OAEG;IACH,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAE9B;;OAEG;IACH,WAAW,CAAC,EAAE,MAAM,IAAI,CAAC;IAEzB;;;OAGG;IACH,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;CAC1B;AAED,kDAAkD;AAClD,MAAM,WAAW,eAAe;IAC9B,uFAAuF;IACvF,IAAI,EAAE,MAAM,IAAI,CAAC;CAClB;AA2BD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,eAAe,CAAC,MAAM,GAAE,eAAoB,GAAG,eAAe,CAsK7E"}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { DEFAULT_ACTIVITY_EVENTS, createActivityTracker } from "./activity-tracker.js";
|
|
2
|
+
import { createLogger } from "./logger.js";
|
|
3
|
+
//#region src/heartbeat.ts
|
|
4
|
+
var DEFAULTS = {
|
|
5
|
+
endpoint: "/api/auth/heartbeat",
|
|
6
|
+
fraction: .3,
|
|
7
|
+
minSkewMs: 5e3,
|
|
8
|
+
minRefreshIntervalMs: 5e3,
|
|
9
|
+
idleThresholdMs: 18e5,
|
|
10
|
+
loggerNamespace: "fides:heartbeat"
|
|
11
|
+
};
|
|
12
|
+
var FALLBACK_INTERVAL_MS = 6e4;
|
|
13
|
+
var MAX_RETRY_DELAY_MS = 6e4;
|
|
14
|
+
/** Parses an ISO 8601 string or epoch-ms number into epoch ms, or null. */
|
|
15
|
+
function toEpochMs(value) {
|
|
16
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
17
|
+
if (typeof value === "string") {
|
|
18
|
+
const t = Date.parse(value);
|
|
19
|
+
return Number.isFinite(t) ? t : null;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Starts an expiry-driven session keepalive and returns a handle to stop it.
|
|
25
|
+
*
|
|
26
|
+
* Schedules each refresh from the access token's actual expiry rather than a
|
|
27
|
+
* fixed interval, so the cadence self-adjusts to whatever TTL the IdP issues: a
|
|
28
|
+
* 5-minute token refreshes ~3.5 min in; a 10-second token refreshes ~5 s in
|
|
29
|
+
* (skew floor). It primes with one refresh on start to learn the current expiry
|
|
30
|
+
* (pass {@link HeartbeatConfig.initialExpiresAt} to skip that), retries on
|
|
31
|
+
* transient failure, and — for hidden or idle tabs — defers the refresh until
|
|
32
|
+
* the tab is focused or the user interacts again.
|
|
33
|
+
*
|
|
34
|
+
* A 401 from the endpoint (refresh token / SSO session gone) is the logout
|
|
35
|
+
* trigger via {@link HeartbeatConfig.onSessionExpired}; the idle gate is only an
|
|
36
|
+
* optimization for abandoned tabs, never a gate on access-token freshness.
|
|
37
|
+
*
|
|
38
|
+
* No-ops (returns a handle whose `stop` does nothing) when there is no `window`,
|
|
39
|
+
* e.g. during server-side rendering.
|
|
40
|
+
*/
|
|
41
|
+
function createHeartbeat(config = {}) {
|
|
42
|
+
if (typeof window === "undefined") return { stop: () => void 0 };
|
|
43
|
+
const endpoint = config.endpoint ?? DEFAULTS.endpoint;
|
|
44
|
+
const fraction = config.fraction ?? DEFAULTS.fraction;
|
|
45
|
+
const minSkewMs = config.minSkewMs ?? DEFAULTS.minSkewMs;
|
|
46
|
+
const minRefreshIntervalMs = config.minRefreshIntervalMs ?? DEFAULTS.minRefreshIntervalMs;
|
|
47
|
+
const idleThresholdMs = config.idleThresholdMs ?? DEFAULTS.idleThresholdMs;
|
|
48
|
+
const initialExpiresAt = toEpochMs(config.initialExpiresAt);
|
|
49
|
+
const loggerNamespace = config.loggerNamespace ?? DEFAULTS.loggerNamespace;
|
|
50
|
+
const logger = createLogger({
|
|
51
|
+
namespace: loggerNamespace,
|
|
52
|
+
context: { component: "Heartbeat" }
|
|
53
|
+
});
|
|
54
|
+
const tracker = createActivityTracker();
|
|
55
|
+
const abortController = new AbortController();
|
|
56
|
+
let timeoutId = null;
|
|
57
|
+
let stopped = false;
|
|
58
|
+
let primed = initialExpiresAt !== null;
|
|
59
|
+
let expiresAt = initialExpiresAt;
|
|
60
|
+
let failureCount = 0;
|
|
61
|
+
let wakerArmed = false;
|
|
62
|
+
const clearTimer = () => {
|
|
63
|
+
if (timeoutId) {
|
|
64
|
+
clearTimeout(timeoutId);
|
|
65
|
+
timeoutId = null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const disarmWaker = () => {
|
|
69
|
+
if (!wakerArmed) return;
|
|
70
|
+
wakerArmed = false;
|
|
71
|
+
for (const event of DEFAULT_ACTIVITY_EVENTS) globalThis.removeEventListener(event, onWake);
|
|
72
|
+
document.removeEventListener("visibilitychange", onVisibleWake);
|
|
73
|
+
};
|
|
74
|
+
const teardown = () => {
|
|
75
|
+
if (stopped) return;
|
|
76
|
+
stopped = true;
|
|
77
|
+
clearTimer();
|
|
78
|
+
disarmWaker();
|
|
79
|
+
abortController.abort();
|
|
80
|
+
tracker.dispose();
|
|
81
|
+
};
|
|
82
|
+
const nextDelay = () => {
|
|
83
|
+
if (!primed) return 0;
|
|
84
|
+
if (expiresAt === null) return FALLBACK_INTERVAL_MS;
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
const ttl = Math.max(0, expiresAt - now);
|
|
87
|
+
const lead = Math.max(minSkewMs, fraction * ttl);
|
|
88
|
+
return Math.max(minRefreshIntervalMs, expiresAt - lead - now);
|
|
89
|
+
};
|
|
90
|
+
const scheduleRefresh = (delay = nextDelay()) => {
|
|
91
|
+
if (stopped) return;
|
|
92
|
+
clearTimer();
|
|
93
|
+
timeoutId = setTimeout(onDue, delay);
|
|
94
|
+
};
|
|
95
|
+
function onWake() {
|
|
96
|
+
if (stopped || !wakerArmed) return;
|
|
97
|
+
disarmWaker();
|
|
98
|
+
onDue();
|
|
99
|
+
}
|
|
100
|
+
function onVisibleWake() {
|
|
101
|
+
if (document.visibilityState !== "visible") return;
|
|
102
|
+
tracker.recordActivity();
|
|
103
|
+
onWake();
|
|
104
|
+
}
|
|
105
|
+
const deferUntilActive = () => {
|
|
106
|
+
if (stopped || wakerArmed) return;
|
|
107
|
+
wakerArmed = true;
|
|
108
|
+
clearTimer();
|
|
109
|
+
for (const event of DEFAULT_ACTIVITY_EVENTS) globalThis.addEventListener(event, onWake, { passive: true });
|
|
110
|
+
document.addEventListener("visibilitychange", onVisibleWake);
|
|
111
|
+
};
|
|
112
|
+
async function onDue() {
|
|
113
|
+
if (stopped) return;
|
|
114
|
+
timeoutId = null;
|
|
115
|
+
if (document.visibilityState === "hidden" || !tracker.isActiveWithin(idleThresholdMs)) {
|
|
116
|
+
logger.debug("Refresh due but tab hidden/idle — deferring until active");
|
|
117
|
+
deferUntilActive();
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
await beat();
|
|
121
|
+
}
|
|
122
|
+
async function beat() {
|
|
123
|
+
if (stopped) return;
|
|
124
|
+
try {
|
|
125
|
+
const response = await fetch(endpoint, {
|
|
126
|
+
method: "POST",
|
|
127
|
+
credentials: "same-origin",
|
|
128
|
+
headers: { "content-type": "application/json" },
|
|
129
|
+
signal: abortController.signal
|
|
130
|
+
});
|
|
131
|
+
if (stopped) return;
|
|
132
|
+
if (response.status === 401) {
|
|
133
|
+
logger.info("Heartbeat returned 401 — session expired");
|
|
134
|
+
config.onSessionExpired?.();
|
|
135
|
+
teardown();
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!response.ok) throw new Error(`Heartbeat failed with status ${response.status}`);
|
|
139
|
+
failureCount = 0;
|
|
140
|
+
primed = true;
|
|
141
|
+
expiresAt = toEpochMs((await response.json().catch(() => null))?.accessTokenExpiresAt);
|
|
142
|
+
logger.debug({ expiresAt }, "Heartbeat refresh succeeded");
|
|
143
|
+
config.onRefreshed?.();
|
|
144
|
+
scheduleRefresh();
|
|
145
|
+
} catch (error) {
|
|
146
|
+
if (stopped) return;
|
|
147
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
148
|
+
logger.warn({ error: err.message }, "Heartbeat tick failed, will retry");
|
|
149
|
+
config.onError?.(err);
|
|
150
|
+
failureCount += 1;
|
|
151
|
+
const backoff = Math.min(MAX_RETRY_DELAY_MS, minRefreshIntervalMs * 2 ** (failureCount - 1));
|
|
152
|
+
scheduleRefresh(backoff);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
scheduleRefresh();
|
|
156
|
+
logger.debug({
|
|
157
|
+
fraction,
|
|
158
|
+
minSkewMs,
|
|
159
|
+
minRefreshIntervalMs,
|
|
160
|
+
idleThresholdMs,
|
|
161
|
+
endpoint,
|
|
162
|
+
primed
|
|
163
|
+
}, "Heartbeat started");
|
|
164
|
+
return { stop: teardown };
|
|
165
|
+
}
|
|
166
|
+
//#endregion
|
|
167
|
+
export { createHeartbeat };
|
|
168
|
+
|
|
169
|
+
//# sourceMappingURL=heartbeat.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"heartbeat.js","names":[],"sources":["../src/heartbeat.ts"],"sourcesContent":["// heartbeat.ts\n//\n// Framework-agnostic, browser-side session keepalive engine. This is the logic\n// behind the React `useHeartbeat` hook, extracted so it has no React dependency:\n// any adapter (React, React Router, plain JS) can start it and get a `stop()`\n// back. It uses browser globals (`fetch`, `document`, `window`) at runtime, but\n// importing this module performs no I/O and touches no globals.\n\nimport {\n createActivityTracker,\n DEFAULT_ACTIVITY_EVENTS,\n type ActivityTracker,\n} from './activity-tracker';\nimport { createLogger } from './logger';\n\n/**\n * Configuration for {@link createHeartbeat}.\n */\nexport interface HeartbeatConfig {\n /**\n * Endpoint to POST to in order to refresh the session.\n * The endpoint must run a server-side refresh and return 2xx with\n * `{ accessTokenExpiresAt }` (ISO 8601) on success, or 401 when the refresh\n * token is no longer valid.\n * @default '/api/auth/heartbeat'\n */\n endpoint?: string;\n\n /**\n * How much of the access-token lifetime to keep as headroom: the refresh is\n * scheduled when `fraction` of the TTL remains. Governs long tokens (a 1 h\n * token at 0.3 refreshes ~42 min in); short tokens are dominated by\n * {@link minSkewMs} instead.\n * @default 0.3\n */\n fraction?: number;\n\n /**\n * Absolute floor for the refresh lead time (network round-trip + clock skew).\n * Dominates for short-lived tokens: a 10 s token still refreshes ~5 s in.\n * @default 5_000 (5 seconds)\n */\n minSkewMs?: number;\n\n /**\n * Floor on how often a refresh may fire, so ultra-short tokens don't hammer\n * the endpoint.\n * @default 5_000 (5 seconds)\n */\n minRefreshIntervalMs?: number;\n\n /**\n * How recently the user must have interacted for a due refresh to fire.\n * Decoupled from the access-token TTL on purpose — this only suppresses\n * refreshes in genuinely abandoned tabs, so keep it generous (tie it to the\n * IdP's SSO/session idle, not the access token). When a refresh is suppressed\n * the engine waits for the next interaction (or tab focus) and refreshes then.\n * @default 1_800_000 (30 minutes)\n */\n idleThresholdMs?: number;\n\n /**\n * Known access-token expiry at start (ISO 8601 string or epoch ms). When\n * provided, the first refresh is scheduled from it instead of priming with an\n * immediate refresh to discover the expiry.\n */\n initialExpiresAt?: string | number | null;\n\n /**\n * Called when the heartbeat endpoint returns 401 — refresh token is gone.\n */\n onSessionExpired?: () => void;\n\n /**\n * Called after a successful refresh.\n */\n onRefreshed?: () => void;\n\n /**\n * Called on transient errors (network failure, 5xx). The engine retries — this\n * is just an observability hook.\n */\n onError?: (error: Error) => void;\n\n /**\n * Namespace for the logger.\n * @default 'fides:heartbeat'\n */\n loggerNamespace?: string;\n}\n\n/** Handle returned by {@link createHeartbeat}. */\nexport interface HeartbeatHandle {\n /** Stops the heartbeat: clears timers, removes listeners, aborts in-flight fetches. */\n stop: () => void;\n}\n\nconst DEFAULTS = {\n endpoint: '/api/auth/heartbeat',\n fraction: 0.3,\n minSkewMs: 5_000,\n minRefreshIntervalMs: 5_000,\n idleThresholdMs: 30 * 60_000,\n loggerNamespace: 'fides:heartbeat',\n} as const;\n\n// Fallback cadence when a refresh succeeds but the response carries no usable\n// expiry — keeps the session alive without busy-looping on an unknown TTL.\nconst FALLBACK_INTERVAL_MS = 60_000;\n// Upper bound for exponential backoff between transient-failure retries.\nconst MAX_RETRY_DELAY_MS = 60_000;\n\n/** Parses an ISO 8601 string or epoch-ms number into epoch ms, or null. */\nfunction toEpochMs(value: string | number | null | undefined): number | null {\n if (typeof value === 'number') return Number.isFinite(value) ? value : null;\n if (typeof value === 'string') {\n const t = Date.parse(value);\n return Number.isFinite(t) ? t : null;\n }\n return null;\n}\n\n/**\n * Starts an expiry-driven session keepalive and returns a handle to stop it.\n *\n * Schedules each refresh from the access token's actual expiry rather than a\n * fixed interval, so the cadence self-adjusts to whatever TTL the IdP issues: a\n * 5-minute token refreshes ~3.5 min in; a 10-second token refreshes ~5 s in\n * (skew floor). It primes with one refresh on start to learn the current expiry\n * (pass {@link HeartbeatConfig.initialExpiresAt} to skip that), retries on\n * transient failure, and — for hidden or idle tabs — defers the refresh until\n * the tab is focused or the user interacts again.\n *\n * A 401 from the endpoint (refresh token / SSO session gone) is the logout\n * trigger via {@link HeartbeatConfig.onSessionExpired}; the idle gate is only an\n * optimization for abandoned tabs, never a gate on access-token freshness.\n *\n * No-ops (returns a handle whose `stop` does nothing) when there is no `window`,\n * e.g. during server-side rendering.\n */\nexport function createHeartbeat(config: HeartbeatConfig = {}): HeartbeatHandle {\n if (typeof window === 'undefined') return { stop: () => undefined };\n\n const endpoint = config.endpoint ?? DEFAULTS.endpoint;\n const fraction = config.fraction ?? DEFAULTS.fraction;\n const minSkewMs = config.minSkewMs ?? DEFAULTS.minSkewMs;\n const minRefreshIntervalMs = config.minRefreshIntervalMs ?? DEFAULTS.minRefreshIntervalMs;\n const idleThresholdMs = config.idleThresholdMs ?? DEFAULTS.idleThresholdMs;\n const initialExpiresAt = toEpochMs(config.initialExpiresAt);\n const loggerNamespace = config.loggerNamespace ?? DEFAULTS.loggerNamespace;\n\n const logger = createLogger({\n namespace: loggerNamespace,\n context: { component: 'Heartbeat' },\n });\n\n const tracker: ActivityTracker = createActivityTracker();\n const abortController = new AbortController();\n\n let timeoutId: ReturnType<typeof setTimeout> | null = null;\n let stopped = false;\n let primed = initialExpiresAt !== null;\n let expiresAt: number | null = initialExpiresAt;\n let failureCount = 0;\n let wakerArmed = false;\n\n const clearTimer = (): void => {\n if (timeoutId) {\n clearTimeout(timeoutId);\n timeoutId = null;\n }\n };\n\n const disarmWaker = (): void => {\n if (!wakerArmed) return;\n wakerArmed = false;\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.removeEventListener(event, onWake);\n }\n document.removeEventListener('visibilitychange', onVisibleWake);\n };\n\n const teardown = (): void => {\n if (stopped) return;\n stopped = true;\n clearTimer();\n disarmWaker();\n abortController.abort();\n tracker.dispose();\n };\n\n // Time until the next refresh should fire, from the current known expiry.\n const nextDelay = (): number => {\n if (!primed) return 0; // prime now to discover the expiry\n if (expiresAt === null) return FALLBACK_INTERVAL_MS;\n const now = Date.now();\n const ttl = Math.max(0, expiresAt - now);\n const lead = Math.max(minSkewMs, fraction * ttl);\n return Math.max(minRefreshIntervalMs, expiresAt - lead - now);\n };\n\n const scheduleRefresh = (delay = nextDelay()): void => {\n if (stopped) return;\n clearTimer();\n timeoutId = setTimeout(onDue, delay);\n };\n\n function onWake(): void {\n if (stopped || !wakerArmed) return;\n disarmWaker();\n void onDue();\n }\n\n function onVisibleWake(): void {\n if (document.visibilityState !== 'visible') return;\n // Returning to the tab counts as activity, so a refresh deferred past\n // idleThresholdMs while hidden runs now instead of deferring again\n // (a visibility change does not touch the activity tracker on its own).\n tracker.recordActivity();\n onWake();\n }\n\n // Suspend refreshing until the tab is focused or the user interacts again.\n const deferUntilActive = (): void => {\n if (stopped || wakerArmed) return;\n wakerArmed = true;\n clearTimer();\n for (const event of DEFAULT_ACTIVITY_EVENTS) {\n globalThis.addEventListener(event, onWake, { passive: true });\n }\n document.addEventListener('visibilitychange', onVisibleWake);\n };\n\n async function onDue(): Promise<void> {\n if (stopped) return;\n timeoutId = null;\n\n // Hidden or abandoned: don't refresh now; wake on focus/interaction.\n if (document.visibilityState === 'hidden' || !tracker.isActiveWithin(idleThresholdMs)) {\n logger.debug('Refresh due but tab hidden/idle — deferring until active');\n deferUntilActive();\n return;\n }\n\n await beat();\n }\n\n async function beat(): Promise<void> {\n if (stopped) return;\n try {\n const response = await fetch(endpoint, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'content-type': 'application/json' },\n signal: abortController.signal,\n });\n\n // Stopped (or 401-driven teardown) during the fetch — bail before touching\n // callbacks so we don't fire on a dead consumer.\n if (stopped) return;\n\n if (response.status === 401) {\n logger.info('Heartbeat returned 401 — session expired');\n config.onSessionExpired?.();\n teardown();\n return;\n }\n\n if (!response.ok) {\n throw new Error(`Heartbeat failed with status ${response.status}`);\n }\n\n failureCount = 0;\n primed = true;\n const body = (await response.json().catch(() => null)) as\n | { accessTokenExpiresAt?: string | number | null }\n | null;\n expiresAt = toEpochMs(body?.accessTokenExpiresAt);\n\n logger.debug({ expiresAt }, 'Heartbeat refresh succeeded');\n config.onRefreshed?.();\n scheduleRefresh();\n } catch (error) {\n if (stopped) return;\n const err = error instanceof Error ? error : new Error(String(error));\n logger.warn({ error: err.message }, 'Heartbeat tick failed, will retry');\n config.onError?.(err);\n\n // Retry with capped exponential backoff; a real expiry resumes the normal\n // cadence once a tick succeeds.\n failureCount += 1;\n const backoff = Math.min(\n MAX_RETRY_DELAY_MS,\n minRefreshIntervalMs * 2 ** (failureCount - 1),\n );\n scheduleRefresh(backoff);\n }\n }\n\n scheduleRefresh();\n logger.debug(\n { fraction, minSkewMs, minRefreshIntervalMs, idleThresholdMs, endpoint, primed },\n 'Heartbeat started',\n );\n\n return { stop: teardown };\n}\n"],"mappings":"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"}
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,oBAAoB,CAAC;AACnC,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,cAAc,oBAAoB,CAAC;AACnC,cAAc,WAAW,CAAC;AAC1B,cAAc,UAAU,CAAC;AACzB,cAAc,iBAAiB,CAAC;AAChC,cAAc,mBAAmB,CAAC;AAClC,cAAc,sBAAsB,CAAC;AACrC,cAAc,SAAS,CAAC;AACxB,cAAc,SAAS,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DEFAULT_ACTIVITY_EVENTS, createActivityTracker } from "./activity-tracker.js";
|
|
2
|
+
import { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, assertCookieWithinLimit, cookieByteSize, defaultOAuthCookieOptions, defaultSessionCookieOptions } from "./cookies.js";
|
|
2
3
|
import { configureLogger, createLogger } from "./logger.js";
|
|
3
4
|
import { getOAuthConfigLogContext, getOAuthErrorLogContext } from "./oauth-logging.js";
|
|
4
5
|
import { refreshSession } from "./session-refresh.js";
|
|
5
|
-
import { accessTokenExpires, createEncryptedJWT, decrypt, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex } from "./utils.js";
|
|
6
|
+
import { accessTokenExpires, createEncryptedJWT, decrypt, decryptJWT, encrypt, generateToken, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, sha256, sha512, toHex } from "./utils.js";
|
|
6
7
|
import { isSession, validateSessionJwt } from "./session-validation.js";
|
|
7
8
|
import "./types.js";
|
|
8
|
-
export { DEFAULT_ACTIVITY_EVENTS, accessTokenExpires, configureLogger, createActivityTracker, createEncryptedJWT, createLogger, decrypt, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, refreshSession, sha256, sha512, toHex, validateSessionJwt };
|
|
9
|
+
export { ACCESS_TOKEN_COOKIE_NAME, COOKIE_INFO_BYTES, COOKIE_MAX_BYTES, CookieTooLargeError, DEFAULT_ACTIVITY_EVENTS, ID_TOKEN_COOKIE_NAME, SESSION_COOKIE_NAME, accessTokenExpires, assertCookieWithinLimit, configureLogger, cookieByteSize, createActivityTracker, createEncryptedJWT, createLogger, decrypt, decryptJWT, defaultOAuthCookieOptions, defaultSessionCookieOptions, encrypt, generateToken, getOAuthConfigLogContext, getOAuthErrorLogContext, getSessionSecret, getSessionSecretUint8Array, hasScope, hexToUint8Array, isSession, refreshSession, sha256, sha512, toHex, validateSessionJwt };
|
package/dist/logger.js
CHANGED