@pablozaiden/webapp 0.1.0 → 0.2.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.
@@ -7,12 +7,13 @@ import {
7
7
  type CryptoKey,
8
8
  type JWK,
9
9
  } from "jose";
10
- import type { AuthSessionSummary, DeviceAuthorizationResponse, DeviceVerificationDetails, TokenResponse } from "../../contracts";
10
+ import type { AuthSessionSummary, CurrentUser, DeviceAuthorizationResponse, DeviceVerificationDetails, TokenResponse } from "../../contracts";
11
11
  import type { RuntimeConfig } from "../runtime-config";
12
12
  import type { RefreshSessionRecord, WebAppStore } from "./store";
13
13
  import { addSeconds, isExpired, nowIso, randomToken, sha256 } from "./crypto";
14
14
  import { getRequestOriginInfo } from "./request-origin";
15
15
  import { AuthError, type AccessTokenClaims } from "./types";
16
+ import { toCurrentUser } from "./users";
16
17
 
17
18
  const ACCESS_TOKEN_TTL_SECONDS = 60 * 10;
18
19
  const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
@@ -87,6 +88,7 @@ function issuer(config: RuntimeConfig): string {
87
88
 
88
89
  async function issueAccessToken(store: WebAppStore, config: RuntimeConfig, input: {
89
90
  sessionId: string;
91
+ user: CurrentUser;
90
92
  clientId: string;
91
93
  scope: string;
92
94
  }): Promise<{ accessToken: string; jti: string }> {
@@ -96,9 +98,11 @@ async function issueAccessToken(store: WebAppStore, config: RuntimeConfig, input
96
98
  sid: input.sessionId,
97
99
  clientId: input.clientId,
98
100
  scope: input.scope,
101
+ username: input.user.username,
102
+ role: input.user.role,
99
103
  })
100
104
  .setProtectedHeader({ alg: key.alg, kid: key.kid })
101
- .setSubject(`${config.envPrefix.toLowerCase()}-user`)
105
+ .setSubject(input.user.id)
102
106
  .setJti(jti)
103
107
  .setIssuer(issuer(config))
104
108
  .setAudience(`${config.envPrefix.toLowerCase()}-api`)
@@ -161,7 +165,7 @@ export function getDeviceVerificationDetails(store: WebAppStore, userCode: strin
161
165
  };
162
166
  }
163
167
 
164
- export function approveDevice(store: WebAppStore, userCode: string): DeviceVerificationDetails {
168
+ export function approveDevice(store: WebAppStore, userCode: string, userId: string): DeviceVerificationDetails {
165
169
  const record = store.getDeviceAuthByUserCode(userCode);
166
170
  if (!record || isExpired(record.expiresAt)) {
167
171
  throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
@@ -169,7 +173,7 @@ export function approveDevice(store: WebAppStore, userCode: string): DeviceVerif
169
173
  if (record.status !== "pending" && record.status !== "approved") {
170
174
  throw new AuthError("invalid_request", "Device authorization can no longer be approved", 400);
171
175
  }
172
- store.updateDeviceAuthStatus(userCode, "approved", nowIso());
176
+ store.updateDeviceAuthStatus(userCode, "approved", nowIso(), userId);
173
177
  return getDeviceVerificationDetails(store, userCode, false);
174
178
  }
175
179
 
@@ -182,7 +186,7 @@ export function denyDevice(store: WebAppStore, userCode: string): DeviceVerifica
182
186
  return getDeviceVerificationDetails(store, userCode, false);
183
187
  }
184
188
 
185
- function createRefreshRecord(clientId: string, scope: string, familyId: string = crypto.randomUUID()): { token: string; record: RefreshSessionRecord } {
189
+ function createRefreshRecord(userId: string, clientId: string, scope: string, familyId: string = crypto.randomUUID()): { token: string; record: RefreshSessionRecord } {
186
190
  const token = randomToken(32);
187
191
  const createdAt = nowIso();
188
192
  const id = crypto.randomUUID();
@@ -190,6 +194,7 @@ function createRefreshRecord(clientId: string, scope: string, familyId: string =
190
194
  token,
191
195
  record: {
192
196
  id,
197
+ userId,
193
198
  familyId,
194
199
  clientId,
195
200
  scope,
@@ -221,10 +226,19 @@ export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConf
221
226
  if (record.status === "consumed") {
222
227
  throw new AuthError("invalid_grant", "Device code has already been used", 400);
223
228
  }
224
- const refresh = createRefreshRecord(record.clientId, record.scope);
229
+ if (!record.approvedByUserId) {
230
+ throw new AuthError("invalid_grant", "Device authorization was not approved by a user", 400);
231
+ }
232
+ const userRecord = store.getUserById(record.approvedByUserId);
233
+ if (!userRecord) {
234
+ throw new AuthError("invalid_grant", "Approving user no longer exists", 400);
235
+ }
236
+ const user = toCurrentUser(userRecord);
237
+ const refresh = createRefreshRecord(user.id, record.clientId, record.scope);
225
238
  store.saveRefreshSession(refresh.record);
226
239
  const access = await issueAccessToken(store, config, {
227
240
  sessionId: refresh.record.id,
241
+ user,
228
242
  clientId: record.clientId,
229
243
  scope: record.scope,
230
244
  });
@@ -248,13 +262,20 @@ export async function exchangeRefreshToken(store: WebAppStore, config: RuntimeCo
248
262
  if (clientId && clientId !== session.clientId) {
249
263
  throw new AuthError("invalid_client", "client_id does not match refresh session", 400);
250
264
  }
251
- const next = createRefreshRecord(session.clientId, session.scope, session.familyId);
265
+ const userRecord = store.getUserById(session.userId);
266
+ if (!userRecord) {
267
+ store.revokeRefreshFamily(session.familyId, nowIso());
268
+ throw new AuthError("invalid_grant", "Refresh token user no longer exists", 400);
269
+ }
270
+ const user = toCurrentUser(userRecord);
271
+ const next = createRefreshRecord(session.userId, session.clientId, session.scope, session.familyId);
252
272
  const rotated = store.rotateRefreshSession(hash, next.record, nowIso());
253
273
  if (!rotated) {
254
274
  throw new AuthError("invalid_grant", "Refresh token is invalid", 400);
255
275
  }
256
276
  const access = await issueAccessToken(store, config, {
257
277
  sessionId: next.record.id,
278
+ user,
258
279
  clientId: next.record.clientId,
259
280
  scope: next.record.scope,
260
281
  });
@@ -270,6 +291,8 @@ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfi
270
291
  const payload = result.payload;
271
292
  return {
272
293
  sub: payload.sub ?? "",
294
+ username: typeof payload["username"] === "string" ? payload["username"] : undefined,
295
+ role: typeof payload["role"] === "string" ? payload["role"] : undefined,
273
296
  jti: payload.jti ?? "",
274
297
  sid: String(payload["sid"] ?? ""),
275
298
  clientId: String(payload["clientId"] ?? ""),
@@ -277,8 +300,8 @@ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfi
277
300
  };
278
301
  }
279
302
 
280
- export function listAuthSessions(store: WebAppStore): AuthSessionSummary[] {
281
- return store.listRefreshSessions().map((session) => ({
303
+ export function listAuthSessions(store: WebAppStore, userId: string): AuthSessionSummary[] {
304
+ return store.listRefreshSessions(userId).map((session) => ({
282
305
  id: session.id,
283
306
  clientId: session.clientId,
284
307
  scope: session.scope,
@@ -291,8 +314,8 @@ export function listAuthSessions(store: WebAppStore): AuthSessionSummary[] {
291
314
  }));
292
315
  }
293
316
 
294
- export function revokeAuthSession(store: WebAppStore, id: string): boolean {
295
- return store.revokeRefreshSession(id, nowIso());
317
+ export function revokeAuthSession(store: WebAppStore, userId: string, id: string): boolean {
318
+ return store.revokeRefreshSession(id, nowIso(), userId);
296
319
  }
297
320
 
298
321
  export function revokeRefreshToken(store: WebAppStore, refreshToken: string): boolean {
@@ -6,19 +6,20 @@ import {
6
6
  type AuthenticationResponseJSON,
7
7
  type RegistrationResponseJSON,
8
8
  } from "@simplewebauthn/server";
9
+ import { isIP } from "node:net";
9
10
  import type { PasskeyAuthStatusResponse } from "../../contracts";
10
11
  import type { RuntimeConfig } from "../runtime-config";
11
- import type { WebAppStore } from "./store";
12
- import { hmacSha256, nowIso, randomToken, secureEqual } from "./crypto";
12
+ import type { UserRecord, WebAppStore } from "./store";
13
+ import { hmacSha256, isExpired, nowIso, randomToken, secureEqual, sha256 } from "./crypto";
13
14
  import { getCookiePath, getRequestOriginInfo } from "./request-origin";
14
15
  import { AuthError } from "./types";
16
+ import { assertValidUsername, audit, createUserRecord, toCurrentUser } from "./users";
15
17
 
16
18
  const SESSION_COOKIE = "webapp_passkey_session";
17
19
  const CHALLENGE_COOKIE = "webapp_passkey_challenge";
18
20
  const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
19
21
  const CHALLENGE_TTL_SECONDS = 60 * 10;
20
22
  const SECRET_KEY = "passkey.secret";
21
- const VERSION_KEY = "passkey.version";
22
23
 
23
24
  interface SignedCookiePayload {
24
25
  expiresAt: number;
@@ -26,12 +27,16 @@ interface SignedCookiePayload {
26
27
 
27
28
  interface ChallengePayload extends SignedCookiePayload {
28
29
  challenge: string;
29
- type: "registration" | "authentication";
30
+ type: "bootstrap" | "owner-reset" | "setup" | "authentication";
31
+ userId?: string;
32
+ username?: string;
33
+ setupTokenHash?: string;
30
34
  }
31
35
 
32
36
  interface SessionPayload extends SignedCookiePayload {
33
37
  nonce: string;
34
- version: number;
38
+ userId: string;
39
+ authVersion: number;
35
40
  }
36
41
 
37
42
  function getSecret(store: WebAppStore): string {
@@ -44,23 +49,6 @@ function getSecret(store: WebAppStore): string {
44
49
  return secret;
45
50
  }
46
51
 
47
- function getVersion(store: WebAppStore): number {
48
- const raw = store.getPreference(VERSION_KEY);
49
- const version = raw ? Number(raw) : 1;
50
- if (!Number.isInteger(version) || version <= 0) {
51
- store.setPreference(VERSION_KEY, "1");
52
- return 1;
53
- }
54
- if (!raw) {
55
- store.setPreference(VERSION_KEY, "1");
56
- }
57
- return version;
58
- }
59
-
60
- function bumpVersion(store: WebAppStore): void {
61
- store.setPreference(VERSION_KEY, String(getVersion(store) + 1));
62
- }
63
-
64
52
  function encodeSigned(payload: object, secret: string): string {
65
53
  const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
66
54
  return `${body}.${hmacSha256(body, secret)}`;
@@ -110,12 +98,13 @@ function expiredCookie(req: Request, name: string, secure: boolean): string {
110
98
  return cookieHeader(req, name, "", 0, secure);
111
99
  }
112
100
 
113
- function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConfig): Headers {
101
+ function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, user: UserRecord): Headers {
114
102
  const origin = getRequestOriginInfo(req, config.publicBaseUrl);
115
103
  const secret = getSecret(store);
116
104
  const payload: SessionPayload = {
117
105
  nonce: randomToken(16),
118
- version: getVersion(store),
106
+ userId: user.id,
107
+ authVersion: user.authVersion,
119
108
  expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000,
120
109
  };
121
110
  const headers = new Headers();
@@ -144,69 +133,53 @@ function readChallenge(req: Request, store: WebAppStore, type: ChallengePayload[
144
133
  return challenge;
145
134
  }
146
135
 
147
- export function hasPasskeySession(req: Request, store: WebAppStore): boolean {
148
- const secret = store.getPreference(SECRET_KEY);
149
- if (!secret) {
150
- return false;
151
- }
152
- const session = decodeSigned<SessionPayload>(getCookie(req, SESSION_COOKIE), secret);
153
- return Boolean(session && session.expiresAt > Date.now() && session.version === getVersion(store));
136
+ function registrationUserId(userId: string): Uint8Array<ArrayBuffer> {
137
+ return new Uint8Array(Buffer.from(userId)) as Uint8Array<ArrayBuffer>;
154
138
  }
155
139
 
156
- export function isPasskeyAuthRequired(store: WebAppStore, config: RuntimeConfig): boolean {
157
- return !config.passkeyDisabled && store.listPasskeys().length > 0;
158
- }
159
-
160
- export function passkeyStatus(req: Request, store: WebAppStore, config: RuntimeConfig, enabled = true): PasskeyAuthStatusResponse {
161
- const passkeyConfigured = store.listPasskeys().length > 0;
162
- return {
163
- enabled,
164
- passkeyConfigured,
165
- passkeyDisabled: config.passkeyDisabled,
166
- passkeyRequired: enabled && passkeyConfigured && !config.passkeyDisabled,
167
- authenticated: !enabled || config.passkeyDisabled || hasPasskeySession(req, store),
168
- };
140
+ function webauthnRpId(hostname: string): string {
141
+ if (isIP(hostname)) {
142
+ throw new AuthError("invalid_passkey_host", "Passkeys require a hostname such as localhost, not an IP address", 400);
143
+ }
144
+ return hostname;
169
145
  }
170
146
 
171
- export async function beginRegistration(req: Request, store: WebAppStore, config: RuntimeConfig) {
172
- if (store.listPasskeys().length > 0) {
173
- throw new AuthError("passkey_exists", "A passkey is already configured", 409);
174
- }
147
+ async function beginRegistrationForUser(req: Request, store: WebAppStore, config: RuntimeConfig, user: { id: string; username: string }, type: ChallengePayload["type"], setupTokenHash?: string) {
175
148
  const origin = getRequestOriginInfo(req, config.publicBaseUrl);
149
+ const rpID = webauthnRpId(origin.hostname);
176
150
  const options = await generateRegistrationOptions({
177
151
  rpName: config.appName,
178
- rpID: origin.hostname,
179
- userID: new Uint8Array(Buffer.from(config.envPrefix.toLowerCase())),
180
- userName: config.envPrefix.toLowerCase(),
181
- userDisplayName: config.appName,
152
+ rpID,
153
+ userID: registrationUserId(user.id),
154
+ userName: user.username,
155
+ userDisplayName: user.username,
182
156
  attestationType: "none",
183
157
  authenticatorSelection: {
184
158
  residentKey: "preferred",
185
159
  userVerification: "preferred",
186
160
  },
187
- });
188
- return { options, headers: setChallengeHeaders(req, store, config, { challenge: options.challenge, type: "registration" }) };
161
+ } as Parameters<typeof generateRegistrationOptions>[0]);
162
+ return { options, headers: setChallengeHeaders(req, store, config, { challenge: options.challenge, type, userId: user.id, username: user.username, setupTokenHash }) };
189
163
  }
190
164
 
191
- export async function completeRegistration(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON) {
192
- if (store.listPasskeys().length > 0) {
193
- throw new AuthError("passkey_exists", "A passkey is already configured", 409);
194
- }
195
- const challenge = readChallenge(req, store, "registration");
165
+ async function verifyAndSavePasskey(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON, user: UserRecord, challenge: ChallengePayload): Promise<Headers> {
196
166
  const origin = getRequestOriginInfo(req, config.publicBaseUrl);
167
+ const rpID = webauthnRpId(origin.hostname);
197
168
  const verification = await verifyRegistrationResponse({
198
169
  response,
199
170
  expectedChallenge: challenge.challenge,
200
171
  expectedOrigin: origin.origin,
201
- expectedRPID: origin.hostname,
172
+ expectedRPID: rpID,
202
173
  requireUserVerification: false,
203
174
  });
204
175
  if (!verification.verified || !verification.registrationInfo) {
205
176
  throw new AuthError("registration_failed", "Passkey registration failed", 400);
206
177
  }
207
178
  const info = verification.registrationInfo;
179
+ const timestamp = nowIso();
208
180
  store.savePasskey({
209
181
  id: crypto.randomUUID(),
182
+ userId: user.id,
210
183
  name: "Primary passkey",
211
184
  credentialId: info.credential.id,
212
185
  publicKey: new Uint8Array(info.credential.publicKey) as Uint8Array<ArrayBuffer>,
@@ -214,11 +187,146 @@ export async function completeRegistration(req: Request, store: WebAppStore, con
214
187
  deviceType: info.credentialDeviceType,
215
188
  backedUp: info.credentialBackedUp,
216
189
  transports: info.credential.transports ?? response.response.transports ?? [],
217
- createdAt: nowIso(),
218
- updatedAt: nowIso(),
190
+ createdAt: timestamp,
191
+ updatedAt: timestamp,
219
192
  });
220
- bumpVersion(store);
221
- return setSessionHeaders(req, store, config);
193
+ store.incrementUserAuthVersion(user.id, timestamp);
194
+ const updatedUser = store.getUserById(user.id) ?? user;
195
+ return setSessionHeaders(req, store, config, updatedUser);
196
+ }
197
+
198
+ export function getPasskeySessionUser(req: Request, store: WebAppStore, config: RuntimeConfig) {
199
+ if (config.passkeyDisabled) {
200
+ const owner = store.getOwnerUser();
201
+ return owner ? toCurrentUser(owner) : undefined;
202
+ }
203
+ const secret = store.getPreference(SECRET_KEY);
204
+ if (!secret) {
205
+ return undefined;
206
+ }
207
+ const session = decodeSigned<SessionPayload>(getCookie(req, SESSION_COOKIE), secret);
208
+ if (!session || session.expiresAt <= Date.now()) {
209
+ return undefined;
210
+ }
211
+ const user = store.getUserById(session.userId);
212
+ if (!user || user.disabledAt || user.authVersion !== session.authVersion) {
213
+ return undefined;
214
+ }
215
+ return toCurrentUser(user);
216
+ }
217
+
218
+ export function hasPasskeySession(req: Request, store: WebAppStore, config: RuntimeConfig): boolean {
219
+ return Boolean(getPasskeySessionUser(req, store, config));
220
+ }
221
+
222
+ export function isPasskeyAuthRequired(store: WebAppStore, config: RuntimeConfig): boolean {
223
+ return !config.passkeyDisabled && store.countUsers() > 0;
224
+ }
225
+
226
+ export function passkeyStatus(req: Request, store: WebAppStore, config: RuntimeConfig, enabled = true): PasskeyAuthStatusResponse {
227
+ const users = store.countUsers();
228
+ const owner = store.getOwnerUser();
229
+ const passkeyConfigured = store.listPasskeys().length > 0;
230
+ const ownerPasskeySetupRequired = users > 0 && Boolean(owner && !owner.passkeyConfigured);
231
+ return {
232
+ enabled,
233
+ passkeyConfigured,
234
+ passkeyDisabled: config.passkeyDisabled,
235
+ passkeyRequired: enabled && users > 0 && !config.passkeyDisabled,
236
+ authenticated: !enabled || Boolean(getPasskeySessionUser(req, store, config)),
237
+ bootstrapRequired: enabled && users === 0,
238
+ ownerPasskeySetupRequired,
239
+ };
240
+ }
241
+
242
+ export async function beginBootstrapRegistration(req: Request, store: WebAppStore, config: RuntimeConfig, username: string) {
243
+ if (store.countUsers() > 0) {
244
+ throw new AuthError("owner_exists", "Owner user is already configured", 409);
245
+ }
246
+ const normalized = assertValidUsername(username);
247
+ return beginRegistrationForUser(req, store, config, { id: crypto.randomUUID(), username: normalized }, "bootstrap");
248
+ }
249
+
250
+ export async function completeBootstrapRegistration(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON) {
251
+ if (store.countUsers() > 0) {
252
+ throw new AuthError("owner_exists", "Owner user is already configured", 409);
253
+ }
254
+ const challenge = readChallenge(req, store, "bootstrap");
255
+ if (!challenge.userId || !challenge.username) {
256
+ throw new AuthError("challenge_invalid", "Passkey challenge is invalid or expired", 400);
257
+ }
258
+ const owner = createUserRecord({ username: challenge.username, role: "owner" });
259
+ owner.id = challenge.userId;
260
+ store.createUser(owner);
261
+ const headers = await verifyAndSavePasskey(req, store, config, response, owner, challenge);
262
+ audit(store, { eventType: "owner_created", targetUserId: owner.id });
263
+ return headers;
264
+ }
265
+
266
+ export async function beginOwnerPasskeySetup(req: Request, store: WebAppStore, config: RuntimeConfig) {
267
+ const owner = store.getOwnerUser();
268
+ if (!owner || owner.passkeyConfigured) {
269
+ throw new AuthError("owner_setup_unavailable", "Owner passkey setup is not available", 409);
270
+ }
271
+ return beginRegistrationForUser(req, store, config, owner, "owner-reset");
272
+ }
273
+
274
+ export async function completeOwnerPasskeySetup(req: Request, store: WebAppStore, config: RuntimeConfig, response: RegistrationResponseJSON) {
275
+ const challenge = readChallenge(req, store, "owner-reset");
276
+ const owner = challenge.userId ? store.getUserById(challenge.userId) : undefined;
277
+ if (!owner || owner.role !== "owner" || owner.passkeyConfigured) {
278
+ throw new AuthError("owner_setup_unavailable", "Owner passkey setup is not available", 409);
279
+ }
280
+ const headers = await verifyAndSavePasskey(req, store, config, response, owner, challenge);
281
+ audit(store, { eventType: "owner_passkey_configured", targetUserId: owner.id });
282
+ return headers;
283
+ }
284
+
285
+ export function getSetupDetails(store: WebAppStore, token: string) {
286
+ const link = store.getSetupLinkByTokenHash(sha256(token));
287
+ if (!link || link.consumedAt || isExpired(link.expiresAt)) {
288
+ throw new AuthError("setup_link_invalid", "Setup link is invalid or expired", 404);
289
+ }
290
+ const user = store.getUserById(link.userId);
291
+ if (!user) {
292
+ throw new AuthError("setup_link_invalid", "Setup link is invalid or expired", 404);
293
+ }
294
+ return {
295
+ username: user.username,
296
+ role: user.role,
297
+ kind: link.kind === "reset" ? "reset" as const : "invite" as const,
298
+ expiresAt: link.expiresAt,
299
+ };
300
+ }
301
+
302
+ export async function beginSetupRegistration(req: Request, store: WebAppStore, config: RuntimeConfig, token: string) {
303
+ const tokenHash = sha256(token);
304
+ const link = store.getSetupLinkByTokenHash(tokenHash);
305
+ if (!link || link.consumedAt || isExpired(link.expiresAt)) {
306
+ throw new AuthError("setup_link_invalid", "Setup link is invalid or expired", 404);
307
+ }
308
+ const user = store.getUserById(link.userId);
309
+ if (!user) {
310
+ throw new AuthError("setup_link_invalid", "Setup link is invalid or expired", 404);
311
+ }
312
+ return beginRegistrationForUser(req, store, config, user, "setup", tokenHash);
313
+ }
314
+
315
+ export async function completeSetupRegistration(req: Request, store: WebAppStore, config: RuntimeConfig, token: string, response: RegistrationResponseJSON) {
316
+ const tokenHash = sha256(token);
317
+ const challenge = readChallenge(req, store, "setup");
318
+ if (challenge.setupTokenHash !== tokenHash || !challenge.userId) {
319
+ throw new AuthError("challenge_invalid", "Passkey challenge is invalid or expired", 400);
320
+ }
321
+ const link = store.getSetupLinkByTokenHash(tokenHash);
322
+ const user = link ? store.getUserById(link.userId) : undefined;
323
+ if (!link || !user || link.consumedAt || isExpired(link.expiresAt) || user.id !== challenge.userId) {
324
+ throw new AuthError("setup_link_invalid", "Setup link is invalid or expired", 404);
325
+ }
326
+ const headers = await verifyAndSavePasskey(req, store, config, response, user, challenge);
327
+ store.consumeSetupLink(link.id, nowIso());
328
+ audit(store, { eventType: link.kind === "reset" ? "user_reset_completed" : "user_invite_completed", targetUserId: user.id });
329
+ return headers;
222
330
  }
223
331
 
224
332
  export async function beginAuthentication(req: Request, store: WebAppStore, config: RuntimeConfig) {
@@ -227,29 +335,32 @@ export async function beginAuthentication(req: Request, store: WebAppStore, conf
227
335
  throw new AuthError("passkey_missing", "No passkey is configured", 409);
228
336
  }
229
337
  const origin = getRequestOriginInfo(req, config.publicBaseUrl);
338
+ const rpID = webauthnRpId(origin.hostname);
230
339
  const options = await generateAuthenticationOptions({
231
- rpID: origin.hostname,
340
+ rpID,
232
341
  allowCredentials: passkeys.map((passkey) => ({
233
342
  id: passkey.credentialId,
234
343
  transports: passkey.transports as AuthenticatorTransport[],
235
344
  })),
236
345
  userVerification: "preferred",
237
- });
346
+ } as Parameters<typeof generateAuthenticationOptions>[0]);
238
347
  return { options, headers: setChallengeHeaders(req, store, config, { challenge: options.challenge, type: "authentication" }) };
239
348
  }
240
349
 
241
350
  export async function completeAuthentication(req: Request, store: WebAppStore, config: RuntimeConfig, response: AuthenticationResponseJSON) {
242
351
  const challenge = readChallenge(req, store, "authentication");
243
352
  const passkey = store.getPasskeyByCredentialId(response.id);
244
- if (!passkey) {
353
+ const user = passkey ? store.getUserById(passkey.userId) : undefined;
354
+ if (!passkey || !user) {
245
355
  throw new AuthError("passkey_not_found", "Passkey credential is not registered", 404);
246
356
  }
247
357
  const origin = getRequestOriginInfo(req, config.publicBaseUrl);
358
+ const rpID = webauthnRpId(origin.hostname);
248
359
  const verification = await verifyAuthenticationResponse({
249
360
  response,
250
361
  expectedChallenge: challenge.challenge,
251
362
  expectedOrigin: origin.origin,
252
- expectedRPID: origin.hostname,
363
+ expectedRPID: rpID,
253
364
  credential: {
254
365
  id: passkey.credentialId,
255
366
  publicKey: passkey.publicKey,
@@ -261,8 +372,11 @@ export async function completeAuthentication(req: Request, store: WebAppStore, c
261
372
  if (!verification.verified) {
262
373
  throw new AuthError("authentication_failed", "Passkey authentication failed", 401);
263
374
  }
264
- store.updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter, nowIso());
265
- return setSessionHeaders(req, store, config);
375
+ const timestamp = nowIso();
376
+ store.updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter, timestamp);
377
+ store.markUserLogin(user.id, timestamp);
378
+ audit(store, { eventType: "user_login", actorUserId: user.id });
379
+ return setSessionHeaders(req, store, config, user);
266
380
  }
267
381
 
268
382
  export function logoutHeaders(req: Request, config: RuntimeConfig): Headers {
@@ -273,8 +387,12 @@ export function logoutHeaders(req: Request, config: RuntimeConfig): Headers {
273
387
  return headers;
274
388
  }
275
389
 
276
- export function deletePasskey(req: Request, store: WebAppStore, config: RuntimeConfig): Headers {
277
- store.deleteAllPasskeys();
278
- bumpVersion(store);
390
+ export function deletePasskey(req: Request, store: WebAppStore, config: RuntimeConfig, userId: string): Headers {
391
+ const timestamp = nowIso();
392
+ store.deletePasskeysForUser(userId);
393
+ store.incrementUserAuthVersion(userId, timestamp);
394
+ store.deleteApiKeysForUser(userId);
395
+ store.revokeRefreshSessionsForUser(userId, timestamp);
396
+ audit(store, { eventType: "passkey_deleted", actorUserId: userId, targetUserId: userId });
279
397
  return logoutHeaders(req, config);
280
398
  }