pi-roundtable-web 0.25.0 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (243) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +165 -12
  3. package/dist/account-zk4efb87.css +102 -0
  4. package/dist/chunk-account-ghq105t4.js +1 -0
  5. package/dist/chunk-action-menu-me0g9qsc.js +1 -0
  6. package/dist/{chunk-agent-0pm9p3nn.js → chunk-agent-rdv7ekrt.js} +1 -1
  7. package/dist/chunk-arranged-rooms-81rqhx9x.js +1 -0
  8. package/dist/chunk-character-creator-bd426yjw.js +1 -0
  9. package/dist/chunk-codeeditor-ajhgd33t.js +2 -0
  10. package/dist/chunk-confirm-p3mts8mx.js +1 -0
  11. package/dist/{chunk-connectors-anhghy3k.js → chunk-connectors-bkv3xn83.js} +1 -1
  12. package/dist/chunk-conversations-0e0vbxwr.js +1 -0
  13. package/dist/{chunk-drag-q4fx9b22.js → chunk-drag-peyb8t0k.js} +1 -1
  14. package/dist/{chunk-entry-he1embgp.js → chunk-entry-mv7q8br0.js} +1 -1
  15. package/dist/chunk-folder-list-era3tksh.js +1 -0
  16. package/dist/chunk-folds-hvws0bd5.js +1 -0
  17. package/dist/chunk-gate-hc7dd0fx.js +1 -0
  18. package/dist/chunk-group-scene-dkbpmbh5.js +1 -0
  19. package/dist/chunk-group-table-fqmxgdfw.js +1 -0
  20. package/dist/chunk-host-icons-4xde9ntk.js +1 -0
  21. package/dist/chunk-host-picture-014yzk20.js +1 -0
  22. package/dist/chunk-host-state-t56e5sdy.js +1 -0
  23. package/dist/chunk-host-ui-p1zy9nmp.js +1 -0
  24. package/dist/chunk-index-0fmmvgee.js +2 -0
  25. package/dist/chunk-index-0q15ysrb.js +1 -0
  26. package/dist/chunk-index-0xh24y25.js +2 -0
  27. package/dist/chunk-index-2yebd9tb.js +426 -0
  28. package/dist/chunk-index-4t0r3t3b.js +2 -0
  29. package/dist/chunk-index-5yzfrf7b.js +138 -0
  30. package/dist/{chunk-index-d5abvq7v.js → chunk-index-6twb7ccd.js} +2 -2
  31. package/dist/chunk-index-73gh7kgr.js +920 -0
  32. package/dist/chunk-index-7xffskfa.js +8 -0
  33. package/dist/chunk-index-aqw7cy5j.js +1 -0
  34. package/dist/chunk-index-b6ca02e8.js +2 -0
  35. package/dist/chunk-index-b8nvhcx5.js +2 -0
  36. package/dist/{chunk-index-bvfqjh77.js → chunk-index-cvbdf2ca.js} +2 -2
  37. package/dist/chunk-index-czdketze.js +2 -0
  38. package/dist/{chunk-index-9jqcskb4.js → chunk-index-dt9775ka.js} +5 -5
  39. package/dist/chunk-index-eg55mqqe.js +1 -0
  40. package/dist/chunk-index-gr6e0hse.js +2 -0
  41. package/dist/chunk-index-gyary7np.js +13 -0
  42. package/dist/chunk-index-j214tczc.js +1 -0
  43. package/dist/chunk-index-jd6632ef.js +2 -0
  44. package/dist/chunk-index-jw7dtshr.js +2 -0
  45. package/dist/{chunk-index-krtk6nn2.js → chunk-index-k7t14rwa.js} +2 -2
  46. package/dist/chunk-index-kwcx62wb.js +4 -0
  47. package/dist/chunk-index-m7fvw92f.js +2 -0
  48. package/dist/chunk-index-mmg788a1.js +2 -0
  49. package/dist/chunk-index-mq0dacwr.js +2 -0
  50. package/dist/chunk-index-p53ak25v.js +2 -0
  51. package/dist/{chunk-index-27qca4mr.js → chunk-index-pk1yxkv4.js} +2 -2
  52. package/dist/{chunk-index-9b1kt7qw.css → chunk-index-rbcz9jd4.css} +1 -1
  53. package/dist/{chunk-index-s681nny9.js → chunk-index-rmxbdmn2.js} +2 -2
  54. package/dist/chunk-index-rnxj3z8k.js +2 -0
  55. package/dist/chunk-index-snzzfhk3.js +2 -0
  56. package/dist/chunk-index-v3bmr0nw.js +2 -0
  57. package/dist/chunk-index-vcyzwtk2.js +2 -0
  58. package/dist/{chunk-index-zfd00hwb.js → chunk-index-w2j3njb6.js} +2 -2
  59. package/dist/chunk-index-w31c4yjv.js +2 -0
  60. package/dist/chunk-index-wrm8w165.js +2 -0
  61. package/dist/chunk-index-xh0mg9cw.js +2 -0
  62. package/dist/chunk-index-ybtvvx85.js +2 -0
  63. package/dist/chunk-index-yzsgsvnp.js +2 -0
  64. package/dist/{chunk-index-18gxy79n.js → chunk-index-z9x4cnzf.js} +2 -2
  65. package/dist/chunk-index-zrvg23jd.js +2 -0
  66. package/dist/chunk-index-zynq0b0w.js +50 -0
  67. package/dist/chunk-lang-css-88v3hp3d.js +1 -0
  68. package/dist/{chunk-lang-dockerfile-d5mzxs60.js → chunk-lang-dockerfile-k728ewsf.js} +1 -1
  69. package/dist/{chunk-lang-go-830fmhba.js → chunk-lang-go-wjwgay4v.js} +3 -3
  70. package/dist/chunk-lang-html-07d47es4.js +1 -0
  71. package/dist/chunk-lang-javascript-fmhhgzwd.js +1 -0
  72. package/dist/{chunk-lang-json-5eaarz7j.js → chunk-lang-json-7gd89mvs.js} +1 -1
  73. package/dist/chunk-lang-markdown-b6qbe8bt.js +6 -0
  74. package/dist/{chunk-lang-python-fjw84w10.js → chunk-lang-python-3v56y31b.js} +2 -2
  75. package/dist/{chunk-lang-rust-5rbp2zdk.js → chunk-lang-rust-kh0z74d1.js} +1 -1
  76. package/dist/{chunk-lang-shell-vzagpym3.js → chunk-lang-shell-9zv0zvbx.js} +1 -1
  77. package/dist/{chunk-lang-sql-0c2w9c2y.js → chunk-lang-sql-yw6bf3t7.js} +2 -2
  78. package/dist/{chunk-lang-toml-yr9fv6eb.js → chunk-lang-toml-tg1r96nq.js} +1 -1
  79. package/dist/chunk-lang-typescript-etjjvr2g.js +1 -0
  80. package/dist/{chunk-lang-xml-eze08p42.js → chunk-lang-xml-ev1mndd4.js} +1 -1
  81. package/dist/{chunk-lang-yaml-c4b6ft3y.js → chunk-lang-yaml-fz5qpb3r.js} +1 -1
  82. package/dist/chunk-layout-actions-48ecgtkg.js +1 -0
  83. package/dist/{chunk-menu-items-jrwxycr1.js → chunk-menu-items-1epy3h0t.js} +2 -2
  84. package/dist/chunk-mode-toggle-fxmfxsa4.js +1 -0
  85. package/dist/chunk-note-row-kd0fy7ee.js +75 -0
  86. package/dist/chunk-notes-yb8f057c.js +1 -0
  87. package/dist/chunk-notice-bell-wr4f1rph.js +1 -0
  88. package/dist/chunk-notices-x07da5bb.js +1 -0
  89. package/dist/chunk-onboarding-csrkbh9z.js +1 -0
  90. package/dist/chunk-overview-avc2d30t.js +1 -0
  91. package/dist/chunk-palette-9nhp95bz.js +1 -0
  92. package/dist/chunk-placement-items-q694dpgk.js +1 -0
  93. package/dist/chunk-profile-6qemf3m7.js +1 -0
  94. package/dist/chunk-prompt-history-7enzp2p3.js +1 -0
  95. package/dist/chunk-prompts-nv6mrn0q.js +1 -0
  96. package/dist/chunk-room-confirms-zz7n61y7.js +1 -0
  97. package/dist/chunk-room-menu-rr6nzap1.js +1 -0
  98. package/dist/chunk-rooms-menu-button-zbtwj47d.js +1 -0
  99. package/dist/chunk-schedules-rw57nr0m.js +1 -0
  100. package/dist/{chunk-seat-menu-jv4s7hmj.js → chunk-seat-menu-ys5v9abz.js} +1 -1
  101. package/dist/chunk-sheet-hosts-jhvaacst.js +1 -0
  102. package/dist/chunk-skill-editor-sz8an8qr.js +8 -0
  103. package/dist/chunk-skills-tnr1h16h.js +1 -0
  104. package/dist/chunk-switcher-list-17enhrse.js +1 -0
  105. package/dist/chunk-tab-dot-r6brdbmw.js +1 -0
  106. package/dist/chunk-table-r12cja6p.js +1 -0
  107. package/dist/chunk-table-seats-g3t392zz.js +1 -0
  108. package/dist/{chunk-table2d-4sje1fr0.js → chunk-table2d-p3ezsdcj.js} +1 -1
  109. package/dist/{chunk-table3d-h64skazp.js → chunk-table3d-4x2n2b00.js} +23 -23
  110. package/dist/chunk-thread-actions-qc6ezkha.js +1 -0
  111. package/dist/chunk-transcript-n2s6dher.js +1 -0
  112. package/dist/chunk-where-f3scdydc.js +1 -0
  113. package/dist/chunk-workspace-xs023bxm.js +1 -0
  114. package/dist/chunk-zh-tw-efvmk3fd.js +1 -0
  115. package/dist/index.html +1 -1
  116. package/dist/sw.js +1 -1
  117. package/package.json +5 -4
  118. package/src/api-types.ts +356 -4
  119. package/src/app/app.ts +201 -32
  120. package/src/app/desk.ts +47 -22
  121. package/src/app/files.ts +73 -7
  122. package/src/app/presence.ts +2 -2
  123. package/src/app/protocol.ts +63 -1
  124. package/src/app/room-log.ts +153 -14
  125. package/src/app/rooms.ts +39 -0
  126. package/src/app/routes.ts +168 -28
  127. package/src/app/socket.ts +349 -81
  128. package/src/app/sockets.ts +108 -20
  129. package/src/app/surface.ts +72 -36
  130. package/src/app/threads.ts +31 -2
  131. package/src/app/uploads.ts +33 -7
  132. package/src/auth/authenticator.ts +77 -8
  133. package/src/auth/login-page.ts +4 -2
  134. package/src/auth/next.ts +52 -0
  135. package/src/auth/oidc-pages.ts +62 -0
  136. package/src/auth/oidc.ts +649 -0
  137. package/src/auth/sessions.ts +193 -0
  138. package/src/authz.ts +173 -0
  139. package/src/console-api.ts +122 -25
  140. package/src/console-server.ts +102 -12
  141. package/src/gate.ts +90 -0
  142. package/src/host/access.ts +161 -0
  143. package/src/host/cards.ts +112 -0
  144. package/src/host/context.ts +188 -0
  145. package/src/host/forms.ts +240 -0
  146. package/src/host/frames.ts +349 -0
  147. package/src/host/host-app.ts +178 -0
  148. package/src/host/host-context.ts +49 -0
  149. package/src/host/host-routes.ts +396 -0
  150. package/src/host/pages.ts +266 -0
  151. package/src/host/reads.ts +155 -0
  152. package/src/host/retention.ts +41 -0
  153. package/src/host/room-views.ts +137 -0
  154. package/src/host/rooms-service.ts +429 -0
  155. package/src/host/stored-json.ts +13 -0
  156. package/src/host/views.ts +106 -0
  157. package/src/i18n/zh-tw.ts +139 -0
  158. package/src/index.ts +15 -1
  159. package/src/notices/api.ts +43 -0
  160. package/src/notices/mount.ts +292 -0
  161. package/src/notices/service.ts +294 -0
  162. package/src/notices/store.ts +298 -0
  163. package/src/notices/urls.ts +38 -0
  164. package/src/onboarding/api.ts +32 -0
  165. package/src/onboarding/service.ts +149 -0
  166. package/src/onboarding/steps.ts +75 -0
  167. package/src/onboarding/waivers.ts +40 -0
  168. package/src/options.ts +534 -34
  169. package/src/page-presentation.ts +47 -2
  170. package/src/profile/api.ts +175 -0
  171. package/src/profile/links.ts +162 -0
  172. package/src/push/api.ts +189 -52
  173. package/src/push/dispatch.ts +156 -0
  174. package/src/push/drain.ts +139 -0
  175. package/src/push/outbox.ts +218 -0
  176. package/src/push/prefs.ts +118 -0
  177. package/src/push/sender.ts +7 -2
  178. package/src/push/triggers.ts +56 -5
  179. package/src/push/vapid.ts +19 -0
  180. package/src/push/web-push-transport.ts +106 -0
  181. package/src/push-sender/index.ts +14 -0
  182. package/src/push-sender/run.ts +118 -0
  183. package/src/push-sender/session.ts +54 -0
  184. package/src/pwa.ts +32 -4
  185. package/src/visitors.ts +94 -6
  186. package/src/web-app.ts +718 -0
  187. package/src/web-plugin.ts +279 -26
  188. package/dist/chunk-action-menu-qe839g4b.js +0 -1
  189. package/dist/chunk-arranged-rooms-enpfswmg.js +0 -1
  190. package/dist/chunk-character-creator-6yf29xwx.js +0 -1
  191. package/dist/chunk-codeeditor-gdbdg7rp.js +0 -2
  192. package/dist/chunk-confirm-xrw7d732.js +0 -1
  193. package/dist/chunk-conversations-ry228aes.js +0 -1
  194. package/dist/chunk-folder-list-5cp8stna.js +0 -1
  195. package/dist/chunk-folds-hff104tk.js +0 -1
  196. package/dist/chunk-group-scene-h228jdat.js +0 -1
  197. package/dist/chunk-index-0p5h2kdz.js +0 -2
  198. package/dist/chunk-index-0pt08yd9.js +0 -1
  199. package/dist/chunk-index-12bw9yj8.js +0 -2
  200. package/dist/chunk-index-2ad17eh4.js +0 -2
  201. package/dist/chunk-index-31wh5twj.js +0 -52
  202. package/dist/chunk-index-4dy94pbz.js +0 -2
  203. package/dist/chunk-index-4ecjtdy8.js +0 -13
  204. package/dist/chunk-index-4v1b7ks9.js +0 -2
  205. package/dist/chunk-index-5d8yjj4m.js +0 -2
  206. package/dist/chunk-index-89ajmxh7.js +0 -2
  207. package/dist/chunk-index-b9ny4623.js +0 -2
  208. package/dist/chunk-index-e1dr6g10.js +0 -2
  209. package/dist/chunk-index-gpkzhq1g.js +0 -2
  210. package/dist/chunk-index-hk3xyy20.js +0 -2
  211. package/dist/chunk-index-jm23vn03.js +0 -4
  212. package/dist/chunk-index-k7gh0jvz.js +0 -2
  213. package/dist/chunk-index-ky42ev0b.js +0 -2
  214. package/dist/chunk-index-pa2yyqh0.js +0 -2
  215. package/dist/chunk-index-pcvt668q.js +0 -2
  216. package/dist/chunk-index-pqw27eye.js +0 -1
  217. package/dist/chunk-index-wmbmmrd4.js +0 -302
  218. package/dist/chunk-index-ymbjrsp6.js +0 -2
  219. package/dist/chunk-lang-css-t85r8rz0.js +0 -1
  220. package/dist/chunk-lang-html-n6jparge.js +0 -1
  221. package/dist/chunk-lang-javascript-xj00cp97.js +0 -1
  222. package/dist/chunk-lang-markdown-zxx8eke8.js +0 -6
  223. package/dist/chunk-lang-typescript-8f6yywt3.js +0 -1
  224. package/dist/chunk-layout-actions-bv0chj41.js +0 -1
  225. package/dist/chunk-notes-9b7pgqfe.js +0 -1
  226. package/dist/chunk-overview-8yw71esz.js +0 -1
  227. package/dist/chunk-palette-frms7axe.js +0 -1
  228. package/dist/chunk-placement-items-fvfy9m0y.js +0 -1
  229. package/dist/chunk-prompt-history-yrqehads.js +0 -1
  230. package/dist/chunk-prompts-g6609tqx.js +0 -1
  231. package/dist/chunk-room-confirms-0a2y0925.js +0 -1
  232. package/dist/chunk-room-menu-g11q97br.js +0 -1
  233. package/dist/chunk-rooms-menu-button-h9qge48n.js +0 -1
  234. package/dist/chunk-schedules-ezmacrbj.js +0 -1
  235. package/dist/chunk-sheet-hosts-b0azyfhb.js +0 -1
  236. package/dist/chunk-skill-editor-h6k6whwg.js +0 -8
  237. package/dist/chunk-skills-gqcfhq56.js +0 -1
  238. package/dist/chunk-switcher-list-mcpamqnj.js +0 -1
  239. package/dist/chunk-table-s8cde1xz.js +0 -1
  240. package/dist/chunk-table-seats-wqkjn8bc.js +0 -1
  241. package/dist/chunk-transcript-c59c6mpm.js +0 -1
  242. package/dist/chunk-workspace-0cyspkk4.js +0 -1
  243. package/dist/chunk-zh-tw-nc93jm10.js +0 -1
@@ -0,0 +1,649 @@
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
+ /** How seldom a live session is reported again to `onSessionVerified`. */
31
+ const VERIFIED_EVERY_MS = 300_000;
32
+ /** The most sessions whose last report is remembered before the lapsed ones are dropped. */
33
+ const VERIFIED_PRUNE_AT = 1024;
34
+ /** Answers to a silent request that mean "the person must be asked", not "something is wrong". */
35
+ const INTERACTION_NEEDED = new Set([
36
+ "login_required",
37
+ "interaction_required",
38
+ "account_selection_required",
39
+ "consent_required",
40
+ ]);
41
+
42
+ /** The provider's endpoints, from its discovery document. */
43
+ export interface ProviderMetadata {
44
+ authorizationEndpoint: string;
45
+ tokenEndpoint: string;
46
+ jwksUri: string;
47
+ /** Where logout can also sign out at the provider; absent when it offers none. */
48
+ endSessionEndpoint?: string;
49
+ }
50
+
51
+ /** A URL the provider's discovery document named, already checked; throws if it somehow is none. */
52
+ function urlOf(value: string): URL {
53
+ const url = URL.parse(value);
54
+ if (!url)
55
+ throw new PluginError("pi-roundtable-web: an oidc endpoint is no URL");
56
+ return url;
57
+ }
58
+
59
+ const endpoint = (value: unknown): string | undefined => {
60
+ const url = typeof value === "string" ? URL.parse(value) : null;
61
+ if (!url) return undefined;
62
+ const loopback =
63
+ url.protocol === "http:" &&
64
+ ["127.0.0.1", "[::1]", "localhost"].includes(url.hostname);
65
+ return url.protocol === "https:" || loopback ? url.href : undefined;
66
+ };
67
+
68
+ /**
69
+ * Reads the provider's discovery document and checks it names this issuer. Throws a PluginError
70
+ * naming what is wrong, so a host with a wrong issuer or an unreachable provider stops at start.
71
+ */
72
+ export async function discoverProvider(
73
+ settings: Pick<ResolvedOidc, "issuer" | "discoveryUrl">,
74
+ fetcher: typeof fetch = fetch,
75
+ ): Promise<ProviderMetadata> {
76
+ const fail = (what: string): never => {
77
+ throw new PluginError(
78
+ `pi-roundtable-web: the oidc discovery document at ${settings.discoveryUrl} ${what}`,
79
+ );
80
+ };
81
+ let body: Record<string, unknown>;
82
+ try {
83
+ const response = await fetcher(settings.discoveryUrl, {
84
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
85
+ });
86
+ if (!response.ok) return fail(`answered ${response.status}`);
87
+ body = (await response.json()) as Record<string, unknown>;
88
+ } catch (error) {
89
+ if (error instanceof PluginError) throw error;
90
+ return fail("could not be read");
91
+ }
92
+ if (body.issuer !== settings.issuer)
93
+ fail(
94
+ `names the issuer ${JSON.stringify(body.issuer)}, not ${JSON.stringify(settings.issuer)}`,
95
+ );
96
+ const authorizationEndpoint = endpoint(body.authorization_endpoint);
97
+ const tokenEndpoint = endpoint(body.token_endpoint);
98
+ const jwksUri = endpoint(body.jwks_uri);
99
+ if (!authorizationEndpoint || !tokenEndpoint || !jwksUri)
100
+ fail("lacks an https authorization_endpoint, token_endpoint or jwks_uri");
101
+ const end = endpoint(body.end_session_endpoint);
102
+ return {
103
+ authorizationEndpoint: authorizationEndpoint as string,
104
+ tokenEndpoint: tokenEndpoint as string,
105
+ jwksUri: jwksUri as string,
106
+ ...(end ? { endSessionEndpoint: end } : {}),
107
+ };
108
+ }
109
+
110
+ /** What a sign-in did, for the event and the people-naming hooks; never carries a token or a claim. */
111
+ export interface SignedIn {
112
+ principalId: string;
113
+ /** `<provider>:<subject>`. */
114
+ speakerId: string;
115
+ provider: string;
116
+ name: string;
117
+ }
118
+
119
+ export interface OidcLoginOptions {
120
+ settings: ResolvedOidc;
121
+ metadata: ProviderMetadata;
122
+ /** The client secret, when the client is confidential. */
123
+ clientSecret?: string;
124
+ sessions: WebSessions;
125
+ /** The session key, which signs the pending-login cookie. */
126
+ key: Buffer;
127
+ origin: string;
128
+ mount: string;
129
+ /** The app's surface, such as `app`: the prefix of the roles a sign-in carries. */
130
+ surface: string;
131
+ routing: "hash" | "path";
132
+ title: string;
133
+ presentation?: ConsolePresentation | undefined;
134
+ /** Who the actor is and the tier they hold; the same admission every request passes. */
135
+ identify(actor: ActorFacts): Promise<Identified>;
136
+ /** A sign-in completed: the host's event and name hooks. Never throws into the flow. */
137
+ onSignIn?(info: SignedIn): void | Promise<void>;
138
+ /** A session ended (logout), so the sockets on it can close. */
139
+ onEnded?(sessionId: string): void;
140
+ /** A request or an open socket proved a live session of this person: the host's `onSessionVerified`. */
141
+ onSessionVerified?(principalId: string): void | Promise<void>;
142
+ logger: Logger;
143
+ /** The provider's signing keys; the published ones by default. */
144
+ keys?: JWTVerifyGetKey;
145
+ fetch?: typeof fetch;
146
+ /** The clock, in milliseconds; default `Date.now`. */
147
+ now?: () => number;
148
+ }
149
+
150
+ interface Pending {
151
+ /** state */
152
+ s: string;
153
+ /** nonce */
154
+ n: string;
155
+ /** PKCE verifier */
156
+ v: string;
157
+ /** where to go afterwards */
158
+ x?: string;
159
+ /** the request was silent (`prompt=none`) */
160
+ i: boolean;
161
+ /** expires, ms */
162
+ e: number;
163
+ }
164
+
165
+ /** A request with no usable session, which signing in would fix. */
166
+ const needsSignIn = (why: string): Unauthenticated => ({
167
+ refusal: why,
168
+ status: 401,
169
+ login: true,
170
+ });
171
+
172
+ const sha256 = (value: string | Buffer) =>
173
+ createHash("sha256").update(value).digest();
174
+
175
+ const same = (a: Buffer, b: Buffer) =>
176
+ a.length === b.length && timingSafeEqual(a, b);
177
+
178
+ const cookieValue = (
179
+ header: string | null,
180
+ name: string,
181
+ ): string | undefined => {
182
+ for (const part of (header ?? "").split(";")) {
183
+ const at = part.indexOf("=");
184
+ if (at > 0 && part.slice(0, at).trim() === name)
185
+ return part.slice(at + 1).trim();
186
+ }
187
+ return undefined;
188
+ };
189
+
190
+ /** A claim's text, or undefined when it is not a usable string. */
191
+ const textClaim = (value: unknown, limit: number): string | undefined => {
192
+ const text =
193
+ typeof value === "number" && Number.isFinite(value) ? String(value) : value;
194
+ return typeof text === "string" && text.trim() && text.length <= limit
195
+ ? text.trim()
196
+ : undefined;
197
+ };
198
+
199
+ /**
200
+ * Sign-in with an OpenID Connect provider, and the app's session behind it.
201
+ *
202
+ * `login` sends the person to the provider with a state, a nonce and a PKCE challenge, kept in a
203
+ * signed cookie that lives ten minutes and works once. `auth/callback` exchanges the code, checks
204
+ * the id_token once (signature, issuer, audience, expiry, nonce), asks the host's `check`, and has
205
+ * `IDENTITY` admit the person; only then is a session made, in `web_sessions`, and its random id
206
+ * set in a cookie. Every later request is judged by that session and a fresh read of the tier;
207
+ * the provider is not asked again unless `reauthHours` says so. Nothing here logs a token, a
208
+ * claim, a code or a session id.
209
+ */
210
+ export class OidcLogin {
211
+ readonly #options: OidcLoginOptions;
212
+ readonly #keys: JWTVerifyGetKey;
213
+ /** The names of the cookies: `__Secure-` because the mount is never the root. */
214
+ readonly #sessionCookie = "__Secure-rt_session";
215
+ readonly #pendingCookie = "__Secure-rt_login";
216
+ readonly #outCookie = "__Secure-rt_out";
217
+ /** States already used, until they would have lapsed. */
218
+ readonly #spent = new Map<string, number>();
219
+ /** When each session was last reported to `onSessionVerified`, by session id. */
220
+ readonly #verified = new Map<string, number>();
221
+
222
+ constructor(options: OidcLoginOptions) {
223
+ this.#options = options;
224
+ this.#keys =
225
+ options.keys ??
226
+ createRemoteJWKSet(urlOf(options.metadata.jwksUri), {
227
+ timeoutDuration: PROVIDER_TIMEOUT_MS,
228
+ });
229
+ }
230
+
231
+ #now(): number {
232
+ return (this.#options.now ?? Date.now)();
233
+ }
234
+
235
+ #sign(value: Pending): string {
236
+ const payload = Buffer.from(JSON.stringify(value)).toString("base64url");
237
+ const mac = createHmac("sha256", this.#options.key)
238
+ .update(payload)
239
+ .digest("base64url");
240
+ return `${payload}.${mac}`;
241
+ }
242
+
243
+ /** The pending sign-in the cookie holds, if it is ours, unaltered and not lapsed. */
244
+ #pending(request: Request): Pending | undefined {
245
+ const value = cookieValue(
246
+ request.headers.get("cookie"),
247
+ this.#pendingCookie,
248
+ );
249
+ const [payload, mac, extra] = (value ?? "").split(".");
250
+ if (!payload || !mac || extra !== undefined) return undefined;
251
+ const want = createHmac("sha256", this.#options.key)
252
+ .update(payload)
253
+ .digest();
254
+ if (!same(want, Buffer.from(mac, "base64url"))) return undefined;
255
+ try {
256
+ const pending = JSON.parse(
257
+ Buffer.from(payload, "base64url").toString("utf8"),
258
+ ) as Pending;
259
+ return typeof pending.s === "string" &&
260
+ typeof pending.n === "string" &&
261
+ typeof pending.v === "string" &&
262
+ typeof pending.e === "number" &&
263
+ pending.e > this.#now()
264
+ ? pending
265
+ : undefined;
266
+ } catch {
267
+ return undefined;
268
+ }
269
+ }
270
+
271
+ #cookie(
272
+ name: string,
273
+ value: string,
274
+ path: string,
275
+ maxAge: number,
276
+ lax = true,
277
+ ): string {
278
+ return `${name}=${value}; Path=${path}; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=${lax ? "Lax" : "Strict"}`;
279
+ }
280
+
281
+ /** Cookie lines that clear the pending-login cookie. */
282
+ #clearPending(): string {
283
+ return this.#cookie(
284
+ this.#pendingCookie,
285
+ "",
286
+ `${this.#options.mount}/auth/`,
287
+ 0,
288
+ );
289
+ }
290
+
291
+ #redirect(to: string, cookies: string[]): Response {
292
+ const headers = new Headers({ location: to, "cache-control": "no-store" });
293
+ for (const cookie of cookies) headers.append("set-cookie", cookie);
294
+ return new Response(null, { status: 303, headers });
295
+ }
296
+
297
+ #page(
298
+ notice: "signed-out" | "failed" | "not-allowed",
299
+ status: number,
300
+ cookies: string[] = [],
301
+ ): Response {
302
+ const { mount, title, presentation } = this.#options;
303
+ const response = oidcPage(notice, mount, title, presentation, status);
304
+ for (const cookie of cookies) response.headers.append("set-cookie", cookie);
305
+ return response;
306
+ }
307
+
308
+ /** The paths below the mount this flow answers without a session; undefined for any other. */
309
+ async open(request: Request, rest: string): Promise<Response | undefined> {
310
+ const read = request.method === "GET" || request.method === "HEAD";
311
+ switch (rest) {
312
+ case "login":
313
+ return read
314
+ ? this.#login(request)
315
+ : new Response("Method not allowed", { status: 405 });
316
+ case "auth/callback":
317
+ return read
318
+ ? this.#callback(request)
319
+ : new Response("Method not allowed", { status: 405 });
320
+ case "auth/signed-out":
321
+ return read
322
+ ? this.#page("signed-out", 200)
323
+ : new Response("Method not allowed", { status: 405 });
324
+ case "auth/logout":
325
+ return request.method === "POST"
326
+ ? this.#logout(request)
327
+ : new Response("Method not allowed", { status: 405 });
328
+ default:
329
+ return undefined;
330
+ }
331
+ }
332
+
333
+ // ── Sign-in ──
334
+
335
+ #login(request: Request): Response {
336
+ const next = allowedNext(
337
+ URL.parse(request.url)?.searchParams.get("next"),
338
+ this.#options.surface,
339
+ );
340
+ // A person who just logged out is asked, not signed in again silently by a provider session that is still alive.
341
+ const silent =
342
+ cookieValue(request.headers.get("cookie"), this.#outCookie) === undefined;
343
+ return this.#authorize(next, silent, []);
344
+ }
345
+
346
+ #authorize(
347
+ next: string | undefined,
348
+ silent: boolean,
349
+ cookies: string[],
350
+ ): Response {
351
+ const { settings, metadata, origin, mount } = this.#options;
352
+ const pending: Pending = {
353
+ s: randomBytes(32).toString("base64url"),
354
+ n: randomBytes(32).toString("base64url"),
355
+ v: randomBytes(48).toString("base64url"),
356
+ ...(next ? { x: next } : {}),
357
+ i: silent,
358
+ e: this.#now() + PENDING_MS,
359
+ };
360
+ const url = urlOf(metadata.authorizationEndpoint);
361
+ const params = url.searchParams;
362
+ params.set("response_type", "code");
363
+ params.set("client_id", settings.clientId);
364
+ params.set("redirect_uri", `${origin}${mount}/auth/callback`);
365
+ params.set("scope", settings.scopes.join(" "));
366
+ params.set("state", pending.s);
367
+ params.set("nonce", pending.n);
368
+ params.set("code_challenge", sha256(pending.v).toString("base64url"));
369
+ params.set("code_challenge_method", "S256");
370
+ if (silent) params.set("prompt", "none");
371
+ return this.#redirect(url.href, [
372
+ ...cookies,
373
+ this.#cookie(
374
+ this.#pendingCookie,
375
+ this.#sign(pending),
376
+ `${mount}/auth/`,
377
+ PENDING_MS / 1000,
378
+ ),
379
+ ]);
380
+ }
381
+
382
+ async #callback(request: Request): Promise<Response> {
383
+ const { logger, mount } = this.#options;
384
+ const clear = [this.#clearPending()];
385
+ const params =
386
+ URL.parse(request.url)?.searchParams ?? new URLSearchParams();
387
+ const pending = this.#pending(request);
388
+ const fail = (
389
+ reason: string,
390
+ notice: "failed" | "not-allowed" = "failed",
391
+ ) => {
392
+ logger.warn({ reason }, "web-console: a sign-in did not complete");
393
+ return this.#page(notice, notice === "failed" ? 400 : 403, clear);
394
+ };
395
+ if (!pending) return fail("no pending sign-in");
396
+ const spent = this.#spent;
397
+ for (const [state, until] of spent)
398
+ if (until <= this.#now()) spent.delete(state);
399
+ const state = params.get("state");
400
+ if (!state || !same(Buffer.from(state), Buffer.from(pending.s)))
401
+ return fail("state mismatch");
402
+ if (spent.has(state)) return fail("state used twice");
403
+ spent.set(state, pending.e);
404
+ const error = params.get("error");
405
+ if (error) {
406
+ // A silent request the provider cannot answer alone becomes a normal sign-in, once.
407
+ if (pending.i && INTERACTION_NEEDED.has(error))
408
+ return this.#authorize(pending.x, false, clear);
409
+ return fail("the provider refused the sign-in");
410
+ }
411
+ const code = params.get("code");
412
+ if (!code || code.length > 4096) return fail("no code");
413
+ let claims: Record<string, unknown>;
414
+ try {
415
+ claims = await this.#claims(code, pending);
416
+ } catch (cause) {
417
+ return fail(
418
+ cause instanceof Error ? cause.message : "token not accepted",
419
+ );
420
+ }
421
+ const { settings } = this.#options;
422
+ try {
423
+ if (settings.check && !settings.check(claims))
424
+ return fail("the host's check refused the token", "not-allowed");
425
+ } catch {
426
+ return fail("the host's check failed", "not-allowed");
427
+ }
428
+ const actor = this.#actorOf(claims);
429
+ if (!actor) return fail("the token names no usable subject");
430
+ let identified: Identified;
431
+ try {
432
+ identified = await this.#options.identify(actor);
433
+ } catch {
434
+ return fail("identity failed");
435
+ }
436
+ if ("refusal" in identified)
437
+ return fail("the access rules refuse this person", "not-allowed");
438
+ const { principal } = identified.visitor;
439
+ const kept: Record<string, unknown> = {};
440
+ for (const name of settings.storeClaims) {
441
+ const value = claims[name];
442
+ if (
443
+ value !== undefined &&
444
+ JSON.stringify(value).length <= MAX_CLAIM_CHARS
445
+ )
446
+ kept[name] = value;
447
+ }
448
+ const stored: SessionActor =
449
+ settings.storeClaims.length > 0 ? { ...actor, claims: kept } : actor;
450
+ const made = await this.#options.sessions.create(stored, principal.id);
451
+ // The sign-in replaces whatever session the browser held.
452
+ const old = cookieValue(request.headers.get("cookie"), this.#sessionCookie);
453
+ if (old) await this.#options.sessions.end(old).catch(() => undefined);
454
+ try {
455
+ await this.#options.onSignIn?.({
456
+ principalId: principal.id,
457
+ speakerId: identified.visitor.speakerId,
458
+ provider: actor.provider,
459
+ name: actor.name,
460
+ });
461
+ } catch (cause) {
462
+ logger.warn({ err: cause }, "web-console: a sign-in hook failed");
463
+ }
464
+ const days = this.#options.settings.sessionDays;
465
+ const next = pending.x ? routeOfNext(pending.x) : "";
466
+ const base = `${mount}/`;
467
+ return this.#redirect(
468
+ this.#options.routing === "path"
469
+ ? `${base}${next}`
470
+ : next
471
+ ? `${base}#/${next}`
472
+ : base,
473
+ [
474
+ ...clear,
475
+ this.#cookie(this.#sessionCookie, made.token, base, days * 86400),
476
+ // A later logout leaves its marker; a sign-in clears it.
477
+ this.#cookie(this.#outCookie, "", base, 0),
478
+ ],
479
+ );
480
+ }
481
+
482
+ /** Exchanges the code and checks the id_token; throws a short reason, never a token. */
483
+ async #claims(
484
+ code: string,
485
+ pending: Pending,
486
+ ): Promise<Record<string, unknown>> {
487
+ const { settings, metadata, origin, mount, clientSecret } = this.#options;
488
+ const form = new URLSearchParams({
489
+ grant_type: "authorization_code",
490
+ code,
491
+ redirect_uri: `${origin}${mount}/auth/callback`,
492
+ client_id: settings.clientId,
493
+ code_verifier: pending.v,
494
+ });
495
+ if (clientSecret) form.set("client_secret", clientSecret);
496
+ let idToken: unknown;
497
+ try {
498
+ const response = await (this.#options.fetch ?? fetch)(
499
+ metadata.tokenEndpoint,
500
+ {
501
+ method: "POST",
502
+ headers: { "content-type": "application/x-www-form-urlencoded" },
503
+ body: form,
504
+ signal: AbortSignal.timeout(PROVIDER_TIMEOUT_MS),
505
+ },
506
+ );
507
+ if (!response.ok) throw new Error("the token endpoint refused the code");
508
+ idToken = ((await response.json()) as { id_token?: unknown }).id_token;
509
+ } catch (cause) {
510
+ throw cause instanceof Error &&
511
+ cause.message === "the token endpoint refused the code"
512
+ ? cause
513
+ : new Error("the token endpoint could not be reached");
514
+ }
515
+ if (typeof idToken !== "string" || idToken.length > 16_384)
516
+ throw new Error("the token response has no id_token");
517
+ let payload: Record<string, unknown>;
518
+ try {
519
+ ({ payload } = await jwtVerify(idToken, this.#keys, {
520
+ issuer: settings.issuer,
521
+ audience: settings.clientId,
522
+ algorithms: ["RS256", "ES256"],
523
+ }));
524
+ } catch {
525
+ throw new Error("the id_token was not accepted");
526
+ }
527
+ if (typeof payload.nonce !== "string" || payload.nonce !== pending.n)
528
+ throw new Error("the id_token's nonce does not match");
529
+ return payload;
530
+ }
531
+
532
+ /** The actor facts a verified token stands for, without the surface or `partial`: admission adds those. */
533
+ #actorOf(claims: Record<string, unknown>): SessionActor | undefined {
534
+ const { settings, surface } = this.#options;
535
+ const subject = textClaim(claims[settings.subjectClaim], MAX_SUBJECT_CHARS);
536
+ if (!subject) return undefined;
537
+ const name =
538
+ textClaim(
539
+ settings.nameClaim
540
+ ? claims[settings.nameClaim]
541
+ : (claims.name ?? claims.preferred_username),
542
+ MAX_NAME_CHARS,
543
+ ) ??
544
+ textClaim(claims.preferred_username, MAX_NAME_CHARS) ??
545
+ subject.slice(0, MAX_NAME_CHARS);
546
+ const given =
547
+ settings.rolesClaim === false ? undefined : claims[settings.rolesClaim];
548
+ const names = (
549
+ Array.isArray(given) ? given : given === undefined ? [] : [given]
550
+ )
551
+ .map((role) => textClaim(role, MAX_ROLE_CHARS))
552
+ .filter((role): role is string => role !== undefined)
553
+ .slice(0, MAX_ROLES);
554
+ return {
555
+ provider: oidcProvider(settings.issuer),
556
+ subject,
557
+ name,
558
+ ...(names.length > 0
559
+ ? {
560
+ roles: [...new Set(names)].map((role) => `${surface}:role:${role}`),
561
+ }
562
+ : {}),
563
+ };
564
+ }
565
+
566
+ // ── The session ──
567
+
568
+ /** The credential of a request: the session its cookie names, or why there is none. */
569
+ readonly credential: Credential = async (request) => {
570
+ const token = cookieValue(
571
+ request.headers.get("cookie"),
572
+ this.#sessionCookie,
573
+ );
574
+ if (!token) return needsSignIn("no session");
575
+ const session = await this.#options.sessions.touch(token);
576
+ if (!session) return needsSignIn("session ended");
577
+ const { reauthHours } = this.#options.settings;
578
+ if (
579
+ reauthHours !== undefined &&
580
+ this.#now() - session.verifiedAt.getTime() > reauthHours * 3_600_000
581
+ )
582
+ return needsSignIn("the provider is due to vouch again");
583
+ const { claims: _kept, ...facts } = session.actor;
584
+ return { admitted: true, actor: facts, session: session.id };
585
+ };
586
+
587
+ /**
588
+ * Reports that a request or an open socket proved this session live, so the host may count the
589
+ * person as present. At most once per session every few minutes; never waits for the hook and
590
+ * never throws: a hook that fails is logged.
591
+ */
592
+ verified(session: string, principalId: string): void {
593
+ const hook = this.#options.onSessionVerified;
594
+ if (!hook) return;
595
+ const now = this.#now();
596
+ const last = this.#verified.get(session);
597
+ if (last !== undefined && now - last < VERIFIED_EVERY_MS) return;
598
+ if (this.#verified.size >= VERIFIED_PRUNE_AT)
599
+ for (const [id, at] of this.#verified)
600
+ if (now - at >= VERIFIED_EVERY_MS) this.#verified.delete(id);
601
+ this.#verified.set(session, now);
602
+ const failed = (cause: unknown) =>
603
+ this.#options.logger.warn(
604
+ { err: cause },
605
+ "web-console: the session-verified hook failed",
606
+ );
607
+ try {
608
+ Promise.resolve(hook(principalId)).catch(failed);
609
+ } catch (cause) {
610
+ failed(cause);
611
+ }
612
+ }
613
+
614
+ async #logout(request: Request): Promise<Response> {
615
+ const { mount, origin, settings, metadata } = this.#options;
616
+ if (request.headers.get("origin") !== origin)
617
+ return new Response("Forbidden", {
618
+ status: 403,
619
+ headers: { "cache-control": "no-store" },
620
+ });
621
+ const token = cookieValue(
622
+ request.headers.get("cookie"),
623
+ this.#sessionCookie,
624
+ );
625
+ if (token) {
626
+ const id = await this.#options.sessions.end(token).catch(() => undefined);
627
+ if (id) {
628
+ this.#verified.delete(id);
629
+ this.#options.onEnded?.(id);
630
+ }
631
+ }
632
+ const base = `${mount}/`;
633
+ let to = `${mount}/auth/signed-out`;
634
+ if (settings.endSession && metadata.endSessionEndpoint) {
635
+ const url = urlOf(metadata.endSessionEndpoint);
636
+ url.searchParams.set("client_id", settings.clientId);
637
+ url.searchParams.set(
638
+ "post_logout_redirect_uri",
639
+ `${origin}${mount}/auth/signed-out`,
640
+ );
641
+ to = url.href;
642
+ }
643
+ return this.#redirect(to, [
644
+ this.#cookie(this.#sessionCookie, "", base, 0),
645
+ // Marks the next sign-in as a deliberate one: no silent round trip until it completes.
646
+ this.#cookie(this.#outCookie, "1", base, 86400),
647
+ ]);
648
+ }
649
+ }