@hearth-auth/sdk 0.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/CHANGELOG.md +12 -0
- package/README.md +680 -0
- package/package.json +44 -0
- package/src/admin.ts +157 -0
- package/src/browser-auth.ts +130 -0
- package/src/claims.ts +180 -0
- package/src/client.ts +251 -0
- package/src/errors.ts +173 -0
- package/src/generated/google/api/annotations_pb.ts +44 -0
- package/src/generated/google/api/http_pb.ts +467 -0
- package/src/generated/hearth/authz/v1/authz_pb.ts +593 -0
- package/src/generated/hearth/cluster/v1/raft_pb.ts +183 -0
- package/src/generated/hearth/events/v1/audit_pb.ts +886 -0
- package/src/generated/hearth/identity/v1/identity_pb.ts +1673 -0
- package/src/generated/hearth/identity/v1/oauth_pb.ts +1138 -0
- package/src/generated/hearth/rbac/v1/rbac_pb.ts +2000 -0
- package/src/hearth-client.ts +288 -0
- package/src/hearth.ts +224 -0
- package/src/index.ts +106 -0
- package/src/introspection-client.ts +83 -0
- package/src/jwks-client.ts +45 -0
- package/src/middleware.ts +82 -0
- package/src/pkce.ts +129 -0
- package/src/react.tsx +57 -0
- package/src/session-version-cache.ts +167 -0
- package/src/types.ts +188 -0
- package/tests/admin-crud.test.ts +97 -0
- package/tests/auth-flow.test.ts +75 -0
- package/tests/authorize.test.ts +386 -0
- package/tests/claims.test.ts +159 -0
- package/tests/hasPermission.test.ts +152 -0
- package/tests/hearth-client.test.ts +243 -0
- package/tests/helpers.ts +90 -0
- package/tests/jwks.test.ts +62 -0
- package/tests/pkce.test.ts +210 -0
- package/tests/react-useHasPermission.test.tsx +92 -0
- package/tests/required-action.test.ts +276 -0
- package/tests/session-version.test.ts +391 -0
- package/tsconfig.json +16 -0
- package/vitest.config.ts +8 -0
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AuthorizationModeMismatchError,
|
|
3
|
+
ConfigurationError,
|
|
4
|
+
DiscoveryError,
|
|
5
|
+
} from "./errors.js";
|
|
6
|
+
import { JwksClient } from "./jwks-client.js";
|
|
7
|
+
import {
|
|
8
|
+
IntrospectionClient,
|
|
9
|
+
type IntrospectionResult,
|
|
10
|
+
} from "./introspection-client.js";
|
|
11
|
+
import type { AccessTokenAuthorizationMode, AuthorizePermissionOptions } from "./types.js";
|
|
12
|
+
|
|
13
|
+
/** Configuration for {@link HearthClient}. */
|
|
14
|
+
export interface HearthClientConfig {
|
|
15
|
+
/**
|
|
16
|
+
* Root URL of the Hearth instance, e.g. `https://auth.example.com`.
|
|
17
|
+
* Required. Must be a valid HTTPS URL.
|
|
18
|
+
*/
|
|
19
|
+
issuerUrl: string;
|
|
20
|
+
/**
|
|
21
|
+
* OAuth 2.0 client ID.
|
|
22
|
+
* Required for flows that need a client identity (e.g. introspection).
|
|
23
|
+
*/
|
|
24
|
+
clientId?: string;
|
|
25
|
+
/**
|
|
26
|
+
* OAuth 2.0 client secret.
|
|
27
|
+
* Required for confidential client flows (e.g. introspection).
|
|
28
|
+
*/
|
|
29
|
+
clientSecret?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Override JWKS cache TTL in milliseconds.
|
|
32
|
+
* Default: respect `Cache-Control: max-age` from the JWKS endpoint,
|
|
33
|
+
* falling back to 5 minutes.
|
|
34
|
+
*/
|
|
35
|
+
jwksTtl?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Override the introspection endpoint URL discovered via OIDC discovery.
|
|
38
|
+
* When absent, the URL is taken from `introspection_endpoint` in the
|
|
39
|
+
* OIDC discovery document.
|
|
40
|
+
*/
|
|
41
|
+
introspectionEndpoint?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Timeout for all outbound HTTP calls in milliseconds.
|
|
44
|
+
* Default: 10 000 (10 seconds).
|
|
45
|
+
*/
|
|
46
|
+
httpTimeout?: number;
|
|
47
|
+
/**
|
|
48
|
+
* Realm ID sent as `X-Realm-ID` on realm-scoped requests.
|
|
49
|
+
* Required for `authorize()` and the `requirePermission()` middleware in
|
|
50
|
+
* `decision` mode.
|
|
51
|
+
*/
|
|
52
|
+
realmId?: string;
|
|
53
|
+
/**
|
|
54
|
+
* Expected access-token authorization mode for this resource server.
|
|
55
|
+
*
|
|
56
|
+
* When set, `introspect()` validates the `mode` field echoed in the
|
|
57
|
+
* introspection response and throws {@link AuthorizationModeMismatchError}
|
|
58
|
+
* if they differ.
|
|
59
|
+
*/
|
|
60
|
+
expectedMode?: AccessTokenAuthorizationMode;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface OidcConfiguration {
|
|
64
|
+
issuer: string;
|
|
65
|
+
jwks_uri: string;
|
|
66
|
+
introspection_endpoint?: string;
|
|
67
|
+
[key: string]: unknown;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Primary entry point for the Hearth Node.js SDK.
|
|
72
|
+
*
|
|
73
|
+
* Accepts a single configuration object, auto-discovers all endpoint URLs
|
|
74
|
+
* from `{issuerUrl}/.well-known/openid-configuration` on first use, and
|
|
75
|
+
* applies `httpTimeout` to every outbound fetch call.
|
|
76
|
+
*
|
|
77
|
+
* Lower-level access is available via {@link JwksClient} and
|
|
78
|
+
* {@link IntrospectionClient}.
|
|
79
|
+
*/
|
|
80
|
+
export class HearthClient {
|
|
81
|
+
/** Issuer URL, trailing slash removed. */
|
|
82
|
+
readonly issuerUrl: string;
|
|
83
|
+
readonly clientId: string | undefined;
|
|
84
|
+
readonly clientSecret: string | undefined;
|
|
85
|
+
readonly jwksTtl: number | undefined;
|
|
86
|
+
readonly introspectionEndpointOverride: string | undefined;
|
|
87
|
+
/** HTTP timeout in milliseconds applied to all outbound fetch calls. */
|
|
88
|
+
readonly httpTimeout: number;
|
|
89
|
+
/** Realm ID for realm-scoped endpoints (e.g. `/oauth/authorize`). */
|
|
90
|
+
readonly realmId: string | undefined;
|
|
91
|
+
/** Expected authorization mode; validated on `introspect()` when present. */
|
|
92
|
+
readonly expectedMode: AccessTokenAuthorizationMode | undefined;
|
|
93
|
+
|
|
94
|
+
private _discovery: OidcConfiguration | null = null;
|
|
95
|
+
private _jwksClient: JwksClient | null = null;
|
|
96
|
+
private _introspectionClient: IntrospectionClient | null = null;
|
|
97
|
+
|
|
98
|
+
constructor(config: HearthClientConfig) {
|
|
99
|
+
if (!config.issuerUrl) {
|
|
100
|
+
throw new ConfigurationError("issuerUrl is required");
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
new URL(config.issuerUrl);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new ConfigurationError(
|
|
106
|
+
`issuerUrl "${config.issuerUrl}" is not a valid URL`,
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.issuerUrl = config.issuerUrl.replace(/\/$/, "");
|
|
111
|
+
this.clientId = config.clientId;
|
|
112
|
+
this.clientSecret = config.clientSecret;
|
|
113
|
+
this.jwksTtl = config.jwksTtl;
|
|
114
|
+
this.introspectionEndpointOverride = config.introspectionEndpoint;
|
|
115
|
+
this.httpTimeout = config.httpTimeout ?? 10_000;
|
|
116
|
+
this.realmId = config.realmId;
|
|
117
|
+
this.expectedMode = config.expectedMode;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Fetches and caches the OIDC discovery document from
|
|
122
|
+
* `{issuerUrl}/.well-known/openid-configuration`.
|
|
123
|
+
*
|
|
124
|
+
* Throws {@link DiscoveryError} when the endpoint is unreachable,
|
|
125
|
+
* returns a non-2xx status, or returns invalid JSON.
|
|
126
|
+
*/
|
|
127
|
+
async discover(): Promise<OidcConfiguration> {
|
|
128
|
+
if (this._discovery) return this._discovery;
|
|
129
|
+
|
|
130
|
+
const url = `${this.issuerUrl}/.well-known/openid-configuration`;
|
|
131
|
+
let resp: Response;
|
|
132
|
+
try {
|
|
133
|
+
resp = await fetch(url, {
|
|
134
|
+
signal: AbortSignal.timeout(this.httpTimeout),
|
|
135
|
+
});
|
|
136
|
+
} catch (err) {
|
|
137
|
+
throw new DiscoveryError(
|
|
138
|
+
`OIDC discovery endpoint unreachable: ${url}`,
|
|
139
|
+
{ cause: err },
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (!resp.ok) {
|
|
144
|
+
throw new DiscoveryError(
|
|
145
|
+
`OIDC discovery returned HTTP ${resp.status}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let doc: OidcConfiguration;
|
|
150
|
+
try {
|
|
151
|
+
doc = (await resp.json()) as OidcConfiguration;
|
|
152
|
+
} catch (err) {
|
|
153
|
+
throw new DiscoveryError(`OIDC discovery returned invalid JSON`, {
|
|
154
|
+
cause: err,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (!doc.jwks_uri) {
|
|
159
|
+
throw new DiscoveryError(
|
|
160
|
+
"OIDC discovery document is missing required field: jwks_uri",
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
this._discovery = doc;
|
|
165
|
+
return doc;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Returns a {@link JwksClient} bound to the `jwks_uri` discovered from
|
|
170
|
+
* the OIDC configuration. The client is created once and reused.
|
|
171
|
+
*/
|
|
172
|
+
async jwksClient(): Promise<JwksClient> {
|
|
173
|
+
if (this._jwksClient) return this._jwksClient;
|
|
174
|
+
const doc = await this.discover();
|
|
175
|
+
this._jwksClient = new JwksClient({
|
|
176
|
+
jwksUri: doc.jwks_uri,
|
|
177
|
+
ttl: this.jwksTtl,
|
|
178
|
+
httpTimeout: this.httpTimeout,
|
|
179
|
+
});
|
|
180
|
+
return this._jwksClient;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Returns an {@link IntrospectionClient} bound to the introspection
|
|
185
|
+
* endpoint. The endpoint is taken from `introspectionEndpoint` config
|
|
186
|
+
* (if provided) or from the OIDC discovery document.
|
|
187
|
+
*
|
|
188
|
+
* Throws {@link ConfigurationError} when:
|
|
189
|
+
* - `clientId` or `clientSecret` are absent (required for introspection)
|
|
190
|
+
* - No introspection endpoint is configured or discoverable
|
|
191
|
+
*/
|
|
192
|
+
async introspectionClient(): Promise<IntrospectionClient> {
|
|
193
|
+
if (this._introspectionClient) return this._introspectionClient;
|
|
194
|
+
|
|
195
|
+
if (!this.clientId || !this.clientSecret) {
|
|
196
|
+
throw new ConfigurationError(
|
|
197
|
+
"clientId and clientSecret are required for token introspection",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const endpoint =
|
|
202
|
+
this.introspectionEndpointOverride ??
|
|
203
|
+
(await this.discover()).introspection_endpoint;
|
|
204
|
+
|
|
205
|
+
if (!endpoint) {
|
|
206
|
+
throw new ConfigurationError(
|
|
207
|
+
"introspection_endpoint is not present in the OIDC discovery document " +
|
|
208
|
+
"and no introspectionEndpoint override was provided in config",
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this._introspectionClient = new IntrospectionClient({
|
|
213
|
+
introspectionEndpoint: endpoint,
|
|
214
|
+
clientId: this.clientId,
|
|
215
|
+
clientSecret: this.clientSecret,
|
|
216
|
+
httpTimeout: this.httpTimeout,
|
|
217
|
+
});
|
|
218
|
+
return this._introspectionClient;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Calls `POST {issuerUrl}/oauth/authorize` to get a per-request permission
|
|
223
|
+
* decision for the given bearer token (Decision mode, HEA-922).
|
|
224
|
+
*
|
|
225
|
+
* Requires `realmId` in config. Fail-closed: returns `false` on any network
|
|
226
|
+
* or server error so authorization cannot be accidentally granted.
|
|
227
|
+
*
|
|
228
|
+
* @throws {@link ConfigurationError} when `realmId` is not configured.
|
|
229
|
+
*/
|
|
230
|
+
async authorize(
|
|
231
|
+
token: string,
|
|
232
|
+
permission: string,
|
|
233
|
+
opts?: AuthorizePermissionOptions,
|
|
234
|
+
): Promise<boolean> {
|
|
235
|
+
if (!this.realmId) {
|
|
236
|
+
throw new ConfigurationError("realmId is required for authorize()");
|
|
237
|
+
}
|
|
238
|
+
const body: Record<string, string> = { permission };
|
|
239
|
+
if (opts?.organizationId) body["organization_id"] = opts.organizationId;
|
|
240
|
+
if (opts?.resource) body["resource"] = opts.resource;
|
|
241
|
+
|
|
242
|
+
try {
|
|
243
|
+
const resp = await fetch(`${this.issuerUrl}/oauth/authorize`, {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: {
|
|
246
|
+
"Content-Type": "application/json",
|
|
247
|
+
"X-Realm-ID": this.realmId,
|
|
248
|
+
Authorization: `Bearer ${token}`,
|
|
249
|
+
},
|
|
250
|
+
body: JSON.stringify(body),
|
|
251
|
+
signal: AbortSignal.timeout(this.httpTimeout),
|
|
252
|
+
});
|
|
253
|
+
if (!resp.ok) return false;
|
|
254
|
+
const data = (await resp.json()) as { allowed?: boolean };
|
|
255
|
+
return data.allowed === true;
|
|
256
|
+
} catch {
|
|
257
|
+
return false; // fail-closed on network/timeout errors
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Introspects a token via RFC 7662 and optionally validates the echoed
|
|
263
|
+
* `mode` field against `expectedMode` from config.
|
|
264
|
+
*
|
|
265
|
+
* Throws {@link AuthorizationModeMismatchError} when `expectedMode` is set
|
|
266
|
+
* and the server echoes a different mode. This catches misconfigured
|
|
267
|
+
* deployments where the resource server and the issuing client disagree on
|
|
268
|
+
* the permission delivery strategy.
|
|
269
|
+
*
|
|
270
|
+
* @throws {@link ConfigurationError} when `clientId`/`clientSecret` are absent.
|
|
271
|
+
* @throws {@link AuthorizationModeMismatchError} on mode echo mismatch.
|
|
272
|
+
*/
|
|
273
|
+
async introspect(token: string): Promise<IntrospectionResult> {
|
|
274
|
+
const ic = await this.introspectionClient();
|
|
275
|
+
const result = await ic.introspect(token);
|
|
276
|
+
if (
|
|
277
|
+
this.expectedMode !== undefined &&
|
|
278
|
+
result.mode !== undefined &&
|
|
279
|
+
result.mode !== this.expectedMode
|
|
280
|
+
) {
|
|
281
|
+
throw new AuthorizationModeMismatchError(
|
|
282
|
+
this.expectedMode,
|
|
283
|
+
String(result.mode),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
}
|
package/src/hearth.ts
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { decodeJwt } from "jose";
|
|
2
|
+
import { HearthApiClient } from "./client.js";
|
|
3
|
+
import { SessionVersionCache } from "./session-version-cache.js";
|
|
4
|
+
import type { MePermissionsResponse, SessionVersionConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/** Options for creating a {@link HearthFacade} via {@link createHearth}. */
|
|
7
|
+
export interface HearthOptions {
|
|
8
|
+
/** Base URL of the Hearth server, e.g. `https://hearth.example.com`. */
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
/** Realm ID to scope all requests to. */
|
|
11
|
+
realmId: string;
|
|
12
|
+
/**
|
|
13
|
+
* Called synchronously on every `hasPermission` / `hasRole` /
|
|
14
|
+
* `inGroup` / `inOrg` check. Return `null`/`undefined` when the
|
|
15
|
+
* caller is unauthenticated.
|
|
16
|
+
*/
|
|
17
|
+
getToken: () => string | null | undefined;
|
|
18
|
+
/**
|
|
19
|
+
* Optional session-version cache configuration (RFC HEA-930 § 13).
|
|
20
|
+
*
|
|
21
|
+
* When `enabled: true` the SDK fetches a session-version snapshot on
|
|
22
|
+
* startup and polls the delta feed at `pollIntervalMs` intervals.
|
|
23
|
+
* Every `hasPermission` / `hasRole` / `inGroup` / `inOrg` call then
|
|
24
|
+
* validates the token's `sv` claim against the local cache — no
|
|
25
|
+
* per-request network hop required.
|
|
26
|
+
*
|
|
27
|
+
* Tokens without an `sv` claim pass through unchanged (backward compat).
|
|
28
|
+
*/
|
|
29
|
+
sessionVersions?: SessionVersionConfig;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Minimum HTTP surface exposed by the facade.
|
|
34
|
+
*
|
|
35
|
+
* For the full API (auth code flow, admin, JWKS, etc.) construct a
|
|
36
|
+
* {@link HearthClient} directly.
|
|
37
|
+
*/
|
|
38
|
+
export interface HearthHttpClient {
|
|
39
|
+
/**
|
|
40
|
+
* Calls `GET /v1/me/permissions` and returns the freshly-resolved
|
|
41
|
+
* RBAC claim set for the current bearer token.
|
|
42
|
+
*/
|
|
43
|
+
permissions(): Promise<MePermissionsResponse>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* RBAC claim-oriented facade over the Hearth SDK.
|
|
48
|
+
*
|
|
49
|
+
* When `sessionVersions` is not configured all boolean predicates are
|
|
50
|
+
* synchronous, lock-free, and decode the JWT returned by `getToken()` on
|
|
51
|
+
* every call. No network traffic, no cache. When the token is absent or
|
|
52
|
+
* malformed every predicate returns `false`.
|
|
53
|
+
*
|
|
54
|
+
* When `sessionVersions.enabled` is `true`, the predicates additionally
|
|
55
|
+
* validate the `sv` claim and may throw {@link SessionVersionRevokedError}
|
|
56
|
+
* or {@link SessionVersionCacheStaleError} (see RFC HEA-930 § 8).
|
|
57
|
+
*/
|
|
58
|
+
export interface HearthFacade {
|
|
59
|
+
/**
|
|
60
|
+
* Returns `true` iff the JWT `permissions` claim contains `permission`.
|
|
61
|
+
*
|
|
62
|
+
* May throw {@link SessionVersionRevokedError} or
|
|
63
|
+
* {@link SessionVersionCacheStaleError} when session-version tracking
|
|
64
|
+
* is enabled and the token's `sv` claim fails validation.
|
|
65
|
+
*/
|
|
66
|
+
hasPermission(permission: string): boolean;
|
|
67
|
+
/**
|
|
68
|
+
* Returns `true` iff the JWT `roles` claim contains `role`.
|
|
69
|
+
*
|
|
70
|
+
* Same session-version throw semantics as {@link hasPermission}.
|
|
71
|
+
*/
|
|
72
|
+
hasRole(role: string): boolean;
|
|
73
|
+
/**
|
|
74
|
+
* Returns `true` iff the JWT `groups` claim contains `group`.
|
|
75
|
+
*
|
|
76
|
+
* Same session-version throw semantics as {@link hasPermission}.
|
|
77
|
+
*/
|
|
78
|
+
inGroup(group: string): boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Returns `true` iff the JWT `oid` claim equals `org`.
|
|
81
|
+
*
|
|
82
|
+
* Same session-version throw semantics as {@link hasPermission}.
|
|
83
|
+
*/
|
|
84
|
+
inOrg(org: string): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Returns the age of the session-version cache in milliseconds.
|
|
87
|
+
*
|
|
88
|
+
* Returns `Infinity` when session-version tracking is not configured or
|
|
89
|
+
* the cache has never been successfully seeded. Use this in health-check
|
|
90
|
+
* endpoints to confirm the cache is fresh before accepting requests.
|
|
91
|
+
*/
|
|
92
|
+
sessionVersionCacheAge(): number;
|
|
93
|
+
/**
|
|
94
|
+
* Stops the background session-version poll loop.
|
|
95
|
+
*
|
|
96
|
+
* Call this when disposing the facade in long-running Node.js services
|
|
97
|
+
* to avoid keeping the event loop alive.
|
|
98
|
+
*/
|
|
99
|
+
stop(): void;
|
|
100
|
+
/** Narrow HTTP surface for live RBAC resolution. */
|
|
101
|
+
client: HearthHttpClient;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface RbacJwtClaims {
|
|
105
|
+
permissions?: unknown;
|
|
106
|
+
roles?: unknown;
|
|
107
|
+
groups?: unknown;
|
|
108
|
+
oid?: unknown;
|
|
109
|
+
/** Session version — `u64` emitted when session_version.enabled=true. */
|
|
110
|
+
sv?: unknown;
|
|
111
|
+
/** Session ID — present on all session-bearing access tokens. */
|
|
112
|
+
sid?: unknown;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Decode the middle JWT segment using `jose.decodeJwt`. Returns `null`
|
|
117
|
+
* when the token is missing, malformed, or cannot be parsed as JSON.
|
|
118
|
+
* Signature is NOT verified — the app trusts its own token.
|
|
119
|
+
*/
|
|
120
|
+
function safeDecode(token: string | null | undefined): RbacJwtClaims | null {
|
|
121
|
+
if (!token || typeof token !== "string") return null;
|
|
122
|
+
try {
|
|
123
|
+
return decodeJwt(token) as RbacJwtClaims;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function arrayContains(claim: unknown, value: string): boolean {
|
|
130
|
+
return Array.isArray(claim) && claim.includes(value);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Extract the `sv` claim as `bigint`, or `undefined` if absent/non-numeric. */
|
|
134
|
+
function extractSv(c: RbacJwtClaims): bigint | undefined {
|
|
135
|
+
if (c.sv === undefined || c.sv === null) return undefined;
|
|
136
|
+
if (typeof c.sv === "number") return BigInt(Math.trunc(c.sv));
|
|
137
|
+
if (typeof c.sv === "bigint") return c.sv;
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Extract the `sid` claim as `string`, or `undefined` if absent. */
|
|
142
|
+
function extractSid(c: RbacJwtClaims): string | undefined {
|
|
143
|
+
return typeof c.sid === "string" ? c.sid : undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Create a {@link HearthFacade} over the RBAC claim set embedded in the
|
|
148
|
+
* JWT returned by `opts.getToken()`.
|
|
149
|
+
*
|
|
150
|
+
* When `opts.sessionVersions.enabled` is `true` the facade additionally
|
|
151
|
+
* starts a background session-version poll loop. Call `facade.stop()` to
|
|
152
|
+
* tear it down.
|
|
153
|
+
*/
|
|
154
|
+
export function createHearth(opts: HearthOptions): HearthFacade {
|
|
155
|
+
const http = new HearthApiClient({
|
|
156
|
+
baseUrl: opts.baseUrl,
|
|
157
|
+
realmId: opts.realmId,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
let svCache: SessionVersionCache | null = null;
|
|
161
|
+
if (opts.sessionVersions?.enabled) {
|
|
162
|
+
svCache = new SessionVersionCache(
|
|
163
|
+
opts.baseUrl,
|
|
164
|
+
opts.realmId,
|
|
165
|
+
opts.sessionVersions,
|
|
166
|
+
);
|
|
167
|
+
svCache.start();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function claims(): RbacJwtClaims | null {
|
|
171
|
+
return safeDecode(opts.getToken());
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Runs the sv check; throws on revoked or stale. No-op when sv absent. */
|
|
175
|
+
function assertSv(c: RbacJwtClaims): void {
|
|
176
|
+
if (svCache !== null) {
|
|
177
|
+
svCache.validateSv(extractSv(c), extractSid(c));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return {
|
|
182
|
+
hasPermission(permission: string): boolean {
|
|
183
|
+
const c = claims();
|
|
184
|
+
if (c === null) return false;
|
|
185
|
+
assertSv(c);
|
|
186
|
+
return arrayContains(c.permissions, permission);
|
|
187
|
+
},
|
|
188
|
+
hasRole(role: string): boolean {
|
|
189
|
+
const c = claims();
|
|
190
|
+
if (c === null) return false;
|
|
191
|
+
assertSv(c);
|
|
192
|
+
return arrayContains(c.roles, role);
|
|
193
|
+
},
|
|
194
|
+
inGroup(group: string): boolean {
|
|
195
|
+
const c = claims();
|
|
196
|
+
if (c === null) return false;
|
|
197
|
+
assertSv(c);
|
|
198
|
+
return arrayContains(c.groups, group);
|
|
199
|
+
},
|
|
200
|
+
inOrg(org: string): boolean {
|
|
201
|
+
const c = claims();
|
|
202
|
+
if (c === null) return false;
|
|
203
|
+
assertSv(c);
|
|
204
|
+
return typeof c.oid === "string" && c.oid === org;
|
|
205
|
+
},
|
|
206
|
+
sessionVersionCacheAge(): number {
|
|
207
|
+
return svCache?.age() ?? Number.POSITIVE_INFINITY;
|
|
208
|
+
},
|
|
209
|
+
stop(): void {
|
|
210
|
+
svCache?.stop();
|
|
211
|
+
},
|
|
212
|
+
client: {
|
|
213
|
+
permissions(): Promise<MePermissionsResponse> {
|
|
214
|
+
const token = opts.getToken();
|
|
215
|
+
if (!token) {
|
|
216
|
+
return Promise.reject(
|
|
217
|
+
new Error("getToken() returned no token; cannot call permissions()"),
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return http.permissions(token);
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// Primary entry point — recommended for all new integrations.
|
|
2
|
+
export { HearthClient } from "./hearth-client.js";
|
|
3
|
+
|
|
4
|
+
// PKCE browser utilities (RFC 7636).
|
|
5
|
+
export {
|
|
6
|
+
generateCodeVerifier,
|
|
7
|
+
generateCodeChallenge,
|
|
8
|
+
buildAuthorizationUrl,
|
|
9
|
+
startLogin,
|
|
10
|
+
} from "./pkce.js";
|
|
11
|
+
export type {
|
|
12
|
+
BuildAuthorizationUrlOptions,
|
|
13
|
+
AuthorizationUrlResult,
|
|
14
|
+
StartLoginOptions,
|
|
15
|
+
StartLoginResult,
|
|
16
|
+
} from "./pkce.js";
|
|
17
|
+
export type { HearthClientConfig } from "./hearth-client.js";
|
|
18
|
+
|
|
19
|
+
// Lower-level primitives (JWKS and introspection).
|
|
20
|
+
export { JwksClient } from "./jwks-client.js";
|
|
21
|
+
export type { JwksClientConfig } from "./jwks-client.js";
|
|
22
|
+
export { IntrospectionClient } from "./introspection-client.js";
|
|
23
|
+
export type {
|
|
24
|
+
IntrospectionClientConfig,
|
|
25
|
+
IntrospectionResult,
|
|
26
|
+
} from "./introspection-client.js";
|
|
27
|
+
|
|
28
|
+
// Error types (spec §5).
|
|
29
|
+
export {
|
|
30
|
+
AuthorizationModeMismatchError,
|
|
31
|
+
ConfigurationError,
|
|
32
|
+
DiscoveryError,
|
|
33
|
+
HearthSdkError,
|
|
34
|
+
IntrospectionError,
|
|
35
|
+
JWKSFetchError,
|
|
36
|
+
RequiredActionError,
|
|
37
|
+
SessionVersionCacheStaleError,
|
|
38
|
+
SessionVersionRevokedError,
|
|
39
|
+
TokenAudienceError,
|
|
40
|
+
TokenExpiredError,
|
|
41
|
+
TokenInvalidError,
|
|
42
|
+
TokenIssuerError,
|
|
43
|
+
TokenNotYetValidError,
|
|
44
|
+
} from "./errors.js";
|
|
45
|
+
|
|
46
|
+
// Mode-aware middleware (HEA-923).
|
|
47
|
+
export { requirePermission } from "./middleware.js";
|
|
48
|
+
export type { PermissionChecker, RequirePermissionOptions } from "./middleware.js";
|
|
49
|
+
|
|
50
|
+
// Claims API (spec §4).
|
|
51
|
+
export { Claims } from "./claims.js";
|
|
52
|
+
|
|
53
|
+
// Lower-level API client (kept for backwards-compatibility).
|
|
54
|
+
export { HearthApiClient, HearthError } from "./client.js";
|
|
55
|
+
export type { HearthApiClientConfig, HandleCallbackParams } from "./client.js";
|
|
56
|
+
export { AdminClient } from "./admin.js";
|
|
57
|
+
export { createHearth } from "./hearth.js";
|
|
58
|
+
export type {
|
|
59
|
+
HearthFacade,
|
|
60
|
+
HearthHttpClient,
|
|
61
|
+
HearthOptions,
|
|
62
|
+
} from "./hearth.js";
|
|
63
|
+
export {
|
|
64
|
+
HearthContext,
|
|
65
|
+
HearthProvider,
|
|
66
|
+
useHasPermission,
|
|
67
|
+
useHasRole,
|
|
68
|
+
useInGroup,
|
|
69
|
+
useInOrg,
|
|
70
|
+
} from "./react.js";
|
|
71
|
+
export type { HearthProviderProps } from "./react.js";
|
|
72
|
+
export type {
|
|
73
|
+
AccessTokenAuthorizationMode,
|
|
74
|
+
AuthorizeParams,
|
|
75
|
+
AuthorizePermissionOptions,
|
|
76
|
+
AuthorizeResponse,
|
|
77
|
+
BootstrapResponse,
|
|
78
|
+
CreateRealmParams,
|
|
79
|
+
CreateUserParams,
|
|
80
|
+
JwksDocument,
|
|
81
|
+
JsonWebKey,
|
|
82
|
+
MePermissionsResponse,
|
|
83
|
+
OAuthClient,
|
|
84
|
+
PageResponse,
|
|
85
|
+
RegisterClientParams,
|
|
86
|
+
Realm,
|
|
87
|
+
SessionVersionConfig,
|
|
88
|
+
TokenExchangeParams,
|
|
89
|
+
TokenResponse,
|
|
90
|
+
UpdateRealmParams,
|
|
91
|
+
UpdateUserParams,
|
|
92
|
+
User,
|
|
93
|
+
UserInfoResponse,
|
|
94
|
+
} from "./types.js";
|
|
95
|
+
export { SessionVersionCache } from "./session-version-cache.js";
|
|
96
|
+
|
|
97
|
+
// Browser auth: token store + PKCE login facade for SPAs.
|
|
98
|
+
export {
|
|
99
|
+
getAccessToken,
|
|
100
|
+
getRefreshToken,
|
|
101
|
+
getIdToken,
|
|
102
|
+
isAuthenticated,
|
|
103
|
+
clearTokens,
|
|
104
|
+
createHearthAuth,
|
|
105
|
+
} from "./browser-auth.js";
|
|
106
|
+
export type { AuthConfig, HearthBrowserAuth } from "./browser-auth.js";
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/** RFC 7662 §2.2 — result of a token introspection request. */
|
|
2
|
+
export interface IntrospectionResult {
|
|
3
|
+
/** Whether the token is currently active. */
|
|
4
|
+
active: boolean;
|
|
5
|
+
/** Subject identifier (when active). */
|
|
6
|
+
sub?: string;
|
|
7
|
+
/** Expiration time as Unix seconds (when active). */
|
|
8
|
+
exp?: number;
|
|
9
|
+
/** Issued-at time as Unix seconds (when active). */
|
|
10
|
+
iat?: number;
|
|
11
|
+
/** Issuer identifier (when active). */
|
|
12
|
+
iss?: string;
|
|
13
|
+
/** Intended audience (when active). */
|
|
14
|
+
aud?: string | string[];
|
|
15
|
+
/** Space-separated scope string (when active and present). */
|
|
16
|
+
scope?: string;
|
|
17
|
+
/** OAuth client that requested the token (when active and present). */
|
|
18
|
+
client_id?: string;
|
|
19
|
+
/**
|
|
20
|
+
* Access-token authorization mode echoed from the issuing client config.
|
|
21
|
+
* Present only when the Hearth server is HEA-922+.
|
|
22
|
+
* Values: `"embedded"` | `"introspection"` | `"decision"`.
|
|
23
|
+
*/
|
|
24
|
+
mode?: string;
|
|
25
|
+
/** Live permission strings (present in introspection/decision mode). */
|
|
26
|
+
permissions?: string[];
|
|
27
|
+
/** Live role names (present in introspection/decision mode). */
|
|
28
|
+
roles?: string[];
|
|
29
|
+
/** Live group slugs (present in introspection/decision mode). */
|
|
30
|
+
groups?: string[];
|
|
31
|
+
/** All non-standard claims. */
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Configuration for {@link IntrospectionClient}. */
|
|
36
|
+
export interface IntrospectionClientConfig {
|
|
37
|
+
/** RFC 7662 introspection endpoint URL. */
|
|
38
|
+
introspectionEndpoint: string;
|
|
39
|
+
/** Client ID used for HTTP Basic authentication. */
|
|
40
|
+
clientId: string;
|
|
41
|
+
/** Client secret used for HTTP Basic authentication. */
|
|
42
|
+
clientSecret: string;
|
|
43
|
+
/** Timeout for outbound HTTP calls in milliseconds. Default: 10 000. */
|
|
44
|
+
httpTimeout?: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Low-level RFC 7662 token introspection client.
|
|
49
|
+
*
|
|
50
|
+
* Results are never cached — per RFC 7662 §2.1, token state can change
|
|
51
|
+
* at any time. Full error taxonomy will be added in §3.
|
|
52
|
+
*/
|
|
53
|
+
export class IntrospectionClient {
|
|
54
|
+
private readonly endpoint: string;
|
|
55
|
+
private readonly clientId: string;
|
|
56
|
+
private readonly clientSecret: string;
|
|
57
|
+
readonly httpTimeout: number;
|
|
58
|
+
|
|
59
|
+
constructor(config: IntrospectionClientConfig) {
|
|
60
|
+
this.endpoint = config.introspectionEndpoint;
|
|
61
|
+
this.clientId = config.clientId;
|
|
62
|
+
this.clientSecret = config.clientSecret;
|
|
63
|
+
this.httpTimeout = config.httpTimeout ?? 10_000;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Introspect a token. Never cached per RFC 7662 §2.1. */
|
|
67
|
+
async introspect(token: string): Promise<IntrospectionResult> {
|
|
68
|
+
const credentials = btoa(`${this.clientId}:${this.clientSecret}`);
|
|
69
|
+
const resp = await fetch(this.endpoint, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: {
|
|
72
|
+
Authorization: `Basic ${credentials}`,
|
|
73
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
74
|
+
},
|
|
75
|
+
body: new URLSearchParams({ token }),
|
|
76
|
+
signal: AbortSignal.timeout(this.httpTimeout),
|
|
77
|
+
});
|
|
78
|
+
if (!resp.ok) {
|
|
79
|
+
throw new Error(`Introspection endpoint returned HTTP ${resp.status}`);
|
|
80
|
+
}
|
|
81
|
+
return resp.json() as Promise<IntrospectionResult>;
|
|
82
|
+
}
|
|
83
|
+
}
|