@valentinkolb/cloud 0.3.1 → 0.5.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 (194) hide show
  1. package/package.json +18 -8
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +119 -47
  4. package/src/_internal/runtime-context.ts +1 -0
  5. package/src/api/accounts-entities.ts +4 -0
  6. package/src/api/admin-core-settings.ts +98 -0
  7. package/src/api/announcements.ts +131 -0
  8. package/src/api/auth/schemas.ts +24 -0
  9. package/src/api/auth.ts +113 -10
  10. package/src/api/index.ts +15 -25
  11. package/src/api/me.ts +203 -14
  12. package/src/api/search/schemas.ts +1 -0
  13. package/src/api/search.ts +62 -8
  14. package/src/config/ssr.ts +2 -9
  15. package/src/contracts/announcements.test.ts +37 -0
  16. package/src/contracts/announcements.ts +121 -0
  17. package/src/contracts/app.ts +4 -0
  18. package/src/contracts/index.ts +3 -2
  19. package/src/contracts/registry.ts +4 -0
  20. package/src/contracts/shared.ts +108 -1
  21. package/src/desktop/index.ts +704 -0
  22. package/src/desktop/solid.tsx +938 -0
  23. package/src/server/api/index.ts +1 -1
  24. package/src/server/api/respond.ts +50 -10
  25. package/src/server/index.ts +44 -38
  26. package/src/server/middleware/auth.ts +98 -9
  27. package/src/server/middleware/index.ts +2 -1
  28. package/src/server/middleware/settings.ts +26 -0
  29. package/src/server/services/access.test.ts +197 -0
  30. package/src/server/services/access.ts +254 -6
  31. package/src/server/services/index.ts +14 -11
  32. package/src/server/services/pagination.ts +22 -0
  33. package/src/server/time.ts +45 -0
  34. package/src/services/account-lifecycle/index.ts +142 -18
  35. package/src/services/accounts/app.ts +658 -170
  36. package/src/services/accounts/authz.test.ts +77 -0
  37. package/src/services/accounts/authz.ts +22 -0
  38. package/src/services/accounts/entities.ts +84 -5
  39. package/src/services/accounts/groups.ts +30 -24
  40. package/src/services/accounts/model.test.ts +30 -0
  41. package/src/services/accounts/switching.test.ts +14 -0
  42. package/src/services/accounts/switching.ts +15 -6
  43. package/src/services/accounts/users.ts +75 -52
  44. package/src/services/announcements/index.test.ts +32 -0
  45. package/src/services/announcements/index.ts +224 -0
  46. package/src/services/audit/index.test.ts +84 -0
  47. package/src/services/audit/index.ts +431 -0
  48. package/src/services/auth-flows/index.ts +9 -2
  49. package/src/services/auth-flows/ipa.ts +0 -2
  50. package/src/services/auth-flows/magic-link.ts +3 -2
  51. package/src/services/auth-flows/password-reset.ts +284 -0
  52. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  53. package/src/services/auth-flows/proxy-return.ts +49 -0
  54. package/src/services/gateway.ts +162 -0
  55. package/src/services/index.ts +44 -2
  56. package/src/services/ipa/effective-groups.test.ts +33 -0
  57. package/src/services/ipa/effective-groups.ts +70 -0
  58. package/src/services/ipa/profile.ts +45 -3
  59. package/src/services/ipa/search.ts +3 -5
  60. package/src/services/ipa/service-account.ts +15 -0
  61. package/src/services/ipa/sync-planning.test.ts +32 -0
  62. package/src/services/ipa/sync-planning.ts +22 -0
  63. package/src/services/ipa/sync.ts +110 -38
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +64 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +49 -0
  92. package/src/shared/redirect.ts +52 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,384 @@
1
+ import { redis, sql } from "bun";
2
+ import {
3
+ generateAuthenticationOptions,
4
+ generateRegistrationOptions,
5
+ verifyAuthenticationResponse,
6
+ verifyRegistrationResponse,
7
+ type AuthenticationResponseJSON,
8
+ type AuthenticatorTransportFuture,
9
+ type PublicKeyCredentialCreationOptionsJSON,
10
+ type PublicKeyCredentialRequestOptionsJSON,
11
+ type RegistrationResponseJSON,
12
+ type WebAuthnCredential,
13
+ } from "@simplewebauthn/server";
14
+ import { err, fail, ok, type Result } from "@valentinkolb/stdlib";
15
+ import type { User, WebAuthnPasskey } from "../contracts/shared";
16
+ import { audit } from "./audit";
17
+ import { accounts } from "./accounts";
18
+ import { logger } from "./logging";
19
+ import { coreSettings } from "./settings/api";
20
+ import { isUniqueViolation, toPgTextArray } from "./postgres";
21
+
22
+ const CHALLENGE_TTL_SECONDS = 300;
23
+ const REGISTRATION_CHALLENGE_PREFIX = "webauthn:registration:";
24
+ const AUTHENTICATION_CHALLENGE_PREFIX = "webauthn:authentication:";
25
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{12}$/i;
26
+ const log = logger("auth:webauthn");
27
+
28
+ type DbPasskeyRow = {
29
+ id: string;
30
+ user_id: string;
31
+ name: string;
32
+ credential_id: string;
33
+ public_key: Uint8Array;
34
+ counter: string | number | bigint;
35
+ transports: string[];
36
+ device_type: string | null;
37
+ backed_up: boolean;
38
+ created_at: Date;
39
+ last_used_at: Date | null;
40
+ };
41
+
42
+ type DbPasskeyWithUserRow = DbPasskeyRow & {
43
+ user_account_expires: Date | null;
44
+ };
45
+
46
+ type StoredWebAuthnPasskey = WebAuthnPasskey & {
47
+ credentialId: string;
48
+ };
49
+
50
+ export type WebAuthnRp = {
51
+ rpName: string;
52
+ rpID: string;
53
+ origin: string;
54
+ };
55
+
56
+ export const resolveWebAuthnRp = (config: { appUrl: string; appName: string }): WebAuthnRp => {
57
+ const rawAppUrl = config.appUrl.trim();
58
+ const hasProtocol = /^https?:\/\//i.test(rawAppUrl);
59
+ const parseUrl = (value: string) => new URL(value);
60
+ const hostname = hasProtocol ? parseUrl(rawAppUrl).hostname : parseUrl(`https://${rawAppUrl}`).hostname;
61
+ const isBareLocalhost = !hasProtocol && (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]");
62
+ const withProtocol = hasProtocol ? rawAppUrl : `${isBareLocalhost ? "http" : "https"}://${rawAppUrl}`;
63
+ const url = new URL(withProtocol);
64
+ const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1" || url.hostname === "[::1]";
65
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && isLocalhost)) {
66
+ throw new Error("WebAuthn requires an HTTPS app.url, except for localhost development.");
67
+ }
68
+ return {
69
+ rpName: config.appName.trim() || "Cloud",
70
+ rpID: url.hostname,
71
+ origin: url.origin,
72
+ };
73
+ };
74
+
75
+ const loadRp = async (): Promise<WebAuthnRp> =>
76
+ resolveWebAuthnRp({
77
+ appUrl: await coreSettings.get<string>("app.url"),
78
+ appName: await coreSettings.get<string>("app.name"),
79
+ });
80
+
81
+ const loadRpResult = async (): Promise<Result<WebAuthnRp>> => {
82
+ try {
83
+ return ok(await loadRp());
84
+ } catch (error) {
85
+ return fail(err.badInput(error instanceof Error ? error.message : "Invalid WebAuthn configuration."));
86
+ }
87
+ };
88
+
89
+ const mapStoredPasskey = (row: DbPasskeyRow): StoredWebAuthnPasskey => ({
90
+ id: row.id,
91
+ userId: row.user_id,
92
+ name: row.name,
93
+ credentialId: row.credential_id,
94
+ transports: row.transports ?? [],
95
+ deviceType: row.device_type,
96
+ backedUp: row.backed_up,
97
+ createdAt: row.created_at.toISOString(),
98
+ lastUsedAt: row.last_used_at?.toISOString() ?? null,
99
+ });
100
+
101
+ const mapPasskey = (row: DbPasskeyRow): WebAuthnPasskey => {
102
+ const { credentialId: _, ...passkey } = mapStoredPasskey(row);
103
+ return passkey;
104
+ };
105
+
106
+ const actorForUser = (user: Pick<User, "id" | "uid" | "provider" | "roles">) => ({
107
+ userId: user.id,
108
+ uid: user.uid,
109
+ provider: user.provider,
110
+ roles: user.roles,
111
+ });
112
+
113
+ const challengeKey = (prefix: string, id: string) => `${prefix}${id}`;
114
+
115
+ const storeRegistrationChallenge = async (userId: string, challenge: string): Promise<void> => {
116
+ await redis.set(challengeKey(REGISTRATION_CHALLENGE_PREFIX, userId), challenge, "EX", CHALLENGE_TTL_SECONDS);
117
+ };
118
+
119
+ const consumeRegistrationChallenge = async (userId: string): Promise<string | null> => {
120
+ return redis.getdel(challengeKey(REGISTRATION_CHALLENGE_PREFIX, userId));
121
+ };
122
+
123
+ const storeAuthenticationChallenge = async (challenge: string): Promise<void> => {
124
+ await redis.set(challengeKey(AUTHENTICATION_CHALLENGE_PREFIX, challenge), "1", "EX", CHALLENGE_TTL_SECONDS);
125
+ };
126
+
127
+ const consumeAuthenticationChallenge = async (challenge: string): Promise<boolean> => {
128
+ const value = await redis.getdel(challengeKey(AUTHENTICATION_CHALLENGE_PREFIX, challenge));
129
+ return value === "1";
130
+ };
131
+
132
+ const userIdToWebAuthnBytes = (userId: string): WebAuthnCredential["publicKey"] =>
133
+ new TextEncoder().encode(userId) as WebAuthnCredential["publicKey"];
134
+
135
+ const toCredential = (row: DbPasskeyRow): WebAuthnCredential => ({
136
+ id: row.credential_id,
137
+ publicKey: new Uint8Array(row.public_key) as WebAuthnCredential["publicKey"],
138
+ counter: Number(row.counter),
139
+ transports: (row.transports ?? []) as AuthenticatorTransportFuture[],
140
+ });
141
+
142
+ const isExpired = (date: Date | null): boolean => Boolean(date && date.getTime() <= Date.now());
143
+
144
+ const listStoredForUser = async (params: { userId: string }): Promise<StoredWebAuthnPasskey[]> => {
145
+ const rows = await sql<DbPasskeyRow[]>`
146
+ SELECT id, user_id, name, credential_id, public_key, counter, transports, device_type,
147
+ backed_up, created_at, last_used_at
148
+ FROM auth.webauthn_credentials
149
+ WHERE user_id = ${params.userId}::uuid
150
+ ORDER BY created_at DESC
151
+ `;
152
+ return rows.map(mapStoredPasskey);
153
+ };
154
+
155
+ export const listForUser = async (params: { userId: string }): Promise<WebAuthnPasskey[]> => {
156
+ const rows = await listStoredForUser(params);
157
+ return rows.map(({ credentialId: _, ...passkey }) => passkey);
158
+ };
159
+
160
+ export const beginRegistration = async (params: { user: User }): Promise<Result<PublicKeyCredentialCreationOptionsJSON>> => {
161
+ const rpResult = await loadRpResult();
162
+ if (!rpResult.ok) return rpResult;
163
+ const rp = rpResult.data;
164
+ const existing = await listStoredForUser({ userId: params.user.id });
165
+ const options = await generateRegistrationOptions({
166
+ rpName: rp.rpName,
167
+ rpID: rp.rpID,
168
+ userID: userIdToWebAuthnBytes(params.user.id),
169
+ userName: params.user.mail ?? params.user.uid,
170
+ userDisplayName: params.user.displayName || params.user.uid,
171
+ attestationType: "none",
172
+ authenticatorSelection: {
173
+ residentKey: "required",
174
+ userVerification: "required",
175
+ },
176
+ excludeCredentials: existing.map((credential) => ({
177
+ id: credential.credentialId,
178
+ transports: credential.transports as AuthenticatorTransportFuture[],
179
+ })),
180
+ });
181
+
182
+ await storeRegistrationChallenge(params.user.id, options.challenge);
183
+ return ok(options);
184
+ };
185
+
186
+ export const finishRegistration = async (params: {
187
+ user: User;
188
+ name: string;
189
+ response: RegistrationResponseJSON;
190
+ }): Promise<Result<WebAuthnPasskey>> => {
191
+ const name = params.name.trim();
192
+ if (!name) return fail(err.badInput("Passkey name is required."));
193
+ if (name.length > 120) return fail(err.badInput("Passkey name must be 120 characters or fewer."));
194
+
195
+ const expectedChallenge = await consumeRegistrationChallenge(params.user.id);
196
+ if (!expectedChallenge) return fail(err.badInput("Passkey registration expired. Please try again."));
197
+
198
+ const rpResult = await loadRpResult();
199
+ if (!rpResult.ok) return rpResult;
200
+ const rp = rpResult.data;
201
+ const verification = await verifyRegistrationResponse({
202
+ response: params.response,
203
+ expectedChallenge,
204
+ expectedOrigin: rp.origin,
205
+ expectedRPID: rp.rpID,
206
+ requireUserVerification: true,
207
+ }).catch((error) => {
208
+ log.warn("Passkey registration verification failed", {
209
+ userId: params.user.id,
210
+ rpID: rp.rpID,
211
+ origin: rp.origin,
212
+ error: error instanceof Error ? error.message : String(error),
213
+ });
214
+ return null;
215
+ });
216
+ if (!verification) return fail(err.badInput("Passkey registration could not be verified."));
217
+ if (!verification.verified) return fail(err.badInput("Passkey registration could not be verified."));
218
+
219
+ const { credential, credentialDeviceType, credentialBackedUp } = verification.registrationInfo;
220
+ const transports = params.response.response.transports ?? [];
221
+
222
+ try {
223
+ return sql.begin(async (tx) => {
224
+ const [row] = await tx<DbPasskeyRow[]>`
225
+ INSERT INTO auth.webauthn_credentials (
226
+ user_id,
227
+ name,
228
+ credential_id,
229
+ public_key,
230
+ counter,
231
+ transports,
232
+ device_type,
233
+ backed_up
234
+ )
235
+ VALUES (
236
+ ${params.user.id}::uuid,
237
+ ${name},
238
+ ${credential.id},
239
+ ${Buffer.from(credential.publicKey)},
240
+ ${credential.counter},
241
+ ${toPgTextArray(transports)}::text[],
242
+ ${credentialDeviceType},
243
+ ${credentialBackedUp}
244
+ )
245
+ RETURNING id, user_id, name, credential_id, public_key, counter, transports, device_type,
246
+ backed_up, created_at, last_used_at
247
+ `;
248
+ const result = row ? ok(mapPasskey(row)) : fail(err.badInput("Passkey could not be saved."));
249
+ return audit.recordResult({
250
+ action: "webauthn_credential.create",
251
+ actor: actorForUser(params.user),
252
+ target: { type: "webauthn_credential", id: row?.id ?? null, label: name },
253
+ metadata: {
254
+ deviceType: credentialDeviceType,
255
+ backedUp: credentialBackedUp,
256
+ transports,
257
+ },
258
+ result,
259
+ db: tx,
260
+ });
261
+ });
262
+ } catch (error) {
263
+ if (isUniqueViolation(error)) return fail(err.conflict("Passkey"));
264
+ throw error;
265
+ }
266
+ };
267
+
268
+ export const beginAuthentication = async (): Promise<Result<PublicKeyCredentialRequestOptionsJSON>> => {
269
+ const rpResult = await loadRpResult();
270
+ if (!rpResult.ok) return rpResult;
271
+ const rp = rpResult.data;
272
+ const options = await generateAuthenticationOptions({
273
+ rpID: rp.rpID,
274
+ userVerification: "required",
275
+ });
276
+ await storeAuthenticationChallenge(options.challenge);
277
+ return ok(options);
278
+ };
279
+
280
+ const findCredentialForAuthentication = async (credentialId: string): Promise<DbPasskeyWithUserRow | null> => {
281
+ const [row] = await sql<DbPasskeyWithUserRow[]>`
282
+ SELECT c.id, c.user_id, c.name, c.credential_id, c.public_key, c.counter, c.transports,
283
+ c.device_type, c.backed_up, c.created_at, c.last_used_at, u.account_expires AS user_account_expires
284
+ FROM auth.webauthn_credentials c
285
+ JOIN auth.users u ON u.id = c.user_id
286
+ WHERE c.credential_id = ${credentialId}
287
+ LIMIT 1
288
+ `;
289
+ return row ?? null;
290
+ };
291
+
292
+ export const finishAuthentication = async (params: {
293
+ response: AuthenticationResponseJSON;
294
+ }): Promise<Result<{ user: User; passkey: WebAuthnPasskey }>> => {
295
+ const row = await findCredentialForAuthentication(params.response.id);
296
+ if (!row) return fail(err.unauthenticated("Passkey could not be verified."));
297
+ if (isExpired(row.user_account_expires)) return fail(err.forbidden("Your account has expired. Contact an administrator."));
298
+
299
+ const rpResult = await loadRpResult();
300
+ if (!rpResult.ok) return rpResult;
301
+ const rp = rpResult.data;
302
+ const verification = await verifyAuthenticationResponse({
303
+ response: params.response,
304
+ expectedChallenge: consumeAuthenticationChallenge,
305
+ expectedOrigin: rp.origin,
306
+ expectedRPID: rp.rpID,
307
+ credential: toCredential(row),
308
+ requireUserVerification: true,
309
+ }).catch((error) => {
310
+ log.warn("Passkey authentication verification failed", {
311
+ credentialId: row.id,
312
+ userId: row.user_id,
313
+ rpID: rp.rpID,
314
+ origin: rp.origin,
315
+ error: error instanceof Error ? error.message : String(error),
316
+ });
317
+ return null;
318
+ });
319
+ if (!verification) return fail(err.unauthenticated("Passkey could not be verified."));
320
+ if (!verification.verified) return fail(err.unauthenticated("Passkey could not be verified."));
321
+
322
+ const user = await accounts.users.get({ id: row.user_id });
323
+ if (!user) return fail(err.unauthenticated("Passkey user not found."));
324
+ if (user.accountExpires && new Date(user.accountExpires).getTime() <= Date.now()) {
325
+ return fail(err.forbidden("Your account has expired. Contact an administrator."));
326
+ }
327
+
328
+ return sql.begin(async (tx) => {
329
+ const [updated] = await tx<DbPasskeyRow[]>`
330
+ UPDATE auth.webauthn_credentials
331
+ SET counter = ${verification.authenticationInfo.newCounter},
332
+ device_type = ${verification.authenticationInfo.credentialDeviceType},
333
+ backed_up = ${verification.authenticationInfo.credentialBackedUp},
334
+ last_used_at = now()
335
+ WHERE id = ${row.id}::uuid
336
+ RETURNING id, user_id, name, credential_id, public_key, counter, transports, device_type,
337
+ backed_up, created_at, last_used_at
338
+ `;
339
+ const passkey = mapPasskey(updated ?? row);
340
+ const result = ok({ user, passkey });
341
+ return audit.recordResult({
342
+ action: "webauthn_credential.authenticate",
343
+ actor: actorForUser(user),
344
+ target: { type: "webauthn_credential", id: row.id, label: row.name },
345
+ metadata: {
346
+ deviceType: verification.authenticationInfo.credentialDeviceType,
347
+ backedUp: verification.authenticationInfo.credentialBackedUp,
348
+ },
349
+ result,
350
+ db: tx,
351
+ });
352
+ });
353
+ };
354
+
355
+ export const deleteForUser = async (params: { user: User; id: string }): Promise<Result<void>> => {
356
+ if (!UUID_PATTERN.test(params.id)) return fail(err.notFound("Passkey"));
357
+
358
+ return sql.begin(async (tx) => {
359
+ const [row] = await tx<Pick<DbPasskeyRow, "id" | "name">[]>`
360
+ DELETE FROM auth.webauthn_credentials
361
+ WHERE id = ${params.id}::uuid
362
+ AND user_id = ${params.user.id}::uuid
363
+ RETURNING id, name
364
+ `;
365
+ const result = row ? ok() : fail(err.notFound("Passkey"));
366
+ return audit.recordResult({
367
+ action: "webauthn_credential.delete",
368
+ actor: actorForUser(params.user),
369
+ target: { type: "webauthn_credential", id: params.id, label: row?.name ?? null },
370
+ result,
371
+ db: tx,
372
+ });
373
+ });
374
+ };
375
+
376
+ export const webauthn = {
377
+ resolveWebAuthnRp,
378
+ listForUser,
379
+ beginRegistration,
380
+ finishRegistration,
381
+ beginAuthentication,
382
+ finishAuthentication,
383
+ deleteForUser,
384
+ };