@pablozaiden/webapp 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +26 -0
  3. package/docs/auth-validation.md +34 -0
  4. package/docs/auth.md +35 -0
  5. package/docs/deployment.md +49 -0
  6. package/docs/getting-started.md +62 -0
  7. package/docs/github-actions.md +421 -0
  8. package/docs/realtime.md +61 -0
  9. package/docs/release.md +64 -0
  10. package/docs/server.md +45 -0
  11. package/docs/settings.md +42 -0
  12. package/docs/sidebar.md +80 -0
  13. package/docs/ui-guidelines.md +55 -0
  14. package/package.json +60 -0
  15. package/src/build/build-binary.ts +42 -0
  16. package/src/build/index.ts +1 -0
  17. package/src/contracts/index.ts +86 -0
  18. package/src/server/auth/api-keys.ts +61 -0
  19. package/src/server/auth/crypto.ts +34 -0
  20. package/src/server/auth/device-auth.ts +324 -0
  21. package/src/server/auth/passkeys.ts +280 -0
  22. package/src/server/auth/request-origin.ts +33 -0
  23. package/src/server/auth/sqlite-store.ts +301 -0
  24. package/src/server/auth/store.ts +91 -0
  25. package/src/server/auth/types.ts +25 -0
  26. package/src/server/create-web-app-server.ts +447 -0
  27. package/src/server/index.ts +7 -0
  28. package/src/server/logger.ts +44 -0
  29. package/src/server/realtime/bus.ts +104 -0
  30. package/src/server/responses.ts +54 -0
  31. package/src/server/routes.ts +67 -0
  32. package/src/server/runtime-config.ts +101 -0
  33. package/src/server/same-origin.ts +35 -0
  34. package/src/types.d.ts +11 -0
  35. package/src/web/WebAppRoot.tsx +706 -0
  36. package/src/web/components/index.tsx +471 -0
  37. package/src/web/index.ts +5 -0
  38. package/src/web/realtime/useRealtime.ts +189 -0
  39. package/src/web/sidebar/types.ts +45 -0
  40. package/src/web/styles.css +1295 -0
@@ -0,0 +1,324 @@
1
+ import {
2
+ exportJWK,
3
+ generateKeyPair,
4
+ importJWK,
5
+ jwtVerify,
6
+ SignJWT,
7
+ type CryptoKey,
8
+ type JWK,
9
+ } from "jose";
10
+ import type { AuthSessionSummary, DeviceAuthorizationResponse, DeviceVerificationDetails, TokenResponse } from "../../contracts";
11
+ import type { RuntimeConfig } from "../runtime-config";
12
+ import type { RefreshSessionRecord, WebAppStore } from "./store";
13
+ import { addSeconds, isExpired, nowIso, randomToken, sha256 } from "./crypto";
14
+ import { getRequestOriginInfo } from "./request-origin";
15
+ import { AuthError, type AccessTokenClaims } from "./types";
16
+
17
+ const ACCESS_TOKEN_TTL_SECONDS = 60 * 10;
18
+ const REFRESH_TOKEN_TTL_SECONDS = 60 * 60 * 24 * 30;
19
+ const DEVICE_CODE_TTL_SECONDS = 60 * 10;
20
+ const DEVICE_POLL_INTERVAL_SECONDS = 5;
21
+ const USER_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
22
+
23
+ interface SigningKeyPair {
24
+ alg: string;
25
+ kid: string;
26
+ publicJwk: JWK;
27
+ privateJwk: JWK;
28
+ publicKey: CryptoKey;
29
+ privateKey: CryptoKey;
30
+ }
31
+
32
+ function generateUserCode(): string {
33
+ const chars: string[] = [];
34
+ const bytes = crypto.getRandomValues(new Uint8Array(8));
35
+ for (const byte of bytes) {
36
+ chars.push(USER_CODE_ALPHABET[byte % USER_CODE_ALPHABET.length]!);
37
+ }
38
+ return `${chars.slice(0, 4).join("")}-${chars.slice(4).join("")}`;
39
+ }
40
+
41
+ function getPublicBaseUrl(req: Request, config: RuntimeConfig): string {
42
+ return config.publicBaseUrl ?? getRequestOriginInfo(req).origin;
43
+ }
44
+
45
+ async function createSigningKey(): Promise<SigningKeyPair> {
46
+ try {
47
+ const pair = await generateKeyPair("EdDSA", { extractable: true });
48
+ const publicJwk = await exportJWK(pair.publicKey);
49
+ const privateJwk = await exportJWK(pair.privateKey);
50
+ const kid = crypto.randomUUID();
51
+ return { alg: "EdDSA", kid, publicJwk, privateJwk, publicKey: pair.publicKey, privateKey: pair.privateKey };
52
+ } catch {
53
+ const pair = await generateKeyPair("ES256", { extractable: true });
54
+ const publicJwk = await exportJWK(pair.publicKey);
55
+ const privateJwk = await exportJWK(pair.privateKey);
56
+ const kid = crypto.randomUUID();
57
+ return { alg: "ES256", kid, publicJwk, privateJwk, publicKey: pair.publicKey, privateKey: pair.privateKey };
58
+ }
59
+ }
60
+
61
+ async function getSigningKey(store: WebAppStore): Promise<SigningKeyPair> {
62
+ const stored = store.getSigningKey();
63
+ if (stored) {
64
+ return {
65
+ alg: stored.alg,
66
+ kid: stored.kid,
67
+ publicJwk: stored.publicJwk as JWK,
68
+ privateJwk: stored.privateJwk as JWK,
69
+ publicKey: await importJWK(stored.publicJwk as JWK, stored.alg) as CryptoKey,
70
+ privateKey: await importJWK(stored.privateJwk as JWK, stored.alg) as CryptoKey,
71
+ };
72
+ }
73
+ const created = await createSigningKey();
74
+ store.saveSigningKey({
75
+ alg: created.alg,
76
+ kid: created.kid,
77
+ publicJwk: created.publicJwk as Record<string, unknown>,
78
+ privateJwk: created.privateJwk as Record<string, unknown>,
79
+ createdAt: nowIso(),
80
+ });
81
+ return created;
82
+ }
83
+
84
+ function issuer(config: RuntimeConfig): string {
85
+ return config.authIssuer || `urn:${config.envPrefix.toLowerCase()}:webapp`;
86
+ }
87
+
88
+ async function issueAccessToken(store: WebAppStore, config: RuntimeConfig, input: {
89
+ sessionId: string;
90
+ clientId: string;
91
+ scope: string;
92
+ }): Promise<{ accessToken: string; jti: string }> {
93
+ const key = await getSigningKey(store);
94
+ const jti = crypto.randomUUID();
95
+ const token = await new SignJWT({
96
+ sid: input.sessionId,
97
+ clientId: input.clientId,
98
+ scope: input.scope,
99
+ })
100
+ .setProtectedHeader({ alg: key.alg, kid: key.kid })
101
+ .setSubject(`${config.envPrefix.toLowerCase()}-user`)
102
+ .setJti(jti)
103
+ .setIssuer(issuer(config))
104
+ .setAudience(`${config.envPrefix.toLowerCase()}-api`)
105
+ .setIssuedAt()
106
+ .setExpirationTime(`${ACCESS_TOKEN_TTL_SECONDS}s`)
107
+ .sign(key.privateKey);
108
+ return { accessToken: token, jti };
109
+ }
110
+
111
+ function tokenSet(accessToken: string, refreshToken: string, scope: string): TokenResponse {
112
+ return {
113
+ access_token: accessToken,
114
+ refresh_token: refreshToken,
115
+ token_type: "Bearer",
116
+ expires_in: ACCESS_TOKEN_TTL_SECONDS,
117
+ scope,
118
+ };
119
+ }
120
+
121
+ export function createDeviceAuthorization(req: Request, store: WebAppStore, config: RuntimeConfig, input: { clientId?: string; scope?: string } = {}): DeviceAuthorizationResponse {
122
+ const deviceCode = randomToken(32);
123
+ let userCode = generateUserCode();
124
+ while (store.getDeviceAuthByUserCode(userCode)) {
125
+ userCode = generateUserCode();
126
+ }
127
+ const baseUrl = getPublicBaseUrl(req, config);
128
+ const record = {
129
+ deviceCodeHash: sha256(deviceCode),
130
+ userCode,
131
+ clientId: input.clientId?.trim() || `${config.envPrefix.toLowerCase()}-cli`,
132
+ scope: input.scope?.trim() || "*",
133
+ status: "pending" as const,
134
+ createdAt: nowIso(),
135
+ updatedAt: nowIso(),
136
+ expiresAt: addSeconds(DEVICE_CODE_TTL_SECONDS),
137
+ };
138
+ store.saveDeviceAuthRequest(record);
139
+ return {
140
+ device_code: deviceCode,
141
+ user_code: userCode,
142
+ verification_uri: `${baseUrl}/device`,
143
+ verification_uri_complete: `${baseUrl}/device?user_code=${encodeURIComponent(userCode)}`,
144
+ expires_in: DEVICE_CODE_TTL_SECONDS,
145
+ interval: DEVICE_POLL_INTERVAL_SECONDS,
146
+ };
147
+ }
148
+
149
+ export function getDeviceVerificationDetails(store: WebAppStore, userCode: string, passkeyRequired: boolean): DeviceVerificationDetails {
150
+ const record = store.getDeviceAuthByUserCode(userCode);
151
+ if (!record || isExpired(record.expiresAt)) {
152
+ throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
153
+ }
154
+ return {
155
+ userCode: record.userCode,
156
+ clientId: record.clientId,
157
+ scope: record.scope,
158
+ status: record.status,
159
+ expiresAt: record.expiresAt,
160
+ passkeyRequired,
161
+ };
162
+ }
163
+
164
+ export function approveDevice(store: WebAppStore, userCode: string): DeviceVerificationDetails {
165
+ const record = store.getDeviceAuthByUserCode(userCode);
166
+ if (!record || isExpired(record.expiresAt)) {
167
+ throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
168
+ }
169
+ if (record.status !== "pending" && record.status !== "approved") {
170
+ throw new AuthError("invalid_request", "Device authorization can no longer be approved", 400);
171
+ }
172
+ store.updateDeviceAuthStatus(userCode, "approved", nowIso());
173
+ return getDeviceVerificationDetails(store, userCode, false);
174
+ }
175
+
176
+ export function denyDevice(store: WebAppStore, userCode: string): DeviceVerificationDetails {
177
+ const record = store.getDeviceAuthByUserCode(userCode);
178
+ if (!record || isExpired(record.expiresAt)) {
179
+ throw new AuthError("invalid_user_code", "Device authorization code is invalid or expired", 404);
180
+ }
181
+ store.updateDeviceAuthStatus(userCode, "denied", nowIso());
182
+ return getDeviceVerificationDetails(store, userCode, false);
183
+ }
184
+
185
+ function createRefreshRecord(clientId: string, scope: string, familyId: string = crypto.randomUUID()): { token: string; record: RefreshSessionRecord } {
186
+ const token = randomToken(32);
187
+ const createdAt = nowIso();
188
+ const id = crypto.randomUUID();
189
+ return {
190
+ token,
191
+ record: {
192
+ id,
193
+ familyId,
194
+ clientId,
195
+ scope,
196
+ refreshTokenHash: sha256(token),
197
+ createdAt,
198
+ updatedAt: createdAt,
199
+ expiresAt: addSeconds(REFRESH_TOKEN_TTL_SECONDS),
200
+ },
201
+ };
202
+ }
203
+
204
+ export async function exchangeDeviceCode(store: WebAppStore, config: RuntimeConfig, deviceCode: string, clientId?: string): Promise<TokenResponse> {
205
+ const record = store.getDeviceAuthByDeviceCodeHash(sha256(deviceCode));
206
+ if (!record) {
207
+ throw new AuthError("invalid_grant", "Device code is invalid", 400);
208
+ }
209
+ if (isExpired(record.expiresAt)) {
210
+ throw new AuthError("expired_token", "Device code has expired", 400);
211
+ }
212
+ if (clientId && clientId !== record.clientId) {
213
+ throw new AuthError("invalid_client", "client_id does not match device authorization", 400);
214
+ }
215
+ if (record.status === "pending") {
216
+ throw new AuthError("authorization_pending", "Device authorization is still pending", 400);
217
+ }
218
+ if (record.status === "denied") {
219
+ throw new AuthError("access_denied", "Device authorization was denied", 400);
220
+ }
221
+ if (record.status === "consumed") {
222
+ throw new AuthError("invalid_grant", "Device code has already been used", 400);
223
+ }
224
+ const refresh = createRefreshRecord(record.clientId, record.scope);
225
+ store.saveRefreshSession(refresh.record);
226
+ const access = await issueAccessToken(store, config, {
227
+ sessionId: refresh.record.id,
228
+ clientId: record.clientId,
229
+ scope: record.scope,
230
+ });
231
+ store.updateDeviceAuthStatus(record.userCode, "consumed", nowIso());
232
+ return tokenSet(access.accessToken, refresh.token, record.scope);
233
+ }
234
+
235
+ export async function exchangeRefreshToken(store: WebAppStore, config: RuntimeConfig, refreshToken: string, clientId?: string): Promise<TokenResponse> {
236
+ const hash = sha256(refreshToken);
237
+ const session = store.getRefreshSessionByHash(hash);
238
+ if (!session) {
239
+ throw new AuthError("invalid_grant", "Refresh token is invalid", 400);
240
+ }
241
+ if (session.revokedAt) {
242
+ store.revokeRefreshFamily(session.familyId, nowIso());
243
+ throw new AuthError("invalid_grant", "Refresh token has been revoked", 400);
244
+ }
245
+ if (isExpired(session.expiresAt)) {
246
+ throw new AuthError("invalid_grant", "Refresh token has expired", 400);
247
+ }
248
+ if (clientId && clientId !== session.clientId) {
249
+ throw new AuthError("invalid_client", "client_id does not match refresh session", 400);
250
+ }
251
+ const next = createRefreshRecord(session.clientId, session.scope, session.familyId);
252
+ const rotated = store.rotateRefreshSession(hash, next.record, nowIso());
253
+ if (!rotated) {
254
+ throw new AuthError("invalid_grant", "Refresh token is invalid", 400);
255
+ }
256
+ const access = await issueAccessToken(store, config, {
257
+ sessionId: next.record.id,
258
+ clientId: next.record.clientId,
259
+ scope: next.record.scope,
260
+ });
261
+ return tokenSet(access.accessToken, next.token, next.record.scope);
262
+ }
263
+
264
+ export async function verifyAccessToken(store: WebAppStore, config: RuntimeConfig, token: string): Promise<AccessTokenClaims> {
265
+ const key = await getSigningKey(store);
266
+ const result = await jwtVerify(token, key.publicKey, {
267
+ issuer: issuer(config),
268
+ audience: `${config.envPrefix.toLowerCase()}-api`,
269
+ });
270
+ const payload = result.payload;
271
+ return {
272
+ sub: payload.sub ?? "",
273
+ jti: payload.jti ?? "",
274
+ sid: String(payload["sid"] ?? ""),
275
+ clientId: String(payload["clientId"] ?? ""),
276
+ scope: String(payload["scope"] ?? ""),
277
+ };
278
+ }
279
+
280
+ export function listAuthSessions(store: WebAppStore): AuthSessionSummary[] {
281
+ return store.listRefreshSessions().map((session) => ({
282
+ id: session.id,
283
+ clientId: session.clientId,
284
+ scope: session.scope,
285
+ createdAt: session.createdAt,
286
+ updatedAt: session.updatedAt,
287
+ expiresAt: session.expiresAt,
288
+ lastUsedAt: session.lastUsedAt,
289
+ revokedAt: session.revokedAt,
290
+ active: !session.revokedAt && !isExpired(session.expiresAt),
291
+ }));
292
+ }
293
+
294
+ export function revokeAuthSession(store: WebAppStore, id: string): boolean {
295
+ return store.revokeRefreshSession(id, nowIso());
296
+ }
297
+
298
+ export function revokeRefreshToken(store: WebAppStore, refreshToken: string): boolean {
299
+ const session = store.getRefreshSessionByHash(sha256(refreshToken));
300
+ return session ? store.revokeRefreshSession(session.id, nowIso()) : false;
301
+ }
302
+
303
+ export async function jwks(store: WebAppStore) {
304
+ const key = await getSigningKey(store);
305
+ return {
306
+ keys: [{
307
+ ...key.publicJwk,
308
+ kid: key.kid,
309
+ alg: key.alg,
310
+ use: "sig",
311
+ }],
312
+ };
313
+ }
314
+
315
+ export function discovery(req: Request, config: RuntimeConfig) {
316
+ const base = getPublicBaseUrl(req, config);
317
+ return {
318
+ issuer: issuer(config),
319
+ jwks_uri: `${base}/.well-known/jwks.json`,
320
+ device_authorization_endpoint: `${base}/api/auth/device`,
321
+ token_endpoint: `${base}/api/auth/token`,
322
+ revocation_endpoint: `${base}/api/auth/revoke`,
323
+ };
324
+ }
@@ -0,0 +1,280 @@
1
+ import {
2
+ generateAuthenticationOptions,
3
+ generateRegistrationOptions,
4
+ verifyAuthenticationResponse,
5
+ verifyRegistrationResponse,
6
+ type AuthenticationResponseJSON,
7
+ type RegistrationResponseJSON,
8
+ } from "@simplewebauthn/server";
9
+ import type { PasskeyAuthStatusResponse } from "../../contracts";
10
+ import type { RuntimeConfig } from "../runtime-config";
11
+ import type { WebAppStore } from "./store";
12
+ import { hmacSha256, nowIso, randomToken, secureEqual } from "./crypto";
13
+ import { getCookiePath, getRequestOriginInfo } from "./request-origin";
14
+ import { AuthError } from "./types";
15
+
16
+ const SESSION_COOKIE = "webapp_passkey_session";
17
+ const CHALLENGE_COOKIE = "webapp_passkey_challenge";
18
+ const SESSION_TTL_SECONDS = 60 * 60 * 24 * 30;
19
+ const CHALLENGE_TTL_SECONDS = 60 * 10;
20
+ const SECRET_KEY = "passkey.secret";
21
+ const VERSION_KEY = "passkey.version";
22
+
23
+ interface SignedCookiePayload {
24
+ expiresAt: number;
25
+ }
26
+
27
+ interface ChallengePayload extends SignedCookiePayload {
28
+ challenge: string;
29
+ type: "registration" | "authentication";
30
+ }
31
+
32
+ interface SessionPayload extends SignedCookiePayload {
33
+ nonce: string;
34
+ version: number;
35
+ }
36
+
37
+ function getSecret(store: WebAppStore): string {
38
+ const existing = store.getPreference(SECRET_KEY);
39
+ if (existing) {
40
+ return existing;
41
+ }
42
+ const secret = randomToken();
43
+ store.setPreference(SECRET_KEY, secret);
44
+ return secret;
45
+ }
46
+
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
+ function encodeSigned(payload: object, secret: string): string {
65
+ const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
66
+ return `${body}.${hmacSha256(body, secret)}`;
67
+ }
68
+
69
+ function decodeSigned<T>(value: string | undefined, secret: string): T | undefined {
70
+ if (!value) {
71
+ return undefined;
72
+ }
73
+ const [body, signature] = value.split(".", 2);
74
+ if (!body || !signature || !secureEqual(signature, hmacSha256(body, secret))) {
75
+ return undefined;
76
+ }
77
+ try {
78
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8")) as T;
79
+ } catch {
80
+ return undefined;
81
+ }
82
+ }
83
+
84
+ function getCookie(req: Request, name: string): string | undefined {
85
+ const cookie = req.headers.get("cookie");
86
+ if (!cookie) {
87
+ return undefined;
88
+ }
89
+ for (const part of cookie.split(";")) {
90
+ const [rawName, ...rawValue] = part.trim().split("=");
91
+ if (rawName === name) {
92
+ return rawValue.join("=");
93
+ }
94
+ }
95
+ return undefined;
96
+ }
97
+
98
+ function cookieHeader(req: Request, name: string, value: string, maxAge: number, secure: boolean): string {
99
+ return [
100
+ `${name}=${value}`,
101
+ `Path=${getCookiePath(req)}`,
102
+ "HttpOnly",
103
+ "SameSite=Strict",
104
+ `Max-Age=${maxAge}`,
105
+ secure ? "Secure" : "",
106
+ ].filter(Boolean).join("; ");
107
+ }
108
+
109
+ function expiredCookie(req: Request, name: string, secure: boolean): string {
110
+ return cookieHeader(req, name, "", 0, secure);
111
+ }
112
+
113
+ function setSessionHeaders(req: Request, store: WebAppStore, config: RuntimeConfig): Headers {
114
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
115
+ const secret = getSecret(store);
116
+ const payload: SessionPayload = {
117
+ nonce: randomToken(16),
118
+ version: getVersion(store),
119
+ expiresAt: Date.now() + SESSION_TTL_SECONDS * 1000,
120
+ };
121
+ const headers = new Headers();
122
+ headers.append("set-cookie", cookieHeader(req, SESSION_COOKIE, encodeSigned(payload, secret), SESSION_TTL_SECONDS, origin.secure));
123
+ headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
124
+ return headers;
125
+ }
126
+
127
+ function setChallengeHeaders(req: Request, store: WebAppStore, config: RuntimeConfig, payload: Omit<ChallengePayload, "expiresAt">): Headers {
128
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
129
+ const secret = getSecret(store);
130
+ const headers = new Headers();
131
+ headers.append("set-cookie", cookieHeader(req, CHALLENGE_COOKIE, encodeSigned({
132
+ ...payload,
133
+ expiresAt: Date.now() + CHALLENGE_TTL_SECONDS * 1000,
134
+ }, secret), CHALLENGE_TTL_SECONDS, origin.secure));
135
+ return headers;
136
+ }
137
+
138
+ function readChallenge(req: Request, store: WebAppStore, type: ChallengePayload["type"]): ChallengePayload {
139
+ const secret = getSecret(store);
140
+ const challenge = decodeSigned<ChallengePayload>(getCookie(req, CHALLENGE_COOKIE), secret);
141
+ if (!challenge || challenge.type !== type || challenge.expiresAt <= Date.now()) {
142
+ throw new AuthError("challenge_invalid", "Passkey challenge is invalid or expired", 400);
143
+ }
144
+ return challenge;
145
+ }
146
+
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));
154
+ }
155
+
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
+ };
169
+ }
170
+
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
+ }
175
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
176
+ const options = await generateRegistrationOptions({
177
+ 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,
182
+ attestationType: "none",
183
+ authenticatorSelection: {
184
+ residentKey: "preferred",
185
+ userVerification: "preferred",
186
+ },
187
+ });
188
+ return { options, headers: setChallengeHeaders(req, store, config, { challenge: options.challenge, type: "registration" }) };
189
+ }
190
+
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");
196
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
197
+ const verification = await verifyRegistrationResponse({
198
+ response,
199
+ expectedChallenge: challenge.challenge,
200
+ expectedOrigin: origin.origin,
201
+ expectedRPID: origin.hostname,
202
+ requireUserVerification: false,
203
+ });
204
+ if (!verification.verified || !verification.registrationInfo) {
205
+ throw new AuthError("registration_failed", "Passkey registration failed", 400);
206
+ }
207
+ const info = verification.registrationInfo;
208
+ store.savePasskey({
209
+ id: crypto.randomUUID(),
210
+ name: "Primary passkey",
211
+ credentialId: info.credential.id,
212
+ publicKey: new Uint8Array(info.credential.publicKey) as Uint8Array<ArrayBuffer>,
213
+ counter: info.credential.counter,
214
+ deviceType: info.credentialDeviceType,
215
+ backedUp: info.credentialBackedUp,
216
+ transports: info.credential.transports ?? response.response.transports ?? [],
217
+ createdAt: nowIso(),
218
+ updatedAt: nowIso(),
219
+ });
220
+ bumpVersion(store);
221
+ return setSessionHeaders(req, store, config);
222
+ }
223
+
224
+ export async function beginAuthentication(req: Request, store: WebAppStore, config: RuntimeConfig) {
225
+ const passkeys = store.listPasskeys();
226
+ if (passkeys.length === 0) {
227
+ throw new AuthError("passkey_missing", "No passkey is configured", 409);
228
+ }
229
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
230
+ const options = await generateAuthenticationOptions({
231
+ rpID: origin.hostname,
232
+ allowCredentials: passkeys.map((passkey) => ({
233
+ id: passkey.credentialId,
234
+ transports: passkey.transports as AuthenticatorTransport[],
235
+ })),
236
+ userVerification: "preferred",
237
+ });
238
+ return { options, headers: setChallengeHeaders(req, store, config, { challenge: options.challenge, type: "authentication" }) };
239
+ }
240
+
241
+ export async function completeAuthentication(req: Request, store: WebAppStore, config: RuntimeConfig, response: AuthenticationResponseJSON) {
242
+ const challenge = readChallenge(req, store, "authentication");
243
+ const passkey = store.getPasskeyByCredentialId(response.id);
244
+ if (!passkey) {
245
+ throw new AuthError("passkey_not_found", "Passkey credential is not registered", 404);
246
+ }
247
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
248
+ const verification = await verifyAuthenticationResponse({
249
+ response,
250
+ expectedChallenge: challenge.challenge,
251
+ expectedOrigin: origin.origin,
252
+ expectedRPID: origin.hostname,
253
+ credential: {
254
+ id: passkey.credentialId,
255
+ publicKey: passkey.publicKey,
256
+ counter: passkey.counter,
257
+ transports: passkey.transports as AuthenticatorTransport[],
258
+ },
259
+ requireUserVerification: false,
260
+ });
261
+ if (!verification.verified) {
262
+ throw new AuthError("authentication_failed", "Passkey authentication failed", 401);
263
+ }
264
+ store.updatePasskeyUsage(passkey.credentialId, verification.authenticationInfo.newCounter, nowIso());
265
+ return setSessionHeaders(req, store, config);
266
+ }
267
+
268
+ export function logoutHeaders(req: Request, config: RuntimeConfig): Headers {
269
+ const origin = getRequestOriginInfo(req, config.publicBaseUrl);
270
+ const headers = new Headers();
271
+ headers.append("set-cookie", expiredCookie(req, SESSION_COOKIE, origin.secure));
272
+ headers.append("set-cookie", expiredCookie(req, CHALLENGE_COOKIE, origin.secure));
273
+ return headers;
274
+ }
275
+
276
+ export function deletePasskey(req: Request, store: WebAppStore, config: RuntimeConfig): Headers {
277
+ store.deleteAllPasskeys();
278
+ bumpVersion(store);
279
+ return logoutHeaders(req, config);
280
+ }
@@ -0,0 +1,33 @@
1
+ export interface RequestOriginInfo {
2
+ origin: string;
3
+ hostname: string;
4
+ secure: boolean;
5
+ }
6
+
7
+ export function getRequestOriginInfo(req: Request, publicBaseUrl?: string): RequestOriginInfo {
8
+ if (publicBaseUrl) {
9
+ const parsed = new URL(publicBaseUrl);
10
+ return {
11
+ origin: parsed.origin,
12
+ hostname: parsed.hostname,
13
+ secure: parsed.protocol === "https:",
14
+ };
15
+ }
16
+ const url = new URL(req.url);
17
+ const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
18
+ const forwardedHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
19
+ const proto = forwardedProto || url.protocol.replace(":", "");
20
+ const host = forwardedHost || req.headers.get("host") || url.host;
21
+ const origin = `${proto}://${host}`;
22
+ const parsed = new URL(origin);
23
+ return {
24
+ origin: parsed.origin,
25
+ hostname: parsed.hostname,
26
+ secure: parsed.protocol === "https:",
27
+ };
28
+ }
29
+
30
+ export function getCookiePath(req: Request): string {
31
+ const prefix = req.headers.get("x-forwarded-prefix")?.trim().replace(/\/+$/, "");
32
+ return prefix || "/";
33
+ }