@soulcode/bexio-oauth 0.1.0 → 0.2.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 (69) hide show
  1. package/README.md +1 -1
  2. package/dist/claims.d.ts +1 -1
  3. package/dist/claims.d.ts.map +1 -1
  4. package/dist/claims.js +2 -2
  5. package/dist/claims.js.map +1 -1
  6. package/dist/client.d.ts +4 -137
  7. package/dist/client.d.ts.map +1 -1
  8. package/dist/client.js +18 -629
  9. package/dist/client.js.map +1 -1
  10. package/dist/config.d.ts +22 -0
  11. package/dist/config.d.ts.map +1 -0
  12. package/dist/config.js +57 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/connect-flow.d.ts +15 -0
  15. package/dist/connect-flow.d.ts.map +1 -0
  16. package/dist/connect-flow.js +123 -0
  17. package/dist/connect-flow.js.map +1 -0
  18. package/dist/crypto.d.ts.map +1 -1
  19. package/dist/crypto.js +15 -10
  20. package/dist/crypto.js.map +1 -1
  21. package/dist/external-tokens.d.ts +8 -0
  22. package/dist/external-tokens.d.ts.map +1 -0
  23. package/dist/external-tokens.js +73 -0
  24. package/dist/external-tokens.js.map +1 -0
  25. package/dist/flow-cookie.d.ts +23 -0
  26. package/dist/flow-cookie.d.ts.map +1 -0
  27. package/dist/flow-cookie.js +44 -0
  28. package/dist/flow-cookie.js.map +1 -0
  29. package/dist/grants.d.ts +36 -0
  30. package/dist/grants.d.ts.map +1 -0
  31. package/dist/grants.js +115 -0
  32. package/dist/grants.js.map +1 -0
  33. package/dist/idle-sweep.d.ts +11 -0
  34. package/dist/idle-sweep.d.ts.map +1 -0
  35. package/dist/idle-sweep.js +70 -0
  36. package/dist/idle-sweep.js.map +1 -0
  37. package/dist/index.d.ts +2 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +1 -1
  40. package/dist/index.js.map +1 -1
  41. package/dist/migrations.generated.d.ts +3 -0
  42. package/dist/migrations.generated.d.ts.map +1 -0
  43. package/dist/migrations.generated.js +40 -0
  44. package/dist/migrations.generated.js.map +1 -0
  45. package/dist/oidc.d.ts +30 -0
  46. package/dist/oidc.d.ts.map +1 -0
  47. package/dist/oidc.js +67 -0
  48. package/dist/oidc.js.map +1 -0
  49. package/dist/refresh.d.ts +30 -0
  50. package/dist/refresh.d.ts.map +1 -0
  51. package/dist/refresh.js +139 -0
  52. package/dist/refresh.js.map +1 -0
  53. package/dist/return-to.d.ts +8 -0
  54. package/dist/return-to.d.ts.map +1 -0
  55. package/dist/return-to.js +31 -0
  56. package/dist/return-to.js.map +1 -0
  57. package/dist/sql.d.ts +6 -1
  58. package/dist/sql.d.ts.map +1 -1
  59. package/dist/sql.js +8 -12
  60. package/dist/sql.js.map +1 -1
  61. package/dist/store/postgres.d.ts +1 -1
  62. package/dist/store/postgres.d.ts.map +1 -1
  63. package/dist/store/postgres.js +11 -2
  64. package/dist/store/postgres.js.map +1 -1
  65. package/dist/types.d.ts +150 -0
  66. package/dist/types.d.ts.map +1 -0
  67. package/dist/types.js +2 -0
  68. package/dist/types.js.map +1 -0
  69. package/package.json +3 -1
package/dist/client.js CHANGED
@@ -1,635 +1,24 @@
1
- import { randomUUID } from "node:crypto";
2
- import * as oidc from "openid-client";
3
- import { createRemoteJWKSet, customFetch as joseCustomFetch, decodeJwt, errors as joseErrors, jwtVerify, } from "jose";
4
- import { BASE_SCOPES, BEXIO_API_BASE_URL, BEXIO_ISSUER, grantBinding, mapClaims, } from "./claims.js";
5
- import { TokenCipher } from "./crypto.js";
6
- import { toMs } from "./duration.js";
7
- import { BexioOAuthError, isBexioOAuthError } from "./errors.js";
8
- import { fetchRole } from "./permissions.js";
9
- const FLOW_COOKIE = "bexio_oauth_flow";
10
- const FLOW_TTL_SECONDS = 600;
11
- /** Refresh a little before the access token really expires. */
12
- const EXPIRY_SKEW_MS = 30_000;
13
- const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
14
- /** Per HTTP request to Bexio. Kept well below the default refresh lock TTL. */
15
- const HTTP_TIMEOUT_SECONDS = 10;
16
- const SIGNING_ALGORITHMS = ["RS256", "PS256", "ES256"];
17
- const MIN_LOCK_TTL_MS = 30_000;
1
+ import { resolveConfig } from "./config.js";
2
+ import { callback, handleCallback, start } from "./connect-flow.js";
3
+ import { saveGrant } from "./external-tokens.js";
4
+ import { disconnect, listGrants } from "./grants.js";
5
+ import { refreshIdle } from "./idle-sweep.js";
6
+ import { getAccessToken } from "./refresh.js";
18
7
  /**
19
- * A safe summary of an upstream error for `BexioOAuthError.cause`: the OAuth
20
- * error code and HTTP status, never response bodies or token values.
8
+ * Creates the client. Validates the config up front (throws `invalid_config`);
9
+ * OIDC discovery runs lazily on first use.
21
10
  */
22
- function describe(error) {
23
- if (error instanceof oidc.ResponseBodyError) {
24
- return { name: error.name, error: error.error, status: error.status };
25
- }
26
- if (error instanceof oidc.AuthorizationResponseError) {
27
- return { name: error.name, error: error.error };
28
- }
29
- if (error instanceof Error) {
30
- const code = error.code;
31
- return {
32
- name: error.name,
33
- ...(typeof code === "string" ? { error: code } : {}),
34
- };
35
- }
36
- return { name: "unknown" };
37
- }
38
- function toGrant(r) {
39
- return {
40
- id: r.id,
41
- ownerId: r.ownerId,
42
- companyId: r.companyId,
43
- companyName: r.companyName,
44
- userId: r.userId,
45
- userName: r.userName,
46
- userEmail: r.userEmail,
47
- companyUserId: r.companyUserId,
48
- role: r.role,
49
- status: r.status,
50
- scope: r.scope,
51
- accessExpiresAt: r.accessExpiresAt,
52
- lastRefreshedAt: r.lastRefreshedAt,
53
- createdAt: r.createdAt,
54
- updatedAt: r.updatedAt,
55
- };
56
- }
57
- const RETURN_TO_BASE = "https://return-to.invalid";
58
- /**
59
- * Only same-origin relative paths: blocks open redirects via `returnTo`.
60
- * Parsed with the URL parser (the same one the redirect will go through), so
61
- * tricks like `/\t/evil.example` are caught, and normalized to path+query+hash.
62
- */
63
- function safeReturnTo(value, fallback) {
64
- if (!value)
65
- return fallback;
66
- const invalid = () => new BexioOAuthError("invalid_config", "returnTo must be a relative path like /settings");
67
- // eslint-disable-next-line no-control-regex
68
- if (!value.startsWith("/") || /[\u0000-\u001f\\]/.test(value))
69
- throw invalid();
70
- let url;
71
- try {
72
- url = new URL(value, RETURN_TO_BASE);
73
- }
74
- catch {
75
- throw invalid();
76
- }
77
- if (url.origin !== RETURN_TO_BASE)
78
- throw invalid();
79
- // Dot segments can collapse to "//host" (e.g. "/.//evil.example"), which a
80
- // browser reads as protocol-relative: check the normalized result too.
81
- if (url.pathname.startsWith("//"))
82
- throw invalid();
83
- return `${url.pathname}${url.search}${url.hash}`;
84
- }
85
- function readCookie(request, name) {
86
- const header = request.headers.get("cookie");
87
- if (!header)
88
- return null;
89
- for (const part of header.split(";")) {
90
- const index = part.indexOf("=");
91
- if (index === -1)
92
- continue;
93
- if (part.slice(0, index).trim() === name)
94
- return part.slice(index + 1).trim();
95
- }
96
- return null;
97
- }
98
- function decodeIfJwt(token) {
99
- try {
100
- return decodeJwt(token);
101
- }
102
- catch {
103
- return {};
104
- }
105
- }
106
- const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
107
11
  export function createBexioOAuth(config) {
108
- for (const key of ["clientId", "clientSecret", "redirectUri"]) {
109
- if (!config[key])
110
- throw new BexioOAuthError("invalid_config", `${key} is required`);
111
- }
112
- if (!config.store)
113
- throw new BexioOAuthError("invalid_config", "store is required");
114
- const cipher = new TokenCipher(config.keys);
115
- const store = config.store;
116
- const clock = config.clock ?? (() => new Date());
117
- const fetchImpl = config.fetch ?? fetch;
118
- const issuer = config.issuer ?? BEXIO_ISSUER;
119
- const apiBaseUrl = config.apiBaseUrl ?? BEXIO_API_BASE_URL;
120
- const redirectUri = new URL(config.redirectUri);
121
- const scope = [...new Set([...BASE_SCOPES, ...(config.scopes ?? [])])].join(" ");
122
- const lockTtlMs = toMs(config.refreshLockTtl ?? "30s", "refreshLockTtl");
123
- // One refresh makes up to three Bexio calls inside the lock: token (10 s),
124
- // the JWKS fetch for the refreshed ID token (10 s), and the role (5 s).
125
- if (lockTtlMs < MIN_LOCK_TTL_MS) {
126
- throw new BexioOAuthError("invalid_config", `refreshLockTtl must be at least ${MIN_LOCK_TTL_MS / 1000}s`);
127
- }
128
- const defaultReturnTo = safeReturnTo(config.defaultReturnTo, "/");
129
- const issuerUrl = new URL(issuer);
130
- // http is allowed only for local mocks; real issuers must use https.
131
- const insecure = issuerUrl.protocol === "http:" && LOOPBACK_HOSTS.has(issuerUrl.hostname);
132
- // ---- OIDC configuration (lazy, retried after a failure) -----------------
133
- let discovered;
134
- function oidcConfig() {
135
- discovered ??= oidc
136
- .discovery(issuerUrl, config.clientId, config.clientSecret, undefined, {
137
- execute: insecure ? [oidc.allowInsecureRequests] : [],
138
- // Well below the refresh lock TTL, so a slow IdP can't outlive the lock.
139
- timeout: HTTP_TIMEOUT_SECONDS,
140
- [oidc.customFetch]: (url, options) => fetchImpl(url, options),
141
- })
142
- .then((c) => {
143
- c[oidc.customFetch] = (url, options) => fetchImpl(url, options);
144
- oidc.enableNonRepudiationChecks(c);
145
- return c;
146
- })
147
- .catch((cause) => {
148
- discovered = undefined;
149
- throw new BexioOAuthError("idp_error", `OIDC discovery failed for ${issuer}`, { cause: describe(cause) });
150
- });
151
- return discovered;
152
- }
153
- // Bexio's signing keys (cached, refetched on an unknown key id, with a
154
- // timeout and a cooldown so a bad token can't hammer the issuer).
155
- let jwks;
156
- async function keySet() {
157
- if (!jwks) {
158
- const uri = (await oidcConfig()).serverMetadata().jwks_uri;
159
- if (!uri)
160
- throw new BexioOAuthError("idp_error", "issuer publishes no jwks_uri");
161
- jwks = createRemoteJWKSet(new URL(uri), {
162
- timeoutDuration: HTTP_TIMEOUT_SECONDS * 1000,
163
- [joseCustomFetch]: fetchImpl,
164
- });
165
- }
166
- return jwks;
167
- }
168
- // ---- Grants ----------------------------------------------------------------
169
- async function resolveGrant(selector) {
170
- if (!selector?.ownerId)
171
- throw new BexioOAuthError("invalid_config", "ownerId is required");
172
- if (selector.grantId !== undefined &&
173
- !/^[0-9a-f-]{36}$/i.test(selector.grantId)) {
174
- throw new BexioOAuthError("not_connected", "no grant with that id");
175
- }
176
- const rows = await store.findGrants(selector);
177
- if (rows.length === 0)
178
- throw new BexioOAuthError("not_connected", "owner has no matching grant");
179
- if (rows.length > 1) {
180
- throw new BexioOAuthError("ambiguous_grant", "owner has several grants; pass companyId or grantId");
181
- }
182
- return rows[0];
183
- }
184
- async function saveTokens(input) {
185
- const identity = mapClaims(input.claims);
186
- const binding = grantBinding(input.ownerId, identity.companyId, identity.userId);
187
- const role = (await fetchRole(fetchImpl, apiBaseUrl, input.accessToken)) ?? "member";
188
- return store.upsertGrant({
189
- ownerId: input.ownerId,
190
- ...identity,
191
- role,
192
- accessToken: await cipher.encrypt(input.accessToken, binding),
193
- refreshToken: input.refreshToken
194
- ? await cipher.encrypt(input.refreshToken, binding)
195
- : null,
196
- accessExpiresAt: input.accessExpiresAt,
197
- refreshExpiresAt: input.refreshExpiresAt,
198
- scope: input.scope,
199
- now: clock(),
200
- });
201
- }
202
- /** Claims from userinfo, only when the tokens lack the company claims. */
203
- async function withUserInfo(claims, accessToken) {
204
- if (claims.company_id !== undefined)
205
- return claims;
206
- try {
207
- const info = await oidc.fetchUserInfo(await oidcConfig(), accessToken, typeof claims.sub === "string" ? claims.sub : oidc.skipSubjectCheck);
208
- return { ...info, ...claims };
209
- }
210
- catch {
211
- return claims;
212
- }
213
- }
214
- function decryptFor(record, value) {
215
- return cipher.decrypt(value, grantBinding(record.ownerId, record.companyId, record.userId));
216
- }
217
- /**
218
- * Refreshes a grant whose lock the caller holds (`lockToken`). Every write
219
- * is fenced by the token: if the lock was lost (expired and taken over, or
220
- * the grant was reconnected meanwhile), the write changes nothing and this
221
- * returns `null`: the caller re-reads the grant.
222
- */
223
- async function refreshLocked(record, lockToken) {
224
- if (!record.refreshToken) {
225
- await store.markReauthRequired(record.id, lockToken, clock());
226
- throw new BexioOAuthError("reauth_required", "grant has no refresh token; reconnect required");
227
- }
228
- let refreshToken;
229
- try {
230
- refreshToken = await decryptFor(record, record.refreshToken);
231
- }
232
- catch (error) {
233
- // Usually a keyring mistake (a key removed too early): don't burn the
234
- // grant, so restoring the key fixes it.
235
- await store.releaseRefreshLock(record.id, lockToken);
236
- throw error;
237
- }
238
- let tokens;
239
- try {
240
- tokens = await oidc.refreshTokenGrant(await oidcConfig(), refreshToken);
241
- }
242
- catch (error) {
243
- if (error instanceof oidc.ResponseBodyError &&
244
- error.error === "invalid_grant") {
245
- await store.markReauthRequired(record.id, lockToken, clock());
246
- throw new BexioOAuthError("reauth_required", "Bexio rejected the refresh token; reconnect required", { cause: describe(error) });
247
- }
248
- await store.releaseRefreshLock(record.id, lockToken);
249
- if (isBexioOAuthError(error))
250
- throw error;
251
- throw new BexioOAuthError("idp_error", "token refresh failed", {
252
- cause: describe(error),
253
- });
254
- }
255
- const now = clock();
256
- const binding = grantBinding(record.ownerId, record.companyId, record.userId);
257
- const expiresIn = tokens.expiresIn();
258
- const refreshExpiresIn = Number(tokens.refresh_expires_in);
259
- const saved = await store.saveRefreshed(record.id, lockToken, {
260
- accessToken: await cipher.encrypt(tokens.access_token, binding),
261
- refreshToken: tokens.refresh_token
262
- ? await cipher.encrypt(tokens.refresh_token, binding)
263
- : null,
264
- accessExpiresAt: expiresIn === undefined
265
- ? null
266
- : new Date(now.getTime() + expiresIn * 1000),
267
- refreshExpiresAt: Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
268
- ? new Date(now.getTime() + refreshExpiresIn * 1000)
269
- : null,
270
- scope: tokens.scope ?? null,
271
- role: await fetchRole(fetchImpl, apiBaseUrl, tokens.access_token),
272
- now,
273
- });
274
- // null: lock lost while refreshing (someone reconnected, or our lock
275
- // expired and another caller took over). Their write wins.
276
- return saved;
277
- }
278
- function isFresh(record, now) {
279
- return (record.accessExpiresAt !== null &&
280
- record.accessExpiresAt.getTime() - EXPIRY_SKEW_MS > now.getTime());
281
- }
282
- async function issue(record) {
283
- return {
284
- accessToken: await decryptFor(record, record.accessToken),
285
- expiresAt: record.accessExpiresAt,
286
- grant: toGrant(record),
287
- };
288
- }
289
- function lockWindow(now) {
290
- return {
291
- now,
292
- lockUntil: new Date(now.getTime() + lockTtlMs),
293
- lockToken: randomUUID(),
294
- };
295
- }
296
- // ---- Public API ------------------------------------------------------------
297
- const api = {
298
- /**
299
- * Starts the Connect flow: redirects to Bexio and sets the encrypted flow
300
- * cookie. Derive `ownerId` from your authenticated session.
301
- */
302
- async start(_request, options) {
303
- if (!options?.ownerId)
304
- throw new BexioOAuthError("invalid_config", "ownerId is required");
305
- const returnTo = safeReturnTo(options.returnTo, defaultReturnTo);
306
- const c = await oidcConfig();
307
- const verifier = oidc.randomPKCECodeVerifier();
308
- const state = oidc.randomState();
309
- const nonce = oidc.randomNonce();
310
- const url = oidc.buildAuthorizationUrl(c, {
311
- redirect_uri: redirectUri.href,
312
- scope,
313
- code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
314
- code_challenge_method: "S256",
315
- state,
316
- nonce,
317
- });
318
- const sealed = await cipher.seal({
319
- s: state,
320
- v: verifier,
321
- n: nonce,
322
- o: options.ownerId,
323
- r: returnTo,
324
- }, FLOW_TTL_SECONDS, clock());
325
- const headers = new Headers({
326
- Location: url.href,
327
- "Cache-Control": "no-store",
328
- });
329
- headers.append("Set-Cookie", flowCookie(sealed, FLOW_TTL_SECONDS));
330
- return new Response(null, { status: 302, headers });
331
- },
332
- /**
333
- * Handles the redirect back from Bexio and saves the grant. Throws a
334
- * `BexioOAuthError` on failure; use `callback` for a ready-made redirect.
335
- */
336
- async handleCallback(request, options) {
337
- if (!options ||
338
- ("ownerId" in options
339
- ? !options.ownerId
340
- : typeof options.authorizeOwner !== "function")) {
341
- throw new BexioOAuthError("invalid_config", "pass ownerId or authorizeOwner");
342
- }
343
- const sealed = readCookie(request, FLOW_COOKIE);
344
- const flow = sealed
345
- ? (await cipher.unseal(sealed, clock()))
346
- : null;
347
- const params = new URL(request.url).searchParams;
348
- if (!flow || params.get("state") !== flow.s) {
349
- throw new BexioOAuthError("state_mismatch", "flow cookie missing or expired, or state mismatch");
350
- }
351
- const ownerAllowed = "ownerId" in options
352
- ? flow.o === options.ownerId
353
- : (await options.authorizeOwner(flow.o)) === true;
354
- if (!ownerAllowed) {
355
- throw new BexioOAuthError("owner_mismatch", "the signed-in user may not connect Bexio for the owner used at start");
356
- }
357
- const error = params.get("error");
358
- if (error) {
359
- throw new BexioOAuthError(error === "access_denied" ? "access_denied" : "idp_error", `Bexio returned ${error}`);
360
- }
361
- // Rebuild the URL on the registered redirect URI, so a proxy that rewrites
362
- // the host doesn't change the redirect_uri sent to the token endpoint.
363
- const currentUrl = new URL(redirectUri.href);
364
- currentUrl.search = params.toString();
365
- const c = await oidcConfig();
366
- let tokens;
367
- try {
368
- tokens = await oidc.authorizationCodeGrant(c, currentUrl, {
369
- pkceCodeVerifier: flow.v,
370
- expectedState: flow.s,
371
- expectedNonce: flow.n,
372
- idTokenExpected: true,
373
- });
374
- }
375
- catch (cause) {
376
- if (cause instanceof oidc.AuthorizationResponseError) {
377
- throw new BexioOAuthError(cause.error === "access_denied" ? "access_denied" : "idp_error", `Bexio returned ${cause.error}`, { cause: describe(cause) });
378
- }
379
- if (cause instanceof TypeError) {
380
- throw new BexioOAuthError("idp_error", "Bexio token endpoint unreachable", { cause: describe(cause) });
381
- }
382
- throw new BexioOAuthError("invalid_callback", "authorization code exchange or ID token validation failed", { cause: describe(cause) });
383
- }
384
- const now = clock();
385
- const expiresIn = tokens.expiresIn();
386
- const refreshExpiresIn = Number(tokens.refresh_expires_in);
387
- const claims = await withUserInfo({ ...decodeIfJwt(tokens.access_token), ...(tokens.claims() ?? {}) }, tokens.access_token);
388
- const record = await saveTokens({
389
- ownerId: flow.o,
390
- claims,
391
- accessToken: tokens.access_token,
392
- refreshToken: tokens.refresh_token ?? null,
393
- accessExpiresAt: expiresIn === undefined
394
- ? null
395
- : new Date(now.getTime() + expiresIn * 1000),
396
- refreshExpiresAt: Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
397
- ? new Date(now.getTime() + refreshExpiresIn * 1000)
398
- : null,
399
- scope: tokens.scope ?? null,
400
- });
401
- const headers = new Headers();
402
- headers.append("Set-Cookie", flowCookie("", 0));
403
- return { grant: toGrant(record), returnTo: flow.r, headers };
404
- },
405
- /**
406
- * Handles the redirect back from Bexio, saves the grant, and redirects to
407
- * `returnTo` (or `returnTo?bexio_error=<code>` on failure).
408
- */
409
- async callback(request, options) {
410
- const headers = new Headers({ "Cache-Control": "no-store" });
411
- headers.append("Set-Cookie", flowCookie("", 0));
412
- let location;
413
- try {
414
- const result = await api.handleCallback(request, options);
415
- location = new URL(result.returnTo, redirectUri.origin);
416
- }
417
- catch (error) {
418
- if (!isBexioOAuthError(error) || error.code === "invalid_config")
419
- throw error;
420
- const sealed = readCookie(request, FLOW_COOKIE);
421
- const flow = sealed
422
- ? (await cipher.unseal(sealed, clock()))
423
- : null;
424
- location = new URL(flow?.r ?? defaultReturnTo, redirectUri.origin);
425
- location.searchParams.set("bexio_error", error.code);
426
- }
427
- headers.set("Location", location.href);
428
- return new Response(null, { status: 302, headers });
429
- },
430
- /**
431
- * Saves tokens obtained by your auth framework ("Sign in with Bexio").
432
- * Verifies the tokens' signatures against Bexio's keys first.
433
- */
434
- async saveGrant(input) {
435
- if (!input?.ownerId)
436
- throw new BexioOAuthError("invalid_config", "ownerId is required");
437
- const { tokens } = input;
438
- if (!tokens?.accessToken)
439
- throw new BexioOAuthError("invalid_config", "tokens.accessToken is required");
440
- const c = await oidcConfig();
441
- const expectedIssuer = c.serverMetadata().issuer;
442
- const verify = async (token, audience) => {
443
- const keys = await keySet();
444
- try {
445
- const { payload } = await jwtVerify(token, keys, {
446
- issuer: expectedIssuer,
447
- audience,
448
- algorithms: SIGNING_ALGORITHMS,
449
- currentDate: clock(),
450
- });
451
- return payload;
452
- }
453
- catch (error) {
454
- // Not getting Bexio's keys is an outage, not a bad token.
455
- // (jose reports a bad JWKS response, e.g. a 503, as its generic error.)
456
- if (error instanceof joseErrors.JWKSTimeout ||
457
- error instanceof TypeError ||
458
- (error instanceof joseErrors.JOSEError &&
459
- error.code === "ERR_JOSE_GENERIC")) {
460
- throw new BexioOAuthError("idp_error", "could not load Bexio's signing keys", { cause: describe(error) });
461
- }
462
- throw new BexioOAuthError("invalid_callback", "token signature, issuer or expiry check failed", { cause: describe(error) });
463
- }
464
- };
465
- const accessClaims = await verify(tokens.accessToken);
466
- // Must be an access token issued to this app: rejects tokens issued to
467
- // another client (token substitution) and ID tokens passed as access tokens.
468
- if (accessClaims.azp !== config.clientId) {
469
- throw new BexioOAuthError("invalid_callback", "access token was not issued to this client");
470
- }
471
- if (accessClaims.typ !== undefined && accessClaims.typ !== "Bearer") {
472
- throw new BexioOAuthError("invalid_callback", "tokens.accessToken is not an access token");
473
- }
474
- const idClaims = tokens.idToken
475
- ? await verify(tokens.idToken, config.clientId)
476
- : {};
477
- if (tokens.idToken && idClaims.sub !== accessClaims.sub) {
478
- throw new BexioOAuthError("invalid_callback", "ID token and access token belong to different users");
479
- }
480
- const claims = await withUserInfo({ ...accessClaims, ...idClaims }, tokens.accessToken);
481
- const exp = typeof accessClaims.exp === "number"
482
- ? new Date(accessClaims.exp * 1000)
483
- : null;
484
- const record = await saveTokens({
485
- ownerId: input.ownerId,
486
- claims,
487
- accessToken: tokens.accessToken,
488
- refreshToken: tokens.refreshToken ?? null,
489
- accessExpiresAt: tokens.expiresAt ?? exp,
490
- refreshExpiresAt: null,
491
- scope: tokens.scope ??
492
- (typeof accessClaims.scope === "string" ? accessClaims.scope : null),
493
- });
494
- return toGrant(record);
495
- },
496
- /**
497
- * A valid access token for the owner's grant, refreshed when needed.
498
- * Safe to call from background jobs and from many instances at once.
499
- */
500
- async getAccessToken(selector) {
501
- let record = await resolveGrant(selector);
502
- const deadline = clock().getTime() + lockTtlMs + 5_000;
503
- let wait = 50;
504
- for (;;) {
505
- if (record.status === "reauth_required") {
506
- throw new BexioOAuthError("reauth_required", "grant needs to be reconnected");
507
- }
508
- const now = clock();
509
- if (isFresh(record, now))
510
- return issue(record);
511
- // Discovery outside the lock, so a cold start doesn't eat into it.
512
- await oidcConfig();
513
- const window = lockWindow(clock());
514
- const locked = await store.acquireRefreshLock({
515
- id: record.id,
516
- ...window,
517
- accessExpiresBefore: new Date(now.getTime() + EXPIRY_SKEW_MS),
518
- });
519
- if (locked) {
520
- const refreshed = await refreshLocked(locked, window.lockToken);
521
- if (refreshed)
522
- return issue(refreshed);
523
- // Lost the lock to a reconnect or another refresher: look at their
524
- // result before anything else (it may be fresh, or reauth_required).
525
- const current = await store.getGrant(record.id);
526
- if (!current)
527
- throw new BexioOAuthError("not_connected", "grant was deleted");
528
- record = current;
529
- continue;
530
- }
531
- // Someone else is refreshing (or just did): wait for their result.
532
- if (clock().getTime() > deadline) {
533
- throw new BexioOAuthError("idp_error", "timed out waiting for a concurrent token refresh");
534
- }
535
- await sleep(wait);
536
- wait = Math.min(wait * 2, 1_000);
537
- const next = await store.getGrant(record.id);
538
- if (!next)
539
- throw new BexioOAuthError("not_connected", "grant was deleted");
540
- record = next;
541
- }
542
- },
543
- /** The owner's grants, without token material. */
544
- async listGrants(query) {
545
- if (!query?.ownerId)
546
- throw new BexioOAuthError("invalid_config", "ownerId is required");
547
- return (await store.findGrants({ ownerId: query.ownerId })).map(toGrant);
548
- },
549
- /** Revokes the refresh token at Bexio (best effort) and deletes the grant. */
550
- async disconnect(selector) {
551
- const record = await resolveGrant(selector);
552
- try {
553
- const c = await oidcConfig();
554
- if (record.refreshToken && c.serverMetadata().revocation_endpoint) {
555
- await oidc.tokenRevocation(c, await decryptFor(record, record.refreshToken), {
556
- token_type_hint: "refresh_token",
557
- });
558
- }
559
- }
560
- catch {
561
- // Revocation is best effort; the grant is deleted either way.
562
- }
563
- await store.deleteGrant(record.id);
564
- },
565
- /**
566
- * Refreshes active grants not refreshed for `idleFor`, so Bexio doesn't end
567
- * their offline sessions. Run it on a schedule.
568
- */
569
- async refreshIdle(options) {
570
- const idleMs = toMs(options?.idleFor, "idleFor");
571
- const result = {
572
- refreshed: 0,
573
- skipped: 0,
574
- reauthRequired: 0,
575
- failed: 0,
576
- };
577
- const refreshedBefore = new Date(clock().getTime() - idleMs);
578
- const idle = await store.listIdleGrants({
579
- refreshedBefore,
580
- limit: options.limit ?? 500,
581
- });
582
- if (idle.length === 0)
583
- return result;
584
- try {
585
- await oidcConfig();
586
- }
587
- catch {
588
- // Bexio unreachable: report, don't throw; the next run retries.
589
- result.failed = idle.length;
590
- return result;
591
- }
592
- for (const grant of idle) {
593
- const window = lockWindow(clock());
594
- const locked = await store.acquireRefreshLock({
595
- id: grant.id,
596
- ...window,
597
- refreshedBefore,
598
- });
599
- if (!locked) {
600
- result.skipped++;
601
- continue;
602
- }
603
- try {
604
- if (await refreshLocked(locked, window.lockToken))
605
- result.refreshed++;
606
- else
607
- result.skipped++;
608
- }
609
- catch (error) {
610
- if (isBexioOAuthError(error, "reauth_required")) {
611
- result.reauthRequired++;
612
- }
613
- else {
614
- result.failed++;
615
- }
616
- }
617
- }
618
- return result;
619
- },
12
+ const ctx = resolveConfig(config);
13
+ return {
14
+ start: (options) => start(ctx, options),
15
+ callback: (request, options) => callback(ctx, request, options),
16
+ handleCallback: (request, options) => handleCallback(ctx, request, options),
17
+ saveGrant: (input) => saveGrant(ctx, input),
18
+ getAccessToken: (selector) => getAccessToken(ctx, selector),
19
+ listGrants: (query) => listGrants(ctx.store, query),
20
+ disconnect: (selector) => disconnect(ctx, selector),
21
+ refreshIdle: (options) => refreshIdle(ctx, options),
620
22
  };
621
- return api;
622
- function flowCookie(value, maxAge) {
623
- const parts = [
624
- `${FLOW_COOKIE}=${value}`,
625
- `Path=${redirectUri.pathname}`,
626
- `Max-Age=${maxAge}`,
627
- "HttpOnly",
628
- "SameSite=Lax",
629
- ];
630
- if (redirectUri.protocol === "https:")
631
- parts.push("Secure");
632
- return parts.join("; ");
633
- }
634
23
  }
635
24
  //# sourceMappingURL=client.js.map