wikimemory 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.
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/dist/npm-cli/scripts/cli-options.js +29 -0
- package/dist/npm-cli/scripts/cli.js +79 -0
- package/dist/npm-cli/scripts/client-tools.js +57 -0
- package/dist/npm-cli/scripts/deployment-record.js +72 -0
- package/dist/npm-cli/scripts/dev.js +49 -0
- package/dist/npm-cli/scripts/lifecycle-runtime.js +38 -0
- package/dist/npm-cli/scripts/package-root.js +15 -0
- package/dist/npm-cli/scripts/passkeys.js +197 -0
- package/dist/npm-cli/scripts/setup.js +523 -0
- package/dist/npm-cli/scripts/status.js +49 -0
- package/dist/npm-cli/scripts/uninstall.js +220 -0
- package/dist/npm-cli/scripts/upgrade.js +301 -0
- package/dist/npm-cli/src/version.js +2 -0
- package/dist/web/assets/index-CjnFBnXp.css +1 -0
- package/dist/web/assets/index-buhGyrxi.js +72 -0
- package/dist/web/index.html +14 -0
- package/migrations/0001_initial.sql +297 -0
- package/migrations/0002_passkeys.sql +30 -0
- package/migrations/0003_passkey_management.sql +38 -0
- package/migrations/0004_credential_bound_registration_tokens.sql +18 -0
- package/package.json +89 -0
- package/release-manifest.json +22 -0
- package/skills/wikimemory-ingest/SKILL.md +24 -0
- package/skills/wikimemory-ingest/agents/openai.yaml +4 -0
- package/skills/wikimemory-install/SKILL.md +81 -0
- package/skills/wikimemory-install/agents/openai.yaml +4 -0
- package/skills/wikimemory-lint/SKILL.md +18 -0
- package/skills/wikimemory-lint/agents/openai.yaml +4 -0
- package/skills/wikimemory-recall/SKILL.md +20 -0
- package/skills/wikimemory-recall/agents/openai.yaml +4 -0
- package/src/auth/local.ts +131 -0
- package/src/auth/passkey-api.ts +100 -0
- package/src/auth/passkey-management.ts +150 -0
- package/src/auth/passkey.ts +908 -0
- package/src/auth/props.ts +96 -0
- package/src/auth/resource.ts +36 -0
- package/src/domain/crypto.ts +27 -0
- package/src/domain/errors.ts +32 -0
- package/src/domain/export-service.ts +363 -0
- package/src/domain/guards.ts +9 -0
- package/src/domain/memory-service.ts +1092 -0
- package/src/domain/secret-scanner.ts +45 -0
- package/src/domain/text-chunk.ts +24 -0
- package/src/domain/types.ts +169 -0
- package/src/env.ts +20 -0
- package/src/index.ts +138 -0
- package/src/mcp/schemas.ts +117 -0
- package/src/mcp/server.ts +506 -0
- package/src/version.ts +2 -0
- package/src/web/app.ts +299 -0
|
@@ -0,0 +1,908 @@
|
|
|
1
|
+
import type { AuthRequest } from "@cloudflare/workers-oauth-provider";
|
|
2
|
+
import { OAuthError } from "@cloudflare/workers-oauth-provider";
|
|
3
|
+
import type {
|
|
4
|
+
AuthenticationResponseJSON,
|
|
5
|
+
AuthenticatorTransportFuture,
|
|
6
|
+
RegistrationResponseJSON,
|
|
7
|
+
VerifiedAuthenticationResponse,
|
|
8
|
+
VerifiedRegistrationResponse,
|
|
9
|
+
WebAuthnCredential
|
|
10
|
+
} from "@simplewebauthn/server";
|
|
11
|
+
import {
|
|
12
|
+
generateAuthenticationOptions,
|
|
13
|
+
generateRegistrationOptions,
|
|
14
|
+
verifyAuthenticationResponse,
|
|
15
|
+
verifyRegistrationResponse
|
|
16
|
+
} from "@simplewebauthn/server";
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { sha256 } from "../domain/crypto";
|
|
19
|
+
import { DomainError } from "../domain/errors";
|
|
20
|
+
import { isMemoryScope } from "../domain/guards";
|
|
21
|
+
import { MemoryService } from "../domain/memory-service";
|
|
22
|
+
import type { ActorContext, MemoryScope, OwnerContext } from "../domain/types";
|
|
23
|
+
import type { Env } from "../env";
|
|
24
|
+
import { PASSKEY_OWNER_ID, registrationToken } from "./passkey-management";
|
|
25
|
+
import { bindAuthorizationResource } from "./resource";
|
|
26
|
+
|
|
27
|
+
const PRINCIPAL_ID = PASSKEY_OWNER_ID;
|
|
28
|
+
const WORKSPACE_ID = "primary-workspace";
|
|
29
|
+
const SESSION_PREFIX = "web-session:";
|
|
30
|
+
const FLOW_TTL_SECONDS = 300;
|
|
31
|
+
const BASE64URL = /^[A-Za-z0-9_-]+$/;
|
|
32
|
+
const TRANSPORT_SCHEMA = z.enum(["ble", "cable", "hybrid", "internal", "nfc", "smart-card", "usb"]);
|
|
33
|
+
const ATTACHMENT_SCHEMA = z.enum(["cross-platform", "platform"]);
|
|
34
|
+
const AUTH_REQUEST_SCHEMA = z.object({
|
|
35
|
+
responseType: z.string(),
|
|
36
|
+
clientId: z.string(),
|
|
37
|
+
redirectUri: z.string(),
|
|
38
|
+
scope: z.array(z.string()),
|
|
39
|
+
state: z.string(),
|
|
40
|
+
codeChallenge: z.string().optional(),
|
|
41
|
+
codeChallengeMethod: z.string().optional(),
|
|
42
|
+
resource: z.union([z.string(), z.array(z.string())]).optional()
|
|
43
|
+
});
|
|
44
|
+
const REGISTRATION_RESPONSE_SCHEMA = z.object({
|
|
45
|
+
id: z.string().regex(BASE64URL),
|
|
46
|
+
rawId: z.string().regex(BASE64URL),
|
|
47
|
+
response: z.object({
|
|
48
|
+
clientDataJSON: z.string().regex(BASE64URL),
|
|
49
|
+
attestationObject: z.string().regex(BASE64URL),
|
|
50
|
+
authenticatorData: z.string().regex(BASE64URL).optional(),
|
|
51
|
+
transports: z.array(TRANSPORT_SCHEMA).optional(),
|
|
52
|
+
publicKeyAlgorithm: z.number().int().optional(),
|
|
53
|
+
publicKey: z.string().regex(BASE64URL).optional()
|
|
54
|
+
}),
|
|
55
|
+
authenticatorAttachment: ATTACHMENT_SCHEMA.optional(),
|
|
56
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
57
|
+
type: z.literal("public-key")
|
|
58
|
+
});
|
|
59
|
+
const AUTHENTICATION_RESPONSE_SCHEMA = z.object({
|
|
60
|
+
id: z.string().regex(BASE64URL),
|
|
61
|
+
rawId: z.string().regex(BASE64URL),
|
|
62
|
+
response: z.object({
|
|
63
|
+
clientDataJSON: z.string().regex(BASE64URL),
|
|
64
|
+
authenticatorData: z.string().regex(BASE64URL),
|
|
65
|
+
signature: z.string().regex(BASE64URL),
|
|
66
|
+
userHandle: z.string().regex(BASE64URL).optional()
|
|
67
|
+
}),
|
|
68
|
+
authenticatorAttachment: ATTACHMENT_SCHEMA.optional(),
|
|
69
|
+
clientExtensionResults: z.record(z.string(), z.unknown()),
|
|
70
|
+
type: z.literal("public-key")
|
|
71
|
+
});
|
|
72
|
+
const PASSKEY_LABEL_SCHEMA = z.string().trim().min(1).max(80);
|
|
73
|
+
const SETUP_REQUEST_SCHEMA = z.object({
|
|
74
|
+
token: z.string().min(32).max(512),
|
|
75
|
+
label: PASSKEY_LABEL_SCHEMA
|
|
76
|
+
});
|
|
77
|
+
const SETUP_VERIFY_SCHEMA = z.object({ flowId: z.uuid(), response: REGISTRATION_RESPONSE_SCHEMA });
|
|
78
|
+
const REGISTRATION_REQUEST_SCHEMA = z.object({ token: z.string().min(32).max(512) });
|
|
79
|
+
const AUTH_VERIFY_SCHEMA = z.object({ flowId: z.uuid(), response: AUTHENTICATION_RESPONSE_SCHEMA });
|
|
80
|
+
const TRANSPORTS_SCHEMA = z.array(TRANSPORT_SCHEMA);
|
|
81
|
+
const SETUP_PAYLOAD_SCHEMA = z.object({
|
|
82
|
+
mode: z.enum(["initial", "recovery"]),
|
|
83
|
+
label: PASSKEY_LABEL_SCHEMA
|
|
84
|
+
});
|
|
85
|
+
const REGISTRATION_PAYLOAD_SCHEMA = z.object({ label: PASSKEY_LABEL_SCHEMA });
|
|
86
|
+
const AUTH_FLOW_PAYLOAD_SCHEMA = z.object({
|
|
87
|
+
kind: z.enum(["mcp", "web"]),
|
|
88
|
+
options: z.unknown(),
|
|
89
|
+
auth: AUTH_REQUEST_SCHEMA.optional(),
|
|
90
|
+
clientName: z.string().optional(),
|
|
91
|
+
requestedScopes: z.array(z.enum(["memory:read", "memory:write", "memory:admin"])).optional()
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
interface CredentialRow {
|
|
95
|
+
credential_id: string;
|
|
96
|
+
label: string;
|
|
97
|
+
public_key: string;
|
|
98
|
+
counter: number;
|
|
99
|
+
transports_json: string;
|
|
100
|
+
device_type: "singleDevice" | "multiDevice";
|
|
101
|
+
backed_up: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
interface SessionRecord {
|
|
105
|
+
principalId: string;
|
|
106
|
+
workspaceId: string;
|
|
107
|
+
credentialId: string;
|
|
108
|
+
authenticatedAt: string;
|
|
109
|
+
createdAt: string;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface ChallengeRow {
|
|
113
|
+
challenge: string;
|
|
114
|
+
payload_json: string | null;
|
|
115
|
+
token_hash: string | null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface WebSessionSummary {
|
|
119
|
+
sessionRef: string;
|
|
120
|
+
authenticatedAt: string;
|
|
121
|
+
createdAt: string;
|
|
122
|
+
current: boolean;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type RelyingPartyEnv = Pick<Env, "APP_BASE_URL">;
|
|
126
|
+
type RegistrationEnv = Pick<Env, "DB" | "APP_BASE_URL" | "SETUP_TOKEN_HASH">;
|
|
127
|
+
type DatabaseEnv = Pick<Env, "DB">;
|
|
128
|
+
type RegistrationVerifier = (
|
|
129
|
+
options: Parameters<typeof verifyRegistrationResponse>[0]
|
|
130
|
+
) => Promise<VerifiedRegistrationResponse>;
|
|
131
|
+
type AuthenticationVerifier = (
|
|
132
|
+
options: Parameters<typeof verifyAuthenticationResponse>[0]
|
|
133
|
+
) => Promise<VerifiedAuthenticationResponse>;
|
|
134
|
+
type RecoveryStateRevoker = (env: Env) => Promise<void>;
|
|
135
|
+
|
|
136
|
+
async function revokeRecoveryState(env: Env): Promise<void> {
|
|
137
|
+
await revokeAllUserGrants(env);
|
|
138
|
+
await revokeAllProductionWebSessions(env);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function relyingParty(env: RelyingPartyEnv): { origin: string; rpID: string } {
|
|
142
|
+
if (env.APP_BASE_URL === undefined) throw new Error("APP_BASE_URL is required");
|
|
143
|
+
const url = new URL(env.APP_BASE_URL);
|
|
144
|
+
return { origin: url.origin, rpID: url.hostname };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function mcpResource(env: Env): string {
|
|
148
|
+
return new URL("/mcp", relyingParty(env).origin).toString();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function base64UrlEncode(bytes: Uint8Array): string {
|
|
152
|
+
let binary = "";
|
|
153
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
154
|
+
return btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function base64UrlDecode(value: string): Uint8Array<ArrayBuffer> {
|
|
158
|
+
if (!BASE64URL.test(value))
|
|
159
|
+
throw new DomainError("validation_failed", "Invalid passkey encoding");
|
|
160
|
+
const normalized = value.replaceAll("-", "+").replaceAll("_", "/");
|
|
161
|
+
const binary = atob(normalized + "=".repeat((4 - (normalized.length % 4)) % 4));
|
|
162
|
+
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function scopes(auth: AuthRequest): MemoryScope[] {
|
|
166
|
+
const requested = auth.scope.length === 0 ? ["memory:read", "memory:write"] : auth.scope;
|
|
167
|
+
if (!requested.every((scope): scope is MemoryScope => isMemoryScope(scope))) {
|
|
168
|
+
throw new OAuthError("invalid_scope", { description: "Unsupported Wikimemory scope" });
|
|
169
|
+
}
|
|
170
|
+
return requested;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function actor(clientId = "wikimemory-web"): ActorContext {
|
|
174
|
+
return {
|
|
175
|
+
workspaceId: WORKSPACE_ID,
|
|
176
|
+
principalId: PRINCIPAL_ID,
|
|
177
|
+
clientId,
|
|
178
|
+
scopes: new Set(["memory:read", "memory:write", "memory:admin"]),
|
|
179
|
+
requestId: crypto.randomUUID()
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function ensureOwner(env: DatabaseEnv): Promise<void> {
|
|
184
|
+
const createdAt = new Date().toISOString().replace(/\.\d{3}Z$/u, "Z");
|
|
185
|
+
await env.DB.batch([
|
|
186
|
+
env.DB.prepare(`INSERT OR IGNORE INTO principals
|
|
187
|
+
(id, provider, provider_subject, email, email_verified, display_name, created_at)
|
|
188
|
+
VALUES (?, 'passkey', ?, NULL, 0, 'Wikimemory Owner', ?)`).bind(
|
|
189
|
+
PRINCIPAL_ID,
|
|
190
|
+
PRINCIPAL_ID,
|
|
191
|
+
createdAt
|
|
192
|
+
),
|
|
193
|
+
env.DB.prepare(
|
|
194
|
+
"INSERT OR IGNORE INTO workspaces(id, name, created_at) VALUES (?, 'Wikimemory', ?)"
|
|
195
|
+
).bind(WORKSPACE_ID, createdAt),
|
|
196
|
+
env.DB.prepare(`INSERT OR IGNORE INTO memberships(workspace_id, principal_id, role, created_at)
|
|
197
|
+
VALUES (?, ?, 'owner', ?)`).bind(WORKSPACE_ID, PRINCIPAL_ID, createdAt)
|
|
198
|
+
]);
|
|
199
|
+
const service = new MemoryService(env.DB);
|
|
200
|
+
const owner = actor("wikimemory-passkey-seed");
|
|
201
|
+
await service.ingest(owner, {
|
|
202
|
+
operationId: "seed-home-v1",
|
|
203
|
+
reason: "seed orientation",
|
|
204
|
+
slug: "home",
|
|
205
|
+
type: "system",
|
|
206
|
+
title: "Wikimemory home",
|
|
207
|
+
summary: "Standard orientation page.",
|
|
208
|
+
body: "# Wikimemory\n\nThe database is authoritative. See [[now]] for current focus."
|
|
209
|
+
});
|
|
210
|
+
await service.ingest(owner, {
|
|
211
|
+
operationId: "seed-now-v1",
|
|
212
|
+
reason: "seed current focus",
|
|
213
|
+
slug: "now",
|
|
214
|
+
type: "system",
|
|
215
|
+
title: "Now",
|
|
216
|
+
summary: "Current focus and active threads.",
|
|
217
|
+
body: "# Now\n\n_(No active work has been recorded yet.)_"
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function credentialRows(env: DatabaseEnv): Promise<CredentialRow[]> {
|
|
222
|
+
const result =
|
|
223
|
+
await env.DB.prepare(`SELECT credential_id, label, public_key, counter, transports_json, device_type, backed_up
|
|
224
|
+
FROM passkey_credentials WHERE principal_id = ? ORDER BY created_at`)
|
|
225
|
+
.bind(PRINCIPAL_ID)
|
|
226
|
+
.all<CredentialRow>();
|
|
227
|
+
return result.results;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function transports(value: string): AuthenticatorTransportFuture[] {
|
|
231
|
+
return TRANSPORTS_SCHEMA.parse(JSON.parse(value));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function expiry(): string {
|
|
235
|
+
return new Date(Date.now() + FLOW_TTL_SECONDS * 1000).toISOString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function passkeyUserId(): Uint8Array<ArrayBuffer> {
|
|
239
|
+
const encoded = new TextEncoder().encode(PRINCIPAL_ID);
|
|
240
|
+
const copied = new Uint8Array(new ArrayBuffer(encoded.byteLength));
|
|
241
|
+
copied.set(encoded);
|
|
242
|
+
return copied;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function consumeChallenge(
|
|
246
|
+
env: DatabaseEnv,
|
|
247
|
+
flowId: string,
|
|
248
|
+
kind: "setup" | "mcp" | "web" | "registration"
|
|
249
|
+
): Promise<ChallengeRow | null> {
|
|
250
|
+
return await env.DB.prepare(`DELETE FROM passkey_challenges
|
|
251
|
+
WHERE flow_id = ? AND kind = ? AND expires_at > ?
|
|
252
|
+
RETURNING challenge, payload_json, token_hash`)
|
|
253
|
+
.bind(flowId, kind, new Date().toISOString())
|
|
254
|
+
.first<ChallengeRow>();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function authRequest(value: z.infer<typeof AUTH_REQUEST_SCHEMA>): AuthRequest {
|
|
258
|
+
return {
|
|
259
|
+
responseType: value.responseType,
|
|
260
|
+
clientId: value.clientId,
|
|
261
|
+
redirectUri: value.redirectUri,
|
|
262
|
+
scope: value.scope,
|
|
263
|
+
state: value.state,
|
|
264
|
+
...(value.codeChallenge === undefined ? {} : { codeChallenge: value.codeChallenge }),
|
|
265
|
+
...(value.codeChallengeMethod === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { codeChallengeMethod: value.codeChallengeMethod }),
|
|
268
|
+
...(value.resource === undefined ? {} : { resource: value.resource })
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function registrationResponseJson(
|
|
273
|
+
value: z.infer<typeof REGISTRATION_RESPONSE_SCHEMA>
|
|
274
|
+
): RegistrationResponseJSON {
|
|
275
|
+
return {
|
|
276
|
+
id: value.id,
|
|
277
|
+
rawId: value.rawId,
|
|
278
|
+
type: value.type,
|
|
279
|
+
clientExtensionResults: value.clientExtensionResults,
|
|
280
|
+
...(value.authenticatorAttachment === undefined
|
|
281
|
+
? {}
|
|
282
|
+
: { authenticatorAttachment: value.authenticatorAttachment }),
|
|
283
|
+
response: {
|
|
284
|
+
clientDataJSON: value.response.clientDataJSON,
|
|
285
|
+
attestationObject: value.response.attestationObject,
|
|
286
|
+
...(value.response.authenticatorData === undefined
|
|
287
|
+
? {}
|
|
288
|
+
: { authenticatorData: value.response.authenticatorData }),
|
|
289
|
+
...(value.response.transports === undefined ? {} : { transports: value.response.transports }),
|
|
290
|
+
...(value.response.publicKeyAlgorithm === undefined
|
|
291
|
+
? {}
|
|
292
|
+
: { publicKeyAlgorithm: value.response.publicKeyAlgorithm }),
|
|
293
|
+
...(value.response.publicKey === undefined ? {} : { publicKey: value.response.publicKey })
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function authenticationResponseJson(
|
|
299
|
+
value: z.infer<typeof AUTHENTICATION_RESPONSE_SCHEMA>
|
|
300
|
+
): AuthenticationResponseJSON {
|
|
301
|
+
return {
|
|
302
|
+
id: value.id,
|
|
303
|
+
rawId: value.rawId,
|
|
304
|
+
type: value.type,
|
|
305
|
+
clientExtensionResults: value.clientExtensionResults,
|
|
306
|
+
...(value.authenticatorAttachment === undefined
|
|
307
|
+
? {}
|
|
308
|
+
: { authenticatorAttachment: value.authenticatorAttachment }),
|
|
309
|
+
response: {
|
|
310
|
+
clientDataJSON: value.response.clientDataJSON,
|
|
311
|
+
authenticatorData: value.response.authenticatorData,
|
|
312
|
+
signature: value.response.signature,
|
|
313
|
+
...(value.response.userHandle === undefined ? {} : { userHandle: value.response.userHandle })
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function setupOptions(request: Request, env: RegistrationEnv): Promise<Response> {
|
|
319
|
+
const body = SETUP_REQUEST_SCHEMA.parse(await request.json());
|
|
320
|
+
const tokenHash = await sha256(body.token);
|
|
321
|
+
if (env.SETUP_TOKEN_HASH === undefined || tokenHash !== env.SETUP_TOKEN_HASH) {
|
|
322
|
+
return Response.json({ error: "This setup link is invalid or expired." }, { status: 403 });
|
|
323
|
+
}
|
|
324
|
+
const used = await env.DB.prepare(
|
|
325
|
+
"SELECT 1 AS present FROM passkey_bootstrap WHERE used_token_hash = ?"
|
|
326
|
+
)
|
|
327
|
+
.bind(tokenHash)
|
|
328
|
+
.first<{ present: number }>();
|
|
329
|
+
if (used !== null)
|
|
330
|
+
return Response.json({ error: "This setup link has already been used." }, { status: 409 });
|
|
331
|
+
const existing = await credentialRows(env);
|
|
332
|
+
const { rpID } = relyingParty(env);
|
|
333
|
+
const options = await generateRegistrationOptions({
|
|
334
|
+
rpName: "Wikimemory",
|
|
335
|
+
rpID,
|
|
336
|
+
userName: "owner",
|
|
337
|
+
userID: passkeyUserId(),
|
|
338
|
+
userDisplayName: "Wikimemory Owner",
|
|
339
|
+
attestationType: "none",
|
|
340
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
|
|
341
|
+
supportedAlgorithmIDs: [-7, -257],
|
|
342
|
+
excludeCredentials: existing.map((row) => ({
|
|
343
|
+
id: row.credential_id,
|
|
344
|
+
transports: transports(row.transports_json)
|
|
345
|
+
}))
|
|
346
|
+
});
|
|
347
|
+
const flowId = crypto.randomUUID();
|
|
348
|
+
await env.DB.batch([
|
|
349
|
+
env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
|
|
350
|
+
new Date().toISOString()
|
|
351
|
+
),
|
|
352
|
+
env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, token_hash, expires_at)
|
|
353
|
+
VALUES (?, 'setup', ?, ?, ?, ?)`).bind(
|
|
354
|
+
flowId,
|
|
355
|
+
options.challenge,
|
|
356
|
+
JSON.stringify({ mode: existing.length === 0 ? "initial" : "recovery", label: body.label }),
|
|
357
|
+
tokenHash,
|
|
358
|
+
expiry()
|
|
359
|
+
)
|
|
360
|
+
]);
|
|
361
|
+
return Response.json({ flowId, options });
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export async function setupVerify(
|
|
365
|
+
request: Request,
|
|
366
|
+
env: Env,
|
|
367
|
+
verify: RegistrationVerifier = verifyRegistrationResponse,
|
|
368
|
+
revokeState: RecoveryStateRevoker = revokeRecoveryState
|
|
369
|
+
): Promise<Response> {
|
|
370
|
+
const body = SETUP_VERIFY_SCHEMA.parse(await request.json());
|
|
371
|
+
const flow = await consumeChallenge(env, body.flowId, "setup");
|
|
372
|
+
if (flow === null)
|
|
373
|
+
return Response.json(
|
|
374
|
+
{ error: "This setup attempt expired or was already used." },
|
|
375
|
+
{ status: 410 }
|
|
376
|
+
);
|
|
377
|
+
if (env.SETUP_TOKEN_HASH === undefined || flow.token_hash !== env.SETUP_TOKEN_HASH) {
|
|
378
|
+
return Response.json(
|
|
379
|
+
{ error: "This setup link expired after a configuration change." },
|
|
380
|
+
{ status: 403 }
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
const used = await env.DB.prepare(
|
|
384
|
+
"SELECT 1 AS present FROM passkey_bootstrap WHERE used_token_hash = ?"
|
|
385
|
+
)
|
|
386
|
+
.bind(flow.token_hash)
|
|
387
|
+
.first<{ present: number }>();
|
|
388
|
+
if (used !== null)
|
|
389
|
+
return Response.json({ error: "This setup link has already been used." }, { status: 409 });
|
|
390
|
+
const payload = SETUP_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json ?? "null"));
|
|
391
|
+
const { origin, rpID } = relyingParty(env);
|
|
392
|
+
const registrationResponse = registrationResponseJson(body.response);
|
|
393
|
+
const verification = await verify({
|
|
394
|
+
response: registrationResponse,
|
|
395
|
+
expectedChallenge: flow.challenge,
|
|
396
|
+
expectedOrigin: origin,
|
|
397
|
+
expectedRPID: rpID,
|
|
398
|
+
requireUserVerification: true,
|
|
399
|
+
supportedAlgorithmIDs: [-7, -257]
|
|
400
|
+
});
|
|
401
|
+
if (!verification.verified)
|
|
402
|
+
return Response.json({ error: "Passkey verification failed." }, { status: 403 });
|
|
403
|
+
await ensureOwner(env);
|
|
404
|
+
const now = new Date().toISOString();
|
|
405
|
+
const credential = verification.registrationInfo.credential;
|
|
406
|
+
try {
|
|
407
|
+
if (payload.mode === "recovery") {
|
|
408
|
+
await revokeState(env);
|
|
409
|
+
}
|
|
410
|
+
const insert = env.DB.prepare(`INSERT INTO passkey_credentials
|
|
411
|
+
(credential_id, principal_id, public_key, counter, transports_json, device_type, backed_up, created_at, label)
|
|
412
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).bind(
|
|
413
|
+
credential.id,
|
|
414
|
+
PRINCIPAL_ID,
|
|
415
|
+
base64UrlEncode(credential.publicKey),
|
|
416
|
+
credential.counter,
|
|
417
|
+
JSON.stringify(credential.transports ?? []),
|
|
418
|
+
verification.registrationInfo.credentialDeviceType,
|
|
419
|
+
verification.registrationInfo.credentialBackedUp ? 1 : 0,
|
|
420
|
+
now,
|
|
421
|
+
payload.label
|
|
422
|
+
);
|
|
423
|
+
await env.DB.batch(
|
|
424
|
+
payload.mode === "recovery"
|
|
425
|
+
? [
|
|
426
|
+
env.DB.prepare("DELETE FROM passkey_registration_tokens WHERE principal_id = ?").bind(
|
|
427
|
+
PRINCIPAL_ID
|
|
428
|
+
),
|
|
429
|
+
insert,
|
|
430
|
+
env.DB.prepare(
|
|
431
|
+
"DELETE FROM passkey_credentials WHERE principal_id = ? AND credential_id <> ?"
|
|
432
|
+
).bind(PRINCIPAL_ID, credential.id),
|
|
433
|
+
env.DB.prepare(
|
|
434
|
+
"INSERT INTO passkey_bootstrap(used_token_hash, completed_at) VALUES (?, ?)"
|
|
435
|
+
).bind(flow.token_hash, now)
|
|
436
|
+
]
|
|
437
|
+
: [
|
|
438
|
+
insert,
|
|
439
|
+
env.DB.prepare(
|
|
440
|
+
"INSERT INTO passkey_bootstrap(used_token_hash, completed_at) VALUES (?, ?)"
|
|
441
|
+
).bind(flow.token_hash, now)
|
|
442
|
+
]
|
|
443
|
+
);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (error instanceof Error && error.message.includes("passkey_bootstrap.used_token_hash")) {
|
|
446
|
+
return Response.json({ error: "This setup link has already been used." }, { status: 409 });
|
|
447
|
+
}
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
return Response.json({ ok: true, mode: payload.mode });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export async function registrationOptions(
|
|
454
|
+
request: Request,
|
|
455
|
+
env: RegistrationEnv
|
|
456
|
+
): Promise<Response> {
|
|
457
|
+
const body = REGISTRATION_REQUEST_SCHEMA.parse(await request.json());
|
|
458
|
+
const available = await registrationToken(env.DB, body.token);
|
|
459
|
+
if (available === null)
|
|
460
|
+
return Response.json(
|
|
461
|
+
{ error: "This passkey registration link is invalid or expired." },
|
|
462
|
+
{ status: 403 }
|
|
463
|
+
);
|
|
464
|
+
const existing = await credentialRows(env);
|
|
465
|
+
const { rpID } = relyingParty(env);
|
|
466
|
+
const options = await generateRegistrationOptions({
|
|
467
|
+
rpName: "Wikimemory",
|
|
468
|
+
rpID,
|
|
469
|
+
userName: "owner",
|
|
470
|
+
userID: passkeyUserId(),
|
|
471
|
+
userDisplayName: "Wikimemory Owner",
|
|
472
|
+
attestationType: "none",
|
|
473
|
+
authenticatorSelection: { residentKey: "preferred", userVerification: "required" },
|
|
474
|
+
supportedAlgorithmIDs: [-7, -257],
|
|
475
|
+
excludeCredentials: existing.map((row) => ({
|
|
476
|
+
id: row.credential_id,
|
|
477
|
+
transports: transports(row.transports_json)
|
|
478
|
+
}))
|
|
479
|
+
});
|
|
480
|
+
const flowId = crypto.randomUUID();
|
|
481
|
+
await env.DB.batch([
|
|
482
|
+
env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
|
|
483
|
+
new Date().toISOString()
|
|
484
|
+
),
|
|
485
|
+
env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, token_hash, expires_at)
|
|
486
|
+
VALUES (?, 'registration', ?, ?, ?, ?)`).bind(
|
|
487
|
+
flowId,
|
|
488
|
+
options.challenge,
|
|
489
|
+
JSON.stringify({ label: available.label }),
|
|
490
|
+
available.tokenHash,
|
|
491
|
+
expiry()
|
|
492
|
+
)
|
|
493
|
+
]);
|
|
494
|
+
return Response.json({ flowId, options, label: available.label });
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export async function registrationVerify(
|
|
498
|
+
request: Request,
|
|
499
|
+
env: RegistrationEnv,
|
|
500
|
+
verify: RegistrationVerifier = verifyRegistrationResponse
|
|
501
|
+
): Promise<Response> {
|
|
502
|
+
const body = SETUP_VERIFY_SCHEMA.parse(await request.json());
|
|
503
|
+
const flow = await consumeChallenge(env, body.flowId, "registration");
|
|
504
|
+
if (flow === null || flow.token_hash === null)
|
|
505
|
+
return Response.json(
|
|
506
|
+
{ error: "This registration attempt expired or was already used." },
|
|
507
|
+
{ status: 410 }
|
|
508
|
+
);
|
|
509
|
+
const payload = REGISTRATION_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json ?? "null"));
|
|
510
|
+
const token = await env.DB.prepare(`SELECT 1 AS present FROM passkey_registration_tokens t
|
|
511
|
+
JOIN passkey_credentials c
|
|
512
|
+
ON c.credential_id = t.authorizing_credential_id AND c.principal_id = t.principal_id
|
|
513
|
+
WHERE t.token_hash = ? AND t.principal_id = ? AND t.expires_at > ?`)
|
|
514
|
+
.bind(flow.token_hash, PRINCIPAL_ID, new Date().toISOString())
|
|
515
|
+
.first<{ present: number }>();
|
|
516
|
+
if (token === null)
|
|
517
|
+
return Response.json(
|
|
518
|
+
{ error: "This passkey registration link is invalid or expired." },
|
|
519
|
+
{ status: 403 }
|
|
520
|
+
);
|
|
521
|
+
const { origin, rpID } = relyingParty(env);
|
|
522
|
+
const verification = await verify({
|
|
523
|
+
response: registrationResponseJson(body.response),
|
|
524
|
+
expectedChallenge: flow.challenge,
|
|
525
|
+
expectedOrigin: origin,
|
|
526
|
+
expectedRPID: rpID,
|
|
527
|
+
requireUserVerification: true,
|
|
528
|
+
supportedAlgorithmIDs: [-7, -257]
|
|
529
|
+
});
|
|
530
|
+
if (!verification.verified)
|
|
531
|
+
return Response.json({ error: "Passkey verification failed." }, { status: 403 });
|
|
532
|
+
const credential = verification.registrationInfo.credential;
|
|
533
|
+
const now = new Date().toISOString();
|
|
534
|
+
const results = await env.DB.batch([
|
|
535
|
+
env.DB.prepare(`INSERT INTO passkey_credentials
|
|
536
|
+
(credential_id, principal_id, public_key, counter, transports_json, device_type, backed_up, created_at, label)
|
|
537
|
+
SELECT ?, ?, ?, ?, ?, ?, ?, ?, ? WHERE EXISTS (
|
|
538
|
+
SELECT 1 FROM passkey_registration_tokens t
|
|
539
|
+
JOIN passkey_credentials c
|
|
540
|
+
ON c.credential_id = t.authorizing_credential_id AND c.principal_id = t.principal_id
|
|
541
|
+
WHERE t.token_hash = ? AND t.principal_id = ? AND t.expires_at > ?
|
|
542
|
+
)`).bind(
|
|
543
|
+
credential.id,
|
|
544
|
+
PRINCIPAL_ID,
|
|
545
|
+
base64UrlEncode(credential.publicKey),
|
|
546
|
+
credential.counter,
|
|
547
|
+
JSON.stringify(credential.transports ?? []),
|
|
548
|
+
verification.registrationInfo.credentialDeviceType,
|
|
549
|
+
verification.registrationInfo.credentialBackedUp ? 1 : 0,
|
|
550
|
+
now,
|
|
551
|
+
payload.label,
|
|
552
|
+
flow.token_hash,
|
|
553
|
+
PRINCIPAL_ID,
|
|
554
|
+
now
|
|
555
|
+
),
|
|
556
|
+
env.DB.prepare(
|
|
557
|
+
"DELETE FROM passkey_registration_tokens WHERE token_hash = ? AND principal_id = ?"
|
|
558
|
+
).bind(flow.token_hash, PRINCIPAL_ID)
|
|
559
|
+
]);
|
|
560
|
+
if (results[0]?.meta.changes !== 1)
|
|
561
|
+
return Response.json({ error: "This passkey registration link expired." }, { status: 409 });
|
|
562
|
+
return Response.json({ ok: true, label: payload.label });
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
export async function beginPasskeyAuthorization(
|
|
566
|
+
request: Request,
|
|
567
|
+
env: Env,
|
|
568
|
+
kind: "mcp" | "web"
|
|
569
|
+
): Promise<Response> {
|
|
570
|
+
const parsedAuth =
|
|
571
|
+
kind === "mcp" ? await env.OAUTH_PROVIDER.parseAuthRequest(request) : undefined;
|
|
572
|
+
const auth =
|
|
573
|
+
parsedAuth === undefined ? undefined : bindAuthorizationResource(parsedAuth, mcpResource(env));
|
|
574
|
+
const requestedScopes = auth === undefined ? undefined : scopes(auth);
|
|
575
|
+
const client = auth === undefined ? null : await env.OAUTH_PROVIDER.lookupClient(auth.clientId);
|
|
576
|
+
const credentials = await credentialRows(env);
|
|
577
|
+
if (credentials.length === 0)
|
|
578
|
+
return new Response(
|
|
579
|
+
"Wikimemory has not been set up. Use the one-time setup URL from the installer.",
|
|
580
|
+
{ status: 503 }
|
|
581
|
+
);
|
|
582
|
+
const { rpID } = relyingParty(env);
|
|
583
|
+
const options = await generateAuthenticationOptions({
|
|
584
|
+
rpID,
|
|
585
|
+
userVerification: "required",
|
|
586
|
+
allowCredentials: credentials.map((row) => ({
|
|
587
|
+
id: row.credential_id,
|
|
588
|
+
transports: transports(row.transports_json)
|
|
589
|
+
}))
|
|
590
|
+
});
|
|
591
|
+
const flowId = crypto.randomUUID();
|
|
592
|
+
await env.DB.batch([
|
|
593
|
+
env.DB.prepare("DELETE FROM passkey_challenges WHERE expires_at <= ?").bind(
|
|
594
|
+
new Date().toISOString()
|
|
595
|
+
),
|
|
596
|
+
env.DB.prepare(`INSERT INTO passkey_challenges(flow_id, kind, challenge, payload_json, expires_at)
|
|
597
|
+
VALUES (?, ?, ?, ?, ?)`).bind(
|
|
598
|
+
flowId,
|
|
599
|
+
kind,
|
|
600
|
+
options.challenge,
|
|
601
|
+
JSON.stringify({
|
|
602
|
+
kind,
|
|
603
|
+
options,
|
|
604
|
+
...(auth === undefined ? {} : { auth }),
|
|
605
|
+
...(client?.clientName === undefined && client?.clientId === undefined
|
|
606
|
+
? {}
|
|
607
|
+
: { clientName: client.clientName ?? client.clientId }),
|
|
608
|
+
...(requestedScopes === undefined ? {} : { requestedScopes })
|
|
609
|
+
}),
|
|
610
|
+
expiry()
|
|
611
|
+
)
|
|
612
|
+
]);
|
|
613
|
+
return Response.redirect(
|
|
614
|
+
new URL(`/login?flowId=${encodeURIComponent(flowId)}`, relyingParty(env).origin).toString(),
|
|
615
|
+
302
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
export async function passkeyAuthorizationOptions(request: Request, env: Env): Promise<Response> {
|
|
620
|
+
const flowId = new URL(request.url).searchParams.get("flowId");
|
|
621
|
+
if (flowId === null || !z.uuid().safeParse(flowId).success)
|
|
622
|
+
throw new DomainError("validation_failed", "Invalid authentication flow");
|
|
623
|
+
const row = await env.DB.prepare(`SELECT payload_json FROM passkey_challenges
|
|
624
|
+
WHERE flow_id = ? AND kind IN ('mcp', 'web') AND expires_at > ?`)
|
|
625
|
+
.bind(flowId, new Date().toISOString())
|
|
626
|
+
.first<{ payload_json: string | null }>();
|
|
627
|
+
if (row?.payload_json === null || row === null)
|
|
628
|
+
return Response.json({ error: "This authentication attempt expired." }, { status: 410 });
|
|
629
|
+
const payload = AUTH_FLOW_PAYLOAD_SCHEMA.parse(JSON.parse(row.payload_json));
|
|
630
|
+
return Response.json({
|
|
631
|
+
flowId,
|
|
632
|
+
kind: payload.kind,
|
|
633
|
+
options: payload.options,
|
|
634
|
+
clientName: payload.clientName,
|
|
635
|
+
requestedScopes: payload.requestedScopes
|
|
636
|
+
});
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export async function verifyPasskeyAuthorization(
|
|
640
|
+
request: Request,
|
|
641
|
+
env: Env,
|
|
642
|
+
verify: AuthenticationVerifier = verifyAuthenticationResponse
|
|
643
|
+
): Promise<Response> {
|
|
644
|
+
const body = AUTH_VERIFY_SCHEMA.parse(await request.json());
|
|
645
|
+
const mcpFlow = await consumeChallenge(env, body.flowId, "mcp");
|
|
646
|
+
const flowKind = mcpFlow === null ? "web" : "mcp";
|
|
647
|
+
const flow = mcpFlow ?? (await consumeChallenge(env, body.flowId, "web"));
|
|
648
|
+
if (flow === null)
|
|
649
|
+
return Response.json(
|
|
650
|
+
{ error: "This authentication attempt expired or was already used." },
|
|
651
|
+
{ status: 410 }
|
|
652
|
+
);
|
|
653
|
+
const row =
|
|
654
|
+
await env.DB.prepare(`SELECT credential_id, label, public_key, counter, transports_json, device_type, backed_up
|
|
655
|
+
FROM passkey_credentials WHERE credential_id = ? AND principal_id = ?`)
|
|
656
|
+
.bind(body.response.id, PRINCIPAL_ID)
|
|
657
|
+
.first<CredentialRow>();
|
|
658
|
+
if (row === null) return Response.json({ error: "Unknown passkey." }, { status: 403 });
|
|
659
|
+
const credential: WebAuthnCredential = {
|
|
660
|
+
id: row.credential_id,
|
|
661
|
+
publicKey: base64UrlDecode(row.public_key),
|
|
662
|
+
counter: row.counter,
|
|
663
|
+
transports: transports(row.transports_json)
|
|
664
|
+
};
|
|
665
|
+
const { origin, rpID } = relyingParty(env);
|
|
666
|
+
const authenticationResponse = authenticationResponseJson(body.response);
|
|
667
|
+
const verification = await verify({
|
|
668
|
+
response: authenticationResponse,
|
|
669
|
+
expectedChallenge: flow.challenge,
|
|
670
|
+
expectedOrigin: origin,
|
|
671
|
+
expectedRPID: rpID,
|
|
672
|
+
credential,
|
|
673
|
+
requireUserVerification: true
|
|
674
|
+
});
|
|
675
|
+
if (!verification.verified || !verification.authenticationInfo.userVerified)
|
|
676
|
+
return Response.json({ error: "Passkey verification failed." }, { status: 403 });
|
|
677
|
+
const authenticatedAt = new Date().toISOString();
|
|
678
|
+
const updated =
|
|
679
|
+
await env.DB.prepare(`UPDATE passkey_credentials SET counter = ?, device_type = ?, backed_up = ?, last_used_at = ?
|
|
680
|
+
WHERE credential_id = ? AND counter = ?`)
|
|
681
|
+
.bind(
|
|
682
|
+
verification.authenticationInfo.newCounter,
|
|
683
|
+
verification.authenticationInfo.credentialDeviceType,
|
|
684
|
+
verification.authenticationInfo.credentialBackedUp ? 1 : 0,
|
|
685
|
+
authenticatedAt,
|
|
686
|
+
row.credential_id,
|
|
687
|
+
row.counter
|
|
688
|
+
)
|
|
689
|
+
.run();
|
|
690
|
+
if (updated.meta.changes !== 1)
|
|
691
|
+
return Response.json({ error: "This passkey assertion was already used." }, { status: 409 });
|
|
692
|
+
if (flowKind === "mcp") {
|
|
693
|
+
if (flow.payload_json === null)
|
|
694
|
+
throw new OAuthError("invalid_request", {
|
|
695
|
+
description: "MCP authorization state is missing"
|
|
696
|
+
});
|
|
697
|
+
const payload = AUTH_FLOW_PAYLOAD_SCHEMA.parse(JSON.parse(flow.payload_json));
|
|
698
|
+
if (payload.auth === undefined)
|
|
699
|
+
throw new OAuthError("invalid_request", {
|
|
700
|
+
description: "MCP authorization state is missing"
|
|
701
|
+
});
|
|
702
|
+
const auth = bindAuthorizationResource(authRequest(payload.auth), mcpResource(env));
|
|
703
|
+
const granted = scopes(auth);
|
|
704
|
+
const { redirectTo } = await env.OAUTH_PROVIDER.completeAuthorization({
|
|
705
|
+
request: auth,
|
|
706
|
+
userId: PRINCIPAL_ID,
|
|
707
|
+
metadata: { identity: "passkey" },
|
|
708
|
+
scope: granted,
|
|
709
|
+
props: {
|
|
710
|
+
workspaceId: WORKSPACE_ID,
|
|
711
|
+
principalId: PRINCIPAL_ID,
|
|
712
|
+
clientId: auth.clientId,
|
|
713
|
+
scopes: granted,
|
|
714
|
+
authenticatedAt,
|
|
715
|
+
credentialId: row.credential_id
|
|
716
|
+
}
|
|
717
|
+
});
|
|
718
|
+
return Response.json({ redirectTo });
|
|
719
|
+
}
|
|
720
|
+
const sessionId = crypto.randomUUID();
|
|
721
|
+
const session = {
|
|
722
|
+
principalId: PRINCIPAL_ID,
|
|
723
|
+
workspaceId: WORKSPACE_ID,
|
|
724
|
+
credentialId: row.credential_id,
|
|
725
|
+
authenticatedAt,
|
|
726
|
+
createdAt: authenticatedAt
|
|
727
|
+
} satisfies SessionRecord;
|
|
728
|
+
await env.OAUTH_KV.put(await sessionStorageKey(sessionId), JSON.stringify(session), {
|
|
729
|
+
expirationTtl: 86_400
|
|
730
|
+
});
|
|
731
|
+
return Response.json(
|
|
732
|
+
{ redirectTo: "/app" },
|
|
733
|
+
{
|
|
734
|
+
headers: {
|
|
735
|
+
"set-cookie": `wm_web_session=${sessionId}; HttpOnly; Secure; Path=/; SameSite=Lax; Max-Age=86400`
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
async function sessionStorageKey(sessionId: string): Promise<string> {
|
|
742
|
+
return `${SESSION_PREFIX}${await sha256(sessionId)}`;
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
function cookieValue(request: Request, name: string): string | null {
|
|
746
|
+
const cookie = (request.headers.get("cookie") ?? "")
|
|
747
|
+
.split(";")
|
|
748
|
+
.map((part) => part.trim())
|
|
749
|
+
.find((part) => part.startsWith(`${name}=`));
|
|
750
|
+
return cookie === undefined ? null : cookie.slice(name.length + 1);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
export async function productionWebOwner(request: Request, env: Env): Promise<OwnerContext | null> {
|
|
754
|
+
const sessionId = cookieValue(request, "wm_web_session");
|
|
755
|
+
if (sessionId === null) return null;
|
|
756
|
+
const raw = await env.OAUTH_KV.get(await sessionStorageKey(sessionId), "json");
|
|
757
|
+
const parsed = z
|
|
758
|
+
.object({
|
|
759
|
+
principalId: z.string(),
|
|
760
|
+
workspaceId: z.string(),
|
|
761
|
+
credentialId: z.string(),
|
|
762
|
+
authenticatedAt: z.string(),
|
|
763
|
+
createdAt: z.string()
|
|
764
|
+
})
|
|
765
|
+
.safeParse(raw);
|
|
766
|
+
if (
|
|
767
|
+
!parsed.success ||
|
|
768
|
+
parsed.data.principalId !== PRINCIPAL_ID ||
|
|
769
|
+
parsed.data.workspaceId !== WORKSPACE_ID
|
|
770
|
+
)
|
|
771
|
+
return null;
|
|
772
|
+
const credential = await env.DB.prepare(
|
|
773
|
+
"SELECT 1 AS present FROM passkey_credentials WHERE credential_id = ? AND principal_id = ?"
|
|
774
|
+
)
|
|
775
|
+
.bind(parsed.data.credentialId, PRINCIPAL_ID)
|
|
776
|
+
.first<{ present: number }>();
|
|
777
|
+
if (credential === null) return null;
|
|
778
|
+
return {
|
|
779
|
+
...actor(),
|
|
780
|
+
role: "owner",
|
|
781
|
+
reauthenticatedAt: parsed.data.authenticatedAt,
|
|
782
|
+
credentialId: parsed.data.credentialId
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
export async function endProductionWebSession(request: Request, env: Env): Promise<void> {
|
|
787
|
+
const sessionId = cookieValue(request, "wm_web_session");
|
|
788
|
+
if (sessionId !== null) await env.OAUTH_KV.delete(await sessionStorageKey(sessionId));
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
export async function listProductionWebSessions(
|
|
792
|
+
request: Request,
|
|
793
|
+
env: Env,
|
|
794
|
+
principalId: string
|
|
795
|
+
): Promise<WebSessionSummary[]> {
|
|
796
|
+
const currentId = cookieValue(request, "wm_web_session");
|
|
797
|
+
const currentKey = currentId === null ? null : await sessionStorageKey(currentId);
|
|
798
|
+
const keys = await env.OAUTH_KV.list({ prefix: SESSION_PREFIX, limit: 100 });
|
|
799
|
+
const sessions = await Promise.all(
|
|
800
|
+
keys.keys.map(async ({ name }) => {
|
|
801
|
+
const raw = await env.OAUTH_KV.get(name, "json");
|
|
802
|
+
const parsed = z
|
|
803
|
+
.object({
|
|
804
|
+
principalId: z.string(),
|
|
805
|
+
workspaceId: z.string(),
|
|
806
|
+
authenticatedAt: z.string(),
|
|
807
|
+
createdAt: z.string()
|
|
808
|
+
})
|
|
809
|
+
.safeParse(raw);
|
|
810
|
+
if (!parsed.success || parsed.data.principalId !== principalId) return null;
|
|
811
|
+
return {
|
|
812
|
+
sessionRef: name.slice(SESSION_PREFIX.length),
|
|
813
|
+
authenticatedAt: parsed.data.authenticatedAt,
|
|
814
|
+
createdAt: parsed.data.createdAt,
|
|
815
|
+
current: name === currentKey
|
|
816
|
+
} satisfies WebSessionSummary;
|
|
817
|
+
})
|
|
818
|
+
);
|
|
819
|
+
return sessions
|
|
820
|
+
.filter((session): session is WebSessionSummary => session !== null)
|
|
821
|
+
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function revokeAllProductionWebSessions(env: Pick<Env, "OAUTH_KV">): Promise<void> {
|
|
825
|
+
let cursor: string | undefined;
|
|
826
|
+
do {
|
|
827
|
+
const page = await env.OAUTH_KV.list({
|
|
828
|
+
prefix: SESSION_PREFIX,
|
|
829
|
+
limit: 100,
|
|
830
|
+
...(cursor === undefined ? {} : { cursor })
|
|
831
|
+
});
|
|
832
|
+
await Promise.all(page.keys.map(async ({ name }) => env.OAUTH_KV.delete(name)));
|
|
833
|
+
cursor = page.list_complete ? undefined : page.cursor;
|
|
834
|
+
} while (cursor !== undefined);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
export async function revokeProductionSessionsForCredential(
|
|
838
|
+
env: Env,
|
|
839
|
+
principalId: string,
|
|
840
|
+
credentialId: string
|
|
841
|
+
): Promise<void> {
|
|
842
|
+
let cursor: string | undefined;
|
|
843
|
+
do {
|
|
844
|
+
const page = await env.OAUTH_KV.list({
|
|
845
|
+
prefix: SESSION_PREFIX,
|
|
846
|
+
limit: 100,
|
|
847
|
+
...(cursor === undefined ? {} : { cursor })
|
|
848
|
+
});
|
|
849
|
+
await Promise.all(
|
|
850
|
+
page.keys.map(async ({ name }) => {
|
|
851
|
+
const raw = await env.OAUTH_KV.get(name, "json");
|
|
852
|
+
const parsed = z
|
|
853
|
+
.object({ principalId: z.string(), credentialId: z.string().optional() })
|
|
854
|
+
.safeParse(raw);
|
|
855
|
+
if (
|
|
856
|
+
parsed.success &&
|
|
857
|
+
parsed.data.principalId === principalId &&
|
|
858
|
+
(parsed.data.credentialId === credentialId || parsed.data.credentialId === undefined)
|
|
859
|
+
) {
|
|
860
|
+
await env.OAUTH_KV.delete(name);
|
|
861
|
+
}
|
|
862
|
+
})
|
|
863
|
+
);
|
|
864
|
+
cursor = page.list_complete ? undefined : page.cursor;
|
|
865
|
+
} while (cursor !== undefined);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
export async function revokeProductionSessionsForCredentialBestEffort(
|
|
869
|
+
env: Env,
|
|
870
|
+
principalId: string,
|
|
871
|
+
credentialId: string
|
|
872
|
+
): Promise<boolean> {
|
|
873
|
+
try {
|
|
874
|
+
await revokeProductionSessionsForCredential(env, principalId, credentialId);
|
|
875
|
+
return true;
|
|
876
|
+
} catch {
|
|
877
|
+
return false;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function revokeAllUserGrants(env: Pick<Env, "OAUTH_PROVIDER">): Promise<void> {
|
|
882
|
+
let cursor: string | undefined;
|
|
883
|
+
do {
|
|
884
|
+
const page = await env.OAUTH_PROVIDER.listUserGrants(PRINCIPAL_ID, {
|
|
885
|
+
limit: 100,
|
|
886
|
+
...(cursor === undefined ? {} : { cursor })
|
|
887
|
+
});
|
|
888
|
+
await Promise.all(
|
|
889
|
+
page.items.map(async (grant) => env.OAUTH_PROVIDER.revokeGrant(grant.id, PRINCIPAL_ID))
|
|
890
|
+
);
|
|
891
|
+
cursor = page.cursor;
|
|
892
|
+
} while (cursor !== undefined);
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export async function revokeProductionWebSession(
|
|
896
|
+
env: Env,
|
|
897
|
+
principalId: string,
|
|
898
|
+
sessionRef: string
|
|
899
|
+
): Promise<void> {
|
|
900
|
+
if (!/^[a-f0-9]{64}$/u.test(sessionRef))
|
|
901
|
+
throw new DomainError("validation_failed", "Invalid session reference");
|
|
902
|
+
const key = `${SESSION_PREFIX}${sessionRef}`;
|
|
903
|
+
const raw = await env.OAUTH_KV.get(key, "json");
|
|
904
|
+
const parsed = z.object({ principalId: z.string() }).safeParse(raw);
|
|
905
|
+
if (!parsed.success || parsed.data.principalId !== principalId)
|
|
906
|
+
throw new DomainError("not_found", "Browser session not found");
|
|
907
|
+
await env.OAUTH_KV.delete(key);
|
|
908
|
+
}
|