@rakomi/node 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -1
  3. package/SECURITY.md +206 -0
  4. package/dist/agents.d.ts +90 -0
  5. package/dist/agents.js +203 -0
  6. package/dist/anonymous.d.ts +50 -0
  7. package/dist/anonymous.js +105 -0
  8. package/dist/ciba.d.ts +97 -0
  9. package/dist/ciba.js +282 -0
  10. package/dist/client.d.ts +93 -0
  11. package/dist/client.js +202 -0
  12. package/dist/credentials.d.ts +87 -0
  13. package/dist/credentials.js +104 -0
  14. package/dist/device.d.ts +76 -0
  15. package/dist/device.js +244 -0
  16. package/dist/doctor.d.ts +11 -0
  17. package/dist/doctor.js +135 -0
  18. package/dist/dpop-session.d.ts +90 -0
  19. package/dist/dpop-session.js +127 -0
  20. package/dist/dpop.d.ts +24 -0
  21. package/dist/dpop.js +51 -0
  22. package/dist/env-detect.d.ts +11 -0
  23. package/dist/env-detect.js +26 -0
  24. package/dist/errors.d.ts +307 -0
  25. package/dist/errors.js +385 -0
  26. package/dist/eudi.d.ts +23 -0
  27. package/dist/eudi.js +27 -0
  28. package/dist/flags.d.ts +50 -0
  29. package/dist/flags.js +173 -0
  30. package/dist/guards.d.ts +16 -0
  31. package/dist/guards.js +104 -0
  32. package/dist/index.d.ts +30 -0
  33. package/dist/index.js +18 -0
  34. package/dist/internal/canonical-url.d.ts +13 -0
  35. package/dist/internal/canonical-url.js +52 -0
  36. package/dist/internal/shared-constants.d.ts +3 -0
  37. package/dist/internal/shared-constants.js +3 -0
  38. package/dist/jwks-cache.d.ts +31 -0
  39. package/dist/jwks-cache.js +135 -0
  40. package/dist/link.d.ts +73 -0
  41. package/dist/link.js +262 -0
  42. package/dist/middleware.d.ts +21 -0
  43. package/dist/middleware.js +84 -0
  44. package/dist/oauth.d.ts +46 -0
  45. package/dist/oauth.js +457 -0
  46. package/dist/rbac.d.ts +12 -0
  47. package/dist/rbac.js +20 -0
  48. package/dist/token-exchange.d.ts +65 -0
  49. package/dist/token-exchange.js +163 -0
  50. package/dist/types.d.ts +436 -0
  51. package/dist/types.js +1 -0
  52. package/dist/verify-publisher-webhook.d.ts +25 -0
  53. package/dist/verify-publisher-webhook.js +47 -0
  54. package/dist/verify-token.d.ts +3 -0
  55. package/dist/verify-token.js +148 -0
  56. package/dist/verify-webhook.d.ts +7 -0
  57. package/dist/verify-webhook.js +101 -0
  58. package/package.json +61 -5
  59. package/sbom.cdx.json +52 -0
package/dist/oauth.js ADDED
@@ -0,0 +1,457 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { decodeJwt } from 'jose';
3
+ import { AUTH_DPOP_PROVER_UNAVAILABLE, AUTH_DPOP_ROTATION_DID_NOT_TAKE, AUTH_DPOP_ROTATION_NOOP, AUTH_INVALID_DPOP_PROOF, AUTH_INVALID_REFRESH_TOKEN, AUTH_REFRESH_SUPERSEDED_BY_ROTATION, OAUTH_INVALID_CLIENT, OAUTH_INVALID_GRANT, OAUTH_INVALID_REQUEST, OAUTH_MISSING_CLIENT_ID, OAUTH_NETWORK_ERROR, OAUTH_UNSUPPORTED_GRANT_TYPE, RakomiError, } from './errors.js';
4
+ const DEFAULT_BASE_URL = 'https://api.rakomi.com';
5
+ const DEFAULT_SCOPE = 'openid profile email';
6
+ const inflightByToken = new Map();
7
+ /**
8
+ * Run a refresh_token-consuming operation under the token-keyed gate. Same-kind
9
+ * concurrency coalesces; cross-kind concurrency fails-safe on the loser (no second
10
+ * spend). The gate entry is registered SYNCHRONOUSLY before the first async
11
+ * signing (TOCTOU pin) so a racing same-token operation observes it, never a gap.
12
+ */
13
+ async function withRefreshTokenGate(token, kind, crossKindFailSafe, run) {
14
+ const existing = inflightByToken.get(token);
15
+ if (existing) {
16
+ if (existing.kind === kind) {
17
+ return existing.promise;
18
+ }
19
+ return { ok: false, error: crossKindFailSafe() };
20
+ }
21
+ const promise = run();
22
+ inflightByToken.set(token, { kind, promise });
23
+ try {
24
+ return await promise;
25
+ }
26
+ finally {
27
+ inflightByToken.delete(token);
28
+ }
29
+ }
30
+ /**
31
+ * Generate a PKCE code verifier and challenge pair.
32
+ * Uses node:crypto for secure random generation.
33
+ */
34
+ export function generatePKCE() {
35
+ const codeVerifier = randomBytes(32).toString('base64url');
36
+ const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url');
37
+ return { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' };
38
+ }
39
+ /**
40
+ * Generate a random state parameter for CSRF protection.
41
+ * Returns 32 random bytes, hex-encoded.
42
+ */
43
+ export function generateState() {
44
+ return randomBytes(32).toString('hex');
45
+ }
46
+ /**
47
+ * Build a full /oauth/authorize URL with all required parameters.
48
+ * Pure function — no config dependency, usable without RakomiClient instance.
49
+ */
50
+ export function buildAuthorizeUrl(options) {
51
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
52
+ const scope = Array.isArray(options.scope) ? options.scope.join(' ') : (options.scope ?? DEFAULT_SCOPE);
53
+ const url = new URL('/oauth/authorize', baseUrl);
54
+ url.searchParams.set('response_type', 'code');
55
+ url.searchParams.set('client_id', options.clientId);
56
+ url.searchParams.set('redirect_uri', options.redirectUri);
57
+ url.searchParams.set('scope', scope);
58
+ url.searchParams.set('state', options.state);
59
+ url.searchParams.set('code_challenge', options.codeChallenge);
60
+ url.searchParams.set('code_challenge_method', 'S256');
61
+ return url.toString();
62
+ }
63
+ /**
64
+ * Exchange an authorization code for tokens via POST /oauth/token.
65
+ * Never throws — returns VerifyResult<OAuthTokenResponse>.
66
+ */
67
+ export async function exchangeCode(options) {
68
+ try {
69
+ const clientId = options.clientId;
70
+ const clientSecret = options.clientSecret;
71
+ if (!clientId) {
72
+ throw new RakomiError(OAUTH_MISSING_CLIENT_ID());
73
+ }
74
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
75
+ const params = {
76
+ grant_type: 'authorization_code',
77
+ code: options.code,
78
+ redirect_uri: options.redirectUri,
79
+ client_id: clientId,
80
+ code_verifier: options.codeVerifier,
81
+ };
82
+ if (clientSecret) {
83
+ params.client_secret = clientSecret;
84
+ }
85
+ const body = new URLSearchParams(params);
86
+ const session = options.dpop;
87
+ let dpopProof;
88
+ if (session) {
89
+ try {
90
+ dpopProof = await session.resolveProof('POST', '/oauth/token');
91
+ }
92
+ catch {
93
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
94
+ }
95
+ if (!dpopProof) {
96
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
97
+ }
98
+ }
99
+ const outcome = await tokenRequest(baseUrl, body, dpopProof);
100
+ if (session && outcome.result.ok) {
101
+ await session.observeTokenType(outcome.result.data.token_type, dpopProof !== undefined);
102
+ }
103
+ return outcome.result;
104
+ }
105
+ catch (err) {
106
+ if (err instanceof RakomiError) {
107
+ return { ok: false, error: { code: err.code, message: err.message, suggestion: err.suggestion, docs_url: err.docs_url } };
108
+ }
109
+ const detail = err instanceof Error ? err.message : 'Unknown error';
110
+ return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
111
+ }
112
+ }
113
+ /**
114
+ * Refresh an OAuth token via POST /oauth/token.
115
+ * Serializes concurrent calls with the same refresh token to prevent nuclear revocation.
116
+ * Never throws — returns VerifyResult<OAuthTokenResponse>.
117
+ */
118
+ export async function refreshToken(options) {
119
+ try {
120
+ if (!options.clientId) {
121
+ throw new RakomiError(OAUTH_MISSING_CLIENT_ID());
122
+ }
123
+ return await withRefreshTokenGate(options.refreshToken, 'refresh', () => AUTH_REFRESH_SUPERSEDED_BY_ROTATION(), () => executeRefresh(options));
124
+ }
125
+ catch (err) {
126
+ if (err instanceof RakomiError) {
127
+ return { ok: false, error: { code: err.code, message: err.message, suggestion: err.suggestion, docs_url: err.docs_url } };
128
+ }
129
+ const detail = err instanceof Error ? err.message : 'Unknown error';
130
+ return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
131
+ }
132
+ }
133
+ /**
134
+ * Perform an in-band DPoP refresh-key ROTATION via POST /oauth/token
135
+ * Co-presents the OLD-key proof (the
136
+ * session's current key, on the primary `DPoP` header — backward-safe) and a
137
+ * fresh NEW-key proof (on the `DPoP-Rotate` header) on ONE refresh request, then
138
+ * atomically swaps the session's active prover to the new key ONLY after the
139
+ * server confirms a 200 whose access-token `cnf.jkt` EQUALS the new key's `jkt`
140
+ * (the master invariant). Any other outcome keeps the OLD key bound (fail-SAFE,
141
+ * never a half-swap) and surfaces a distinct non-success signal.
142
+ *
143
+ * This is a DEDICATED ceremony, not a flag on {@link refreshToken}: the second
144
+ * key is created only inside this call frame, so a `DPoP-Rotate` header is
145
+ * structurally impossible to attach to an ordinary refresh. Single-flight
146
+ * per session — a concurrent rotation coalesces. Never throws; always resolves to
147
+ * a `VerifyResult`.
148
+ *
149
+ * @public — additive-only after the first public release.
150
+ */
151
+ export async function rotateRefreshKey(options) {
152
+ try {
153
+ if (!options.clientId) {
154
+ throw new RakomiError(OAUTH_MISSING_CLIENT_ID());
155
+ }
156
+ const session = options.dpop;
157
+ if (session.isBound !== true) {
158
+ return {
159
+ ok: false,
160
+ error: AUTH_INVALID_DPOP_PROOF('Cannot rotate the key of a session that is not DPoP-bound. (Re)bind via exchangeCode first.'),
161
+ };
162
+ }
163
+ return await withRefreshTokenGate(options.refreshToken, 'rotation', () => AUTH_DPOP_ROTATION_DID_NOT_TAKE('A concurrent ordinary refresh is consuming this refresh token; the rotation was not sent. Retry the rotation with the rotated refresh token.'), () => session.runExclusiveRotation(() => executeRotation(options, session)));
164
+ }
165
+ catch (err) {
166
+ if (err instanceof RakomiError) {
167
+ return { ok: false, error: { code: err.code, message: err.message, suggestion: err.suggestion, docs_url: err.docs_url } };
168
+ }
169
+ const detail = err instanceof Error ? err.message : 'Unknown error';
170
+ return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
171
+ }
172
+ }
173
+ const REFRESH_PATH = '/oauth/token';
174
+ /**
175
+ * Run the rotation ceremony INSIDE the session's single-flight latch. Builds both
176
+ * proofs (call-scoped incoming prover), sends the dual-header request, does the
177
+ * single bounded nonce retry REUSING the incoming prover (no second keygen), and
178
+ * finalizes via the master invariant. Never throws.
179
+ */
180
+ async function executeRotation(options, session) {
181
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
182
+ const params = {
183
+ grant_type: 'refresh_token',
184
+ refresh_token: options.refreshToken,
185
+ client_id: options.clientId,
186
+ };
187
+ if (options.clientSecret) {
188
+ params.client_secret = options.clientSecret;
189
+ }
190
+ const body = new URLSearchParams(params);
191
+ let proofs;
192
+ try {
193
+ proofs = await session.resolveRotationProofs('POST', REFRESH_PATH);
194
+ }
195
+ catch {
196
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
197
+ }
198
+ if (!proofs.oldProof || !proofs.newProof) {
199
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
200
+ }
201
+ let outcome = await tokenRequest(baseUrl, body, proofs.oldProof, proofs.newProof);
202
+ if (outcome.nonceChallenge !== undefined) {
203
+ let retry;
204
+ try {
205
+ retry = await session.resolveRotationProofs('POST', REFRESH_PATH, {
206
+ nonce: outcome.nonceChallenge,
207
+ incoming: proofs.incoming,
208
+ });
209
+ }
210
+ catch {
211
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
212
+ }
213
+ if (!retry.oldProof || !retry.newProof) {
214
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
215
+ }
216
+ outcome = await tokenRequest(baseUrl, body, retry.oldProof, retry.newProof);
217
+ }
218
+ return finalizeRotation(outcome, session, proofs.incoming, proofs.newJkt);
219
+ }
220
+ /**
221
+ * The master invariant: a rotation succeeded ONLY when the response is a
222
+ * 200 with `token_type:"DPoP"` AND an observed access-token `cnf.jkt` that EQUALS
223
+ * the new key's `jkt`. EVERY other outcome keeps the OLD prover (fail-SAFE) and
224
+ * surfaces a distinct non-success signal. The one positive check defends the
225
+ * half-swap, the rotation-suppression (stripped/malformed `DPoP-Rotate`), and the
226
+ * rotation-unaware-server 200-on-old-key cases simultaneously.
227
+ */
228
+ async function finalizeRotation(outcome, session, incoming, newJkt) {
229
+ if (!outcome.result.ok) {
230
+ return mapRotationError(outcome);
231
+ }
232
+ const data = outcome.result.data;
233
+ const observedJkt = decodeCnfJkt(data.access_token);
234
+ if (data.token_type === 'DPoP' && observedJkt !== undefined && observedJkt === newJkt) {
235
+ const committed = await session.commitRotation(incoming);
236
+ if (committed) {
237
+ return { ok: true, data: { ...data, rotated: true } };
238
+ }
239
+ return { ok: false, error: AUTH_DPOP_ROTATION_DID_NOT_TAKE('Local bound-key invariant prevented the swap') };
240
+ }
241
+ await session.observeTokenType(data.token_type, true);
242
+ return { ok: true, data: { ...data, rotated: false } };
243
+ }
244
+ /**
245
+ * Map a non-200 rotation outcome to the SDK taxonomy. `rotation_noop` (the
246
+ * server's `400 invalid_request` reason, read from the Rakomi API error
247
+ * envelope's `details.reason`) becomes the distinct {@link AUTH_DPOP_ROTATION_NOOP}; the
248
+ * OLD/NEW proof rejects (`401 invalid_dpop_proof` via `WWW-Authenticate`) are
249
+ * already mapped to {@link AUTH_INVALID_DPOP_PROOF} by `tokenRequest`; everything
250
+ * else (network, `invalid_grant`→refresh-token) flows through `remapRefreshError`.
251
+ */
252
+ function mapRotationError(outcome) {
253
+ if (outcome.errorReason === 'rotation_noop') {
254
+ return { ok: false, error: AUTH_DPOP_ROTATION_NOOP() };
255
+ }
256
+ const mapped = remapRefreshError(outcome.result);
257
+ return mapped.ok
258
+ ? { ok: false, error: OAUTH_NETWORK_ERROR('Unexpected success on the rotation error path') }
259
+ : mapped;
260
+ }
261
+ /**
262
+ * Decode the RFC 7800 `cnf.jkt` confirmation claim from a DPoP-bound access
263
+ * token. Reads the claim WITHOUT signature verification (the SDK is not the
264
+ * token's audience — it only needs the server-asserted bound thumbprint to gate
265
+ * the local prover swap). Returns `undefined` for a malformed token or an absent
266
+ * `cnf.jkt` (treated as rotation-did-not-take by the caller).
267
+ */
268
+ function decodeCnfJkt(accessToken) {
269
+ try {
270
+ const claims = decodeJwt(accessToken);
271
+ const jkt = claims.cnf?.jkt;
272
+ return typeof jkt === 'string' && jkt.length > 0 ? jkt : undefined;
273
+ }
274
+ catch {
275
+ return undefined;
276
+ }
277
+ }
278
+ /**
279
+ * Build + send the refresh request, attaching a DPoP proof when the session is
280
+ * bound, with a single bounded nonce-challenge retry. Never throws —
281
+ * always resolves to a `VerifyResult`. Runs INSIDE the single-flight critical
282
+ * section registered by `refreshToken`.
283
+ */
284
+ async function executeRefresh(options) {
285
+ const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
286
+ const session = options.dpop;
287
+ const params = {
288
+ grant_type: 'refresh_token',
289
+ refresh_token: options.refreshToken,
290
+ client_id: options.clientId,
291
+ };
292
+ if (options.clientSecret) {
293
+ params.client_secret = options.clientSecret;
294
+ }
295
+ const body = new URLSearchParams(params);
296
+ const attachProof = session?.isBound === true;
297
+ if (!attachProof) {
298
+ const outcome = await tokenRequest(baseUrl, body);
299
+ if (session && outcome.result.ok) {
300
+ await session.observeTokenType(outcome.result.data.token_type, false);
301
+ }
302
+ return remapRefreshError(outcome.result);
303
+ }
304
+ const proof = await resolveRefreshProof(session);
305
+ if (proof === null) {
306
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
307
+ }
308
+ const outcome = await tokenRequest(baseUrl, body, proof);
309
+ if (outcome.nonceChallenge !== undefined) {
310
+ const retryProof = await resolveRefreshProof(session, outcome.nonceChallenge);
311
+ if (retryProof === null) {
312
+ return { ok: false, error: AUTH_DPOP_PROVER_UNAVAILABLE() };
313
+ }
314
+ const retryOutcome = await tokenRequest(baseUrl, body, retryProof);
315
+ if (retryOutcome.result.ok) {
316
+ await session.observeTokenType(retryOutcome.result.data.token_type, true);
317
+ }
318
+ return remapRefreshError(retryOutcome.result);
319
+ }
320
+ if (outcome.result.ok) {
321
+ await session.observeTokenType(outcome.result.data.token_type, true);
322
+ }
323
+ return remapRefreshError(outcome.result);
324
+ }
325
+ /**
326
+ * Resolve a refresh proof string from the session prover. Returns `null` (NOT a
327
+ * malformed/empty header) when the signer throws or yields a falsy value — the
328
+ * caller maps that to `auth/dpop_prover_unavailable` (never a silent Bearer
329
+ * downgrade). A fresh proof ⇒ a fresh `jti` per HTTP attempt.
330
+ */
331
+ async function resolveRefreshProof(session, nonce) {
332
+ try {
333
+ const proof = await session.resolveProof('POST', REFRESH_PATH, nonce !== undefined ? { nonce } : undefined);
334
+ return proof || null;
335
+ }
336
+ catch {
337
+ return null;
338
+ }
339
+ }
340
+ /**
341
+ * On the refresh operation, an RFC 6749 `invalid_grant` means the refresh token
342
+ * itself is revoked/expired — surface the distinct `auth/invalid_refresh_token`
343
+ * (class 3), keeping it separable from `auth/invalid_dpop_proof` (class 2) so
344
+ * a caller can tell a recoverable proof problem from a genuine revocation.
345
+ * exchangeCode keeps `oauth/invalid_grant`
346
+ * (an invalid authorization code is a different failure).
347
+ */
348
+ function remapRefreshError(result) {
349
+ if (!result.ok && result.error.code === 'oauth/invalid_grant') {
350
+ return { ok: false, error: AUTH_INVALID_REFRESH_TOKEN(result.error.message) };
351
+ }
352
+ return result;
353
+ }
354
+ const RFC6749_ERROR_MAP = {
355
+ invalid_grant: OAUTH_INVALID_GRANT,
356
+ invalid_client: OAUTH_INVALID_CLIENT,
357
+ invalid_request: OAUTH_INVALID_REQUEST,
358
+ unsupported_grant_type: OAUTH_UNSUPPORTED_GRANT_TYPE,
359
+ };
360
+ /**
361
+ * Build the token-endpoint headers. Adds the `DPoP` header iff an OLD-key proof
362
+ * is supplied, and the `DPoP-Rotate` header iff a NEW-key (rotation) proof is
363
+ * supplied. Both are `set` (replace) semantics — a duplicated header would
364
+ * comma-join server-side into a malformed proof → DEGRADE.
365
+ */
366
+ function buildTokenHeaders(dpopProof, dpopRotateProof) {
367
+ const headers = {
368
+ 'Content-Type': 'application/x-www-form-urlencoded',
369
+ };
370
+ if (dpopProof) {
371
+ headers.DPoP = dpopProof;
372
+ }
373
+ if (dpopRotateProof) {
374
+ headers['DPoP-Rotate'] = dpopRotateProof;
375
+ }
376
+ return headers;
377
+ }
378
+ /** Detect an RFC 9449 challenge value in a `WWW-Authenticate: DPoP …` header (case-insensitive on the keyword). */
379
+ function wwwAuthenticateHasError(headerValue, error) {
380
+ if (!headerValue)
381
+ return false;
382
+ return new RegExp(`error="${error}"`).test(headerValue);
383
+ }
384
+ async function tokenRequest(baseUrl, body, dpopProof, dpopRotateProof) {
385
+ let response;
386
+ try {
387
+ response = await fetch(`${baseUrl}/oauth/token`, {
388
+ method: 'POST',
389
+ redirect: 'error',
390
+ headers: buildTokenHeaders(dpopProof, dpopRotateProof),
391
+ body,
392
+ });
393
+ }
394
+ catch (err) {
395
+ const detail = err instanceof Error ? err.message : 'Network error';
396
+ return { result: { ok: false, error: OAUTH_NETWORK_ERROR(detail) } };
397
+ }
398
+ let json;
399
+ try {
400
+ json = await response.json();
401
+ }
402
+ catch {
403
+ return {
404
+ result: { ok: false, error: OAUTH_NETWORK_ERROR('Invalid JSON response from token endpoint') },
405
+ };
406
+ }
407
+ if (!response.ok) {
408
+ const errorBody = json;
409
+ const errorObj = typeof errorBody.error === 'object' && errorBody.error !== null
410
+ ? errorBody.error
411
+ : undefined;
412
+ const errorCode = typeof errorBody.error === 'string' ? errorBody.error : 'unknown';
413
+ const errorDescription = typeof errorBody.error_description === 'string'
414
+ ? errorBody.error_description
415
+ : errorObj && typeof errorObj.message === 'string'
416
+ ? errorObj.message
417
+ : undefined;
418
+ const details = errorObj && typeof errorObj.details === 'object' && errorObj.details !== null
419
+ ? errorObj.details
420
+ : undefined;
421
+ const errorReason = details && typeof details.reason === 'string' ? details.reason : undefined;
422
+ const wwwAuth = response.headers.get('WWW-Authenticate');
423
+ if (errorCode === 'use_dpop_nonce' || wwwAuthenticateHasError(wwwAuth, 'use_dpop_nonce')) {
424
+ const nonce = response.headers.get('DPoP-Nonce');
425
+ return {
426
+ result: { ok: false, error: AUTH_INVALID_DPOP_PROOF(errorDescription) },
427
+ ...(nonce !== null && nonce.length > 0 ? { nonceChallenge: nonce } : {}),
428
+ };
429
+ }
430
+ if (errorCode === 'invalid_dpop_proof' || wwwAuthenticateHasError(wwwAuth, 'invalid_dpop_proof')) {
431
+ return { result: { ok: false, error: AUTH_INVALID_DPOP_PROOF(errorDescription) } };
432
+ }
433
+ const factory = RFC6749_ERROR_MAP[errorCode];
434
+ if (factory) {
435
+ return { result: { ok: false, error: factory(errorDescription) }, ...(errorReason !== undefined && { errorReason }) };
436
+ }
437
+ return {
438
+ result: { ok: false, error: OAUTH_INVALID_REQUEST(errorDescription || `Token endpoint error: ${errorCode}`) },
439
+ ...(errorReason !== undefined && { errorReason }),
440
+ };
441
+ }
442
+ const data = json;
443
+ if (typeof data.access_token !== 'string' ||
444
+ data.access_token.length === 0 ||
445
+ data.access_token.length > 8192 ||
446
+ typeof data.token_type !== 'string') {
447
+ return {
448
+ result: { ok: false, error: OAUTH_NETWORK_ERROR('Invalid token response: missing or oversized access_token or token_type') },
449
+ };
450
+ }
451
+ if (typeof data.expires_in !== 'number' || !Number.isFinite(data.expires_in) || data.expires_in <= 0 || data.expires_in > 86400) {
452
+ return {
453
+ result: { ok: false, error: OAUTH_NETWORK_ERROR('Invalid token response: expires_in out of acceptable range [1, 86400]') },
454
+ };
455
+ }
456
+ return { result: { ok: true, data: json } };
457
+ }
package/dist/rbac.d.ts ADDED
@@ -0,0 +1,12 @@
1
+ import type { TokenPayload } from './types.js';
2
+ /**
3
+ * Check if the token payload contains a specific role key.
4
+ * Role keys are immutable slugs (e.g., 'team_admin'), not display names.
5
+ */
6
+ export declare function hasRole(payload: TokenPayload, roleKey: string): boolean;
7
+ /**
8
+ * Check if the token payload contains a specific permission.
9
+ * Supports wildcard matching: if user has 'posts:*', hasPermission('posts:read') returns true.
10
+ * Match logic: split on ':', compare namespace exactly, '*' in action position matches any action.
11
+ */
12
+ export declare function hasPermission(payload: TokenPayload, permission: string): boolean;
package/dist/rbac.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Check if the token payload contains a specific role key.
3
+ * Role keys are immutable slugs (e.g., 'team_admin'), not display names.
4
+ */
5
+ export function hasRole(payload, roleKey) {
6
+ return payload.roles.includes(roleKey);
7
+ }
8
+ /**
9
+ * Check if the token payload contains a specific permission.
10
+ * Supports wildcard matching: if user has 'posts:*', hasPermission('posts:read') returns true.
11
+ * Match logic: split on ':', compare namespace exactly, '*' in action position matches any action.
12
+ */
13
+ export function hasPermission(payload, permission) {
14
+ if (payload.permissions.includes(permission))
15
+ return true;
16
+ const [namespace] = permission.split(':');
17
+ if (!namespace)
18
+ return false;
19
+ return payload.permissions.includes(`${namespace}:*`);
20
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * RFC 8693 Token Exchange helper.
3
+ *
4
+ * Higher-level wrapper around POST /oauth/token with grant_type =
5
+ * `urn:ietf:params:oauth:grant-type:token-exchange`. Authenticates with the
6
+ * SDK client's pre-configured `clientId` + `clientSecret` (HTTP Basic) — the
7
+ * agent client MUST be registered with `clientType: 'agent'` and
8
+ * `grantTypes: ['urn:ietf:params:oauth:grant-type:token-exchange']`.
9
+ *
10
+ * Returns a typed Result (never throws on known API failures); error classes
11
+ * map RFC 8693 / RFC 6749 §5.2 codes for ergonomic catch-handling.
12
+ *
13
+ * **Server-side only** agent client_secret MUST NEVER be embedded in browser
14
+ * or mobile client code. Agents run server-side; if you need a browser-side
15
+ * agent flow, use CIBA.
16
+ */
17
+ import { TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE } from './internal/shared-constants.js';
18
+ import type { VerifyResult } from './types.js';
19
+ export interface TokenExchangeOptions {
20
+ /** A user's currently-valid Rakomi access token (RS256 JWT). */
21
+ subjectToken: string;
22
+ /** Optional space-delimited or array-of-strings narrowed scope set. */
23
+ scope?: string | string[];
24
+ /** Optional target audience for the agent token's `aud` claim. */
25
+ audience?: string;
26
+ }
27
+ export interface TokenExchangeResponse {
28
+ accessToken: string;
29
+ tokenType: 'Bearer';
30
+ expiresIn: number;
31
+ scope: string;
32
+ issuedTokenType: typeof TOKEN_EXCHANGE_ACCESS_TOKEN_TYPE;
33
+ }
34
+ export declare class TokenExchangeError extends Error {
35
+ readonly code: string;
36
+ readonly description: string;
37
+ constructor(code: string, description: string);
38
+ }
39
+ export declare class TokenExchangeInvalidGrantError extends TokenExchangeError {
40
+ constructor(description: string);
41
+ }
42
+ export declare class TokenExchangeUnauthorizedClientError extends TokenExchangeError {
43
+ constructor(description: string);
44
+ }
45
+ export declare class TokenExchangeInvalidScopeError extends TokenExchangeError {
46
+ constructor(description: string);
47
+ }
48
+ export declare class TokenExchangeRateLimitedError extends TokenExchangeError {
49
+ constructor(description: string);
50
+ }
51
+ export declare class TokenExchangeInvalidClientError extends TokenExchangeError {
52
+ constructor(description: string);
53
+ }
54
+ interface ExchangeContext {
55
+ baseUrl: string;
56
+ clientId: string;
57
+ clientSecret: string;
58
+ }
59
+ export declare function exchangeTokenViaApi(ctx: ExchangeContext, options: TokenExchangeOptions): Promise<VerifyResult<TokenExchangeResponse>>;
60
+ /**
61
+ * Throwing variant — used by `client.tokens.exchange` per (typed errors).
62
+ * Maps the Result to `TokenExchange*Error` instances.
63
+ */
64
+ export declare function exchangeTokenOrThrow(ctx: ExchangeContext, options: TokenExchangeOptions): Promise<TokenExchangeResponse>;
65
+ export {};