@hearth-auth/sdk 0.0.1 → 1.0.1
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/admin.d.ts +43 -0
- package/dist/admin.js +126 -0
- package/dist/admin.js.map +1 -0
- package/dist/browser-auth.d.ts +32 -0
- package/dist/browser-auth.js +99 -0
- package/dist/browser-auth.js.map +1 -0
- package/dist/claims.d.ts +86 -0
- package/dist/claims.js +137 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +77 -0
- package/dist/client.js +190 -0
- package/dist/client.js.map +1 -0
- package/dist/errors.d.ts +114 -0
- package/{src/errors.ts → dist/errors.js} +83 -97
- package/dist/errors.js.map +1 -0
- package/dist/hearth-client.d.ts +133 -0
- package/dist/hearth-client.js +192 -0
- package/dist/hearth-client.js.map +1 -0
- package/dist/hearth.d.ts +105 -0
- package/dist/hearth.js +109 -0
- package/dist/hearth.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/introspection-client.d.ts +59 -0
- package/dist/introspection-client.js +36 -0
- package/dist/introspection-client.js.map +1 -0
- package/dist/jwks-client.d.ts +28 -0
- package/dist/jwks-client.js +28 -0
- package/dist/jwks-client.js.map +1 -0
- package/dist/middleware.d.ts +38 -0
- package/dist/middleware.js +51 -0
- package/dist/middleware.js.map +1 -0
- package/dist/pkce.d.ts +64 -0
- package/dist/pkce.js +64 -0
- package/dist/pkce.js.map +1 -0
- package/dist/react.d.ts +32 -0
- package/dist/react.js +41 -0
- package/dist/react.js.map +1 -0
- package/dist/session-version-cache.d.ts +50 -0
- package/dist/session-version-cache.js +129 -0
- package/dist/session-version-cache.js.map +1 -0
- package/dist/types.d.ts +168 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +13 -4
- package/CHANGELOG.md +0 -12
- package/src/admin.ts +0 -157
- package/src/browser-auth.ts +0 -130
- package/src/claims.ts +0 -180
- package/src/client.ts +0 -251
- package/src/generated/google/api/annotations_pb.ts +0 -44
- package/src/generated/google/api/http_pb.ts +0 -467
- package/src/generated/hearth/authz/v1/authz_pb.ts +0 -593
- package/src/generated/hearth/cluster/v1/raft_pb.ts +0 -183
- package/src/generated/hearth/events/v1/audit_pb.ts +0 -886
- package/src/generated/hearth/identity/v1/identity_pb.ts +0 -1673
- package/src/generated/hearth/identity/v1/oauth_pb.ts +0 -1138
- package/src/generated/hearth/rbac/v1/rbac_pb.ts +0 -2000
- package/src/hearth-client.ts +0 -288
- package/src/hearth.ts +0 -224
- package/src/index.ts +0 -106
- package/src/introspection-client.ts +0 -83
- package/src/jwks-client.ts +0 -45
- package/src/middleware.ts +0 -82
- package/src/pkce.ts +0 -129
- package/src/react.tsx +0 -57
- package/src/session-version-cache.ts +0 -167
- package/src/types.ts +0 -188
- package/tests/admin-crud.test.ts +0 -97
- package/tests/auth-flow.test.ts +0 -75
- package/tests/authorize.test.ts +0 -386
- package/tests/claims.test.ts +0 -159
- package/tests/hasPermission.test.ts +0 -152
- package/tests/hearth-client.test.ts +0 -243
- package/tests/helpers.ts +0 -90
- package/tests/jwks.test.ts +0 -62
- package/tests/pkce.test.ts +0 -210
- package/tests/react-useHasPermission.test.tsx +0 -92
- package/tests/required-action.test.ts +0 -276
- package/tests/session-version.test.ts +0 -391
- package/tsconfig.json +0 -16
- package/vitest.config.ts +0 -8
package/src/jwks-client.ts
DELETED
|
@@ -1,45 +0,0 @@
|
|
|
1
|
-
import type { JsonWebKey } from "./types.js";
|
|
2
|
-
|
|
3
|
-
/** Configuration for {@link JwksClient}. */
|
|
4
|
-
export interface JwksClientConfig {
|
|
5
|
-
/** URL of the JWKS endpoint (e.g. from OIDC discovery `jwks_uri`). */
|
|
6
|
-
jwksUri: string;
|
|
7
|
-
/**
|
|
8
|
-
* Override cache TTL in milliseconds.
|
|
9
|
-
* When absent, the client respects `Cache-Control: max-age` from the JWKS
|
|
10
|
-
* response and falls back to 5 minutes.
|
|
11
|
-
*/
|
|
12
|
-
ttl?: number;
|
|
13
|
-
/** Timeout for outbound HTTP calls in milliseconds. Default: 10 000. */
|
|
14
|
-
httpTimeout?: number;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* Low-level JWKS fetcher.
|
|
19
|
-
*
|
|
20
|
-
* Fetches the JSON Web Key Set from the configured endpoint.
|
|
21
|
-
* Full caching and rotation logic will be added in §2.
|
|
22
|
-
*/
|
|
23
|
-
export class JwksClient {
|
|
24
|
-
private readonly jwksUri: string;
|
|
25
|
-
readonly ttl: number | undefined;
|
|
26
|
-
readonly httpTimeout: number;
|
|
27
|
-
|
|
28
|
-
constructor(config: JwksClientConfig) {
|
|
29
|
-
this.jwksUri = config.jwksUri;
|
|
30
|
-
this.ttl = config.ttl;
|
|
31
|
-
this.httpTimeout = config.httpTimeout ?? 10_000;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** Fetch the current JWKS keys from the endpoint. */
|
|
35
|
-
async fetchKeys(): Promise<JsonWebKey[]> {
|
|
36
|
-
const resp = await fetch(this.jwksUri, {
|
|
37
|
-
signal: AbortSignal.timeout(this.httpTimeout),
|
|
38
|
-
});
|
|
39
|
-
if (!resp.ok) {
|
|
40
|
-
throw new Error(`JWKS fetch failed with HTTP ${resp.status}`);
|
|
41
|
-
}
|
|
42
|
-
const doc = (await resp.json()) as { keys: JsonWebKey[] };
|
|
43
|
-
return doc.keys;
|
|
44
|
-
}
|
|
45
|
-
}
|
package/src/middleware.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { decodeJwt } from "jose";
|
|
2
|
-
import { AuthorizationModeMismatchError } from "./errors.js";
|
|
3
|
-
import type { HearthClient } from "./hearth-client.js";
|
|
4
|
-
import type { AccessTokenAuthorizationMode, AuthorizePermissionOptions } from "./types.js";
|
|
5
|
-
|
|
6
|
-
/** Options for {@link requirePermission}. */
|
|
7
|
-
export interface RequirePermissionOptions extends AuthorizePermissionOptions {
|
|
8
|
-
/**
|
|
9
|
-
* Which permission delivery mode the resource server expects.
|
|
10
|
-
*
|
|
11
|
-
* MUST be set explicitly — the middleware MUST NOT auto-detect the mode from
|
|
12
|
-
* JWT claim presence. Absence of a `permissions` claim in the token does not
|
|
13
|
-
* change behavior (per HEA-923 design constraint).
|
|
14
|
-
*/
|
|
15
|
-
mode: AccessTokenAuthorizationMode;
|
|
16
|
-
/** HearthClient instance used for network calls in decision/introspection modes. */
|
|
17
|
-
client: HearthClient;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* A synchronous-or-async gate that returns `true` iff the token holder has
|
|
22
|
-
* the given permission under the configured mode.
|
|
23
|
-
*/
|
|
24
|
-
export type PermissionChecker = (token: string) => Promise<boolean>;
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Returns a mode-aware permission checker for the given `permission`.
|
|
28
|
-
*
|
|
29
|
-
* Behaviour by mode:
|
|
30
|
-
* - **embedded** — decodes the JWT locally and checks the `permissions` claim.
|
|
31
|
-
* No network traffic. Returns `false` when the claim is absent; DOES NOT
|
|
32
|
-
* fall back to network (design constraint: absence of claims ≠ switch mode).
|
|
33
|
-
* - **decision** — calls `client.authorize(token, permission, opts)` which
|
|
34
|
-
* POSTs to `POST /oauth/authorize`. Fail-closed on network/server errors.
|
|
35
|
-
* - **introspection** — calls `client.introspectionClient().introspect(token)`,
|
|
36
|
-
* validates the echoed `mode` field if present, then checks the returned
|
|
37
|
-
* `permissions` array. Throws {@link AuthorizationModeMismatchError} if the
|
|
38
|
-
* server echoes a mode that differs from `opts.mode`.
|
|
39
|
-
*
|
|
40
|
-
* @param permission - The permission string to check (e.g. `"docs.write"`).
|
|
41
|
-
* @param opts - Mode, client reference, and optional scoping parameters.
|
|
42
|
-
*/
|
|
43
|
-
export function requirePermission(
|
|
44
|
-
permission: string,
|
|
45
|
-
opts: RequirePermissionOptions,
|
|
46
|
-
): PermissionChecker {
|
|
47
|
-
const { mode, client, organizationId, resource } = opts;
|
|
48
|
-
|
|
49
|
-
switch (mode) {
|
|
50
|
-
case "embedded":
|
|
51
|
-
return async (token: string): Promise<boolean> => {
|
|
52
|
-
let claims: Record<string, unknown> | null = null;
|
|
53
|
-
try {
|
|
54
|
-
claims = decodeJwt(token) as Record<string, unknown>;
|
|
55
|
-
} catch {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
const perms = claims["permissions"];
|
|
59
|
-
return Array.isArray(perms) && perms.includes(permission);
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
case "decision":
|
|
63
|
-
return async (token: string): Promise<boolean> =>
|
|
64
|
-
client.authorize(token, permission, { organizationId, resource });
|
|
65
|
-
|
|
66
|
-
case "introspection":
|
|
67
|
-
return async (token: string): Promise<boolean> => {
|
|
68
|
-
const ic = await client.introspectionClient();
|
|
69
|
-
const result = await ic.introspect(token);
|
|
70
|
-
|
|
71
|
-
// Validate mode echo when present — catches misconfigured deployments.
|
|
72
|
-
if (result.mode !== undefined && result.mode !== "introspection") {
|
|
73
|
-
throw new AuthorizationModeMismatchError("introspection", String(result.mode));
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (!result.active) return false;
|
|
77
|
-
return (
|
|
78
|
-
Array.isArray(result.permissions) && result.permissions.includes(permission)
|
|
79
|
-
);
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
}
|
package/src/pkce.ts
DELETED
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
/** Minimal interface required by {@link startLogin} — satisfied by {@link HearthApiClient}. */
|
|
2
|
-
interface DiscoverySource {
|
|
3
|
-
discovery(): Promise<Record<string, unknown>>;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
/** Generate a cryptographically random RFC 7636 code verifier (256-bit / 32 bytes). */
|
|
7
|
-
export function generateCodeVerifier(): string {
|
|
8
|
-
const bytes = new Uint8Array(32);
|
|
9
|
-
crypto.getRandomValues(bytes);
|
|
10
|
-
return base64urlEncode(bytes);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Derive the S256 code challenge from a verifier (RFC 7636 §4.2). */
|
|
14
|
-
export async function generateCodeChallenge(verifier: string): Promise<string> {
|
|
15
|
-
const data = new TextEncoder().encode(verifier);
|
|
16
|
-
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
17
|
-
return base64urlEncode(new Uint8Array(hash));
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** Options for {@link buildAuthorizationUrl}. */
|
|
21
|
-
export interface BuildAuthorizationUrlOptions {
|
|
22
|
-
/** OIDC `authorization_endpoint` from the discovery document. */
|
|
23
|
-
authorizationEndpoint: string;
|
|
24
|
-
/** OAuth 2.0 client ID. */
|
|
25
|
-
clientId: string;
|
|
26
|
-
/** Redirect URI registered for this client. */
|
|
27
|
-
redirectUri: string;
|
|
28
|
-
/** Base64url-encoded S256 code challenge (from {@link generateCodeChallenge}). */
|
|
29
|
-
codeChallenge: string;
|
|
30
|
-
/** OAuth 2.0 scope string. Default: `"openid profile email"`. */
|
|
31
|
-
scope?: string;
|
|
32
|
-
/** CSRF state token. Auto-generated (16 random bytes) when absent. */
|
|
33
|
-
state?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Return value of {@link buildAuthorizationUrl}. */
|
|
37
|
-
export interface AuthorizationUrlResult {
|
|
38
|
-
/** Full authorization redirect URL to navigate the browser to. */
|
|
39
|
-
url: string;
|
|
40
|
-
/** State value embedded in the URL — persist for CSRF validation in the callback. */
|
|
41
|
-
state: string;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/** Build the full authorization redirect URL for an RFC 7636 PKCE flow. */
|
|
45
|
-
export function buildAuthorizationUrl(
|
|
46
|
-
opts: BuildAuthorizationUrlOptions,
|
|
47
|
-
): AuthorizationUrlResult {
|
|
48
|
-
const state = opts.state ?? generateState();
|
|
49
|
-
const params = new URLSearchParams({
|
|
50
|
-
response_type: "code",
|
|
51
|
-
client_id: opts.clientId,
|
|
52
|
-
redirect_uri: opts.redirectUri,
|
|
53
|
-
code_challenge: opts.codeChallenge,
|
|
54
|
-
code_challenge_method: "S256",
|
|
55
|
-
scope: opts.scope ?? "openid profile email",
|
|
56
|
-
state,
|
|
57
|
-
});
|
|
58
|
-
return { url: `${opts.authorizationEndpoint}?${params.toString()}`, state };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Options for {@link startLogin}. */
|
|
62
|
-
export interface StartLoginOptions {
|
|
63
|
-
/** OAuth 2.0 client ID. */
|
|
64
|
-
clientId: string;
|
|
65
|
-
/** Redirect URI registered for this client. */
|
|
66
|
-
redirectUri: string;
|
|
67
|
-
/** OAuth 2.0 scope string. Default: `"openid profile email"`. */
|
|
68
|
-
scope?: string;
|
|
69
|
-
/** CSRF state token. Auto-generated when absent. */
|
|
70
|
-
state?: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
/** Return value of {@link startLogin}. */
|
|
74
|
-
export interface StartLoginResult {
|
|
75
|
-
/** Full authorization URL — redirect the browser here to begin login. */
|
|
76
|
-
url: string;
|
|
77
|
-
/** OAuth 2.0 state value — persist for CSRF validation in the callback. */
|
|
78
|
-
state: string;
|
|
79
|
-
/**
|
|
80
|
-
* RFC 7636 code verifier — persist (e.g. `sessionStorage`) and pass as
|
|
81
|
-
* `codeVerifier` to `handleCallback()` during the token exchange step.
|
|
82
|
-
*/
|
|
83
|
-
codeVerifier: string;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* One-shot PKCE login initiation: discovers the authorization endpoint,
|
|
88
|
-
* generates a code verifier/challenge, and builds the redirect URL.
|
|
89
|
-
*
|
|
90
|
-
* The caller MUST persist `codeVerifier` and `state` (e.g. in `sessionStorage`)
|
|
91
|
-
* before navigating to `url`, and pass them to `handleCallback()` on return.
|
|
92
|
-
*/
|
|
93
|
-
export async function startLogin(
|
|
94
|
-
client: DiscoverySource,
|
|
95
|
-
opts: StartLoginOptions,
|
|
96
|
-
): Promise<StartLoginResult> {
|
|
97
|
-
const doc = await client.discovery();
|
|
98
|
-
const authorizationEndpoint = doc["authorization_endpoint"] as string | undefined;
|
|
99
|
-
if (!authorizationEndpoint) {
|
|
100
|
-
throw new Error(
|
|
101
|
-
"startLogin: authorization_endpoint not found in OIDC discovery document",
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
const codeVerifier = generateCodeVerifier();
|
|
105
|
-
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
106
|
-
const { url, state } = buildAuthorizationUrl({
|
|
107
|
-
authorizationEndpoint,
|
|
108
|
-
clientId: opts.clientId,
|
|
109
|
-
redirectUri: opts.redirectUri,
|
|
110
|
-
codeChallenge,
|
|
111
|
-
scope: opts.scope,
|
|
112
|
-
state: opts.state,
|
|
113
|
-
});
|
|
114
|
-
return { url, state, codeVerifier };
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function generateState(): string {
|
|
118
|
-
const bytes = new Uint8Array(16);
|
|
119
|
-
crypto.getRandomValues(bytes);
|
|
120
|
-
return base64urlEncode(bytes);
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function base64urlEncode(input: Uint8Array): string {
|
|
124
|
-
let binary = "";
|
|
125
|
-
for (const byte of input) {
|
|
126
|
-
binary += String.fromCharCode(byte);
|
|
127
|
-
}
|
|
128
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
129
|
-
}
|
package/src/react.tsx
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
import * as React from "react";
|
|
2
|
-
import type { HearthFacade } from "./hearth.js";
|
|
3
|
-
|
|
4
|
-
/**
|
|
5
|
-
* React context carrying a {@link HearthFacade} down the tree.
|
|
6
|
-
*
|
|
7
|
-
* The default value is `null`; the hooks treat a `null` context as
|
|
8
|
-
* unauthenticated and return `false`.
|
|
9
|
-
*/
|
|
10
|
-
export const HearthContext = React.createContext<HearthFacade | null>(null);
|
|
11
|
-
|
|
12
|
-
/** Props for {@link HearthProvider}. */
|
|
13
|
-
export interface HearthProviderProps {
|
|
14
|
-
client: HearthFacade;
|
|
15
|
-
children: React.ReactNode;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Provides a {@link HearthFacade} to descendants via {@link HearthContext}.
|
|
20
|
-
*
|
|
21
|
-
* Wrap your React tree once with this after calling `createHearth(...)`.
|
|
22
|
-
*/
|
|
23
|
-
export function HearthProvider(props: HearthProviderProps): React.ReactElement {
|
|
24
|
-
return React.createElement(
|
|
25
|
-
HearthContext.Provider,
|
|
26
|
-
{ value: props.client },
|
|
27
|
-
props.children,
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Returns `true` iff the nearest {@link HearthProvider} client reports
|
|
33
|
-
* the permission as present in the JWT claim set. Returns `false`
|
|
34
|
-
* when no provider is mounted.
|
|
35
|
-
*/
|
|
36
|
-
export function useHasPermission(permission: string): boolean {
|
|
37
|
-
const client = React.useContext(HearthContext);
|
|
38
|
-
return client !== null && client.hasPermission(permission);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** Returns `true` iff the JWT `roles` claim contains `role`. */
|
|
42
|
-
export function useHasRole(role: string): boolean {
|
|
43
|
-
const client = React.useContext(HearthContext);
|
|
44
|
-
return client !== null && client.hasRole(role);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** Returns `true` iff the JWT `groups` claim contains `group`. */
|
|
48
|
-
export function useInGroup(group: string): boolean {
|
|
49
|
-
const client = React.useContext(HearthContext);
|
|
50
|
-
return client !== null && client.inGroup(group);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Returns `true` iff the JWT `oid` claim equals `org`. */
|
|
54
|
-
export function useInOrg(org: string): boolean {
|
|
55
|
-
const client = React.useContext(HearthContext);
|
|
56
|
-
return client !== null && client.inOrg(org);
|
|
57
|
-
}
|
|
@@ -1,167 +0,0 @@
|
|
|
1
|
-
import type { SessionVersionConfig } from "./types.js";
|
|
2
|
-
import {
|
|
3
|
-
SessionVersionCacheStaleError,
|
|
4
|
-
SessionVersionRevokedError,
|
|
5
|
-
} from "./errors.js";
|
|
6
|
-
|
|
7
|
-
interface SnapshotResponse {
|
|
8
|
-
realm: string;
|
|
9
|
-
current_seq: number;
|
|
10
|
-
versions: Record<string, number>;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
interface DeltaEntry {
|
|
14
|
-
seq: number;
|
|
15
|
-
session_id: string;
|
|
16
|
-
min_sv: number;
|
|
17
|
-
bumped_at: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface DeltaFeedResponse {
|
|
21
|
-
realm: string;
|
|
22
|
-
next_seq: number;
|
|
23
|
-
deltas: DeltaEntry[];
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Client-side cache of per-session minimum accepted `sv` values.
|
|
28
|
-
*
|
|
29
|
-
* Polls `GET /oauth/session-versions` at `cfg.pollIntervalMs` intervals and
|
|
30
|
-
* applies delta entries to an in-memory `Map<sessionId, bigint>`. Used by
|
|
31
|
-
* `createHearth()` to validate the `sv` claim in access tokens without any
|
|
32
|
-
* per-request network call.
|
|
33
|
-
*
|
|
34
|
-
* Background poll errors are swallowed; the cache age then grows and eventually
|
|
35
|
-
* trips the stale threshold, triggering fail-closed behaviour (§ 8.1).
|
|
36
|
-
*/
|
|
37
|
-
export class SessionVersionCache {
|
|
38
|
-
private readonly baseUrl: string;
|
|
39
|
-
private readonly realmId: string;
|
|
40
|
-
private readonly cfg: SessionVersionConfig;
|
|
41
|
-
private readonly versions = new Map<string, bigint>();
|
|
42
|
-
private lastRefreshed = 0;
|
|
43
|
-
private seq = 0;
|
|
44
|
-
private pollTimer: ReturnType<typeof setTimeout> | null = null;
|
|
45
|
-
|
|
46
|
-
constructor(baseUrl: string, realmId: string, cfg: SessionVersionConfig) {
|
|
47
|
-
this.baseUrl = baseUrl.replace(/\/$/, "");
|
|
48
|
-
this.realmId = realmId;
|
|
49
|
-
this.cfg = cfg;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Kicks off the initial snapshot fetch (async, non-blocking) and starts the
|
|
54
|
-
* background poll loop. Call once after construction.
|
|
55
|
-
*
|
|
56
|
-
* If `staleThresholdMs <= pollIntervalMs` a console warning is emitted.
|
|
57
|
-
* Until the first snapshot completes, `age()` returns `Infinity` which
|
|
58
|
-
* will trip the stale threshold if `staleThresholdMs` is finite.
|
|
59
|
-
*/
|
|
60
|
-
start(): void {
|
|
61
|
-
if (this.cfg.staleThresholdMs <= this.cfg.pollIntervalMs) {
|
|
62
|
-
console.warn(
|
|
63
|
-
"[hearth] sessionVersions.staleThresholdMs must be > pollIntervalMs " +
|
|
64
|
-
`(stale=${this.cfg.staleThresholdMs}ms, poll=${this.cfg.pollIntervalMs}ms). ` +
|
|
65
|
-
"Recommended: staleThresholdMs = pollIntervalMs × 3.",
|
|
66
|
-
);
|
|
67
|
-
}
|
|
68
|
-
void this.fetchSnapshot().catch(() => undefined);
|
|
69
|
-
this.schedulePoll();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Stops the background poll timer. Call when disposing the Hearth facade. */
|
|
73
|
-
stop(): void {
|
|
74
|
-
if (this.pollTimer !== null) {
|
|
75
|
-
clearTimeout(this.pollTimer);
|
|
76
|
-
this.pollTimer = null;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/** Returns milliseconds since the cache was last successfully refreshed. */
|
|
81
|
-
age(): number {
|
|
82
|
-
if (this.lastRefreshed === 0) return Number.POSITIVE_INFINITY;
|
|
83
|
-
return Date.now() - this.lastRefreshed;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Validates the `sv` claim against the local cache.
|
|
88
|
-
*
|
|
89
|
-
* - Absent `sv` or absent `sid` → no-op (backward compat, RFC § 8.2).
|
|
90
|
-
* - Cache age > `staleThresholdMs` → throws {@link SessionVersionCacheStaleError}.
|
|
91
|
-
* - `sv < minSv` → throws {@link SessionVersionRevokedError}.
|
|
92
|
-
*
|
|
93
|
-
* When `onStale` is `"introspect"`, callers should catch
|
|
94
|
-
* {@link SessionVersionCacheStaleError} and fall back to the introspection
|
|
95
|
-
* endpoint, which performs a fresh server-side check.
|
|
96
|
-
*/
|
|
97
|
-
validateSv(sv: bigint | undefined, sessionId: string | undefined): void {
|
|
98
|
-
if (sv === undefined || sessionId === undefined) return;
|
|
99
|
-
|
|
100
|
-
const ageMs = this.age();
|
|
101
|
-
if (ageMs > this.cfg.staleThresholdMs) {
|
|
102
|
-
throw new SessionVersionCacheStaleError(
|
|
103
|
-
isFinite(ageMs) ? ageMs : -1,
|
|
104
|
-
this.cfg.onStale,
|
|
105
|
-
);
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const minSv = this.versions.get(sessionId) ?? 1n;
|
|
109
|
-
if (sv < minSv) {
|
|
110
|
-
throw new SessionVersionRevokedError(sessionId, sv, minSv);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
// ── Private ─────────────────────────────────────────────────────────────────
|
|
115
|
-
|
|
116
|
-
private async fetchSnapshot(): Promise<void> {
|
|
117
|
-
const url = `${this.baseUrl}/oauth/session-versions/snapshot?realm=${encodeURIComponent(this.realmId)}`;
|
|
118
|
-
const resp = await fetch(url, {
|
|
119
|
-
headers: { Authorization: `Bearer ${this.cfg.serviceToken}` },
|
|
120
|
-
});
|
|
121
|
-
if (!resp.ok) {
|
|
122
|
-
throw new Error(`SV snapshot fetch failed: HTTP ${resp.status}`);
|
|
123
|
-
}
|
|
124
|
-
const data = (await resp.json()) as SnapshotResponse;
|
|
125
|
-
this.versions.clear();
|
|
126
|
-
for (const [sid, minSv] of Object.entries(data.versions)) {
|
|
127
|
-
this.versions.set(sid, BigInt(minSv));
|
|
128
|
-
}
|
|
129
|
-
this.seq = data.current_seq;
|
|
130
|
-
this.lastRefreshed = Date.now();
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
private schedulePoll(): void {
|
|
134
|
-
this.pollTimer = setTimeout(() => {
|
|
135
|
-
void this.poll()
|
|
136
|
-
.catch(() => undefined)
|
|
137
|
-
.finally(() => this.schedulePoll());
|
|
138
|
-
}, this.cfg.pollIntervalMs);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
private async poll(): Promise<void> {
|
|
142
|
-
const url =
|
|
143
|
-
`${this.baseUrl}/oauth/session-versions?since=${this.seq}` +
|
|
144
|
-
`&realm=${encodeURIComponent(this.realmId)}`;
|
|
145
|
-
const resp = await fetch(url, {
|
|
146
|
-
headers: { Authorization: `Bearer ${this.cfg.serviceToken}` },
|
|
147
|
-
});
|
|
148
|
-
if (resp.status === 204) {
|
|
149
|
-
this.lastRefreshed = Date.now();
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (resp.status === 400) {
|
|
153
|
-
// Sequence predates retention window — must re-seed from snapshot.
|
|
154
|
-
await this.fetchSnapshot();
|
|
155
|
-
return;
|
|
156
|
-
}
|
|
157
|
-
if (!resp.ok) {
|
|
158
|
-
throw new Error(`SV delta poll failed: HTTP ${resp.status}`);
|
|
159
|
-
}
|
|
160
|
-
const data = (await resp.json()) as DeltaFeedResponse;
|
|
161
|
-
for (const delta of data.deltas) {
|
|
162
|
-
this.versions.set(delta.session_id, BigInt(delta.min_sv));
|
|
163
|
-
}
|
|
164
|
-
this.seq = data.next_seq;
|
|
165
|
-
this.lastRefreshed = Date.now();
|
|
166
|
-
}
|
|
167
|
-
}
|
package/src/types.ts
DELETED
|
@@ -1,188 +0,0 @@
|
|
|
1
|
-
/** Response from the dev bootstrap endpoint. */
|
|
2
|
-
export interface BootstrapResponse {
|
|
3
|
-
realm_id: string;
|
|
4
|
-
user_id: string;
|
|
5
|
-
access_token: string;
|
|
6
|
-
refresh_token: string;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/** Parameters for initiating an authorization code flow. */
|
|
10
|
-
export interface AuthorizeParams {
|
|
11
|
-
clientId: string;
|
|
12
|
-
redirectUri: string;
|
|
13
|
-
scope: string;
|
|
14
|
-
state: string;
|
|
15
|
-
responseType?: string;
|
|
16
|
-
userId: string;
|
|
17
|
-
codeChallenge?: string;
|
|
18
|
-
codeChallengeMethod?: string;
|
|
19
|
-
nonce?: string;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Response from the authorize endpoint. */
|
|
23
|
-
export interface AuthorizeResponse {
|
|
24
|
-
code: string;
|
|
25
|
-
state: string;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Parameters for exchanging an authorization code. */
|
|
29
|
-
export interface TokenExchangeParams {
|
|
30
|
-
clientId: string;
|
|
31
|
-
code: string;
|
|
32
|
-
redirectUri: string;
|
|
33
|
-
codeVerifier?: string;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/** Response from the token exchange endpoint. */
|
|
37
|
-
export interface TokenResponse {
|
|
38
|
-
access_token: string;
|
|
39
|
-
id_token: string;
|
|
40
|
-
token_type: string;
|
|
41
|
-
expires_in: number;
|
|
42
|
-
refresh_token: string;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
/** UserInfo response from the OIDC UserInfo endpoint. */
|
|
46
|
-
export interface UserInfoResponse {
|
|
47
|
-
sub: string;
|
|
48
|
-
name?: string;
|
|
49
|
-
email?: string;
|
|
50
|
-
email_verified?: boolean;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Parameters for creating a user. */
|
|
54
|
-
export interface CreateUserParams {
|
|
55
|
-
email: string;
|
|
56
|
-
displayName: string;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** User record from the API. */
|
|
60
|
-
export interface User {
|
|
61
|
-
id: string;
|
|
62
|
-
email: string;
|
|
63
|
-
display_name: string;
|
|
64
|
-
status: string;
|
|
65
|
-
created_at?: number;
|
|
66
|
-
updated_at?: number;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/** Parameters for updating a user. */
|
|
70
|
-
export interface UpdateUserParams {
|
|
71
|
-
email?: string;
|
|
72
|
-
displayName?: string;
|
|
73
|
-
status?: string;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Parameters for creating a realm. */
|
|
77
|
-
export interface CreateRealmParams {
|
|
78
|
-
name: string;
|
|
79
|
-
config?: Record<string, unknown>;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/** Realm record from the API. */
|
|
83
|
-
export interface Realm {
|
|
84
|
-
id: string;
|
|
85
|
-
name: string;
|
|
86
|
-
status: string;
|
|
87
|
-
config: Record<string, unknown> | null;
|
|
88
|
-
created_at?: number;
|
|
89
|
-
updated_at?: number;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/** Parameters for updating a realm. */
|
|
93
|
-
export interface UpdateRealmParams {
|
|
94
|
-
name?: string;
|
|
95
|
-
status?: string;
|
|
96
|
-
config?: Record<string, unknown>;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/** Paginated list response. */
|
|
100
|
-
export interface PageResponse<T> {
|
|
101
|
-
items: T[];
|
|
102
|
-
next_cursor: string | null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/** Parameters for registering an OAuth client. */
|
|
106
|
-
export interface RegisterClientParams {
|
|
107
|
-
clientName: string;
|
|
108
|
-
redirectUris: string[];
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** OAuth client record from the API. */
|
|
112
|
-
export interface OAuthClient {
|
|
113
|
-
client_id: string;
|
|
114
|
-
client_name: string;
|
|
115
|
-
redirect_uris: string[];
|
|
116
|
-
grant_types: string[];
|
|
117
|
-
created_at?: number;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
/** JWKS document containing public keys. */
|
|
121
|
-
export interface JwksDocument {
|
|
122
|
-
keys: JsonWebKey[];
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/** A single JWK entry. */
|
|
126
|
-
export interface JsonWebKey {
|
|
127
|
-
kty: string;
|
|
128
|
-
crv?: string;
|
|
129
|
-
x?: string;
|
|
130
|
-
kid?: string;
|
|
131
|
-
use?: string;
|
|
132
|
-
alg?: string;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Response from `GET /v1/me/permissions`.
|
|
137
|
-
*
|
|
138
|
-
* Returns the freshly-resolved RBAC claim set for the bearer-token user.
|
|
139
|
-
*/
|
|
140
|
-
export interface MePermissionsResponse {
|
|
141
|
-
roles: string[];
|
|
142
|
-
groups: string[];
|
|
143
|
-
permissions: string[];
|
|
144
|
-
scope: string;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/** The three permission delivery modes introduced in HEA-922. */
|
|
148
|
-
export type AccessTokenAuthorizationMode = "embedded" | "introspection" | "decision";
|
|
149
|
-
|
|
150
|
-
/** Options for a per-request permission decision call to `POST /oauth/authorize`. */
|
|
151
|
-
export interface AuthorizePermissionOptions {
|
|
152
|
-
/** Constrain the decision to a specific organization. */
|
|
153
|
-
organizationId?: string;
|
|
154
|
-
/** Constrain the decision to a specific resource. */
|
|
155
|
-
resource?: string;
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
/**
|
|
159
|
-
* Configuration for the client-side session-version cache (RFC HEA-930 § 13).
|
|
160
|
-
*
|
|
161
|
-
* When enabled, the SDK fetches a snapshot of `{sessionId → minSv}` on startup,
|
|
162
|
-
* polls `GET /oauth/session-versions` for deltas at `pollIntervalMs` intervals,
|
|
163
|
-
* and validates the `sv` claim on every `hasPermission` / `hasRole` / `inGroup`
|
|
164
|
-
* / `inOrg` call without any per-request network hop.
|
|
165
|
-
*/
|
|
166
|
-
export interface SessionVersionConfig {
|
|
167
|
-
/** Whether session-version validation is enabled. */
|
|
168
|
-
enabled: boolean;
|
|
169
|
-
/** Delta feed poll interval in milliseconds. Recommended: 5 000. */
|
|
170
|
-
pollIntervalMs: number;
|
|
171
|
-
/**
|
|
172
|
-
* Maximum cache age before the cache is considered stale, in milliseconds.
|
|
173
|
-
* MUST be greater than `pollIntervalMs`. Recommended: `pollIntervalMs × 3`.
|
|
174
|
-
*/
|
|
175
|
-
staleThresholdMs: number;
|
|
176
|
-
/**
|
|
177
|
-
* Action when the cache exceeds `staleThresholdMs`:
|
|
178
|
-
* - `"reject"` — throw {@link SessionVersionCacheStaleError} (fail-closed).
|
|
179
|
-
* - `"introspect"` — caller should catch {@link SessionVersionCacheStaleError}
|
|
180
|
-
* and fall back to the introspection endpoint.
|
|
181
|
-
*/
|
|
182
|
-
onStale: "reject" | "introspect";
|
|
183
|
-
/**
|
|
184
|
-
* Service-to-service access token with `hearth.sv_feed` scope.
|
|
185
|
-
* Required when `enabled` is `true`.
|
|
186
|
-
*/
|
|
187
|
-
serviceToken: string;
|
|
188
|
-
}
|