@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
@@ -0,0 +1,234 @@
1
+ import { appendCookies, sessionCookieParams } from "./session.js";
2
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
3
+ //#region src/core/auth/csrf.ts
4
+ /**
5
+ * Cross-site request forgery: a page on another site causing a request here that the visitor did not mean to make.
6
+ *
7
+ * There are **two** attacks under that name, and they need different answers — conflating them is how a server
8
+ * ends up either refusing legitimate sign-ins or leaving login CSRF wide open.
9
+ *
10
+ * 1. **An action taken as somebody.** A cookie is attached by the browser to every request to its origin,
11
+ * including ones another site caused. Not hypothetical here: `sessionCookieParams` defaults `SameSite` to
12
+ * `None` off localhost, because a Plitzi space is embedded in an iframe on somebody else's domain — which is
13
+ * exactly what `Lax` exists to prevent. Answered by a token, demanded whenever a session cookie is present.
14
+ * 2. **Login CSRF.** Another site signs the visitor into an account IT controls, so what they do next is recorded
15
+ * against it. There is no cookie yet to look for, so the first answer does not apply at all. Answered by
16
+ * `foreign` below: where the request came from, which is what separates it from a legitimate sign-in.
17
+ *
18
+ * A request carrying `Authorization: Bearer` is at risk from neither and is never asked for a token: a
19
+ * cross-origin page cannot set that header without a preflight the server would have to allow. Requiring one
20
+ * there would break every API client to protect nobody.
21
+ *
22
+ * The token is a signed double-submit cookie: an HMAC over a nonce **and the session it belongs to**, handed to
23
+ * the page in a readable cookie and echoed back in a header. An attacker's page cannot read the cookie, so it
24
+ * cannot produce the header; and unlike plain double-submit, somebody who can merely *write* a cookie — a
25
+ * sub-domain they took over — still cannot forge one, because they do not have the secret.
26
+ */
27
+ var VERSION = "v1";
28
+ /** GET, HEAD and OPTIONS change nothing, and demanding a token for them would break every ordinary navigation. */
29
+ var SAFE_METHODS = /* @__PURE__ */ new Set([
30
+ "GET",
31
+ "HEAD",
32
+ "OPTIONS"
33
+ ]);
34
+ /** Where the echo is looked for, and the field a `<form>` may carry it in instead. */
35
+ var HEADER_NAME = "x-csrf-token";
36
+ var FIELD_NAME = "_csrf";
37
+ /** Twelve hours: long enough for a working day, short enough to bound a leak. */
38
+ var TOKEN_LIFETIME = 43200;
39
+ var csrfFailureMessage = {
40
+ missing: "A CSRF token is required",
41
+ malformed: "CSRF token malformed",
42
+ expired: "CSRF token expired",
43
+ mismatch: "CSRF token does not match this session"
44
+ };
45
+ var equal = (a, b) => {
46
+ const left = Buffer.from(a);
47
+ const right = Buffer.from(b);
48
+ return left.length === right.length && timingSafeEqual(left, right);
49
+ };
50
+ var header = (carrier, name) => {
51
+ const value = carrier.headers[name];
52
+ return typeof value === "string" ? value : void 0;
53
+ };
54
+ var readCookieHeader = (carrier, name) => {
55
+ if (carrier.cookies?.[name] !== void 0) return carrier.cookies[name];
56
+ const raw = header(carrier, "cookie");
57
+ if (!raw) return;
58
+ for (const part of raw.split(";")) {
59
+ const index = part.indexOf("=");
60
+ if (index !== -1 && part.slice(0, index).trim() === name) return decodeURIComponent(part.slice(index + 1).trim());
61
+ }
62
+ };
63
+ /** The host out of an `Origin`, which is a serialized origin (`https://acme.test:8443`) and never a bare host. */
64
+ var originHost = (origin) => {
65
+ try {
66
+ return new URL(origin).hostname;
67
+ } catch {
68
+ return;
69
+ }
70
+ };
71
+ var createCsrf = (config) => {
72
+ const { secret, cookie, allowedOrigins = [] } = config;
73
+ const permitted = (origin) => typeof allowedOrigins === "function" ? allowedOrigins(origin) : allowedOrigins.includes(origin);
74
+ /**
75
+ * Did this request come from a site this deployment does not recognise?
76
+ *
77
+ * This is what protects signing in, where there is no session to bind a token to and no cookie to look for. It
78
+ * rests on two headers a page CANNOT set — the browser attaches both, and script may not override either:
79
+ *
80
+ * - **`Sec-Fetch-Site`** is the browser's own account of where the request came from, and is believed first.
81
+ * Anything but `cross-site` (`same-origin`, `same-site`, or `none` for a typed URL) is this deployment's own.
82
+ * The browser works that out with the real public suffix list, which is why it is worth more than anything
83
+ * derivable here.
84
+ * - **`Origin`** is the fallback, for a browser too old to send the above — Safari only did from 16.4. Matched
85
+ * **exactly**, against this host or an origin the deployment named.
86
+ *
87
+ * That exactness is deliberate and was a bug once: this compared *registrable domains*, last-two-labels, the
88
+ * same helper that scopes the session cookie. It has no public suffix list, so on a deployment at `acme.co.uk`
89
+ * every other `.co.uk` in the world counted as the same site and could sign a visitor in. Guessing where a
90
+ * domain boundary falls is not something to do in a security decision — a sibling sub-domain that needs to be
91
+ * trusted is one line in `allowedOrigins`, which `createAuth` already fills from `identity.platformOrigins`.
92
+ *
93
+ * **Neither header means this is not a browser**, and a client that is not a browser cannot be made to forge
94
+ * anything: there is no victim's session sitting in it. That is what keeps every API client, mobile app and
95
+ * script signing in with nothing extra to send.
96
+ */
97
+ const foreign = (carrier) => {
98
+ const site = header(carrier, "sec-fetch-site");
99
+ const origin = header(carrier, "origin");
100
+ const allowed = origin !== void 0 && permitted(origin);
101
+ if (site !== void 0) return site === "cross-site" && !allowed;
102
+ if (!origin) return false;
103
+ return originHost(origin) !== carrier.hostname && !allowed;
104
+ };
105
+ const nameFor = (hostname) => `${sessionCookieParams(hostname, cookie).name}_csrf`;
106
+ const sign = (nonce, expiry, binding) => createHmac("sha256", secret).update(`${nonce}.${expiry}.${binding}`).digest("base64url");
107
+ /**
108
+ * `binding` is what ties a token to one session. Hashed rather than used raw so the token never carries a
109
+ * credential, even indirectly: it goes in a cookie a page can read.
110
+ */
111
+ const bind = (sessionToken) => sessionToken ? createHmac("sha256", secret).update(sessionToken).digest("base64url") : "";
112
+ /**
113
+ * A token for this session, or for none.
114
+ *
115
+ * The unbound form is what protects sign-in itself: a page asks for one before anybody is authenticated, which
116
+ * is what stops another site logging a visitor into an account it controls. It is upgraded to a bound one the
117
+ * moment a session exists, and a bound token is refused for a different session.
118
+ */
119
+ const issue = (sessionToken) => {
120
+ const nonce = randomBytes(16).toString("base64url");
121
+ const expiry = Math.floor(Date.now() / 1e3) + TOKEN_LIFETIME;
122
+ return `${VERSION}.${nonce}.${expiry}.${sign(nonce, expiry, bind(sessionToken))}`;
123
+ };
124
+ /** One place the cookie's attributes are decided, so writing it and dropping it cannot disagree about them. */
125
+ const serialize = (hostname, value, maxAge) => {
126
+ const params = sessionCookieParams(hostname, cookie);
127
+ return [
128
+ `${nameFor(hostname)}=${value}`,
129
+ "Path=/",
130
+ `SameSite=${params.sameSite === "none" ? "None" : "Lax"}`,
131
+ `Max-Age=${maxAge}`,
132
+ ...params.domain ? [`Domain=${params.domain}`] : [],
133
+ ...params.secure ? ["Secure"] : []
134
+ ].join("; ");
135
+ };
136
+ const check = (token, sessionToken) => {
137
+ if (!token) return {
138
+ ok: false,
139
+ reason: "missing"
140
+ };
141
+ const [version, nonce, expiry, signature] = token.split(".");
142
+ if (version !== VERSION || !nonce || !expiry || !signature) return {
143
+ ok: false,
144
+ reason: "malformed"
145
+ };
146
+ const expiresAt = Number(expiry);
147
+ if (!Number.isFinite(expiresAt)) return {
148
+ ok: false,
149
+ reason: "malformed"
150
+ };
151
+ if (expiresAt < Math.floor(Date.now() / 1e3)) return {
152
+ ok: false,
153
+ reason: "expired"
154
+ };
155
+ /**
156
+ * Verified against the session the request actually carries. A token minted for nobody does NOT satisfy a
157
+ * request that has a session — otherwise an attacker could mint one from their own browser, where they are
158
+ * signed out, and replay it against a victim who is not.
159
+ */
160
+ if (!equal(signature, sign(nonce, expiresAt, bind(sessionToken)))) return {
161
+ ok: false,
162
+ reason: "mismatch"
163
+ };
164
+ return { ok: true };
165
+ };
166
+ return {
167
+ issue,
168
+ /** The name the cookie lands under, for a client that reads it by hand. */
169
+ cookieName: nameFor,
170
+ /** The header the echo is expected in, published by `GET /auth/csrf` so a client is never told it twice. */
171
+ headerName: HEADER_NAME,
172
+ /** Readable on purpose: `httpOnly` would make it impossible for a page to echo it back. It is not a credential. */
173
+ write: (req, res, token) => {
174
+ appendCookies(res, [serialize(req.hostname, encodeURIComponent(token), TOKEN_LIFETIME)]);
175
+ },
176
+ /**
177
+ * Drops the cookie, on the same attributes it was written with — anything else leaves the original in place and
178
+ * adds a second one the browser then has to choose between.
179
+ *
180
+ * Called when a session ends. Nothing depended on it going: the token is signed over the session it belongs to,
181
+ * so once that session is revoked it verifies against nothing, and a write is only ever asked for a token when a
182
+ * session cookie is present. But a logout that leaves a cookie behind is one somebody has to reason about to
183
+ * dismiss, and on a shared browser "signed out" should mean nothing of the session is still sitting there.
184
+ */
185
+ clear: (req, res) => {
186
+ appendCookies(res, [serialize(req.hostname, "", 0)]);
187
+ },
188
+ read: (carrier) => readCookieHeader(carrier, nameFor(carrier.hostname)),
189
+ /**
190
+ * Does this request need a token at all?
191
+ *
192
+ * Two requests can never be forged into, whatever they are reaching, and neither is ever asked:
193
+ *
194
+ * - a **safe method**, which changes nothing;
195
+ * - one presenting **`Authorization: Bearer`** — a cross-origin page cannot set that header without a
196
+ * preflight this server would have to allow, so requiring a token would break every API client to protect
197
+ * nobody.
198
+ *
199
+ * After that it depends on what the flow does, which is what `subject` says — an action taken as somebody is
200
+ * protected by the session cookie's presence, a sign-in by where the request came from. See {@link CsrfSubject}.
201
+ */
202
+ required: (carrier, subject = "write") => {
203
+ if (SAFE_METHODS.has((carrier.method ?? "GET").toUpperCase()) || header(carrier, "authorization")) return false;
204
+ if (subject === "delegated") return false;
205
+ if (subject === "signIn")
206
+ /**
207
+ * Not "is there a session cookie". A sign-in does not act on the session the request carries — it
208
+ * REPLACES it — so a browser still holding a stale one was being refused the very request that fixes
209
+ * that. What matters is whether another site caused this.
210
+ */
211
+ return foreign(carrier);
212
+ return readCookieHeader(carrier, sessionCookieParams(carrier.hostname, cookie).name) !== void 0;
213
+ },
214
+ /**
215
+ * The whole check. The echoed token must verify against the session the request carries, and — when the
216
+ * cookie is also present — match it. The second half costs nothing and blocks a token that leaked somewhere
217
+ * the cookie did not.
218
+ */
219
+ verify: (carrier, sessionToken) => {
220
+ const field = carrier.body?.[FIELD_NAME];
221
+ const echoed = header(carrier, HEADER_NAME) ?? (typeof field === "string" ? field : void 0);
222
+ const result = check(echoed, sessionToken);
223
+ if (!result.ok) return result;
224
+ const fromCookie = readCookieHeader(carrier, nameFor(carrier.hostname));
225
+ if (fromCookie !== void 0 && !equal(fromCookie, echoed)) return {
226
+ ok: false,
227
+ reason: "mismatch"
228
+ };
229
+ return { ok: true };
230
+ }
231
+ };
232
+ };
233
+ //#endregion
234
+ export { createCsrf, csrfFailureMessage };
@@ -0,0 +1 @@
1
+ export {};
@@ -11,6 +11,12 @@ export interface Actor {
11
11
  token: string;
12
12
  /** Unix seconds the presented credential dies at, off the stored row rather than the claim. */
13
13
  expiresAt: number;
14
+ /**
15
+ * The administrator behind this session, when somebody is acting AS this account — see `admin.impersonate`. Off
16
+ * the credential's `act` claim, so it survives into anything that reads an actor: an audit line that says who
17
+ * really did it, a UI banner, a flow that declines to run while borrowed.
18
+ */
19
+ impersonatedBy?: number;
14
20
  }
15
21
  /** A space token, resolved. `userId` is set for `agent` grants and names the member who consented. */
16
22
  export interface Grant {
@@ -1,4 +1,5 @@
1
1
  import { domainAllowed } from "./domains.js";
2
+ import { OFF_BROWSER_SPACE_SCOPES } from "./tokens.js";
2
3
  //#region src/core/auth/identity.ts
3
4
  var createIdentity = ({ tokens, carriers, adapters, config = {}, presentedOrigin }) => {
4
5
  const { platformHosts = [], platformOrigins = [], allowWithoutOrigin = false, log } = config;
@@ -18,9 +19,13 @@ var createIdentity = ({ tokens, carriers, adapters, config = {}, presentedOrigin
18
19
  ok: false,
19
20
  reason: "expired"
20
21
  };
22
+ const actingId = Number(verified.payload.act?.sub);
21
23
  return {
22
24
  ok: true,
23
- actor
25
+ actor: Number.isInteger(actingId) ? {
26
+ ...actor,
27
+ impersonatedBy: actingId
28
+ } : actor
24
29
  };
25
30
  };
26
31
  const resolveActor = async (carrier) => {
@@ -85,13 +90,37 @@ var createIdentity = ({ tokens, carriers, adapters, config = {}, presentedOrigin
85
90
  reason: "origin-not-allowed"
86
91
  };
87
92
  }
88
- if (!origin) return allowWithoutOrigin ? {
89
- ok: true,
90
- grant
91
- } : {
92
- ok: false,
93
- reason: "origin-not-allowed"
94
- };
93
+ /**
94
+ * No `Origin` means no browser made this request — and what that is worth depends entirely on whether the
95
+ * credential was a secret to begin with.
96
+ *
97
+ * A **public** one — `render`, which ships in the clear inside every published page — is held to the allowlist
98
+ * even so, and therefore refused here. The check only ever worked because a BROWSER is made to state where it is
99
+ * presenting from; a client that simply omits the header is not proving anything, it is declining to be asked. If
100
+ * that were enough, a render key lifted from someone's page would be enough to serve a byte-identical clone of
101
+ * their site from any server, and the domain binding above would not stop it: a self-hosted renderer addresses
102
+ * this platform's own host, which is allowed by construction.
103
+ *
104
+ * A **secret** one — `host` for a server that renders the space as its own, `agent` for a consented integration —
105
+ * has nothing to prove by an origin, because possessing it is the proof. That is what `space:host` is FOR: it is
106
+ * shown once, never embedded, and revocable on its own row, so self-hosting no longer has to be paid for by
107
+ * making the public key work off-browser.
108
+ */
109
+ if (!origin) {
110
+ if (OFF_BROWSER_SPACE_SCOPES.includes(scope) || allowWithoutOrigin) return {
111
+ ok: true,
112
+ grant
113
+ };
114
+ log?.("Access Not Authorized - Origin", {
115
+ spaceId,
116
+ scope,
117
+ reason: "public token presented with no Origin"
118
+ });
119
+ return {
120
+ ok: false,
121
+ reason: "origin-not-allowed"
122
+ };
123
+ }
95
124
  if (!originAllowed(origin, origins)) {
96
125
  log?.("Access Not Authorized - Origin", {
97
126
  spaceId,
@@ -0,0 +1 @@
1
+ export {};
@@ -60,6 +60,14 @@ export declare const createSocialAuth: ({ config, adapters, providers: configure
60
60
  /** Adapters for providers the server does not ship, merged over the built-in ones and selected the same way. */
61
61
  customProviders?: Partial<Record<string, OAuthProviderFactory>>;
62
62
  }) => {
63
+ /**
64
+ * Vet a caller-supplied landing page against this deployment's policy, for the flows that are not this one.
65
+ *
66
+ * Exposed because social sign-in is no longer the only thing that takes a `?redirect=` and then navigates to
67
+ * it: the shared sign-in screen does too, and the check has to be the SAME check. Two implementations of "is
68
+ * this target ours" is how one of them ends up accepting `//evil.com`.
69
+ */
70
+ sanitizeRedirect: (target: unknown) => string;
63
71
  /** Registered providers, so a front-end renders exactly the buttons that will work. */
64
72
  list: () => {
65
73
  id: string;
@@ -3,19 +3,13 @@ import { buildAuthorizationUrl, exchangeCodeForToken, requestProfileJson } from
3
3
  import { createGitHubProvider } from "./providers/github.js";
4
4
  import { createGoogleProvider } from "./providers/google.js";
5
5
  import { codeChallenge, consumeFlow, startFlow } from "./state.js";
6
+ import { createRedirectPolicy } from "../redirects.js";
6
7
  //#region src/core/auth/oauth/index.ts
7
8
  /** The adapters shipped with the server. A deployment adds its credentials and gets the provider; nothing else. */
8
9
  var BUILT_IN_PROVIDERS = {
9
10
  google: createGoogleProvider,
10
11
  github: createGitHubProvider
11
12
  };
12
- var originOf = (value) => {
13
- try {
14
- return new URL(value).origin;
15
- } catch {
16
- return null;
17
- }
18
- };
19
13
  /**
20
14
  * Social sign-in, as two functions and a list.
21
15
  *
@@ -39,16 +33,20 @@ var createSocialAuth = ({ config, adapters, providers: configured, customProvide
39
33
  redirectUri: credentials.redirectUri || `${config.callbackBaseUrl ?? ""}/auth/${id}/callback`
40
34
  }));
41
35
  }
42
- const sanitizeRedirect = (target) => {
43
- const fallback = config.defaultRedirect;
44
- if (typeof target !== "string" || target === "") return fallback;
45
- if (target.startsWith("/") && !target.startsWith("//") && !target.startsWith("/\\")) return target;
46
- const origin = originOf(target);
47
- if (!origin) return fallback;
48
- return [fallback, ...config.allowedRedirects ?? []].map(originOf).filter((value) => value !== null).includes(origin) ? target : fallback;
49
- };
36
+ const sanitizeRedirect = createRedirectPolicy({
37
+ defaultRedirect: config.defaultRedirect,
38
+ allowedRedirects: config.allowedRedirects
39
+ });
50
40
  const withError = (target, reason) => `${target}${target.includes("?") ? "&" : "?"}error=${encodeURIComponent(reason)}`;
51
41
  return {
42
+ /**
43
+ * Vet a caller-supplied landing page against this deployment's policy, for the flows that are not this one.
44
+ *
45
+ * Exposed because social sign-in is no longer the only thing that takes a `?redirect=` and then navigates to
46
+ * it: the shared sign-in screen does too, and the check has to be the SAME check. Two implementations of "is
47
+ * this target ours" is how one of them ends up accepting `//evil.com`.
48
+ */
49
+ sanitizeRedirect,
52
50
  /** Registered providers, so a front-end renders exactly the buttons that will work. */
53
51
  list: () => [...registry.values()].map((provider) => ({
54
52
  id: provider.id,
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Password hashing that a deployment does not have to choose, and by default does not even have to know about:
3
+ * this is what `createAuthApi` uses when nobody says otherwise.
4
+ *
5
+ * `scrypt` from the standard library, so this costs no dependency and nothing has to be compiled. It is a memory-hard
6
+ * KDF and an entirely respectable answer; argon2 or bcrypt are better ones, and a deployment that wants either passes
7
+ * its own `verifyPassword`/`hashPassword` to `createAuth` — nothing here assumes an algorithm.
8
+ *
9
+ * The format is `scrypt$<salt>$<hash>`, tagged so a later change of algorithm can recognise what it is looking at
10
+ * instead of guessing from the shape.
11
+ */
12
+ export declare const hashPassword: (plain: string) => Promise<string>;
13
+ export declare const verifyPassword: (plain: string, stored: string) => Promise<boolean>;
14
+ /** Opaque single-use strings, for validation and password-reset links. */
15
+ export declare const generateToken: () => string;
@@ -0,0 +1,32 @@
1
+ import { randomBytes, randomUUID, scrypt, timingSafeEqual } from "node:crypto";
2
+ import { promisify } from "node:util";
3
+ //#region src/core/auth/passwords.ts
4
+ var scrypt$1 = promisify(scrypt);
5
+ var KEY_LENGTH = 64;
6
+ /**
7
+ * Password hashing that a deployment does not have to choose, and by default does not even have to know about:
8
+ * this is what `createAuthApi` uses when nobody says otherwise.
9
+ *
10
+ * `scrypt` from the standard library, so this costs no dependency and nothing has to be compiled. It is a memory-hard
11
+ * KDF and an entirely respectable answer; argon2 or bcrypt are better ones, and a deployment that wants either passes
12
+ * its own `verifyPassword`/`hashPassword` to `createAuth` — nothing here assumes an algorithm.
13
+ *
14
+ * The format is `scrypt$<salt>$<hash>`, tagged so a later change of algorithm can recognise what it is looking at
15
+ * instead of guessing from the shape.
16
+ */
17
+ var hashPassword = async (plain) => {
18
+ const salt = randomBytes(16).toString("hex");
19
+ return `scrypt$${salt}$${(await scrypt$1(plain, salt, KEY_LENGTH)).toString("hex")}`;
20
+ };
21
+ var verifyPassword = async (plain, stored) => {
22
+ const [algorithm, salt, digest] = stored.split("$");
23
+ if (algorithm !== "scrypt" || !salt || !digest) return false;
24
+ const expected = Buffer.from(digest, "hex");
25
+ if (expected.length !== KEY_LENGTH) return false;
26
+ const actual = await scrypt$1(plain, salt, KEY_LENGTH);
27
+ return timingSafeEqual(expected, actual);
28
+ };
29
+ /** Opaque single-use strings, for validation and password-reset links. */
30
+ var generateToken = () => randomUUID().replace(/-/g, "");
31
+ //#endregion
32
+ export { generateToken, hashPassword, verifyPassword };
@@ -0,0 +1,18 @@
1
+ export interface RedirectPolicyConfig {
2
+ /** Where a destination that is missing, malformed or not ours goes instead. Its own origin is always allowed. */
3
+ defaultRedirect: string;
4
+ /** Absolute targets a caller may name, compared by origin. */
5
+ allowedRedirects?: string[];
6
+ }
7
+ /**
8
+ * Vet a caller-supplied landing page against a deployment's policy: the target itself when it is ours, the default when
9
+ * it is not.
10
+ *
11
+ * Every flow that takes a `?redirect=` somebody else wrote and later navigates to it — social sign-in, the shared
12
+ * sign-in screen's way out, a confirmation mail that has to bring somebody back — asks THIS, built once from the same
13
+ * configuration. Two implementations of "is this target ours" is how one of them ends up accepting `//evil.com`.
14
+ *
15
+ * A relative path stays on the host that navigates to it and is allowed; a protocol-relative (`//evil.com`) or
16
+ * backslash (`/\evil.com`) one reads as a path and navigates off-site, and is not.
17
+ */
18
+ export declare const createRedirectPolicy: (config: RedirectPolicyConfig) => ((target: unknown) => string);
@@ -0,0 +1,31 @@
1
+ //#region src/core/auth/redirects.ts
2
+ var originOf = (value) => {
3
+ try {
4
+ return new URL(value).origin;
5
+ } catch {
6
+ return null;
7
+ }
8
+ };
9
+ /**
10
+ * Vet a caller-supplied landing page against a deployment's policy: the target itself when it is ours, the default when
11
+ * it is not.
12
+ *
13
+ * Every flow that takes a `?redirect=` somebody else wrote and later navigates to it — social sign-in, the shared
14
+ * sign-in screen's way out, a confirmation mail that has to bring somebody back — asks THIS, built once from the same
15
+ * configuration. Two implementations of "is this target ours" is how one of them ends up accepting `//evil.com`.
16
+ *
17
+ * A relative path stays on the host that navigates to it and is allowed; a protocol-relative (`//evil.com`) or
18
+ * backslash (`/\evil.com`) one reads as a path and navigates off-site, and is not.
19
+ */
20
+ var createRedirectPolicy = (config) => {
21
+ const fallback = config.defaultRedirect;
22
+ const allowed = [fallback, ...config.allowedRedirects ?? []].map(originOf).filter((value) => value !== null);
23
+ return (target) => {
24
+ if (typeof target !== "string" || target === "") return fallback;
25
+ if (target.startsWith("/") && !target.startsWith("//") && !target.startsWith("/\\")) return target;
26
+ const origin = originOf(target);
27
+ return origin && allowed.includes(origin) ? target : fallback;
28
+ };
29
+ };
30
+ //#endregion
31
+ export { createRedirectPolicy };
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,7 @@
1
1
  import { AuthApi, AuthOutcome } from './api';
2
2
  import { AuthPolicy, Requirement } from './authorize';
3
3
  import { CredentialCarrier } from './credentials';
4
+ import { Csrf } from './csrf';
4
5
  import { Actor } from './identity';
5
6
  import { CookieSink, SessionCookies } from './session';
6
7
  /**
@@ -28,9 +29,10 @@ export interface AuthRoute {
28
29
  }
29
30
  /** The flows, ready to mount. Which of them actually answer is decided by the API itself — no adapter, no endpoint —
30
31
  * so mounting all of them is correct even for a deployment that offers three. */
31
- export declare const authRoutes: ({ api, cookies }: {
32
+ export declare const authRoutes: ({ api, cookies, csrf }: {
32
33
  api: AuthApi;
33
34
  cookies: SessionCookies;
35
+ csrf?: Csrf;
34
36
  }) => AuthRoute[];
35
37
  /**
36
38
  * The same flows as authorization rules, for the guard that runs in front of them.
@@ -47,4 +49,4 @@ export declare const authPolicyRules: (prefix?: string) => AuthPolicy["rules"];
47
49
  */
48
50
  export declare const applySessionOutcome: (req: {
49
51
  hostname: string;
50
- }, res: CookieSink, outcome: AuthOutcome, cookies: SessionCookies) => void;
52
+ }, res: CookieSink, outcome: AuthOutcome, cookies: SessionCookies, csrf?: Csrf) => void;