@noresponse/node 6.0.1 → 7.0.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/CHANGELOG.md +30 -0
- package/README.md +22 -8
- package/dist/commonjs/auth.d.ts +1 -1
- package/dist/commonjs/claim-map.js +1 -1
- package/dist/commonjs/claim-map.js.map +1 -1
- package/dist/commonjs/client.d.ts +14 -0
- package/dist/commonjs/client.d.ts.map +1 -1
- package/dist/commonjs/client.js +20 -0
- package/dist/commonjs/client.js.map +1 -1
- package/dist/commonjs/funcs/accounts-list-changed.d.ts +18 -0
- package/dist/commonjs/funcs/accounts-list-changed.d.ts.map +1 -0
- package/dist/commonjs/funcs/accounts-list-changed.js +153 -0
- package/dist/commonjs/funcs/accounts-list-changed.js.map +1 -0
- package/dist/commonjs/funcs/accounts-record-enforcement.d.ts +18 -0
- package/dist/commonjs/funcs/accounts-record-enforcement.d.ts.map +1 -0
- package/dist/commonjs/funcs/accounts-record-enforcement.js +154 -0
- package/dist/commonjs/funcs/accounts-record-enforcement.js.map +1 -0
- package/dist/commonjs/index.d.ts +1 -1
- package/dist/commonjs/index.d.ts.map +1 -1
- package/dist/commonjs/index.js +5 -8
- package/dist/commonjs/index.js.map +1 -1
- package/dist/commonjs/late-results.d.ts +85 -139
- package/dist/commonjs/late-results.d.ts.map +1 -1
- package/dist/commonjs/late-results.js +242 -331
- package/dist/commonjs/late-results.js.map +1 -1
- package/dist/commonjs/lib/config.d.ts +2 -2
- package/dist/commonjs/lib/config.js +2 -2
- package/dist/commonjs/models/account-list.d.ts +18 -0
- package/dist/commonjs/models/account-list.d.ts.map +1 -0
- package/dist/commonjs/models/account-list.js +56 -0
- package/dist/commonjs/models/account-list.js.map +1 -0
- package/dist/commonjs/models/conflict-error-code.d.ts +1 -0
- package/dist/commonjs/models/conflict-error-code.d.ts.map +1 -1
- package/dist/commonjs/models/conflict-error-code.js +1 -0
- package/dist/commonjs/models/conflict-error-code.js.map +1 -1
- package/dist/commonjs/models/enforcement-action.d.ts +22 -0
- package/dist/commonjs/models/enforcement-action.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement-action.js +53 -0
- package/dist/commonjs/models/enforcement-action.js.map +1 -0
- package/dist/commonjs/models/enforcement-provider.d.ts +20 -0
- package/dist/commonjs/models/enforcement-provider.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement-provider.js +51 -0
- package/dist/commonjs/models/enforcement-provider.js.map +1 -0
- package/dist/commonjs/models/enforcement-request.d.ts +53 -0
- package/dist/commonjs/models/enforcement-request.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement-request.js +64 -0
- package/dist/commonjs/models/enforcement-request.js.map +1 -0
- package/dist/commonjs/models/enforcement-state.d.ts +21 -0
- package/dist/commonjs/models/enforcement-state.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement-state.js +52 -0
- package/dist/commonjs/models/enforcement-state.js.map +1 -0
- package/dist/commonjs/models/enforcement-status.d.ts +18 -0
- package/dist/commonjs/models/enforcement-status.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement-status.js +49 -0
- package/dist/commonjs/models/enforcement-status.js.map +1 -0
- package/dist/commonjs/models/enforcement.d.ts +57 -0
- package/dist/commonjs/models/enforcement.d.ts.map +1 -0
- package/dist/commonjs/models/enforcement.js +72 -0
- package/dist/commonjs/models/enforcement.js.map +1 -0
- package/dist/commonjs/models/index.d.ts +7 -0
- package/dist/commonjs/models/index.d.ts.map +1 -1
- package/dist/commonjs/models/index.js +7 -0
- package/dist/commonjs/models/index.js.map +1 -1
- package/dist/commonjs/models/operations/index.d.ts +2 -0
- package/dist/commonjs/models/operations/index.d.ts.map +1 -1
- package/dist/commonjs/models/operations/index.js +2 -0
- package/dist/commonjs/models/operations/index.js.map +1 -1
- package/dist/commonjs/models/operations/list-accounts.d.ts +37 -0
- package/dist/commonjs/models/operations/list-accounts.d.ts.map +1 -0
- package/dist/commonjs/models/operations/list-accounts.js +69 -0
- package/dist/commonjs/models/operations/list-accounts.js.map +1 -0
- package/dist/commonjs/models/operations/record-enforcement.d.ts +29 -0
- package/dist/commonjs/models/operations/record-enforcement.d.ts.map +1 -0
- package/dist/commonjs/models/operations/record-enforcement.js +68 -0
- package/dist/commonjs/models/operations/record-enforcement.js.map +1 -0
- package/dist/commonjs/sdk/accounts.d.ts +14 -0
- package/dist/commonjs/sdk/accounts.d.ts.map +1 -1
- package/dist/commonjs/sdk/accounts.js +20 -0
- package/dist/commonjs/sdk/accounts.js.map +1 -1
- package/dist/esm/auth.d.ts +1 -1
- package/dist/esm/claim-map.js +1 -1
- package/dist/esm/claim-map.js.map +1 -1
- package/dist/esm/client.d.ts +14 -0
- package/dist/esm/client.d.ts.map +1 -1
- package/dist/esm/client.js +20 -0
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/funcs/accounts-list-changed.d.ts +18 -0
- package/dist/esm/funcs/accounts-list-changed.d.ts.map +1 -0
- package/dist/esm/funcs/accounts-list-changed.js +117 -0
- package/dist/esm/funcs/accounts-list-changed.js.map +1 -0
- package/dist/esm/funcs/accounts-record-enforcement.d.ts +18 -0
- package/dist/esm/funcs/accounts-record-enforcement.d.ts.map +1 -0
- package/dist/esm/funcs/accounts-record-enforcement.js +118 -0
- package/dist/esm/funcs/accounts-record-enforcement.js.map +1 -0
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/late-results.d.ts +85 -139
- package/dist/esm/late-results.d.ts.map +1 -1
- package/dist/esm/late-results.js +237 -324
- package/dist/esm/late-results.js.map +1 -1
- package/dist/esm/lib/config.d.ts +2 -2
- package/dist/esm/lib/config.js +2 -2
- package/dist/esm/models/account-list.d.ts +18 -0
- package/dist/esm/models/account-list.d.ts.map +1 -0
- package/dist/esm/models/account-list.js +19 -0
- package/dist/esm/models/account-list.js.map +1 -0
- package/dist/esm/models/conflict-error-code.d.ts +1 -0
- package/dist/esm/models/conflict-error-code.d.ts.map +1 -1
- package/dist/esm/models/conflict-error-code.js +1 -0
- package/dist/esm/models/conflict-error-code.js.map +1 -1
- package/dist/esm/models/enforcement-action.d.ts +22 -0
- package/dist/esm/models/enforcement-action.d.ts.map +1 -0
- package/dist/esm/models/enforcement-action.js +17 -0
- package/dist/esm/models/enforcement-action.js.map +1 -0
- package/dist/esm/models/enforcement-provider.d.ts +20 -0
- package/dist/esm/models/enforcement-provider.d.ts.map +1 -0
- package/dist/esm/models/enforcement-provider.js +15 -0
- package/dist/esm/models/enforcement-provider.js.map +1 -0
- package/dist/esm/models/enforcement-request.d.ts +53 -0
- package/dist/esm/models/enforcement-request.d.ts.map +1 -0
- package/dist/esm/models/enforcement-request.js +27 -0
- package/dist/esm/models/enforcement-request.js.map +1 -0
- package/dist/esm/models/enforcement-state.d.ts +21 -0
- package/dist/esm/models/enforcement-state.d.ts.map +1 -0
- package/dist/esm/models/enforcement-state.js +16 -0
- package/dist/esm/models/enforcement-state.js.map +1 -0
- package/dist/esm/models/enforcement-status.d.ts +18 -0
- package/dist/esm/models/enforcement-status.d.ts.map +1 -0
- package/dist/esm/models/enforcement-status.js +13 -0
- package/dist/esm/models/enforcement-status.js.map +1 -0
- package/dist/esm/models/enforcement.d.ts +57 -0
- package/dist/esm/models/enforcement.d.ts.map +1 -0
- package/dist/esm/models/enforcement.js +35 -0
- package/dist/esm/models/enforcement.js.map +1 -0
- package/dist/esm/models/index.d.ts +7 -0
- package/dist/esm/models/index.d.ts.map +1 -1
- package/dist/esm/models/index.js +7 -0
- package/dist/esm/models/index.js.map +1 -1
- package/dist/esm/models/operations/index.d.ts +2 -0
- package/dist/esm/models/operations/index.d.ts.map +1 -1
- package/dist/esm/models/operations/index.js +2 -0
- package/dist/esm/models/operations/index.js.map +1 -1
- package/dist/esm/models/operations/list-accounts.d.ts +37 -0
- package/dist/esm/models/operations/list-accounts.d.ts.map +1 -0
- package/dist/esm/models/operations/list-accounts.js +31 -0
- package/dist/esm/models/operations/list-accounts.js.map +1 -0
- package/dist/esm/models/operations/record-enforcement.d.ts +29 -0
- package/dist/esm/models/operations/record-enforcement.d.ts.map +1 -0
- package/dist/esm/models/operations/record-enforcement.js +30 -0
- package/dist/esm/models/operations/record-enforcement.js.map +1 -0
- package/dist/esm/sdk/accounts.d.ts +14 -0
- package/dist/esm/sdk/accounts.d.ts.map +1 -1
- package/dist/esm/sdk/accounts.js +20 -0
- package/dist/esm/sdk/accounts.js.map +1 -1
- package/package.json +1 -1
package/dist/esm/late-results.js
CHANGED
|
@@ -1,19 +1,15 @@
|
|
|
1
|
+
import { NotFoundError } from "./models/errors/not-found-error.js";
|
|
1
2
|
import { constructEvent } from "./webhooks.js";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
});
|
|
9
|
-
/**
|
|
10
|
-
* Thrown when the provider's admin API refuses a call. Answer the webhook with a 5xx so NoResponse retries it.
|
|
11
|
-
* A 404 is never thrown: the user or session is already gone, so the action is done.
|
|
12
|
-
*/
|
|
3
|
+
const TABLE = { "lifecycle": { "onboarding": { "flow": "account_creation", "on": { "review": ["fail_onboarding", "revoke_sessions"], "block": ["fail_onboarding", "revoke_sessions"] } }, "provisional": { "flow": "account_creation", "on": { "review": ["reject_activation", "revoke_sessions"], "block": ["reject_activation", "revoke_sessions"] } }, "active": { "flow": "login", "on": { "review": ["revoke_sessions"], "block": ["restrict", "revoke_sessions"] } }, "restricted": { "flow": "login", "on": {}, "reinstate": { "allow": ["unrestrict"] } }, "rejected": { "flow": "account_creation", "on": {} } }, "providers": { "clerk": { "base_url": "https://api.clerk.com/v1", "actions": { "fail_onboarding": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "reject_activation": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "restrict": [{ "call": { "method": "POST", "path": "/users/{user}/ban" } }], "unrestrict": [{ "call": { "method": "POST", "path": "/users/{user}/unban" } }], "revoke_sessions": [{ "each": { "list": { "method": "GET", "path": "/sessions", "query": { "user_id": "{user}", "status": "active", "limit": "100" } }, "page": { "kind": "offset", "param": "offset", "size": 100 }, "where": { "status": "active" } }, "call": { "method": "POST", "path": "/sessions/{item}/revoke" } }] } }, "workos": { "base_url": "https://api.workos.com", "actions": { "fail_onboarding": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "onboarding_failed" } } } }], "reject_activation": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "activation_rejected" } } } }], "restrict": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": "restricted" } } } }], "unrestrict": [{ "call": { "method": "PUT", "path": "/user_management/users/{user}", "body": { "metadata": { "noresponse_state": null } } } }], "revoke_sessions": [{ "each": { "list": { "method": "GET", "path": "/user_management/users/{user}/sessions", "query": { "limit": "100" } }, "page": { "kind": "cursor", "param": "after", "next": ["list_metadata", "after"] }, "where": { "status": "active" } }, "call": { "method": "POST", "path": "/user_management/sessions/revoke", "body": { "session_id": "{item}" } } }] } }, "auth0": { "base_url": "https://{domain}/api/v2", "actions": { "fail_onboarding": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "reject_activation": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "restrict": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }], "unrestrict": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": false } } }], "revoke_sessions": [{ "call": { "method": "DELETE", "path": "/users/{user}/sessions" }, "fallback": { "status": 403, "code": "feature_not_enabled", "reports": "restrict", "steps": [{ "call": { "method": "PATCH", "path": "/users/{user}", "body": { "blocked": true } } }] } }, { "call": { "method": "DELETE", "path": "/users/{user}/refresh-tokens" }, "fallback": { "status": 403, "code": "feature_not_enabled", "steps": [{ "each": { "list": { "method": "GET", "path": "/device-credentials", "query": { "user_id": "{user}", "type": "refresh_token", "per_page": "100" } }, "page": { "kind": "index", "param": "page", "size": 100 } }, "call": { "method": "DELETE", "path": "/device-credentials/{item}" } }, { "each": { "list": { "method": "GET", "path": "/device-credentials", "query": { "user_id": "{user}", "type": "rotating_refresh_token", "per_page": "100" } }, "page": { "kind": "index", "param": "page", "size": 100 } }, "call": { "method": "DELETE", "path": "/device-credentials/{item}" } }] } }] } } }, "limits": { "retries": 4, "budget_ms": 3000, "backoff_ms": 250, "max_pages": 100 } };
|
|
4
|
+
/** Every action a provider runs, as the table names them. */
|
|
5
|
+
const ACTIONS = new Set(Object.values(TABLE.providers).flatMap((provider) => Object.keys(provider.actions)));
|
|
6
|
+
/** Each admin API call gives up after this long; the provider then counts as unreachable. */
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 10000;
|
|
8
|
+
/** The provider refused an action or could not be reached (status 0). Answer the webhook with a 5xx so it is delivered again. */
|
|
13
9
|
export class ProviderActionError extends Error {
|
|
14
10
|
provider;
|
|
15
11
|
status;
|
|
16
|
-
/** The provider's error code
|
|
12
|
+
/** The provider's error code, such as Auth0's errorCode "insufficient_scope", when it sent one. */
|
|
17
13
|
code;
|
|
18
14
|
constructor(provider, status, message, code) {
|
|
19
15
|
super(message);
|
|
@@ -23,86 +19,200 @@ export class ProviderActionError extends Error {
|
|
|
23
19
|
this.code = code;
|
|
24
20
|
}
|
|
25
21
|
}
|
|
26
|
-
const ACTIONS = ["none", "revoke_sessions", "ban"];
|
|
27
22
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
23
|
+
* Verify an account.risk_changed delivery and enforce the account's current
|
|
24
|
+
* risk. A delivery that fails verification throws WebhookVerificationError
|
|
25
|
+
* before anything is read; any other event, and a test event, does nothing.
|
|
31
26
|
*/
|
|
32
|
-
export function
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (event.type
|
|
36
|
-
return "
|
|
37
|
-
if (event.type === "assessment.updated" && event.test === true)
|
|
38
|
-
return "none";
|
|
39
|
-
const data = event.data;
|
|
40
|
-
// A pending answer carries an earlier assessment's decision; acting on it would repeat that action.
|
|
41
|
-
if (event.type === "assessment.updated" && data.status === "pending")
|
|
42
|
-
return "none";
|
|
43
|
-
if (event.type === "account.risk_changed") {
|
|
44
|
-
// The event is sent when any flow's decision changes; sessions are about the login
|
|
45
|
-
// decision, so an unchanged login decision acts on nothing.
|
|
46
|
-
const changed = event.data;
|
|
47
|
-
if (changed.previousDecisionsByFlow.login === changed.account.decisionsByFlow.login)
|
|
48
|
-
return "none";
|
|
27
|
+
export async function handleRiskChange(client, payload, headers, secrets, options) {
|
|
28
|
+
const { provider: _provider, getAccountState: _getAccountState, flows: _flows, ...verify } = options;
|
|
29
|
+
const event = await constructEvent(payload, headers, secrets, verify);
|
|
30
|
+
if (event.type !== "account.risk_changed" || event.test === true) {
|
|
31
|
+
return { event, skipped: "not_actionable", enforcements: [] };
|
|
49
32
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
return action;
|
|
33
|
+
let account;
|
|
34
|
+
try {
|
|
35
|
+
account = await client.accounts.get(event.data.account.id);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (error instanceof NotFoundError)
|
|
39
|
+
return { event, skipped: "account_gone", enforcements: [] };
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
return { event, ...(await enforceAccount(client, account, options)) };
|
|
61
43
|
}
|
|
44
|
+
const FLOW_KEYS = { account_creation: "accountCreation", login: "login", purchase: "purchase" };
|
|
62
45
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
* `payload`, `headers`, `secrets` and the tolerance options are exactly what
|
|
66
|
-
* constructEvent takes; a delivery that fails verification throws
|
|
67
|
-
* WebhookVerificationError before any provider call. A test event acts on
|
|
68
|
-
* nothing. Revoke and ban are safe to repeat, but deduplicate on
|
|
69
|
-
* `result.event.id` and ignore versions older than one you have acted on.
|
|
46
|
+
* Enforce one account as read: the account a handler fetched, or one from
|
|
47
|
+
* accounts.listChanged({ unenforced: true }) when catching up.
|
|
70
48
|
*/
|
|
71
|
-
export async function
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
49
|
+
export async function enforceAccount(client, account, options) {
|
|
50
|
+
const answer = await options.getAccountState(account);
|
|
51
|
+
if (!answer)
|
|
52
|
+
return { account, skipped: "unknown_user", enforcements: [] };
|
|
53
|
+
const { state } = answer;
|
|
54
|
+
const rule = TABLE.lifecycle[state];
|
|
55
|
+
if (!rule)
|
|
56
|
+
throw new TypeError(`unknown account state ${String(state)}`);
|
|
57
|
+
const riskVersion = account.riskVersion;
|
|
58
|
+
if (answer.enforcedRiskVersion !== undefined && riskVersion <= answer.enforcedRiskVersion) {
|
|
59
|
+
return { account, state, riskVersion, skipped: "already_enforced", enforcements: [] };
|
|
60
|
+
}
|
|
61
|
+
const { flow } = rule;
|
|
62
|
+
const decision = account.decisionsByFlow[FLOW_KEYS[flow]];
|
|
63
|
+
const actions = rule.on[decision] ?? (options.flows?.[flow]?.reinstate ? rule.reinstate?.[decision] : undefined) ?? [];
|
|
64
|
+
const provider = options.provider;
|
|
65
|
+
const enforcements = [];
|
|
66
|
+
const record = async (action, status, errorCode) => {
|
|
67
|
+
const body = { riskVersion, flow, action, stateBefore: state, status, provider: provider.name, ...(errorCode ? { errorCode } : {}) };
|
|
68
|
+
await client.accounts.recordEnforcement(account.id, body);
|
|
69
|
+
enforcements.push({ flow, action, status, ...(errorCode ? { errorCode } : {}) });
|
|
70
|
+
};
|
|
71
|
+
if (actions.length === 0) {
|
|
72
|
+
// An adverse or step_up decision that needs nothing still counts as handled.
|
|
73
|
+
if (decision !== "allow")
|
|
74
|
+
await record("none", "applied");
|
|
75
|
+
return { account, state, riskVersion, enforcements };
|
|
76
|
+
}
|
|
77
|
+
const recorded = new Set();
|
|
78
|
+
for (const action of actions) {
|
|
79
|
+
let alsoRan;
|
|
80
|
+
try {
|
|
81
|
+
alsoRan = await provider.run(action, answer.userId || account.id);
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
await record(action, "failed", failureCode(provider.name, error));
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
for (const ran of [...alsoRan, action]) {
|
|
88
|
+
if (recorded.has(ran))
|
|
89
|
+
continue;
|
|
90
|
+
recorded.add(ran);
|
|
91
|
+
await record(ran, "applied");
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { account, state, riskVersion, enforcements };
|
|
91
95
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
*/
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
96
|
+
const SLUG = /^[a-z0-9_.-]+$/;
|
|
97
|
+
/** The error_code of a failed action: the provider's code, else its HTTP status, as <provider>.<code>. */
|
|
98
|
+
function failureCode(provider, error) {
|
|
99
|
+
const code = error?.code;
|
|
100
|
+
const status = error instanceof ProviderActionError ? error.status : undefined;
|
|
101
|
+
const suffix = typeof code === "string" && SLUG.test(code.toLowerCase())
|
|
102
|
+
? code.toLowerCase()
|
|
103
|
+
: status === 0 ? "unreachable" : status !== undefined ? `http_${status}` : "error";
|
|
104
|
+
return `${provider}.${suffix}`.slice(0, 128);
|
|
105
|
+
}
|
|
106
|
+
/** Clerk, WorkOS or Auth0, run from the shared action table. */
|
|
107
|
+
export function identityProvider(name, options) {
|
|
108
|
+
const spec = TABLE.providers[name];
|
|
109
|
+
if (!spec)
|
|
110
|
+
throw new TypeError(`unknown identity provider ${String(name)}`);
|
|
111
|
+
if (!options?.token)
|
|
112
|
+
throw new TypeError(`${name} needs token`);
|
|
113
|
+
let base = options.baseUrl ?? spec.base_url;
|
|
114
|
+
if (base.includes("{domain}")) {
|
|
115
|
+
if (!options.domain)
|
|
116
|
+
throw new TypeError(`${name} needs domain or baseUrl`);
|
|
117
|
+
base = base.replace("{domain}", options.domain);
|
|
118
|
+
}
|
|
119
|
+
const http = { name, base: base.replace(/\/+$/, ""), authorization: `Bearer ${options.token}`, fetch: options.fetch ?? globalThis.fetch };
|
|
120
|
+
return {
|
|
121
|
+
name,
|
|
122
|
+
async run(action, userId) {
|
|
123
|
+
if (typeof userId !== "string" || userId === "")
|
|
124
|
+
throw new TypeError("user id must be a non-empty string");
|
|
125
|
+
const steps = spec.actions[action];
|
|
126
|
+
if (!steps)
|
|
127
|
+
throw new TypeError(`unknown action ${String(action)}`);
|
|
128
|
+
return runSteps(steps, { ...http, user: userId, waitedMs: 0 });
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Your own user store: one function per action you support. An action without one fails. */
|
|
133
|
+
export function customProvider(handlers) {
|
|
134
|
+
return {
|
|
135
|
+
name: "custom",
|
|
136
|
+
async run(action, userId) {
|
|
137
|
+
const handler = handlers[action];
|
|
138
|
+
if (!handler)
|
|
139
|
+
throw new TypeError(`customProvider has no ${action} handler`);
|
|
140
|
+
const alsoRan = await handler(userId);
|
|
141
|
+
// Only a list of action names counts; any other return value is no extra action.
|
|
142
|
+
return Array.isArray(alsoRan) ? alsoRan.filter((name) => typeof name === "string" && ACTIONS.has(name)) : [];
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
async function runSteps(steps, ctx) {
|
|
147
|
+
const alsoRan = [];
|
|
148
|
+
for (const step of steps) {
|
|
149
|
+
if (step.each) {
|
|
150
|
+
for (const item of await collect(step.each, ctx))
|
|
151
|
+
await send(step.call, ctx, item);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
await send(step.call, ctx);
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const fallback = step.fallback;
|
|
159
|
+
if (!fallback || !(error instanceof ProviderActionError) || error.status !== fallback.status || error.code !== fallback.code)
|
|
160
|
+
throw error;
|
|
161
|
+
alsoRan.push(...(await runSteps(fallback.steps, ctx)));
|
|
162
|
+
if (fallback.reports)
|
|
163
|
+
alsoRan.push(fallback.reports);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return alsoRan;
|
|
167
|
+
}
|
|
168
|
+
/** Every matching item id across the list's pages, collected before any is acted on. */
|
|
169
|
+
async function collect(each, ctx) {
|
|
170
|
+
const { list, page, where } = each;
|
|
171
|
+
const ids = [];
|
|
172
|
+
let cursor;
|
|
173
|
+
for (let index = 0; index < TABLE.limits.max_pages; index++) {
|
|
174
|
+
const query = { ...list.query };
|
|
175
|
+
if (page.kind === "offset")
|
|
176
|
+
query[page.param] = String(index * (page.size ?? 0));
|
|
177
|
+
else if (page.kind === "index")
|
|
178
|
+
query[page.param] = String(index);
|
|
179
|
+
else if (cursor !== undefined)
|
|
180
|
+
query[page.param] = cursor;
|
|
181
|
+
const body = await send({ ...list, query }, ctx);
|
|
182
|
+
const data = body?.data;
|
|
183
|
+
const items = Array.isArray(body) ? body : Array.isArray(data) ? data : [];
|
|
184
|
+
for (const item of items) {
|
|
185
|
+
const record = item;
|
|
186
|
+
const id = record?.["id"];
|
|
187
|
+
if (typeof id !== "string" || id === "")
|
|
188
|
+
continue;
|
|
189
|
+
if (Object.entries(where ?? {}).every(([key, value]) => record?.[key] === value))
|
|
190
|
+
ids.push(id);
|
|
191
|
+
}
|
|
192
|
+
if (page.kind === "cursor") {
|
|
193
|
+
let next = body;
|
|
194
|
+
for (const key of page.next ?? [])
|
|
195
|
+
next = next?.[key];
|
|
196
|
+
if (typeof next !== "string" || next === "")
|
|
197
|
+
break;
|
|
198
|
+
cursor = next;
|
|
199
|
+
}
|
|
200
|
+
else if (items.length < (page.size ?? 0))
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
return ids;
|
|
204
|
+
}
|
|
205
|
+
function fill(value, user, item) {
|
|
206
|
+
if (typeof value === "string")
|
|
207
|
+
return value.replaceAll("{user}", user).replaceAll("{item}", item ?? "");
|
|
208
|
+
if (Array.isArray(value))
|
|
209
|
+
return value.map((entry) => fill(entry, user, item));
|
|
210
|
+
if (value !== null && typeof value === "object") {
|
|
211
|
+
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, fill(entry, user, item)]));
|
|
212
|
+
}
|
|
213
|
+
return value;
|
|
214
|
+
}
|
|
215
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
216
|
/** How long a 429 asks to wait, in milliseconds. */
|
|
107
217
|
function rateLimitWaitMs(headers, retry) {
|
|
108
218
|
const after = headers.get("retry-after")?.trim();
|
|
@@ -117,261 +227,64 @@ function rateLimitWaitMs(headers, retry) {
|
|
|
117
227
|
if (Number.isFinite(at))
|
|
118
228
|
return Math.max(0, at * 1000 - Date.now());
|
|
119
229
|
}
|
|
120
|
-
return
|
|
121
|
-
}
|
|
122
|
-
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
123
|
-
function caller(name, baseUrl, authorization, fetcher, rateLimit) {
|
|
124
|
-
const base = baseUrl.replace(/\/+$/, "");
|
|
125
|
-
const run = fetcher ?? globalThis.fetch;
|
|
126
|
-
// Every call names a user or a session. A 404 means it is already gone, so
|
|
127
|
-
// revoke and ban count it as done and a repeated delivery does not fail.
|
|
128
|
-
return async ({ method, path, query, body }) => {
|
|
129
|
-
const url = new URL(base + path);
|
|
130
|
-
for (const [key, value] of Object.entries(query ?? {}))
|
|
131
|
-
url.searchParams.set(key, value);
|
|
132
|
-
const headers = { authorization, accept: "application/json" };
|
|
133
|
-
if (body !== undefined)
|
|
134
|
-
headers["content-type"] = "application/json";
|
|
135
|
-
for (let retry = 0;; retry++) {
|
|
136
|
-
const response = await run(url, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
|
|
137
|
-
const text = await response.text();
|
|
138
|
-
if (response.status === 429 && rateLimit && retry < RATE_LIMIT_RETRIES) {
|
|
139
|
-
const wait = rateLimitWaitMs(response.headers, retry);
|
|
140
|
-
if (rateLimit.waitedMs + wait <= RATE_LIMIT_BUDGET_MS) {
|
|
141
|
-
rateLimit.waitedMs += wait;
|
|
142
|
-
await sleep(wait);
|
|
143
|
-
continue;
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
if (response.status === 404)
|
|
147
|
-
return undefined;
|
|
148
|
-
if (!response.ok)
|
|
149
|
-
throw new ProviderActionError(name, response.status, `${name} ${method} ${path} answered ${response.status}`, errorCode(text));
|
|
150
|
-
if (!text)
|
|
151
|
-
return undefined;
|
|
152
|
-
try {
|
|
153
|
-
return JSON.parse(text);
|
|
154
|
-
}
|
|
155
|
-
catch {
|
|
156
|
-
return undefined;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
};
|
|
230
|
+
return TABLE.limits.backoff_ms * 2 ** retry;
|
|
160
231
|
}
|
|
161
|
-
/** The
|
|
232
|
+
/** The provider's error code: Auth0's errorCode, WorkOS's code or Clerk's errors[0].code. */
|
|
162
233
|
function errorCode(text) {
|
|
163
234
|
try {
|
|
164
235
|
const body = JSON.parse(text);
|
|
165
|
-
const code = body?.errorCode ?? body?.code;
|
|
236
|
+
const code = body?.errorCode ?? body?.code ?? body?.errors?.[0]?.code;
|
|
166
237
|
return typeof code === "string" && code !== "" ? code : undefined;
|
|
167
238
|
}
|
|
168
239
|
catch {
|
|
169
240
|
return undefined;
|
|
170
241
|
}
|
|
171
242
|
}
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
* (GET /sessions?user_id&status=active) and revokes each
|
|
186
|
-
* (POST /sessions/{id}/revoke). ban is POST /users/{id}/ban, which revokes
|
|
187
|
-
* every session and blocks sign-in.
|
|
188
|
-
*/
|
|
189
|
-
export function clerkActions(options) {
|
|
190
|
-
if (!options?.secretKey)
|
|
191
|
-
throw new TypeError("clerkActions needs secretKey");
|
|
192
|
-
const call = caller("clerk", options.baseUrl ?? "https://api.clerk.com/v1", `Bearer ${options.secretKey}`, options.fetch);
|
|
193
|
-
const PAGE = 100;
|
|
194
|
-
return {
|
|
195
|
-
name: "clerk",
|
|
196
|
-
async revokeSessions(userId) {
|
|
197
|
-
segment(userId);
|
|
198
|
-
const active = [];
|
|
199
|
-
for (let offset = 0; offset < PAGE * 1000; offset += PAGE) {
|
|
200
|
-
const page = sessions(await call({ method: "GET", path: "/sessions", query: { user_id: userId, status: "active", limit: String(PAGE), offset: String(offset) } }));
|
|
201
|
-
for (const s of page)
|
|
202
|
-
if (s.status === "active" && typeof s.id === "string" && s.id)
|
|
203
|
-
active.push(s.id);
|
|
204
|
-
if (page.length < PAGE)
|
|
205
|
-
break;
|
|
206
|
-
}
|
|
207
|
-
for (const id of active)
|
|
208
|
-
await call({ method: "POST", path: `/sessions/${encodeURIComponent(id)}/revoke` });
|
|
209
|
-
},
|
|
210
|
-
async ban(userId) {
|
|
211
|
-
await call({ method: "POST", path: `/users/${segment(userId)}/ban` });
|
|
212
|
-
},
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
/**
|
|
216
|
-
* WorkOS User Management. revokeSessions pages through
|
|
217
|
-
* GET /user_management/users/{id}/sessions and revokes each active one with
|
|
218
|
-
* POST /user_management/sessions/revoke. WorkOS has no block or ban, so ban
|
|
219
|
-
* revokes the sessions and then deletes the user
|
|
220
|
-
* (DELETE /user_management/users/{id}); deletion is permanent.
|
|
221
|
-
*/
|
|
222
|
-
export function workosActions(options) {
|
|
223
|
-
if (!options?.apiKey)
|
|
224
|
-
throw new TypeError("workosActions needs apiKey");
|
|
225
|
-
const call = caller("workos", options.baseUrl ?? "https://api.workos.com", `Bearer ${options.apiKey}`, options.fetch);
|
|
226
|
-
const revokeSessions = async (userId) => {
|
|
227
|
-
const user = segment(userId);
|
|
228
|
-
const active = [];
|
|
229
|
-
let after;
|
|
230
|
-
for (let round = 0; round < 1000; round++) {
|
|
231
|
-
const query = { limit: "100" };
|
|
232
|
-
if (after)
|
|
233
|
-
query["after"] = after;
|
|
234
|
-
const body = (await call({ method: "GET", path: `/user_management/users/${user}/sessions`, query }));
|
|
235
|
-
for (const s of sessions(body))
|
|
236
|
-
if (s.status === "active" && typeof s.id === "string" && s.id)
|
|
237
|
-
active.push(s.id);
|
|
238
|
-
const next = body?.list_metadata?.after;
|
|
239
|
-
if (typeof next !== "string" || next === "")
|
|
240
|
-
break;
|
|
241
|
-
after = next;
|
|
242
|
-
}
|
|
243
|
-
for (const id of active)
|
|
244
|
-
await call({ method: "POST", path: "/user_management/sessions/revoke", body: { session_id: id } });
|
|
245
|
-
};
|
|
246
|
-
return {
|
|
247
|
-
name: "workos",
|
|
248
|
-
revokeSessions,
|
|
249
|
-
async ban(userId) {
|
|
250
|
-
await revokeSessions(userId);
|
|
251
|
-
await call({ method: "DELETE", path: `/user_management/users/${segment(userId)}` });
|
|
252
|
-
},
|
|
253
|
-
};
|
|
254
|
-
}
|
|
255
|
-
/** Auth0 answers 403 with errorCode feature_not_enabled when the tenant's plan does not include an endpoint. */
|
|
256
|
-
const notOnPlan = (error) => error instanceof ProviderActionError && error.status === 403 && error.code === "feature_not_enabled";
|
|
257
|
-
/**
|
|
258
|
-
* Auth0 Management API.
|
|
259
|
-
*
|
|
260
|
-
* revokeSessions deletes every session (DELETE /users/{id}/sessions) and
|
|
261
|
-
* every refresh token (DELETE /users/{id}/refresh-tokens). ban first sets
|
|
262
|
-
* blocked: true (PATCH /users/{id}), then does the same.
|
|
263
|
-
*
|
|
264
|
-
* Auth0 offers the sessions and refresh-token endpoints on Enterprise plans
|
|
265
|
-
* only; on other plans they answer 403 feature_not_enabled. Then:
|
|
266
|
-
* - refresh tokens are deleted one by one through
|
|
267
|
-
* /device-credentials (types refresh_token and rotating_refresh_token),
|
|
268
|
-
* which every plan has;
|
|
269
|
-
* - a login session cannot be ended, so revokeSessions blocks the user
|
|
270
|
-
* (PATCH blocked: true) before deleting the refresh tokens, and act
|
|
271
|
-
* reports "ban". Unblock the user in the dashboard to undo it.
|
|
272
|
-
* Any other refusal, such as 403 insufficient_scope, throws.
|
|
273
|
-
*
|
|
274
|
-
* A 429 from the Management API is waited out and retried: Retry-After, else
|
|
275
|
-
* X-RateLimit-Reset, else 250 ms doubling, at most 4 retries a call and 3 s of
|
|
276
|
-
* waiting a revoke or ban. Past that the 429 throws as ProviderActionError.
|
|
277
|
-
*/
|
|
278
|
-
export function auth0Actions(options) {
|
|
279
|
-
if (!options?.token)
|
|
280
|
-
throw new TypeError("auth0Actions needs token");
|
|
281
|
-
const base = options.baseUrl ?? (options.domain ? `https://${options.domain}/api/v2` : undefined);
|
|
282
|
-
if (!base)
|
|
283
|
-
throw new TypeError("auth0Actions needs domain or baseUrl");
|
|
284
|
-
// A fresh caller per action, so each revoke or ban has its own rate-limit budget.
|
|
285
|
-
const session = () => caller("auth0", base, `Bearer ${options.token}`, options.fetch, { waitedMs: 0 });
|
|
286
|
-
const block = (call, user) => call({ method: "PATCH", path: `/users/${user}`, body: { blocked: true } });
|
|
287
|
-
/** End every session; false when the plan has no sessions API. */
|
|
288
|
-
const deleteSessions = async (call, user) => {
|
|
243
|
+
/** One admin API call. A 404 is done (undefined); a 429 is waited out inside the action's budget; any other failure throws. */
|
|
244
|
+
async function send(request, ctx, item) {
|
|
245
|
+
const path = request.path.replaceAll("{user}", encodeURIComponent(ctx.user)).replaceAll("{item}", encodeURIComponent(item ?? ""));
|
|
246
|
+
const url = new URL(ctx.base + path);
|
|
247
|
+
for (const [key, value] of Object.entries(request.query ?? {}))
|
|
248
|
+
url.searchParams.set(key, fill(value, ctx.user, item));
|
|
249
|
+
const headers = { authorization: ctx.authorization, accept: "application/json" };
|
|
250
|
+
const body = request.body === undefined ? undefined : JSON.stringify(fill(request.body, ctx.user, item));
|
|
251
|
+
if (body !== undefined)
|
|
252
|
+
headers["content-type"] = "application/json";
|
|
253
|
+
const label = `${ctx.name} ${request.method} ${request.path}`;
|
|
254
|
+
for (let retry = 0;; retry++) {
|
|
255
|
+
let response;
|
|
289
256
|
try {
|
|
290
|
-
await
|
|
291
|
-
|
|
257
|
+
response = await ctx.fetch(url, {
|
|
258
|
+
method: request.method,
|
|
259
|
+
headers,
|
|
260
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
261
|
+
...(body !== undefined ? { body } : {}),
|
|
262
|
+
});
|
|
292
263
|
}
|
|
293
264
|
catch (error) {
|
|
294
|
-
|
|
295
|
-
return false;
|
|
296
|
-
throw error;
|
|
265
|
+
throw new ProviderActionError(ctx.name, 0, `${label} failed: ${error?.message ?? String(error)}`);
|
|
297
266
|
}
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
267
|
+
const text = await response.text();
|
|
268
|
+
if (response.status === 429 && retry < TABLE.limits.retries) {
|
|
269
|
+
const wait = rateLimitWaitMs(response.headers, retry);
|
|
270
|
+
if (ctx.waitedMs + wait <= TABLE.limits.budget_ms) {
|
|
271
|
+
ctx.waitedMs += wait;
|
|
272
|
+
await sleep(wait);
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
303
275
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
276
|
+
if (response.status === 404)
|
|
277
|
+
return undefined;
|
|
278
|
+
if (!response.ok)
|
|
279
|
+
throw new ProviderActionError(ctx.name, response.status, `${label} answered ${response.status}`, errorCode(text));
|
|
280
|
+
if (!text)
|
|
281
|
+
return undefined;
|
|
282
|
+
try {
|
|
283
|
+
return JSON.parse(text);
|
|
307
284
|
}
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
for (const type of ["refresh_token", "rotating_refresh_token"]) {
|
|
311
|
-
for (let page = 0; page < 10; page++) {
|
|
312
|
-
const body = await call({ method: "GET", path: "/device-credentials", query: { user_id: userId, type, page: String(page), per_page: "100" } });
|
|
313
|
-
const list = Array.isArray(body) ? body : [];
|
|
314
|
-
for (const credential of list)
|
|
315
|
-
if (typeof credential?.id === "string" && credential.id)
|
|
316
|
-
ids.push(credential.id);
|
|
317
|
-
if (list.length < 100)
|
|
318
|
-
break;
|
|
319
|
-
}
|
|
285
|
+
catch {
|
|
286
|
+
return undefined;
|
|
320
287
|
}
|
|
321
|
-
|
|
322
|
-
await call({ method: "DELETE", path: `/device-credentials/${encodeURIComponent(id)}` });
|
|
323
|
-
};
|
|
324
|
-
const revoke = async (userId) => {
|
|
325
|
-
const user = segment(userId);
|
|
326
|
-
const call = session();
|
|
327
|
-
const ended = await deleteSessions(call, user);
|
|
328
|
-
// Without the sessions API a login session survives; blocking is the only way to end it.
|
|
329
|
-
if (!ended)
|
|
330
|
-
await block(call, user);
|
|
331
|
-
await deleteRefreshTokens(call, userId, user);
|
|
332
|
-
return ended ? "revoke_sessions" : "ban";
|
|
333
|
-
};
|
|
334
|
-
const ban = async (userId) => {
|
|
335
|
-
const user = segment(userId);
|
|
336
|
-
const call = session();
|
|
337
|
-
await block(call, user);
|
|
338
|
-
// The block already ends a session the plan cannot delete.
|
|
339
|
-
await deleteSessions(call, user);
|
|
340
|
-
await deleteRefreshTokens(call, userId, user);
|
|
341
|
-
};
|
|
342
|
-
return {
|
|
343
|
-
name: "auth0",
|
|
344
|
-
async revokeSessions(userId) {
|
|
345
|
-
await revoke(userId);
|
|
346
|
-
},
|
|
347
|
-
ban,
|
|
348
|
-
async act(action, userId) {
|
|
349
|
-
if (action === "ban") {
|
|
350
|
-
await ban(userId);
|
|
351
|
-
return "ban";
|
|
352
|
-
}
|
|
353
|
-
return revoke(userId);
|
|
354
|
-
},
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
/**
|
|
358
|
-
* A provider for an app with its own login, or for trying the handler before
|
|
359
|
-
* you wire up a real one. revokeSessions and ban each log what they would do
|
|
360
|
-
* and call nothing: no request leaves your process. Replace it with your own
|
|
361
|
-
* LateResultProvider once you know what revoking and banning mean in your app.
|
|
362
|
-
*/
|
|
363
|
-
export function logOnlyActions(options = {}) {
|
|
364
|
-
const log = options.log ?? ((message) => console.info(message));
|
|
365
|
-
return {
|
|
366
|
-
name: "log_only",
|
|
367
|
-
async revokeSessions(userId) {
|
|
368
|
-
segment(userId);
|
|
369
|
-
log(`NoResponse late result (log only): would revoke every session of user ${userId}`, { action: "revoke_sessions", userId });
|
|
370
|
-
},
|
|
371
|
-
async ban(userId) {
|
|
372
|
-
segment(userId);
|
|
373
|
-
log(`NoResponse late result (log only): would ban user ${userId} and end their sessions`, { action: "ban", userId });
|
|
374
|
-
},
|
|
375
|
-
};
|
|
288
|
+
}
|
|
376
289
|
}
|
|
377
290
|
//# sourceMappingURL=late-results.js.map
|