@soulcode/bexio-oauth 0.1.0 → 0.3.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/README.md +9 -5
- package/dist/claims.d.ts +3 -3
- package/dist/claims.d.ts.map +1 -1
- package/dist/claims.js +5 -5
- package/dist/claims.js.map +1 -1
- package/dist/client.d.ts +4 -137
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +18 -629
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +57 -0
- package/dist/config.js.map +1 -0
- package/dist/connect-flow.d.ts +15 -0
- package/dist/connect-flow.d.ts.map +1 -0
- package/dist/connect-flow.js +123 -0
- package/dist/connect-flow.js.map +1 -0
- package/dist/crypto.d.ts +2 -2
- package/dist/crypto.d.ts.map +1 -1
- package/dist/crypto.js +20 -15
- package/dist/crypto.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/external-tokens.d.ts +8 -0
- package/dist/external-tokens.d.ts.map +1 -0
- package/dist/external-tokens.js +73 -0
- package/dist/external-tokens.js.map +1 -0
- package/dist/flow-cookie.d.ts +23 -0
- package/dist/flow-cookie.d.ts.map +1 -0
- package/dist/flow-cookie.js +44 -0
- package/dist/flow-cookie.js.map +1 -0
- package/dist/grants.d.ts +45 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +143 -0
- package/dist/grants.js.map +1 -0
- package/dist/idle-sweep.d.ts +11 -0
- package/dist/idle-sweep.d.ts.map +1 -0
- package/dist/idle-sweep.js +70 -0
- package/dist/idle-sweep.js.map +1 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/migrations.generated.d.ts +3 -0
- package/dist/migrations.generated.d.ts.map +1 -0
- package/dist/migrations.generated.js +56 -0
- package/dist/migrations.generated.js.map +1 -0
- package/dist/oidc.d.ts +30 -0
- package/dist/oidc.d.ts.map +1 -0
- package/dist/oidc.js +67 -0
- package/dist/oidc.js.map +1 -0
- package/dist/refresh.d.ts +30 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +147 -0
- package/dist/refresh.js.map +1 -0
- package/dist/return-to.d.ts +8 -0
- package/dist/return-to.d.ts.map +1 -0
- package/dist/return-to.js +31 -0
- package/dist/return-to.js.map +1 -0
- package/dist/sql.d.ts +6 -1
- package/dist/sql.d.ts.map +1 -1
- package/dist/sql.js +8 -12
- package/dist/sql.js.map +1 -1
- package/dist/store/memory.d.ts.map +1 -1
- package/dist/store/memory.js +82 -46
- package/dist/store/memory.js.map +1 -1
- package/dist/store/postgres.d.ts +4 -4
- package/dist/store/postgres.d.ts.map +1 -1
- package/dist/store/postgres.js +137 -59
- package/dist/store/postgres.js.map +1 -1
- package/dist/store/types.d.ts +61 -21
- package/dist/store/types.d.ts.map +1 -1
- package/dist/types.d.ts +164 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +3 -1
- package/sql/0001_bexio_oauth.sql +48 -0
- package/sql/0001_bexio_grants.sql +0 -32
package/dist/client.js
CHANGED
|
@@ -1,635 +1,24 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|