@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/device.js
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { DEVICE_AUTHORIZATION_DENIED, DEVICE_AUTHORIZATION_EXPIRED, DEVICE_AUTHORIZATION_PENDING, DEVICE_AUTHORIZATION_RATE_LIMITED, DEVICE_AUTHORIZATION_SLOW_DOWN, DEVICE_AUTHORIZATION_TIMEOUT, OAUTH_INVALID_CLIENT, OAUTH_INVALID_GRANT, OAUTH_INVALID_REQUEST, OAUTH_MISSING_CLIENT_ID, OAUTH_NETWORK_ERROR, RakomiError, } from './errors.js';
|
|
2
|
+
const DEFAULT_BASE_URL = 'https://api.rakomi.com';
|
|
3
|
+
const DEVICE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code';
|
|
4
|
+
const AWAIT_MAX_ITERATIONS = 2000;
|
|
5
|
+
const MAX_POLL_INTERVAL_MS = 60_000;
|
|
6
|
+
/**
|
|
7
|
+
* POST /oauth/device/code — initiate a device authorization request.
|
|
8
|
+
*/
|
|
9
|
+
export async function startDeviceAuthorization(options) {
|
|
10
|
+
if (!options.clientId) {
|
|
11
|
+
return { ok: false, error: OAUTH_MISSING_CLIENT_ID() };
|
|
12
|
+
}
|
|
13
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
14
|
+
const body = new URLSearchParams({ client_id: options.clientId });
|
|
15
|
+
if (options.clientSecret)
|
|
16
|
+
body.set('client_secret', options.clientSecret);
|
|
17
|
+
if (options.scope) {
|
|
18
|
+
body.set('scope', Array.isArray(options.scope) ? options.scope.join(' ') : options.scope);
|
|
19
|
+
}
|
|
20
|
+
if (options.nonce)
|
|
21
|
+
body.set('nonce', options.nonce);
|
|
22
|
+
let response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetch(`${baseUrl}/oauth/device/code`, {
|
|
25
|
+
method: 'POST',
|
|
26
|
+
redirect: 'error',
|
|
27
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
28
|
+
body,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
const detail = err instanceof Error ? err.message : 'Network error';
|
|
33
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
|
|
34
|
+
}
|
|
35
|
+
let json;
|
|
36
|
+
try {
|
|
37
|
+
json = await response.json();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR('Invalid JSON from device-code endpoint') };
|
|
41
|
+
}
|
|
42
|
+
if (!response.ok) {
|
|
43
|
+
return { ok: false, error: mapStartError(json) };
|
|
44
|
+
}
|
|
45
|
+
const data = json;
|
|
46
|
+
if (typeof data.device_code !== 'string' ||
|
|
47
|
+
typeof data.user_code !== 'string' ||
|
|
48
|
+
typeof data.verification_uri !== 'string' ||
|
|
49
|
+
typeof data.verification_uri_complete !== 'string' ||
|
|
50
|
+
typeof data.expires_in !== 'number' ||
|
|
51
|
+
typeof data.interval !== 'number') {
|
|
52
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR('Malformed device authorization response') };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, data: data };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* POST /oauth/token — single poll for a device authorization grant.
|
|
58
|
+
*
|
|
59
|
+
* Returns:
|
|
60
|
+
* - ok: true → access_token + refresh_token (+ id_token if openid was requested)
|
|
61
|
+
* - ok: false, error.code === 'device/authorization_pending' → keep polling
|
|
62
|
+
* - ok: false, error.code === 'device/slow_down' → increment interval +5s, keep polling
|
|
63
|
+
* - ok: false, error.code === 'device/access_denied' | 'device/expired_token' → stop
|
|
64
|
+
* - ok: false, OAuth/network error → stop
|
|
65
|
+
*/
|
|
66
|
+
export async function pollForDeviceToken(options) {
|
|
67
|
+
if (!options.clientId) {
|
|
68
|
+
return { ok: false, error: OAUTH_MISSING_CLIENT_ID() };
|
|
69
|
+
}
|
|
70
|
+
const baseUrl = options.baseUrl ?? DEFAULT_BASE_URL;
|
|
71
|
+
const params = {
|
|
72
|
+
grant_type: DEVICE_GRANT_TYPE,
|
|
73
|
+
device_code: options.deviceCode,
|
|
74
|
+
client_id: options.clientId,
|
|
75
|
+
};
|
|
76
|
+
if (options.clientSecret)
|
|
77
|
+
params.client_secret = options.clientSecret;
|
|
78
|
+
const body = new URLSearchParams(params);
|
|
79
|
+
let response;
|
|
80
|
+
try {
|
|
81
|
+
response = await fetch(`${baseUrl}/oauth/token`, {
|
|
82
|
+
method: 'POST',
|
|
83
|
+
redirect: 'error',
|
|
84
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
85
|
+
body,
|
|
86
|
+
signal: options.signal,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const detail = err instanceof Error ? err.message : 'Network error';
|
|
91
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
|
|
92
|
+
}
|
|
93
|
+
let json;
|
|
94
|
+
try {
|
|
95
|
+
json = await response.json();
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR('Invalid JSON from token endpoint') };
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
return { ok: false, error: mapPollError(json) };
|
|
102
|
+
}
|
|
103
|
+
const data = json;
|
|
104
|
+
if (typeof data.access_token !== 'string' || typeof data.token_type !== 'string') {
|
|
105
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR('Invalid token response: missing access_token or token_type') };
|
|
106
|
+
}
|
|
107
|
+
return { ok: true, data: json };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* High-level helper: poll the token endpoint at the server-suggested interval
|
|
111
|
+
* until success, terminal error, timeout, or abort. Honors RFC 8628 §3.5
|
|
112
|
+
* `slow_down` by incrementing the interval by 5s before the next poll.
|
|
113
|
+
*/
|
|
114
|
+
export async function awaitDeviceTokens(options) {
|
|
115
|
+
let intervalMs = Math.max(1, options.intervalSeconds) * 1000;
|
|
116
|
+
const deadline = Date.now() + (options.timeoutMs ?? 30 * 60 * 1000);
|
|
117
|
+
for (let i = 0; i < AWAIT_MAX_ITERATIONS; i++) {
|
|
118
|
+
if (options.signal?.aborted) {
|
|
119
|
+
return { ok: false, error: DEVICE_AUTHORIZATION_TIMEOUT('Polling cancelled by AbortSignal') };
|
|
120
|
+
}
|
|
121
|
+
if (Date.now() >= deadline) {
|
|
122
|
+
return { ok: false, error: DEVICE_AUTHORIZATION_TIMEOUT() };
|
|
123
|
+
}
|
|
124
|
+
await sleep(intervalMs, options.signal);
|
|
125
|
+
if (options.signal?.aborted) {
|
|
126
|
+
return { ok: false, error: DEVICE_AUTHORIZATION_TIMEOUT('Polling cancelled by AbortSignal') };
|
|
127
|
+
}
|
|
128
|
+
const result = await pollForDeviceToken({
|
|
129
|
+
deviceCode: options.deviceCode,
|
|
130
|
+
clientId: options.clientId,
|
|
131
|
+
clientSecret: options.clientSecret,
|
|
132
|
+
baseUrl: options.baseUrl,
|
|
133
|
+
signal: options.signal,
|
|
134
|
+
});
|
|
135
|
+
if (result.ok)
|
|
136
|
+
return result;
|
|
137
|
+
if (result.error.code === 'device/authorization_pending')
|
|
138
|
+
continue;
|
|
139
|
+
if (result.error.code === 'device/slow_down') {
|
|
140
|
+
intervalMs = Math.min(intervalMs + 5000, MAX_POLL_INTERVAL_MS);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
return { ok: false, error: DEVICE_AUTHORIZATION_TIMEOUT('Exceeded maximum polling iterations') };
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* One-shot device flow: initiate, surface the code via `onCode`, poll until
|
|
149
|
+
* tokens are issued. Returns the same VerifyResult as awaitDeviceTokens.
|
|
150
|
+
*
|
|
151
|
+
* Copy-paste recipe for CLIs and AI agents.
|
|
152
|
+
*/
|
|
153
|
+
export async function run(options) {
|
|
154
|
+
const issued = await startDeviceAuthorization({
|
|
155
|
+
clientId: options.clientId,
|
|
156
|
+
clientSecret: options.clientSecret,
|
|
157
|
+
scope: options.scope,
|
|
158
|
+
nonce: options.nonce,
|
|
159
|
+
baseUrl: options.baseUrl,
|
|
160
|
+
});
|
|
161
|
+
if (!issued.ok)
|
|
162
|
+
return { ok: false, error: issued.error };
|
|
163
|
+
try {
|
|
164
|
+
await options.onCode(issued.data);
|
|
165
|
+
}
|
|
166
|
+
catch (err) {
|
|
167
|
+
if (err instanceof RakomiError) {
|
|
168
|
+
return {
|
|
169
|
+
ok: false,
|
|
170
|
+
error: { code: err.code, message: err.message, suggestion: err.suggestion, docs_url: err.docs_url },
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
const detail = err instanceof Error ? err.message : 'onCode handler threw';
|
|
174
|
+
return { ok: false, error: OAUTH_NETWORK_ERROR(detail) };
|
|
175
|
+
}
|
|
176
|
+
return awaitDeviceTokens({
|
|
177
|
+
deviceCode: issued.data.device_code,
|
|
178
|
+
clientId: options.clientId,
|
|
179
|
+
clientSecret: options.clientSecret,
|
|
180
|
+
intervalSeconds: issued.data.interval,
|
|
181
|
+
timeoutMs: options.timeoutMs ?? issued.data.expires_in * 1000,
|
|
182
|
+
baseUrl: options.baseUrl,
|
|
183
|
+
signal: options.signal,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function mapStartError(json) {
|
|
187
|
+
const errorBody = json;
|
|
188
|
+
const errorCode = typeof errorBody.error === 'string' ? errorBody.error : 'unknown';
|
|
189
|
+
const errorDescription = typeof errorBody.error_description === 'string' ? errorBody.error_description : undefined;
|
|
190
|
+
switch (errorCode) {
|
|
191
|
+
case 'invalid_client':
|
|
192
|
+
return OAUTH_INVALID_CLIENT(errorDescription);
|
|
193
|
+
case 'unauthorized_client':
|
|
194
|
+
return OAUTH_INVALID_CLIENT(errorDescription || 'Client is not authorized for the device_code grant');
|
|
195
|
+
case 'invalid_request':
|
|
196
|
+
return OAUTH_INVALID_REQUEST(errorDescription);
|
|
197
|
+
case 'invalid_scope':
|
|
198
|
+
return OAUTH_INVALID_REQUEST(errorDescription || 'Requested scope is not allowed for this client');
|
|
199
|
+
case 'slow_down':
|
|
200
|
+
return DEVICE_AUTHORIZATION_RATE_LIMITED(errorDescription);
|
|
201
|
+
default:
|
|
202
|
+
return OAUTH_INVALID_REQUEST(errorDescription || `device-code endpoint error: ${errorCode}`);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function mapPollError(json) {
|
|
206
|
+
const errorBody = json;
|
|
207
|
+
const errorCode = typeof errorBody.error === 'string' ? errorBody.error : 'unknown';
|
|
208
|
+
const errorDescription = typeof errorBody.error_description === 'string' ? errorBody.error_description : undefined;
|
|
209
|
+
switch (errorCode) {
|
|
210
|
+
case 'authorization_pending':
|
|
211
|
+
return DEVICE_AUTHORIZATION_PENDING(errorDescription);
|
|
212
|
+
case 'slow_down':
|
|
213
|
+
return DEVICE_AUTHORIZATION_SLOW_DOWN(errorDescription);
|
|
214
|
+
case 'access_denied':
|
|
215
|
+
return DEVICE_AUTHORIZATION_DENIED(errorDescription);
|
|
216
|
+
case 'expired_token':
|
|
217
|
+
return DEVICE_AUTHORIZATION_EXPIRED(errorDescription);
|
|
218
|
+
case 'invalid_grant':
|
|
219
|
+
return OAUTH_INVALID_GRANT(errorDescription);
|
|
220
|
+
case 'invalid_client':
|
|
221
|
+
case 'unauthorized_client':
|
|
222
|
+
return OAUTH_INVALID_CLIENT(errorDescription);
|
|
223
|
+
default:
|
|
224
|
+
return OAUTH_INVALID_REQUEST(errorDescription || `token endpoint error: ${errorCode}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function sleep(ms, signal) {
|
|
228
|
+
return new Promise((resolve) => {
|
|
229
|
+
if (signal?.aborted) {
|
|
230
|
+
resolve();
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
signal?.removeEventListener('abort', onAbort);
|
|
235
|
+
resolve();
|
|
236
|
+
}, ms);
|
|
237
|
+
const onAbort = () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
signal?.removeEventListener('abort', onAbort);
|
|
240
|
+
resolve();
|
|
241
|
+
};
|
|
242
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
243
|
+
});
|
|
244
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
export interface CheckResult {
|
|
3
|
+
name: string;
|
|
4
|
+
passed: boolean;
|
|
5
|
+
detail: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function getSdkVersion(): string;
|
|
8
|
+
export declare function checkApiReachability(baseUrl: string): Promise<CheckResult>;
|
|
9
|
+
export declare function checkJwks(baseUrl: string): Promise<CheckResult>;
|
|
10
|
+
export declare function checkTokenVerification(baseUrl: string, apiKey: string): Promise<CheckResult>;
|
|
11
|
+
export declare function computeExitCode(results: CheckResult[]): number;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const GREEN = '\x1b[32m';
|
|
6
|
+
const RED = '\x1b[31m';
|
|
7
|
+
const RESET = '\x1b[0m';
|
|
8
|
+
const BOLD = '\x1b[1m';
|
|
9
|
+
export function getSdkVersion() {
|
|
10
|
+
const dir = fileURLToPath(new URL('.', import.meta.url));
|
|
11
|
+
const pkgPath = resolve(dir, '..', 'package.json');
|
|
12
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
13
|
+
return pkg.version;
|
|
14
|
+
}
|
|
15
|
+
export async function checkApiReachability(baseUrl) {
|
|
16
|
+
const url = `${baseUrl}/v1/health`;
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
20
|
+
const latency = Date.now() - start;
|
|
21
|
+
if (res.ok) {
|
|
22
|
+
return { name: 'API reachable', passed: true, detail: `${url} (${latency}ms)` };
|
|
23
|
+
}
|
|
24
|
+
return { name: 'API reachable', passed: false, detail: `HTTP ${String(res.status)} from ${url}` };
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return {
|
|
28
|
+
name: 'API reachable',
|
|
29
|
+
passed: false,
|
|
30
|
+
detail: err instanceof Error ? err.message : 'Unknown error',
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export async function checkJwks(baseUrl) {
|
|
35
|
+
const url = `${baseUrl}/.well-known/jwks.json`;
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(url, { redirect: 'error', signal: AbortSignal.timeout(10_000) });
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
return { name: 'JWKS available', passed: false, detail: `HTTP ${String(res.status)} from ${url}` };
|
|
40
|
+
}
|
|
41
|
+
const data = (await res.json());
|
|
42
|
+
if (!Array.isArray(data.keys)) {
|
|
43
|
+
return { name: 'JWKS available', passed: false, detail: 'Response missing keys array' };
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
name: 'JWKS available',
|
|
47
|
+
passed: true,
|
|
48
|
+
detail: `${String(data.keys.length)} key(s) found`,
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
return {
|
|
53
|
+
name: 'JWKS available',
|
|
54
|
+
passed: false,
|
|
55
|
+
detail: err instanceof Error ? err.message : 'Unknown error',
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const TOKEN_ERROR_PREFIX = 'token/';
|
|
60
|
+
export async function checkTokenVerification(baseUrl, apiKey) {
|
|
61
|
+
try {
|
|
62
|
+
const { RakomiClient } = await import('./client.js');
|
|
63
|
+
const ca = new RakomiClient({ apiKey, baseUrl });
|
|
64
|
+
const result = await Promise.race([
|
|
65
|
+
ca.verifyToken('test'),
|
|
66
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('Token verification timed out after 10s')), 10_000)),
|
|
67
|
+
]);
|
|
68
|
+
if (!result.ok) {
|
|
69
|
+
if (result.error.code.startsWith(TOKEN_ERROR_PREFIX)) {
|
|
70
|
+
return {
|
|
71
|
+
name: 'Token verification',
|
|
72
|
+
passed: true,
|
|
73
|
+
detail: `${result.error.code} (expected — no real token provided)`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
name: 'Token verification',
|
|
78
|
+
passed: false,
|
|
79
|
+
detail: `${result.error.code}: ${result.error.message}`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
return { name: 'Token verification', passed: true, detail: 'SDK initialized correctly' };
|
|
83
|
+
}
|
|
84
|
+
catch (err) {
|
|
85
|
+
return {
|
|
86
|
+
name: 'Token verification',
|
|
87
|
+
passed: false,
|
|
88
|
+
detail: err instanceof Error ? err.message : 'SDK initialization failed',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
export function computeExitCode(results) {
|
|
93
|
+
return results.every((r) => r.passed) ? 0 : 1;
|
|
94
|
+
}
|
|
95
|
+
function formatResult(result) {
|
|
96
|
+
const icon = result.passed ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`;
|
|
97
|
+
return ` ${icon} ${result.name}: ${result.detail}`;
|
|
98
|
+
}
|
|
99
|
+
function parseArgs(args) {
|
|
100
|
+
let baseUrl = 'https://api.rakomi.com';
|
|
101
|
+
let apiKey = 'ca_test_doctor_check';
|
|
102
|
+
for (let i = 0; i < args.length; i++) {
|
|
103
|
+
if (args[i] === '--base-url' && args[i + 1]) {
|
|
104
|
+
baseUrl = args[i + 1];
|
|
105
|
+
i++;
|
|
106
|
+
}
|
|
107
|
+
else if (args[i] === '--api-key' && args[i + 1]) {
|
|
108
|
+
apiKey = args[i + 1];
|
|
109
|
+
i++;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { baseUrl, apiKey };
|
|
113
|
+
}
|
|
114
|
+
async function main() {
|
|
115
|
+
const { baseUrl, apiKey } = parseArgs(process.argv.slice(2));
|
|
116
|
+
const version = getSdkVersion();
|
|
117
|
+
process.stdout.write(`\n${BOLD}@rakomi/node Doctor v${version}${RESET}\n`);
|
|
118
|
+
process.stdout.write('================================\n\n');
|
|
119
|
+
const results = [];
|
|
120
|
+
results.push({ name: 'SDK version', passed: true, detail: version });
|
|
121
|
+
process.stdout.write(formatResult(results[results.length - 1]) + '\n');
|
|
122
|
+
results.push(await checkApiReachability(baseUrl));
|
|
123
|
+
process.stdout.write(formatResult(results[results.length - 1]) + '\n');
|
|
124
|
+
results.push(await checkJwks(baseUrl));
|
|
125
|
+
process.stdout.write(formatResult(results[results.length - 1]) + '\n');
|
|
126
|
+
results.push(await checkTokenVerification(baseUrl, apiKey));
|
|
127
|
+
process.stdout.write(formatResult(results[results.length - 1]) + '\n');
|
|
128
|
+
const passedCount = results.filter((r) => r.passed).length;
|
|
129
|
+
process.stdout.write(`\n${String(passedCount)}/${String(results.length)} checks passed\n\n`);
|
|
130
|
+
process.exitCode = computeExitCode(results);
|
|
131
|
+
}
|
|
132
|
+
main().catch((err) => {
|
|
133
|
+
process.stderr.write(`Doctor failed: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
134
|
+
process.exitCode = 1;
|
|
135
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { type DpopProver } from './dpop.js';
|
|
2
|
+
/** Reason a DPoP-requested session was downgraded to Bearer by the server. */
|
|
3
|
+
export interface DpopDowngradeInfo {
|
|
4
|
+
/** The server returned `token_type: "Bearer"` for a session that presented a DPoP proof. */
|
|
5
|
+
reason: 'server_returned_bearer';
|
|
6
|
+
}
|
|
7
|
+
/** Options for {@link createDpopSession} / the {@link DpopSession} constructor. */
|
|
8
|
+
export interface CreateDpopSessionOptions {
|
|
9
|
+
/**
|
|
10
|
+
* Base URL of the Rakomi API (e.g. `https://api.rakomi.com`). Used to build
|
|
11
|
+
* the canonical `htu` of the DPoP proof. MUST match the host the refresh call
|
|
12
|
+
* targets, or the server rejects the proof (`htu` mismatch, RFC 9449 §4.3).
|
|
13
|
+
*/
|
|
14
|
+
baseUrl: string;
|
|
15
|
+
/**
|
|
16
|
+
* Pinned proof algorithm. Defaults to `ES256` — the cross-port baseline
|
|
17
|
+
* (universally available; Apple Secure-Enclave-eligible). `EdDSA` is
|
|
18
|
+
* `@rakomi/node`-only. The `alg` is NEVER derived from a key or any input.
|
|
19
|
+
*/
|
|
20
|
+
alg?: 'ES256' | 'EdDSA';
|
|
21
|
+
/**
|
|
22
|
+
* Invoked at most once if the server downgrades a DPoP-requested session to
|
|
23
|
+
* `Bearer` (security-downgrade detector). The session does NOT become bound.
|
|
24
|
+
* No secret material (proof / key / jwk / jti) is ever passed to this hook.
|
|
25
|
+
*/
|
|
26
|
+
onDowngrade?: (info: DpopDowngradeInfo) => void;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Opaque, session-scoped DPoP binding handle. Create ONE per logged-in session,
|
|
30
|
+
* pass it to `exchangeCode({..., dpop })` AND every `refreshToken({..., dpop })`
|
|
31
|
+
* for that session. Never share an instance across distinct sessions or across
|
|
32
|
+
* a refresh token boundary.
|
|
33
|
+
*
|
|
34
|
+
* @public — additive-only after the first public release (a removed/renamed member
|
|
35
|
+
* is a MAJOR bump). The options-bag shape leaves room for a future rotation hook
|
|
36
|
+
* to be appended additively.
|
|
37
|
+
*/
|
|
38
|
+
export declare class DpopSession {
|
|
39
|
+
private prover;
|
|
40
|
+
private readonly onDowngrade?;
|
|
41
|
+
private readonly baseUrl;
|
|
42
|
+
private readonly alg;
|
|
43
|
+
private _bound;
|
|
44
|
+
private _downgraded;
|
|
45
|
+
private _downgradeNotified;
|
|
46
|
+
private _boundJkt;
|
|
47
|
+
private _inflightRotation;
|
|
48
|
+
constructor(options: CreateDpopSessionOptions);
|
|
49
|
+
/**
|
|
50
|
+
* `true` once the server has confirmed (`token_type === "DPoP"`) that this
|
|
51
|
+
* session's key is sender-bound. The SDK attaches a refresh proof IFF this is
|
|
52
|
+
* `true`. Starts `false`; set by observing issuance/refresh responses.
|
|
53
|
+
*/
|
|
54
|
+
get isBound(): boolean;
|
|
55
|
+
/**
|
|
56
|
+
* `true` if a session that presented a DPoP proof was returned as `Bearer` by
|
|
57
|
+
* the server (a security downgrade — flag off / server-side downgrade). The
|
|
58
|
+
* session is NOT bound in this state and attaches no further proofs.
|
|
59
|
+
*/
|
|
60
|
+
get isDowngraded(): boolean;
|
|
61
|
+
/**
|
|
62
|
+
* The committed RFC 7638 thumbprint (`jkt`) of the session keypair, available
|
|
63
|
+
* once the session is bound. Lets the SDK cross-check that the bound
|
|
64
|
+
* thumbprint matches the refresh-time key (a `jkt`-continuity self-check).
|
|
65
|
+
*/
|
|
66
|
+
get boundJkt(): string | undefined;
|
|
67
|
+
resolveProof(htm: string, path: string, opts?: {
|
|
68
|
+
nonce?: string;
|
|
69
|
+
}): Promise<string>;
|
|
70
|
+
jktHint(): Promise<string>;
|
|
71
|
+
resolveRotationProofs(htm: string, path: string, opts?: {
|
|
72
|
+
nonce?: string;
|
|
73
|
+
incoming?: DpopProver;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
oldProof: string;
|
|
76
|
+
newProof: string;
|
|
77
|
+
incoming: DpopProver;
|
|
78
|
+
newJkt: string;
|
|
79
|
+
}>;
|
|
80
|
+
commitRotation(incoming: DpopProver): Promise<boolean>;
|
|
81
|
+
runExclusiveRotation<T>(fn: () => Promise<T>): Promise<T>;
|
|
82
|
+
observeTokenType(tokenType: string | undefined, attachedProof: boolean): Promise<void>;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Factory for a {@link DpopSession}. Equivalent to `new DpopSession(options)` —
|
|
86
|
+
* provided for parity with the functional `createDpopProver` API.
|
|
87
|
+
*
|
|
88
|
+
* @public — additive-only after the first public release.
|
|
89
|
+
*/
|
|
90
|
+
export declare function createDpopSession(options: CreateDpopSessionOptions): DpopSession;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { createDpopProver } from './dpop.js';
|
|
2
|
+
/**
|
|
3
|
+
* Opaque, session-scoped DPoP binding handle. Create ONE per logged-in session,
|
|
4
|
+
* pass it to `exchangeCode({..., dpop })` AND every `refreshToken({..., dpop })`
|
|
5
|
+
* for that session. Never share an instance across distinct sessions or across
|
|
6
|
+
* a refresh token boundary.
|
|
7
|
+
*
|
|
8
|
+
* @public — additive-only after the first public release (a removed/renamed member
|
|
9
|
+
* is a MAJOR bump). The options-bag shape leaves room for a future rotation hook
|
|
10
|
+
* to be appended additively.
|
|
11
|
+
*/
|
|
12
|
+
export class DpopSession {
|
|
13
|
+
prover;
|
|
14
|
+
onDowngrade;
|
|
15
|
+
baseUrl;
|
|
16
|
+
alg;
|
|
17
|
+
_bound = false;
|
|
18
|
+
_downgraded = false;
|
|
19
|
+
_downgradeNotified = false;
|
|
20
|
+
_boundJkt;
|
|
21
|
+
_inflightRotation = null;
|
|
22
|
+
constructor(options) {
|
|
23
|
+
this.baseUrl = options.baseUrl;
|
|
24
|
+
this.alg = options.alg;
|
|
25
|
+
this.prover = createDpopProver({
|
|
26
|
+
baseUrl: options.baseUrl,
|
|
27
|
+
...(options.alg !== undefined ? { alg: options.alg } : {}),
|
|
28
|
+
});
|
|
29
|
+
this.onDowngrade = options.onDowngrade;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* `true` once the server has confirmed (`token_type === "DPoP"`) that this
|
|
33
|
+
* session's key is sender-bound. The SDK attaches a refresh proof IFF this is
|
|
34
|
+
* `true`. Starts `false`; set by observing issuance/refresh responses.
|
|
35
|
+
*/
|
|
36
|
+
get isBound() {
|
|
37
|
+
return this._bound;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* `true` if a session that presented a DPoP proof was returned as `Bearer` by
|
|
41
|
+
* the server (a security downgrade — flag off / server-side downgrade). The
|
|
42
|
+
* session is NOT bound in this state and attaches no further proofs.
|
|
43
|
+
*/
|
|
44
|
+
get isDowngraded() {
|
|
45
|
+
return this._downgraded;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The committed RFC 7638 thumbprint (`jkt`) of the session keypair, available
|
|
49
|
+
* once the session is bound. Lets the SDK cross-check that the bound
|
|
50
|
+
* thumbprint matches the refresh-time key (a `jkt`-continuity self-check).
|
|
51
|
+
*/
|
|
52
|
+
get boundJkt() {
|
|
53
|
+
return this._boundJkt;
|
|
54
|
+
}
|
|
55
|
+
async resolveProof(htm, path, opts) {
|
|
56
|
+
return this.prover.proof(htm, path, opts);
|
|
57
|
+
}
|
|
58
|
+
async jktHint() {
|
|
59
|
+
return this.prover.jktHint();
|
|
60
|
+
}
|
|
61
|
+
async resolveRotationProofs(htm, path, opts) {
|
|
62
|
+
const incoming = opts?.incoming ??
|
|
63
|
+
createDpopProver({
|
|
64
|
+
baseUrl: this.baseUrl,
|
|
65
|
+
...(this.alg !== undefined ? { alg: this.alg } : {}),
|
|
66
|
+
});
|
|
67
|
+
const proofOpts = opts?.nonce !== undefined ? { nonce: opts.nonce } : undefined;
|
|
68
|
+
const [oldProof, newProof, newJkt] = await Promise.all([
|
|
69
|
+
this.prover.proof(htm, path, proofOpts),
|
|
70
|
+
incoming.proof(htm, path, proofOpts),
|
|
71
|
+
incoming.jktHint(),
|
|
72
|
+
]);
|
|
73
|
+
return { oldProof, newProof, incoming, newJkt };
|
|
74
|
+
}
|
|
75
|
+
async commitRotation(incoming) {
|
|
76
|
+
const outgoingJkt = await this.prover.jktHint();
|
|
77
|
+
if (this._boundJkt !== undefined && this._boundJkt !== outgoingJkt) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
const incomingJkt = await incoming.jktHint();
|
|
81
|
+
this.prover = incoming;
|
|
82
|
+
this._bound = true;
|
|
83
|
+
this._downgraded = false;
|
|
84
|
+
this._boundJkt = incomingJkt;
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
async runExclusiveRotation(fn) {
|
|
88
|
+
if (this._inflightRotation !== null) {
|
|
89
|
+
return this._inflightRotation;
|
|
90
|
+
}
|
|
91
|
+
const promise = fn();
|
|
92
|
+
this._inflightRotation = promise;
|
|
93
|
+
try {
|
|
94
|
+
return await promise;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
this._inflightRotation = null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
async observeTokenType(tokenType, attachedProof) {
|
|
101
|
+
if (tokenType === 'DPoP') {
|
|
102
|
+
this._bound = true;
|
|
103
|
+
this._downgraded = false;
|
|
104
|
+
if (this._boundJkt === undefined) {
|
|
105
|
+
this._boundJkt = await this.prover.jktHint();
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
this._bound = false;
|
|
110
|
+
if (attachedProof) {
|
|
111
|
+
this._downgraded = true;
|
|
112
|
+
if (!this._downgradeNotified) {
|
|
113
|
+
this._downgradeNotified = true;
|
|
114
|
+
this.onDowngrade?.({ reason: 'server_returned_bearer' });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Factory for a {@link DpopSession}. Equivalent to `new DpopSession(options)` —
|
|
121
|
+
* provided for parity with the functional `createDpopProver` API.
|
|
122
|
+
*
|
|
123
|
+
* @public — additive-only after the first public release.
|
|
124
|
+
*/
|
|
125
|
+
export function createDpopSession(options) {
|
|
126
|
+
return new DpopSession(options);
|
|
127
|
+
}
|
package/dist/dpop.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
declare const DPOP_PROOF_ALLOWED_ALGS: readonly ["ES256", "EdDSA"];
|
|
2
|
+
type DpopProofAlg = (typeof DPOP_PROOF_ALLOWED_ALGS)[number];
|
|
3
|
+
export interface DpopProver {
|
|
4
|
+
/**
|
|
5
|
+
* Build a DPoP-proof JWT for `htm` + `path`. Resolves to the compact-
|
|
6
|
+
* serialized proof string to set on the `DPoP` HTTP header. When
|
|
7
|
+
* `accessToken` is provided, adds the `ath` claim (RFC 9449 §4.3 — REQUIRED
|
|
8
|
+
* on resource-server calls). `nonce` is optional (RFC 9449 §8 nonce
|
|
9
|
+
* challenge response). The proof is signed with the SDK instance's
|
|
10
|
+
* ephemeral keypair (generated lazily on first call).
|
|
11
|
+
*/
|
|
12
|
+
proof(htm: string, path: string, options?: {
|
|
13
|
+
accessToken?: string;
|
|
14
|
+
nonce?: string;
|
|
15
|
+
}): Promise<string>;
|
|
16
|
+
/** SHA-256 thumbprint of the ephemeral public JWK (RFC 7638). */
|
|
17
|
+
jktHint(): Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
export interface CreateDpopProverOptions {
|
|
20
|
+
baseUrl: string;
|
|
21
|
+
alg?: DpopProofAlg;
|
|
22
|
+
}
|
|
23
|
+
export declare function createDpopProver(options: CreateDpopProverOptions): DpopProver;
|
|
24
|
+
export {};
|
package/dist/dpop.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { calculateJwkThumbprint, exportJWK, generateKeyPair, SignJWT, } from 'jose';
|
|
3
|
+
import { canonicalizeUrl } from './internal/canonical-url.js';
|
|
4
|
+
const DPOP_PROOF_ALLOWED_ALGS = ['ES256', 'EdDSA'];
|
|
5
|
+
export function createDpopProver(options) {
|
|
6
|
+
const alg = options.alg ?? DPOP_PROOF_ALLOWED_ALGS[0];
|
|
7
|
+
const baseUrl = options.baseUrl.endsWith('/') ? options.baseUrl.slice(0, -1) : options.baseUrl;
|
|
8
|
+
let statePromise = null;
|
|
9
|
+
async function getState() {
|
|
10
|
+
if (!statePromise) {
|
|
11
|
+
statePromise = (async () => {
|
|
12
|
+
const { privateKey, publicKey } = await generateKeyPair(alg, {
|
|
13
|
+
...(alg === 'EdDSA' ? { crv: 'Ed25519' } : {}),
|
|
14
|
+
extractable: false,
|
|
15
|
+
});
|
|
16
|
+
const publicJwk = await exportJWK(publicKey);
|
|
17
|
+
const jkt = await calculateJwkThumbprint(publicJwk, 'sha256');
|
|
18
|
+
return { privateKey, publicJwk, alg, jkt };
|
|
19
|
+
})();
|
|
20
|
+
}
|
|
21
|
+
return statePromise;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
async proof(htm, path, opts) {
|
|
25
|
+
const state = await getState();
|
|
26
|
+
const sanitizedPath = path.split('?')[0].split('#')[0];
|
|
27
|
+
const htu = canonicalizeUrl(`${baseUrl}${sanitizedPath}`);
|
|
28
|
+
const payload = {
|
|
29
|
+
htm,
|
|
30
|
+
htu,
|
|
31
|
+
iat: Math.floor(Date.now() / 1000),
|
|
32
|
+
jti: randomUUID(),
|
|
33
|
+
...(opts?.accessToken !== undefined && {
|
|
34
|
+
ath: createHash('sha256').update(opts.accessToken, 'utf8').digest('base64url'),
|
|
35
|
+
}),
|
|
36
|
+
...(opts?.nonce !== undefined && { nonce: opts.nonce }),
|
|
37
|
+
};
|
|
38
|
+
return new SignJWT(payload)
|
|
39
|
+
.setProtectedHeader({
|
|
40
|
+
typ: 'dpop+jwt',
|
|
41
|
+
alg: state.alg,
|
|
42
|
+
jwk: state.publicJwk,
|
|
43
|
+
})
|
|
44
|
+
.sign(state.privateKey);
|
|
45
|
+
},
|
|
46
|
+
async jktHint() {
|
|
47
|
+
const state = await getState();
|
|
48
|
+
return state.jkt;
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|