@valentinkolb/cloud 0.4.0 → 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.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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
|
+
};
|