@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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node SDK surface for anonymous sign-ins.
|
|
3
|
+
*
|
|
4
|
+
* Pure server-side: no browser globals (window/document/navigator/localStorage).
|
|
5
|
+
* Browser apps should use the React hook `useAnonymousSignin` from `@rakomi/react`
|
|
6
|
+
* which calls this under the hood via the user's backend, OR hit `/v1/auth/anonymous`
|
|
7
|
+
* directly from the browser with the tenant's public API key.
|
|
8
|
+
*
|
|
9
|
+
* Returns a Result shape consistent with the rest of the SDK: NEVER throws on
|
|
10
|
+
* expected API errors (403/429/402/401), throws ONLY on programmer errors.
|
|
11
|
+
*/
|
|
12
|
+
import { RakomiError } from './errors.js';
|
|
13
|
+
import { ANONYMOUS_DISABLED, ANONYMOUS_MAU_EXHAUSTED, ANONYMOUS_NETWORK_ERROR, ANONYMOUS_RATE_LIMITED, AnonymousSessionExpiredError, } from './errors.js';
|
|
14
|
+
/**
|
|
15
|
+
* POST /v1/auth/anonymous. Returns Result — never throws on 4xx.
|
|
16
|
+
*/
|
|
17
|
+
export async function anonymousSignin(ctx, options = {}) {
|
|
18
|
+
const fetchImpl = ctx.fetchImpl ?? fetch;
|
|
19
|
+
const url = `${ctx.baseUrl}/v1/auth/anonymous`;
|
|
20
|
+
let res;
|
|
21
|
+
try {
|
|
22
|
+
res = await fetchImpl(url, {
|
|
23
|
+
method: 'POST',
|
|
24
|
+
headers: {
|
|
25
|
+
'Content-Type': 'application/json',
|
|
26
|
+
'X-API-Key': ctx.apiKey,
|
|
27
|
+
},
|
|
28
|
+
body: JSON.stringify(options.publicMetadata ? { public_metadata: options.publicMetadata } : {}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
catch (err) {
|
|
32
|
+
return {
|
|
33
|
+
ok: false,
|
|
34
|
+
error: ANONYMOUS_NETWORK_ERROR(err instanceof Error ? err.message : undefined),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (res.status === 201) {
|
|
38
|
+
const body = (await res.json());
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
data: {
|
|
42
|
+
accessToken: body.access_token,
|
|
43
|
+
refreshToken: body.refresh_token,
|
|
44
|
+
expiresIn: body.expires_in,
|
|
45
|
+
user: {
|
|
46
|
+
id: body.user.id,
|
|
47
|
+
isAnonymous: true,
|
|
48
|
+
createdAt: body.user.created_at,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
let retryAfterSeconds;
|
|
54
|
+
const retryAfter = res.headers.get('retry-after');
|
|
55
|
+
if (retryAfter) {
|
|
56
|
+
const n = Number(retryAfter);
|
|
57
|
+
if (Number.isFinite(n) && n > 0)
|
|
58
|
+
retryAfterSeconds = Math.round(n);
|
|
59
|
+
}
|
|
60
|
+
let sdkError;
|
|
61
|
+
switch (res.status) {
|
|
62
|
+
case 403:
|
|
63
|
+
sdkError = ANONYMOUS_DISABLED();
|
|
64
|
+
break;
|
|
65
|
+
case 402:
|
|
66
|
+
sdkError = ANONYMOUS_MAU_EXHAUSTED();
|
|
67
|
+
break;
|
|
68
|
+
case 429:
|
|
69
|
+
sdkError = ANONYMOUS_RATE_LIMITED(retryAfterSeconds);
|
|
70
|
+
break;
|
|
71
|
+
default: {
|
|
72
|
+
const body = await res.text().catch(() => '');
|
|
73
|
+
sdkError = ANONYMOUS_NETWORK_ERROR(body.slice(0, 200));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ok: false, error: sdkError };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Decode an access token's `is_anonymous` claim WITHOUT verifying the signature.
|
|
80
|
+
* Used only to gate the `AnonymousSessionExpiredError` path — callers should still
|
|
81
|
+
* verify any trust-sensitive claim via `client.verifyToken()`.
|
|
82
|
+
*/
|
|
83
|
+
export function isAnonymousTokenHeuristic(accessToken) {
|
|
84
|
+
try {
|
|
85
|
+
const parts = accessToken.split('.');
|
|
86
|
+
if (parts.length !== 3)
|
|
87
|
+
return false;
|
|
88
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf-8'));
|
|
89
|
+
return payload.is_anonymous === true;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Classify a refresh failure: if the prior token was anonymous and the refresh
|
|
97
|
+
* returned 401, throw `AnonymousSessionExpiredError`. Otherwise the caller
|
|
98
|
+
* decides its own UX routing.
|
|
99
|
+
*/
|
|
100
|
+
export function maybeThrowAnonymousExpired(priorAccessToken, refreshStatus) {
|
|
101
|
+
if (refreshStatus === 401 && priorAccessToken && isAnonymousTokenHeuristic(priorAccessToken)) {
|
|
102
|
+
throw new AnonymousSessionExpiredError();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export { AnonymousSessionExpiredError, RakomiError };
|
package/dist/ciba.d.ts
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import type { VerifyResult } from './types.js';
|
|
2
|
+
export interface CibaInitiateOptions {
|
|
3
|
+
/** Space-delimited or array-of-strings scopes. MUST include `openid`. */
|
|
4
|
+
scope: string | string[];
|
|
5
|
+
/** Email or user UUID identifying the human approver. */
|
|
6
|
+
loginHint: string;
|
|
7
|
+
/** Human-readable description of the action being authorized. ≤256 chars. */
|
|
8
|
+
bindingMessage: string;
|
|
9
|
+
/** Optional bound expiry (60–600s). Server default 120s. */
|
|
10
|
+
requestedExpiry?: number;
|
|
11
|
+
/** Optional IETF BCP 47 locale tag (e.g. `pl-PL`). */
|
|
12
|
+
locale?: string;
|
|
13
|
+
}
|
|
14
|
+
export interface CibaInitiateResponse {
|
|
15
|
+
authReqId: string;
|
|
16
|
+
expiresIn: number;
|
|
17
|
+
interval: number;
|
|
18
|
+
}
|
|
19
|
+
export interface CibaPollResponse {
|
|
20
|
+
accessToken: string;
|
|
21
|
+
tokenType: 'Bearer';
|
|
22
|
+
expiresIn: number;
|
|
23
|
+
scope: string;
|
|
24
|
+
idToken?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare class CibaError extends Error {
|
|
27
|
+
readonly code: string;
|
|
28
|
+
readonly description: string;
|
|
29
|
+
constructor(code: string, description: string);
|
|
30
|
+
}
|
|
31
|
+
export declare class CibaAuthorizationPendingError extends CibaError {
|
|
32
|
+
constructor(d?: string);
|
|
33
|
+
}
|
|
34
|
+
export declare class CibaSlowDownError extends CibaError {
|
|
35
|
+
constructor(d?: string);
|
|
36
|
+
}
|
|
37
|
+
export declare class CibaAccessDeniedError extends CibaError {
|
|
38
|
+
constructor(d?: string);
|
|
39
|
+
}
|
|
40
|
+
export declare class CibaExpiredTokenError extends CibaError {
|
|
41
|
+
constructor(d?: string);
|
|
42
|
+
}
|
|
43
|
+
export declare class CibaReplayError extends CibaError {
|
|
44
|
+
constructor(d: string);
|
|
45
|
+
}
|
|
46
|
+
export declare class CibaInvalidClientError extends CibaError {
|
|
47
|
+
constructor(d: string);
|
|
48
|
+
}
|
|
49
|
+
export declare class CibaInvalidScopeError extends CibaError {
|
|
50
|
+
constructor(d: string);
|
|
51
|
+
}
|
|
52
|
+
export declare class CibaUnauthorizedClientError extends CibaError {
|
|
53
|
+
constructor(d: string);
|
|
54
|
+
}
|
|
55
|
+
export declare class CibaUnknownUserError extends CibaError {
|
|
56
|
+
constructor(d: string);
|
|
57
|
+
}
|
|
58
|
+
export declare class CibaUserCapReachedError extends CibaError {
|
|
59
|
+
constructor(d: string);
|
|
60
|
+
}
|
|
61
|
+
export declare class CibaInvalidRequestError extends CibaError {
|
|
62
|
+
constructor(d: string);
|
|
63
|
+
}
|
|
64
|
+
interface CibaContext {
|
|
65
|
+
baseUrl: string;
|
|
66
|
+
clientId: string;
|
|
67
|
+
clientSecret: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Initiate a CIBA authentication request via POST /oauth/bc-authorize.
|
|
71
|
+
*
|
|
72
|
+
* SSRF hardening: `redirect: 'error'` on fetch.
|
|
73
|
+
*/
|
|
74
|
+
export declare function initiateCiba(ctx: CibaContext, options: CibaInitiateOptions): Promise<VerifyResult<CibaInitiateResponse>>;
|
|
75
|
+
/**
|
|
76
|
+
* Poll for CIBA approval via POST /oauth/token grant=urn:openid:params:grant-type:ciba.
|
|
77
|
+
*
|
|
78
|
+
* Returns Result so the SDK never throws on known API failures. Caller can
|
|
79
|
+
* branch on `result.error.code` (`ciba/authorization_pending` etc.) — the
|
|
80
|
+
* canonical mapping mirrors OIDC CIBA Core §11.
|
|
81
|
+
*/
|
|
82
|
+
export declare function pollCiba(ctx: CibaContext, authReqId: string): Promise<VerifyResult<CibaPollResponse>>;
|
|
83
|
+
export interface CibaAwaitDecisionOptions {
|
|
84
|
+
authReqId: string;
|
|
85
|
+
/** Initial polling interval (ms). Server default = 5000. */
|
|
86
|
+
intervalMs?: number;
|
|
87
|
+
/** Optional AbortSignal — used to cancel the poll loop early. */
|
|
88
|
+
signal?: AbortSignal;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Poll loop. Resolves on token issuance; rejects on terminal status
|
|
92
|
+
* (denied / expired / replay / abort). On `slow_down`, doubles the interval
|
|
93
|
+
* up to 60s. SDK-managed in-flight guard — never issues two
|
|
94
|
+
* concurrent polls for the same `authReqId`.
|
|
95
|
+
*/
|
|
96
|
+
export declare function awaitCibaDecision(ctx: CibaContext, options: CibaAwaitDecisionOptions): Promise<CibaPollResponse>;
|
|
97
|
+
export {};
|
package/dist/ciba.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC CIBA Core 1.0 (asynchronous user consent) helper.
|
|
3
|
+
*
|
|
4
|
+
* Three-step API for AI agents that need user approval out-of-band:
|
|
5
|
+
* - `initiate(options)` → POST /oauth/bc-authorize. Returns `auth_req_id`.
|
|
6
|
+
* - `poll(authReqId)` → POST /oauth/token grant=urn:openid:params:grant-type:ciba.
|
|
7
|
+
* - `awaitDecision(options)`→ poll-loop with adaptive interval; resolves on
|
|
8
|
+
* approve / deny / expiry / AbortSignal.
|
|
9
|
+
*
|
|
10
|
+
* Confidential-only: the SDK requires a non-empty `clientSecret` on the
|
|
11
|
+
* RakomiClient config. Browser/edge runtimes that cannot keep a secret MUST
|
|
12
|
+
* use the device-grant flow instead.
|
|
13
|
+
*/
|
|
14
|
+
import { CIBA_GRANT_TYPE } from './internal/shared-constants.js';
|
|
15
|
+
export class CibaError extends Error {
|
|
16
|
+
code;
|
|
17
|
+
description;
|
|
18
|
+
constructor(code, description) {
|
|
19
|
+
super(`${code}: ${description}`);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.description = description;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export class CibaAuthorizationPendingError extends CibaError {
|
|
25
|
+
constructor(d = 'authorization_pending') { super('authorization_pending', d); }
|
|
26
|
+
}
|
|
27
|
+
export class CibaSlowDownError extends CibaError {
|
|
28
|
+
constructor(d = 'slow_down') { super('slow_down', d); }
|
|
29
|
+
}
|
|
30
|
+
export class CibaAccessDeniedError extends CibaError {
|
|
31
|
+
constructor(d = 'access_denied') { super('access_denied', d); }
|
|
32
|
+
}
|
|
33
|
+
export class CibaExpiredTokenError extends CibaError {
|
|
34
|
+
constructor(d = 'expired_token') { super('expired_token', d); }
|
|
35
|
+
}
|
|
36
|
+
export class CibaReplayError extends CibaError {
|
|
37
|
+
constructor(d) { super('invalid_grant', d); }
|
|
38
|
+
}
|
|
39
|
+
export class CibaInvalidClientError extends CibaError {
|
|
40
|
+
constructor(d) { super('invalid_client', d); }
|
|
41
|
+
}
|
|
42
|
+
export class CibaInvalidScopeError extends CibaError {
|
|
43
|
+
constructor(d) { super('invalid_scope', d); }
|
|
44
|
+
}
|
|
45
|
+
export class CibaUnauthorizedClientError extends CibaError {
|
|
46
|
+
constructor(d) { super('unauthorized_client', d); }
|
|
47
|
+
}
|
|
48
|
+
export class CibaUnknownUserError extends CibaError {
|
|
49
|
+
constructor(d) { super('unknown_user_id', d); }
|
|
50
|
+
}
|
|
51
|
+
export class CibaUserCapReachedError extends CibaError {
|
|
52
|
+
constructor(d) { super('user_cap_reached', d); }
|
|
53
|
+
}
|
|
54
|
+
export class CibaInvalidRequestError extends CibaError {
|
|
55
|
+
constructor(d) { super('invalid_request', d); }
|
|
56
|
+
}
|
|
57
|
+
function basicAuth(clientId, secret) {
|
|
58
|
+
const raw = `${clientId}:${secret}`;
|
|
59
|
+
if (typeof Buffer !== 'undefined')
|
|
60
|
+
return `Basic ${Buffer.from(raw).toString('base64')}`;
|
|
61
|
+
return `Basic ${btoa(raw)}`;
|
|
62
|
+
}
|
|
63
|
+
function makeError(code, description) {
|
|
64
|
+
return {
|
|
65
|
+
code: `ciba/${code}`,
|
|
66
|
+
message: description,
|
|
67
|
+
suggestion: code === 'unknown_user_id'
|
|
68
|
+
? 'Verify the login_hint matches a real user in this tenant.'
|
|
69
|
+
: code === 'authorization_pending'
|
|
70
|
+
? 'Continue polling at the interval the server returned.'
|
|
71
|
+
: code === 'slow_down'
|
|
72
|
+
? 'Server-mandated back-off — increase polling interval by 5s.'
|
|
73
|
+
: code === 'invalid_scope'
|
|
74
|
+
? 'Requested scope is empty after intersection. Reduce the scope set or extend the client allowlist.'
|
|
75
|
+
: 'See description; consult Starlight docs for CIBA grant.',
|
|
76
|
+
docs_url: 'https://docs.rakomi.dev/oauth/ciba',
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Initiate a CIBA authentication request via POST /oauth/bc-authorize.
|
|
81
|
+
*
|
|
82
|
+
* SSRF hardening: `redirect: 'error'` on fetch.
|
|
83
|
+
*/
|
|
84
|
+
export async function initiateCiba(ctx, options) {
|
|
85
|
+
const params = new URLSearchParams();
|
|
86
|
+
params.set('client_id', ctx.clientId);
|
|
87
|
+
const scopeStr = Array.isArray(options.scope) ? options.scope.join(' ') : options.scope;
|
|
88
|
+
params.set('scope', scopeStr);
|
|
89
|
+
params.set('login_hint', options.loginHint);
|
|
90
|
+
params.set('binding_message', options.bindingMessage);
|
|
91
|
+
if (options.requestedExpiry !== undefined) {
|
|
92
|
+
params.set('requested_expiry', String(options.requestedExpiry));
|
|
93
|
+
}
|
|
94
|
+
if (options.locale !== undefined) {
|
|
95
|
+
params.set('binding_message_locale', options.locale);
|
|
96
|
+
}
|
|
97
|
+
let res;
|
|
98
|
+
try {
|
|
99
|
+
res = await fetch(`${ctx.baseUrl}/oauth/bc-authorize`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
redirect: 'error',
|
|
102
|
+
headers: {
|
|
103
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
104
|
+
Authorization: basicAuth(ctx.clientId, ctx.clientSecret),
|
|
105
|
+
},
|
|
106
|
+
body: params.toString(),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
catch (err) {
|
|
110
|
+
return {
|
|
111
|
+
ok: false,
|
|
112
|
+
error: {
|
|
113
|
+
code: 'ciba/network_error',
|
|
114
|
+
message: err?.message ?? 'Network error',
|
|
115
|
+
suggestion: 'Verify Rakomi base URL is reachable and that DNS / TLS is healthy.',
|
|
116
|
+
docs_url: 'https://docs.rakomi.dev/oauth/ciba',
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
let body = {};
|
|
121
|
+
try {
|
|
122
|
+
body = (await res.json());
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
}
|
|
126
|
+
if (res.ok) {
|
|
127
|
+
const authReqId = body.auth_req_id;
|
|
128
|
+
const expiresIn = body.expires_in;
|
|
129
|
+
const interval = body.interval;
|
|
130
|
+
if (typeof authReqId !== 'string' ||
|
|
131
|
+
typeof expiresIn !== 'number' ||
|
|
132
|
+
typeof interval !== 'number') {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: {
|
|
136
|
+
code: 'ciba/malformed_response',
|
|
137
|
+
message: 'Server returned 200 with a body that does not match OIDC CIBA Core §7.3 shape',
|
|
138
|
+
suggestion: 'Server-side bug — file an issue.',
|
|
139
|
+
docs_url: 'https://docs.rakomi.dev/oauth/ciba',
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, data: { authReqId, expiresIn, interval } };
|
|
144
|
+
}
|
|
145
|
+
const code = body.error ?? `http_${res.status}`;
|
|
146
|
+
const description = body.error_description ?? `HTTP ${res.status}`;
|
|
147
|
+
return { ok: false, error: makeError(code, description) };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Poll for CIBA approval via POST /oauth/token grant=urn:openid:params:grant-type:ciba.
|
|
151
|
+
*
|
|
152
|
+
* Returns Result so the SDK never throws on known API failures. Caller can
|
|
153
|
+
* branch on `result.error.code` (`ciba/authorization_pending` etc.) — the
|
|
154
|
+
* canonical mapping mirrors OIDC CIBA Core §11.
|
|
155
|
+
*/
|
|
156
|
+
export async function pollCiba(ctx, authReqId) {
|
|
157
|
+
const params = new URLSearchParams();
|
|
158
|
+
params.set('grant_type', CIBA_GRANT_TYPE);
|
|
159
|
+
params.set('auth_req_id', authReqId);
|
|
160
|
+
let res;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetch(`${ctx.baseUrl}/oauth/token`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
redirect: 'error',
|
|
165
|
+
headers: {
|
|
166
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
167
|
+
Authorization: basicAuth(ctx.clientId, ctx.clientSecret),
|
|
168
|
+
},
|
|
169
|
+
body: params.toString(),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
catch (err) {
|
|
173
|
+
return {
|
|
174
|
+
ok: false,
|
|
175
|
+
error: {
|
|
176
|
+
code: 'ciba/network_error',
|
|
177
|
+
message: err?.message ?? 'Network error',
|
|
178
|
+
suggestion: 'Verify Rakomi base URL is reachable.',
|
|
179
|
+
docs_url: 'https://docs.rakomi.dev/oauth/ciba',
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
let body = {};
|
|
184
|
+
try {
|
|
185
|
+
body = (await res.json());
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
}
|
|
189
|
+
if (res.ok) {
|
|
190
|
+
const accessToken = body.access_token;
|
|
191
|
+
const tokenType = body.token_type;
|
|
192
|
+
const expiresIn = body.expires_in;
|
|
193
|
+
const scope = body.scope;
|
|
194
|
+
const idToken = body.id_token;
|
|
195
|
+
if (typeof accessToken !== 'string' ||
|
|
196
|
+
tokenType !== 'Bearer' ||
|
|
197
|
+
typeof expiresIn !== 'number' ||
|
|
198
|
+
typeof scope !== 'string') {
|
|
199
|
+
return {
|
|
200
|
+
ok: false,
|
|
201
|
+
error: {
|
|
202
|
+
code: 'ciba/malformed_response',
|
|
203
|
+
message: 'Server returned 200 with body that does not match the OAuth token-response shape',
|
|
204
|
+
suggestion: 'Server-side bug — file an issue.',
|
|
205
|
+
docs_url: 'https://docs.rakomi.dev/oauth/ciba',
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const out = {
|
|
210
|
+
accessToken,
|
|
211
|
+
tokenType: 'Bearer',
|
|
212
|
+
expiresIn,
|
|
213
|
+
scope,
|
|
214
|
+
...(idToken ? { idToken } : {}),
|
|
215
|
+
};
|
|
216
|
+
return { ok: true, data: out };
|
|
217
|
+
}
|
|
218
|
+
const code = body.error ?? `http_${res.status}`;
|
|
219
|
+
const description = body.error_description ?? `HTTP ${res.status}`;
|
|
220
|
+
return { ok: false, error: makeError(code, description) };
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Poll loop. Resolves on token issuance; rejects on terminal status
|
|
224
|
+
* (denied / expired / replay / abort). On `slow_down`, doubles the interval
|
|
225
|
+
* up to 60s. SDK-managed in-flight guard — never issues two
|
|
226
|
+
* concurrent polls for the same `authReqId`.
|
|
227
|
+
*/
|
|
228
|
+
export async function awaitCibaDecision(ctx, options) {
|
|
229
|
+
let interval = Math.max(options.intervalMs ?? 5000, 1000);
|
|
230
|
+
const maxInterval = 60_000;
|
|
231
|
+
while (true) {
|
|
232
|
+
if (options.signal?.aborted) {
|
|
233
|
+
throw new CibaError('aborted', 'CIBA poll aborted');
|
|
234
|
+
}
|
|
235
|
+
const result = await pollCiba(ctx, options.authReqId);
|
|
236
|
+
if (result.ok)
|
|
237
|
+
return result.data;
|
|
238
|
+
const code = result.error.code.replace(/^ciba\//, '');
|
|
239
|
+
switch (code) {
|
|
240
|
+
case 'authorization_pending':
|
|
241
|
+
await sleep(interval, options.signal);
|
|
242
|
+
continue;
|
|
243
|
+
case 'slow_down':
|
|
244
|
+
interval = Math.min(interval + 5000, maxInterval);
|
|
245
|
+
await sleep(interval, options.signal);
|
|
246
|
+
continue;
|
|
247
|
+
case 'access_denied':
|
|
248
|
+
throw new CibaAccessDeniedError(result.error.message);
|
|
249
|
+
case 'expired_token':
|
|
250
|
+
throw new CibaExpiredTokenError(result.error.message);
|
|
251
|
+
case 'invalid_grant':
|
|
252
|
+
throw new CibaReplayError(result.error.message);
|
|
253
|
+
case 'invalid_scope':
|
|
254
|
+
throw new CibaInvalidScopeError(result.error.message);
|
|
255
|
+
case 'unauthorized_client':
|
|
256
|
+
throw new CibaUnauthorizedClientError(result.error.message);
|
|
257
|
+
case 'invalid_client':
|
|
258
|
+
throw new CibaInvalidClientError(result.error.message);
|
|
259
|
+
case 'invalid_request':
|
|
260
|
+
throw new CibaInvalidRequestError(result.error.message);
|
|
261
|
+
default:
|
|
262
|
+
throw new CibaError(code, result.error.message);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
function sleep(ms, signal) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
if (signal?.aborted) {
|
|
269
|
+
reject(new CibaError('aborted', 'CIBA poll aborted'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const timer = setTimeout(() => {
|
|
273
|
+
signal?.removeEventListener('abort', onAbort);
|
|
274
|
+
resolve();
|
|
275
|
+
}, ms);
|
|
276
|
+
const onAbort = () => {
|
|
277
|
+
clearTimeout(timer);
|
|
278
|
+
reject(new CibaError('aborted', 'CIBA poll aborted'));
|
|
279
|
+
};
|
|
280
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
281
|
+
});
|
|
282
|
+
}
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { AgentsClient } from './agents.js';
|
|
2
|
+
import { type AnonymousSigninOptions, type AnonymousSigninResult } from './anonymous.js';
|
|
3
|
+
import { type CibaAwaitDecisionOptions, type CibaInitiateOptions, type CibaInitiateResponse, type CibaPollResponse } from './ciba.js';
|
|
4
|
+
import { CredentialsClient } from './credentials.js';
|
|
5
|
+
import { FlagsClient } from './flags.js';
|
|
6
|
+
import { LinkClient } from './link.js';
|
|
7
|
+
import type { MiddlewareRequest, MiddlewareResponse, NextFunction } from './middleware.js';
|
|
8
|
+
import { type TokenExchangeOptions, type TokenExchangeResponse } from './token-exchange.js';
|
|
9
|
+
import type { AuthorizeUrlOptions, MiddlewareOptions, OAuthExchangeOptions, OAuthRefreshOptions, OAuthTokenResponse, PkceChallenge, RakomiConfig, TokenPayload, VerifyResult, WebhookEvent, WebhookVerifyData } from './types.js';
|
|
10
|
+
export declare class RakomiClient {
|
|
11
|
+
private readonly apiKey;
|
|
12
|
+
private readonly baseUrl;
|
|
13
|
+
private readonly clockTolerance;
|
|
14
|
+
private readonly environment?;
|
|
15
|
+
private readonly webhookSecret?;
|
|
16
|
+
private readonly webhookTolerance;
|
|
17
|
+
private readonly clientId?;
|
|
18
|
+
private readonly clientSecret?;
|
|
19
|
+
private jwksCache;
|
|
20
|
+
readonly flags: FlagsClient;
|
|
21
|
+
/** Verifiable Credentials issuance (tenant server-to-server). */
|
|
22
|
+
readonly credentials: CredentialsClient;
|
|
23
|
+
/** c: user-scoped account-linking resource. Requires end-user JWT per call. */
|
|
24
|
+
readonly link: LinkClient;
|
|
25
|
+
/** end-user agent management (`users.me.agents.list/.revoke`). Requires end-user JWT per call. */
|
|
26
|
+
readonly users: {
|
|
27
|
+
readonly me: {
|
|
28
|
+
readonly agents: AgentsClient;
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
constructor(config: RakomiConfig);
|
|
32
|
+
verifyToken<T extends TokenPayload = TokenPayload>(token: string): Promise<VerifyResult<T>>;
|
|
33
|
+
verifyWebhook<T = WebhookEvent>(body: string | Buffer, headers: Record<string, string | string[] | undefined>, options?: {
|
|
34
|
+
tolerance?: number;
|
|
35
|
+
}): Promise<VerifyResult<WebhookVerifyData<T>>>;
|
|
36
|
+
middleware(options?: MiddlewareOptions): (req: MiddlewareRequest, res: MiddlewareResponse, next: NextFunction) => void;
|
|
37
|
+
generatePKCE(): PkceChallenge;
|
|
38
|
+
generateState(): string;
|
|
39
|
+
buildAuthorizeUrl(options: Omit<AuthorizeUrlOptions, 'clientId'> & {
|
|
40
|
+
clientId?: string;
|
|
41
|
+
}): string;
|
|
42
|
+
exchangeCode(options: Omit<OAuthExchangeOptions, 'clientId' | 'clientSecret'> & {
|
|
43
|
+
clientId?: string;
|
|
44
|
+
clientSecret?: string;
|
|
45
|
+
}): Promise<VerifyResult<OAuthTokenResponse>>;
|
|
46
|
+
refreshToken(options: Omit<OAuthRefreshOptions, 'clientId' | 'clientSecret'> & {
|
|
47
|
+
clientId?: string;
|
|
48
|
+
clientSecret?: string;
|
|
49
|
+
}): Promise<VerifyResult<OAuthTokenResponse>>;
|
|
50
|
+
/**
|
|
51
|
+
* RFC 8693 token-exchange resource.
|
|
52
|
+
*
|
|
53
|
+
* Exchange a user's currently-valid access token for a scoped-down agent
|
|
54
|
+
* token. Returns RFC 8693 §2.2.1 response. Throwing variant for ergonomic
|
|
55
|
+
* try/catch flows (typed errors per RFC 6749 §5.2 codes); the underlying
|
|
56
|
+
* Result-based variant is exposed as `tokens.exchangeViaApi(...)`.
|
|
57
|
+
*
|
|
58
|
+
* The SDK client MUST be constructed with `clientId` + `clientSecret`
|
|
59
|
+
* referencing an agent-type OAuth client (server-side only — never embed
|
|
60
|
+
* `clientSecret` in browser/mobile code). Invocations without those credentials
|
|
61
|
+
* throw `TokenExchangeInvalidClientError` synchronously.
|
|
62
|
+
*/
|
|
63
|
+
readonly tokens: {
|
|
64
|
+
exchange: (options: TokenExchangeOptions) => Promise<TokenExchangeResponse>;
|
|
65
|
+
exchangeViaApi: (options: TokenExchangeOptions) => Promise<VerifyResult<TokenExchangeResponse>>;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* OIDC CIBA Core 1.0 (asynchronous user consent) resource.
|
|
69
|
+
*
|
|
70
|
+
* Three operations:
|
|
71
|
+
* - `initiate(options)` → POST /oauth/bc-authorize.
|
|
72
|
+
* - `poll(authReqId)` → POST /oauth/token grant=ciba.
|
|
73
|
+
* - `awaitDecision(opts)` → poll-loop until decision / abort.
|
|
74
|
+
*
|
|
75
|
+
* The SDK client MUST be constructed with `clientId` + `clientSecret`
|
|
76
|
+
* referencing a confidential or agent-type OAuth client registered with
|
|
77
|
+
* `grantTypes` including `urn:openid:params:grant-type:ciba` and the
|
|
78
|
+
* Pro+ tier flag. Invocations without those credentials reject the
|
|
79
|
+
* Result with `OAUTH_MISSING_CLIENT_ID`.
|
|
80
|
+
*/
|
|
81
|
+
readonly ciba: {
|
|
82
|
+
initiate: (options: CibaInitiateOptions) => Promise<VerifyResult<CibaInitiateResponse>>;
|
|
83
|
+
poll: (authReqId: string) => Promise<VerifyResult<CibaPollResponse>>;
|
|
84
|
+
awaitDecision: (options: CibaAwaitDecisionOptions) => Promise<CibaPollResponse>;
|
|
85
|
+
};
|
|
86
|
+
/**
|
|
87
|
+
* Create an anonymous user and return its token pair.
|
|
88
|
+
*
|
|
89
|
+
* Call from a trusted backend. Returns a Result (never throws on known API
|
|
90
|
+
* errors); on 403/402/429 the error is mapped to a stable SDK error code.
|
|
91
|
+
*/
|
|
92
|
+
anonymous(options?: AnonymousSigninOptions): Promise<VerifyResult<AnonymousSigninResult>>;
|
|
93
|
+
}
|