pi-roundtable-web 0.25.0 → 0.26.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 (222) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +145 -8
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-hw49gd0d.js +1 -0
  5. package/dist/chunk-action-menu-rm5cyqkj.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-b017vswv.js} +1 -1
  7. package/dist/chunk-arranged-rooms-q5v3dm1r.js +1 -0
  8. package/dist/chunk-character-creator-5dxcvd16.js +1 -0
  9. package/dist/chunk-codeeditor-pyt09fwg.js +2 -0
  10. package/dist/chunk-confirm-521yqmta.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-hrm06nx8.js} +1 -1
  12. package/dist/chunk-conversations-e26rrf01.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-v41gx2mc.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-jxxb3ath.js} +1 -1
  15. package/dist/{chunk-folder-list-5cp8stna.js → chunk-folder-list-b97xk12b.js} +1 -1
  16. package/dist/chunk-folds-b8q7eags.js +1 -0
  17. package/dist/chunk-gate-8k61stsc.js +1 -0
  18. package/dist/chunk-group-scene-dx6wt19m.js +1 -0
  19. package/dist/chunk-group-table-e4jr8r3a.js +1 -0
  20. package/dist/chunk-host-state-g7e7c0k5.js +1 -0
  21. package/dist/chunk-host-ui-x7k6n07v.js +939 -0
  22. package/dist/{chunk-index-bvfqjh77.js → chunk-index-014rjv9k.js} +2 -2
  23. package/dist/{chunk-index-d5abvq7v.js → chunk-index-2d0kg096.js} +2 -2
  24. package/dist/chunk-index-2qd81wbx.js +2 -0
  25. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-2stynxw6.css} +1 -1
  26. package/dist/{chunk-index-gpkzhq1g.js → chunk-index-459drsft.js} +2 -2
  27. package/dist/chunk-index-4mfj596y.js +2 -0
  28. package/dist/{chunk-index-18gxy79n.js → chunk-index-66nskrdj.js} +2 -2
  29. package/dist/chunk-index-awx6nnta.js +2 -0
  30. package/dist/chunk-index-bbsytycb.js +2 -0
  31. package/dist/chunk-index-c07p8k0x.js +4 -0
  32. package/dist/chunk-index-cq1e9xdz.js +2 -0
  33. package/dist/chunk-index-d1prgfbp.js +2 -0
  34. package/dist/chunk-index-dhpt8qr9.js +2 -0
  35. package/dist/chunk-index-e5sd30xy.js +2 -0
  36. package/dist/chunk-index-g351921r.js +1 -0
  37. package/dist/chunk-index-hby0ap91.js +2 -0
  38. package/dist/chunk-index-hssjbr1k.js +2 -0
  39. package/dist/chunk-index-k2qq1gdj.js +2 -0
  40. package/dist/chunk-index-ke9ynm0n.js +2 -0
  41. package/dist/{chunk-index-s681nny9.js → chunk-index-km40bhsz.js} +2 -2
  42. package/dist/chunk-index-kpscvg85.js +2 -0
  43. package/dist/chunk-index-m0d100mg.js +2 -0
  44. package/dist/chunk-index-mb72snk9.js +2 -0
  45. package/dist/chunk-index-mjqqver5.js +4 -0
  46. package/dist/{chunk-index-zfd00hwb.js → chunk-index-nqfkwkfp.js} +2 -2
  47. package/dist/chunk-index-nwqk447w.js +2 -0
  48. package/dist/{chunk-index-wmbmmrd4.js → chunk-index-pcfhpm8v.js} +4 -4
  49. package/dist/chunk-index-q8h8xzbm.js +2 -0
  50. package/dist/chunk-index-r5vszgfs.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-rhtvcbsq.js} +2 -2
  52. package/dist/chunk-index-rjv7vrwd.js +13 -0
  53. package/dist/chunk-index-v23m38p6.js +50 -0
  54. package/dist/chunk-index-va0g7d88.js +1 -0
  55. package/dist/{chunk-index-krtk6nn2.js → chunk-index-wnjp6wv0.js} +2 -2
  56. package/dist/chunk-index-xgkd15n3.js +2 -0
  57. package/dist/{chunk-index-9jqcskb4.js → chunk-index-y8rnq8zf.js} +5 -5
  58. package/dist/chunk-index-z3k18atc.js +2 -0
  59. package/dist/chunk-lang-css-6bepqpnx.js +1 -0
  60. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-hrav55t7.js} +1 -1
  61. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-x9ahv6e5.js} +3 -3
  62. package/dist/chunk-lang-html-8jc72zt4.js +1 -0
  63. package/dist/chunk-lang-javascript-2p2qtbnf.js +1 -0
  64. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-hsemyd0r.js} +1 -1
  65. package/dist/chunk-lang-markdown-zkwb4c9n.js +6 -0
  66. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-tewx7006.js} +2 -2
  67. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-0ydx84s1.js} +1 -1
  68. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-gq1tmdz4.js} +1 -1
  69. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-xt1qyycg.js} +2 -2
  70. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-0hamc5fc.js} +1 -1
  71. package/dist/chunk-lang-typescript-82xf7z2e.js +1 -0
  72. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-wf6h5pt2.js} +1 -1
  73. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-7vk521h1.js} +1 -1
  74. package/dist/chunk-layout-actions-zh7bezk7.js +1 -0
  75. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1n9qargb.js} +2 -2
  76. package/dist/chunk-notes-t15bh04m.js +1 -0
  77. package/dist/chunk-notice-bell-w3y0h0sr.js +1 -0
  78. package/dist/chunk-notices-fa3g7qze.js +1 -0
  79. package/dist/chunk-onboarding-34v3nr17.js +1 -0
  80. package/dist/chunk-overview-km5vq375.js +1 -0
  81. package/dist/chunk-palette-a8txdgd0.js +1 -0
  82. package/dist/chunk-placement-items-wgaxsbvs.js +1 -0
  83. package/dist/chunk-profile-ypxdbsk1.js +1 -0
  84. package/dist/chunk-prompt-history-wm9dsy6s.js +1 -0
  85. package/dist/chunk-prompts-7mhhq7zs.js +1 -0
  86. package/dist/chunk-room-confirms-t11a1gsw.js +1 -0
  87. package/dist/chunk-room-menu-2tm2xtfx.js +1 -0
  88. package/dist/chunk-rooms-menu-button-wxnsxjwc.js +1 -0
  89. package/dist/chunk-schedules-2ft6xxyw.js +1 -0
  90. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-d3npdtx1.js} +1 -1
  91. package/dist/chunk-sheet-hosts-vngvp7wv.js +1 -0
  92. package/dist/chunk-skill-editor-65n2hsq4.js +8 -0
  93. package/dist/chunk-skills-shkdwes8.js +1 -0
  94. package/dist/chunk-switcher-list-g6hhmr1t.js +1 -0
  95. package/dist/chunk-table-1b6bsq2y.js +1 -0
  96. package/dist/chunk-table-seats-vy1qhdks.js +1 -0
  97. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-n26b7gd0.js} +1 -1
  98. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-8aysyf56.js} +12 -12
  99. package/dist/chunk-transcript-355faceq.js +1 -0
  100. package/dist/chunk-where-j5qdt8a4.js +1 -0
  101. package/dist/chunk-workspace-vrbs6zx1.js +1 -0
  102. package/dist/chunk-zh-tw-zeg600bk.js +1 -0
  103. package/dist/index.html +1 -1
  104. package/dist/sw.js +1 -1
  105. package/package.json +5 -4
  106. package/src/api-types.ts +301 -4
  107. package/src/app/app.ts +196 -32
  108. package/src/app/desk.ts +47 -22
  109. package/src/app/files.ts +73 -7
  110. package/src/app/presence.ts +2 -2
  111. package/src/app/protocol.ts +63 -1
  112. package/src/app/room-log.ts +141 -14
  113. package/src/app/routes.ts +124 -26
  114. package/src/app/socket.ts +309 -77
  115. package/src/app/sockets.ts +108 -20
  116. package/src/app/surface.ts +72 -36
  117. package/src/app/uploads.ts +33 -7
  118. package/src/auth/authenticator.ts +71 -8
  119. package/src/auth/next.ts +52 -0
  120. package/src/auth/oidc-pages.ts +60 -0
  121. package/src/auth/oidc.ts +611 -0
  122. package/src/auth/sessions.ts +193 -0
  123. package/src/authz.ts +172 -0
  124. package/src/console-api.ts +122 -25
  125. package/src/console-server.ts +70 -6
  126. package/src/gate.ts +90 -0
  127. package/src/host/access.ts +161 -0
  128. package/src/host/cards.ts +99 -0
  129. package/src/host/context.ts +141 -0
  130. package/src/host/forms.ts +237 -0
  131. package/src/host/frames.ts +342 -0
  132. package/src/host/host-app.ts +176 -0
  133. package/src/host/host-context.ts +49 -0
  134. package/src/host/host-routes.ts +363 -0
  135. package/src/host/pages.ts +266 -0
  136. package/src/host/reads.ts +155 -0
  137. package/src/host/retention.ts +41 -0
  138. package/src/host/room-views.ts +137 -0
  139. package/src/host/rooms-service.ts +414 -0
  140. package/src/host/stored-json.ts +13 -0
  141. package/src/host/views.ts +103 -0
  142. package/src/i18n/zh-tw.ts +132 -0
  143. package/src/notices/api.ts +43 -0
  144. package/src/notices/mount.ts +292 -0
  145. package/src/notices/service.ts +294 -0
  146. package/src/notices/store.ts +298 -0
  147. package/src/notices/urls.ts +38 -0
  148. package/src/onboarding/api.ts +32 -0
  149. package/src/onboarding/service.ts +149 -0
  150. package/src/onboarding/steps.ts +75 -0
  151. package/src/onboarding/waivers.ts +40 -0
  152. package/src/options.ts +498 -34
  153. package/src/profile/api.ts +175 -0
  154. package/src/profile/links.ts +162 -0
  155. package/src/push/api.ts +189 -52
  156. package/src/push/dispatch.ts +156 -0
  157. package/src/push/drain.ts +139 -0
  158. package/src/push/outbox.ts +218 -0
  159. package/src/push/prefs.ts +118 -0
  160. package/src/push/sender.ts +7 -2
  161. package/src/push/triggers.ts +56 -5
  162. package/src/push/vapid.ts +19 -0
  163. package/src/push/web-push-transport.ts +106 -0
  164. package/src/push-sender/index.ts +14 -0
  165. package/src/push-sender/run.ts +118 -0
  166. package/src/push-sender/session.ts +54 -0
  167. package/src/visitors.ts +94 -6
  168. package/src/web-app.ts +674 -0
  169. package/src/web-plugin.ts +268 -26
  170. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  171. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  172. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  173. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  174. package/dist/chunk-confirm-xrw7d732.js +0 -1
  175. package/dist/chunk-conversations-ry228aes.js +0 -1
  176. package/dist/chunk-folds-hff104tk.js +0 -1
  177. package/dist/chunk-group-scene-h228jdat.js +0 -1
  178. package/dist/chunk-index-0p5h2kdz.js +0 -2
  179. package/dist/chunk-index-0pt08yd9.js +0 -1
  180. package/dist/chunk-index-12bw9yj8.js +0 -2
  181. package/dist/chunk-index-2ad17eh4.js +0 -2
  182. package/dist/chunk-index-31wh5twj.js +0 -52
  183. package/dist/chunk-index-4dy94pbz.js +0 -2
  184. package/dist/chunk-index-4ecjtdy8.js +0 -13
  185. package/dist/chunk-index-4v1b7ks9.js +0 -2
  186. package/dist/chunk-index-5d8yjj4m.js +0 -2
  187. package/dist/chunk-index-89ajmxh7.js +0 -2
  188. package/dist/chunk-index-b9ny4623.js +0 -2
  189. package/dist/chunk-index-e1dr6g10.js +0 -2
  190. package/dist/chunk-index-hk3xyy20.js +0 -2
  191. package/dist/chunk-index-jm23vn03.js +0 -4
  192. package/dist/chunk-index-k7gh0jvz.js +0 -2
  193. package/dist/chunk-index-ky42ev0b.js +0 -2
  194. package/dist/chunk-index-pa2yyqh0.js +0 -2
  195. package/dist/chunk-index-pcvt668q.js +0 -2
  196. package/dist/chunk-index-pqw27eye.js +0 -1
  197. package/dist/chunk-index-ymbjrsp6.js +0 -2
  198. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  199. package/dist/chunk-lang-html-n6jparge.js +0 -1
  200. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  201. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  202. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  203. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  204. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  205. package/dist/chunk-overview-8yw71esz.js +0 -1
  206. package/dist/chunk-palette-frms7axe.js +0 -1
  207. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  208. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  209. package/dist/chunk-prompts-g6609tqx.js +0 -1
  210. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  211. package/dist/chunk-room-menu-g11q97br.js +0 -1
  212. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  213. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  214. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  215. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  216. package/dist/chunk-skills-gqcfhq56.js +0 -1
  217. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  218. package/dist/chunk-table-s8cde1xz.js +0 -1
  219. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  220. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  221. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  222. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -0,0 +1,60 @@
1
+ import { type ConsolePresentation, message } from "../features.ts";
2
+ import { escapeHtml } from "../http.ts";
3
+ import { LOGIN_CSP } from "./login-page.ts";
4
+
5
+ /** What a page of the sign-in flow tells, and the link it offers. */
6
+ type Notice = "signed-out" | "failed" | "not-allowed";
7
+
8
+ /**
9
+ * The static pages of the sign-in flow: logged out, a sign-in that did not complete, and a person
10
+ * the app does not admit. They carry no script and no detail of why: the reason goes to the log.
11
+ */
12
+ export function oidcPage(
13
+ notice: Notice,
14
+ mount: string,
15
+ title: string,
16
+ presentation: ConsolePresentation | undefined,
17
+ status: number,
18
+ ): Response {
19
+ const say = (source: string) => escapeHtml(message(presentation, source));
20
+ const text =
21
+ notice === "signed-out"
22
+ ? say("You are signed out.")
23
+ : notice === "not-allowed"
24
+ ? say("This account is not allowed to use this app.")
25
+ : say("Sign-in did not complete.");
26
+ const link = notice === "signed-out" ? say("Sign in") : say("Try again");
27
+ const html = `<!doctype html>
28
+ <html lang="${escapeHtml(presentation?.locale ?? "en")}">
29
+ <head>
30
+ <meta charset="utf-8">
31
+ <meta name="viewport" content="width=device-width, initial-scale=1">
32
+ <meta name="robots" content="noindex">
33
+ <title>${escapeHtml(title)}</title>
34
+ <style>
35
+ body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#f6f6f4;color:#1c1c1a}
36
+ @media(prefers-color-scheme:dark){body{background:#151514;color:#ecece8}}
37
+ main{width:min(22rem,calc(100vw - 2rem));display:grid;gap:1rem}
38
+ h1{font-size:1.25rem;margin:0}
39
+ p{margin:0}
40
+ a{font:inherit;padding:.6rem .75rem;border-radius:.5rem;background:#2d5be3;color:#fff;text-decoration:none;text-align:center}
41
+ </style>
42
+ </head>
43
+ <body>
44
+ <main>
45
+ <h1>${escapeHtml(title)}</h1>
46
+ <p role="${notice === "signed-out" ? "status" : "alert"}">${text}</p>
47
+ <a href="${escapeHtml(mount)}/login">${link}</a>
48
+ </main>
49
+ </body>
50
+ </html>
51
+ `;
52
+ return new Response(html, {
53
+ status,
54
+ headers: {
55
+ "content-type": "text/html;charset=utf-8",
56
+ "cache-control": "no-store",
57
+ "content-security-policy": LOGIN_CSP,
58
+ },
59
+ });
60
+ }
@@ -0,0 +1,611 @@
1
+ import {
2
+ createHash,
3
+ createHmac,
4
+ randomBytes,
5
+ timingSafeEqual,
6
+ } from "node:crypto";
7
+ import { createRemoteJWKSet, type JWTVerifyGetKey, jwtVerify } from "jose";
8
+ import type { ActorFacts, Logger } from "pi-roundtable";
9
+ import { PluginError } from "pi-roundtable";
10
+ import { oidcProvider } from "pi-roundtable/kit";
11
+ import type { ConsolePresentation } from "../features.ts";
12
+ import type { ResolvedOidc } from "../options.ts";
13
+ import type { Identified } from "../visitors.ts";
14
+ import type { Credential, Unauthenticated } from "./authenticator.ts";
15
+ import { allowedNext, routeOfNext } from "./next.ts";
16
+ import { oidcPage } from "./oidc-pages.ts";
17
+ import type { SessionActor, WebSessions } from "./sessions.ts";
18
+
19
+ /** How long a sign-in may take between the redirect to the provider and its callback. */
20
+ const PENDING_MS = 600_000;
21
+ /** How long the provider's answers may take. */
22
+ const PROVIDER_TIMEOUT_MS = 10_000;
23
+ /** The most roles one sign-in carries, and the longest role name. */
24
+ const MAX_ROLES = 50;
25
+ const MAX_ROLE_CHARS = 100;
26
+ const MAX_NAME_CHARS = 100;
27
+ const MAX_SUBJECT_CHARS = 256;
28
+ /** The most characters of one stored claim, as JSON. */
29
+ const MAX_CLAIM_CHARS = 2000;
30
+ /** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
31
+ const INTERACTION_NEEDED = new Set([
32
+ "login_required",
33
+ "interaction_required",
34
+ "account_selection_required",
35
+ "consent_required",
36
+ ]);
37
+
38
+ /** The provider's endpoints, from its discovery document. */
39
+ export interface ProviderMetadata {
40
+ authorizationEndpoint: string;
41
+ tokenEndpoint: string;
42
+ jwksUri: string;
43
+ /** Where logout can also sign out at the provider; absent when it offers none. */
44
+ endSessionEndpoint?: string;
45
+ }
46
+
47
+ /** A URL the provider's discovery document named, already checked; throws if it somehow is none. */
48
+ function urlOf(value: string): URL {
49
+ const url = URL.parse(value);
50
+ if (!url)
51
+ throw new PluginError("pi-roundtable-web: an oidc endpoint is no URL");
52
+ return url;
53
+ }
54
+
55
+ const endpoint = (value: unknown): string | undefined => {
56
+ const url = typeof value === "string" ? URL.parse(value) : null;
57
+ if (!url) return undefined;
58
+ const loopback =
59
+ url.protocol === "http:" &&
60
+ ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
61
+ return url.protocol === "https:" || loopback ? url.href : undefined;
62
+ };
63
+
64
+ /**
65
+ * Reads the provider's discovery document and checks it names this issuer. Throws a PluginError
66
+ * naming what is wrong, so a host with a wrong issuer or an unreachable provider stops at start.
67
+ */
68
+ export async function discoverProvider(
69
+ settings: Pick<ResolvedOidc, "issuer" | "discoveryUrl">,
70
+ fetcher: typeof fetch = fetch,
71
+ ): Promise<ProviderMetadata> {
72
+ const fail = (what: string): never => {
73
+ throw new PluginError(
74
+ `pi-roundtable-web: the oidc discovery document at ${settings.discoveryUrl} ${what}`,
75
+ );
76
+ };
77
+ let body: Record<string, unknown>;
78
+ try {
79
+ const response = await fetcher(settings.discoveryUrl, {
80
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
81
+ });
82
+ if (!response.ok) return fail(`answered ${response.status}`);
83
+ body = (await response.json()) as Record<string, unknown>;
84
+ } catch (error) {
85
+ if (error instanceof PluginError) throw error;
86
+ return fail("could not be read");
87
+ }
88
+ if (body.issuer !== settings.issuer)
89
+ fail(
90
+ `names the issuer ${JSON.stringify(body.issuer)}, not ${JSON.stringify(settings.issuer)}`,
91
+ );
92
+ const authorizationEndpoint = endpoint(body.authorization_endpoint);
93
+ const tokenEndpoint = endpoint(body.token_endpoint);
94
+ const jwksUri = endpoint(body.jwks_uri);
95
+ if (!authorizationEndpoint || !tokenEndpoint || !jwksUri)
96
+ fail("lacks an https authorization_endpoint, token_endpoint or jwks_uri");
97
+ const end = endpoint(body.end_session_endpoint);
98
+ return {
99
+ authorizationEndpoint: authorizationEndpoint as string,
100
+ tokenEndpoint: tokenEndpoint as string,
101
+ jwksUri: jwksUri as string,
102
+ ...(end ? { endSessionEndpoint: end } : {}),
103
+ };
104
+ }
105
+
106
+ /** What a sign-in did, for the event and the people-naming hooks; never carries a token or a claim. */
107
+ export interface SignedIn {
108
+ principalId: string;
109
+ /** `<provider>:<subject>`. */
110
+ speakerId: string;
111
+ provider: string;
112
+ name: string;
113
+ }
114
+
115
+ export interface OidcLoginOptions {
116
+ settings: ResolvedOidc;
117
+ metadata: ProviderMetadata;
118
+ /** The client secret, when the client is confidential. */
119
+ clientSecret?: string;
120
+ sessions: WebSessions;
121
+ /** The session key, which signs the pending-login cookie. */
122
+ key: Buffer;
123
+ origin: string;
124
+ mount: string;
125
+ /** The app's surface, such as `app`: the prefix of the roles a sign-in carries. */
126
+ surface: string;
127
+ routing: "hash" | "path";
128
+ title: string;
129
+ presentation?: ConsolePresentation | undefined;
130
+ /** Who the actor is and the tier they hold; the same admission every request passes. */
131
+ identify(actor: ActorFacts): Promise<Identified>;
132
+ /** A sign-in completed: the host's event and name hooks. Never throws into the flow. */
133
+ onSignIn?(info: SignedIn): void | Promise<void>;
134
+ /** A session ended (logout), so the sockets on it can close. */
135
+ onEnded?(sessionId: string): void;
136
+ logger: Logger;
137
+ /** The provider's signing keys; the published ones by default. */
138
+ keys?: JWTVerifyGetKey;
139
+ fetch?: typeof fetch;
140
+ /** The clock, in milliseconds; default `Date.now`. */
141
+ now?: () => number;
142
+ }
143
+
144
+ interface Pending {
145
+ /** state */
146
+ s: string;
147
+ /** nonce */
148
+ n: string;
149
+ /** PKCE verifier */
150
+ v: string;
151
+ /** where to go afterwards */
152
+ x?: string;
153
+ /** the request was silent (`prompt=none`) */
154
+ i: boolean;
155
+ /** expires, ms */
156
+ e: number;
157
+ }
158
+
159
+ /** A request with no usable session, which signing in would fix. */
160
+ const needsSignIn = (why: string): Unauthenticated => ({
161
+ refusal: why,
162
+ status: 401,
163
+ login: true,
164
+ });
165
+
166
+ const sha256 = (value: string | Buffer) =>
167
+ createHash("sha256").update(value).digest();
168
+
169
+ const same = (a: Buffer, b: Buffer) =>
170
+ a.length === b.length && timingSafeEqual(a, b);
171
+
172
+ const cookieValue = (
173
+ header: string | null,
174
+ name: string,
175
+ ): string | undefined => {
176
+ for (const part of (header ?? "").split(";")) {
177
+ const at = part.indexOf("=");
178
+ if (at > 0 && part.slice(0, at).trim() === name)
179
+ return part.slice(at + 1).trim();
180
+ }
181
+ return undefined;
182
+ };
183
+
184
+ /** A claim's text, or undefined when it is not a usable string. */
185
+ const textClaim = (value: unknown, limit: number): string | undefined => {
186
+ const text =
187
+ typeof value === "number" && Number.isFinite(value) ? String(value) : value;
188
+ return typeof text === "string" && text.trim() && text.length <= limit
189
+ ? text.trim()
190
+ : undefined;
191
+ };
192
+
193
+ /**
194
+ * Sign-in with an OpenID Connect provider, and the app's session behind it.
195
+ *
196
+ * `login` sends the person to the provider with a state, a nonce and a PKCE challenge, kept in a
197
+ * signed cookie that lives ten minutes and works once. `auth/callback` exchanges the code, checks
198
+ * the id_token once (signature, issuer, audience, expiry, nonce), asks the host's `check`, and has
199
+ * `IDENTITY` admit the person; only then is a session made, in `web_sessions`, and its random id
200
+ * set in a cookie. Every later request is judged by that session and a fresh read of the tier;
201
+ * the provider is not asked again unless `reauthHours` says so. Nothing here logs a token, a
202
+ * claim, a code or a session id.
203
+ */
204
+ export class OidcLogin {
205
+ readonly #options: OidcLoginOptions;
206
+ readonly #keys: JWTVerifyGetKey;
207
+ /** The names of the cookies: `__Secure-` because the mount is never the root. */
208
+ readonly #sessionCookie = "__Secure-rt_session";
209
+ readonly #pendingCookie = "__Secure-rt_login";
210
+ readonly #outCookie = "__Secure-rt_out";
211
+ /** States already used, until they would have lapsed. */
212
+ readonly #spent = new Map<string, number>();
213
+
214
+ constructor(options: OidcLoginOptions) {
215
+ this.#options = options;
216
+ this.#keys =
217
+ options.keys ??
218
+ createRemoteJWKSet(urlOf(options.metadata.jwksUri), {
219
+ timeoutDuration: PROVIDER_TIMEOUT_MS,
220
+ });
221
+ }
222
+
223
+ #now(): number {
224
+ return (this.#options.now ?? Date.now)();
225
+ }
226
+
227
+ #sign(value: Pending): string {
228
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
229
+ const mac = createHmac("sha256", this.#options.key)
230
+ .update(payload)
231
+ .digest("base64url");
232
+ return `${payload}.${mac}`;
233
+ }
234
+
235
+ /** The pending sign-in the cookie holds, if it is ours, unaltered and not lapsed. */
236
+ #pending(request: Request): Pending | undefined {
237
+ const value = cookieValue(
238
+ request.headers.get("cookie"),
239
+ this.#pendingCookie,
240
+ );
241
+ const [payload, mac, extra] = (value ?? "").split(".");
242
+ if (!payload || !mac || extra !== undefined) return undefined;
243
+ const want = createHmac("sha256", this.#options.key)
244
+ .update(payload)
245
+ .digest();
246
+ if (!same(want, Buffer.from(mac, "base64url"))) return undefined;
247
+ try {
248
+ const pending = JSON.parse(
249
+ Buffer.from(payload, "base64url").toString("utf8"),
250
+ ) as Pending;
251
+ return typeof pending.s === "string" &&
252
+ typeof pending.n === "string" &&
253
+ typeof pending.v === "string" &&
254
+ typeof pending.e === "number" &&
255
+ pending.e > this.#now()
256
+ ? pending
257
+ : undefined;
258
+ } catch {
259
+ return undefined;
260
+ }
261
+ }
262
+
263
+ #cookie(
264
+ name: string,
265
+ value: string,
266
+ path: string,
267
+ maxAge: number,
268
+ lax = true,
269
+ ): string {
270
+ return `${name}=${value}; Path=${path}; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=${lax ? "Lax" : "Strict"}`;
271
+ }
272
+
273
+ /** Cookie lines that clear the pending-login cookie. */
274
+ #clearPending(): string {
275
+ return this.#cookie(
276
+ this.#pendingCookie,
277
+ "",
278
+ `${this.#options.mount}/auth/`,
279
+ 0,
280
+ );
281
+ }
282
+
283
+ #redirect(to: string, cookies: string[]): Response {
284
+ const headers = new Headers({ location: to, "cache-control": "no-store" });
285
+ for (const cookie of cookies) headers.append("set-cookie", cookie);
286
+ return new Response(null, { status: 303, headers });
287
+ }
288
+
289
+ #page(
290
+ notice: "signed-out" | "failed" | "not-allowed",
291
+ status: number,
292
+ cookies: string[] = [],
293
+ ): Response {
294
+ const { mount, title, presentation } = this.#options;
295
+ const response = oidcPage(notice, mount, title, presentation, status);
296
+ for (const cookie of cookies) response.headers.append("set-cookie", cookie);
297
+ return response;
298
+ }
299
+
300
+ /** The paths below the mount this flow answers without a session; undefined for any other. */
301
+ async open(request: Request, rest: string): Promise<Response | undefined> {
302
+ const read = request.method === "GET" || request.method === "HEAD";
303
+ switch (rest) {
304
+ case "login":
305
+ return read
306
+ ? this.#login(request)
307
+ : new Response("Method not allowed", { status: 405 });
308
+ case "auth/callback":
309
+ return read
310
+ ? this.#callback(request)
311
+ : new Response("Method not allowed", { status: 405 });
312
+ case "auth/signed-out":
313
+ return read
314
+ ? this.#page("signed-out", 200)
315
+ : new Response("Method not allowed", { status: 405 });
316
+ case "auth/logout":
317
+ return request.method === "POST"
318
+ ? this.#logout(request)
319
+ : new Response("Method not allowed", { status: 405 });
320
+ default:
321
+ return undefined;
322
+ }
323
+ }
324
+
325
+ // ── Sign-in ──
326
+
327
+ #login(request: Request): Response {
328
+ const next = allowedNext(
329
+ URL.parse(request.url)?.searchParams.get("next"),
330
+ this.#options.surface,
331
+ );
332
+ // A person who just logged out is asked, not signed in again silently by a provider session that is still alive.
333
+ const silent =
334
+ cookieValue(request.headers.get("cookie"), this.#outCookie) === undefined;
335
+ return this.#authorize(next, silent, []);
336
+ }
337
+
338
+ #authorize(
339
+ next: string | undefined,
340
+ silent: boolean,
341
+ cookies: string[],
342
+ ): Response {
343
+ const { settings, metadata, origin, mount } = this.#options;
344
+ const pending: Pending = {
345
+ s: randomBytes(32).toString("base64url"),
346
+ n: randomBytes(32).toString("base64url"),
347
+ v: randomBytes(48).toString("base64url"),
348
+ ...(next ? { x: next } : {}),
349
+ i: silent,
350
+ e: this.#now() + PENDING_MS,
351
+ };
352
+ const url = urlOf(metadata.authorizationEndpoint);
353
+ const params = url.searchParams;
354
+ params.set("response_type", "code");
355
+ params.set("client_id", settings.clientId);
356
+ params.set("redirect_uri", `${origin}${mount}/auth/callback`);
357
+ params.set("scope", settings.scopes.join(" "));
358
+ params.set("state", pending.s);
359
+ params.set("nonce", pending.n);
360
+ params.set("code_challenge", sha256(pending.v).toString("base64url"));
361
+ params.set("code_challenge_method", "S256");
362
+ if (silent) params.set("prompt", "none");
363
+ return this.#redirect(url.href, [
364
+ ...cookies,
365
+ this.#cookie(
366
+ this.#pendingCookie,
367
+ this.#sign(pending),
368
+ `${mount}/auth/`,
369
+ PENDING_MS / 1000,
370
+ ),
371
+ ]);
372
+ }
373
+
374
+ async #callback(request: Request): Promise<Response> {
375
+ const { logger, mount } = this.#options;
376
+ const clear = [this.#clearPending()];
377
+ const params =
378
+ URL.parse(request.url)?.searchParams ?? new URLSearchParams();
379
+ const pending = this.#pending(request);
380
+ const fail = (
381
+ reason: string,
382
+ notice: "failed" | "not-allowed" = "failed",
383
+ ) => {
384
+ logger.warn({ reason }, "web-console: a sign-in did not complete");
385
+ return this.#page(notice, notice === "failed" ? 400 : 403, clear);
386
+ };
387
+ if (!pending) return fail("no pending sign-in");
388
+ const spent = this.#spent;
389
+ for (const [state, until] of spent)
390
+ if (until <= this.#now()) spent.delete(state);
391
+ const state = params.get("state");
392
+ if (!state || !same(Buffer.from(state), Buffer.from(pending.s)))
393
+ return fail("state mismatch");
394
+ if (spent.has(state)) return fail("state used twice");
395
+ spent.set(state, pending.e);
396
+ const error = params.get("error");
397
+ if (error) {
398
+ // A silent request the provider cannot answer alone becomes a normal sign-in, once.
399
+ if (pending.i && INTERACTION_NEEDED.has(error))
400
+ return this.#authorize(pending.x, false, clear);
401
+ return fail("the provider refused the sign-in");
402
+ }
403
+ const code = params.get("code");
404
+ if (!code || code.length > 4096) return fail("no code");
405
+ let claims: Record<string, unknown>;
406
+ try {
407
+ claims = await this.#claims(code, pending);
408
+ } catch (cause) {
409
+ return fail(
410
+ cause instanceof Error ? cause.message : "token not accepted",
411
+ );
412
+ }
413
+ const { settings } = this.#options;
414
+ try {
415
+ if (settings.check && !settings.check(claims))
416
+ return fail("the host's check refused the token", "not-allowed");
417
+ } catch {
418
+ return fail("the host's check failed", "not-allowed");
419
+ }
420
+ const actor = this.#actorOf(claims);
421
+ if (!actor) return fail("the token names no usable subject");
422
+ let identified: Identified;
423
+ try {
424
+ identified = await this.#options.identify(actor);
425
+ } catch {
426
+ return fail("identity failed");
427
+ }
428
+ if ("refusal" in identified)
429
+ return fail("the access rules refuse this person", "not-allowed");
430
+ const { principal } = identified.visitor;
431
+ const kept: Record<string, unknown> = {};
432
+ for (const name of settings.storeClaims) {
433
+ const value = claims[name];
434
+ if (
435
+ value !== undefined &&
436
+ JSON.stringify(value).length <= MAX_CLAIM_CHARS
437
+ )
438
+ kept[name] = value;
439
+ }
440
+ const stored: SessionActor =
441
+ settings.storeClaims.length > 0 ? { ...actor, claims: kept } : actor;
442
+ const made = await this.#options.sessions.create(stored, principal.id);
443
+ // The sign-in replaces whatever session the browser held.
444
+ const old = cookieValue(request.headers.get("cookie"), this.#sessionCookie);
445
+ if (old) await this.#options.sessions.end(old).catch(() => undefined);
446
+ try {
447
+ await this.#options.onSignIn?.({
448
+ principalId: principal.id,
449
+ speakerId: identified.visitor.speakerId,
450
+ provider: actor.provider,
451
+ name: actor.name,
452
+ });
453
+ } catch (cause) {
454
+ logger.warn({ err: cause }, "web-console: a sign-in hook failed");
455
+ }
456
+ const days = this.#options.settings.sessionDays;
457
+ const next = pending.x ? routeOfNext(pending.x) : "";
458
+ const base = `${mount}/`;
459
+ return this.#redirect(
460
+ this.#options.routing === "path"
461
+ ? `${base}${next}`
462
+ : next
463
+ ? `${base}#/${next}`
464
+ : base,
465
+ [
466
+ ...clear,
467
+ this.#cookie(this.#sessionCookie, made.token, base, days * 86400),
468
+ // A later logout leaves its marker; a sign-in clears it.
469
+ this.#cookie(this.#outCookie, "", base, 0),
470
+ ],
471
+ );
472
+ }
473
+
474
+ /** Exchanges the code and checks the id_token; throws a short reason, never a token. */
475
+ async #claims(
476
+ code: string,
477
+ pending: Pending,
478
+ ): Promise<Record<string, unknown>> {
479
+ const { settings, metadata, origin, mount, clientSecret } = this.#options;
480
+ const form = new URLSearchParams({
481
+ grant_type: "authorization_code",
482
+ code,
483
+ redirect_uri: `${origin}${mount}/auth/callback`,
484
+ client_id: settings.clientId,
485
+ code_verifier: pending.v,
486
+ });
487
+ if (clientSecret) form.set("client_secret", clientSecret);
488
+ let idToken: unknown;
489
+ try {
490
+ const response = await (this.#options.fetch ?? fetch)(
491
+ metadata.tokenEndpoint,
492
+ {
493
+ method: "POST",
494
+ headers: { "content-type": "application/x-www-form-urlencoded" },
495
+ body: form,
496
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
497
+ },
498
+ );
499
+ if (!response.ok) throw new Error("the token endpoint refused the code");
500
+ idToken = ((await response.json()) as { id_token?: unknown }).id_token;
501
+ } catch (cause) {
502
+ throw cause instanceof Error &&
503
+ cause.message === "the token endpoint refused the code"
504
+ ? cause
505
+ : new Error("the token endpoint could not be reached");
506
+ }
507
+ if (typeof idToken !== "string" || idToken.length > 16_384)
508
+ throw new Error("the token response has no id_token");
509
+ let payload: Record<string, unknown>;
510
+ try {
511
+ ({ payload } = await jwtVerify(idToken, this.#keys, {
512
+ issuer: settings.issuer,
513
+ audience: settings.clientId,
514
+ algorithms: ["RS256", "ES256"],
515
+ }));
516
+ } catch {
517
+ throw new Error("the id_token was not accepted");
518
+ }
519
+ if (typeof payload.nonce !== "string" || payload.nonce !== pending.n)
520
+ throw new Error("the id_token's nonce does not match");
521
+ return payload;
522
+ }
523
+
524
+ /** The actor facts a verified token stands for, without the surface or `partial`: admission adds those. */
525
+ #actorOf(claims: Record<string, unknown>): SessionActor | undefined {
526
+ const { settings, surface } = this.#options;
527
+ const subject = textClaim(claims[settings.subjectClaim], MAX_SUBJECT_CHARS);
528
+ if (!subject) return undefined;
529
+ const name =
530
+ textClaim(
531
+ settings.nameClaim
532
+ ? claims[settings.nameClaim]
533
+ : (claims.name ?? claims.preferred_username),
534
+ MAX_NAME_CHARS,
535
+ ) ??
536
+ textClaim(claims.preferred_username, MAX_NAME_CHARS) ??
537
+ subject.slice(0, MAX_NAME_CHARS);
538
+ const given =
539
+ settings.rolesClaim === false ? undefined : claims[settings.rolesClaim];
540
+ const names = (
541
+ Array.isArray(given) ? given : given === undefined ? [] : [given]
542
+ )
543
+ .map((role) => textClaim(role, MAX_ROLE_CHARS))
544
+ .filter((role): role is string => role !== undefined)
545
+ .slice(0, MAX_ROLES);
546
+ return {
547
+ provider: oidcProvider(settings.issuer),
548
+ subject,
549
+ name,
550
+ ...(names.length > 0
551
+ ? {
552
+ roles: [...new Set(names)].map((role) => `${surface}:role:${role}`),
553
+ }
554
+ : {}),
555
+ };
556
+ }
557
+
558
+ // ── The session ──
559
+
560
+ /** The credential of a request: the session its cookie names, or why there is none. */
561
+ readonly credential: Credential = async (request) => {
562
+ const token = cookieValue(
563
+ request.headers.get("cookie"),
564
+ this.#sessionCookie,
565
+ );
566
+ if (!token) return needsSignIn("no session");
567
+ const session = await this.#options.sessions.touch(token);
568
+ if (!session) return needsSignIn("session ended");
569
+ const { reauthHours } = this.#options.settings;
570
+ if (
571
+ reauthHours !== undefined &&
572
+ this.#now() - session.verifiedAt.getTime() > reauthHours * 3_600_000
573
+ )
574
+ return needsSignIn("the provider is due to vouch again");
575
+ const { claims: _kept, ...facts } = session.actor;
576
+ return { admitted: true, actor: facts, session: session.id };
577
+ };
578
+
579
+ async #logout(request: Request): Promise<Response> {
580
+ const { mount, origin, settings, metadata } = this.#options;
581
+ if (request.headers.get("origin") !== origin)
582
+ return new Response("Forbidden", {
583
+ status: 403,
584
+ headers: { "cache-control": "no-store" },
585
+ });
586
+ const token = cookieValue(
587
+ request.headers.get("cookie"),
588
+ this.#sessionCookie,
589
+ );
590
+ if (token) {
591
+ const id = await this.#options.sessions.end(token).catch(() => undefined);
592
+ if (id) this.#options.onEnded?.(id);
593
+ }
594
+ const base = `${mount}/`;
595
+ let to = `${mount}/auth/signed-out`;
596
+ if (settings.endSession && metadata.endSessionEndpoint) {
597
+ const url = urlOf(metadata.endSessionEndpoint);
598
+ url.searchParams.set("client_id", settings.clientId);
599
+ url.searchParams.set(
600
+ "post_logout_redirect_uri",
601
+ `${origin}${mount}/auth/signed-out`,
602
+ );
603
+ to = url.href;
604
+ }
605
+ return this.#redirect(to, [
606
+ this.#cookie(this.#sessionCookie, "", base, 0),
607
+ // Marks the next sign-in as a deliberate one: no silent round trip until it completes.
608
+ this.#cookie(this.#outCookie, "1", base, 86400),
609
+ ]);
610
+ }
611
+ }