@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.
- package/LICENSE +21 -0
- package/README.md +57 -1
- package/SECURITY.md +206 -0
- package/dist/agents.d.ts +90 -0
- package/dist/agents.js +203 -0
- package/dist/anonymous.d.ts +50 -0
- package/dist/anonymous.js +105 -0
- package/dist/ciba.d.ts +97 -0
- package/dist/ciba.js +282 -0
- package/dist/client.d.ts +93 -0
- package/dist/client.js +202 -0
- package/dist/credentials.d.ts +87 -0
- package/dist/credentials.js +104 -0
- package/dist/device.d.ts +76 -0
- package/dist/device.js +244 -0
- package/dist/doctor.d.ts +11 -0
- package/dist/doctor.js +135 -0
- package/dist/dpop-session.d.ts +90 -0
- package/dist/dpop-session.js +127 -0
- package/dist/dpop.d.ts +24 -0
- package/dist/dpop.js +51 -0
- package/dist/env-detect.d.ts +11 -0
- package/dist/env-detect.js +26 -0
- package/dist/errors.d.ts +307 -0
- package/dist/errors.js +385 -0
- package/dist/eudi.d.ts +23 -0
- package/dist/eudi.js +27 -0
- package/dist/flags.d.ts +50 -0
- package/dist/flags.js +173 -0
- package/dist/guards.d.ts +16 -0
- package/dist/guards.js +104 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +18 -0
- package/dist/internal/canonical-url.d.ts +13 -0
- package/dist/internal/canonical-url.js +52 -0
- package/dist/internal/shared-constants.d.ts +3 -0
- package/dist/internal/shared-constants.js +3 -0
- package/dist/jwks-cache.d.ts +31 -0
- package/dist/jwks-cache.js +135 -0
- package/dist/link.d.ts +73 -0
- package/dist/link.js +262 -0
- package/dist/middleware.d.ts +21 -0
- package/dist/middleware.js +84 -0
- package/dist/oauth.d.ts +46 -0
- package/dist/oauth.js +457 -0
- package/dist/rbac.d.ts +12 -0
- package/dist/rbac.js +20 -0
- package/dist/token-exchange.d.ts +65 -0
- package/dist/token-exchange.js +163 -0
- package/dist/types.d.ts +436 -0
- package/dist/types.js +1 -0
- package/dist/verify-publisher-webhook.d.ts +25 -0
- package/dist/verify-publisher-webhook.js +47 -0
- package/dist/verify-token.d.ts +3 -0
- package/dist/verify-token.js +148 -0
- package/dist/verify-webhook.d.ts +7 -0
- package/dist/verify-webhook.js +101 -0
- package/package.json +61 -5
- 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 {};
|