@plitzi/sdk-server 0.33.2 → 0.34.1
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 +54 -0
- package/README.md +283 -8
- package/dist/actions.d.ts +62 -0
- package/dist/actions.js +12 -0
- package/dist/adapters/cloudAdapters.d.ts +72 -0
- package/dist/adapters/cloudAdapters.js +303 -0
- package/dist/adapters/space/index.d.ts +93 -0
- package/dist/adapters/space/index.js +122 -0
- package/dist/adapters/space/resolvers.d.ts +69 -0
- package/dist/adapters/space/resolvers.js +79 -0
- package/dist/adapters/space/space.test.d.ts +1 -0
- package/dist/adapters/space/types.d.ts +64 -0
- package/dist/adapters/space/types.js +41 -0
- package/dist/auth.d.ts +50 -0
- package/dist/auth.js +19 -0
- package/dist/core/auth/api.d.ts +400 -11
- package/dist/core/auth/api.js +1044 -50
- package/dist/core/auth/createAuth.d.ts +135 -30
- package/dist/core/auth/createAuth.js +96 -24
- package/dist/core/auth/credentials.d.ts +9 -3
- package/dist/core/auth/credentials.js +10 -4
- package/dist/core/auth/csrf.d.ts +89 -0
- package/dist/core/auth/csrf.js +234 -0
- package/dist/core/auth/csrf.test.d.ts +1 -0
- package/dist/core/auth/identity.d.ts +6 -0
- package/dist/core/auth/identity.js +37 -8
- package/dist/core/auth/lifecycle.test.d.ts +1 -0
- package/dist/core/auth/oauth/index.d.ts +8 -0
- package/dist/core/auth/oauth/index.js +13 -15
- package/dist/core/auth/passwords.d.ts +15 -0
- package/dist/core/auth/passwords.js +32 -0
- package/dist/core/auth/redirects.d.ts +18 -0
- package/dist/core/auth/redirects.js +31 -0
- package/dist/core/auth/redirects.test.d.ts +1 -0
- package/dist/core/auth/routes.d.ts +4 -2
- package/dist/core/auth/routes.js +234 -9
- package/dist/core/auth/session.d.ts +11 -0
- package/dist/core/auth/session.js +1 -1
- package/dist/core/auth/spaceTokens.d.ts +38 -2
- package/dist/core/auth/spaceTokens.js +54 -2
- package/dist/core/auth/throttle.d.ts +5 -0
- package/dist/core/auth/throttle.js +81 -0
- package/dist/core/auth/throttle.test.d.ts +1 -0
- package/dist/core/auth/tokens.d.ts +83 -12
- package/dist/core/auth/tokens.js +51 -9
- package/dist/core/auth/totp.d.ts +48 -0
- package/dist/core/auth/totp.js +118 -0
- package/dist/core/auth/totp.test.d.ts +1 -0
- package/dist/core/createServer.d.ts +0 -6
- package/dist/core/createServer.js +35 -3
- package/dist/core/fontStore.d.ts +33 -0
- package/dist/core/fontStore.js +53 -0
- package/dist/core/fontStore.test.d.ts +1 -0
- package/dist/core/handlers/authMiddleware.js +1 -1
- package/dist/core/handlers/authRouteHandlers.d.ts +9 -1
- package/dist/core/handlers/authRouteHandlers.js +4 -3
- package/dist/core/handlers/csrfMiddleware.d.ts +20 -0
- package/dist/core/handlers/csrfMiddleware.js +32 -0
- package/dist/core/handlers/oauthRouteHandlers.d.ts +46 -0
- package/dist/core/handlers/oauthRouteHandlers.js +159 -0
- package/dist/core/handlers/socialRouteHandlers.d.ts +40 -0
- package/dist/core/handlers/socialRouteHandlers.js +129 -0
- package/dist/core/handlers/socialRouteHandlers.test.d.ts +1 -0
- package/dist/core/handlers/types.d.ts +22 -5
- package/dist/core/health.d.ts +11 -1
- package/dist/core/health.js +27 -3
- package/dist/core/http/dispatcher.d.ts +1 -1
- package/dist/core/http/dispatcher.js +20 -1
- package/dist/core/http/stages/authApi.js +1 -1
- package/dist/core/http/stages/fontAssets.d.ts +9 -0
- package/dist/core/http/stages/fontAssets.js +27 -0
- package/dist/core/http/stages/oauth.d.ts +19 -0
- package/dist/core/http/stages/oauth.js +118 -0
- package/dist/core/http/types.d.ts +12 -0
- package/dist/core/oauth/authorize.d.ts +22 -0
- package/dist/core/oauth/authorize.js +254 -0
- package/dist/core/oauth/authorize.test.d.ts +1 -0
- package/dist/core/oauth/challenge.d.ts +11 -0
- package/dist/core/oauth/challenge.js +33 -0
- package/dist/core/oauth/consentPage.d.ts +12 -0
- package/dist/core/oauth/consentPage.js +131 -0
- package/dist/core/oauth/metadata.d.ts +41 -0
- package/dist/core/oauth/metadata.js +71 -0
- package/dist/core/oauth/params.d.ts +5 -0
- package/dist/core/oauth/params.js +5 -0
- package/dist/core/oauth/pkce.d.ts +6 -0
- package/dist/core/oauth/pkce.js +15 -0
- package/dist/core/oauth/records.d.ts +72 -0
- package/dist/core/oauth/records.js +49 -0
- package/dist/core/oauth/register.d.ts +5 -0
- package/dist/core/oauth/register.js +55 -0
- package/dist/core/oauth/respond.d.ts +21 -0
- package/dist/core/oauth/respond.js +59 -0
- package/dist/core/oauth/token.d.ts +14 -0
- package/dist/core/oauth/token.js +130 -0
- package/dist/core/previewToken.d.ts +20 -0
- package/dist/core/previewToken.js +29 -1
- package/dist/core/server/pageServer.js +4 -1
- package/dist/core/services/action.d.ts +6 -4
- package/dist/core/services/action.js +103 -10
- package/dist/core/services/registry.js +2 -0
- package/dist/core/services/resolve.d.ts +8 -0
- package/dist/core/services/resolve.js +12 -1
- package/dist/core/staticFiles.js +1 -2
- package/dist/handlers.d.ts +7 -1
- package/dist/handlers.js +4 -1
- package/dist/helpers/buildResponseHelpers.d.ts +8 -0
- package/dist/helpers/buildResponseHelpers.js +9 -1
- package/dist/helpers/buildServerInfo.d.ts +8 -3
- package/dist/helpers/buildServerInfo.js +18 -14
- package/dist/helpers/cache/keys.d.ts +13 -0
- package/dist/helpers/cache/keys.js +12 -1
- package/dist/helpers/onAbort.d.ts +12 -0
- package/dist/helpers/onAbort.js +22 -0
- package/dist/helpers/onAbort.test.d.ts +1 -0
- package/dist/helpers/outboundGuard.d.ts +27 -0
- package/dist/helpers/outboundGuard.js +49 -0
- package/dist/helpers/outboundGuard.test.d.ts +1 -0
- package/dist/helpers/serverLog.d.ts +6 -3
- package/dist/helpers/serverLog.js +20 -3
- package/dist/index.d.ts +6 -0
- package/dist/index.js +9 -4
- package/dist/kernel.d.ts +5 -22
- package/dist/kernel.js +1 -14
- package/dist/modules/actions/{handler.js → connectorWrite.js} +5 -2
- package/dist/modules/actions/connectorWrite.test.d.ts +1 -0
- package/dist/modules/actions/index.d.ts +40 -0
- package/dist/modules/actions/index.js +46 -0
- package/dist/modules/actions/moduleFor.d.ts +3 -0
- package/dist/modules/actions/moduleFor.js +35 -0
- package/dist/modules/actions/moduleFor.test.d.ts +1 -0
- package/dist/modules/actions/runtime/check.d.ts +24 -0
- package/dist/modules/actions/runtime/check.js +111 -0
- package/dist/modules/actions/runtime/check.test.d.ts +1 -0
- package/dist/modules/actions/runtime/errors.d.ts +11 -0
- package/dist/modules/actions/runtime/errors.js +17 -0
- package/dist/modules/actions/runtime/guards.d.ts +126 -0
- package/dist/modules/actions/runtime/guards.js +246 -0
- package/dist/modules/actions/runtime/guards.test.d.ts +1 -0
- package/dist/modules/actions/runtime/kvStore.d.ts +27 -0
- package/dist/modules/actions/runtime/kvStore.js +41 -0
- package/dist/modules/actions/runtime/kvStore.test.d.ts +1 -0
- package/dist/modules/actions/runtime/limits.d.ts +11 -0
- package/dist/modules/actions/runtime/limits.js +35 -0
- package/dist/modules/actions/runtime/memoryKv.d.ts +2 -0
- package/dist/modules/actions/runtime/memoryKv.js +77 -0
- package/dist/modules/actions/runtime/namespaceKv.d.ts +9 -0
- package/dist/modules/actions/runtime/namespaceKv.js +19 -0
- package/dist/modules/actions/runtime/precheck.d.ts +27 -0
- package/dist/modules/actions/runtime/precheck.js +45 -0
- package/dist/modules/actions/runtime/renderResolver.d.ts +15 -0
- package/dist/modules/actions/runtime/renderResolver.js +130 -0
- package/dist/modules/actions/runtime/renderResolver.test.d.ts +1 -0
- package/dist/modules/actions/runtime/renderShare.d.ts +29 -0
- package/dist/modules/actions/runtime/renderShare.js +36 -0
- package/dist/modules/actions/runtime/renderShare.test.d.ts +1 -0
- package/dist/modules/actions/runtime/report.d.ts +14 -0
- package/dist/modules/actions/runtime/report.js +22 -0
- package/dist/modules/actions/runtime/runAction.d.ts +12 -0
- package/dist/modules/actions/runtime/runAction.js +405 -0
- package/dist/modules/actions/runtime/runAction.test.d.ts +1 -0
- package/dist/modules/actions/runtime/runLogger.d.ts +31 -0
- package/dist/modules/actions/runtime/runLogger.js +66 -0
- package/dist/modules/actions/runtime/runLogger.test.d.ts +1 -0
- package/dist/modules/actions/runtime/schedule.d.ts +35 -0
- package/dist/modules/actions/runtime/schedule.js +76 -0
- package/dist/modules/actions/runtime/schedule.test.d.ts +1 -0
- package/dist/modules/actions/runtime/scope.d.ts +40 -0
- package/dist/modules/actions/runtime/scope.js +103 -0
- package/dist/modules/actions/runtime/triggers.d.ts +12 -0
- package/dist/modules/actions/runtime/triggers.js +14 -0
- package/dist/modules/actions/taskCatalog.d.ts +26 -0
- package/dist/modules/actions/taskCatalog.js +24 -0
- package/dist/modules/actions/taskCatalog.test.d.ts +1 -0
- package/dist/modules/actions/tasks/auth.d.ts +2 -0
- package/dist/modules/actions/tasks/auth.js +40 -0
- package/dist/modules/actions/tasks/builtins.d.ts +8 -0
- package/dist/modules/actions/tasks/builtins.js +24 -0
- package/dist/modules/actions/tasks/connector.d.ts +2 -0
- package/dist/modules/actions/tasks/connector.js +141 -0
- package/dist/modules/actions/tasks/db.d.ts +2 -0
- package/dist/modules/actions/tasks/db.js +59 -0
- package/dist/modules/actions/tasks/db.test.d.ts +1 -0
- package/dist/modules/actions/tasks/flow.d.ts +3 -0
- package/dist/modules/actions/tasks/flow.js +109 -0
- package/dist/modules/actions/tasks/helpers.d.ts +16 -0
- package/dist/modules/actions/tasks/helpers.js +30 -0
- package/dist/modules/actions/tasks/http.d.ts +2 -0
- package/dist/modules/actions/tasks/http.js +111 -0
- package/dist/modules/actions/tasks/http.test.d.ts +1 -0
- package/dist/modules/actions/tasks/kv.d.ts +2 -0
- package/dist/modules/actions/tasks/kv.js +102 -0
- package/dist/modules/actions/tasks/kv.test.d.ts +1 -0
- package/dist/modules/actions/tasks/registry.d.ts +10 -0
- package/dist/modules/actions/tasks/registry.js +36 -0
- package/dist/modules/actions/tasks/transform.d.ts +2 -0
- package/dist/modules/actions/tasks/transform.js +34 -0
- package/dist/modules/actions/transport/callHandler.d.ts +24 -0
- package/dist/modules/actions/transport/callHandler.js +233 -0
- package/dist/modules/actions/transport/callHandler.test.d.ts +1 -0
- package/dist/modules/actions/transport/cancelHandler.d.ts +23 -0
- package/dist/modules/actions/transport/cancelHandler.js +23 -0
- package/dist/modules/actions/transport/catalogHandler.d.ts +18 -0
- package/dist/modules/actions/transport/catalogHandler.js +21 -0
- package/dist/modules/actions/transport/stream.d.ts +23 -0
- package/dist/modules/actions/transport/stream.js +100 -0
- package/dist/modules/actions/transport/verifySignature.d.ts +16 -0
- package/dist/modules/actions/transport/verifySignature.js +72 -0
- package/dist/modules/actions/transport/webhookHandler.d.ts +25 -0
- package/dist/modules/actions/transport/webhookHandler.js +206 -0
- package/dist/modules/actions/transport/webhookHandler.test.d.ts +1 -0
- package/dist/modules/actions/types.d.ts +234 -0
- package/dist/modules/connectors/engine.js +4 -1
- package/dist/modules/connectors/resolver.d.ts +10 -2
- package/dist/modules/connectors/resolver.js +8 -3
- package/dist/modules/mysql/accounts.d.ts +5 -0
- package/dist/modules/mysql/accounts.js +365 -0
- package/dist/modules/mysql/accounts.test.d.ts +1 -0
- package/dist/modules/mysql/admin.d.ts +58 -0
- package/dist/modules/mysql/admin.js +133 -0
- package/dist/modules/mysql/config.d.ts +82 -0
- package/dist/modules/mysql/config.js +30 -0
- package/dist/modules/mysql/identities.d.ts +35 -0
- package/dist/modules/mysql/identities.js +43 -0
- package/dist/modules/mysql/index.d.ts +67 -0
- package/dist/modules/mysql/index.js +62 -0
- package/dist/modules/mysql/pool.d.ts +3 -0
- package/dist/modules/mysql/pool.js +88 -0
- package/dist/modules/mysql/query.d.ts +12 -0
- package/dist/modules/mysql/query.js +21 -0
- package/dist/modules/mysql/schema.d.ts +43 -0
- package/dist/modules/mysql/schema.js +340 -0
- package/dist/modules/mysql/schema.test.d.ts +1 -0
- package/dist/modules/mysql/sessions.d.ts +33 -0
- package/dist/modules/mysql/sessions.js +46 -0
- package/dist/modules/mysql/spaceTokens.d.ts +11 -0
- package/dist/modules/mysql/spaceTokens.js +59 -0
- package/dist/modules/mysql/store.integration.test.d.ts +1 -0
- package/dist/modules/rsc/connectorRscData.d.ts +25 -0
- package/dist/modules/rsc/connectorRscData.js +51 -0
- package/dist/modules/rsc/connectorRscData.test.d.ts +1 -0
- package/dist/modules/rsc/handler.js +16 -3
- package/dist/modules/rsc/matchRscPage.d.ts +14 -0
- package/dist/modules/rsc/matchRscPage.js +26 -0
- package/dist/modules/rsc/resolveRscData.d.ts +14 -1
- package/dist/modules/rsc/resolveRscData.js +23 -34
- package/dist/modules/ssr/Component.d.ts +6 -2
- package/dist/modules/ssr/Component.js +3 -1
- package/dist/modules/ssr/draftSession.test.d.ts +1 -0
- package/dist/modules/ssr/handler.js +15 -2
- package/dist/modules/ssr/loadPluginComponents.js +22 -5
- package/dist/modules/ssr/prepareRender.js +87 -8
- package/dist/modules/ssr/prepareRender.test.d.ts +1 -0
- package/dist/modules/ssr/preview.d.ts +21 -8
- package/dist/modules/ssr/preview.js +38 -14
- package/dist/modules/ssr/registerExternalPlugins.js +2 -2
- package/dist/modules/ssr/resolvePageSeo.d.ts +18 -0
- package/dist/modules/ssr/resolvePageSeo.js +26 -0
- package/dist/modules/ssr/resolvePageSeo.test.d.ts +1 -0
- package/dist/modules/ssr/template.test.d.ts +1 -0
- package/dist/modules/ssr/views/template.ejs +71 -5
- package/dist/mysql.d.ts +26 -0
- package/dist/mysql.js +4 -0
- package/dist/oauth.d.ts +24 -0
- package/dist/oauth.js +10 -0
- package/dist/plugins/compile.d.ts +1 -0
- package/dist/plugins/compile.js +37 -3
- package/dist/plugins/manager.d.ts +40 -0
- package/dist/plugins/manager.js +113 -13
- package/dist/plugins/manager.test.d.ts +1 -0
- package/package.json +43 -14
- package/skills/plitzi-authoring/SKILL.md +259 -0
- /package/dist/{modules/actions/handler.test.d.ts → adapters/cloudAdapters.test.d.ts} +0 -0
- /package/dist/modules/actions/{handler.d.ts → connectorWrite.d.ts} +0 -0
package/dist/core/auth/api.js
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
|
+
import { checkPermission } from "./authorize.js";
|
|
2
|
+
import { generateToken, hashPassword, verifyPassword } from "./passwords.js";
|
|
3
|
+
import { createMemoryRateLimit } from "./throttle.js";
|
|
4
|
+
import { authFailureMessage } from "./tokens.js";
|
|
5
|
+
import { generateRecoveryCodes, generateTotpSecret, normalizeRecoveryCode, randomCode, totpUri, verifyTotp } from "./totp.js";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
1
7
|
//#region src/core/auth/api.ts
|
|
8
|
+
/** The user agent, off whatever carried the request. Nothing else here reads headers, so it is done once. */
|
|
9
|
+
var clientOf = (carrier) => {
|
|
10
|
+
const userAgent = carrier?.headers["user-agent"];
|
|
11
|
+
return typeof userAgent === "string" && userAgent ? { userAgent } : void 0;
|
|
12
|
+
};
|
|
2
13
|
var refuse = (status, error, reason) => ({
|
|
3
14
|
ok: false,
|
|
4
15
|
status,
|
|
@@ -19,6 +30,56 @@ var asText = (value) => {
|
|
|
19
30
|
};
|
|
20
31
|
var asString = (value) => asText(value).trim();
|
|
21
32
|
/**
|
|
33
|
+
* The destination a caller named, for a mail to carry — or nothing at all.
|
|
34
|
+
*
|
|
35
|
+
* Carried, not judged: the kernel has no redirect policy, and the link it ends up in is composed by the deployment,
|
|
36
|
+
* which vets it there (`createRedirectPolicy`). Absent rather than empty when there is none, so a mail template never
|
|
37
|
+
* has to know that `''` means "nowhere".
|
|
38
|
+
*/
|
|
39
|
+
var destinationOf = (value) => {
|
|
40
|
+
const redirect = asString(value);
|
|
41
|
+
return redirect ? { redirect } : {};
|
|
42
|
+
};
|
|
43
|
+
var now = () => Math.floor(Date.now() / 1e3);
|
|
44
|
+
var HOUR = 3600;
|
|
45
|
+
/**
|
|
46
|
+
* How long the things this server mails or hands out stay good for. Constants rather than configuration: each is a
|
|
47
|
+
* security answer with one sensible value, and an option here is a question every deployment has to work out an
|
|
48
|
+
* answer to before it can start.
|
|
49
|
+
*/
|
|
50
|
+
var LIFETIME = {
|
|
51
|
+
/** A code from an email. Long enough to go and find it, short enough that a stolen one is worthless. */
|
|
52
|
+
signInCode: 600,
|
|
53
|
+
/** A reset link IS a password, and it sits in an inbox, a mail archive, a forwarded ticket. */
|
|
54
|
+
resetLink: HOUR,
|
|
55
|
+
/** Confirming an address is not a credential in the same way, and people read their mail tomorrow. */
|
|
56
|
+
confirmLink: 24 * HOUR,
|
|
57
|
+
/** A borrowed session — see `admin.impersonate`. Short, and it cannot renew. */
|
|
58
|
+
impersonation: 900
|
|
59
|
+
};
|
|
60
|
+
var STATUSES = [
|
|
61
|
+
"active",
|
|
62
|
+
"inactive",
|
|
63
|
+
"blocked"
|
|
64
|
+
];
|
|
65
|
+
/**
|
|
66
|
+
* Why an account may not hold a session, when it may not.
|
|
67
|
+
*
|
|
68
|
+
* `active` answers whether it may sign in at all; `verified` answers whether its owner can do anything about that.
|
|
69
|
+
* A deployment where confirming an address is what makes an account active — Plitzi is one — refuses both through
|
|
70
|
+
* the same flag, so a screen told only `inactive` has to tell somebody whose address has simply never answered that
|
|
71
|
+
* their password was wrong. It was, in fact, right.
|
|
72
|
+
*/
|
|
73
|
+
var deniedReason = (account) => account && !account.verified ? "unverified" : "inactive";
|
|
74
|
+
/** An account as it may be shown. Never the password hash, and never the credentials — not even to an admin. */
|
|
75
|
+
var profileOf = (account) => ({
|
|
76
|
+
id: account.id,
|
|
77
|
+
username: account.username,
|
|
78
|
+
email: account.email,
|
|
79
|
+
active: account.active,
|
|
80
|
+
verified: account.verified
|
|
81
|
+
});
|
|
82
|
+
/**
|
|
22
83
|
* The HTTP surface of authentication, as functions rather than routes.
|
|
23
84
|
*
|
|
24
85
|
* Nothing here knows about Express, or about this server's own pipeline: a handler is given what the request
|
|
@@ -27,7 +88,87 @@ var asString = (value) => asText(value).trim();
|
|
|
27
88
|
* form without either owning it.
|
|
28
89
|
*/
|
|
29
90
|
var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
30
|
-
const { verifyPassword, hashPassword, generateToken, verifyOnSignup = false } = config;
|
|
91
|
+
const { verifyPassword: verifyPassword$1 = verifyPassword, hashPassword: hashPassword$1 = hashPassword, generateToken: generateToken$1 = generateToken, verifyOnSignup = false, adminPermission = "userManage", impersonationPermission, password: policy = {}, rateLimit = createMemoryRateLimit(), onMailError, onEvent, mfaIssuer, csrf } = config;
|
|
92
|
+
/** Never awaited, never able to throw: a logging outage must not become an authentication outage. */
|
|
93
|
+
const record = (event) => {
|
|
94
|
+
if (!onEvent) return;
|
|
95
|
+
try {
|
|
96
|
+
onEvent({
|
|
97
|
+
...event,
|
|
98
|
+
at: Math.floor(Date.now() / 1e3)
|
|
99
|
+
});
|
|
100
|
+
} catch (error) {
|
|
101
|
+
console.error("[auth] security event handler threw:", error);
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* A single-use link token that stops working.
|
|
106
|
+
*
|
|
107
|
+
* The deadline is carried **inside the token**, after a `~`, and that is what makes this cost nothing: no column,
|
|
108
|
+
* no adapter argument, and nothing a deployment with its own store has to remember to enforce — which is the
|
|
109
|
+
* failure mode of putting the check in the adapters, where forgetting it is silent and looks like everything
|
|
110
|
+
* working. It is not a secret and does not need to be: the token is compared against the stored copy, so editing
|
|
111
|
+
* the deadline just produces a string that matches no row.
|
|
112
|
+
*
|
|
113
|
+
* A token issued before this existed has no `~`, reads as no deadline, and keeps working — the links already in
|
|
114
|
+
* people's inboxes when a deployment upgrades.
|
|
115
|
+
*/
|
|
116
|
+
const mintLink = (random, lifetime) => `${random}~${now() + lifetime}`;
|
|
117
|
+
const linkExpired = (token) => {
|
|
118
|
+
const deadline = Number(token.slice(token.lastIndexOf("~") + 1));
|
|
119
|
+
return Number.isFinite(deadline) && token.includes("~") && deadline < now();
|
|
120
|
+
};
|
|
121
|
+
/** Recovery codes are high-entropy, so a fast digest is right — scrypt ten times per sign-in would not be. */
|
|
122
|
+
const digestRecoveryCode = (code) => createHash("sha256").update(normalizeRecoveryCode(code)).digest("hex");
|
|
123
|
+
/**
|
|
124
|
+
* Sends, and never fails the flow if it cannot.
|
|
125
|
+
*
|
|
126
|
+
* Every one of these goes out AFTER something has already been committed — an account created, an address
|
|
127
|
+
* changed, a reset token stored. Letting the mail provider decide whether that request succeeded turns an
|
|
128
|
+
* outage at Brevo into a 500 on a change that did happen, which is the worst of both: the caller is told it
|
|
129
|
+
* failed and retries against the new state. Reported instead, and the person can ask for another.
|
|
130
|
+
*/
|
|
131
|
+
const deliver = async (message) => {
|
|
132
|
+
try {
|
|
133
|
+
await adapters.sendMail?.(message);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
(onMailError ?? ((cause) => console.error("[auth] could not send mail:", cause)))(error, {
|
|
136
|
+
to: message.to,
|
|
137
|
+
template: message.template
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
/**
|
|
142
|
+
* Checked before the password is, so a throttled attempt costs no hash — which is the difference between a rate
|
|
143
|
+
* limit and a slightly slower way to be brute-forced.
|
|
144
|
+
*/
|
|
145
|
+
/** Whatever was counted against this key is forgiven: the attempt it was guarding against succeeded. */
|
|
146
|
+
const throttleSucceeded = (attempt) => {
|
|
147
|
+
Promise.resolve(rateLimit({
|
|
148
|
+
...attempt,
|
|
149
|
+
succeeded: true
|
|
150
|
+
})).catch(() => void 0);
|
|
151
|
+
};
|
|
152
|
+
const throttled = async (attempt) => {
|
|
153
|
+
const verdict = await rateLimit(attempt);
|
|
154
|
+
if (typeof verdict === "boolean" ? verdict : verdict.allowed) return;
|
|
155
|
+
const retryAfter = typeof verdict === "boolean" ? void 0 : verdict.retryAfter;
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
status: 429,
|
|
159
|
+
body: {
|
|
160
|
+
error: "Too many attempts",
|
|
161
|
+
...retryAfter !== void 0 ? { retryAfter } : {}
|
|
162
|
+
}
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
/** The one place a new password is judged, so signing up, resetting and changing cannot disagree about it. */
|
|
166
|
+
const rejectPassword = async (value, context = {}) => {
|
|
167
|
+
const minLength = policy.minLength ?? 8;
|
|
168
|
+
if (value.length < minLength) return refuse(400, `The password must be at least ${minLength} characters`);
|
|
169
|
+
const reason = await policy.validate?.(value, context);
|
|
170
|
+
return reason ? refuse(400, reason) : void 0;
|
|
171
|
+
};
|
|
31
172
|
/**
|
|
32
173
|
* What this deployment offers, decided by what it supplied and nothing else.
|
|
33
174
|
*
|
|
@@ -37,30 +178,64 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
37
178
|
* that answers 404 for no reason anyone can see. Declining a flow is now one act: do not implement it.
|
|
38
179
|
*/
|
|
39
180
|
const capabilities = {
|
|
40
|
-
passwordLogin: !!adapters.findByUsername
|
|
181
|
+
passwordLogin: !!adapters.findByUsername,
|
|
41
182
|
refresh: !!adapters.findByRefreshToken,
|
|
42
|
-
signup: !!adapters.createAccount
|
|
43
|
-
passwordReset: !!adapters.findByEmail && !!adapters.setResetToken
|
|
44
|
-
emailVerification: !!adapters.findByValidationToken && !!adapters.
|
|
45
|
-
exchange: !!adapters.exchangeCredential
|
|
183
|
+
signup: !!adapters.createAccount,
|
|
184
|
+
passwordReset: !!adapters.findByEmail && !!adapters.setResetToken,
|
|
185
|
+
emailVerification: !!adapters.findByValidationToken && !!adapters.setVerified,
|
|
186
|
+
exchange: !!adapters.exchangeCredential,
|
|
187
|
+
mfa: !!adapters.loadMfa && !!adapters.saveMfa,
|
|
188
|
+
passwordless: !!adapters.saveOtp && !!adapters.findOtp && !!adapters.findByEmail && !!adapters.sendMail,
|
|
189
|
+
profile: !!adapters.updateAccount,
|
|
190
|
+
passwordChange: !!adapters.findById && !!adapters.setPassword,
|
|
191
|
+
accountDeletion: !!adapters.deleteAccount,
|
|
192
|
+
sessionList: !!adapters.listSessions,
|
|
193
|
+
administration: !!adapters.listAccounts && !!adapters.findById,
|
|
194
|
+
/** Changing an address by confirming it, rather than on the spot. Needs somewhere to park it and a way to write. */
|
|
195
|
+
emailChange: !!adapters.setPendingEmail && !!adapters.findByPendingEmail && !!adapters.clearPendingEmail && !!adapters.updateAccount && !!adapters.sendMail,
|
|
196
|
+
/** Off unless the deployment named the capability it takes — see `impersonationPermission`. */
|
|
197
|
+
impersonation: !!impersonationPermission && !!adapters.findById
|
|
198
|
+
};
|
|
199
|
+
/**
|
|
200
|
+
* An administrator acting on somebody else.
|
|
201
|
+
*
|
|
202
|
+
* `subject` refuses the case where that somebody is themselves. Banning or deleting your own account through the
|
|
203
|
+
* admin surface is how a deployment loses its last administrator, and it is never what was meant: closing your
|
|
204
|
+
* own account is the self-service flow, which asks for a password precisely because it is irreversible.
|
|
205
|
+
*/
|
|
206
|
+
const requireAdmin = (actor, subject) => {
|
|
207
|
+
const check = checkPermission(actor, adminPermission);
|
|
208
|
+
if (!check.ok) return refuse(check.status, check.error, check.status === 401 ? "missing" : void 0);
|
|
209
|
+
if (subject !== void 0 && subject === actor?.id) return refuse(400, "Use the self-service route to act on your own account");
|
|
46
210
|
};
|
|
47
|
-
|
|
211
|
+
/**
|
|
212
|
+
* A session for this account. `options` is only for the one that is not an ordinary sign-in: a borrowed session
|
|
213
|
+
* carries who borrowed it and cannot renew itself, so it dies at its deadline instead of quietly living for a
|
|
214
|
+
* month behind a refresh token.
|
|
215
|
+
*/
|
|
216
|
+
const issue = async (userId, context, options = {}) => {
|
|
217
|
+
const { actingAs, ttlSeconds, renewable = true } = options;
|
|
48
218
|
const now = Math.floor(Date.now() / 1e3);
|
|
49
219
|
const session = {
|
|
50
|
-
token: tokens.generateUserToken(userId
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
220
|
+
token: tokens.generateUserToken(userId, {
|
|
221
|
+
actingAs,
|
|
222
|
+
ttlSeconds
|
|
223
|
+
}),
|
|
224
|
+
expiresAt: now + (ttlSeconds ?? tokens.lifetimes.access),
|
|
225
|
+
...renewable ? {
|
|
226
|
+
refreshToken: tokens.generateRefreshToken(userId),
|
|
227
|
+
refreshExpiresAt: now + tokens.lifetimes.refresh
|
|
228
|
+
} : {}
|
|
54
229
|
};
|
|
55
|
-
await adapters.saveSession(userId, session);
|
|
230
|
+
await adapters.saveSession(userId, session, context);
|
|
56
231
|
return session;
|
|
57
232
|
};
|
|
58
233
|
/**
|
|
59
234
|
* The body a grant answers with. `details` is the same object the session endpoint returns, deliberately: it makes
|
|
60
235
|
* a grant an identity, so a client that has just signed in or renewed knows who it is without a second request.
|
|
61
236
|
*/
|
|
62
|
-
const grantBody = async (account, session) => {
|
|
63
|
-
const access = await adapters.loadAccess(account.id);
|
|
237
|
+
const grantBody = async (account, session, loaded) => {
|
|
238
|
+
const access = loaded ?? await adapters.loadAccess(account.id);
|
|
64
239
|
const now = Math.floor(Date.now() / 1e3);
|
|
65
240
|
return {
|
|
66
241
|
success: true,
|
|
@@ -80,6 +255,57 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
80
255
|
refresh_expire_at: session.refreshExpiresAt
|
|
81
256
|
};
|
|
82
257
|
};
|
|
258
|
+
/**
|
|
259
|
+
* Turning a credential from an external identity provider into a session here, answered in accounts.
|
|
260
|
+
*
|
|
261
|
+
* `offered` separates "this deployment does not do exchanges" from "that credential was refused" — a 404 and a
|
|
262
|
+
* 401 respectively, and a caller that cannot tell them apart reports a misconfiguration as a bad password.
|
|
263
|
+
*/
|
|
264
|
+
const exchangeAccount = async (provider, token, carrier) => {
|
|
265
|
+
if (!capabilities.exchange) return {
|
|
266
|
+
ok: false,
|
|
267
|
+
offered: false,
|
|
268
|
+
status: 404,
|
|
269
|
+
error: "Not found"
|
|
270
|
+
};
|
|
271
|
+
if (!provider || !token) return {
|
|
272
|
+
ok: false,
|
|
273
|
+
offered: true,
|
|
274
|
+
status: 400,
|
|
275
|
+
error: "A provider and a token are required"
|
|
276
|
+
};
|
|
277
|
+
if (await throttled({
|
|
278
|
+
action: "exchange",
|
|
279
|
+
key: provider,
|
|
280
|
+
carrier
|
|
281
|
+
})) return {
|
|
282
|
+
ok: false,
|
|
283
|
+
offered: true,
|
|
284
|
+
status: 429,
|
|
285
|
+
error: "Too many attempts"
|
|
286
|
+
};
|
|
287
|
+
const result = await adapters.exchangeCredential?.(provider, token, carrier);
|
|
288
|
+
if (!result) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
offered: true,
|
|
291
|
+
status: 401,
|
|
292
|
+
error: "Token Invalid",
|
|
293
|
+
reason: "revoked"
|
|
294
|
+
};
|
|
295
|
+
if ("error" in result) return {
|
|
296
|
+
ok: false,
|
|
297
|
+
offered: true,
|
|
298
|
+
status: result.status ?? 400,
|
|
299
|
+
error: result.error,
|
|
300
|
+
reason: "revoked"
|
|
301
|
+
};
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
account: result,
|
|
305
|
+
access: await adapters.loadAccess(result.id),
|
|
306
|
+
session: await issue(result.id, { client: clientOf(carrier) })
|
|
307
|
+
};
|
|
308
|
+
};
|
|
83
309
|
return {
|
|
84
310
|
capabilities,
|
|
85
311
|
/** What a client reads to know which of the flows below actually exist here. */
|
|
@@ -105,7 +331,8 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
105
331
|
email: actor.email,
|
|
106
332
|
verified: actor.verified,
|
|
107
333
|
roles: actor.roles,
|
|
108
|
-
permissions: actor.permissions
|
|
334
|
+
permissions: actor.permissions,
|
|
335
|
+
...actor.impersonatedBy === void 0 ? {} : { impersonatedBy: actor.impersonatedBy }
|
|
109
336
|
},
|
|
110
337
|
access_token: actor.token,
|
|
111
338
|
expire_in: Math.max(0, actor.expiresAt - now),
|
|
@@ -113,29 +340,373 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
113
340
|
}
|
|
114
341
|
};
|
|
115
342
|
},
|
|
116
|
-
login: async (credentials) => {
|
|
343
|
+
login: async (credentials, carrier) => {
|
|
117
344
|
if (!capabilities.passwordLogin) return NOT_OFFERED;
|
|
118
345
|
const username = asString(credentials.username);
|
|
119
346
|
const password = asText(credentials.password);
|
|
120
347
|
if (!username || !password) return refuse(400, "A username and a password are required");
|
|
121
|
-
const
|
|
348
|
+
const limited = await throttled({
|
|
349
|
+
action: "login",
|
|
350
|
+
key: username,
|
|
351
|
+
carrier
|
|
352
|
+
});
|
|
353
|
+
if (limited) return limited;
|
|
354
|
+
/**
|
|
355
|
+
* The username, or the email — in that order.
|
|
356
|
+
*
|
|
357
|
+
* Every sign-in screen worth using offers both, because the one thing a person reliably remembers about an
|
|
358
|
+
* account is the address it was created with. Username first so an account whose name happens to be somebody
|
|
359
|
+
* else's address cannot shadow the owner of that address; the email lookup only runs when the first misses,
|
|
360
|
+
* so this costs a second query on a failed sign-in and nothing on a successful one.
|
|
361
|
+
*
|
|
362
|
+
* `findByEmail` is optional like everything else here: a deployment that supplies no email store keeps exactly
|
|
363
|
+
* the behaviour it had.
|
|
364
|
+
*/
|
|
365
|
+
const account = await adapters.findByUsername?.(username) ?? (username.includes("@") ? await adapters.findByEmail?.(username) : void 0);
|
|
122
366
|
if (!account) return refuse(401, "Invalid credentials");
|
|
123
|
-
if (!account.
|
|
124
|
-
|
|
125
|
-
|
|
367
|
+
if (!account.passwordHash || !await verifyPassword$1(password, account.passwordHash)) {
|
|
368
|
+
record({
|
|
369
|
+
type: "login.failed",
|
|
370
|
+
userId: account.id,
|
|
371
|
+
carrier,
|
|
372
|
+
detail: { username }
|
|
373
|
+
});
|
|
374
|
+
return refuse(401, "Invalid credentials");
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Whether this account may hold a session — asked AFTER the password, and that order is the point.
|
|
378
|
+
*
|
|
379
|
+
* Anything said about an account before its password is checked is said to anybody who types an address. The
|
|
380
|
+
* other way round, "that address has not been confirmed" once cost nothing to establish: two attempts with a
|
|
381
|
+
* made-up password told a stranger which of a list of addresses have accounts here and which do not. It is
|
|
382
|
+
* only a refusal somebody has earned the right to understand once they have proved the account is theirs.
|
|
383
|
+
*/
|
|
384
|
+
if (!account.active) {
|
|
385
|
+
const reason = deniedReason(account);
|
|
386
|
+
return refuse(401, authFailureMessage[reason], reason);
|
|
387
|
+
}
|
|
388
|
+
if ((capabilities.mfa ? await adapters.loadMfa?.(account.id) : void 0)?.confirmedAt) {
|
|
389
|
+
record({
|
|
390
|
+
type: "login.mfa-required",
|
|
391
|
+
userId: account.id,
|
|
392
|
+
carrier
|
|
393
|
+
});
|
|
394
|
+
return {
|
|
395
|
+
ok: true,
|
|
396
|
+
body: {
|
|
397
|
+
success: false,
|
|
398
|
+
mfaRequired: true,
|
|
399
|
+
mfaToken: tokens.generateMfaChallenge(account.id)
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
const session = await issue(account.id, { client: clientOf(carrier) });
|
|
404
|
+
throttleSucceeded({
|
|
405
|
+
action: "login",
|
|
406
|
+
key: username,
|
|
407
|
+
carrier
|
|
408
|
+
});
|
|
409
|
+
record({
|
|
410
|
+
type: "login",
|
|
411
|
+
userId: account.id,
|
|
412
|
+
carrier
|
|
413
|
+
});
|
|
126
414
|
return {
|
|
127
415
|
ok: true,
|
|
128
416
|
body: await grantBody(account, session),
|
|
129
417
|
session
|
|
130
418
|
};
|
|
131
419
|
},
|
|
132
|
-
|
|
420
|
+
/**
|
|
421
|
+
* The second half of a sign-in that owed a factor.
|
|
422
|
+
*
|
|
423
|
+
* Takes a TOTP code or a recovery code. A recovery code is SPENT — removed from the stored list — because one
|
|
424
|
+
* that survives being used is a password with extra steps.
|
|
425
|
+
*/
|
|
426
|
+
completeMfa: async (mfaToken, code, carrier) => {
|
|
427
|
+
if (!capabilities.mfa) return NOT_OFFERED;
|
|
428
|
+
const verified = tokens.verifyMfaChallenge(mfaToken);
|
|
429
|
+
if (!verified.ok) return refuse(401, authFailureMessage[verified.reason], verified.reason);
|
|
430
|
+
const userId = Number(verified.payload.sub);
|
|
431
|
+
const limited = await throttled({
|
|
432
|
+
action: "mfa",
|
|
433
|
+
key: String(userId),
|
|
434
|
+
carrier
|
|
435
|
+
});
|
|
436
|
+
if (limited) return limited;
|
|
437
|
+
const account = await adapters.findById?.(userId);
|
|
438
|
+
const mfa = await adapters.loadMfa?.(userId);
|
|
439
|
+
if (!account || !mfa?.confirmedAt) return refuse(401, "Invalid credentials");
|
|
440
|
+
if (!account.active) {
|
|
441
|
+
const reason = deniedReason(account);
|
|
442
|
+
return refuse(401, authFailureMessage[reason], reason);
|
|
443
|
+
}
|
|
444
|
+
const stored = mfa.recoveryCodes ?? [];
|
|
445
|
+
const supplied = digestRecoveryCode(code);
|
|
446
|
+
const usedRecovery = stored.includes(supplied);
|
|
447
|
+
if (!usedRecovery && !verifyTotp(mfa.secret, code)) {
|
|
448
|
+
record({
|
|
449
|
+
type: "mfa.failed",
|
|
450
|
+
userId,
|
|
451
|
+
carrier
|
|
452
|
+
});
|
|
453
|
+
return refuse(401, "Invalid code");
|
|
454
|
+
}
|
|
455
|
+
if (usedRecovery) await adapters.saveMfa?.(userId, {
|
|
456
|
+
...mfa,
|
|
457
|
+
recoveryCodes: stored.filter((entry) => entry !== supplied)
|
|
458
|
+
});
|
|
459
|
+
const session = await issue(userId, { client: clientOf(carrier) });
|
|
460
|
+
throttleSucceeded({
|
|
461
|
+
action: "mfa",
|
|
462
|
+
key: String(userId),
|
|
463
|
+
carrier
|
|
464
|
+
});
|
|
465
|
+
record({
|
|
466
|
+
type: "login",
|
|
467
|
+
userId,
|
|
468
|
+
carrier,
|
|
469
|
+
detail: { recoveryCode: usedRecovery }
|
|
470
|
+
});
|
|
471
|
+
return {
|
|
472
|
+
ok: true,
|
|
473
|
+
body: {
|
|
474
|
+
...await grantBody(account, session),
|
|
475
|
+
...usedRecovery ? { recoveryCodeUsed: true } : {}
|
|
476
|
+
},
|
|
477
|
+
session
|
|
478
|
+
};
|
|
479
|
+
},
|
|
480
|
+
/**
|
|
481
|
+
* Signing in with a code sent to an email address, and no password at all.
|
|
482
|
+
*
|
|
483
|
+
* Two decisions worth stating. **The request answers the same whether the address exists or not** — anything
|
|
484
|
+
* else turns this endpoint into a way to ask which addresses have accounts. And **it never creates an
|
|
485
|
+
* account**: a sign-in flow that silently registers whoever asks is a different feature, and one a deployment
|
|
486
|
+
* should choose deliberately rather than inherit.
|
|
487
|
+
*/
|
|
488
|
+
passwordless: {
|
|
489
|
+
request: async (email, carrier) => {
|
|
490
|
+
if (!capabilities.passwordless) return NOT_OFFERED;
|
|
491
|
+
const address = asString(email).toLowerCase();
|
|
492
|
+
if (!address) return refuse(400, "An email is required");
|
|
493
|
+
const limited = await throttled({
|
|
494
|
+
action: "passwordless",
|
|
495
|
+
key: address,
|
|
496
|
+
carrier
|
|
497
|
+
});
|
|
498
|
+
if (limited) return limited;
|
|
499
|
+
const account = await adapters.findByEmail?.(address);
|
|
500
|
+
if (account?.active) {
|
|
501
|
+
/**
|
|
502
|
+
* Six characters somebody reads out of an email and types, not a link token — `generateToken` mints 128
|
|
503
|
+
* random bits, which is right for something clicked and hostile for something retyped. Two different jobs
|
|
504
|
+
* that were sharing one function, and the deployment that fixed the code for typing quietly weakened
|
|
505
|
+
* every reset link it mailed.
|
|
506
|
+
*/
|
|
507
|
+
const code = randomCode(6);
|
|
508
|
+
await adapters.saveOtp?.({
|
|
509
|
+
purpose: "signin",
|
|
510
|
+
identifier: address,
|
|
511
|
+
codeHash: digestRecoveryCode(code),
|
|
512
|
+
expiresAt: now() + LIFETIME.signInCode,
|
|
513
|
+
userId: account.id
|
|
514
|
+
});
|
|
515
|
+
await deliver({
|
|
516
|
+
to: account.email,
|
|
517
|
+
template: "signin-code",
|
|
518
|
+
data: {
|
|
519
|
+
username: account.username,
|
|
520
|
+
code
|
|
521
|
+
}
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
return {
|
|
525
|
+
ok: true,
|
|
526
|
+
body: { message: "If that address has an account, a sign-in link is on its way" }
|
|
527
|
+
};
|
|
528
|
+
},
|
|
529
|
+
complete: async (email, code, carrier) => {
|
|
530
|
+
if (!capabilities.passwordless) return NOT_OFFERED;
|
|
531
|
+
const address = asString(email).toLowerCase();
|
|
532
|
+
if (!address || !code) return refuse(400, "An email and a code are required");
|
|
533
|
+
const limited = await throttled({
|
|
534
|
+
action: "passwordless",
|
|
535
|
+
key: address,
|
|
536
|
+
carrier
|
|
537
|
+
});
|
|
538
|
+
if (limited) return limited;
|
|
539
|
+
const stored = await adapters.findOtp?.("signin", address);
|
|
540
|
+
if (!stored || stored.expiresAt < now() || stored.codeHash !== digestRecoveryCode(code)) {
|
|
541
|
+
record({
|
|
542
|
+
type: "login.failed",
|
|
543
|
+
carrier,
|
|
544
|
+
detail: {
|
|
545
|
+
method: "passwordless",
|
|
546
|
+
email: address
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
return refuse(401, "Invalid or expired code");
|
|
550
|
+
}
|
|
551
|
+
await adapters.consumeOtp?.(stored.id);
|
|
552
|
+
const account = stored.userId === void 0 ? void 0 : await adapters.findById?.(stored.userId);
|
|
553
|
+
if (!account?.active) {
|
|
554
|
+
const reason = deniedReason(account);
|
|
555
|
+
return refuse(401, authFailureMessage[reason], reason);
|
|
556
|
+
}
|
|
557
|
+
if ((capabilities.mfa ? await adapters.loadMfa?.(account.id) : void 0)?.confirmedAt) {
|
|
558
|
+
record({
|
|
559
|
+
type: "login.mfa-required",
|
|
560
|
+
userId: account.id,
|
|
561
|
+
carrier
|
|
562
|
+
});
|
|
563
|
+
return {
|
|
564
|
+
ok: true,
|
|
565
|
+
body: {
|
|
566
|
+
success: false,
|
|
567
|
+
mfaRequired: true,
|
|
568
|
+
mfaToken: tokens.generateMfaChallenge(account.id)
|
|
569
|
+
}
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
const session = await issue(account.id, { client: clientOf(carrier) });
|
|
573
|
+
throttleSucceeded({
|
|
574
|
+
action: "passwordless",
|
|
575
|
+
key: address,
|
|
576
|
+
carrier
|
|
577
|
+
});
|
|
578
|
+
record({
|
|
579
|
+
type: "login",
|
|
580
|
+
userId: account.id,
|
|
581
|
+
carrier,
|
|
582
|
+
detail: { method: "passwordless" }
|
|
583
|
+
});
|
|
584
|
+
return {
|
|
585
|
+
ok: true,
|
|
586
|
+
body: await grantBody(account, session),
|
|
587
|
+
session
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
},
|
|
591
|
+
/**
|
|
592
|
+
* Everything about this account's second factor. `begin` hands back a secret and the URI an app scans; nothing
|
|
593
|
+
* is in force until `confirm` proves a code from it.
|
|
594
|
+
*/
|
|
595
|
+
mfa: {
|
|
596
|
+
status: async (actor) => {
|
|
597
|
+
if (!capabilities.mfa) return NOT_OFFERED;
|
|
598
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
599
|
+
const mfa = await adapters.loadMfa?.(actor.id);
|
|
600
|
+
return {
|
|
601
|
+
ok: true,
|
|
602
|
+
body: {
|
|
603
|
+
enabled: Boolean(mfa?.confirmedAt),
|
|
604
|
+
pending: Boolean(mfa && !mfa.confirmedAt),
|
|
605
|
+
recoveryCodesRemaining: mfa?.recoveryCodes?.length ?? 0
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
begin: async (actor) => {
|
|
610
|
+
if (!capabilities.mfa) return NOT_OFFERED;
|
|
611
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
612
|
+
if ((await adapters.loadMfa?.(actor.id))?.confirmedAt) return refuse(409, "A second factor is already set up. Remove it before enrolling another.");
|
|
613
|
+
const secret = generateTotpSecret();
|
|
614
|
+
await adapters.saveMfa?.(actor.id, { secret });
|
|
615
|
+
return {
|
|
616
|
+
ok: true,
|
|
617
|
+
body: {
|
|
618
|
+
secret,
|
|
619
|
+
uri: totpUri({
|
|
620
|
+
secret,
|
|
621
|
+
account: actor.email || actor.username,
|
|
622
|
+
issuer: mfaIssuer ?? tokens.issuer
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
},
|
|
627
|
+
/** Proves the app was actually set up, and only then does the factor start being required. */
|
|
628
|
+
confirm: async (actor, code) => {
|
|
629
|
+
if (!capabilities.mfa) return NOT_OFFERED;
|
|
630
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
631
|
+
const mfa = await adapters.loadMfa?.(actor.id);
|
|
632
|
+
if (!mfa) return refuse(400, "Start the enrolment first");
|
|
633
|
+
if (mfa.confirmedAt) return refuse(409, "A second factor is already set up");
|
|
634
|
+
if (!verifyTotp(mfa.secret, code)) {
|
|
635
|
+
record({
|
|
636
|
+
type: "mfa.failed",
|
|
637
|
+
userId: actor.id
|
|
638
|
+
});
|
|
639
|
+
return refuse(401, "Invalid code");
|
|
640
|
+
}
|
|
641
|
+
const plain = generateRecoveryCodes();
|
|
642
|
+
await adapters.saveMfa?.(actor.id, {
|
|
643
|
+
...mfa,
|
|
644
|
+
confirmedAt: Math.floor(Date.now() / 1e3),
|
|
645
|
+
recoveryCodes: plain.map(digestRecoveryCode)
|
|
646
|
+
});
|
|
647
|
+
record({
|
|
648
|
+
type: "mfa.enabled",
|
|
649
|
+
userId: actor.id
|
|
650
|
+
});
|
|
651
|
+
return {
|
|
652
|
+
ok: true,
|
|
653
|
+
body: {
|
|
654
|
+
success: true,
|
|
655
|
+
recoveryCodes: plain
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
},
|
|
659
|
+
/** Removing a factor is a security downgrade, so it asks for the password the way deleting an account does. */
|
|
660
|
+
disable: async (actor, password) => {
|
|
661
|
+
if (!capabilities.mfa || !adapters.deleteMfa) return NOT_OFFERED;
|
|
662
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
663
|
+
const account = await adapters.findById?.(actor.id);
|
|
664
|
+
if (account?.passwordHash) {
|
|
665
|
+
if (!password || !await verifyPassword$1(password, account.passwordHash)) return refuse(401, "Invalid credentials");
|
|
666
|
+
}
|
|
667
|
+
await adapters.deleteMfa(actor.id);
|
|
668
|
+
record({
|
|
669
|
+
type: "mfa.disabled",
|
|
670
|
+
userId: actor.id
|
|
671
|
+
});
|
|
672
|
+
return {
|
|
673
|
+
ok: true,
|
|
674
|
+
body: { success: true }
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
},
|
|
678
|
+
refresh: async (refreshToken, carrier) => {
|
|
133
679
|
if (!capabilities.refresh) return NOT_OFFERED;
|
|
134
680
|
if (!refreshToken) return refuse(400, "Refresh token is required");
|
|
681
|
+
/**
|
|
682
|
+
* Every refusal below ends the session in this browser, because each of them means the credential it just
|
|
683
|
+
* presented can never work again: no such row, past its deadline, or an account that may not hold one. What
|
|
684
|
+
* is being cleared is not authority — the row is already gone — it is the browser's reason to keep asking.
|
|
685
|
+
*/
|
|
135
686
|
const account = await adapters.findByRefreshToken?.(refreshToken);
|
|
136
|
-
if (!account || !account.refreshExpiresAt || account.refreshExpiresAt < Math.floor(Date.now() / 1e3)) return
|
|
137
|
-
|
|
138
|
-
|
|
687
|
+
if (!account || !account.refreshExpiresAt || account.refreshExpiresAt < Math.floor(Date.now() / 1e3)) return {
|
|
688
|
+
...refuse(401, "Invalid or expired refresh token", "expired"),
|
|
689
|
+
endSession: true
|
|
690
|
+
};
|
|
691
|
+
if (!account.active) {
|
|
692
|
+
const reason = deniedReason(account);
|
|
693
|
+
return {
|
|
694
|
+
...refuse(401, authFailureMessage[reason], reason),
|
|
695
|
+
endSession: true
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
const cap = tokens.lifetimes.session;
|
|
699
|
+
if (cap > 0 && account.sessionStartedAt !== void 0 && account.sessionStartedAt + cap < now()) {
|
|
700
|
+
await adapters.clearSession({ refreshToken });
|
|
701
|
+
return {
|
|
702
|
+
...refuse(401, "This session has reached its maximum lifetime", "expired"),
|
|
703
|
+
endSession: true
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
const session = await issue(account.id, {
|
|
707
|
+
replaces: { refreshToken },
|
|
708
|
+
client: clientOf(carrier)
|
|
709
|
+
});
|
|
139
710
|
return {
|
|
140
711
|
ok: true,
|
|
141
712
|
body: await grantBody(account, session),
|
|
@@ -166,43 +737,63 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
166
737
|
};
|
|
167
738
|
},
|
|
168
739
|
exchange: async (provider, token, carrier) => {
|
|
169
|
-
|
|
170
|
-
if (!
|
|
171
|
-
const
|
|
172
|
-
if (!result) return refuse(401, "Token Invalid", "revoked");
|
|
173
|
-
if ("error" in result) return refuse(result.status ?? 400, result.error, "revoked");
|
|
174
|
-
const session = await issue(result.id);
|
|
740
|
+
const result = await exchangeAccount(provider, token, carrier);
|
|
741
|
+
if (!result.ok) return result.offered ? refuse(result.status, result.error, result.reason) : NOT_OFFERED;
|
|
742
|
+
const { account, access, session } = result;
|
|
175
743
|
return {
|
|
176
744
|
ok: true,
|
|
177
|
-
body: await grantBody(
|
|
745
|
+
body: await grantBody(account, session, access),
|
|
178
746
|
session
|
|
179
747
|
};
|
|
180
748
|
},
|
|
749
|
+
/**
|
|
750
|
+
* The same exchange, answering in accounts rather than in HTTP.
|
|
751
|
+
*
|
|
752
|
+
* A page server needs who came back and the session they got; the flow above needs a body and a status. They
|
|
753
|
+
* are the same act, and the translation between them has one correct answer — so it is made once, here, rather
|
|
754
|
+
* than by every deployment that wires an external identity provider into a rendered page.
|
|
755
|
+
*/
|
|
756
|
+
exchangeAccount,
|
|
181
757
|
signup: async (fields) => {
|
|
182
758
|
if (!capabilities.signup) return NOT_OFFERED;
|
|
183
759
|
const username = asString(fields.username);
|
|
184
760
|
const email = asString(fields.email);
|
|
185
761
|
const password = asText(fields.password);
|
|
186
762
|
if (!username || !email || !password) return refuse(400, "A username, an email and a password are required");
|
|
763
|
+
const weak = await rejectPassword(password, {
|
|
764
|
+
username,
|
|
765
|
+
email
|
|
766
|
+
});
|
|
767
|
+
if (weak) return weak;
|
|
768
|
+
const limited = await throttled({
|
|
769
|
+
action: "signup",
|
|
770
|
+
key: email
|
|
771
|
+
});
|
|
772
|
+
if (limited) return limited;
|
|
187
773
|
if (await adapters.findByUsername?.(username) ?? await adapters.findByEmail?.(email)) return refuse(400, "User already exists");
|
|
188
|
-
if (!adapters.createAccount
|
|
774
|
+
if (!adapters.createAccount) return NOT_OFFERED;
|
|
189
775
|
const account = await adapters.createAccount({
|
|
190
776
|
username,
|
|
191
777
|
email,
|
|
192
|
-
passwordHash: await hashPassword(password)
|
|
778
|
+
passwordHash: await hashPassword$1(password)
|
|
193
779
|
});
|
|
194
|
-
if (!verifyOnSignup && capabilities.emailVerification &&
|
|
195
|
-
const validationToken = generateToken();
|
|
780
|
+
if (!verifyOnSignup && capabilities.emailVerification && adapters.setValidationToken) {
|
|
781
|
+
const validationToken = mintLink(generateToken$1(), LIFETIME.confirmLink);
|
|
196
782
|
await adapters.setValidationToken(account.id, validationToken);
|
|
197
|
-
await
|
|
783
|
+
await deliver({
|
|
198
784
|
to: email,
|
|
199
785
|
template: "validation",
|
|
200
786
|
data: {
|
|
201
787
|
username,
|
|
202
|
-
validationToken
|
|
788
|
+
validationToken,
|
|
789
|
+
...destinationOf(fields.redirect)
|
|
203
790
|
}
|
|
204
791
|
});
|
|
205
792
|
}
|
|
793
|
+
record({
|
|
794
|
+
type: "signup",
|
|
795
|
+
userId: account.id
|
|
796
|
+
});
|
|
206
797
|
return {
|
|
207
798
|
ok: true,
|
|
208
799
|
status: 201,
|
|
@@ -214,12 +805,17 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
214
805
|
},
|
|
215
806
|
/** Answers the same either way: whether an address has an account here is not something a stranger may probe. */
|
|
216
807
|
forgotPassword: async (email) => {
|
|
217
|
-
if (!capabilities.passwordReset
|
|
808
|
+
if (!capabilities.passwordReset) return NOT_OFFERED;
|
|
809
|
+
const limited = await throttled({
|
|
810
|
+
action: "forgotPassword",
|
|
811
|
+
key: asString(email)
|
|
812
|
+
});
|
|
813
|
+
if (limited) return limited;
|
|
218
814
|
const account = await adapters.findByEmail?.(asString(email));
|
|
219
815
|
if (account) {
|
|
220
|
-
const resetToken = generateToken();
|
|
816
|
+
const resetToken = mintLink(generateToken$1(), LIFETIME.resetLink);
|
|
221
817
|
await adapters.setResetToken?.(account.id, resetToken);
|
|
222
|
-
await
|
|
818
|
+
await deliver({
|
|
223
819
|
to: account.email,
|
|
224
820
|
template: "password-reset",
|
|
225
821
|
data: {
|
|
@@ -234,13 +830,27 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
234
830
|
};
|
|
235
831
|
},
|
|
236
832
|
resetPassword: async (token, password) => {
|
|
237
|
-
if (!capabilities.passwordReset
|
|
833
|
+
if (!capabilities.passwordReset) return NOT_OFFERED;
|
|
238
834
|
if (!token || !password) return refuse(400, "A token and a password are required");
|
|
239
|
-
const
|
|
835
|
+
const limited = await throttled({
|
|
836
|
+
action: "resetPassword",
|
|
837
|
+
key: token
|
|
838
|
+
});
|
|
839
|
+
if (limited) return limited;
|
|
840
|
+
const account = linkExpired(token) ? void 0 : await adapters.findByResetToken?.(token);
|
|
240
841
|
if (!account) return refuse(400, "Invalid or expired token");
|
|
241
|
-
|
|
842
|
+
const weak = await rejectPassword(password, {
|
|
843
|
+
username: account.username,
|
|
844
|
+
email: account.email
|
|
845
|
+
});
|
|
846
|
+
if (weak) return weak;
|
|
847
|
+
await adapters.setPassword?.(account.id, await hashPassword$1(password));
|
|
242
848
|
await adapters.setResetToken?.(account.id, "");
|
|
243
849
|
await adapters.clearSession({ userId: account.id });
|
|
850
|
+
record({
|
|
851
|
+
type: "password.reset",
|
|
852
|
+
userId: account.id
|
|
853
|
+
});
|
|
244
854
|
return {
|
|
245
855
|
ok: true,
|
|
246
856
|
body: { message: "Password reset successfully" },
|
|
@@ -249,26 +859,27 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
249
859
|
},
|
|
250
860
|
validateAccount: async (token) => {
|
|
251
861
|
if (!capabilities.emailVerification) return NOT_OFFERED;
|
|
252
|
-
const account = await adapters.findByValidationToken?.(token);
|
|
862
|
+
const account = linkExpired(token) ? void 0 : await adapters.findByValidationToken?.(token);
|
|
253
863
|
if (!account) return refuse(400, "Invalid or expired token");
|
|
254
|
-
await adapters.
|
|
864
|
+
await adapters.setVerified?.(account.id, true);
|
|
255
865
|
return {
|
|
256
866
|
ok: true,
|
|
257
867
|
body: { message: "Account validated successfully" }
|
|
258
868
|
};
|
|
259
869
|
},
|
|
260
|
-
resendVerification: async (email) => {
|
|
261
|
-
if (!capabilities.emailVerification
|
|
870
|
+
resendVerification: async (email, redirect) => {
|
|
871
|
+
if (!capabilities.emailVerification) return NOT_OFFERED;
|
|
262
872
|
const account = await adapters.findByEmail?.(asString(email));
|
|
263
873
|
if (account && !account.verified) {
|
|
264
|
-
const validationToken = generateToken();
|
|
874
|
+
const validationToken = mintLink(generateToken$1(), LIFETIME.confirmLink);
|
|
265
875
|
await adapters.setValidationToken?.(account.id, validationToken);
|
|
266
|
-
await
|
|
876
|
+
await deliver({
|
|
267
877
|
to: account.email,
|
|
268
878
|
template: "validation",
|
|
269
879
|
data: {
|
|
270
880
|
username: account.username,
|
|
271
|
-
validationToken
|
|
881
|
+
validationToken,
|
|
882
|
+
...destinationOf(redirect)
|
|
272
883
|
}
|
|
273
884
|
});
|
|
274
885
|
}
|
|
@@ -277,6 +888,389 @@ var createAuthApi = ({ tokens, identity, adapters, config = {} }) => {
|
|
|
277
888
|
body: { message: "Verification email resent" }
|
|
278
889
|
};
|
|
279
890
|
},
|
|
891
|
+
/**
|
|
892
|
+
* Change what an account says about itself.
|
|
893
|
+
*
|
|
894
|
+
* Both fields are sign-in identifiers, so both are checked for collision first — a store that only has a unique
|
|
895
|
+
* index reports the clash as a driver error five hundred lines away.
|
|
896
|
+
*/
|
|
897
|
+
updateProfile: async (actor, fields) => {
|
|
898
|
+
if (!capabilities.profile || !adapters.updateAccount) return NOT_OFFERED;
|
|
899
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
900
|
+
const username = asString(fields.username);
|
|
901
|
+
const email = asString(fields.email);
|
|
902
|
+
if (!username && !email) return refuse(400, "Nothing to change");
|
|
903
|
+
if (username && username !== actor.username) {
|
|
904
|
+
const taken = await adapters.findByUsername?.(username);
|
|
905
|
+
if (taken && taken.id !== actor.id) return refuse(409, "That username is taken");
|
|
906
|
+
}
|
|
907
|
+
if (email && email !== actor.email) {
|
|
908
|
+
const taken = await adapters.findByEmail?.(email);
|
|
909
|
+
if (taken && taken.id !== actor.id) return refuse(409, "That email is taken");
|
|
910
|
+
}
|
|
911
|
+
const changingEmail = Boolean(email) && email !== actor.email;
|
|
912
|
+
/**
|
|
913
|
+
* A new address is PARKED, not applied: the account keeps the one it has until the new one answers a
|
|
914
|
+
* confirmation. That is what makes a typo survivable — `n@exmaple.com` costs a resend, not the account.
|
|
915
|
+
*
|
|
916
|
+
* The alternative, applying it and taking `verified` away, was tried and reverted: `verified` in this server
|
|
917
|
+
* gates ACCESS (`createAuthorizer` refuses to present an unverified account as an actor at all), so it locked
|
|
918
|
+
* people out of the account they were sitting in. And the attack it guarded against needs a live session,
|
|
919
|
+
* from which the same person could simply change the password.
|
|
920
|
+
*
|
|
921
|
+
* A deployment that supplies nowhere to park it changes the address on the spot, as it always did.
|
|
922
|
+
*/
|
|
923
|
+
const parking = changingEmail && capabilities.emailChange;
|
|
924
|
+
const account = await adapters.updateAccount(actor.id, {
|
|
925
|
+
...username ? { username } : {},
|
|
926
|
+
...email && !parking ? { email } : {}
|
|
927
|
+
});
|
|
928
|
+
if (parking && adapters.setPendingEmail) {
|
|
929
|
+
const confirmationToken = mintLink(generateToken$1(), LIFETIME.confirmLink);
|
|
930
|
+
await adapters.setPendingEmail(actor.id, email, confirmationToken);
|
|
931
|
+
await deliver({
|
|
932
|
+
to: email,
|
|
933
|
+
template: "email-change",
|
|
934
|
+
data: {
|
|
935
|
+
username: account.username,
|
|
936
|
+
email,
|
|
937
|
+
confirmationToken
|
|
938
|
+
}
|
|
939
|
+
});
|
|
940
|
+
return {
|
|
941
|
+
ok: true,
|
|
942
|
+
body: {
|
|
943
|
+
success: true,
|
|
944
|
+
details: profileOf(account),
|
|
945
|
+
pendingEmail: email,
|
|
946
|
+
message: "A confirmation was sent to the new address. It takes effect once confirmed."
|
|
947
|
+
}
|
|
948
|
+
};
|
|
949
|
+
}
|
|
950
|
+
if (changingEmail && capabilities.emailVerification && adapters.setValidationToken) {
|
|
951
|
+
const validationToken = mintLink(generateToken$1(), LIFETIME.confirmLink);
|
|
952
|
+
await adapters.setValidationToken(actor.id, validationToken);
|
|
953
|
+
await deliver({
|
|
954
|
+
to: account.email,
|
|
955
|
+
template: "validation",
|
|
956
|
+
data: {
|
|
957
|
+
username: account.username,
|
|
958
|
+
validationToken
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
return {
|
|
962
|
+
ok: true,
|
|
963
|
+
body: {
|
|
964
|
+
success: true,
|
|
965
|
+
details: profileOf(account),
|
|
966
|
+
message: "A confirmation was sent to the new address"
|
|
967
|
+
}
|
|
968
|
+
};
|
|
969
|
+
}
|
|
970
|
+
return {
|
|
971
|
+
ok: true,
|
|
972
|
+
body: {
|
|
973
|
+
success: true,
|
|
974
|
+
details: profileOf(account)
|
|
975
|
+
}
|
|
976
|
+
};
|
|
977
|
+
},
|
|
978
|
+
/**
|
|
979
|
+
* Takes the parked address, having proved somebody reads it.
|
|
980
|
+
*
|
|
981
|
+
* Public, and it has to be: the link is opened from a mail client, in whatever browser that happens to be, with
|
|
982
|
+
* no session. The token is the whole credential — which is why it is single-use and why confirming also marks
|
|
983
|
+
* the account verified: an address that answered a link is an address that was proven.
|
|
984
|
+
*/
|
|
985
|
+
confirmEmailChange: async (token) => {
|
|
986
|
+
if (!capabilities.emailChange || !adapters.findByPendingEmail || !adapters.clearPendingEmail) return NOT_OFFERED;
|
|
987
|
+
const confirmation = asString(token);
|
|
988
|
+
const pending = linkExpired(confirmation) ? void 0 : await adapters.findByPendingEmail(confirmation);
|
|
989
|
+
if (!pending) return refuse(400, "Invalid or expired token");
|
|
990
|
+
const taken = await adapters.findByEmail?.(pending.email);
|
|
991
|
+
if (taken && taken.id !== pending.account.id) {
|
|
992
|
+
await adapters.clearPendingEmail(pending.account.id);
|
|
993
|
+
return refuse(409, "That email is taken");
|
|
994
|
+
}
|
|
995
|
+
const account = await adapters.updateAccount?.(pending.account.id, { email: pending.email });
|
|
996
|
+
await adapters.clearPendingEmail(pending.account.id);
|
|
997
|
+
if (adapters.setVerified) await adapters.setVerified(pending.account.id, true);
|
|
998
|
+
record({
|
|
999
|
+
type: "email.changed",
|
|
1000
|
+
userId: pending.account.id,
|
|
1001
|
+
detail: { email: pending.email }
|
|
1002
|
+
});
|
|
1003
|
+
const confirmed = {
|
|
1004
|
+
...account ?? pending.account,
|
|
1005
|
+
email: pending.email
|
|
1006
|
+
};
|
|
1007
|
+
return {
|
|
1008
|
+
ok: true,
|
|
1009
|
+
body: {
|
|
1010
|
+
success: true,
|
|
1011
|
+
details: profileOf(adapters.setVerified ? {
|
|
1012
|
+
...confirmed,
|
|
1013
|
+
verified: true
|
|
1014
|
+
} : confirmed)
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
},
|
|
1018
|
+
/**
|
|
1019
|
+
* Change a password, having proved you know the current one.
|
|
1020
|
+
*
|
|
1021
|
+
* Then **every other session ends**. Changing a password is what somebody does when they think a credential
|
|
1022
|
+
* escaped, and one that leaves the other sessions signed in has not done the thing they asked for. The session
|
|
1023
|
+
* making the change survives, or the act of securing the account would sign them out of it.
|
|
1024
|
+
*/
|
|
1025
|
+
changePassword: async (actor, currentPassword, newPassword) => {
|
|
1026
|
+
if (!capabilities.passwordChange || !adapters.findById) return NOT_OFFERED;
|
|
1027
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
1028
|
+
if (!currentPassword || !newPassword) return refuse(400, "The current and the new password are required");
|
|
1029
|
+
const weak = await rejectPassword(newPassword, {
|
|
1030
|
+
username: actor.username,
|
|
1031
|
+
email: actor.email
|
|
1032
|
+
});
|
|
1033
|
+
if (weak) return weak;
|
|
1034
|
+
const limited = await throttled({
|
|
1035
|
+
action: "changePassword",
|
|
1036
|
+
key: String(actor.id)
|
|
1037
|
+
});
|
|
1038
|
+
if (limited) return limited;
|
|
1039
|
+
const account = await adapters.findById(actor.id);
|
|
1040
|
+
if (!account?.passwordHash || !await verifyPassword$1(currentPassword, account.passwordHash)) return refuse(401, "Invalid credentials");
|
|
1041
|
+
await adapters.setPassword?.(actor.id, await hashPassword$1(newPassword));
|
|
1042
|
+
await adapters.revokeOtherSessions?.(actor.id, actor.token);
|
|
1043
|
+
throttleSucceeded({
|
|
1044
|
+
action: "changePassword",
|
|
1045
|
+
key: String(actor.id)
|
|
1046
|
+
});
|
|
1047
|
+
record({
|
|
1048
|
+
type: "password.changed",
|
|
1049
|
+
userId: actor.id
|
|
1050
|
+
});
|
|
1051
|
+
return {
|
|
1052
|
+
ok: true,
|
|
1053
|
+
body: {
|
|
1054
|
+
success: true,
|
|
1055
|
+
message: "Password changed. Other sessions were signed out."
|
|
1056
|
+
}
|
|
1057
|
+
};
|
|
1058
|
+
},
|
|
1059
|
+
/**
|
|
1060
|
+
* Close the account.
|
|
1061
|
+
*
|
|
1062
|
+
* Confirmed with the password when there is one — this is irreversible, and a borrowed session should not be
|
|
1063
|
+
* able to do it. An account with no password (one that signs in through a provider) cannot be asked, so the
|
|
1064
|
+
* session alone has to be enough.
|
|
1065
|
+
*/
|
|
1066
|
+
deleteSelf: async (actor, password) => {
|
|
1067
|
+
if (!capabilities.accountDeletion || !adapters.deleteAccount) return NOT_OFFERED;
|
|
1068
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
1069
|
+
const account = await adapters.findById?.(actor.id);
|
|
1070
|
+
if (account?.passwordHash) {
|
|
1071
|
+
if (!password || !await verifyPassword$1(password, account.passwordHash)) return refuse(401, "Invalid credentials");
|
|
1072
|
+
}
|
|
1073
|
+
await adapters.deleteAccount(actor.id);
|
|
1074
|
+
record({
|
|
1075
|
+
type: "account.deleted",
|
|
1076
|
+
userId: actor.id
|
|
1077
|
+
});
|
|
1078
|
+
return {
|
|
1079
|
+
ok: true,
|
|
1080
|
+
body: {
|
|
1081
|
+
success: true,
|
|
1082
|
+
message: "Account deleted"
|
|
1083
|
+
},
|
|
1084
|
+
endSession: true
|
|
1085
|
+
};
|
|
1086
|
+
},
|
|
1087
|
+
/** The devices this account is signed in on, with the one asking marked. */
|
|
1088
|
+
listSessions: async (actor) => {
|
|
1089
|
+
if (!capabilities.sessionList || !adapters.listSessions) return NOT_OFFERED;
|
|
1090
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
1091
|
+
return {
|
|
1092
|
+
ok: true,
|
|
1093
|
+
body: { sessions: await adapters.listSessions(actor.id, actor.token) }
|
|
1094
|
+
};
|
|
1095
|
+
},
|
|
1096
|
+
/** End one of them. Scoped to the caller's own account, or a session id would be an IDOR. */
|
|
1097
|
+
revokeSession: async (actor, sessionId) => {
|
|
1098
|
+
if (!adapters.revokeSession) return NOT_OFFERED;
|
|
1099
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
1100
|
+
if (!Number.isInteger(sessionId)) return refuse(400, "A session id is required");
|
|
1101
|
+
return await adapters.revokeSession(actor.id, sessionId) ? {
|
|
1102
|
+
ok: true,
|
|
1103
|
+
body: { success: true }
|
|
1104
|
+
} : refuse(404, "No such session");
|
|
1105
|
+
},
|
|
1106
|
+
/** "Sign out everywhere else" — the thing to do from a device you still hold after losing one you do not. */
|
|
1107
|
+
revokeOtherSessions: async (actor) => {
|
|
1108
|
+
if (!adapters.revokeOtherSessions) return NOT_OFFERED;
|
|
1109
|
+
if (!actor) return refuse(401, "Not authenticated", "missing");
|
|
1110
|
+
return {
|
|
1111
|
+
ok: true,
|
|
1112
|
+
body: {
|
|
1113
|
+
success: true,
|
|
1114
|
+
revoked: await adapters.revokeOtherSessions(actor.id, actor.token)
|
|
1115
|
+
}
|
|
1116
|
+
};
|
|
1117
|
+
},
|
|
1118
|
+
admin: {
|
|
1119
|
+
list: async (actor, query) => {
|
|
1120
|
+
const denial = requireAdmin(actor);
|
|
1121
|
+
if (denial) return denial;
|
|
1122
|
+
if (!adapters.listAccounts) return NOT_OFFERED;
|
|
1123
|
+
const { accounts, total } = await adapters.listAccounts(query);
|
|
1124
|
+
return {
|
|
1125
|
+
ok: true,
|
|
1126
|
+
body: {
|
|
1127
|
+
accounts: accounts.map(profileOf),
|
|
1128
|
+
total
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
},
|
|
1132
|
+
get: async (actor, userId) => {
|
|
1133
|
+
const denial = requireAdmin(actor);
|
|
1134
|
+
if (denial) return denial;
|
|
1135
|
+
const account = await adapters.findById?.(userId);
|
|
1136
|
+
if (!account) return refuse(404, "No such account");
|
|
1137
|
+
return {
|
|
1138
|
+
ok: true,
|
|
1139
|
+
body: {
|
|
1140
|
+
account: profileOf(account),
|
|
1141
|
+
access: await adapters.loadAccess(userId)
|
|
1142
|
+
}
|
|
1143
|
+
};
|
|
1144
|
+
},
|
|
1145
|
+
/**
|
|
1146
|
+
* Suspend, block or restore an account — and **end its sessions when it stops being active**.
|
|
1147
|
+
*
|
|
1148
|
+
* That second half is the whole point. A ban that leaves the credential working is not a ban, it is a note
|
|
1149
|
+
* in a database; the person stays signed in until their token happens to lapse.
|
|
1150
|
+
*/
|
|
1151
|
+
setStatus: async (actor, userId, status) => {
|
|
1152
|
+
const denial = requireAdmin(actor, userId);
|
|
1153
|
+
if (denial) return denial;
|
|
1154
|
+
if (!adapters.setStatus) return NOT_OFFERED;
|
|
1155
|
+
if (!STATUSES.includes(status)) return refuse(400, `Status must be one of ${STATUSES.join(", ")}`);
|
|
1156
|
+
if (!await adapters.findById?.(userId)) return refuse(404, "No such account");
|
|
1157
|
+
await adapters.setStatus(userId, status);
|
|
1158
|
+
if (status !== "active") await adapters.clearSession({ userId });
|
|
1159
|
+
record({
|
|
1160
|
+
type: "admin.status-changed",
|
|
1161
|
+
userId,
|
|
1162
|
+
actorId: actor?.id,
|
|
1163
|
+
detail: { status }
|
|
1164
|
+
});
|
|
1165
|
+
return {
|
|
1166
|
+
ok: true,
|
|
1167
|
+
body: {
|
|
1168
|
+
success: true,
|
|
1169
|
+
status
|
|
1170
|
+
}
|
|
1171
|
+
};
|
|
1172
|
+
},
|
|
1173
|
+
setRoles: async (actor, userId, roles) => {
|
|
1174
|
+
const denial = requireAdmin(actor);
|
|
1175
|
+
if (denial) return denial;
|
|
1176
|
+
if (!adapters.setRoles) return NOT_OFFERED;
|
|
1177
|
+
if (!Array.isArray(roles) || roles.some((role) => typeof role !== "string")) return refuse(400, "Roles must be a list of names");
|
|
1178
|
+
const names = roles;
|
|
1179
|
+
if (!await adapters.findById?.(userId)) return refuse(404, "No such account");
|
|
1180
|
+
await adapters.setRoles(userId, names);
|
|
1181
|
+
record({
|
|
1182
|
+
type: "admin.roles-changed",
|
|
1183
|
+
userId,
|
|
1184
|
+
actorId: actor?.id,
|
|
1185
|
+
detail: { roles: names }
|
|
1186
|
+
});
|
|
1187
|
+
return {
|
|
1188
|
+
ok: true,
|
|
1189
|
+
body: {
|
|
1190
|
+
success: true,
|
|
1191
|
+
roles: names
|
|
1192
|
+
}
|
|
1193
|
+
};
|
|
1194
|
+
},
|
|
1195
|
+
remove: async (actor, userId) => {
|
|
1196
|
+
const denial = requireAdmin(actor, userId);
|
|
1197
|
+
if (denial) return denial;
|
|
1198
|
+
if (!adapters.deleteAccount) return NOT_OFFERED;
|
|
1199
|
+
if (!await adapters.findById?.(userId)) return refuse(404, "No such account");
|
|
1200
|
+
await adapters.deleteAccount(userId);
|
|
1201
|
+
record({
|
|
1202
|
+
type: "admin.account-deleted",
|
|
1203
|
+
userId,
|
|
1204
|
+
actorId: actor?.id
|
|
1205
|
+
});
|
|
1206
|
+
return {
|
|
1207
|
+
ok: true,
|
|
1208
|
+
body: { success: true }
|
|
1209
|
+
};
|
|
1210
|
+
},
|
|
1211
|
+
/**
|
|
1212
|
+
* A session AS somebody else, for support that has to see what they see.
|
|
1213
|
+
*
|
|
1214
|
+
* Three properties make it something a deployment can live with, and all three are deliberate:
|
|
1215
|
+
*
|
|
1216
|
+
* - **It says so.** The credential carries `act` (RFC 8693), so every request made with it can be told from
|
|
1217
|
+
* one the account holder made — `Actor.impersonatedBy` is that claim, read back. A borrowed session that
|
|
1218
|
+
* is indistinguishable from a real one turns an audit log into fiction.
|
|
1219
|
+
* - **It is short and cannot renew.** Fifteen minutes by default and no refresh token, so it expires rather
|
|
1220
|
+
* than becoming a permanent second key to somebody's account.
|
|
1221
|
+
* - **It is off unless asked for**, behind its own permission — see `impersonationPermission`.
|
|
1222
|
+
*
|
|
1223
|
+
* The session is answered in the BODY and no cookie is written. Swapping the administrator's own session
|
|
1224
|
+
* cookie for this one would sign them out of the account they administer from, and getting back would mean
|
|
1225
|
+
* signing in again; whoever asked decides where to put a credential they were handed.
|
|
1226
|
+
*
|
|
1227
|
+
* One caveat a deployment has to know: a store that keeps a single session per account — no `session` table —
|
|
1228
|
+
* will overwrite the subject's own session with this one, signing them out. Anything with per-session rows,
|
|
1229
|
+
* which is what `createMysqlStore` builds, does not.
|
|
1230
|
+
*/
|
|
1231
|
+
impersonate: async (actor, userId, carrier) => {
|
|
1232
|
+
if (!capabilities.impersonation || !impersonationPermission || !adapters.findById) return NOT_OFFERED;
|
|
1233
|
+
const check = checkPermission(actor, impersonationPermission);
|
|
1234
|
+
if (!check.ok) return refuse(check.status, check.error, check.status === 401 ? "missing" : void 0);
|
|
1235
|
+
if (!actor || userId === actor.id) return refuse(400, "You are already signed in as yourself");
|
|
1236
|
+
const account = await adapters.findById(userId);
|
|
1237
|
+
if (!account) return refuse(404, "No such account");
|
|
1238
|
+
if (!account.active) return refuse(403, "That account is not active");
|
|
1239
|
+
const session = await issue(userId, { client: clientOf(carrier) }, {
|
|
1240
|
+
actingAs: actor.id,
|
|
1241
|
+
ttlSeconds: LIFETIME.impersonation,
|
|
1242
|
+
renewable: false
|
|
1243
|
+
});
|
|
1244
|
+
record({
|
|
1245
|
+
type: "admin.impersonated",
|
|
1246
|
+
userId,
|
|
1247
|
+
actorId: actor.id
|
|
1248
|
+
});
|
|
1249
|
+
return {
|
|
1250
|
+
ok: true,
|
|
1251
|
+
body: {
|
|
1252
|
+
...await grantBody(account, session),
|
|
1253
|
+
impersonatedBy: actor.id
|
|
1254
|
+
}
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
},
|
|
1258
|
+
/**
|
|
1259
|
+
* A CSRF token for whoever is asking, bound to their session when they have one. Answered as a body AND as the
|
|
1260
|
+
* cookie the binding writes, so a page can take whichever it finds easier to read.
|
|
1261
|
+
*/
|
|
1262
|
+
issueCsrf: (sessionToken) => {
|
|
1263
|
+
if (!csrf) return NOT_OFFERED;
|
|
1264
|
+
const token = csrf.issue(sessionToken);
|
|
1265
|
+
return {
|
|
1266
|
+
ok: true,
|
|
1267
|
+
body: {
|
|
1268
|
+
token,
|
|
1269
|
+
headerName: csrf.headerName
|
|
1270
|
+
},
|
|
1271
|
+
csrf: token
|
|
1272
|
+
};
|
|
1273
|
+
},
|
|
280
1274
|
/** Exposed so a deployment can mint a session outside the flows above — a rendered page's login form. */
|
|
281
1275
|
issueSession: issue,
|
|
282
1276
|
resolveActor: identity.resolveActor
|