@cboxdk/id-js 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.md +21 -0
- package/README.md +143 -0
- package/dist/chunk-D4OUHK6N.js +490 -0
- package/dist/chunk-D4OUHK6N.js.map +1 -0
- package/dist/client-BvPEUSfe.d.cts +256 -0
- package/dist/client-BvPEUSfe.d.ts +256 -0
- package/dist/index.cjs +503 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +37 -0
- package/dist/index.d.ts +37 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/nextjs.cjs +570 -0
- package/dist/nextjs.cjs.map +1 -0
- package/dist/nextjs.d.cts +29 -0
- package/dist/nextjs.d.ts +29 -0
- package/dist/nextjs.js +80 -0
- package/dist/nextjs.js.map +1 -0
- package/package.json +75 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) Cbox
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# @cboxdk/id-js
|
|
2
|
+
|
|
3
|
+
Turnkey [Cbox ID](https://github.com/cboxdk/laravel-id) client for JavaScript /
|
|
4
|
+
TypeScript. It speaks standard OpenID Connect against a Cbox ID instance — so
|
|
5
|
+
integrating is a redirect and a callback, not a rewrite — and adds the conveniences a
|
|
6
|
+
hosted-identity product needs:
|
|
7
|
+
|
|
8
|
+
- **Login** — one redirect, one callback. PKCE (S256), a CSRF `state`, a nonce, and
|
|
9
|
+
full `id_token` verification (signature against the instance's JWKS via
|
|
10
|
+
[`jose`](https://github.com/panva/jose), plus issuer, audience and nonce) are
|
|
11
|
+
handled for you.
|
|
12
|
+
- **Hosted profile management** — send a signed-in user to the instance's own account
|
|
13
|
+
page (password, MFA, passkeys, sessions) and back to your app.
|
|
14
|
+
- **Back-channel calls** — machine (client-credentials) tokens, UserInfo, RFC 7662
|
|
15
|
+
introspection.
|
|
16
|
+
- **Webhook / action verification** — confirm an inbound `X-Cbox-Signature`.
|
|
17
|
+
|
|
18
|
+
Runs on Node, edge runtimes and the browser (built on Web Crypto and `fetch`), with a
|
|
19
|
+
first-class **Next.js** adapter.
|
|
20
|
+
|
|
21
|
+
## Install
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm install @cboxdk/id-js
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Next.js (App Router)
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
// lib/cbox.ts
|
|
31
|
+
import { createCboxId } from '@cboxdk/id-js/nextjs';
|
|
32
|
+
|
|
33
|
+
// Reads CBOX_ID_ISSUER / CBOX_ID_CLIENT_ID / CBOX_ID_CLIENT_SECRET / CBOX_ID_REDIRECT_URI
|
|
34
|
+
export const cboxId = createCboxId();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// app/auth/sign-in/route.ts
|
|
39
|
+
import { cboxId } from '@/lib/cbox';
|
|
40
|
+
export const GET = () => cboxId.signIn();
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
// app/auth/callback/route.ts
|
|
45
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
46
|
+
import { cboxId } from '@/lib/cbox';
|
|
47
|
+
|
|
48
|
+
export async function GET(request: NextRequest) {
|
|
49
|
+
const user = await cboxId.callback(request); // verifies state, PKCE and the id_token
|
|
50
|
+
// create your own session for `user.id` (the stable subject), then:
|
|
51
|
+
return NextResponse.redirect(new URL('/dashboard', request.url));
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Send users to hosted profile management:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
// app/account/route.ts
|
|
59
|
+
import { cboxId } from '@/lib/cbox';
|
|
60
|
+
export const GET = () => cboxId.profileRedirect('/dashboard');
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## Any framework (the core)
|
|
64
|
+
|
|
65
|
+
`CboxIdClient` is framework-agnostic — it hands you the values to persist and takes
|
|
66
|
+
them back:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { CboxIdClient } from '@cboxdk/id-js';
|
|
70
|
+
|
|
71
|
+
const client = new CboxIdClient({
|
|
72
|
+
issuer: 'https://id.acme.com',
|
|
73
|
+
clientId: process.env.CBOX_ID_CLIENT_ID!,
|
|
74
|
+
clientSecret: process.env.CBOX_ID_CLIENT_SECRET,
|
|
75
|
+
redirectUri: 'https://app.acme.com/auth/callback',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Start login — persist state/codeVerifier/nonce (e.g. signed httpOnly cookies).
|
|
79
|
+
const { url, state, codeVerifier, nonce } = await client.createAuthorizationRequest();
|
|
80
|
+
// redirect the user to `url` ...
|
|
81
|
+
|
|
82
|
+
// On the callback:
|
|
83
|
+
const user = await client.authenticate({
|
|
84
|
+
params: { code, state: callbackState },
|
|
85
|
+
stored: { state, codeVerifier, nonce },
|
|
86
|
+
});
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Back-channel calls
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const token = await client.machineToken({ scopes: ['reports.read'] }); // as your app
|
|
93
|
+
const claims = await client.userinfo(user.accessToken); // as a user
|
|
94
|
+
const introspection = await client.introspect(someToken); // RFC 7662
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
## Verify webhooks
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { verifyWebhook } from '@cboxdk/id-js';
|
|
101
|
+
|
|
102
|
+
const ok = await verifyWebhook({
|
|
103
|
+
payload: rawBody, // the exact bytes received
|
|
104
|
+
signatureHeader: req.headers['x-cbox-signature'],
|
|
105
|
+
secret: process.env.CBOX_ID_WEBHOOK_SECRET!,
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
## Token Vault
|
|
110
|
+
|
|
111
|
+
Broker downstream credentials (API keys for OpenAI, GitHub, …) through the instance's
|
|
112
|
+
Token Vault: provision + grant with a `vault.manage` token, and let an authorized
|
|
113
|
+
agent client redeem the plaintext with a `vault.lease` token.
|
|
114
|
+
|
|
115
|
+
```ts
|
|
116
|
+
// Provisioning backend (vault.manage)
|
|
117
|
+
const admin = client.vault(await client.machineToken({ scopes: ['vault.manage'] }));
|
|
118
|
+
const secret = await admin.store({ name: 'openai', provider: 'openai', secret: 'sk-live-…' });
|
|
119
|
+
await admin.grant(secret.id, 'agent-1');
|
|
120
|
+
|
|
121
|
+
// Agent worker (vault.lease) — keyed on its own client
|
|
122
|
+
const agent = client.vault(await client.machineToken({ scopes: ['vault.lease'] }));
|
|
123
|
+
const lease = await agent.lease(secret.id, 'call openai');
|
|
124
|
+
// use lease.secret immediately; it is never persisted
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A lease with no live grant is refused — the vault is deny-by-default.
|
|
128
|
+
|
|
129
|
+
## Security & scope
|
|
130
|
+
|
|
131
|
+
Login is hardened by default — PKCE, `state`, nonce, and full `id_token` verification
|
|
132
|
+
(signature/issuer/audience) via `jose`; webhook checks are constant-time within a
|
|
133
|
+
freshness window. Keep `clientSecret` and webhook secrets server-side.
|
|
134
|
+
|
|
135
|
+
This is a **client**. It authenticates users and calls a Cbox ID instance's standard
|
|
136
|
+
endpoints; it does not configure SSO, run SCIM, or manage organizations — those are
|
|
137
|
+
platform capabilities of [`cboxdk/laravel-id`](https://github.com/cboxdk/laravel-id).
|
|
138
|
+
|
|
139
|
+
Report vulnerabilities via this repo's GitHub **Private Vulnerability Reporting**.
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT © Cbox.
|
|
@@ -0,0 +1,490 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
2
|
+
|
|
3
|
+
// src/errors.ts
|
|
4
|
+
var CboxIdError = class extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = new.target.name;
|
|
8
|
+
}
|
|
9
|
+
};
|
|
10
|
+
var ConfigurationError = class extends CboxIdError {
|
|
11
|
+
};
|
|
12
|
+
var InvalidStateError = class extends CboxIdError {
|
|
13
|
+
};
|
|
14
|
+
var AuthenticationError = class extends CboxIdError {
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// src/pkce.ts
|
|
18
|
+
function base64url(bytes) {
|
|
19
|
+
let binary = "";
|
|
20
|
+
for (const byte of bytes) {
|
|
21
|
+
binary += String.fromCharCode(byte);
|
|
22
|
+
}
|
|
23
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
24
|
+
}
|
|
25
|
+
function randomToken(bytes = 32) {
|
|
26
|
+
return base64url(crypto.getRandomValues(new Uint8Array(bytes)));
|
|
27
|
+
}
|
|
28
|
+
function createVerifier() {
|
|
29
|
+
return randomToken(32);
|
|
30
|
+
}
|
|
31
|
+
async function challenge(verifier) {
|
|
32
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
33
|
+
return base64url(new Uint8Array(digest));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// src/vault.ts
|
|
37
|
+
var VaultClient = class {
|
|
38
|
+
constructor(issuer, token, timeoutMs = 1e4) {
|
|
39
|
+
this.token = token;
|
|
40
|
+
this.timeoutMs = timeoutMs;
|
|
41
|
+
this.base = `${issuer.replace(/\/$/, "")}/api/v1/vault`;
|
|
42
|
+
}
|
|
43
|
+
token;
|
|
44
|
+
timeoutMs;
|
|
45
|
+
base;
|
|
46
|
+
/** Ingest a downstream credential, sealed at rest (scope `vault.manage`). */
|
|
47
|
+
store(input) {
|
|
48
|
+
return this.json("POST", "/secrets", {
|
|
49
|
+
name: input.name,
|
|
50
|
+
provider: input.provider,
|
|
51
|
+
secret: input.secret,
|
|
52
|
+
owner_type: input.ownerType,
|
|
53
|
+
owner_id: input.ownerId,
|
|
54
|
+
expires_at: input.expiresAt
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Rotate a secret's value, keeping its id and grants (scope `vault.manage`). */
|
|
58
|
+
rotate(secretId, secret) {
|
|
59
|
+
return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/rotate`, { secret });
|
|
60
|
+
}
|
|
61
|
+
/** Revoke a secret permanently (scope `vault.manage`). */
|
|
62
|
+
async revoke(secretId) {
|
|
63
|
+
await this.request("DELETE", `/secrets/${encodeURIComponent(secretId)}`);
|
|
64
|
+
}
|
|
65
|
+
/** Authorize an agent client to lease a secret (scope `vault.manage`). */
|
|
66
|
+
grant(secretId, clientId, maxTtlSeconds) {
|
|
67
|
+
return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/grants`, {
|
|
68
|
+
client_id: clientId,
|
|
69
|
+
max_ttl_seconds: maxTtlSeconds
|
|
70
|
+
}).then((raw) => {
|
|
71
|
+
const r = raw;
|
|
72
|
+
return { secretId: r.secret_id, clientId: r.client_id, maxTtlSeconds: r.max_ttl_seconds };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/** Revoke an agent's authorization (scope `vault.manage`). */
|
|
76
|
+
async revokeGrant(secretId, clientId) {
|
|
77
|
+
await this.request(
|
|
78
|
+
"DELETE",
|
|
79
|
+
`/secrets/${encodeURIComponent(secretId)}/grants/${encodeURIComponent(clientId)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Redeem a leased credential for immediate use (scope `vault.lease`). The caller
|
|
84
|
+
* is identified by the token's client; a lease with no live grant is refused.
|
|
85
|
+
*/
|
|
86
|
+
lease(secretId, purpose) {
|
|
87
|
+
return this.json("POST", `/secrets/${encodeURIComponent(secretId)}/lease`, { purpose }).then((raw) => {
|
|
88
|
+
const r = raw;
|
|
89
|
+
return { secretId: r.secret_id, provider: r.provider, secret: r.secret, expiresAt: r.expires_at };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
async json(method, path, body) {
|
|
93
|
+
const response = await this.request(method, path, body);
|
|
94
|
+
return await response.json();
|
|
95
|
+
}
|
|
96
|
+
async request(method, path, body) {
|
|
97
|
+
const controller = new AbortController();
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
99
|
+
try {
|
|
100
|
+
const init = {
|
|
101
|
+
method,
|
|
102
|
+
headers: {
|
|
103
|
+
authorization: `Bearer ${this.token}`,
|
|
104
|
+
...body ? { "content-type": "application/json" } : {}
|
|
105
|
+
},
|
|
106
|
+
signal: controller.signal
|
|
107
|
+
};
|
|
108
|
+
if (body) {
|
|
109
|
+
init.body = JSON.stringify(body);
|
|
110
|
+
}
|
|
111
|
+
const response = await fetch(`${this.base}${path}`, init);
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new AuthenticationError(`Vault ${method} ${path} failed with status ${response.status}.`);
|
|
114
|
+
}
|
|
115
|
+
return response;
|
|
116
|
+
} finally {
|
|
117
|
+
clearTimeout(timer);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// src/webhook.ts
|
|
123
|
+
function timingSafeEqual(a, b) {
|
|
124
|
+
if (a.length !== b.length) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
let mismatch = 0;
|
|
128
|
+
for (let i = 0; i < a.length; i++) {
|
|
129
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
130
|
+
}
|
|
131
|
+
return mismatch === 0;
|
|
132
|
+
}
|
|
133
|
+
function toHex(bytes) {
|
|
134
|
+
return Array.from(new Uint8Array(bytes)).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
135
|
+
}
|
|
136
|
+
async function verifyWebhook(options) {
|
|
137
|
+
const { payload, signatureHeader, secret } = options;
|
|
138
|
+
const toleranceSeconds = options.toleranceSeconds ?? 300;
|
|
139
|
+
const now = options.now ?? Math.floor(Date.now() / 1e3);
|
|
140
|
+
if (!signatureHeader) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
const parts = {};
|
|
144
|
+
for (const segment of signatureHeader.split(",")) {
|
|
145
|
+
const [key2, value] = segment.trim().split("=", 2);
|
|
146
|
+
if (key2) {
|
|
147
|
+
parts[key2] = value ?? "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const timestamp = parts["t"] ?? "";
|
|
151
|
+
const signature = parts["v1"] ?? "";
|
|
152
|
+
if (timestamp === "" || signature === "" || !/^\d+$/.test(timestamp)) {
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
const key = await crypto.subtle.importKey(
|
|
159
|
+
"raw",
|
|
160
|
+
new TextEncoder().encode(secret),
|
|
161
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
162
|
+
false,
|
|
163
|
+
["sign"]
|
|
164
|
+
);
|
|
165
|
+
const expected = toHex(
|
|
166
|
+
await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${payload}`))
|
|
167
|
+
);
|
|
168
|
+
return timingSafeEqual(expected, signature);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/discovery.ts
|
|
172
|
+
var Discovery = class {
|
|
173
|
+
constructor(issuer, timeoutMs, cacheTtlMs, now = Date.now) {
|
|
174
|
+
this.issuer = issuer;
|
|
175
|
+
this.timeoutMs = timeoutMs;
|
|
176
|
+
this.cacheTtlMs = cacheTtlMs;
|
|
177
|
+
this.now = now;
|
|
178
|
+
}
|
|
179
|
+
issuer;
|
|
180
|
+
timeoutMs;
|
|
181
|
+
cacheTtlMs;
|
|
182
|
+
now;
|
|
183
|
+
cached = null;
|
|
184
|
+
async document() {
|
|
185
|
+
if (this.cached && this.cached.expiresAt > this.now()) {
|
|
186
|
+
return this.cached.doc;
|
|
187
|
+
}
|
|
188
|
+
const url = `${this.issuer.replace(/\/$/, "")}/.well-known/openid-configuration`;
|
|
189
|
+
const controller = new AbortController();
|
|
190
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
191
|
+
let doc;
|
|
192
|
+
try {
|
|
193
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
194
|
+
if (!response.ok) {
|
|
195
|
+
throw new AuthenticationError(`Discovery request failed with status ${response.status}.`);
|
|
196
|
+
}
|
|
197
|
+
doc = await response.json();
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (error instanceof AuthenticationError) {
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
throw new AuthenticationError(`Discovery request failed: ${String(error)}`);
|
|
203
|
+
} finally {
|
|
204
|
+
clearTimeout(timer);
|
|
205
|
+
}
|
|
206
|
+
if (typeof doc.issuer !== "string" || doc.issuer === "") {
|
|
207
|
+
throw new AuthenticationError("The discovery document was missing an issuer.");
|
|
208
|
+
}
|
|
209
|
+
this.cached = { doc, expiresAt: this.now() + this.cacheTtlMs };
|
|
210
|
+
return doc;
|
|
211
|
+
}
|
|
212
|
+
/** A required endpoint from the discovery document. */
|
|
213
|
+
async endpoint(name) {
|
|
214
|
+
const value = (await this.document())[name];
|
|
215
|
+
if (typeof value !== "string" || value === "") {
|
|
216
|
+
throw new ConfigurationError(`The instance does not advertise a ${name}.`);
|
|
217
|
+
}
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
/** An optional endpoint, or null when the instance advertises none. */
|
|
221
|
+
async optionalEndpoint(name) {
|
|
222
|
+
const value = (await this.document())[name];
|
|
223
|
+
return typeof value === "string" && value !== "" ? value : null;
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
// src/client.ts
|
|
228
|
+
var CboxIdClient = class {
|
|
229
|
+
constructor(config) {
|
|
230
|
+
this.config = config;
|
|
231
|
+
if (!config.issuer) {
|
|
232
|
+
throw new ConfigurationError("Cbox ID config `issuer` is required.");
|
|
233
|
+
}
|
|
234
|
+
if (!config.clientId) {
|
|
235
|
+
throw new ConfigurationError("Cbox ID config `clientId` is required.");
|
|
236
|
+
}
|
|
237
|
+
if (!config.redirectUri) {
|
|
238
|
+
throw new ConfigurationError("Cbox ID config `redirectUri` is required.");
|
|
239
|
+
}
|
|
240
|
+
this.discovery = new Discovery(
|
|
241
|
+
config.issuer,
|
|
242
|
+
config.timeoutMs ?? 1e4,
|
|
243
|
+
config.cacheTtlMs ?? 36e5
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
config;
|
|
247
|
+
discovery;
|
|
248
|
+
jwksByUri = /* @__PURE__ */ new Map();
|
|
249
|
+
/**
|
|
250
|
+
* Begin login. Returns the authorize URL plus the `state`, `codeVerifier` and
|
|
251
|
+
* `nonce` — persist those (signed, httpOnly cookies are ideal) and hand them back
|
|
252
|
+
* to {@link authenticate} on the callback.
|
|
253
|
+
*/
|
|
254
|
+
async createAuthorizationRequest(options = {}) {
|
|
255
|
+
const codeVerifier = createVerifier();
|
|
256
|
+
const state = options.state ?? randomToken(16);
|
|
257
|
+
const nonce = randomToken(16);
|
|
258
|
+
const params = new URLSearchParams({
|
|
259
|
+
response_type: "code",
|
|
260
|
+
client_id: this.config.clientId,
|
|
261
|
+
redirect_uri: options.redirectUri ?? this.config.redirectUri,
|
|
262
|
+
scope: (options.scopes ?? this.scopes()).join(" "),
|
|
263
|
+
state,
|
|
264
|
+
nonce,
|
|
265
|
+
code_challenge: await challenge(codeVerifier),
|
|
266
|
+
code_challenge_method: "S256"
|
|
267
|
+
});
|
|
268
|
+
if (options.prompt) {
|
|
269
|
+
params.set("prompt", options.prompt);
|
|
270
|
+
}
|
|
271
|
+
if (options.loginHint) {
|
|
272
|
+
params.set("login_hint", options.loginHint);
|
|
273
|
+
}
|
|
274
|
+
const endpoint = await this.discovery.endpoint("authorization_endpoint");
|
|
275
|
+
return { url: `${endpoint}?${params.toString()}`, state, codeVerifier, nonce };
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Complete login on your callback route: verify state, exchange the code with the
|
|
279
|
+
* PKCE verifier, verify the id_token, and return the authenticated user.
|
|
280
|
+
*
|
|
281
|
+
* @throws InvalidStateError when state does not match (forged/stale request)
|
|
282
|
+
* @throws AuthenticationError on any other failure
|
|
283
|
+
*/
|
|
284
|
+
async authenticate(input) {
|
|
285
|
+
const { params, stored } = input;
|
|
286
|
+
if (!params.state || !stored.state || !timingSafeEqualString(params.state, stored.state)) {
|
|
287
|
+
throw new InvalidStateError("The login state did not match \u2014 the request may be forged or stale.");
|
|
288
|
+
}
|
|
289
|
+
if (params.error) {
|
|
290
|
+
throw new AuthenticationError(
|
|
291
|
+
`Cbox ID returned an error: ${params.error}${params.error_description ? ` (${params.error_description})` : ""}`
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
if (!params.code) {
|
|
295
|
+
throw new AuthenticationError("The callback was missing an authorization code.");
|
|
296
|
+
}
|
|
297
|
+
const tokens = await this.exchange(params.code, stored.codeVerifier, input.redirectUri);
|
|
298
|
+
if (!tokens.access_token) {
|
|
299
|
+
throw new AuthenticationError("No access token was returned.");
|
|
300
|
+
}
|
|
301
|
+
let claims = {};
|
|
302
|
+
if (tokens.id_token) {
|
|
303
|
+
claims = await this.verifyIdToken(tokens.id_token, stored.nonce);
|
|
304
|
+
}
|
|
305
|
+
claims = { ...claims, ...await this.userinfo(tokens.access_token) };
|
|
306
|
+
const sub = claims["sub"];
|
|
307
|
+
if (typeof sub !== "string" || sub === "") {
|
|
308
|
+
throw new AuthenticationError("The verified token carried no subject.");
|
|
309
|
+
}
|
|
310
|
+
return {
|
|
311
|
+
id: sub,
|
|
312
|
+
email: typeof claims["email"] === "string" ? claims["email"] : null,
|
|
313
|
+
name: typeof claims["name"] === "string" ? claims["name"] : null,
|
|
314
|
+
organizationId: typeof claims["org"] === "string" ? claims["org"] : null,
|
|
315
|
+
claims,
|
|
316
|
+
accessToken: tokens.access_token,
|
|
317
|
+
refreshToken: tokens.refresh_token ?? null,
|
|
318
|
+
idToken: tokens.id_token ?? null,
|
|
319
|
+
expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : 0
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The URL of the instance's hosted account/profile page (self-service password,
|
|
324
|
+
* MFA, passkeys, sessions). A signed-in user is authenticated there by their Cbox
|
|
325
|
+
* ID session; `returnTo` is passed so the page can link back to your app.
|
|
326
|
+
*/
|
|
327
|
+
profileUrl(returnTo) {
|
|
328
|
+
const base = `${this.config.issuer.replace(/\/$/, "")}${this.accountPath()}`;
|
|
329
|
+
return returnTo ? `${base}?${new URLSearchParams({ return_to: returnTo }).toString()}` : base;
|
|
330
|
+
}
|
|
331
|
+
/** The RP-initiated logout URL, or null when the instance advertises none. */
|
|
332
|
+
async logoutUrl(returnTo) {
|
|
333
|
+
const endpoint = await this.discovery.optionalEndpoint("end_session_endpoint");
|
|
334
|
+
if (!endpoint) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
return returnTo ? `${endpoint}?${new URLSearchParams({ post_logout_redirect_uri: returnTo }).toString()}` : endpoint;
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* A machine (client-credentials) access token for calling Cbox ID APIs as your
|
|
341
|
+
* app, not on a user's behalf. Requires a client secret.
|
|
342
|
+
*/
|
|
343
|
+
async machineToken(options = {}) {
|
|
344
|
+
const body = new URLSearchParams({
|
|
345
|
+
grant_type: "client_credentials",
|
|
346
|
+
client_id: this.config.clientId,
|
|
347
|
+
client_secret: this.requireSecret()
|
|
348
|
+
});
|
|
349
|
+
if (options.scopes && options.scopes.length > 0) {
|
|
350
|
+
body.set("scope", options.scopes.join(" "));
|
|
351
|
+
}
|
|
352
|
+
if (options.resource) {
|
|
353
|
+
body.set("resource", options.resource);
|
|
354
|
+
}
|
|
355
|
+
const response = await this.post(await this.discovery.endpoint("token_endpoint"), body);
|
|
356
|
+
if (!response.ok) {
|
|
357
|
+
throw new AuthenticationError(`Machine token request failed: ${await response.text()}`);
|
|
358
|
+
}
|
|
359
|
+
const json = await response.json();
|
|
360
|
+
if (typeof json.access_token !== "string") {
|
|
361
|
+
throw new AuthenticationError("The token response had no access_token.");
|
|
362
|
+
}
|
|
363
|
+
return json.access_token;
|
|
364
|
+
}
|
|
365
|
+
/** The OIDC userinfo claims for an access token. */
|
|
366
|
+
async userinfo(accessToken) {
|
|
367
|
+
const endpoint = await this.discovery.optionalEndpoint("userinfo_endpoint");
|
|
368
|
+
if (!endpoint) {
|
|
369
|
+
return {};
|
|
370
|
+
}
|
|
371
|
+
const response = await this.fetchWithTimeout(endpoint, {
|
|
372
|
+
headers: { authorization: `Bearer ${accessToken}` }
|
|
373
|
+
});
|
|
374
|
+
if (!response.ok) {
|
|
375
|
+
throw new AuthenticationError("Userinfo request failed.");
|
|
376
|
+
}
|
|
377
|
+
return await response.json();
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* RFC 7662 token introspection (confidential-client auth). Returns the raw
|
|
381
|
+
* response; `active` tells you if the token is currently valid. Requires a secret.
|
|
382
|
+
*/
|
|
383
|
+
async introspect(token) {
|
|
384
|
+
const endpoint = await this.discovery.endpoint("introspection_endpoint");
|
|
385
|
+
const basic = btoa(`${this.config.clientId}:${this.requireSecret()}`);
|
|
386
|
+
const response = await this.post(
|
|
387
|
+
endpoint,
|
|
388
|
+
new URLSearchParams({ token }),
|
|
389
|
+
{ authorization: `Basic ${basic}` }
|
|
390
|
+
);
|
|
391
|
+
if (!response.ok) {
|
|
392
|
+
throw new AuthenticationError("Introspection request failed.");
|
|
393
|
+
}
|
|
394
|
+
return await response.json();
|
|
395
|
+
}
|
|
396
|
+
/** Verify a Cbox ID webhook / inline-action signature. See {@link verifyWebhook}. */
|
|
397
|
+
verifyWebhook(options) {
|
|
398
|
+
return verifyWebhook(options);
|
|
399
|
+
}
|
|
400
|
+
/**
|
|
401
|
+
* A Token Vault client bound to an access token. Obtain the token with
|
|
402
|
+
* {@link machineToken} (scope `vault.manage` to provision/grant, `vault.lease` to
|
|
403
|
+
* redeem), then call `store` / `grant` / `lease` etc.
|
|
404
|
+
*/
|
|
405
|
+
vault(accessToken) {
|
|
406
|
+
return new VaultClient(this.config.issuer, accessToken, this.config.timeoutMs ?? 1e4);
|
|
407
|
+
}
|
|
408
|
+
async exchange(code, verifier, redirectUri) {
|
|
409
|
+
const body = new URLSearchParams({
|
|
410
|
+
grant_type: "authorization_code",
|
|
411
|
+
code,
|
|
412
|
+
redirect_uri: redirectUri ?? this.config.redirectUri,
|
|
413
|
+
client_id: this.config.clientId,
|
|
414
|
+
code_verifier: verifier
|
|
415
|
+
});
|
|
416
|
+
if (this.config.clientSecret) {
|
|
417
|
+
body.set("client_secret", this.config.clientSecret);
|
|
418
|
+
}
|
|
419
|
+
const response = await this.post(await this.discovery.endpoint("token_endpoint"), body);
|
|
420
|
+
if (!response.ok) {
|
|
421
|
+
throw new AuthenticationError(`Token exchange failed: ${await response.text()}`);
|
|
422
|
+
}
|
|
423
|
+
return await response.json();
|
|
424
|
+
}
|
|
425
|
+
async verifyIdToken(idToken, nonce) {
|
|
426
|
+
const jwksUri = await this.discovery.endpoint("jwks_uri");
|
|
427
|
+
let resolver = this.jwksByUri.get(jwksUri);
|
|
428
|
+
if (!resolver) {
|
|
429
|
+
resolver = createRemoteJWKSet(new URL(jwksUri));
|
|
430
|
+
this.jwksByUri.set(jwksUri, resolver);
|
|
431
|
+
}
|
|
432
|
+
let payload;
|
|
433
|
+
try {
|
|
434
|
+
({ payload } = await jwtVerify(idToken, resolver, {
|
|
435
|
+
issuer: this.config.issuer,
|
|
436
|
+
audience: this.config.clientId
|
|
437
|
+
}));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
throw new AuthenticationError(`The id_token could not be verified: ${String(error)}`);
|
|
440
|
+
}
|
|
441
|
+
if (nonce && payload["nonce"] !== nonce) {
|
|
442
|
+
throw new AuthenticationError("The id_token nonce did not match \u2014 possible replay.");
|
|
443
|
+
}
|
|
444
|
+
return payload;
|
|
445
|
+
}
|
|
446
|
+
scopes() {
|
|
447
|
+
const scopes = this.config.scopes;
|
|
448
|
+
return scopes && scopes.length > 0 ? scopes : ["openid", "profile", "email"];
|
|
449
|
+
}
|
|
450
|
+
accountPath() {
|
|
451
|
+
const path = this.config.accountPath;
|
|
452
|
+
return path && path !== "" ? `/${path.replace(/^\//, "")}` : "/settings";
|
|
453
|
+
}
|
|
454
|
+
requireSecret() {
|
|
455
|
+
if (!this.config.clientSecret) {
|
|
456
|
+
throw new ConfigurationError("This call requires a `clientSecret`, but none is configured.");
|
|
457
|
+
}
|
|
458
|
+
return this.config.clientSecret;
|
|
459
|
+
}
|
|
460
|
+
post(url, body, headers = {}) {
|
|
461
|
+
return this.fetchWithTimeout(url, {
|
|
462
|
+
method: "POST",
|
|
463
|
+
headers: { "content-type": "application/x-www-form-urlencoded", ...headers },
|
|
464
|
+
body
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
async fetchWithTimeout(url, init) {
|
|
468
|
+
const controller = new AbortController();
|
|
469
|
+
const timer = setTimeout(() => controller.abort(), this.config.timeoutMs ?? 1e4);
|
|
470
|
+
try {
|
|
471
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
472
|
+
} finally {
|
|
473
|
+
clearTimeout(timer);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
function timingSafeEqualString(a, b) {
|
|
478
|
+
if (a.length !== b.length) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
let mismatch = 0;
|
|
482
|
+
for (let i = 0; i < a.length; i++) {
|
|
483
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
484
|
+
}
|
|
485
|
+
return mismatch === 0;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
export { AuthenticationError, CboxIdClient, CboxIdError, ConfigurationError, InvalidStateError, VaultClient, challenge, createVerifier, randomToken, verifyWebhook };
|
|
489
|
+
//# sourceMappingURL=chunk-D4OUHK6N.js.map
|
|
490
|
+
//# sourceMappingURL=chunk-D4OUHK6N.js.map
|