@pablozaiden/webapp 0.1.0 → 0.2.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.
- package/README.md +4 -3
- package/docs/auth-validation.md +24 -5
- package/docs/auth.md +33 -9
- package/docs/cli.md +45 -0
- package/docs/deployment.md +3 -0
- package/docs/getting-started.md +47 -7
- package/docs/github-actions.md +8 -3
- package/docs/realtime.md +7 -2
- package/docs/server.md +89 -4
- package/docs/settings.md +15 -5
- package/docs/sidebar.md +3 -3
- package/docs/ui-guidelines.md +8 -2
- package/package.json +2 -1
- package/src/cli/api-command.ts +119 -0
- package/src/cli/credentials.ts +67 -0
- package/src/cli/device-auth.ts +179 -0
- package/src/cli/index.ts +4 -0
- package/src/cli/runtime.ts +51 -0
- package/src/contracts/index.ts +53 -0
- package/src/server/auth/api-keys.ts +13 -8
- package/src/server/auth/device-auth.ts +34 -11
- package/src/server/auth/passkeys.ts +191 -73
- package/src/server/auth/sqlite-store.ts +394 -44
- package/src/server/auth/store.ts +57 -13
- package/src/server/auth/types.ts +7 -3
- package/src/server/auth/users.ts +83 -0
- package/src/server/create-web-app-server.ts +451 -54
- package/src/server/index.ts +1 -0
- package/src/server/realtime/bus.ts +8 -2
- package/src/server/route-catalog.ts +147 -0
- package/src/server/routes.ts +35 -4
- package/src/server/runtime-config.ts +1 -1
- package/src/web/WebAppRoot.tsx +336 -30
- package/src/web/api-client.ts +64 -0
- package/src/web/components/index.tsx +44 -11
- package/src/web/index.ts +2 -0
- package/src/web/realtime/useRealtime.ts +2 -2
- package/src/web/render.tsx +26 -0
- package/src/web/styles.css +104 -2
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
|
179
|
-
userID:
|
|
180
|
-
userName:
|
|
181
|
-
userDisplayName:
|
|
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:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
218
|
-
updatedAt:
|
|
190
|
+
createdAt: timestamp,
|
|
191
|
+
updatedAt: timestamp,
|
|
219
192
|
});
|
|
220
|
-
|
|
221
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
265
|
-
|
|
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
|
-
|
|
278
|
-
|
|
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
|
}
|