@ulexid/js 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -2
- package/dist/index.d.ts +167 -0
- package/dist/index.js +316 -0
- package/package.json +24 -4
package/README.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @ulexid/js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Version `0.1.1` — [Ulexid Documentation](https://docs.ulexid.com)
|
|
4
|
+
|
|
5
|
+
Browser & isomorphic OAuth 2.1 / PKCE / OIDC client SDK for [Ulexid](https://www.ulexid.com).
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @ulexid/js
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## License
|
|
12
|
+
|
|
13
|
+
Proprietary (`UNLICENSED`). Copyright (c) Ulexid. All rights reserved.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ulexid/js` — Universal browser & runtime OIDC / OAuth 2.1 / Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* Uses WebCrypto (`crypto.subtle`) and standard `fetch` with zero runtime
|
|
5
|
+
* dependencies so it runs identically in browsers, Cloudflare Workers, Vercel
|
|
6
|
+
* Edge, Deno, Bun, and Node.js >= 20.
|
|
7
|
+
*
|
|
8
|
+
* Security invariants enforced by the client:
|
|
9
|
+
* - PKCE `S256` is mandatory on every authorization URL (`plain` is never emitted).
|
|
10
|
+
* - `redirect_uri` values are transmitted verbatim for exact server comparison.
|
|
11
|
+
*/
|
|
12
|
+
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
|
|
13
|
+
export interface PkcePair {
|
|
14
|
+
readonly verifier: string;
|
|
15
|
+
readonly challenge: string;
|
|
16
|
+
readonly method: 'S256';
|
|
17
|
+
}
|
|
18
|
+
export interface UlexClientConfig {
|
|
19
|
+
/** Tenant or platform issuer URL, e.g. `https://ulexid.com/t/acme` or `https://auth.acme.com`. */
|
|
20
|
+
readonly issuer: string;
|
|
21
|
+
/** OAuth 2.1 `client_id` for the application. Optional for pure zero-touch agent onboarding. */
|
|
22
|
+
readonly clientId?: string;
|
|
23
|
+
/** Default exact `redirect_uri` registered on the application. */
|
|
24
|
+
readonly redirectUri?: string;
|
|
25
|
+
/** Optional custom `fetch` implementation for testing or custom runtimes. */
|
|
26
|
+
readonly fetch?: FetchLike;
|
|
27
|
+
}
|
|
28
|
+
export interface BuildAuthorizeUrlOptions {
|
|
29
|
+
readonly codeChallenge: string;
|
|
30
|
+
readonly state: string;
|
|
31
|
+
readonly redirectUri?: string;
|
|
32
|
+
readonly scopes?: readonly string[];
|
|
33
|
+
readonly nonce?: string;
|
|
34
|
+
/** Target workforce organization ID or slug for B2B SSO routing. */
|
|
35
|
+
readonly organization?: string;
|
|
36
|
+
/** Upstream IdP connection ID when bypassing discovery. */
|
|
37
|
+
readonly connection?: string;
|
|
38
|
+
readonly loginHint?: string;
|
|
39
|
+
readonly prompt?: 'none' | 'login' | 'consent' | 'select_account';
|
|
40
|
+
}
|
|
41
|
+
export interface TokenSet {
|
|
42
|
+
readonly access_token: string;
|
|
43
|
+
readonly token_type: string;
|
|
44
|
+
readonly expires_in: number;
|
|
45
|
+
readonly refresh_token?: string;
|
|
46
|
+
readonly id_token?: string;
|
|
47
|
+
readonly scope?: string;
|
|
48
|
+
readonly issued_token_type?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface ExchangeAuthorizationCodeOptions {
|
|
51
|
+
readonly code: string;
|
|
52
|
+
readonly codeVerifier: string;
|
|
53
|
+
readonly redirectUri?: string;
|
|
54
|
+
readonly clientSecret?: string;
|
|
55
|
+
}
|
|
56
|
+
export interface RefreshTokensOptions {
|
|
57
|
+
readonly refreshToken: string;
|
|
58
|
+
readonly scopes?: readonly string[];
|
|
59
|
+
readonly clientSecret?: string;
|
|
60
|
+
}
|
|
61
|
+
export interface TokenExchangeOptions {
|
|
62
|
+
readonly subjectToken: string;
|
|
63
|
+
readonly subjectTokenType?: string;
|
|
64
|
+
readonly actorToken?: string;
|
|
65
|
+
readonly actorTokenType?: string;
|
|
66
|
+
readonly scopes?: readonly string[];
|
|
67
|
+
readonly audience?: string;
|
|
68
|
+
readonly resource?: string;
|
|
69
|
+
readonly clientSecret?: string;
|
|
70
|
+
}
|
|
71
|
+
export interface UserInfo {
|
|
72
|
+
readonly sub: string;
|
|
73
|
+
readonly email?: string;
|
|
74
|
+
readonly email_verified?: boolean;
|
|
75
|
+
readonly name?: string;
|
|
76
|
+
readonly given_name?: string;
|
|
77
|
+
readonly family_name?: string;
|
|
78
|
+
readonly picture?: string;
|
|
79
|
+
readonly org_id?: string;
|
|
80
|
+
readonly [claim: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
export interface DeviceAuthorizationResponse {
|
|
83
|
+
readonly device_code: string;
|
|
84
|
+
readonly user_code: string;
|
|
85
|
+
readonly verification_uri: string;
|
|
86
|
+
readonly verification_uri_complete: string;
|
|
87
|
+
readonly expires_in: number;
|
|
88
|
+
readonly interval: number;
|
|
89
|
+
}
|
|
90
|
+
export interface StartDeviceActivationOptions {
|
|
91
|
+
readonly agentName?: string;
|
|
92
|
+
readonly applicationName?: string;
|
|
93
|
+
readonly scopes?: readonly string[];
|
|
94
|
+
}
|
|
95
|
+
export interface AgentApprovedCredentials {
|
|
96
|
+
readonly status?: string;
|
|
97
|
+
readonly access_token?: string;
|
|
98
|
+
readonly api_key?: string;
|
|
99
|
+
readonly tenant_id?: string;
|
|
100
|
+
readonly tenant_slug?: string;
|
|
101
|
+
readonly issuer?: string;
|
|
102
|
+
readonly mcp_url?: string;
|
|
103
|
+
readonly scope?: string;
|
|
104
|
+
readonly [key: string]: unknown;
|
|
105
|
+
}
|
|
106
|
+
export type DevicePollResult = {
|
|
107
|
+
readonly status: 'pending';
|
|
108
|
+
readonly retryAfterSeconds: number;
|
|
109
|
+
} | {
|
|
110
|
+
readonly status: 'slow_down';
|
|
111
|
+
readonly retryAfterSeconds: number;
|
|
112
|
+
} | {
|
|
113
|
+
readonly status: 'expired';
|
|
114
|
+
readonly errorDescription?: string;
|
|
115
|
+
} | {
|
|
116
|
+
readonly status: 'denied';
|
|
117
|
+
readonly errorDescription?: string;
|
|
118
|
+
} | {
|
|
119
|
+
readonly status: 'approved';
|
|
120
|
+
readonly credentials: AgentApprovedCredentials;
|
|
121
|
+
};
|
|
122
|
+
export interface BuildLogoutUrlOptions {
|
|
123
|
+
readonly idTokenHint?: string;
|
|
124
|
+
readonly postLogoutRedirectUri?: string;
|
|
125
|
+
readonly state?: string;
|
|
126
|
+
}
|
|
127
|
+
export declare class UlexOAuthError extends Error {
|
|
128
|
+
readonly error: string;
|
|
129
|
+
readonly errorDescription?: string;
|
|
130
|
+
readonly status: number;
|
|
131
|
+
constructor(error: string, status: number, errorDescription?: string);
|
|
132
|
+
}
|
|
133
|
+
export declare function generateState(byteLength?: number): string;
|
|
134
|
+
/**
|
|
135
|
+
* Generates an RFC 7636 PKCE `S256` verifier + challenge pair using WebCrypto.
|
|
136
|
+
*/
|
|
137
|
+
export declare function generatePkcePair(byteLength?: number): Promise<PkcePair>;
|
|
138
|
+
export declare class UlexClient {
|
|
139
|
+
readonly issuer: string;
|
|
140
|
+
readonly clientId?: string;
|
|
141
|
+
readonly redirectUri?: string;
|
|
142
|
+
private readonly fetchImpl;
|
|
143
|
+
constructor(config: UlexClientConfig);
|
|
144
|
+
private requireClientId;
|
|
145
|
+
buildAuthorizeUrl(options: BuildAuthorizeUrlOptions): string;
|
|
146
|
+
exchangeAuthorizationCode(options: ExchangeAuthorizationCodeOptions): Promise<TokenSet>;
|
|
147
|
+
refreshTokens(options: RefreshTokensOptions): Promise<TokenSet>;
|
|
148
|
+
/**
|
|
149
|
+
* RFC 8693 OAuth 2.0 Token Exchange for scoped, short-lived agent delegation.
|
|
150
|
+
*/
|
|
151
|
+
exchangeToken(options: TokenExchangeOptions): Promise<TokenSet>;
|
|
152
|
+
getUserInfo(accessToken: string): Promise<UserInfo>;
|
|
153
|
+
revokeToken(token: string, tokenTypeHint?: 'access_token' | 'refresh_token'): Promise<void>;
|
|
154
|
+
buildLogoutUrl(options?: BuildLogoutUrlOptions): string;
|
|
155
|
+
/**
|
|
156
|
+
* Starts RFC 8628 zero-touch agent onboarding (`/oauth2/device_authorization`),
|
|
157
|
+
* returning a `verification_uri_complete` deep link (`/activate?code=XXXX-YYYY`)
|
|
158
|
+
* for a human operator to approve.
|
|
159
|
+
*/
|
|
160
|
+
startDeviceActivation(options?: StartDeviceActivationOptions): Promise<DeviceAuthorizationResponse>;
|
|
161
|
+
/**
|
|
162
|
+
* Polls `/api/v1/agent/poll` once for RFC 8628 device activation status.
|
|
163
|
+
*/
|
|
164
|
+
pollDeviceActivation(deviceCode: string, intervalSeconds?: number): Promise<DevicePollResult>;
|
|
165
|
+
private postTokenForm;
|
|
166
|
+
private throwOAuthError;
|
|
167
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@ulexid/js` — Universal browser & runtime OIDC / OAuth 2.1 / Agent SDK.
|
|
3
|
+
*
|
|
4
|
+
* Uses WebCrypto (`crypto.subtle`) and standard `fetch` with zero runtime
|
|
5
|
+
* dependencies so it runs identically in browsers, Cloudflare Workers, Vercel
|
|
6
|
+
* Edge, Deno, Bun, and Node.js >= 20.
|
|
7
|
+
*
|
|
8
|
+
* Security invariants enforced by the client:
|
|
9
|
+
* - PKCE `S256` is mandatory on every authorization URL (`plain` is never emitted).
|
|
10
|
+
* - `redirect_uri` values are transmitted verbatim for exact server comparison.
|
|
11
|
+
*/
|
|
12
|
+
export class UlexOAuthError extends Error {
|
|
13
|
+
error;
|
|
14
|
+
errorDescription;
|
|
15
|
+
status;
|
|
16
|
+
constructor(error, status, errorDescription) {
|
|
17
|
+
super(errorDescription ? `${error}: ${errorDescription}` : error);
|
|
18
|
+
this.name = 'UlexOAuthError';
|
|
19
|
+
this.error = error;
|
|
20
|
+
this.status = status;
|
|
21
|
+
this.errorDescription = errorDescription;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
|
|
25
|
+
function bytesToBase64Url(bytes) {
|
|
26
|
+
let out = '';
|
|
27
|
+
const len = bytes.length;
|
|
28
|
+
for (let i = 0; i < len; i += 3) {
|
|
29
|
+
const b0 = bytes[i] ?? 0;
|
|
30
|
+
const b1 = i + 1 < len ? (bytes[i + 1] ?? 0) : 0;
|
|
31
|
+
const b2 = i + 2 < len ? (bytes[i + 2] ?? 0) : 0;
|
|
32
|
+
const triplet = (b0 << 16) | (b1 << 8) | b2;
|
|
33
|
+
out += BASE64_CHARS[(triplet >> 18) & 0x3f];
|
|
34
|
+
out += BASE64_CHARS[(triplet >> 12) & 0x3f];
|
|
35
|
+
out += i + 1 < len ? BASE64_CHARS[(triplet >> 6) & 0x3f] : '';
|
|
36
|
+
out += i + 2 < len ? BASE64_CHARS[triplet & 0x3f] : '';
|
|
37
|
+
}
|
|
38
|
+
return out.replace(/\+/g, '-').replace(/\//g, '_');
|
|
39
|
+
}
|
|
40
|
+
export function generateState(byteLength = 24) {
|
|
41
|
+
const bytes = new Uint8Array(byteLength);
|
|
42
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
43
|
+
return bytesToBase64Url(bytes);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Generates an RFC 7636 PKCE `S256` verifier + challenge pair using WebCrypto.
|
|
47
|
+
*/
|
|
48
|
+
export async function generatePkcePair(byteLength = 32) {
|
|
49
|
+
if (byteLength < 32 || byteLength > 96) {
|
|
50
|
+
throw new RangeError('PKCE verifier byteLength must be between 32 and 96 bytes (RFC 7636).');
|
|
51
|
+
}
|
|
52
|
+
const randomBytes = new Uint8Array(byteLength);
|
|
53
|
+
globalThis.crypto.getRandomValues(randomBytes);
|
|
54
|
+
const verifier = bytesToBase64Url(randomBytes);
|
|
55
|
+
const encoded = new TextEncoder().encode(verifier);
|
|
56
|
+
const digest = await globalThis.crypto.subtle.digest('SHA-256', encoded);
|
|
57
|
+
const challenge = bytesToBase64Url(new Uint8Array(digest));
|
|
58
|
+
return { verifier, challenge, method: 'S256' };
|
|
59
|
+
}
|
|
60
|
+
function normalizeIssuer(issuer) {
|
|
61
|
+
const trimmed = issuer.trim().replace(/\/+$/, '');
|
|
62
|
+
if (!trimmed) {
|
|
63
|
+
throw new TypeError('UlexClient requires a non-empty `issuer` URL.');
|
|
64
|
+
}
|
|
65
|
+
return trimmed;
|
|
66
|
+
}
|
|
67
|
+
export class UlexClient {
|
|
68
|
+
issuer;
|
|
69
|
+
clientId;
|
|
70
|
+
redirectUri;
|
|
71
|
+
fetchImpl;
|
|
72
|
+
constructor(config) {
|
|
73
|
+
this.issuer = normalizeIssuer(config.issuer);
|
|
74
|
+
this.clientId = config.clientId;
|
|
75
|
+
this.redirectUri = config.redirectUri;
|
|
76
|
+
this.fetchImpl = config.fetch ?? globalThis.fetch.bind(globalThis);
|
|
77
|
+
}
|
|
78
|
+
requireClientId() {
|
|
79
|
+
if (!this.clientId) {
|
|
80
|
+
throw new UlexOAuthError('missing_client_id', 400, 'This operation requires `clientId` on UlexClientConfig.');
|
|
81
|
+
}
|
|
82
|
+
return this.clientId;
|
|
83
|
+
}
|
|
84
|
+
buildAuthorizeUrl(options) {
|
|
85
|
+
const clientId = this.requireClientId();
|
|
86
|
+
const redirectUri = options.redirectUri ?? this.redirectUri;
|
|
87
|
+
if (!redirectUri) {
|
|
88
|
+
throw new UlexOAuthError('missing_redirect_uri', 400, 'An exact `redirectUri` is required.');
|
|
89
|
+
}
|
|
90
|
+
if (!options.codeChallenge) {
|
|
91
|
+
throw new UlexOAuthError('missing_pkce_challenge', 400, 'PKCE `codeChallenge` (S256) is mandatory.');
|
|
92
|
+
}
|
|
93
|
+
if (!options.state) {
|
|
94
|
+
throw new UlexOAuthError('missing_state', 400, 'OAuth `state` parameter is required.');
|
|
95
|
+
}
|
|
96
|
+
const scopes = options.scopes?.length ? options.scopes.join(' ') : 'openid profile email';
|
|
97
|
+
const url = new URL(`${this.issuer}/oauth2/authorize`);
|
|
98
|
+
url.searchParams.set('response_type', 'code');
|
|
99
|
+
url.searchParams.set('client_id', clientId);
|
|
100
|
+
url.searchParams.set('redirect_uri', redirectUri);
|
|
101
|
+
url.searchParams.set('scope', scopes);
|
|
102
|
+
url.searchParams.set('state', options.state);
|
|
103
|
+
url.searchParams.set('code_challenge', options.codeChallenge);
|
|
104
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
105
|
+
if (options.nonce)
|
|
106
|
+
url.searchParams.set('nonce', options.nonce);
|
|
107
|
+
if (options.organization)
|
|
108
|
+
url.searchParams.set('organization', options.organization);
|
|
109
|
+
if (options.connection)
|
|
110
|
+
url.searchParams.set('connection', options.connection);
|
|
111
|
+
if (options.loginHint)
|
|
112
|
+
url.searchParams.set('login_hint', options.loginHint);
|
|
113
|
+
if (options.prompt)
|
|
114
|
+
url.searchParams.set('prompt', options.prompt);
|
|
115
|
+
return url.toString();
|
|
116
|
+
}
|
|
117
|
+
async exchangeAuthorizationCode(options) {
|
|
118
|
+
const clientId = this.requireClientId();
|
|
119
|
+
const redirectUri = options.redirectUri ?? this.redirectUri;
|
|
120
|
+
if (!redirectUri) {
|
|
121
|
+
throw new UlexOAuthError('missing_redirect_uri', 400, 'An exact `redirectUri` is required.');
|
|
122
|
+
}
|
|
123
|
+
const body = new URLSearchParams({
|
|
124
|
+
grant_type: 'authorization_code',
|
|
125
|
+
client_id: clientId,
|
|
126
|
+
code: options.code,
|
|
127
|
+
redirect_uri: redirectUri,
|
|
128
|
+
code_verifier: options.codeVerifier,
|
|
129
|
+
});
|
|
130
|
+
if (options.clientSecret) {
|
|
131
|
+
body.set('client_secret', options.clientSecret);
|
|
132
|
+
}
|
|
133
|
+
return this.postTokenForm(body);
|
|
134
|
+
}
|
|
135
|
+
async refreshTokens(options) {
|
|
136
|
+
const clientId = this.requireClientId();
|
|
137
|
+
const body = new URLSearchParams({
|
|
138
|
+
grant_type: 'refresh_token',
|
|
139
|
+
client_id: clientId,
|
|
140
|
+
refresh_token: options.refreshToken,
|
|
141
|
+
});
|
|
142
|
+
if (options.scopes?.length) {
|
|
143
|
+
body.set('scope', options.scopes.join(' '));
|
|
144
|
+
}
|
|
145
|
+
if (options.clientSecret) {
|
|
146
|
+
body.set('client_secret', options.clientSecret);
|
|
147
|
+
}
|
|
148
|
+
return this.postTokenForm(body);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* RFC 8693 OAuth 2.0 Token Exchange for scoped, short-lived agent delegation.
|
|
152
|
+
*/
|
|
153
|
+
async exchangeToken(options) {
|
|
154
|
+
const clientId = this.requireClientId();
|
|
155
|
+
const body = new URLSearchParams({
|
|
156
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange',
|
|
157
|
+
client_id: clientId,
|
|
158
|
+
subject_token: options.subjectToken,
|
|
159
|
+
subject_token_type: options.subjectTokenType ?? 'urn:ietf:params:oauth:token-type:access_token',
|
|
160
|
+
});
|
|
161
|
+
if (options.actorToken) {
|
|
162
|
+
body.set('actor_token', options.actorToken);
|
|
163
|
+
body.set('actor_token_type', options.actorTokenType ?? 'urn:ietf:params:oauth:token-type:access_token');
|
|
164
|
+
}
|
|
165
|
+
if (options.scopes?.length)
|
|
166
|
+
body.set('scope', options.scopes.join(' '));
|
|
167
|
+
if (options.audience)
|
|
168
|
+
body.set('audience', options.audience);
|
|
169
|
+
if (options.resource)
|
|
170
|
+
body.set('resource', options.resource);
|
|
171
|
+
if (options.clientSecret)
|
|
172
|
+
body.set('client_secret', options.clientSecret);
|
|
173
|
+
return this.postTokenForm(body);
|
|
174
|
+
}
|
|
175
|
+
async getUserInfo(accessToken) {
|
|
176
|
+
const res = await this.fetchImpl(`${this.issuer}/oauth2/userinfo`, {
|
|
177
|
+
method: 'GET',
|
|
178
|
+
headers: {
|
|
179
|
+
authorization: `Bearer ${accessToken}`,
|
|
180
|
+
accept: 'application/json',
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
if (!res.ok) {
|
|
184
|
+
await this.throwOAuthError(res);
|
|
185
|
+
}
|
|
186
|
+
return (await res.json());
|
|
187
|
+
}
|
|
188
|
+
async revokeToken(token, tokenTypeHint) {
|
|
189
|
+
const clientId = this.requireClientId();
|
|
190
|
+
const body = new URLSearchParams({
|
|
191
|
+
client_id: clientId,
|
|
192
|
+
token,
|
|
193
|
+
});
|
|
194
|
+
if (tokenTypeHint)
|
|
195
|
+
body.set('token_type_hint', tokenTypeHint);
|
|
196
|
+
const res = await this.fetchImpl(`${this.issuer}/oauth2/revoke`, {
|
|
197
|
+
method: 'POST',
|
|
198
|
+
headers: {
|
|
199
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
200
|
+
accept: 'application/json',
|
|
201
|
+
},
|
|
202
|
+
body: body.toString(),
|
|
203
|
+
});
|
|
204
|
+
if (!res.ok) {
|
|
205
|
+
await this.throwOAuthError(res);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
buildLogoutUrl(options = {}) {
|
|
209
|
+
const url = new URL(`${this.issuer}/oauth2/logout`);
|
|
210
|
+
if (this.clientId)
|
|
211
|
+
url.searchParams.set('client_id', this.clientId);
|
|
212
|
+
if (options.idTokenHint)
|
|
213
|
+
url.searchParams.set('id_token_hint', options.idTokenHint);
|
|
214
|
+
if (options.postLogoutRedirectUri) {
|
|
215
|
+
url.searchParams.set('post_logout_redirect_uri', options.postLogoutRedirectUri);
|
|
216
|
+
}
|
|
217
|
+
if (options.state)
|
|
218
|
+
url.searchParams.set('state', options.state);
|
|
219
|
+
return url.toString();
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Starts RFC 8628 zero-touch agent onboarding (`/oauth2/device_authorization`),
|
|
223
|
+
* returning a `verification_uri_complete` deep link (`/activate?code=XXXX-YYYY`)
|
|
224
|
+
* for a human operator to approve.
|
|
225
|
+
*/
|
|
226
|
+
async startDeviceActivation(options = {}) {
|
|
227
|
+
const body = new URLSearchParams();
|
|
228
|
+
if (this.clientId)
|
|
229
|
+
body.set('client_id', this.clientId);
|
|
230
|
+
if (options.agentName)
|
|
231
|
+
body.set('agent_name', options.agentName);
|
|
232
|
+
if (options.applicationName)
|
|
233
|
+
body.set('application_name', options.applicationName);
|
|
234
|
+
if (options.scopes?.length)
|
|
235
|
+
body.set('scope', options.scopes.join(' '));
|
|
236
|
+
const res = await this.fetchImpl(`${this.issuer}/oauth2/device_authorization`, {
|
|
237
|
+
method: 'POST',
|
|
238
|
+
headers: {
|
|
239
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
240
|
+
accept: 'application/json',
|
|
241
|
+
},
|
|
242
|
+
body: body.toString(),
|
|
243
|
+
});
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
await this.throwOAuthError(res);
|
|
246
|
+
}
|
|
247
|
+
return (await res.json());
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Polls `/api/v1/agent/poll` once for RFC 8628 device activation status.
|
|
251
|
+
*/
|
|
252
|
+
async pollDeviceActivation(deviceCode, intervalSeconds = 5) {
|
|
253
|
+
const body = new URLSearchParams({
|
|
254
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
255
|
+
device_code: deviceCode,
|
|
256
|
+
});
|
|
257
|
+
if (this.clientId)
|
|
258
|
+
body.set('client_id', this.clientId);
|
|
259
|
+
const res = await this.fetchImpl(`${this.issuer}/api/v1/agent/poll`, {
|
|
260
|
+
method: 'POST',
|
|
261
|
+
headers: {
|
|
262
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
263
|
+
accept: 'application/json',
|
|
264
|
+
},
|
|
265
|
+
body: body.toString(),
|
|
266
|
+
});
|
|
267
|
+
const payload = (await res.json().catch(() => ({})));
|
|
268
|
+
if (res.ok) {
|
|
269
|
+
return { status: 'approved', credentials: payload };
|
|
270
|
+
}
|
|
271
|
+
const code = typeof payload['error'] === 'string' ? payload['error'] : 'unknown_error';
|
|
272
|
+
const desc = typeof payload['error_description'] === 'string' ? payload['error_description'] : undefined;
|
|
273
|
+
if (code === 'authorization_pending') {
|
|
274
|
+
return { status: 'pending', retryAfterSeconds: intervalSeconds };
|
|
275
|
+
}
|
|
276
|
+
if (code === 'slow_down') {
|
|
277
|
+
return { status: 'slow_down', retryAfterSeconds: intervalSeconds + 5 };
|
|
278
|
+
}
|
|
279
|
+
if (code === 'expired_token') {
|
|
280
|
+
return { status: 'expired', errorDescription: desc };
|
|
281
|
+
}
|
|
282
|
+
if (code === 'access_denied') {
|
|
283
|
+
return { status: 'denied', errorDescription: desc };
|
|
284
|
+
}
|
|
285
|
+
throw new UlexOAuthError(code, res.status, desc);
|
|
286
|
+
}
|
|
287
|
+
async postTokenForm(body) {
|
|
288
|
+
const res = await this.fetchImpl(`${this.issuer}/oauth2/token`, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: {
|
|
291
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
292
|
+
accept: 'application/json',
|
|
293
|
+
},
|
|
294
|
+
body: body.toString(),
|
|
295
|
+
});
|
|
296
|
+
if (!res.ok) {
|
|
297
|
+
await this.throwOAuthError(res);
|
|
298
|
+
}
|
|
299
|
+
return (await res.json());
|
|
300
|
+
}
|
|
301
|
+
async throwOAuthError(res) {
|
|
302
|
+
const payload = (await res.json().catch(() => ({})));
|
|
303
|
+
const errObj = payload['error'];
|
|
304
|
+
if (typeof errObj === 'string') {
|
|
305
|
+
const desc = typeof payload['error_description'] === 'string' ? payload['error_description'] : undefined;
|
|
306
|
+
throw new UlexOAuthError(errObj, res.status, desc);
|
|
307
|
+
}
|
|
308
|
+
if (typeof errObj === 'object' && errObj !== null) {
|
|
309
|
+
const nested = errObj;
|
|
310
|
+
const code = typeof nested['code'] === 'string' ? nested['code'] : `http_${res.status}`;
|
|
311
|
+
const msg = typeof nested['message'] === 'string' ? nested['message'] : undefined;
|
|
312
|
+
throw new UlexOAuthError(code, res.status, msg);
|
|
313
|
+
}
|
|
314
|
+
throw new UlexOAuthError(`http_${res.status}`, res.status, res.statusText || undefined);
|
|
315
|
+
}
|
|
316
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,26 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulexid/js",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"license": "UNLICENSED",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "Official browser and universal JavaScript/TypeScript OIDC, OAuth 2.1 PKCE (S256), RFC 8693 token exchange, and RFC 8628 agent device-flow SDK for Ulexid.",
|
|
7
|
+
"homepage": "https://www.ulexid.com",
|
|
8
|
+
"engines": {
|
|
9
|
+
"node": ">=18.0.0"
|
|
10
|
+
},
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"publishConfig": {
|
|
14
|
+
"access": "public"
|
|
15
|
+
},
|
|
16
|
+
"exports": {
|
|
17
|
+
".": {
|
|
18
|
+
"types": "./dist/index.d.ts",
|
|
19
|
+
"default": "./dist/index.js"
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"dist",
|
|
24
|
+
"README.md"
|
|
25
|
+
]
|
|
26
|
+
}
|