@x1id/resolve 0.23.0 → 0.43.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.
Files changed (49) hide show
  1. package/README.md +71 -0
  2. package/dist/accounts.d.ts +6 -0
  3. package/dist/accounts.js +6 -1
  4. package/dist/agent.d.ts +184 -8
  5. package/dist/agent.js +351 -3
  6. package/dist/agentPolicy.d.ts +238 -0
  7. package/dist/agentPolicy.js +364 -0
  8. package/dist/attestation.d.ts +25 -0
  9. package/dist/attestation.js +25 -0
  10. package/dist/checkout.d.ts +216 -0
  11. package/dist/checkout.js +327 -0
  12. package/dist/credentials.d.ts +466 -0
  13. package/dist/credentials.js +669 -0
  14. package/dist/gate.d.ts +94 -4
  15. package/dist/gate.js +206 -7
  16. package/dist/gateClient.d.ts +12 -0
  17. package/dist/giftLink.d.ts +272 -0
  18. package/dist/giftLink.js +452 -0
  19. package/dist/index.d.ts +13 -0
  20. package/dist/index.js +32 -0
  21. package/dist/loginButton.d.ts +69 -0
  22. package/dist/loginButton.js +207 -0
  23. package/dist/logo.d.ts +1 -0
  24. package/dist/logo.js +8 -0
  25. package/dist/messaging.d.ts +439 -0
  26. package/dist/messaging.js +979 -0
  27. package/dist/nsGate.d.ts +98 -0
  28. package/dist/nsGate.js +172 -0
  29. package/dist/oidc.d.ts +272 -0
  30. package/dist/oidc.js +641 -0
  31. package/dist/pay.d.ts +513 -0
  32. package/dist/pay.js +756 -0
  33. package/dist/payment.d.ts +9 -2
  34. package/dist/payment.js +9 -2
  35. package/dist/recovery.d.ts +186 -0
  36. package/dist/recovery.js +276 -0
  37. package/dist/recoveryNft.d.ts +183 -0
  38. package/dist/recoveryNft.js +291 -0
  39. package/dist/shield.d.ts +373 -0
  40. package/dist/shield.js +695 -0
  41. package/dist/subscription.d.ts +439 -0
  42. package/dist/subscription.js +642 -0
  43. package/dist/textRecords.d.ts +52 -6
  44. package/dist/textRecords.js +80 -8
  45. package/dist/types.d.ts +3 -0
  46. package/dist/x402Receipt.d.ts +246 -0
  47. package/dist/x402Receipt.js +374 -0
  48. package/package.json +18 -1
  49. package/schema/agent-manifest.json +91 -8
package/dist/oidc.js ADDED
@@ -0,0 +1,641 @@
1
+ /**
2
+ * "Sign in with X1ID" — a relying-party (RP) client for the X1ID OpenID Connect
3
+ * provider at `https://auth.x1id.io` (services/auth, OpenProject #8842).
4
+ *
5
+ * Authorization code flow with PKCE S256, `state` and `nonce`; the ID token is
6
+ * verified against the provider's JWKS (signature for the algorithms discovery
7
+ * advertises, `iss`, `aud`, `azp`, `exp`/`iat` with a small clock skew,
8
+ * `nonce`), then `/me` (UserInfo) is fetched and its `sub` checked against the
9
+ * ID token's.
10
+ *
11
+ * Runs in browsers and in Node 20+: Web Crypto + `fetch` only, no runtime
12
+ * dependency, no DOM access at import time. Browser SPAs use
13
+ * {@link beginX1idLogin} / {@link completeX1idLogin} (pending state kept in
14
+ * `sessionStorage`); servers call {@link X1idOidcClient.createAuthorization}
15
+ * and {@link X1idOidcClient.handleCallback} and keep the pending state in their
16
+ * own session.
17
+ *
18
+ * What a sign-in proves: the user controlled the wallet (`sub`) at sign-in. It
19
+ * is NOT proof of legal identity, personhood or KYC. Key accounts on `sub`
20
+ * (the wallet), never on the handle — handles move.
21
+ */
22
+ export const X1ID_ISSUER = "https://auth.x1id.io";
23
+ export const X1ID_DEFAULT_SCOPE = "openid profile wallet";
24
+ /** ID-token algorithms this client can verify (the provider's ES256 default,
25
+ * EdDSA/Ed25519, and the opt-in RS256 chain). Never `none`, never HS*. */
26
+ export const X1ID_SUPPORTED_ID_TOKEN_ALGS = ["ES256", "EdDSA", "RS256"];
27
+ /** Default tolerance for `exp` / `iat` against the local clock, in seconds. */
28
+ export const X1ID_DEFAULT_CLOCK_SKEW_SECS = 60;
29
+ /** How long a pending login (state/nonce/verifier) stays usable. Matches the
30
+ * provider's interaction lifetime. */
31
+ export const X1ID_PENDING_TTL_MS = 15 * 60 * 1000;
32
+ /** Minimum spacing between JWKS refetches triggered by an unknown `kid`. */
33
+ const JWKS_REFETCH_MIN_MS = 30_000;
34
+ /** Cached JWKS lifetime (the provider lets edges cache it for up to 5 min). */
35
+ const JWKS_MAX_AGE_MS = 10 * 60 * 1000;
36
+ export class X1idAuthError extends Error {
37
+ code;
38
+ /** The provider's `error_description`, when it sent one. */
39
+ description;
40
+ constructor(code, message, description) {
41
+ super(message);
42
+ this.name = "X1idAuthError";
43
+ this.code = code;
44
+ this.description = description;
45
+ }
46
+ }
47
+ // ---------------------------------------------------------------------------
48
+ // Encoding helpers
49
+ // ---------------------------------------------------------------------------
50
+ function base64UrlEncode(bytes) {
51
+ let bin = "";
52
+ for (let i = 0; i < bytes.length; i++)
53
+ bin += String.fromCharCode(bytes[i]);
54
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
55
+ }
56
+ function base64UrlDecode(s) {
57
+ if (!/^[A-Za-z0-9_-]*$/.test(s))
58
+ throw new Error("not base64url");
59
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
60
+ const bin = atob(b64);
61
+ const out = new Uint8Array(bin.length);
62
+ for (let i = 0; i < bin.length; i++)
63
+ out[i] = bin.charCodeAt(i);
64
+ return out;
65
+ }
66
+ function getCrypto(c) {
67
+ const cr = c ?? globalThis.crypto;
68
+ if (!cr?.subtle || typeof cr.getRandomValues !== "function") {
69
+ throw new X1idAuthError("no_crypto", "Web Crypto (crypto.subtle) is not available in this runtime");
70
+ }
71
+ return cr;
72
+ }
73
+ // ---------------------------------------------------------------------------
74
+ // PKCE, state, nonce
75
+ // ---------------------------------------------------------------------------
76
+ /** `bytes` random bytes, base64url (32 bytes → 43 chars, 256 bits). */
77
+ export function randomUrlSafe(bytes = 32, crypto) {
78
+ const buf = new Uint8Array(bytes);
79
+ getCrypto(crypto).getRandomValues(buf);
80
+ return base64UrlEncode(buf);
81
+ }
82
+ /** A fresh RFC 7636 code verifier: 32 random bytes → 43 base64url chars. */
83
+ export function generateCodeVerifier(crypto) {
84
+ return randomUrlSafe(32, crypto);
85
+ }
86
+ /** RFC 7636 S256: BASE64URL(SHA-256(ASCII(verifier))). */
87
+ export async function codeChallengeS256(verifier, crypto) {
88
+ if (!/^[A-Za-z0-9._~-]{43,128}$/.test(verifier)) {
89
+ throw new X1idAuthError("invalid_config", "code_verifier must be 43-128 unreserved characters (RFC 7636 §4.1)");
90
+ }
91
+ const digest = await getCrypto(crypto).subtle.digest("SHA-256", new TextEncoder().encode(verifier));
92
+ return base64UrlEncode(new Uint8Array(digest));
93
+ }
94
+ // ---------------------------------------------------------------------------
95
+ // ID-token verification (standalone, so servers can verify tokens they
96
+ // receive from elsewhere with their own key source)
97
+ // ---------------------------------------------------------------------------
98
+ function fail(msg) {
99
+ throw new X1idAuthError("invalid_id_token", `ID token rejected: ${msg}`);
100
+ }
101
+ function parseJson(bytes, what) {
102
+ let v;
103
+ try {
104
+ v = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
105
+ }
106
+ catch {
107
+ fail(`${what} is not valid JSON`);
108
+ }
109
+ if (!v || typeof v !== "object" || Array.isArray(v))
110
+ fail(`${what} is not a JSON object`);
111
+ return v;
112
+ }
113
+ /** Decode a JWT without verifying it (for logging / inspection only). */
114
+ export function decodeJwtUnverified(jwt) {
115
+ const parts = jwt.split(".");
116
+ if (parts.length !== 3)
117
+ fail("not a compact JWS (three parts)");
118
+ let h, p;
119
+ try {
120
+ h = base64UrlDecode(parts[0]);
121
+ p = base64UrlDecode(parts[1]);
122
+ }
123
+ catch {
124
+ fail("malformed base64url");
125
+ }
126
+ const header = parseJson(h, "header");
127
+ if (typeof header["alg"] !== "string")
128
+ fail("header has no alg");
129
+ return { header: header, payload: parseJson(p, "payload") };
130
+ }
131
+ /** Web Crypto parameters for a JWS alg + matching JWK, or null on mismatch. */
132
+ function importParams(alg, jwk) {
133
+ if (jwk.use !== undefined && jwk.use !== "sig")
134
+ return null;
135
+ if (jwk.alg !== undefined && jwk.alg !== alg)
136
+ return null;
137
+ switch (alg) {
138
+ case "ES256":
139
+ if (jwk.kty !== "EC" || jwk.crv !== "P-256" || !jwk.x || !jwk.y)
140
+ return null;
141
+ return {
142
+ importAlg: { name: "ECDSA", namedCurve: "P-256" },
143
+ verifyAlg: { name: "ECDSA", hash: "SHA-256" },
144
+ publicJwk: { kty: "EC", crv: "P-256", x: jwk.x, y: jwk.y },
145
+ };
146
+ case "EdDSA":
147
+ if (jwk.kty !== "OKP" || jwk.crv !== "Ed25519" || !jwk.x)
148
+ return null;
149
+ return { importAlg: { name: "Ed25519" }, verifyAlg: { name: "Ed25519" }, publicJwk: { kty: "OKP", crv: "Ed25519", x: jwk.x } };
150
+ case "RS256": {
151
+ if (jwk.kty !== "RSA" || !jwk.n || !jwk.e)
152
+ return null;
153
+ // Refuse RSA keys under 2048 bits (the provider writes 3072).
154
+ let bits = 0;
155
+ try {
156
+ const n = base64UrlDecode(jwk.n);
157
+ let i = 0;
158
+ while (i < n.length && n[i] === 0)
159
+ i++;
160
+ bits = (n.length - i) * 8;
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ if (bits < 2048)
166
+ return null;
167
+ return {
168
+ importAlg: { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
169
+ verifyAlg: { name: "RSASSA-PKCS1-v1_5" },
170
+ publicJwk: { kty: "RSA", n: jwk.n, e: jwk.e },
171
+ };
172
+ }
173
+ default:
174
+ return null;
175
+ }
176
+ }
177
+ /**
178
+ * Verify an X1ID ID token: signature (only `algorithms`, never `none`/HS*),
179
+ * `iss` exact match, `aud` contains the client, `azp` (if present, or if
180
+ * several audiences) equals the client, `exp` in the future and `iat` not in
181
+ * the future (± `clockSkewSecs`), `nonce` equal to the one sent, `sub` a
182
+ * non-empty string. Returns the claims; throws `X1idAuthError("invalid_id_token")`.
183
+ */
184
+ export async function verifyX1idIdToken(jwt, o) {
185
+ const crypto = getCrypto(o.crypto);
186
+ const { header, payload } = decodeJwtUnverified(jwt);
187
+ const alg = header.alg;
188
+ if (!X1ID_SUPPORTED_ID_TOKEN_ALGS.includes(alg) || !o.algorithms.includes(alg)) {
189
+ fail(`alg "${alg}" is not allowed`);
190
+ }
191
+ if (header["crit"] !== undefined)
192
+ fail("crit header is not supported");
193
+ const jwk = (await o.getKey(header));
194
+ if (!jwk)
195
+ fail(`no matching key for kid "${header.kid ?? ""}"`);
196
+ const params = importParams(alg, jwk);
197
+ if (!params)
198
+ fail(`key "${jwk.kid ?? ""}" does not fit alg ${alg}`);
199
+ const [h64, p64, s64] = jwt.split(".");
200
+ let sig;
201
+ try {
202
+ sig = base64UrlDecode(s64);
203
+ }
204
+ catch {
205
+ fail("malformed signature");
206
+ }
207
+ if (alg === "ES256" && sig.length !== 64)
208
+ fail("ES256 signature must be 64 bytes (r||s)");
209
+ if (alg === "EdDSA" && sig.length !== 64)
210
+ fail("Ed25519 signature must be 64 bytes");
211
+ let ok = false;
212
+ try {
213
+ const key = await crypto.subtle.importKey("jwk", params.publicJwk, params.importAlg, false, ["verify"]);
214
+ ok = await crypto.subtle.verify(params.verifyAlg, key, sig, new TextEncoder().encode(`${h64}.${p64}`));
215
+ }
216
+ catch (e) {
217
+ fail(`signature check failed (${e.message})`);
218
+ }
219
+ if (!ok)
220
+ fail("bad signature");
221
+ // Claims (OIDC Core §3.1.3.7).
222
+ const c = payload;
223
+ if (c["iss"] !== o.issuer)
224
+ fail(`iss "${String(c["iss"])}" is not "${o.issuer}"`);
225
+ const aud = c["aud"];
226
+ const auds = typeof aud === "string" ? [aud] : Array.isArray(aud) && aud.every((a) => typeof a === "string") ? aud : null;
227
+ if (!auds || !auds.includes(o.clientId))
228
+ fail("aud does not contain this client");
229
+ if (c["azp"] !== undefined && c["azp"] !== o.clientId)
230
+ fail("azp is not this client");
231
+ if (auds.length > 1 && c["azp"] === undefined)
232
+ fail("several audiences and no azp");
233
+ if (typeof c["sub"] !== "string" || c["sub"] === "")
234
+ fail("sub missing");
235
+ const nowS = (o.now ?? Date.now)() / 1000;
236
+ const skew = o.clockSkewSecs ?? X1ID_DEFAULT_CLOCK_SKEW_SECS;
237
+ const exp = c["exp"];
238
+ const iat = c["iat"];
239
+ if (typeof exp !== "number" || !Number.isFinite(exp))
240
+ fail("exp missing");
241
+ if (typeof iat !== "number" || !Number.isFinite(iat))
242
+ fail("iat missing");
243
+ if (nowS - skew >= exp)
244
+ fail("expired");
245
+ if (iat > nowS + skew)
246
+ fail("issued in the future (iat)");
247
+ const nbf = c["nbf"];
248
+ if (nbf !== undefined && (typeof nbf !== "number" || nbf > nowS + skew))
249
+ fail("not yet valid (nbf)");
250
+ if (o.nonce !== undefined) {
251
+ if (c["nonce"] !== o.nonce)
252
+ fail("nonce mismatch");
253
+ }
254
+ return c;
255
+ }
256
+ // ---------------------------------------------------------------------------
257
+ // The client
258
+ // ---------------------------------------------------------------------------
259
+ function isLoopback(host) {
260
+ return host === "localhost" || host === "127.0.0.1" || host === "[::1]";
261
+ }
262
+ function checkUrl(u, what, code = "invalid_config") {
263
+ let url;
264
+ try {
265
+ url = new URL(u);
266
+ }
267
+ catch {
268
+ throw new X1idAuthError(code, `${what} is not an absolute URL`);
269
+ }
270
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLoopback(url.hostname))) {
271
+ throw new X1idAuthError(code, `${what} must be https (http only on loopback)`);
272
+ }
273
+ return url;
274
+ }
275
+ function scopeString(s) {
276
+ const list = (typeof s === "string" ? s.split(/\s+/) : s ? [...s] : X1ID_DEFAULT_SCOPE.split(" ")).filter(Boolean);
277
+ if (!list.includes("openid"))
278
+ list.unshift("openid");
279
+ return [...new Set(list)].join(" ");
280
+ }
281
+ const RESERVED_PARAMS = new Set([
282
+ "client_id",
283
+ "redirect_uri",
284
+ "response_type",
285
+ "scope",
286
+ "state",
287
+ "nonce",
288
+ "code_challenge",
289
+ "code_challenge_method",
290
+ "prompt",
291
+ "max_age",
292
+ "ui_locales",
293
+ ]);
294
+ /** Create an RP client. Nothing is fetched until first use. */
295
+ export function createX1idOidcClient(config) {
296
+ if (!config.clientId)
297
+ throw new X1idAuthError("invalid_config", "clientId is required");
298
+ const issuer = (config.issuer ?? X1ID_ISSUER).replace(/\/+$/, "");
299
+ checkUrl(issuer, "issuer");
300
+ checkUrl(config.redirectUri, "redirectUri");
301
+ // A private-use scheme (native apps) would also be legal, but this client
302
+ // targets web RPs; checkUrl above enforces https / loopback http.
303
+ const clientId = config.clientId;
304
+ const redirectUri = config.redirectUri;
305
+ const authMethod = config.tokenEndpointAuthMethod ?? "client_secret_basic";
306
+ const now = config.now ?? Date.now;
307
+ const doFetch = config.fetch ?? ((...a) => globalThis.fetch(...a));
308
+ let discovery = null;
309
+ let jwks = null;
310
+ let jwksInflight = null;
311
+ async function getJson(url, init, code, what) {
312
+ let res;
313
+ try {
314
+ res = await doFetch(url, init);
315
+ }
316
+ catch (e) {
317
+ throw new X1idAuthError(code, `${what}: network error (${e.message})`);
318
+ }
319
+ let body = null;
320
+ try {
321
+ body = await res.json();
322
+ }
323
+ catch {
324
+ /* fallthrough */
325
+ }
326
+ const obj = body && typeof body === "object" && !Array.isArray(body) ? body : null;
327
+ if (!res.ok) {
328
+ const err = obj && typeof obj["error"] === "string" ? obj["error"] : code;
329
+ const desc = obj && typeof obj["error_description"] === "string" ? obj["error_description"] : undefined;
330
+ throw new X1idAuthError(err, `${what} failed (${res.status}${desc ? `: ${desc}` : ""})`, desc);
331
+ }
332
+ if (!obj)
333
+ throw new X1idAuthError(code, `${what}: response is not a JSON object`);
334
+ return obj;
335
+ }
336
+ function discover() {
337
+ discovery ??= (async () => {
338
+ const d = await getJson(`${issuer}/.well-known/openid-configuration`, { headers: { accept: "application/json" } }, "discovery_failed", "discovery");
339
+ // OIDC Discovery §4.3: the document's issuer MUST equal the one we asked.
340
+ if (d["issuer"] !== issuer)
341
+ throw new X1idAuthError("discovery_failed", `discovery issuer "${String(d["issuer"])}" is not "${issuer}"`);
342
+ for (const k of ["authorization_endpoint", "token_endpoint", "jwks_uri"]) {
343
+ if (typeof d[k] !== "string")
344
+ throw new X1idAuthError("discovery_failed", `discovery has no ${k}`);
345
+ checkUrl(d[k], k, "discovery_failed");
346
+ }
347
+ const methods = d["code_challenge_methods_supported"];
348
+ if (Array.isArray(methods) && !methods.includes("S256"))
349
+ throw new X1idAuthError("discovery_failed", "provider does not support PKCE S256");
350
+ return d;
351
+ })();
352
+ // A failed discovery is retried on the next call rather than cached.
353
+ discovery.catch(() => {
354
+ discovery = null;
355
+ });
356
+ return discovery;
357
+ }
358
+ async function fetchJwks() {
359
+ jwksInflight ??= (async () => {
360
+ try {
361
+ const d = await discover();
362
+ const body = await getJson(d.jwks_uri, { headers: { accept: "application/json" } }, "discovery_failed", "jwks");
363
+ const keys = Array.isArray(body["keys"]) ? body["keys"].filter((k) => k && typeof k === "object") : [];
364
+ jwks = { keys, fetchedAt: now() };
365
+ return keys;
366
+ }
367
+ finally {
368
+ jwksInflight = null;
369
+ }
370
+ })();
371
+ return jwksInflight;
372
+ }
373
+ function pick(keys, header) {
374
+ const fitting = keys.filter((k) => importParams(header.alg, k) !== null);
375
+ if (header.kid !== undefined)
376
+ return fitting.find((k) => k.kid === header.kid) ?? null;
377
+ return fitting.length === 1 ? fitting[0] : null; // no kid: only an unambiguous key
378
+ }
379
+ async function getKey(header) {
380
+ // Cached JWKS, refreshed after JWKS_MAX_AGE_MS so a key the provider
381
+ // dropped (emergency rotation) stops verifying here too.
382
+ const cached = jwks && now() - jwks.fetchedAt < JWKS_MAX_AGE_MS ? jwks : null;
383
+ const keys = cached ? cached.keys : await fetchJwks();
384
+ const hit = pick(keys, header);
385
+ if (hit || !cached)
386
+ return hit;
387
+ // Unknown kid on a cached set: the provider may have rotated. Refetch,
388
+ // at most once per JWKS_REFETCH_MIN_MS (a forged kid can't hammer it).
389
+ if (now() - cached.fetchedAt < JWKS_REFETCH_MIN_MS)
390
+ return null;
391
+ return pick(await fetchJwks(), header);
392
+ }
393
+ async function algorithms() {
394
+ const d = await discover();
395
+ const advertised = Array.isArray(d.id_token_signing_alg_values_supported) ? d.id_token_signing_alg_values_supported : [...X1ID_SUPPORTED_ID_TOKEN_ALGS];
396
+ const usable = advertised.filter((a) => X1ID_SUPPORTED_ID_TOKEN_ALGS.includes(a));
397
+ if (config.idTokenSigningAlg) {
398
+ if (!usable.includes(config.idTokenSigningAlg)) {
399
+ throw new X1idAuthError("invalid_config", `provider does not advertise ${config.idTokenSigningAlg} ID tokens`);
400
+ }
401
+ return [config.idTokenSigningAlg];
402
+ }
403
+ return usable;
404
+ }
405
+ async function verifyIdToken(idToken, opts = {}) {
406
+ return verifyX1idIdToken(idToken, {
407
+ issuer,
408
+ clientId,
409
+ ...(opts.nonce !== undefined ? { nonce: opts.nonce } : {}),
410
+ algorithms: await algorithms(),
411
+ getKey,
412
+ clockSkewSecs: config.clockSkewSecs ?? X1ID_DEFAULT_CLOCK_SKEW_SECS,
413
+ now,
414
+ ...(config.crypto ? { crypto: config.crypto } : {}),
415
+ });
416
+ }
417
+ function clientAuth(body) {
418
+ const headers = { "content-type": "application/x-www-form-urlencoded", accept: "application/json" };
419
+ if (config.clientSecret === undefined) {
420
+ body.set("client_id", clientId);
421
+ }
422
+ else if (authMethod === "client_secret_post") {
423
+ body.set("client_id", clientId);
424
+ body.set("client_secret", config.clientSecret);
425
+ }
426
+ else {
427
+ // RFC 6749 §2.3.1: form-urlencode both parts before base64.
428
+ const enc = (s) => encodeURIComponent(s).replace(/%20/g, "+");
429
+ const raw = new TextEncoder().encode(`${enc(clientId)}:${enc(config.clientSecret)}`);
430
+ let bin = "";
431
+ raw.forEach((b) => (bin += String.fromCharCode(b)));
432
+ headers["authorization"] = `Basic ${btoa(bin)}`;
433
+ }
434
+ return headers;
435
+ }
436
+ function parseTokens(t) {
437
+ if (typeof t["access_token"] !== "string" || !t["access_token"])
438
+ throw new X1idAuthError("invalid_token_response", "token response has no access_token");
439
+ if (typeof t["token_type"] !== "string" || t["token_type"].toLowerCase() !== "bearer") {
440
+ throw new X1idAuthError("invalid_token_response", "token_type is not Bearer");
441
+ }
442
+ const exp = typeof t["expires_in"] === "number" ? now() + t["expires_in"] * 1000 : undefined;
443
+ return {
444
+ accessToken: t["access_token"],
445
+ tokenType: t["token_type"],
446
+ expiresAt: exp,
447
+ refreshToken: typeof t["refresh_token"] === "string" ? t["refresh_token"] : undefined,
448
+ scope: typeof t["scope"] === "string" ? t["scope"] : undefined,
449
+ idToken: typeof t["id_token"] === "string" ? t["id_token"] : undefined,
450
+ };
451
+ }
452
+ async function tokenRequest(body) {
453
+ const d = await discover();
454
+ const headers = clientAuth(body);
455
+ return getJson(d.token_endpoint, { method: "POST", headers, body: body.toString() }, "token_request_failed", "token request");
456
+ }
457
+ function checkPending(pending) {
458
+ if (pending.issuer !== issuer || pending.redirectUri !== redirectUri) {
459
+ throw new X1idAuthError("state_mismatch", "pending login belongs to a different issuer or redirect URI");
460
+ }
461
+ if (now() - pending.createdAt > X1ID_PENDING_TTL_MS) {
462
+ throw new X1idAuthError("pending_expired", "the login took too long; start again");
463
+ }
464
+ }
465
+ async function exchangeCode(code, pending) {
466
+ checkPending(pending);
467
+ const t = parseTokens(await tokenRequest(new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: redirectUri, code_verifier: pending.codeVerifier })));
468
+ if (!t.idToken)
469
+ throw new X1idAuthError("invalid_token_response", "token response has no id_token (is `openid` in the scope?)");
470
+ const idTokenClaims = await verifyIdToken(t.idToken, { nonce: pending.nonce });
471
+ return { ...t, idToken: t.idToken, idTokenClaims };
472
+ }
473
+ async function fetchUserInfo(accessToken, expectedSub) {
474
+ const d = await discover();
475
+ if (!d.userinfo_endpoint)
476
+ throw new X1idAuthError("userinfo_failed", "provider has no userinfo_endpoint");
477
+ checkUrl(d.userinfo_endpoint, "userinfo_endpoint", "userinfo_failed");
478
+ const u = await getJson(d.userinfo_endpoint, { headers: { authorization: `Bearer ${accessToken}`, accept: "application/json" } }, "userinfo_failed", "userinfo");
479
+ if (typeof u["sub"] !== "string")
480
+ throw new X1idAuthError("userinfo_failed", "userinfo has no sub");
481
+ if (expectedSub !== undefined && u["sub"] !== expectedSub)
482
+ throw new X1idAuthError("userinfo_failed", "userinfo sub does not match the ID token");
483
+ return u;
484
+ }
485
+ return {
486
+ issuer,
487
+ clientId,
488
+ redirectUri,
489
+ discover,
490
+ async createAuthorization(opts = {}) {
491
+ const d = await discover();
492
+ const state = randomUrlSafe(32, config.crypto);
493
+ const nonce = randomUrlSafe(32, config.crypto);
494
+ const codeVerifier = generateCodeVerifier(config.crypto);
495
+ const url = new URL(d.authorization_endpoint);
496
+ for (const [k, v] of Object.entries(opts.extraParams ?? {})) {
497
+ if (RESERVED_PARAMS.has(k))
498
+ throw new X1idAuthError("invalid_config", `extraParams may not set "${k}"`);
499
+ url.searchParams.set(k, v);
500
+ }
501
+ url.searchParams.set("client_id", clientId);
502
+ url.searchParams.set("redirect_uri", redirectUri);
503
+ url.searchParams.set("response_type", "code");
504
+ url.searchParams.set("scope", scopeString(opts.scope ?? config.scope));
505
+ url.searchParams.set("state", state);
506
+ url.searchParams.set("nonce", nonce);
507
+ url.searchParams.set("code_challenge", await codeChallengeS256(codeVerifier, config.crypto));
508
+ url.searchParams.set("code_challenge_method", "S256");
509
+ if (opts.prompt)
510
+ url.searchParams.set("prompt", opts.prompt);
511
+ if (opts.maxAge !== undefined)
512
+ url.searchParams.set("max_age", String(opts.maxAge));
513
+ if (opts.uiLocales)
514
+ url.searchParams.set("ui_locales", opts.uiLocales);
515
+ return { url: url.toString(), pending: { state, nonce, codeVerifier, redirectUri, issuer, createdAt: now() } };
516
+ },
517
+ async handleCallback(callbackUrl, pending, opts = {}) {
518
+ const url = new URL(String(callbackUrl));
519
+ // Params arrive in the query (response_mode=query, the default).
520
+ const p = url.searchParams;
521
+ const state = p.get("state");
522
+ if (!state || state !== pending.state) {
523
+ throw new X1idAuthError("state_mismatch", "state in the callback does not match the login this browser started");
524
+ }
525
+ // RFC 9207: reject a response from a different issuer (mix-up attack).
526
+ const d = await discover();
527
+ const iss = p.get("iss");
528
+ if (iss !== null ? iss !== issuer : d.authorization_response_iss_parameter_supported === true) {
529
+ throw new X1idAuthError("iss_mismatch", "the authorization response's iss is missing or not this issuer");
530
+ }
531
+ const err = p.get("error");
532
+ if (err) {
533
+ const desc = p.get("error_description") ?? undefined;
534
+ throw new X1idAuthError(err, `X1ID sign-in failed: ${err}${desc ? ` (${desc})` : ""}`, desc);
535
+ }
536
+ const code = p.get("code");
537
+ if (!code)
538
+ throw new X1idAuthError("missing_code", "callback has no code");
539
+ const t = await exchangeCode(code, pending);
540
+ const userInfo = opts.userInfo === false ? undefined : await fetchUserInfo(t.accessToken, t.idTokenClaims.sub);
541
+ return { ...t, sub: t.idTokenClaims.sub, userInfo };
542
+ },
543
+ exchangeCode,
544
+ verifyIdToken,
545
+ fetchUserInfo,
546
+ async refresh(refreshToken, opts = {}) {
547
+ const t = parseTokens(await tokenRequest(new URLSearchParams({ grant_type: "refresh_token", refresh_token: refreshToken })));
548
+ let idTokenClaims;
549
+ if (t.idToken) {
550
+ idTokenClaims = await verifyIdToken(t.idToken);
551
+ if (opts.expectedSub !== undefined && idTokenClaims.sub !== opts.expectedSub) {
552
+ throw new X1idAuthError("invalid_id_token", "ID token rejected: refreshed sub differs");
553
+ }
554
+ }
555
+ return { ...t, idTokenClaims };
556
+ },
557
+ async revoke(token, hint) {
558
+ const d = await discover();
559
+ if (!d.revocation_endpoint)
560
+ throw new X1idAuthError("invalid_config", "provider has no revocation_endpoint");
561
+ const body = new URLSearchParams({ token });
562
+ if (hint)
563
+ body.set("token_type_hint", hint);
564
+ const headers = clientAuth(body);
565
+ const res = await doFetch(d.revocation_endpoint, { method: "POST", headers, body: body.toString() });
566
+ if (!res.ok)
567
+ throw new X1idAuthError("token_request_failed", `revocation failed (${res.status})`);
568
+ },
569
+ async logoutUrl(opts = {}) {
570
+ const d = await discover();
571
+ if (!d.end_session_endpoint)
572
+ throw new X1idAuthError("invalid_config", "provider has no end_session_endpoint");
573
+ const url = new URL(d.end_session_endpoint);
574
+ url.searchParams.set("client_id", clientId);
575
+ if (opts.idTokenHint)
576
+ url.searchParams.set("id_token_hint", opts.idTokenHint);
577
+ if (opts.postLogoutRedirectUri)
578
+ url.searchParams.set("post_logout_redirect_uri", opts.postLogoutRedirectUri);
579
+ if (opts.state)
580
+ url.searchParams.set("state", opts.state);
581
+ return url.toString();
582
+ },
583
+ };
584
+ }
585
+ // ---------------------------------------------------------------------------
586
+ // Browser SPA helpers (sessionStorage + redirect)
587
+ // ---------------------------------------------------------------------------
588
+ const STORAGE_PREFIX = "x1id.oidc.pending.";
589
+ function defaultStorage() {
590
+ const s = globalThis.sessionStorage;
591
+ if (!s)
592
+ throw new X1idAuthError("invalid_config", "no sessionStorage: pass `storage`, or use createAuthorization/handleCallback on a server");
593
+ return s;
594
+ }
595
+ /**
596
+ * Start "Sign in with X1ID" in a browser: stores the pending login in
597
+ * `sessionStorage` (keyed by state, so two tabs don't collide) and navigates
598
+ * to auth.x1id.io. Pass `navigate` to handle the redirect yourself.
599
+ */
600
+ export async function beginX1idLogin(client, opts = {}) {
601
+ const { storage, navigate, ...authOpts } = opts;
602
+ const { url, pending } = await client.createAuthorization(authOpts);
603
+ (storage ?? defaultStorage()).setItem(STORAGE_PREFIX + pending.state, JSON.stringify(pending));
604
+ if (navigate)
605
+ navigate(url);
606
+ else
607
+ globalThis.location.assign(url);
608
+ return url;
609
+ }
610
+ /**
611
+ * Finish the login on your redirect page: looks up (and removes — single use)
612
+ * the pending login for the callback's `state`, then runs
613
+ * {@link X1idOidcClient.handleCallback}. By default strips `code`/`state` from
614
+ * the address bar afterwards (`history.replaceState`).
615
+ */
616
+ export async function completeX1idLogin(client, opts = {}) {
617
+ const href = String(opts.url ?? globalThis.location.href);
618
+ const storage = opts.storage ?? defaultStorage();
619
+ const state = new URL(href).searchParams.get("state");
620
+ const key = state ? STORAGE_PREFIX + state : null;
621
+ const raw = key ? storage.getItem(key) : null;
622
+ if (key)
623
+ storage.removeItem(key);
624
+ const g = globalThis;
625
+ if (opts.cleanUrl !== false && opts.url === undefined && g.history && g.location) {
626
+ const clean = new URL(g.location.href);
627
+ for (const k of ["code", "state", "iss", "error", "error_description", "error_uri", "session_state"])
628
+ clean.searchParams.delete(k);
629
+ g.history.replaceState(g.history.state, "", clean.toString());
630
+ }
631
+ if (!raw)
632
+ throw new X1idAuthError("state_mismatch", "no pending X1ID login for this callback (already used, or started in another tab/browser)");
633
+ let pending;
634
+ try {
635
+ pending = JSON.parse(raw);
636
+ }
637
+ catch {
638
+ throw new X1idAuthError("state_mismatch", "pending X1ID login is corrupt");
639
+ }
640
+ return client.handleCallback(href, pending, opts.userInfo === undefined ? {} : { userInfo: opts.userInfo });
641
+ }