@soulcode/bexio-oauth 0.0.0-stage → 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.
- package/LICENSE +21 -0
- package/README.md +66 -2
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +4 -0
- package/dist/bin.js.map +1 -0
- package/dist/claims.d.ts +31 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +58 -0
- package/dist/claims.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +44 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.d.ts +7 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +24 -0
- package/dist/client.js.map +1 -0
- 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 +27 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +106 -0
- package/dist/crypto.js.map +1 -0
- package/dist/duration.d.ts +4 -0
- package/dist/duration.d.ts.map +1 -0
- package/dist/duration.js +15 -0
- package/dist/duration.js.map +1 -0
- package/dist/errors.d.ts +38 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -0
- 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 +36 -0
- package/dist/grants.d.ts.map +1 -0
- package/dist/grants.js +115 -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 +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.generated.d.ts +3 -0
- package/dist/migrations.generated.d.ts.map +1 -0
- package/dist/migrations.generated.js +40 -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/permissions.d.ts +9 -0
- package/dist/permissions.d.ts.map +1 -0
- package/dist/permissions.js +29 -0
- package/dist/permissions.js.map +1 -0
- package/dist/refresh.d.ts +30 -0
- package/dist/refresh.d.ts.map +1 -0
- package/dist/refresh.js +139 -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 +14 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +11 -0
- package/dist/sql.js.map +1 -0
- package/dist/store/memory.d.ts +7 -0
- package/dist/store/memory.d.ts.map +1 -0
- package/dist/store/memory.js +104 -0
- package/dist/store/memory.js.map +1 -0
- package/dist/store/postgres.d.ts +17 -0
- package/dist/store/postgres.d.ts.map +1 -0
- package/dist/store/postgres.js +177 -0
- package/dist/store/postgres.js.map +1 -0
- package/dist/store/types.d.ts +105 -0
- package/dist/store/types.d.ts.map +1 -0
- package/dist/store/types.js +2 -0
- package/dist/store/types.js.map +1 -0
- package/dist/types.d.ts +150 -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 +60 -4
- package/sql/0001_bexio_grants.sql +32 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { createBexioOAuth } from "./client.js";
|
|
2
|
+
export type { AccessToken, BexioOAuth, BexioOAuthConfig, CallbackOptions, CallbackResult, ExternalTokens, Grant, GrantSelector, RefreshIdleResult, } from "./types.js";
|
|
3
|
+
export { BASE_SCOPES, BEXIO_API_BASE_URL, BEXIO_ISSUER, mapClaims, type BexioIdentity, } from "./claims.js";
|
|
4
|
+
export { generateKey, type Keyring } from "./crypto.js";
|
|
5
|
+
export type { Duration } from "./duration.js";
|
|
6
|
+
export { BexioOAuthError, isBexioOAuthError, type BexioOAuthErrorCode, } from "./errors.js";
|
|
7
|
+
export { roleFromPermissions, type Role } from "./permissions.js";
|
|
8
|
+
export { migrations, type Migration } from "./sql.js";
|
|
9
|
+
export { memoryStore } from "./store/memory.js";
|
|
10
|
+
export { postgresStore, type SqlClient } from "./store/postgres.js";
|
|
11
|
+
export type { GrantRecord, GrantRefresh, GrantStatus, GrantStore, GrantUpsert, LockRequest, } from "./store/types.js";
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAC/C,YAAY,EACV,WAAW,EACX,UAAU,EACV,gBAAgB,EAChB,eAAe,EACf,cAAc,EACd,cAAc,EACd,KAAK,EACL,aAAa,EACb,iBAAiB,GAClB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,YAAY,EACZ,SAAS,EACT,KAAK,aAAa,GACnB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAE,KAAK,OAAO,EAAE,MAAM,aAAa,CAAC;AACxD,YAAY,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAC9C,OAAO,EACL,eAAe,EACf,iBAAiB,EACjB,KAAK,mBAAmB,GACzB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,mBAAmB,EAAE,KAAK,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAE,KAAK,SAAS,EAAE,MAAM,UAAU,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAE,KAAK,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACpE,YAAY,EACV,WAAW,EACX,YAAY,EACZ,WAAW,EACX,UAAU,EACV,WAAW,EACX,WAAW,GACZ,MAAM,kBAAkB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { createBexioOAuth } from "./client.js";
|
|
2
|
+
export { BASE_SCOPES, BEXIO_API_BASE_URL, BEXIO_ISSUER, mapClaims, } from "./claims.js";
|
|
3
|
+
export { generateKey } from "./crypto.js";
|
|
4
|
+
export { BexioOAuthError, isBexioOAuthError, } from "./errors.js";
|
|
5
|
+
export { roleFromPermissions } from "./permissions.js";
|
|
6
|
+
export { migrations } from "./sql.js";
|
|
7
|
+
export { memoryStore } from "./store/memory.js";
|
|
8
|
+
export { postgresStore } from "./store/postgres.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAY/C,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,YAAY,EACZ,SAAS,GAEV,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,WAAW,EAAgB,MAAM,aAAa,CAAC;AAExD,OAAO,EACL,eAAe,EACf,iBAAiB,GAElB,MAAM,aAAa,CAAC;AACrB,OAAO,EAAE,mBAAmB,EAAa,MAAM,kBAAkB,CAAC;AAClE,OAAO,EAAE,UAAU,EAAkB,MAAM,UAAU,CAAC;AACtD,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,aAAa,EAAkB,MAAM,qBAAqB,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.generated.d.ts","sourceRoot":"","sources":["../src/migrations.generated.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAC;AAE1C,eAAO,MAAM,UAAU,EAAE,SAAS,SAAS,EAsC1C,CAAC"}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const MIGRATIONS = [
|
|
2
|
+
{
|
|
3
|
+
number: 1,
|
|
4
|
+
name: "0001_bexio_grants.sql",
|
|
5
|
+
sql: `-- @soulcode/bexio-oauth 0001: the grants table.
|
|
6
|
+
-- One row per (owner, Bexio company, Bexio user). Token columns hold JWE
|
|
7
|
+
-- ciphertext; only your server-side database role should be able to read them.
|
|
8
|
+
|
|
9
|
+
CREATE TABLE IF NOT EXISTS bexio_grants (
|
|
10
|
+
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
11
|
+
owner_id text NOT NULL,
|
|
12
|
+
bexio_company_id text NOT NULL,
|
|
13
|
+
bexio_user_id text NOT NULL,
|
|
14
|
+
bexio_company_user_id integer,
|
|
15
|
+
company_name text,
|
|
16
|
+
user_name text,
|
|
17
|
+
user_email text,
|
|
18
|
+
role text NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
|
|
19
|
+
status text NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'reauth_required')),
|
|
20
|
+
access_token text NOT NULL,
|
|
21
|
+
refresh_token text,
|
|
22
|
+
access_expires_at timestamptz,
|
|
23
|
+
refresh_expires_at timestamptz,
|
|
24
|
+
scope text,
|
|
25
|
+
refresh_locked_until timestamptz,
|
|
26
|
+
refresh_lock_token text,
|
|
27
|
+
last_refreshed_at timestamptz NOT NULL DEFAULT now(),
|
|
28
|
+
created_at timestamptz NOT NULL DEFAULT now(),
|
|
29
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
30
|
+
CONSTRAINT bexio_grants_owner_company_user_key UNIQUE (owner_id, bexio_company_id, bexio_user_id)
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
-- refreshIdle(): active grants by last refresh.
|
|
34
|
+
CREATE INDEX IF NOT EXISTS bexio_grants_idle_idx
|
|
35
|
+
ON bexio_grants (last_refreshed_at)
|
|
36
|
+
WHERE status = 'active';
|
|
37
|
+
`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
//# sourceMappingURL=migrations.generated.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"migrations.generated.js","sourceRoot":"","sources":["../src/migrations.generated.ts"],"names":[],"mappings":"AAIA,MAAM,CAAC,MAAM,UAAU,GAAyB;IAC9C;QACE,MAAM,EAAE,CAAC;QACT,IAAI,EAAE,uBAAuB;QAC7B,GAAG,EAAE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAgCR;KACE;CACF,CAAC"}
|
package/dist/oidc.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import { createRemoteJWKSet } from "jose";
|
|
3
|
+
/** Per HTTP request to Bexio. Kept well below the default refresh lock TTL. */
|
|
4
|
+
export declare const HTTP_TIMEOUT_SECONDS = 10;
|
|
5
|
+
/**
|
|
6
|
+
* A safe summary of an upstream error for `BexioOAuthError.cause`: the OAuth
|
|
7
|
+
* error code and HTTP status, never response bodies or token values.
|
|
8
|
+
*/
|
|
9
|
+
export declare function describe(error: unknown): {
|
|
10
|
+
name: string;
|
|
11
|
+
error?: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
};
|
|
14
|
+
export type KeySet = ReturnType<typeof createRemoteJWKSet>;
|
|
15
|
+
/** Bexio's OIDC metadata and signing keys, loaded lazily. */
|
|
16
|
+
export interface Oidc {
|
|
17
|
+
/** The discovered configuration; a failed discovery is retried on the next call. */
|
|
18
|
+
config(): Promise<oidc.Configuration>;
|
|
19
|
+
/** Bexio's signing keys (cached, refetched on an unknown key id). */
|
|
20
|
+
keySet(): Promise<KeySet>;
|
|
21
|
+
}
|
|
22
|
+
export declare function createOidc(options: {
|
|
23
|
+
issuer: string;
|
|
24
|
+
clientId: string;
|
|
25
|
+
clientSecret: string;
|
|
26
|
+
/** Allow plain http (local mocks only). */
|
|
27
|
+
allowInsecure: boolean;
|
|
28
|
+
fetchImpl: typeof fetch;
|
|
29
|
+
}): Oidc;
|
|
30
|
+
//# sourceMappingURL=oidc.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oidc.d.ts","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AACtC,OAAO,EAAE,kBAAkB,EAAkC,MAAM,MAAM,CAAC;AAG1E,+EAA+E;AAC/E,eAAO,MAAM,oBAAoB,KAAK,CAAC;AAEvC;;;GAGG;AACH,wBAAgB,QAAQ,CAAC,KAAK,EAAE,OAAO,GAAG;IACxC,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB,CAeA;AAED,MAAM,MAAM,MAAM,GAAG,UAAU,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE3D,6DAA6D;AAC7D,MAAM,WAAW,IAAI;IACnB,oFAAoF;IACpF,MAAM,IAAI,OAAO,CAAC,IAAI,CAAC,aAAa,CAAC,CAAC;IACtC,qEAAqE;IACrE,MAAM,IAAI,OAAO,CAAC,MAAM,CAAC,CAAC;CAC3B;AAED,wBAAgB,UAAU,CAAC,OAAO,EAAE;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,2CAA2C;IAC3C,aAAa,EAAE,OAAO,CAAC;IACvB,SAAS,EAAE,OAAO,KAAK,CAAC;CACzB,GAAG,IAAI,CA+CP"}
|
package/dist/oidc.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import { createRemoteJWKSet, customFetch as joseCustomFetch } from "jose";
|
|
3
|
+
import { BexioOAuthError } from "./errors.js";
|
|
4
|
+
/** Per HTTP request to Bexio. Kept well below the default refresh lock TTL. */
|
|
5
|
+
export const HTTP_TIMEOUT_SECONDS = 10;
|
|
6
|
+
/**
|
|
7
|
+
* A safe summary of an upstream error for `BexioOAuthError.cause`: the OAuth
|
|
8
|
+
* error code and HTTP status, never response bodies or token values.
|
|
9
|
+
*/
|
|
10
|
+
export function describe(error) {
|
|
11
|
+
if (error instanceof oidc.ResponseBodyError) {
|
|
12
|
+
return { name: error.name, error: error.error, status: error.status };
|
|
13
|
+
}
|
|
14
|
+
if (error instanceof oidc.AuthorizationResponseError) {
|
|
15
|
+
return { name: error.name, error: error.error };
|
|
16
|
+
}
|
|
17
|
+
if (error instanceof Error) {
|
|
18
|
+
const code = error.code;
|
|
19
|
+
return {
|
|
20
|
+
name: error.name,
|
|
21
|
+
...(typeof code === "string" ? { error: code } : {}),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { name: "unknown" };
|
|
25
|
+
}
|
|
26
|
+
export function createOidc(options) {
|
|
27
|
+
const { issuer, clientId, clientSecret, allowInsecure, fetchImpl } = options;
|
|
28
|
+
const issuerUrl = new URL(issuer);
|
|
29
|
+
const customFetch = (url, init) => fetchImpl(url, init);
|
|
30
|
+
let discovered;
|
|
31
|
+
function config() {
|
|
32
|
+
discovered ??= oidc
|
|
33
|
+
.discovery(issuerUrl, clientId, clientSecret, undefined, {
|
|
34
|
+
execute: allowInsecure ? [oidc.allowInsecureRequests] : [],
|
|
35
|
+
// Well below the refresh lock TTL, so a slow IdP can't outlive the lock.
|
|
36
|
+
timeout: HTTP_TIMEOUT_SECONDS,
|
|
37
|
+
[oidc.customFetch]: customFetch,
|
|
38
|
+
})
|
|
39
|
+
.then((c) => {
|
|
40
|
+
c[oidc.customFetch] = customFetch;
|
|
41
|
+
oidc.enableNonRepudiationChecks(c);
|
|
42
|
+
return c;
|
|
43
|
+
})
|
|
44
|
+
.catch((cause) => {
|
|
45
|
+
discovered = undefined;
|
|
46
|
+
throw new BexioOAuthError("idp_error", `OIDC discovery failed for ${issuer}`, { cause: describe(cause) });
|
|
47
|
+
});
|
|
48
|
+
return discovered;
|
|
49
|
+
}
|
|
50
|
+
// Cached, refetched on an unknown key id, with a timeout and a cooldown so a
|
|
51
|
+
// bad token can't hammer the issuer.
|
|
52
|
+
let jwks;
|
|
53
|
+
async function keySet() {
|
|
54
|
+
if (!jwks) {
|
|
55
|
+
const uri = (await config()).serverMetadata().jwks_uri;
|
|
56
|
+
if (!uri)
|
|
57
|
+
throw new BexioOAuthError("idp_error", "issuer publishes no jwks_uri");
|
|
58
|
+
jwks = createRemoteJWKSet(new URL(uri), {
|
|
59
|
+
timeoutDuration: HTTP_TIMEOUT_SECONDS * 1000,
|
|
60
|
+
[joseCustomFetch]: fetchImpl,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return jwks;
|
|
64
|
+
}
|
|
65
|
+
return { config, keySet };
|
|
66
|
+
}
|
|
67
|
+
//# sourceMappingURL=oidc.js.map
|
package/dist/oidc.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"oidc.js","sourceRoot":"","sources":["../src/oidc.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Role = "admin" | "member";
|
|
2
|
+
/** `admin` when Bexio reports `permissions.admin.activation === "enabled"`. */
|
|
3
|
+
export declare function roleFromPermissions(body: unknown): Role;
|
|
4
|
+
/**
|
|
5
|
+
* Reads the user's role from `GET /3.0/permissions`. Best effort: returns
|
|
6
|
+
* `null` on any failure so callers keep the previous role.
|
|
7
|
+
*/
|
|
8
|
+
export declare function fetchRole(fetchImpl: typeof fetch, apiBaseUrl: string, accessToken: string): Promise<Role | null>;
|
|
9
|
+
//# sourceMappingURL=permissions.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permissions.d.ts","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,IAAI,GAAG,OAAO,GAAG,QAAQ,CAAC;AAMtC,+EAA+E;AAC/E,wBAAgB,mBAAmB,CAAC,IAAI,EAAE,OAAO,GAAG,IAAI,CAIvD;AAED;;;GAGG;AACH,wBAAsB,SAAS,CAC7B,SAAS,EAAE,OAAO,KAAK,EACvB,UAAU,EAAE,MAAM,EAClB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,IAAI,GAAG,IAAI,CAAC,CAetB"}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** `admin` when Bexio reports `permissions.admin.activation === "enabled"`. */
|
|
2
|
+
export function roleFromPermissions(body) {
|
|
3
|
+
const activation = body?.permissions?.admin
|
|
4
|
+
?.activation;
|
|
5
|
+
return activation === "enabled" ? "admin" : "member";
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Reads the user's role from `GET /3.0/permissions`. Best effort: returns
|
|
9
|
+
* `null` on any failure so callers keep the previous role.
|
|
10
|
+
*/
|
|
11
|
+
export async function fetchRole(fetchImpl, apiBaseUrl, accessToken) {
|
|
12
|
+
try {
|
|
13
|
+
const res = await fetchImpl(new URL("/3.0/permissions", apiBaseUrl), {
|
|
14
|
+
headers: {
|
|
15
|
+
Authorization: `Bearer ${accessToken}`,
|
|
16
|
+
Accept: "application/json",
|
|
17
|
+
},
|
|
18
|
+
// Runs while the refresh lock is held: never wait long.
|
|
19
|
+
signal: AbortSignal.timeout(5_000),
|
|
20
|
+
});
|
|
21
|
+
if (!res.ok)
|
|
22
|
+
return null;
|
|
23
|
+
return roleFromPermissions(await res.json());
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=permissions.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"permissions.js","sourceRoot":"","sources":["../src/permissions.ts"],"names":[],"mappings":"AAMA,+EAA+E;AAC/E,MAAM,UAAU,mBAAmB,CAAC,IAAa;IAC/C,MAAM,UAAU,GAAI,IAAmC,EAAE,WAAW,EAAE,KAAK;QACzE,EAAE,UAAU,CAAC;IACf,OAAO,UAAU,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,QAAQ,CAAC;AACvD,CAAC;AAED;;;GAGG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAC7B,SAAuB,EACvB,UAAkB,EAClB,WAAmB;IAEnB,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,CAAC,IAAI,GAAG,CAAC,kBAAkB,EAAE,UAAU,CAAC,EAAE;YACnE,OAAO,EAAE;gBACP,aAAa,EAAE,UAAU,WAAW,EAAE;gBACtC,MAAM,EAAE,kBAAkB;aAC3B;YACD,wDAAwD;YACxD,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,KAAK,CAAC;SACnC,CAAC,CAAC;QACH,IAAI,CAAC,GAAG,CAAC,EAAE;YAAE,OAAO,IAAI,CAAC;QACzB,OAAO,mBAAmB,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC;IAC/C,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAC;IACd,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as oidc from "openid-client";
|
|
2
|
+
import type { ClientContext } from "./config.js";
|
|
3
|
+
import type { GrantRecord } from "./store/types.js";
|
|
4
|
+
import type { AccessToken, GrantSelector } from "./types.js";
|
|
5
|
+
/** Refresh a little before the access token really expires. */
|
|
6
|
+
export declare const EXPIRY_SKEW_MS = 30000;
|
|
7
|
+
export type TokenResponse = oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers;
|
|
8
|
+
/** Absolute expiries from a token response's `expires_in` / `refresh_expires_in`. */
|
|
9
|
+
export declare function tokenExpiry(tokens: TokenResponse, now: Date): {
|
|
10
|
+
accessExpiresAt: Date | null;
|
|
11
|
+
refreshExpiresAt: Date | null;
|
|
12
|
+
};
|
|
13
|
+
/** True while the access token is valid for longer than the skew. */
|
|
14
|
+
export declare function isFresh(record: GrantRecord, now: Date): boolean;
|
|
15
|
+
/** A new lock holder's token and lease, starting at `now`. */
|
|
16
|
+
export declare function lockWindow(ctx: ClientContext, now: Date): {
|
|
17
|
+
now: Date;
|
|
18
|
+
lockUntil: Date;
|
|
19
|
+
lockToken: `${string}-${string}-${string}-${string}-${string}`;
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* Refreshes a grant whose lock the caller holds (`lockToken`). Every write
|
|
23
|
+
* is fenced by the token: if the lock was lost (expired and taken over, or
|
|
24
|
+
* the grant was reconnected meanwhile), the write changes nothing and this
|
|
25
|
+
* returns `null`: the caller re-reads the grant.
|
|
26
|
+
*/
|
|
27
|
+
export declare function refreshLocked(ctx: ClientContext, record: GrantRecord, lockToken: string): Promise<GrantRecord | null>;
|
|
28
|
+
/** `BexioOAuth.getAccessToken`. */
|
|
29
|
+
export declare function getAccessToken(ctx: ClientContext, selector: GrantSelector): Promise<AccessToken>;
|
|
30
|
+
//# sourceMappingURL=refresh.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh.d.ts","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AACtC,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAKjD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AACpD,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAE7D,+DAA+D;AAC/D,eAAO,MAAM,cAAc,QAAS,CAAC;AAIrC,MAAM,MAAM,aAAa,GAAG,IAAI,CAAC,qBAAqB,GACpD,IAAI,CAAC,4BAA4B,CAAC;AAEpC,qFAAqF;AACrF,wBAAgB,WAAW,CACzB,MAAM,EAAE,aAAa,EACrB,GAAG,EAAE,IAAI,GACR;IAAE,eAAe,EAAE,IAAI,GAAG,IAAI,CAAC;IAAC,gBAAgB,EAAE,IAAI,GAAG,IAAI,CAAA;CAAE,CAajE;AAED,qEAAqE;AACrE,wBAAgB,OAAO,CAAC,MAAM,EAAE,WAAW,EAAE,GAAG,EAAE,IAAI,GAAG,OAAO,CAK/D;AAED,8DAA8D;AAC9D,wBAAgB,UAAU,CAAC,GAAG,EAAE,aAAa,EAAE,GAAG,EAAE,IAAI;;;;EAMvD;AAED;;;;;GAKG;AACH,wBAAsB,aAAa,CACjC,GAAG,EAAE,aAAa,EAClB,MAAM,EAAE,WAAW,EACnB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CA0D7B;AAID,mCAAmC;AACnC,wBAAsB,cAAc,CAClC,GAAG,EAAE,aAAa,EAClB,QAAQ,EAAE,aAAa,GACtB,OAAO,CAAC,WAAW,CAAC,CAmDtB"}
|
package/dist/refresh.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import * as oidc from "openid-client";
|
|
3
|
+
import { BexioOAuthError, isBexioOAuthError } from "./errors.js";
|
|
4
|
+
import { bindingOf, decryptFor, issue, resolveGrant } from "./grants.js";
|
|
5
|
+
import { describe } from "./oidc.js";
|
|
6
|
+
import { fetchRole } from "./permissions.js";
|
|
7
|
+
/** Refresh a little before the access token really expires. */
|
|
8
|
+
export const EXPIRY_SKEW_MS = 30_000;
|
|
9
|
+
/** Extra time `getAccessToken` waits for a concurrent refresh beyond the lock TTL. */
|
|
10
|
+
const WAIT_GRACE_MS = 5_000;
|
|
11
|
+
/** Absolute expiries from a token response's `expires_in` / `refresh_expires_in`. */
|
|
12
|
+
export function tokenExpiry(tokens, now) {
|
|
13
|
+
const expiresIn = tokens.expiresIn();
|
|
14
|
+
const refreshExpiresIn = Number(tokens.refresh_expires_in);
|
|
15
|
+
return {
|
|
16
|
+
accessExpiresAt: expiresIn === undefined
|
|
17
|
+
? null
|
|
18
|
+
: new Date(now.getTime() + expiresIn * 1000),
|
|
19
|
+
refreshExpiresAt: Number.isFinite(refreshExpiresIn) && refreshExpiresIn > 0
|
|
20
|
+
? new Date(now.getTime() + refreshExpiresIn * 1000)
|
|
21
|
+
: null,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** True while the access token is valid for longer than the skew. */
|
|
25
|
+
export function isFresh(record, now) {
|
|
26
|
+
return (record.accessExpiresAt !== null &&
|
|
27
|
+
record.accessExpiresAt.getTime() - EXPIRY_SKEW_MS > now.getTime());
|
|
28
|
+
}
|
|
29
|
+
/** A new lock holder's token and lease, starting at `now`. */
|
|
30
|
+
export function lockWindow(ctx, now) {
|
|
31
|
+
return {
|
|
32
|
+
now,
|
|
33
|
+
lockUntil: new Date(now.getTime() + ctx.lockTtlMs),
|
|
34
|
+
lockToken: randomUUID(),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Refreshes a grant whose lock the caller holds (`lockToken`). Every write
|
|
39
|
+
* is fenced by the token: if the lock was lost (expired and taken over, or
|
|
40
|
+
* the grant was reconnected meanwhile), the write changes nothing and this
|
|
41
|
+
* returns `null`: the caller re-reads the grant.
|
|
42
|
+
*/
|
|
43
|
+
export async function refreshLocked(ctx, record, lockToken) {
|
|
44
|
+
const { store, cipher, clock } = ctx;
|
|
45
|
+
if (!record.refreshToken) {
|
|
46
|
+
await store.markReauthRequired(record.id, lockToken, clock());
|
|
47
|
+
throw new BexioOAuthError("reauth_required", "grant has no refresh token; reconnect required");
|
|
48
|
+
}
|
|
49
|
+
let refreshToken;
|
|
50
|
+
try {
|
|
51
|
+
refreshToken = await decryptFor(cipher, record, record.refreshToken);
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
// Usually a keyring mistake (a key removed too early): don't burn the
|
|
55
|
+
// grant, so restoring the key fixes it.
|
|
56
|
+
await store.releaseRefreshLock(record.id, lockToken);
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
let tokens;
|
|
60
|
+
try {
|
|
61
|
+
tokens = await oidc.refreshTokenGrant(await ctx.oidc.config(), refreshToken);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error instanceof oidc.ResponseBodyError &&
|
|
65
|
+
error.error === "invalid_grant") {
|
|
66
|
+
await store.markReauthRequired(record.id, lockToken, clock());
|
|
67
|
+
throw new BexioOAuthError("reauth_required", "Bexio rejected the refresh token; reconnect required", { cause: describe(error) });
|
|
68
|
+
}
|
|
69
|
+
await store.releaseRefreshLock(record.id, lockToken);
|
|
70
|
+
if (isBexioOAuthError(error))
|
|
71
|
+
throw error;
|
|
72
|
+
throw new BexioOAuthError("idp_error", "token refresh failed", {
|
|
73
|
+
cause: describe(error),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
const now = clock();
|
|
77
|
+
const binding = bindingOf(record);
|
|
78
|
+
// null: lock lost while refreshing (someone reconnected, or our lock
|
|
79
|
+
// expired and another caller took over). Their write wins.
|
|
80
|
+
return store.saveRefreshed(record.id, lockToken, {
|
|
81
|
+
accessToken: await cipher.encrypt(tokens.access_token, binding),
|
|
82
|
+
refreshToken: tokens.refresh_token
|
|
83
|
+
? await cipher.encrypt(tokens.refresh_token, binding)
|
|
84
|
+
: null,
|
|
85
|
+
...tokenExpiry(tokens, now),
|
|
86
|
+
scope: tokens.scope ?? null,
|
|
87
|
+
role: await fetchRole(ctx.fetchImpl, ctx.apiBaseUrl, tokens.access_token),
|
|
88
|
+
now,
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
92
|
+
/** `BexioOAuth.getAccessToken`. */
|
|
93
|
+
export async function getAccessToken(ctx, selector) {
|
|
94
|
+
const { store, clock } = ctx;
|
|
95
|
+
let record = await resolveGrant(store, selector);
|
|
96
|
+
const deadline = clock().getTime() + ctx.lockTtlMs + WAIT_GRACE_MS;
|
|
97
|
+
let wait = 50;
|
|
98
|
+
const reread = async (id) => {
|
|
99
|
+
const current = await store.getGrant(id);
|
|
100
|
+
if (!current)
|
|
101
|
+
throw new BexioOAuthError("not_connected", "grant was deleted");
|
|
102
|
+
return current;
|
|
103
|
+
};
|
|
104
|
+
for (;;) {
|
|
105
|
+
if (record.status === "reauth_required") {
|
|
106
|
+
throw new BexioOAuthError("reauth_required", "grant needs to be reconnected");
|
|
107
|
+
}
|
|
108
|
+
const now = clock();
|
|
109
|
+
if (isFresh(record, now))
|
|
110
|
+
return issue(ctx.cipher, record);
|
|
111
|
+
// Every iteration that would do more work is bounded, whether the last
|
|
112
|
+
// one waited for another refresher or lost its own lock.
|
|
113
|
+
if (now.getTime() > deadline) {
|
|
114
|
+
throw new BexioOAuthError("idp_error", "timed out waiting for a concurrent token refresh");
|
|
115
|
+
}
|
|
116
|
+
// Discovery outside the lock, so a cold start doesn't eat into it.
|
|
117
|
+
await ctx.oidc.config();
|
|
118
|
+
const window = lockWindow(ctx, clock());
|
|
119
|
+
const locked = await store.acquireRefreshLock({
|
|
120
|
+
id: record.id,
|
|
121
|
+
...window,
|
|
122
|
+
accessExpiresBefore: new Date(now.getTime() + EXPIRY_SKEW_MS),
|
|
123
|
+
});
|
|
124
|
+
if (locked) {
|
|
125
|
+
const refreshed = await refreshLocked(ctx, locked, window.lockToken);
|
|
126
|
+
if (refreshed)
|
|
127
|
+
return issue(ctx.cipher, refreshed);
|
|
128
|
+
// Lost the lock to a reconnect or another refresher: look at their
|
|
129
|
+
// result before anything else (it may be fresh, or reauth_required).
|
|
130
|
+
record = await reread(record.id);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Someone else is refreshing (or just did): wait for their result.
|
|
134
|
+
await sleep(wait);
|
|
135
|
+
wait = Math.min(wait * 2, 1_000);
|
|
136
|
+
record = await reread(record.id);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=refresh.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refresh.js","sourceRoot":"","sources":["../src/refresh.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AACzC,OAAO,KAAK,IAAI,MAAM,eAAe,CAAC;AAEtC,OAAO,EAAE,eAAe,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAC;AACjE,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AACzE,OAAO,EAAE,QAAQ,EAAE,MAAM,WAAW,CAAC;AACrC,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAI7C,+DAA+D;AAC/D,MAAM,CAAC,MAAM,cAAc,GAAG,MAAM,CAAC;AACrC,sFAAsF;AACtF,MAAM,aAAa,GAAG,KAAK,CAAC;AAK5B,qFAAqF;AACrF,MAAM,UAAU,WAAW,CACzB,MAAqB,EACrB,GAAS;IAET,MAAM,SAAS,GAAG,MAAM,CAAC,SAAS,EAAE,CAAC;IACrC,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,CAAC,kBAAkB,CAAC,CAAC;IAC3D,OAAO;QACL,eAAe,EACb,SAAS,KAAK,SAAS;YACrB,CAAC,CAAC,IAAI;YACN,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,SAAS,GAAG,IAAI,CAAC;QAChD,gBAAgB,EACd,MAAM,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,gBAAgB,GAAG,CAAC;YACvD,CAAC,CAAC,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,gBAAgB,GAAG,IAAI,CAAC;YACnD,CAAC,CAAC,IAAI;KACX,CAAC;AACJ,CAAC;AAED,qEAAqE;AACrE,MAAM,UAAU,OAAO,CAAC,MAAmB,EAAE,GAAS;IACpD,OAAO,CACL,MAAM,CAAC,eAAe,KAAK,IAAI;QAC/B,MAAM,CAAC,eAAe,CAAC,OAAO,EAAE,GAAG,cAAc,GAAG,GAAG,CAAC,OAAO,EAAE,CAClE,CAAC;AACJ,CAAC;AAED,8DAA8D;AAC9D,MAAM,UAAU,UAAU,CAAC,GAAkB,EAAE,GAAS;IACtD,OAAO;QACL,GAAG;QACH,SAAS,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,SAAS,CAAC;QAClD,SAAS,EAAE,UAAU,EAAE;KACxB,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CACjC,GAAkB,EAClB,MAAmB,EACnB,SAAiB;IAEjB,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC;IACrC,IAAI,CAAC,MAAM,CAAC,YAAY,EAAE,CAAC;QACzB,MAAM,KAAK,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;QAC9D,MAAM,IAAI,eAAe,CACvB,iBAAiB,EACjB,gDAAgD,CACjD,CAAC;IACJ,CAAC;IACD,IAAI,YAAoB,CAAC;IACzB,IAAI,CAAC;QACH,YAAY,GAAG,MAAM,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,YAAY,CAAC,CAAC;IACvE,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,sEAAsE;QACtE,wCAAwC;QACxC,MAAM,KAAK,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;QACrD,MAAM,KAAK,CAAC;IACd,CAAC;IAED,IAAI,MAAqB,CAAC;IAC1B,IAAI,CAAC;QACH,MAAM,GAAG,MAAM,IAAI,CAAC,iBAAiB,CACnC,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,EACvB,YAAY,CACb,CAAC;IACJ,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,IACE,KAAK,YAAY,IAAI,CAAC,iBAAiB;YACvC,KAAK,CAAC,KAAK,KAAK,eAAe,EAC/B,CAAC;YACD,MAAM,KAAK,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE,KAAK,EAAE,CAAC,CAAC;YAC9D,MAAM,IAAI,eAAe,CACvB,iBAAiB,EACjB,sDAAsD,EACtD,EAAE,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,CAC3B,CAAC;QACJ,CAAC;QACD,MAAM,KAAK,CAAC,kBAAkB,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,CAAC,CAAC;QACrD,IAAI,iBAAiB,CAAC,KAAK,CAAC;YAAE,MAAM,KAAK,CAAC;QAC1C,MAAM,IAAI,eAAe,CAAC,WAAW,EAAE,sBAAsB,EAAE;YAC7D,KAAK,EAAE,QAAQ,CAAC,KAAK,CAAC;SACvB,CAAC,CAAC;IACL,CAAC;IAED,MAAM,GAAG,GAAG,KAAK,EAAE,CAAC;IACpB,MAAM,OAAO,GAAG,SAAS,CAAC,MAAM,CAAC,CAAC;IAClC,qEAAqE;IACrE,2DAA2D;IAC3D,OAAO,KAAK,CAAC,aAAa,CAAC,MAAM,CAAC,EAAE,EAAE,SAAS,EAAE;QAC/C,WAAW,EAAE,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,OAAO,CAAC;QAC/D,YAAY,EAAE,MAAM,CAAC,aAAa;YAChC,CAAC,CAAC,MAAM,MAAM,CAAC,OAAO,CAAC,MAAM,CAAC,aAAa,EAAE,OAAO,CAAC;YACrD,CAAC,CAAC,IAAI;QACR,GAAG,WAAW,CAAC,MAAM,EAAE,GAAG,CAAC;QAC3B,KAAK,EAAE,MAAM,CAAC,KAAK,IAAI,IAAI;QAC3B,IAAI,EAAE,MAAM,SAAS,CAAC,GAAG,CAAC,SAAS,EAAE,GAAG,CAAC,UAAU,EAAE,MAAM,CAAC,YAAY,CAAC;QACzE,GAAG;KACJ,CAAC,CAAC;AACL,CAAC;AAED,MAAM,KAAK,GAAG,CAAC,EAAU,EAAE,EAAE,CAAC,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAEhF,mCAAmC;AACnC,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,GAAkB,EAClB,QAAuB;IAEvB,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,GAAG,GAAG,CAAC;IAC7B,IAAI,MAAM,GAAG,MAAM,YAAY,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;IACjD,MAAM,QAAQ,GAAG,KAAK,EAAE,CAAC,OAAO,EAAE,GAAG,GAAG,CAAC,SAAS,GAAG,aAAa,CAAC;IACnE,IAAI,IAAI,GAAG,EAAE,CAAC;IACd,MAAM,MAAM,GAAG,KAAK,EAAE,EAAU,EAAE,EAAE;QAClC,MAAM,OAAO,GAAG,MAAM,KAAK,CAAC,QAAQ,CAAC,EAAE,CAAC,CAAC;QACzC,IAAI,CAAC,OAAO;YACV,MAAM,IAAI,eAAe,CAAC,eAAe,EAAE,mBAAmB,CAAC,CAAC;QAClE,OAAO,OAAO,CAAC;IACjB,CAAC,CAAC;IACF,SAAS,CAAC;QACR,IAAI,MAAM,CAAC,MAAM,KAAK,iBAAiB,EAAE,CAAC;YACxC,MAAM,IAAI,eAAe,CACvB,iBAAiB,EACjB,+BAA+B,CAChC,CAAC;QACJ,CAAC;QACD,MAAM,GAAG,GAAG,KAAK,EAAE,CAAC;QACpB,IAAI,OAAO,CAAC,MAAM,EAAE,GAAG,CAAC;YAAE,OAAO,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;QAC3D,uEAAuE;QACvE,yDAAyD;QACzD,IAAI,GAAG,CAAC,OAAO,EAAE,GAAG,QAAQ,EAAE,CAAC;YAC7B,MAAM,IAAI,eAAe,CACvB,WAAW,EACX,kDAAkD,CACnD,CAAC;QACJ,CAAC;QAED,mEAAmE;QACnE,MAAM,GAAG,CAAC,IAAI,CAAC,MAAM,EAAE,CAAC;QACxB,MAAM,MAAM,GAAG,UAAU,CAAC,GAAG,EAAE,KAAK,EAAE,CAAC,CAAC;QACxC,MAAM,MAAM,GAAG,MAAM,KAAK,CAAC,kBAAkB,CAAC;YAC5C,EAAE,EAAE,MAAM,CAAC,EAAE;YACb,GAAG,MAAM;YACT,mBAAmB,EAAE,IAAI,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,GAAG,cAAc,CAAC;SAC9D,CAAC,CAAC;QACH,IAAI,MAAM,EAAE,CAAC;YACX,MAAM,SAAS,GAAG,MAAM,aAAa,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,SAAS,CAAC,CAAC;YACrE,IAAI,SAAS;gBAAE,OAAO,KAAK,CAAC,GAAG,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;YACnD,mEAAmE;YACnE,qEAAqE;YACrE,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;YACjC,SAAS;QACX,CAAC;QAED,mEAAmE;QACnE,MAAM,KAAK,CAAC,IAAI,CAAC,CAAC;QAClB,IAAI,GAAG,IAAI,CAAC,GAAG,CAAC,IAAI,GAAG,CAAC,EAAE,KAAK,CAAC,CAAC;QACjC,MAAM,GAAG,MAAM,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;IACnC,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Only same-origin relative paths: blocks open redirects via `returnTo`.
|
|
3
|
+
* Parsed with the URL parser (the same one the redirect will go through), so
|
|
4
|
+
* tricks like `/\t/evil.example` are caught, and normalized to path+query+hash.
|
|
5
|
+
* Returns `fallback` for an empty value; throws `invalid_config` otherwise.
|
|
6
|
+
*/
|
|
7
|
+
export declare function safeReturnTo(value: string | undefined, fallback: string): string;
|
|
8
|
+
//# sourceMappingURL=return-to.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"return-to.d.ts","sourceRoot":"","sources":["../src/return-to.ts"],"names":[],"mappings":"AAIA;;;;;GAKG;AACH,wBAAgB,YAAY,CAC1B,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,QAAQ,EAAE,MAAM,GACf,MAAM,CAqBR"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { BexioOAuthError } from "./errors.js";
|
|
2
|
+
const RETURN_TO_BASE = "https://return-to.invalid";
|
|
3
|
+
/**
|
|
4
|
+
* Only same-origin relative paths: blocks open redirects via `returnTo`.
|
|
5
|
+
* Parsed with the URL parser (the same one the redirect will go through), so
|
|
6
|
+
* tricks like `/\t/evil.example` are caught, and normalized to path+query+hash.
|
|
7
|
+
* Returns `fallback` for an empty value; throws `invalid_config` otherwise.
|
|
8
|
+
*/
|
|
9
|
+
export function safeReturnTo(value, fallback) {
|
|
10
|
+
if (!value)
|
|
11
|
+
return fallback;
|
|
12
|
+
const invalid = () => new BexioOAuthError("invalid_config", "returnTo must be a relative path like /settings");
|
|
13
|
+
// eslint-disable-next-line no-control-regex
|
|
14
|
+
if (!value.startsWith("/") || /[\u0000-\u001f\\]/.test(value))
|
|
15
|
+
throw invalid();
|
|
16
|
+
let url;
|
|
17
|
+
try {
|
|
18
|
+
url = new URL(value, RETURN_TO_BASE);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw invalid();
|
|
22
|
+
}
|
|
23
|
+
if (url.origin !== RETURN_TO_BASE)
|
|
24
|
+
throw invalid();
|
|
25
|
+
// Dot segments can collapse to "//host" (e.g. "/.//evil.example"), which a
|
|
26
|
+
// browser reads as protocol-relative: check the normalized result too.
|
|
27
|
+
if (url.pathname.startsWith("//"))
|
|
28
|
+
throw invalid();
|
|
29
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=return-to.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"return-to.js","sourceRoot":"","sources":["../src/return-to.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAE9C,MAAM,cAAc,GAAG,2BAA2B,CAAC;AAEnD;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAC1B,KAAyB,EACzB,QAAgB;IAEhB,IAAI,CAAC,KAAK;QAAE,OAAO,QAAQ,CAAC;IAC5B,MAAM,OAAO,GAAG,GAAG,EAAE,CACnB,IAAI,eAAe,CACjB,gBAAgB,EAChB,iDAAiD,CAClD,CAAC;IACJ,4CAA4C;IAC5C,IAAI,CAAC,KAAK,CAAC,UAAU,CAAC,GAAG,CAAC,IAAI,mBAAmB,CAAC,IAAI,CAAC,KAAK,CAAC;QAC3D,MAAM,OAAO,EAAE,CAAC;IAClB,IAAI,GAAQ,CAAC;IACb,IAAI,CAAC;QACH,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,EAAE,cAAc,CAAC,CAAC;IACvC,CAAC;IAAC,MAAM,CAAC;QACP,MAAM,OAAO,EAAE,CAAC;IAClB,CAAC;IACD,IAAI,GAAG,CAAC,MAAM,KAAK,cAAc;QAAE,MAAM,OAAO,EAAE,CAAC;IACnD,2EAA2E;IAC3E,uEAAuE;IACvE,IAAI,GAAG,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC;QAAE,MAAM,OAAO,EAAE,CAAC;IACnD,OAAO,GAAG,GAAG,CAAC,QAAQ,GAAG,GAAG,CAAC,MAAM,GAAG,GAAG,CAAC,IAAI,EAAE,CAAC;AACnD,CAAC"}
|
package/dist/sql.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface Migration {
|
|
2
|
+
/** Sequence number, from the file name prefix (`0001_…` → 1). */
|
|
3
|
+
number: number;
|
|
4
|
+
name: string;
|
|
5
|
+
sql: string;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* The schema migrations shipped with this version, in order.
|
|
9
|
+
*
|
|
10
|
+
* The SQL is embedded in the build (generated from `sql/*.sql`), not read from
|
|
11
|
+
* disk, so this works unchanged when a bundler inlines the library.
|
|
12
|
+
*/
|
|
13
|
+
export declare function migrations(): Promise<Migration[]>;
|
|
14
|
+
//# sourceMappingURL=sql.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.d.ts","sourceRoot":"","sources":["../src/sql.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,SAAS;IACxB,iEAAiE;IACjE,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AAED;;;;;GAKG;AACH,wBAAsB,UAAU,IAAI,OAAO,CAAC,SAAS,EAAE,CAAC,CAEvD"}
|
package/dist/sql.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { MIGRATIONS } from "./migrations.generated.js";
|
|
2
|
+
/**
|
|
3
|
+
* The schema migrations shipped with this version, in order.
|
|
4
|
+
*
|
|
5
|
+
* The SQL is embedded in the build (generated from `sql/*.sql`), not read from
|
|
6
|
+
* disk, so this works unchanged when a bundler inlines the library.
|
|
7
|
+
*/
|
|
8
|
+
export async function migrations() {
|
|
9
|
+
return MIGRATIONS.map((m) => ({ ...m }));
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=sql.js.map
|
package/dist/sql.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sql.js","sourceRoot":"","sources":["../src/sql.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AASvD;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,UAAU;IAC9B,OAAO,UAAU,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;AAC3C,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"memory.d.ts","sourceRoot":"","sources":["../../src/store/memory.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAGV,UAAU,EAGX,MAAM,YAAY,CAAC;AAEpB;;;GAGG;AACH,wBAAgB,WAAW,IAAI,UAAU,CAgHxC"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory store for tests. Same semantics as `postgresStore`, but data
|
|
4
|
+
* lives in this process only.
|
|
5
|
+
*/
|
|
6
|
+
export function memoryStore() {
|
|
7
|
+
const rows = new Map();
|
|
8
|
+
const lockTokens = new Map();
|
|
9
|
+
const copy = (row) => ({ ...row });
|
|
10
|
+
return {
|
|
11
|
+
async upsertGrant(input) {
|
|
12
|
+
const existing = [...rows.values()].find((r) => r.ownerId === input.ownerId &&
|
|
13
|
+
r.companyId === input.companyId &&
|
|
14
|
+
r.userId === input.userId);
|
|
15
|
+
const { now, ...fields } = input;
|
|
16
|
+
const row = {
|
|
17
|
+
id: existing?.id ?? randomUUID(),
|
|
18
|
+
...fields,
|
|
19
|
+
status: "active",
|
|
20
|
+
refreshLockedUntil: null,
|
|
21
|
+
lastRefreshedAt: now,
|
|
22
|
+
createdAt: existing?.createdAt ?? now,
|
|
23
|
+
updatedAt: now,
|
|
24
|
+
};
|
|
25
|
+
rows.set(row.id, row);
|
|
26
|
+
lockTokens.delete(row.id);
|
|
27
|
+
return copy(row);
|
|
28
|
+
},
|
|
29
|
+
async findGrants({ ownerId, companyId, grantId }) {
|
|
30
|
+
return [...rows.values()]
|
|
31
|
+
.filter((r) => r.ownerId === ownerId &&
|
|
32
|
+
(companyId === undefined || r.companyId === companyId) &&
|
|
33
|
+
(grantId === undefined || r.id === grantId))
|
|
34
|
+
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
|
35
|
+
.map(copy);
|
|
36
|
+
},
|
|
37
|
+
async getGrant(id) {
|
|
38
|
+
const row = rows.get(id);
|
|
39
|
+
return row ? copy(row) : null;
|
|
40
|
+
},
|
|
41
|
+
async acquireRefreshLock(req) {
|
|
42
|
+
const row = rows.get(req.id);
|
|
43
|
+
if (!row || row.status !== "active")
|
|
44
|
+
return null;
|
|
45
|
+
if (row.refreshLockedUntil && row.refreshLockedUntil > req.now)
|
|
46
|
+
return null;
|
|
47
|
+
const needsRefresh = (req.accessExpiresBefore !== undefined &&
|
|
48
|
+
(row.accessExpiresAt === null ||
|
|
49
|
+
row.accessExpiresAt <= req.accessExpiresBefore)) ||
|
|
50
|
+
(req.refreshedBefore !== undefined &&
|
|
51
|
+
row.lastRefreshedAt < req.refreshedBefore);
|
|
52
|
+
if (!needsRefresh)
|
|
53
|
+
return null;
|
|
54
|
+
row.refreshLockedUntil = req.lockUntil;
|
|
55
|
+
lockTokens.set(row.id, req.lockToken);
|
|
56
|
+
return copy(row);
|
|
57
|
+
},
|
|
58
|
+
async saveRefreshed(id, lockToken, refresh) {
|
|
59
|
+
const row = rows.get(id);
|
|
60
|
+
if (!row || lockTokens.get(id) !== lockToken)
|
|
61
|
+
return null;
|
|
62
|
+
lockTokens.delete(id);
|
|
63
|
+
row.accessToken = refresh.accessToken;
|
|
64
|
+
row.refreshToken = refresh.refreshToken ?? row.refreshToken;
|
|
65
|
+
row.accessExpiresAt = refresh.accessExpiresAt;
|
|
66
|
+
row.refreshExpiresAt = refresh.refreshExpiresAt ?? row.refreshExpiresAt;
|
|
67
|
+
row.scope = refresh.scope ?? row.scope;
|
|
68
|
+
row.role = refresh.role ?? row.role;
|
|
69
|
+
row.status = "active";
|
|
70
|
+
row.refreshLockedUntil = null;
|
|
71
|
+
row.lastRefreshedAt = refresh.now;
|
|
72
|
+
row.updatedAt = refresh.now;
|
|
73
|
+
return copy(row);
|
|
74
|
+
},
|
|
75
|
+
async markReauthRequired(id, lockToken, now) {
|
|
76
|
+
const row = rows.get(id);
|
|
77
|
+
if (!row || lockTokens.get(id) !== lockToken)
|
|
78
|
+
return;
|
|
79
|
+
lockTokens.delete(id);
|
|
80
|
+
row.status = "reauth_required";
|
|
81
|
+
row.refreshLockedUntil = null;
|
|
82
|
+
row.updatedAt = now;
|
|
83
|
+
},
|
|
84
|
+
async releaseRefreshLock(id, lockToken) {
|
|
85
|
+
const row = rows.get(id);
|
|
86
|
+
if (!row || lockTokens.get(id) !== lockToken)
|
|
87
|
+
return;
|
|
88
|
+
lockTokens.delete(id);
|
|
89
|
+
row.refreshLockedUntil = null;
|
|
90
|
+
},
|
|
91
|
+
async deleteGrant(id) {
|
|
92
|
+
rows.delete(id);
|
|
93
|
+
lockTokens.delete(id);
|
|
94
|
+
},
|
|
95
|
+
async listIdleGrants({ refreshedBefore, limit }) {
|
|
96
|
+
return [...rows.values()]
|
|
97
|
+
.filter((r) => r.status === "active" && r.lastRefreshedAt < refreshedBefore)
|
|
98
|
+
.sort((a, b) => a.lastRefreshedAt.getTime() - b.lastRefreshedAt.getTime())
|
|
99
|
+
.slice(0, limit)
|
|
100
|
+
.map(copy);
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
//# sourceMappingURL=memory.js.map
|