@plitzi/sdk-server 0.33.2 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (274) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +283 -8
  3. package/dist/actions.d.ts +62 -0
  4. package/dist/actions.js +12 -0
  5. package/dist/adapters/cloudAdapters.d.ts +72 -0
  6. package/dist/adapters/cloudAdapters.js +303 -0
  7. package/dist/adapters/space/index.d.ts +93 -0
  8. package/dist/adapters/space/index.js +122 -0
  9. package/dist/adapters/space/resolvers.d.ts +69 -0
  10. package/dist/adapters/space/resolvers.js +79 -0
  11. package/dist/adapters/space/space.test.d.ts +1 -0
  12. package/dist/adapters/space/types.d.ts +64 -0
  13. package/dist/adapters/space/types.js +41 -0
  14. package/dist/auth.d.ts +50 -0
  15. package/dist/auth.js +19 -0
  16. package/dist/core/auth/api.d.ts +400 -11
  17. package/dist/core/auth/api.js +1044 -50
  18. package/dist/core/auth/createAuth.d.ts +135 -30
  19. package/dist/core/auth/createAuth.js +96 -24
  20. package/dist/core/auth/credentials.d.ts +9 -3
  21. package/dist/core/auth/credentials.js +10 -4
  22. package/dist/core/auth/csrf.d.ts +89 -0
  23. package/dist/core/auth/csrf.js +234 -0
  24. package/dist/core/auth/csrf.test.d.ts +1 -0
  25. package/dist/core/auth/identity.d.ts +6 -0
  26. package/dist/core/auth/identity.js +37 -8
  27. package/dist/core/auth/lifecycle.test.d.ts +1 -0
  28. package/dist/core/auth/oauth/index.d.ts +8 -0
  29. package/dist/core/auth/oauth/index.js +13 -15
  30. package/dist/core/auth/passwords.d.ts +15 -0
  31. package/dist/core/auth/passwords.js +32 -0
  32. package/dist/core/auth/redirects.d.ts +18 -0
  33. package/dist/core/auth/redirects.js +31 -0
  34. package/dist/core/auth/redirects.test.d.ts +1 -0
  35. package/dist/core/auth/routes.d.ts +4 -2
  36. package/dist/core/auth/routes.js +234 -9
  37. package/dist/core/auth/session.d.ts +11 -0
  38. package/dist/core/auth/session.js +1 -1
  39. package/dist/core/auth/spaceTokens.d.ts +38 -2
  40. package/dist/core/auth/spaceTokens.js +54 -2
  41. package/dist/core/auth/throttle.d.ts +5 -0
  42. package/dist/core/auth/throttle.js +81 -0
  43. package/dist/core/auth/throttle.test.d.ts +1 -0
  44. package/dist/core/auth/tokens.d.ts +83 -12
  45. package/dist/core/auth/tokens.js +51 -9
  46. package/dist/core/auth/totp.d.ts +48 -0
  47. package/dist/core/auth/totp.js +118 -0
  48. package/dist/core/auth/totp.test.d.ts +1 -0
  49. package/dist/core/createServer.d.ts +0 -6
  50. package/dist/core/createServer.js +35 -3
  51. package/dist/core/fontStore.d.ts +33 -0
  52. package/dist/core/fontStore.js +53 -0
  53. package/dist/core/fontStore.test.d.ts +1 -0
  54. package/dist/core/handlers/authMiddleware.js +1 -1
  55. package/dist/core/handlers/authRouteHandlers.d.ts +9 -1
  56. package/dist/core/handlers/authRouteHandlers.js +4 -3
  57. package/dist/core/handlers/csrfMiddleware.d.ts +20 -0
  58. package/dist/core/handlers/csrfMiddleware.js +32 -0
  59. package/dist/core/handlers/oauthRouteHandlers.d.ts +46 -0
  60. package/dist/core/handlers/oauthRouteHandlers.js +159 -0
  61. package/dist/core/handlers/socialRouteHandlers.d.ts +40 -0
  62. package/dist/core/handlers/socialRouteHandlers.js +129 -0
  63. package/dist/core/handlers/socialRouteHandlers.test.d.ts +1 -0
  64. package/dist/core/handlers/types.d.ts +22 -5
  65. package/dist/core/health.d.ts +11 -1
  66. package/dist/core/health.js +27 -3
  67. package/dist/core/http/dispatcher.d.ts +1 -1
  68. package/dist/core/http/dispatcher.js +20 -1
  69. package/dist/core/http/stages/authApi.js +1 -1
  70. package/dist/core/http/stages/fontAssets.d.ts +9 -0
  71. package/dist/core/http/stages/fontAssets.js +27 -0
  72. package/dist/core/http/stages/oauth.d.ts +19 -0
  73. package/dist/core/http/stages/oauth.js +118 -0
  74. package/dist/core/http/types.d.ts +12 -0
  75. package/dist/core/oauth/authorize.d.ts +22 -0
  76. package/dist/core/oauth/authorize.js +254 -0
  77. package/dist/core/oauth/authorize.test.d.ts +1 -0
  78. package/dist/core/oauth/challenge.d.ts +11 -0
  79. package/dist/core/oauth/challenge.js +33 -0
  80. package/dist/core/oauth/consentPage.d.ts +12 -0
  81. package/dist/core/oauth/consentPage.js +131 -0
  82. package/dist/core/oauth/metadata.d.ts +41 -0
  83. package/dist/core/oauth/metadata.js +71 -0
  84. package/dist/core/oauth/params.d.ts +5 -0
  85. package/dist/core/oauth/params.js +5 -0
  86. package/dist/core/oauth/pkce.d.ts +6 -0
  87. package/dist/core/oauth/pkce.js +15 -0
  88. package/dist/core/oauth/records.d.ts +72 -0
  89. package/dist/core/oauth/records.js +49 -0
  90. package/dist/core/oauth/register.d.ts +5 -0
  91. package/dist/core/oauth/register.js +55 -0
  92. package/dist/core/oauth/respond.d.ts +21 -0
  93. package/dist/core/oauth/respond.js +59 -0
  94. package/dist/core/oauth/token.d.ts +14 -0
  95. package/dist/core/oauth/token.js +130 -0
  96. package/dist/core/previewToken.d.ts +20 -0
  97. package/dist/core/previewToken.js +29 -1
  98. package/dist/core/server/pageServer.js +4 -1
  99. package/dist/core/services/action.d.ts +6 -4
  100. package/dist/core/services/action.js +103 -10
  101. package/dist/core/services/registry.js +2 -0
  102. package/dist/core/services/resolve.d.ts +8 -0
  103. package/dist/core/services/resolve.js +12 -1
  104. package/dist/core/staticFiles.js +1 -2
  105. package/dist/handlers.d.ts +7 -1
  106. package/dist/handlers.js +4 -1
  107. package/dist/helpers/buildResponseHelpers.d.ts +8 -0
  108. package/dist/helpers/buildResponseHelpers.js +9 -1
  109. package/dist/helpers/buildServerInfo.d.ts +8 -3
  110. package/dist/helpers/buildServerInfo.js +18 -14
  111. package/dist/helpers/cache/keys.d.ts +13 -0
  112. package/dist/helpers/cache/keys.js +12 -1
  113. package/dist/helpers/onAbort.d.ts +12 -0
  114. package/dist/helpers/onAbort.js +22 -0
  115. package/dist/helpers/onAbort.test.d.ts +1 -0
  116. package/dist/helpers/outboundGuard.d.ts +27 -0
  117. package/dist/helpers/outboundGuard.js +49 -0
  118. package/dist/helpers/outboundGuard.test.d.ts +1 -0
  119. package/dist/helpers/serverLog.d.ts +6 -3
  120. package/dist/helpers/serverLog.js +20 -3
  121. package/dist/index.d.ts +6 -0
  122. package/dist/index.js +9 -4
  123. package/dist/kernel.d.ts +5 -22
  124. package/dist/kernel.js +1 -14
  125. package/dist/modules/actions/{handler.js → connectorWrite.js} +5 -2
  126. package/dist/modules/actions/connectorWrite.test.d.ts +1 -0
  127. package/dist/modules/actions/index.d.ts +40 -0
  128. package/dist/modules/actions/index.js +46 -0
  129. package/dist/modules/actions/moduleFor.d.ts +3 -0
  130. package/dist/modules/actions/moduleFor.js +35 -0
  131. package/dist/modules/actions/moduleFor.test.d.ts +1 -0
  132. package/dist/modules/actions/runtime/check.d.ts +24 -0
  133. package/dist/modules/actions/runtime/check.js +111 -0
  134. package/dist/modules/actions/runtime/check.test.d.ts +1 -0
  135. package/dist/modules/actions/runtime/errors.d.ts +11 -0
  136. package/dist/modules/actions/runtime/errors.js +17 -0
  137. package/dist/modules/actions/runtime/guards.d.ts +126 -0
  138. package/dist/modules/actions/runtime/guards.js +246 -0
  139. package/dist/modules/actions/runtime/guards.test.d.ts +1 -0
  140. package/dist/modules/actions/runtime/kvStore.d.ts +27 -0
  141. package/dist/modules/actions/runtime/kvStore.js +41 -0
  142. package/dist/modules/actions/runtime/kvStore.test.d.ts +1 -0
  143. package/dist/modules/actions/runtime/limits.d.ts +11 -0
  144. package/dist/modules/actions/runtime/limits.js +35 -0
  145. package/dist/modules/actions/runtime/memoryKv.d.ts +2 -0
  146. package/dist/modules/actions/runtime/memoryKv.js +77 -0
  147. package/dist/modules/actions/runtime/namespaceKv.d.ts +9 -0
  148. package/dist/modules/actions/runtime/namespaceKv.js +19 -0
  149. package/dist/modules/actions/runtime/precheck.d.ts +27 -0
  150. package/dist/modules/actions/runtime/precheck.js +45 -0
  151. package/dist/modules/actions/runtime/renderResolver.d.ts +15 -0
  152. package/dist/modules/actions/runtime/renderResolver.js +130 -0
  153. package/dist/modules/actions/runtime/renderResolver.test.d.ts +1 -0
  154. package/dist/modules/actions/runtime/renderShare.d.ts +29 -0
  155. package/dist/modules/actions/runtime/renderShare.js +36 -0
  156. package/dist/modules/actions/runtime/renderShare.test.d.ts +1 -0
  157. package/dist/modules/actions/runtime/report.d.ts +14 -0
  158. package/dist/modules/actions/runtime/report.js +22 -0
  159. package/dist/modules/actions/runtime/runAction.d.ts +12 -0
  160. package/dist/modules/actions/runtime/runAction.js +405 -0
  161. package/dist/modules/actions/runtime/runAction.test.d.ts +1 -0
  162. package/dist/modules/actions/runtime/runLogger.d.ts +31 -0
  163. package/dist/modules/actions/runtime/runLogger.js +66 -0
  164. package/dist/modules/actions/runtime/runLogger.test.d.ts +1 -0
  165. package/dist/modules/actions/runtime/schedule.d.ts +35 -0
  166. package/dist/modules/actions/runtime/schedule.js +76 -0
  167. package/dist/modules/actions/runtime/schedule.test.d.ts +1 -0
  168. package/dist/modules/actions/runtime/scope.d.ts +40 -0
  169. package/dist/modules/actions/runtime/scope.js +103 -0
  170. package/dist/modules/actions/runtime/triggers.d.ts +12 -0
  171. package/dist/modules/actions/runtime/triggers.js +14 -0
  172. package/dist/modules/actions/taskCatalog.d.ts +26 -0
  173. package/dist/modules/actions/taskCatalog.js +24 -0
  174. package/dist/modules/actions/taskCatalog.test.d.ts +1 -0
  175. package/dist/modules/actions/tasks/auth.d.ts +2 -0
  176. package/dist/modules/actions/tasks/auth.js +40 -0
  177. package/dist/modules/actions/tasks/builtins.d.ts +8 -0
  178. package/dist/modules/actions/tasks/builtins.js +24 -0
  179. package/dist/modules/actions/tasks/connector.d.ts +2 -0
  180. package/dist/modules/actions/tasks/connector.js +141 -0
  181. package/dist/modules/actions/tasks/db.d.ts +2 -0
  182. package/dist/modules/actions/tasks/db.js +59 -0
  183. package/dist/modules/actions/tasks/db.test.d.ts +1 -0
  184. package/dist/modules/actions/tasks/flow.d.ts +3 -0
  185. package/dist/modules/actions/tasks/flow.js +109 -0
  186. package/dist/modules/actions/tasks/helpers.d.ts +16 -0
  187. package/dist/modules/actions/tasks/helpers.js +30 -0
  188. package/dist/modules/actions/tasks/http.d.ts +2 -0
  189. package/dist/modules/actions/tasks/http.js +111 -0
  190. package/dist/modules/actions/tasks/http.test.d.ts +1 -0
  191. package/dist/modules/actions/tasks/kv.d.ts +2 -0
  192. package/dist/modules/actions/tasks/kv.js +102 -0
  193. package/dist/modules/actions/tasks/kv.test.d.ts +1 -0
  194. package/dist/modules/actions/tasks/registry.d.ts +10 -0
  195. package/dist/modules/actions/tasks/registry.js +36 -0
  196. package/dist/modules/actions/tasks/transform.d.ts +2 -0
  197. package/dist/modules/actions/tasks/transform.js +34 -0
  198. package/dist/modules/actions/transport/callHandler.d.ts +24 -0
  199. package/dist/modules/actions/transport/callHandler.js +233 -0
  200. package/dist/modules/actions/transport/callHandler.test.d.ts +1 -0
  201. package/dist/modules/actions/transport/cancelHandler.d.ts +23 -0
  202. package/dist/modules/actions/transport/cancelHandler.js +23 -0
  203. package/dist/modules/actions/transport/catalogHandler.d.ts +18 -0
  204. package/dist/modules/actions/transport/catalogHandler.js +21 -0
  205. package/dist/modules/actions/transport/stream.d.ts +23 -0
  206. package/dist/modules/actions/transport/stream.js +100 -0
  207. package/dist/modules/actions/transport/verifySignature.d.ts +16 -0
  208. package/dist/modules/actions/transport/verifySignature.js +72 -0
  209. package/dist/modules/actions/transport/webhookHandler.d.ts +25 -0
  210. package/dist/modules/actions/transport/webhookHandler.js +206 -0
  211. package/dist/modules/actions/transport/webhookHandler.test.d.ts +1 -0
  212. package/dist/modules/actions/types.d.ts +234 -0
  213. package/dist/modules/connectors/engine.js +4 -1
  214. package/dist/modules/connectors/resolver.d.ts +10 -2
  215. package/dist/modules/connectors/resolver.js +8 -3
  216. package/dist/modules/mysql/accounts.d.ts +5 -0
  217. package/dist/modules/mysql/accounts.js +365 -0
  218. package/dist/modules/mysql/accounts.test.d.ts +1 -0
  219. package/dist/modules/mysql/admin.d.ts +58 -0
  220. package/dist/modules/mysql/admin.js +133 -0
  221. package/dist/modules/mysql/config.d.ts +82 -0
  222. package/dist/modules/mysql/config.js +30 -0
  223. package/dist/modules/mysql/identities.d.ts +35 -0
  224. package/dist/modules/mysql/identities.js +43 -0
  225. package/dist/modules/mysql/index.d.ts +67 -0
  226. package/dist/modules/mysql/index.js +62 -0
  227. package/dist/modules/mysql/pool.d.ts +3 -0
  228. package/dist/modules/mysql/pool.js +88 -0
  229. package/dist/modules/mysql/query.d.ts +12 -0
  230. package/dist/modules/mysql/query.js +21 -0
  231. package/dist/modules/mysql/schema.d.ts +43 -0
  232. package/dist/modules/mysql/schema.js +340 -0
  233. package/dist/modules/mysql/schema.test.d.ts +1 -0
  234. package/dist/modules/mysql/sessions.d.ts +33 -0
  235. package/dist/modules/mysql/sessions.js +46 -0
  236. package/dist/modules/mysql/spaceTokens.d.ts +11 -0
  237. package/dist/modules/mysql/spaceTokens.js +59 -0
  238. package/dist/modules/mysql/store.integration.test.d.ts +1 -0
  239. package/dist/modules/rsc/connectorRscData.d.ts +25 -0
  240. package/dist/modules/rsc/connectorRscData.js +51 -0
  241. package/dist/modules/rsc/connectorRscData.test.d.ts +1 -0
  242. package/dist/modules/rsc/handler.js +16 -3
  243. package/dist/modules/rsc/matchRscPage.d.ts +14 -0
  244. package/dist/modules/rsc/matchRscPage.js +26 -0
  245. package/dist/modules/rsc/resolveRscData.d.ts +14 -1
  246. package/dist/modules/rsc/resolveRscData.js +23 -34
  247. package/dist/modules/ssr/Component.d.ts +6 -2
  248. package/dist/modules/ssr/Component.js +3 -1
  249. package/dist/modules/ssr/draftSession.test.d.ts +1 -0
  250. package/dist/modules/ssr/handler.js +15 -2
  251. package/dist/modules/ssr/loadPluginComponents.js +22 -5
  252. package/dist/modules/ssr/prepareRender.js +87 -8
  253. package/dist/modules/ssr/prepareRender.test.d.ts +1 -0
  254. package/dist/modules/ssr/preview.d.ts +21 -8
  255. package/dist/modules/ssr/preview.js +38 -14
  256. package/dist/modules/ssr/registerExternalPlugins.js +2 -2
  257. package/dist/modules/ssr/resolvePageSeo.d.ts +18 -0
  258. package/dist/modules/ssr/resolvePageSeo.js +26 -0
  259. package/dist/modules/ssr/resolvePageSeo.test.d.ts +1 -0
  260. package/dist/modules/ssr/template.test.d.ts +1 -0
  261. package/dist/modules/ssr/views/template.ejs +71 -5
  262. package/dist/mysql.d.ts +26 -0
  263. package/dist/mysql.js +4 -0
  264. package/dist/oauth.d.ts +24 -0
  265. package/dist/oauth.js +10 -0
  266. package/dist/plugins/compile.d.ts +1 -0
  267. package/dist/plugins/compile.js +37 -3
  268. package/dist/plugins/manager.d.ts +40 -0
  269. package/dist/plugins/manager.js +113 -13
  270. package/dist/plugins/manager.test.d.ts +1 -0
  271. package/package.json +43 -14
  272. package/skills/plitzi-authoring/SKILL.md +252 -0
  273. /package/dist/{modules/actions/handler.test.d.ts → adapters/cloudAdapters.test.d.ts} +0 -0
  274. /package/dist/modules/actions/{handler.d.ts → connectorWrite.d.ts} +0 -0
@@ -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 && !!verifyPassword,
181
+ passwordLogin: !!adapters.findByUsername,
41
182
  refresh: !!adapters.findByRefreshToken,
42
- signup: !!adapters.createAccount && !!hashPassword,
43
- passwordReset: !!adapters.findByEmail && !!adapters.setResetToken && !!hashPassword,
44
- emailVerification: !!adapters.findByValidationToken && !!adapters.markVerified,
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
- const issue = async (userId) => {
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
- expiresAt: now + tokens.lifetimes.access,
52
- refreshToken: tokens.generateRefreshToken(userId),
53
- refreshExpiresAt: now + tokens.lifetimes.refresh
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 account = await adapters.findByUsername?.(username);
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.active) return refuse(401, "Account is not active");
124
- if (!account.passwordHash || !await verifyPassword?.(password, account.passwordHash)) return refuse(401, "Invalid credentials");
125
- const session = await issue(account.id);
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
- refresh: async (refreshToken) => {
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 refuse(401, "Invalid or expired refresh token", "expired");
137
- if (!account.active) return refuse(401, "Account is not active", "inactive");
138
- const session = await issue(account.id);
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
- if (!capabilities.exchange) return NOT_OFFERED;
170
- if (!provider || !token) return refuse(400, "A provider and a token are required");
171
- const result = await adapters.exchangeCredential?.(provider, token, carrier);
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(result, session),
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 || !hashPassword) return NOT_OFFERED;
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 && generateToken && adapters.setValidationToken) {
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 adapters.sendMail?.({
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 || !generateToken) return NOT_OFFERED;
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 adapters.sendMail?.({
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 || !hashPassword) return NOT_OFFERED;
833
+ if (!capabilities.passwordReset) return NOT_OFFERED;
238
834
  if (!token || !password) return refuse(400, "A token and a password are required");
239
- const account = await adapters.findByResetToken?.(token);
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
- await adapters.setPassword?.(account.id, await hashPassword(password));
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.markVerified?.(account.id);
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 || !generateToken) return NOT_OFFERED;
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 adapters.sendMail?.({
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