Passkey sign-in, Kumo design tokens, and Access only for recovery
People now sign in with passkeys (WebAuthn) and get a session cookie. Invite links let new people pick a username and create a passkey in one step. Site admins can issue one-time sign-in links for lost passkeys, and Cloudflare Access now guards only /auth/access as email recovery. Settings gains a Passkeys and sessions page. The web UI uses Kumo's color tokens, radii and focus styles. A new end-to-end test drives Chromium with a virtual authenticator through invites, adding and removing passkeys, signing out and in, and sign-in links. Team preferences are written down in docs/PREFERENCES.md. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AizKXSsengXZRegJxRATkL
18 files changed+1537−112
Files (18)
- .github/workflows/ci.yml+2−1
- README.md+34−18
- apps/diffinery/migrations/0002_passkeys.sql+46−0
- apps/diffinery/package.json+3−1
- apps/diffinery/src/auth/principal.ts+9−12
- apps/diffinery/src/auth/sessions.ts+209−0
- apps/diffinery/src/lib/directory.ts+3−3
- apps/diffinery/src/lib/util.ts+1−1
- apps/diffinery/src/web/account.tsx+156−13
- apps/diffinery/src/web/assets.ts+59−23
- apps/diffinery/src/web/auth.tsx+424−0
- apps/diffinery/src/web/repo-settings.tsx+1−1
- apps/diffinery/src/web/routes.tsx+4−2
- apps/diffinery/src/web/ui.tsx+28−6
- apps/diffinery/test/e2e/passkeys.test.mjs+236−0
- docs/PREFERENCES.md+24−0
- pnpm-lock.yaml+276−2
- scripts/bootstrap.sh+22−29
modified.github/workflows/ci.yml+2−1View file
| @@ -36,7 +36,7 @@ | ||
| 36 | 36 | run: pnpm exec wrangler deploy --dry-run --env dev --outdir dist |
| 37 | 37 | |
| 38 | 38 | e2e: |
| 39 | - name: End-to-end (git, PRs, Actions) | |
| 39 | + name: End-to-end (git, PRs, Actions, passkeys) | |
| 40 | 40 | runs-on: ubuntu-latest |
| 41 | 41 | timeout-minutes: 20 |
| 42 | 42 | steps: |
| @@ -48,4 +48,5 @@ | ||
| 48 | 48 | cache: pnpm |
| 49 | 49 | - run: pnpm install --frozen-lockfile |
| 50 | 50 | - run: pnpm -r --if-present build |
| 51 | + - run: pnpm --filter @diffinery/app exec playwright install --with-deps chromium | |
| 51 | 52 | - run: pnpm --filter @diffinery/app e2e |
modifiedREADME.md+34−18View file
| @@ -13,8 +13,8 @@ | ||
| 13 | 13 | branches, and their pull requests always need a human approval. Every commit |
| 14 | 14 | they make is stamped with who they act for. They work through MCP |
| 15 | 15 | (`/mcp`) or the REST API, and can start sandboxes to build and test. |
| 16 | -- **Secure by default.** Cloudflare Access handles sign-in, while invites, | |
| 17 | - roles and branch protection decide what people can do. Pushes are scanned for | |
| 16 | +- **Secure by default.** People sign in with passkeys, with no passwords to | |
| 17 | + leak or phish. Invites, roles and branch protection decide what they can do. Pushes are scanned for | |
| 18 | 18 | leaked credentials. Secrets use envelope encryption, and every sensitive |
| 19 | 19 | action goes to an append-only audit log. |
| 20 | 20 | |
| @@ -31,7 +31,7 @@ | ||
| 31 | 31 | | Job runners and agent sandboxes | Containers (Ubuntu 24.04 image in `containers/runner`) | |
| 32 | 32 | | Scheduled workflows (`on: schedule`) | Cron Triggers | |
| 33 | 33 | | Live run logs and PR updates | Durable Object WebSockets (hibernation) | |
| 34 | -| Sign-in | Cloudflare Access (GitHub, Google, email one-time PIN, etc.) | | |
| 34 | +| Sign-in | Passkeys (WebAuthn) and sessions in D1. Cloudflare Access guards only `/auth/access`, the email recovery path | | |
| 35 | 35 | |
| 36 | 36 | ``` |
| 37 | 37 | apps/diffinery the Worker (src/index.ts), D1 migrations, wrangler config, e2e test |
| @@ -54,9 +54,8 @@ | ||
| 54 | 54 | - `CLOUDFLARE_ACCOUNT_ID` |
| 55 | 55 | |
| 56 | 56 | On each deploy, `scripts/bootstrap.sh` creates anything that is missing: the D1 |
| 57 | -database, R2 bucket, queues and the two Access applications (sign-in for the | |
| 58 | -site, and a bypass for the git, API, MCP and runner paths, which check | |
| 59 | -Diffinery tokens themselves). It then applies D1 migrations. `wrangler deploy` | |
| 57 | +database, R2 bucket, queues and one Access application that covers only | |
| 58 | +`/auth/access` (email-based account recovery). It then applies D1 migrations. `wrangler deploy` | |
| 60 | 59 | builds and pushes the runner image. On the first deploy, `ensure-secrets.sh` |
| 61 | 60 | generates `MASTER_KEY` and the OIDC signing key inside the job, so neither |
| 62 | 61 | value is ever printed. |
| @@ -64,21 +63,32 @@ | ||
| 64 | 63 | If the token can't read your Zero Trust organization, set a repository |
| 65 | 64 | **variable** `ACCESS_TEAM_DOMAIN` (for example `codician.cloudflareaccess.com`). |
| 66 | 65 | |
| 67 | -To let people sign in with GitHub, add GitHub as a login method under Zero | |
| 68 | -Trust → Settings → Authentication. Email one-time PIN works without any setup. | |
| 66 | +Email recovery uses Access's email one-time PIN, which works without any setup. | |
| 67 | +You can also add GitHub as a login method under Zero Trust → Settings → Authentication. | |
| 69 | 68 | |
| 70 | -## Inviting people | |
| 69 | +## Signing in and inviting people | |
| 71 | 70 | |
| 72 | -1. The emails in `BOOTSTRAP_ADMINS` (in `apps/diffinery/wrangler.jsonc`) | |
| 73 | - become site admins the first time they sign in. | |
| 74 | -2. Anyone else who signs in lands on a waiting page. They can request access, | |
| 75 | - which admins approve, or an admin or org owner can send an invite from | |
| 76 | - *Settings → People* or with `diffinery invite friend@example.com --org codician`. | |
| 77 | -3. Repository and org roles (read, triage, write, maintain, admin) decide what | |
| 71 | +Everyone signs in with a passkey. There are no passwords. | |
| 72 | + | |
| 73 | +1. **First sign-in for admins.** The emails in `BOOTSTRAP_ADMINS` (in | |
| 74 | + `apps/diffinery/wrangler.jsonc`) become site admins. Open | |
| 75 | + `https://diffinery.com/auth/access`, confirm your email with the one-time | |
| 76 | + code, and add a passkey on the page that follows. | |
| 77 | +2. **Inviting someone.** An admin or org owner creates an invite from | |
| 78 | + *Settings → Site administration*, an organization's *People* tab, or | |
| 79 | + `diffinery invite friend@example.com --org codician`. The person opens the | |
| 80 | + link, picks a username and creates a passkey, and they're in. | |
| 81 | +3. **Lost passkey.** A site admin clicks *Sign-in link* next to the person in | |
| 82 | + *Settings → Site administration* and sends them the one-time link, which | |
| 83 | + works for 24 hours. People can also recover through `/auth/access` by email. | |
| 84 | +4. **More devices.** Add passkeys under *Settings → Passkeys and sessions*, or | |
| 85 | + keep one in a password manager that syncs. The same page signs out other | |
| 86 | + browsers. | |
| 87 | +5. Repository and org roles (read, triage, write, maintain, admin) decide what | |
| 78 | 88 | each person can do. |
| 79 | 89 | |
| 80 | -Nobody needs a Cloudflare account. They need an Access login and, for git and | |
| 81 | -the CLI, a personal token from *Settings → Tokens*. | |
| 90 | +Nobody needs a Cloudflare account. Git and the CLI use a personal token from | |
| 91 | +*Settings → Tokens*. | |
| 82 | 92 | |
| 83 | 93 | ```sh |
| 84 | 94 | git clone https://diffinery.com/codician/website.git # username: anything, password: your dfy_ token |
| @@ -143,7 +153,7 @@ | ||
| 143 | 153 | pnpm -r build && pnpm -r typecheck && pnpm -r test |
| 144 | 154 | pnpm --filter @diffinery/app db:migrate:local |
| 145 | 155 | pnpm dev # http://localhost:8787, signed in as dev@diffinery.local |
| 146 | -pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent | |
| 156 | +pnpm --filter @diffinery/app e2e # wrangler dev + real git + the runner agent + Chromium | |
| 147 | 157 | ``` |
| 148 | 158 | |
| 149 | 159 | The end-to-end test does the following: |
| @@ -155,3 +165,9 @@ | ||
| 155 | 165 | check, then merges it. |
| 156 | 166 | 5. Clones shallow, then deepens the clone. |
| 157 | 167 | 6. Drives the MCP server. |
| 168 | +7. In Chromium with a virtual authenticator, joins from an invite with a new | |
| 169 | + passkey, adds and removes passkeys, signs out and in, and uses an admin | |
| 170 | + sign-in link. | |
| 171 | + | |
| 172 | +Team preferences for product and design decisions are in | |
| 173 | +[docs/PREFERENCES.md](docs/PREFERENCES.md). |
addedapps/diffinery/migrations/0002_passkeys.sql+46−0View file
| @@ -1,0 +1,46 @@ | ||
| 1 | +-- Passkey (WebAuthn) sign-in, browser sessions and one-time sign-in links. | |
| 2 | + | |
| 3 | +CREATE TABLE passkeys ( | |
| 4 | + id TEXT PRIMARY KEY, -- credential id, base64url | |
| 5 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 6 | + public_key TEXT NOT NULL, -- COSE public key, base64url | |
| 7 | + counter INTEGER NOT NULL DEFAULT 0, | |
| 8 | + transports TEXT, -- JSON array | |
| 9 | + device_type TEXT NOT NULL, -- singleDevice | multiDevice | |
| 10 | + backed_up INTEGER NOT NULL DEFAULT 0, | |
| 11 | + name TEXT NOT NULL, | |
| 12 | + created_at TEXT NOT NULL, | |
| 13 | + last_used_at TEXT | |
| 14 | +); | |
| 15 | +CREATE INDEX passkeys_account ON passkeys(account_id); | |
| 16 | + | |
| 17 | +CREATE TABLE sessions ( | |
| 18 | + id TEXT PRIMARY KEY, -- sha256 of the cookie value | |
| 19 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 20 | + method TEXT NOT NULL, -- passkey | access | link | |
| 21 | + passkey_id TEXT, | |
| 22 | + user_agent TEXT, | |
| 23 | + created_at TEXT NOT NULL, | |
| 24 | + last_seen_at TEXT NOT NULL, | |
| 25 | + expires_at TEXT NOT NULL | |
| 26 | +); | |
| 27 | +CREATE INDEX sessions_account ON sessions(account_id); | |
| 28 | + | |
| 29 | +CREATE TABLE webauthn_challenges ( | |
| 30 | + id TEXT PRIMARY KEY, -- sha256 of the browser cookie value | |
| 31 | + purpose TEXT NOT NULL CHECK (purpose IN ('login', 'register')), | |
| 32 | + challenge TEXT NOT NULL, | |
| 33 | + subject TEXT NOT NULL, -- JSON: who the passkey is for | |
| 34 | + expires_at TEXT NOT NULL | |
| 35 | +); | |
| 36 | + | |
| 37 | +-- Admin-issued, single-use links that let someone add a passkey to an existing account. | |
| 38 | +CREATE TABLE signin_links ( | |
| 39 | + id TEXT PRIMARY KEY, | |
| 40 | + token_hash TEXT NOT NULL UNIQUE, | |
| 41 | + account_id TEXT NOT NULL REFERENCES accounts(id), | |
| 42 | + created_by TEXT NOT NULL REFERENCES accounts(id), | |
| 43 | + created_at TEXT NOT NULL, | |
| 44 | + expires_at TEXT NOT NULL, | |
| 45 | + used_at TEXT | |
| 46 | +); |
modifiedapps/diffinery/package.json+3−1View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | "typecheck": "tsc --noEmit", |
| 10 | 10 | "test": "vitest run --passWithNoTests --exclude 'test/e2e/**'", |
| 11 | 11 | "cf-typegen": "wrangler types", |
| 12 | - "e2e": "node --test --test-timeout=600000 test/e2e/e2e.test.mjs", | |
| 12 | + "e2e": "node --test --test-concurrency=1 --test-timeout=600000 test/e2e/e2e.test.mjs test/e2e/passkeys.test.mjs", | |
| 13 | 13 | "db:migrate:local": "wrangler d1 migrations apply diffinery-dev --local -e dev" |
| 14 | 14 | }, |
| 15 | 15 | "dependencies": { |
| @@ -17,11 +17,13 @@ | ||
| 17 | 17 | "@diffinery/git-core": "workspace:*", |
| 18 | 18 | "@diffinery/shared": "workspace:*", |
| 19 | 19 | "@diffinery/workflow-yaml": "workspace:*", |
| 20 | + "@simplewebauthn/server": "14.0.3", | |
| 20 | 21 | "hono": "^4.13.0" |
| 21 | 22 | }, |
| 22 | 23 | "devDependencies": { |
| 23 | 24 | "@cloudflare/vitest-pool-workers": "^0.23.0", |
| 24 | 25 | "@cloudflare/workers-types": "^5.20261012.1", |
| 26 | + "playwright": "1.56.1", | |
| 25 | 27 | "typescript": "^5.9.3", |
| 26 | 28 | "vitest": "^4.1.0", |
| 27 | 29 | "wrangler": "^4.149.0" |
modifiedapps/diffinery/src/auth/principal.ts+9−12View file
| @@ -1,13 +1,13 @@ | ||
| 1 | 1 | import type { Context } from "hono"; |
| 2 | 2 | import type { Env } from "../env.js"; |
| 3 | 3 | import { Directory, type Account, type Repo, type TokenScope } from "../lib/directory.js"; |
| 4 | -import { verifyAccessJwt } from "../lib/jwt.js"; | |
| 4 | +import { Auth, SESSION_COOKIE, cookieFrom } from "./sessions.js"; | |
| 5 | 5 | import { HttpError, fromB64 } from "../lib/util.js"; |
| 6 | 6 | import type { Caller, Role } from "../repo/types.js"; |
| 7 | 7 | import { ROLE_LEVEL } from "../repo/types.js"; |
| 8 | 8 | |
| 9 | 9 | export type Principal = |
| 10 | - | { type: "account"; account: Account; via: "access" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null } | |
| 10 | + | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string } | |
| 11 | 11 | | { type: "repo-token"; repoId: string; token: string } |
| 12 | 12 | | { type: "anonymous" }; |
| 13 | 13 | |
| @@ -35,7 +35,8 @@ | ||
| 35 | 35 | return null; |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | -function accessToken(req: Request): string | null { | |
| 38 | +/** The Cloudflare Access assertion, present only on the Access-protected recovery path. */ | |
| 39 | +export function accessToken(req: Request): string | null { | |
| 39 | 40 | const h = req.headers.get("cf-access-jwt-assertion"); |
| 40 | 41 | if (h) return h; |
| 41 | 42 | const cookie = req.headers.get("cookie") ?? ""; |
| @@ -58,15 +59,11 @@ | ||
| 58 | 59 | } |
| 59 | 60 | throw new HttpError(401, "unrecognised token; create one under Settings → Tokens"); |
| 60 | 61 | } |
| 61 | - const jwt = accessToken(req); | |
| 62 | - if (jwt && env.ACCESS_TEAM_DOMAIN && env.ACCESS_AUD) { | |
| 63 | - const claims = await verifyAccessJwt(jwt, env.ACCESS_TEAM_DOMAIN, env.ACCESS_AUD).catch((e) => { | |
| 64 | - throw new HttpError(401, `Access token rejected: ${(e as Error).message}`); | |
| 65 | - }); | |
| 66 | - const email = typeof claims.email === "string" ? claims.email : null; | |
| 67 | - if (!email) throw new HttpError(401, "Access token has no email; service tokens must use a Diffinery token"); | |
| 68 | - const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined }); | |
| 69 | - return { type: "account", account, via: "access", scopes: ["admin"], repoIds: null }; | |
| 62 | + // Browsers sign in with a passkey (or the Access recovery path) and carry a session cookie. | |
| 63 | + const session = cookieFrom(req, SESSION_COOKIE); | |
| 64 | + if (session) { | |
| 65 | + const s = await new Auth(env).session(session); | |
| 66 | + if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id }; | |
| 70 | 67 | } |
| 71 | 68 | if (env.DEV_AUTH && !env.ACCESS_AUD) { |
| 72 | 69 | const account = await dir.userForEmail(env.DEV_AUTH); |
addedapps/diffinery/src/auth/sessions.ts+209−0View file
| @@ -1,0 +1,209 @@ | ||
| 1 | +import type { Env } from "../env.js"; | |
| 2 | +import type { Account } from "../lib/directory.js"; | |
| 3 | +import { newId, now, randomToken, sha256 } from "../lib/util.js"; | |
| 4 | + | |
| 5 | +/** Browser session cookie. `__Host-` pins it to this origin: Secure, Path=/, no Domain. */ | |
| 6 | +export const SESSION_COOKIE = "__Host-dfy_session"; | |
| 7 | +/** Binds an in-flight WebAuthn ceremony to the browser that started it. */ | |
| 8 | +export const CHALLENGE_COOKIE = "__Host-dfy_webauthn"; | |
| 9 | +export const SESSION_DAYS = 30; | |
| 10 | +const CHALLENGE_MINUTES = 5; | |
| 11 | +const LINK_HOURS = 24; | |
| 12 | + | |
| 13 | +export type PasskeyRow = { | |
| 14 | + id: string; | |
| 15 | + account_id: string; | |
| 16 | + public_key: string; | |
| 17 | + counter: number; | |
| 18 | + transports: string | null; | |
| 19 | + device_type: string; | |
| 20 | + backed_up: number; | |
| 21 | + name: string; | |
| 22 | + created_at: string; | |
| 23 | + last_used_at: string | null; | |
| 24 | +}; | |
| 25 | + | |
| 26 | +export type SessionRow = { | |
| 27 | + id: string; | |
| 28 | + account_id: string; | |
| 29 | + method: string; | |
| 30 | + passkey_id: string | null; | |
| 31 | + user_agent: string | null; | |
| 32 | + created_at: string; | |
| 33 | + last_seen_at: string; | |
| 34 | + expires_at: string; | |
| 35 | +}; | |
| 36 | + | |
| 37 | +export type InviteRow = { id: string; email: string | null; org_id: string | null; repo_id: string | null; role: string }; | |
| 38 | + | |
| 39 | +/** Who a passkey being registered will belong to. */ | |
| 40 | +export type Subject = | |
| 41 | + | { kind: "account"; accountId: string } | |
| 42 | + | { kind: "invite"; inviteHash: string; email: string; login: string; name: string; accountId: string } | |
| 43 | + | { kind: "link"; linkHash: string; accountId: string } | |
| 44 | + | { kind: "login" }; | |
| 45 | + | |
| 46 | +const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString(); | |
| 47 | + | |
| 48 | +/** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */ | |
| 49 | +export class Auth { | |
| 50 | + constructor(private env: Env) {} | |
| 51 | + | |
| 52 | + private get db() { | |
| 53 | + return this.env.DB; | |
| 54 | + } | |
| 55 | + | |
| 56 | + /** Relying party: passkeys are bound to the hostname of PUBLIC_URL. */ | |
| 57 | + rp(): { id: string; origin: string; name: string } { | |
| 58 | + const u = new URL(this.env.PUBLIC_URL); | |
| 59 | + return { id: u.hostname, origin: u.origin, name: "Diffinery" }; | |
| 60 | + } | |
| 61 | + | |
| 62 | + // ---------------------------------------------------------------- sessions | |
| 63 | + | |
| 64 | + async createSession(accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null, userAgent: string | null): Promise<string> { | |
| 65 | + const token = randomToken(32); | |
| 66 | + const at = now(); | |
| 67 | + await this.db | |
| 68 | + .prepare("INSERT INTO sessions (id, account_id, method, passkey_id, user_agent, created_at, last_seen_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)") | |
| 69 | + .bind(await sha256(token), accountId, method, passkeyId, userAgent?.slice(0, 300) ?? null, at, at, days(SESSION_DAYS)) | |
| 70 | + .run(); | |
| 71 | + return token; | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days. */ | |
| 75 | + async session(token: string): Promise<{ session: SessionRow; account: Account } | null> { | |
| 76 | + if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; | |
| 77 | + const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>(); | |
| 78 | + if (!row || row.expires_at < now()) return null; | |
| 79 | + const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); | |
| 80 | + if (!account || account.kind !== "user") return null; | |
| 81 | + if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { | |
| 82 | + await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run(); | |
| 83 | + } | |
| 84 | + return { session: row, account }; | |
| 85 | + } | |
| 86 | + | |
| 87 | + async endSession(token: string): Promise<void> { | |
| 88 | + await this.db.prepare("DELETE FROM sessions WHERE id = ?").bind(await sha256(token)).run(); | |
| 89 | + } | |
| 90 | + | |
| 91 | + async sessions(accountId: string): Promise<SessionRow[]> { | |
| 92 | + const r = await this.db.prepare("SELECT * FROM sessions WHERE account_id = ? AND expires_at > ? ORDER BY last_seen_at DESC LIMIT 100").bind(accountId, now()).all<SessionRow>(); | |
| 93 | + return r.results; | |
| 94 | + } | |
| 95 | + | |
| 96 | + async endOtherSessions(accountId: string, keepId: string | null): Promise<void> { | |
| 97 | + await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run(); | |
| 98 | + } | |
| 99 | + | |
| 100 | + async endAllSessions(accountId: string): Promise<void> { | |
| 101 | + await this.db.prepare("DELETE FROM sessions WHERE account_id = ?").bind(accountId).run(); | |
| 102 | + } | |
| 103 | + | |
| 104 | + // ---------------------------------------------------------------- passkeys | |
| 105 | + | |
| 106 | + async passkeys(accountId: string): Promise<PasskeyRow[]> { | |
| 107 | + const r = await this.db.prepare("SELECT * FROM passkeys WHERE account_id = ? ORDER BY created_at").bind(accountId).all<PasskeyRow>(); | |
| 108 | + return r.results; | |
| 109 | + } | |
| 110 | + | |
| 111 | + async passkey(id: string): Promise<PasskeyRow | null> { | |
| 112 | + return this.db.prepare("SELECT * FROM passkeys WHERE id = ?").bind(id).first<PasskeyRow>(); | |
| 113 | + } | |
| 114 | + | |
| 115 | + async addPasskey(p: Omit<PasskeyRow, "created_at" | "last_used_at">): Promise<void> { | |
| 116 | + await this.db | |
| 117 | + .prepare("INSERT INTO passkeys (id, account_id, public_key, counter, transports, device_type, backed_up, name, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)") | |
| 118 | + .bind(p.id, p.account_id, p.public_key, p.counter, p.transports, p.device_type, p.backed_up, p.name.slice(0, 100), now()) | |
| 119 | + .run(); | |
| 120 | + } | |
| 121 | + | |
| 122 | + async passkeyUsed(id: string, counter: number, backedUp: boolean): Promise<void> { | |
| 123 | + await this.db.prepare("UPDATE passkeys SET counter = ?, backed_up = ?, last_used_at = ? WHERE id = ?").bind(counter, backedUp ? 1 : 0, now(), id).run(); | |
| 124 | + } | |
| 125 | + | |
| 126 | + async renamePasskey(accountId: string, id: string, name: string): Promise<void> { | |
| 127 | + await this.db.prepare("UPDATE passkeys SET name = ? WHERE id = ? AND account_id = ?").bind(name.slice(0, 100), id, accountId).run(); | |
| 128 | + } | |
| 129 | + | |
| 130 | + /** Remove a passkey and every other session it signed in (a lost device's browsers). */ | |
| 131 | + async deletePasskey(accountId: string, id: string, keepSessionId: string | null): Promise<void> { | |
| 132 | + await this.db.batch([ | |
| 133 | + this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND passkey_id = ? AND id != ?").bind(accountId, id, keepSessionId ?? ""), | |
| 134 | + this.db.prepare("DELETE FROM passkeys WHERE id = ? AND account_id = ?").bind(id, accountId), | |
| 135 | + ]); | |
| 136 | + } | |
| 137 | + | |
| 138 | + // ---------------------------------------------------------------- WebAuthn challenges | |
| 139 | + | |
| 140 | + /** Store a challenge; returns the value for the browser's challenge cookie. */ | |
| 141 | + async saveChallenge(purpose: "login" | "register", challenge: string, subject: Subject): Promise<string> { | |
| 142 | + const token = randomToken(24); | |
| 143 | + const expires = new Date(Date.now() + CHALLENGE_MINUTES * 60_000).toISOString(); | |
| 144 | + await this.db.batch([ | |
| 145 | + this.db.prepare("DELETE FROM webauthn_challenges WHERE expires_at < ?").bind(now()), | |
| 146 | + this.db.prepare("INSERT INTO webauthn_challenges (id, purpose, challenge, subject, expires_at) VALUES (?, ?, ?, ?, ?)").bind(await sha256(token), purpose, challenge, JSON.stringify(subject), expires), | |
| 147 | + ]); | |
| 148 | + return token; | |
| 149 | + } | |
| 150 | + | |
| 151 | + /** Consume a challenge exactly once. */ | |
| 152 | + async takeChallenge(token: string | undefined, purpose: "login" | "register"): Promise<{ challenge: string; subject: Subject } | null> { | |
| 153 | + if (!token) return null; | |
| 154 | + const row = await this.db | |
| 155 | + .prepare("DELETE FROM webauthn_challenges WHERE id = ? RETURNING purpose, challenge, subject, expires_at") | |
| 156 | + .bind(await sha256(token)) | |
| 157 | + .first<{ purpose: string; challenge: string; subject: string; expires_at: string }>(); | |
| 158 | + if (!row || row.purpose !== purpose || row.expires_at < now()) return null; | |
| 159 | + return { challenge: row.challenge, subject: JSON.parse(row.subject) as Subject }; | |
| 160 | + } | |
| 161 | + | |
| 162 | + // ---------------------------------------------------------------- invites and sign-in links | |
| 163 | + | |
| 164 | + /** An open invite by its secret token (null when used, expired or unknown). */ | |
| 165 | + async invite(token: string): Promise<(InviteRow & { hash: string }) | null> { | |
| 166 | + const hash = await sha256(token); | |
| 167 | + const row = await this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); | |
| 168 | + return row ? { ...row, hash } : null; | |
| 169 | + } | |
| 170 | + | |
| 171 | + async inviteByHash(hash: string): Promise<InviteRow | null> { | |
| 172 | + return this.db.prepare("SELECT id, email, org_id, repo_id, role FROM invites WHERE token_hash = ? AND accepted_at IS NULL AND expires_at > ?").bind(hash, now()).first<InviteRow>(); | |
| 173 | + } | |
| 174 | + | |
| 175 | + /** A single-use link that lets the holder add a passkey to `account` (account recovery, new devices). */ | |
| 176 | + async createSigninLink(by: Account, account: Account): Promise<{ url: string; expiresAt: string }> { | |
| 177 | + const token = randomToken(32); | |
| 178 | + const expiresAt = new Date(Date.now() + LINK_HOURS * 3600_000).toISOString(); | |
| 179 | + await this.db | |
| 180 | + .prepare("INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?)") | |
| 181 | + .bind(newId(), await sha256(token), account.id, by.id, now(), expiresAt) | |
| 182 | + .run(); | |
| 183 | + return { url: `${this.env.PUBLIC_URL}/signin/${token}`, expiresAt }; | |
| 184 | + } | |
| 185 | + | |
| 186 | + async signinLink(token: string): Promise<{ hash: string; account: Account } | null> { | |
| 187 | + const hash = await sha256(token); | |
| 188 | + const row = await this.db.prepare("SELECT account_id FROM signin_links WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(hash, now()).first<{ account_id: string }>(); | |
| 189 | + if (!row) return null; | |
| 190 | + const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); | |
| 191 | + return account && account.kind === "user" ? { hash, account } : null; | |
| 192 | + } | |
| 193 | + | |
| 194 | + /** Mark a link used; false when someone else used it first. */ | |
| 195 | + async useSigninLink(hash: string): Promise<boolean> { | |
| 196 | + const r = await this.db.prepare("UPDATE signin_links SET used_at = ? WHERE token_hash = ? AND used_at IS NULL AND expires_at > ?").bind(now(), hash, now()).run(); | |
| 197 | + return (r.meta.changes ?? 0) > 0; | |
| 198 | + } | |
| 199 | +} | |
| 200 | + | |
| 201 | +/** Read one cookie from a raw request. */ | |
| 202 | +export function cookieFrom(req: Request, name: string): string | undefined { | |
| 203 | + const cookie = req.headers.get("cookie") ?? ""; | |
| 204 | + for (const part of cookie.split(";")) { | |
| 205 | + const i = part.indexOf("="); | |
| 206 | + if (i > 0 && part.slice(0, i).trim() === name) return part.slice(i + 1).trim(); | |
| 207 | + } | |
| 208 | + return undefined; | |
| 209 | +} |
modifiedapps/diffinery/src/lib/directory.ts+3−3View file
| @@ -70,8 +70,8 @@ | ||
| 70 | 70 | return candidate; |
| 71 | 71 | } |
| 72 | 72 | |
| 73 | - /** Find or create the user behind a verified email (Access sign-in). */ | |
| 74 | - async userForEmail(email: string, hints: { name?: string; login?: string } = {}): Promise<Account> { | |
| 73 | + /** Find or create the user behind a verified email (Access recovery, invite links, dev sign-in). */ | |
| 74 | + async userForEmail(email: string, hints: { name?: string; login?: string; id?: string } = {}): Promise<Account> { | |
| 75 | 75 | const existing = await this.accountByEmail(email); |
| 76 | 76 | if (existing) { |
| 77 | 77 | if (existing.status === "pending" && this.isBootstrapAdmin(email, existing.login)) { |
| @@ -82,7 +82,7 @@ | ||
| 82 | 82 | } |
| 83 | 83 | const admin = this.isBootstrapAdmin(email, hints.login); |
| 84 | 84 | const login = await this.uniqueLogin(hints.login ?? email.split("@")[0]!); |
| 85 | - const id = newId(); | |
| 85 | + const id = hints.id ?? newId(); | |
| 86 | 86 | const hasInvite = await this.db.prepare("SELECT 1 FROM invites WHERE email = ? AND accepted_at IS NULL AND expires_at > ?").bind(email, now()).first(); |
| 87 | 87 | await this.db |
| 88 | 88 | .prepare("INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES (?, ?, 'user', ?, ?, ?, ?, ?)") |
modifiedapps/diffinery/src/lib/util.ts+1−1View file
| @@ -67,7 +67,7 @@ | ||
| 67 | 67 | } |
| 68 | 68 | |
| 69 | 69 | /** Top-level paths the app serves; no account may use them as its login. */ |
| 70 | -export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery"]); | |
| 70 | +export const RESERVED_LOGINS = new Set(["api", "mcp", "runner", "settings", "new", "notifications", "invite", "request-access", "healthz", "login", "logout", "admin", "explore", "favicon.ico", "robots.txt", "about", "docs", "static", "assets", "www", "git", "diffinery", "auth", "signin", "signup", "security", "-"]); | |
| 71 | 71 | |
| 72 | 72 | export const isValidLogin = (s: string): boolean => isValidName(s) && !s.includes(".") && !RESERVED_LOGINS.has(s.toLowerCase()); |
| 73 | 73 |
modifiedapps/diffinery/src/web/account.tsx+156−13View file
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | import type { Role } from "../repo/types.js"; |
| 9 | 9 | import { ICONS } from "./assets.js"; |
| 10 | 10 | import { csrfToken } from "./csrf.js"; |
| 11 | +import { Auth } from "../auth/sessions.js"; | |
| 12 | +import { browserAccount, deviceLabel, invitePasskeyPage } from "./auth.js"; | |
| 11 | 13 | import { Actor, Avatar, CopyButton, Form, ROLES, Time, act, ago, post, publicUrl, render, repoUrl, type Stub, type WebApp, prm } from "./ui.js"; |
| 12 | 14 | |
| 13 | 15 | // ---------------------------------------------------------------- shared bits |
| @@ -64,11 +66,11 @@ | ||
| 64 | 66 | <h1>Diffinery</h1> |
| 65 | 67 | <p>Code hosting and CI for the agentic era. Git repositories, pull requests with intent, GitHub Actions–compatible workflows and AI agents as first-class collaborators, running on Cloudflare.</p> |
| 66 | 68 | <p class="row" style="justify-content:center"> |
| 67 | - <a class="btn primary" href="/settings"> | |
| 68 | - Sign in | |
| 69 | + <a class="btn primary" href="/login"> | |
| 70 | + Sign in with a passkey | |
| 69 | 71 | </a> |
| 70 | 72 | </p> |
| 71 | - <p class="muted small">Access is through Cloudflare Access. Sign in with the email address you were invited with; new accounts wait for an invite from an administrator.</p> | |
| 73 | + <p class="muted small">Diffinery is invite-only. Open the invite link someone sent you to create your passkey; there are no passwords.</p> | |
| 72 | 74 | </section> |
| 73 | 75 | <div class="features"> |
| 74 | 76 | <div class="box"> |
| @@ -402,7 +404,7 @@ | ||
| 402 | 404 | |
| 403 | 405 | web.get("/invite/:token", async (c) => { |
| 404 | 406 | const p = c.get("principal"); |
| 405 | - if (p.type !== "account") throw new HttpError(401, "sign in to accept this invite"); | |
| 407 | + if (p.type !== "account") return invitePasskeyPage(c, prm(c, "token")); | |
| 406 | 408 | if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); |
| 407 | 409 | return render( |
| 408 | 410 | c, |
| @@ -490,7 +492,7 @@ | ||
| 490 | 492 | |
| 491 | 493 | // ---------------------------------------------------------------- /settings |
| 492 | 494 | |
| 493 | -type SettingsTab = "profile" | "tokens" | "agents" | "orgs" | "admin" | "audit"; | |
| 495 | +type SettingsTab = "profile" | "security" | "tokens" | "agents" | "orgs" | "admin" | "audit"; | |
| 494 | 496 | |
| 495 | 497 | const SettingsNav: FC<{ me: Account; tab: SettingsTab }> = ({ me, tab }) => ( |
| 496 | 498 | <aside class="side"> |
| @@ -506,6 +508,9 @@ | ||
| 506 | 508 | <a href="/settings" class={tab === "profile" ? "on" : ""}> |
| 507 | 509 | Profile |
| 508 | 510 | </a> |
| 511 | + <a href="/settings/security" class={tab === "security" ? "on" : ""}> | |
| 512 | + Passkeys and sessions | |
| 513 | + </a> | |
| 509 | 514 | <a href="/settings/tokens" class={tab === "tokens" ? "on" : ""}> |
| 510 | 515 | Access tokens |
| 511 | 516 | </a> |
| @@ -618,6 +623,128 @@ | ||
| 618 | 623 | ); |
| 619 | 624 | }); |
| 620 | 625 | |
| 626 | + // Passkeys and sessions | |
| 627 | + web.get("/settings/security", async (c) => { | |
| 628 | + const me = requireAccount(c); | |
| 629 | + const t = csrfToken(c); | |
| 630 | + const auth = new Auth(c.env); | |
| 631 | + const p = c.get("principal"); | |
| 632 | + const current = p.type === "account" ? p.sessionId : undefined; | |
| 633 | + const [keys, sessions] = await Promise.all([auth.passkeys(me.id), auth.sessions(me.id)]); | |
| 634 | + const recovery = !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD); | |
| 635 | + return page( | |
| 636 | + c, | |
| 637 | + me, | |
| 638 | + "security", | |
| 639 | + "Passkeys and sessions", | |
| 640 | + <> | |
| 641 | + <h1>Passkeys and sessions</h1> | |
| 642 | + <p class="muted">You sign in to Diffinery with passkeys instead of a password. A passkey lives on your device or in your password manager and is unlocked with your fingerprint, face or screen lock.</p> | |
| 643 | + <div class="box"> | |
| 644 | + <div class="head"> | |
| 645 | + <h2>Passkeys</h2> | |
| 646 | + <span class="badge">{keys.length}</span> | |
| 647 | + </div> | |
| 648 | + {keys.length ? ( | |
| 649 | + <ul class="list"> | |
| 650 | + {keys.map((k) => ( | |
| 651 | + <li> | |
| 652 | + <span class="st faint">{raw(ICONS.key)}</span> | |
| 653 | + <div class="grow"> | |
| 654 | + <b>{k.name}</b> {k.backed_up ? <span class="badge blue" title="Backed up by your password manager or platform, so it syncs to your other devices">synced</span> : <span class="badge" title="Stored only on one device or security key">this device only</span>} | |
| 655 | + <div class="meta"> | |
| 656 | + Added <Time at={k.created_at} /> · {k.last_used_at ? <>last used <Time at={k.last_used_at} /></> : "never used to sign in"} | |
| 657 | + </div> | |
| 658 | + </div> | |
| 659 | + {keys.length > 1 ? ( | |
| 660 | + <Form action={`/settings/security/passkeys/${encodeURIComponent(k.id)}/delete`} t={t} class="inline" confirm={`Remove the passkey "${k.name}"? Sessions it signed in are signed out.`}> | |
| 661 | + <button class="btn sm danger">Remove</button> | |
| 662 | + </Form> | |
| 663 | + ) : ( | |
| 664 | + <span class="small faint" title="Add another passkey before removing this one">Only passkey</span> | |
| 665 | + )} | |
| 666 | + </li> | |
| 667 | + ))} | |
| 668 | + </ul> | |
| 669 | + ) : ( | |
| 670 | + <div class="empty">No passkeys yet. Add one so you can sign in.</div> | |
| 671 | + )} | |
| 672 | + <div class="body" style="border-top:1px solid var(--border)"> | |
| 673 | + <form data-passkey="register" data-next="/settings/security" class="row" style="align-items:flex-end"> | |
| 674 | + <div class="field grow m0"> | |
| 675 | + <label for="pklabel">Add a passkey</label> | |
| 676 | + <input id="pklabel" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 677 | + <div class="hint">Add one on each device you use, or keep it in a password manager that syncs.</div> | |
| 678 | + </div> | |
| 679 | + <button class="btn primary">Add a passkey</button> | |
| 680 | + </form> | |
| 681 | + <div class="mt"> | |
| 682 | + <div class="flash error" data-passkey-msg role="alert" hidden></div> | |
| 683 | + </div> | |
| 684 | + </div> | |
| 685 | + </div> | |
| 686 | + <div class="box"> | |
| 687 | + <div class="head"> | |
| 688 | + <h2>Signed-in browsers</h2> | |
| 689 | + <span class="badge">{sessions.length}</span> | |
| 690 | + </div> | |
| 691 | + {sessions.length ? ( | |
| 692 | + <ul class="list"> | |
| 693 | + {sessions.map((s) => ( | |
| 694 | + <li> | |
| 695 | + <div class="grow"> | |
| 696 | + <b>{deviceLabel(s.user_agent ?? undefined)}</b> {s.id === current ? <span class="badge green">this browser</span> : null} | |
| 697 | + <div class="meta"> | |
| 698 | + Signed in <Time at={s.created_at} /> with {s.method === "access" ? "email recovery" : s.method === "link" ? "a sign-in link" : "a passkey"} · active <Time at={s.last_seen_at} /> | |
| 699 | + </div> | |
| 700 | + </div> | |
| 701 | + </li> | |
| 702 | + ))} | |
| 703 | + </ul> | |
| 704 | + ) : ( | |
| 705 | + <div class="empty">No browser sessions.</div> | |
| 706 | + )} | |
| 707 | + {sessions.some((s) => s.id !== current) ? ( | |
| 708 | + <div class="body" style="border-top:1px solid var(--border)"> | |
| 709 | + <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?"> | |
| 710 | + <button class="btn">Sign out everywhere else</button> | |
| 711 | + </Form> | |
| 712 | + </div> | |
| 713 | + ) : null} | |
| 714 | + </div> | |
| 715 | + <p class="muted small"> | |
| 716 | + Lost every passkey? {recovery ? <>Use <a href="/auth/access">email recovery</a>, then add a new passkey here.</> : "Ask a site administrator for a one-time sign-in link."} Git, the CLI and agents use <a href="/settings/tokens">access tokens</a> instead of passkeys. | |
| 717 | + </p> | |
| 718 | + </>, | |
| 719 | + ); | |
| 720 | + }); | |
| 721 | + | |
| 722 | + post(web, "/settings/security/passkeys/:id/delete", async (c) => { | |
| 723 | + const me = browserAccount(c); | |
| 724 | + const auth = new Auth(c.env); | |
| 725 | + const id = prm(c, "id"); | |
| 726 | + return act( | |
| 727 | + c, | |
| 728 | + "/settings/security", | |
| 729 | + async () => { | |
| 730 | + const keys = await auth.passkeys(me.id); | |
| 731 | + const key = keys.find((k) => k.id === id); | |
| 732 | + if (!key) throw new HttpError(404, "passkey not found"); | |
| 733 | + if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one."); | |
| 734 | + const p = c.get("principal"); | |
| 735 | + await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null); | |
| 736 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } })); | |
| 737 | + }, | |
| 738 | + "Passkey removed.", | |
| 739 | + ); | |
| 740 | + }); | |
| 741 | + | |
| 742 | + post(web, "/settings/security/sessions/others", async (c) => { | |
| 743 | + const me = browserAccount(c); | |
| 744 | + const p = c.get("principal"); | |
| 745 | + return act(c, "/settings/security", async () => new Auth(c.env).endOtherSessions(me.id, p.type === "account" ? (p.sessionId ?? null) : null), "Signed out of every other browser."); | |
| 746 | + }); | |
| 747 | + | |
| 621 | 748 | // Tokens |
| 622 | 749 | const tokensPage = async (c: AppContext, me: Account, created?: { token: string }) => { |
| 623 | 750 | const dir = c.get("dir"); |
| @@ -997,7 +1124,7 @@ | ||
| 997 | 1124 | return me; |
| 998 | 1125 | }; |
| 999 | 1126 | |
| 1000 | - const adminPage = async (c: AppContext, me: Account, invite?: { url: string; email: string }) => { | |
| 1127 | + const adminPage = async (c: AppContext, me: Account, invite?: { url: string; title: string; note: string }) => { | |
| 1001 | 1128 | const dir = c.get("dir"); |
| 1002 | 1129 | const t = csrfToken(c); |
| 1003 | 1130 | const [users, requests] = await Promise.all([dir.users(), dir.accessRequests()]); |
| @@ -1011,8 +1138,8 @@ | ||
| 1011 | 1138 | {invite ? ( |
| 1012 | 1139 | <div class="box" style="border-color:var(--amber)"> |
| 1013 | 1140 | <div class="body"> |
| 1014 | - <h3>Invite created for {invite.email}</h3> | |
| 1015 | - <p class="muted">Send this link. Signing in with that email through Cloudflare Access also activates the account automatically.</p> | |
| 1141 | + <h3>{invite.title}</h3> | |
| 1142 | + <p class="muted">{invite.note}</p> | |
| 1016 | 1143 | <div class="row"> |
| 1017 | 1144 | <div class="secret grow">{invite.url}</div> |
| 1018 | 1145 | <CopyButton text={invite.url} /> |
| @@ -1057,7 +1184,7 @@ | ||
| 1057 | 1184 | <div class="field grow m0"> |
| 1058 | 1185 | <label for="iemail">Email address</label> |
| 1059 | 1186 | <input id="iemail" name="email" type="email" required placeholder="friend@example.com" /> |
| 1060 | - <div class="hint">They also need to be allowed by your Cloudflare Access policy.</div> | |
| 1187 | + <div class="hint">Opening the link lets them pick a username and create a passkey.</div> | |
| 1061 | 1188 | </div> |
| 1062 | 1189 | <button class="btn primary">Create invite</button> |
| 1063 | 1190 | </div> |
| @@ -1074,6 +1201,7 @@ | ||
| 1074 | 1201 | <th>Email</th> |
| 1075 | 1202 | <th>Joined</th> |
| 1076 | 1203 | <th>Status</th> |
| 1204 | + <th /> | |
| 1077 | 1205 | </tr> |
| 1078 | 1206 | </thead> |
| 1079 | 1207 | <tbody> |
| @@ -1100,6 +1228,11 @@ | ||
| 1100 | 1228 | </Form> |
| 1101 | 1229 | )} |
| 1102 | 1230 | </td> |
| 1231 | + <td class="right"> | |
| 1232 | + <Form action={`/settings/admin/users/${u.id}/signin-link`} t={t} class="inline" confirm={`Create a one-time link that adds a passkey to ${u.login}? Only send it to them.`}> | |
| 1233 | + <button class="btn sm" title="For a lost passkey or a first sign-in">Sign-in link</button> | |
| 1234 | + </Form> | |
| 1235 | + </td> | |
| 1103 | 1236 | </tr> |
| 1104 | 1237 | ))} |
| 1105 | 1238 | </tbody> |
| @@ -1130,13 +1263,24 @@ | ||
| 1130 | 1263 | ); |
| 1131 | 1264 | }); |
| 1132 | 1265 | |
| 1266 | + post(web, "/settings/admin/users/:id/signin-link", async (c) => { | |
| 1267 | + const me = requireSiteAdmin(c); | |
| 1268 | + const dir = c.get("dir"); | |
| 1269 | + const target = await dir.account(prm(c, "id")); | |
| 1270 | + if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); | |
| 1271 | + if (target.status === "disabled") throw new HttpError(422, "enable this account first"); | |
| 1272 | + const link = await new Auth(c.env).createSigninLink(me, target); | |
| 1273 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login })); | |
| 1274 | + return adminPage(c, me, { url: link.url, title: `Sign-in link for ${target.login}`, note: "Send this only to them. It works once within 24 hours and adds a passkey to their account." }); | |
| 1275 | + }); | |
| 1276 | + | |
| 1133 | 1277 | post(web, "/settings/admin/invite", async (c, f) => { |
| 1134 | 1278 | const me = requireSiteAdmin(c); |
| 1135 | 1279 | const email = f.s("email").trim(); |
| 1136 | 1280 | if (!/^[^\s@]+@[^\s@]+$/.test(email)) throw new HttpError(422, "enter a valid email address"); |
| 1137 | 1281 | const inv = await c.get("dir").createInvite(me, { email, role: "read" }); |
| 1138 | 1282 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "invite.create", target: email })); |
| 1139 | - return adminPage(c, me, { url: inv.url, email }); | |
| 1283 | + return adminPage(c, me, { url: inv.url, title: `Invite created for ${email}`, note: "Send them this link. It expires in 14 days and works once: they pick a username and create a passkey." }); | |
| 1140 | 1284 | }); |
| 1141 | 1285 | |
| 1142 | 1286 | web.get("/settings/audit", async (c) => { |
| @@ -1400,7 +1544,7 @@ | ||
| 1400 | 1544 | </div> |
| 1401 | 1545 | <div class="body"> |
| 1402 | 1546 | <p class="muted small"> |
| 1403 | - New people sign in through Cloudflare Access with their email and join automatically. <a href={`${base}?tab=invites`}>Create an invite →</a> | |
| 1547 | + New people join from an invite link, where they create a passkey. <a href={`${base}?tab=invites`}>Create an invite →</a> | |
| 1404 | 1548 | </p> |
| 1405 | 1549 | </div> |
| 1406 | 1550 | </div> |
| @@ -1417,7 +1561,7 @@ | ||
| 1417 | 1561 | <div class="box" style="border-color:var(--amber)"> |
| 1418 | 1562 | <div class="body"> |
| 1419 | 1563 | <h3>Invite created for {invite.email}</h3> |
| 1420 | - <p class="muted">Send them this link. It expires in 14 days and only works for that email address.</p> | |
| 1564 | + <p class="muted">Send them this link. It expires in 14 days. Opening it lets them create a passkey and join.</p> | |
| 1421 | 1565 | <div class="row"> |
| 1422 | 1566 | <div class="secret grow">{invite.url}</div> |
| 1423 | 1567 | <CopyButton text={invite.url} /> |
| @@ -1444,7 +1588,6 @@ | ||
| 1444 | 1588 | </div> |
| 1445 | 1589 | <button class="btn primary">Create invite</button> |
| 1446 | 1590 | </div> |
| 1447 | - <div class="body muted small" style="padding-top:0">The person must also be allowed by your Cloudflare Access policy to reach this site.</div> | |
| 1448 | 1591 | </Form> |
| 1449 | 1592 | <div class="box"> |
| 1450 | 1593 | {invites.length ? ( |
modifiedapps/diffinery/src/web/assets.ts+59−23View file
| @@ -2,25 +2,35 @@ | ||
| 2 | 2 | |
| 3 | 3 | export const CSS = ` |
| 4 | 4 | :root{color-scheme:light dark; |
| 5 | ---bg:#ffffff;--bg2:#f7f8fa;--bg3:#eff1f4;--bg-hover:#f3f4f7;--fg:#1c2024;--fg2:#5b6370;--fg3:#8b929c; | |
| 6 | ---border:#dfe3e8;--border2:#ebeef1;--accent:#5e6ad2;--accent-hover:#4f5bc4;--accent-fg:#fff;--accent-soft:#eef0fc; | |
| 7 | ---green:#1a7f37;--green-soft:#e3f6e8;--red:#cf222e;--red-soft:#fdecec;--amber:#9a6700;--amber-soft:#fff6d6; | |
| 8 | ---purple:#8250df;--purple-soft:#f4edff;--blue:#0969da;--blue-soft:#e6f0fd; | |
| 9 | ---add-bg:#e9f8ee;--add-ln:#cdf0d7;--del-bg:#fdeeee;--del-ln:#fbd5d5;--hunk-bg:#eef3fb;--hl:#fff8c5; | |
| 10 | ---code-bg:#f5f6f8;--shadow:0 1px 2px rgba(20,24,31,.05),0 8px 24px rgba(20,24,31,.08);--r:8px; | |
| 11 | ---mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,"Liberation Mono",monospace; | |
| 12 | ---sans:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans",sans-serif} | |
| 5 | +/* Colors, radii and type follow Cloudflare's Kumo design system tokens (kumo-ui.com, MIT). */ | |
| 6 | +--canvas:oklch(98.75% 0 0);--bg:#fff;--bg2:oklch(98% 0 0);--bg3:oklch(96% 0 0);--bg-hover:oklch(97% 0 0); | |
| 7 | +--fg:oklch(21% 0.006 285.885);--fg2:oklch(55.6% 0 0);--fg3:oklch(70.8% 0 0); | |
| 8 | +--border:oklch(14.5% 0 0 / .1);--border2:oklch(93.5% 0 0); | |
| 9 | +--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(93.2% 0.032 255.6 / .45); | |
| 10 | +--link:oklch(42.4% 0.199 265.638);--focus:oklch(15% 0 0); | |
| 11 | +--green:oklch(43.2% 0.095 166.913);--green-soft:oklch(96.2% 0.043 156.7 / .57);--red:oklch(50.5% 0.213 27.518);--red-soft:oklch(93.6% 0.032 17.7 / .42); | |
| 12 | +--amber:oklch(59.7% 0.144 57.5);--amber-soft:oklch(93.1% 0.107 94.6 / .2); | |
| 13 | +--purple:oklch(55.8% 0.288 302.321);--purple-soft:oklch(55.8% 0.288 302.321 / .1);--blue:oklch(42.4% 0.199 265.638);--blue-soft:oklch(93.2% 0.032 255.6 / .45); | |
| 14 | +--brand:#f6821f; | |
| 15 | +--add-bg:oklch(96.2% 0.043 156.7 / .57);--add-ln:oklch(90.5% 0.093 164.15 / .7);--del-bg:oklch(93.6% 0.032 17.7 / .42);--del-ln:oklch(88.5% 0.062 18.334 / .7);--hunk-bg:oklch(93.2% 0.032 255.6 / .45);--hl:oklch(97.3% 0.071 103.193); | |
| 16 | +--code-bg:oklch(96% 0 0);--shadow:0 0 0 1px oklch(0% 0 0 / .12),0 8px 24px oklch(0% 0 0 / .08);--r:.5rem;--r-sm:.375rem; | |
| 17 | +--mono:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace; | |
| 18 | +--sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"} | |
| 13 | 19 | @media (prefers-color-scheme:dark){:root{ |
| 14 | ---bg:#0f1115;--bg2:#15181d;--bg3:#1c2027;--bg-hover:#1a1e24;--fg:#e6e8eb;--fg2:#a0a7b1;--fg3:#6f7782; | |
| 15 | ---border:#2a2f37;--border2:#22262d;--accent:#7c86e8;--accent-hover:#8e97ef;--accent-fg:#0f1115;--accent-soft:#232744; | |
| 16 | ---green:#3fb950;--green-soft:#15291c;--red:#f85149;--red-soft:#2d1517;--amber:#d29922;--amber-soft:#2b2312; | |
| 17 | ---purple:#a371f7;--purple-soft:#241a35;--blue:#58a6ff;--blue-soft:#14233a; | |
| 18 | ---add-bg:#12261a;--add-ln:#1a3a25;--del-bg:#2a1416;--del-ln:#45191d;--hunk-bg:#151f30;--hl:#3a3213; | |
| 19 | ---code-bg:#171a20;--shadow:0 1px 2px rgba(0,0,0,.3),0 8px 24px rgba(0,0,0,.35)}} | |
| 20 | +--canvas:oklch(10% 0 0);--bg:oklch(17% 0 0);--bg2:oklch(12% 0 0);--bg3:oklch(15% 0 0);--bg-hover:oklch(26.9% 0 0); | |
| 21 | +--fg:oklch(97% 0 0);--fg2:oklch(70.8% 0 0);--fg3:oklch(55.6% 0 0); | |
| 22 | +--border:oklch(32% 0 0);--border2:oklch(26.9% 0 0); | |
| 23 | +--accent:oklch(48.8% 0.243 264.376);--accent-hover:oklch(54.6% 0.245 262.881);--accent-fg:#fff;--accent-soft:oklch(38% 0.145 265.5 / .22); | |
| 24 | +--link:oklch(70.7% 0.165 254.624);--focus:oklch(93.5% 0 0); | |
| 25 | +--green:oklch(76.5% 0.177 163.223);--green-soft:oklch(39.3% 0.096 152.3 / .2);--red:oklch(70.4% 0.191 22.216);--red-soft:oklch(42.9% 0.176 28.7 / .17); | |
| 26 | +--amber:oklch(75% 0.183 55.934);--amber-soft:oklch(35.3% 0.079 65 / .37); | |
| 27 | +--purple:oklch(71.4% 0.203 305.504);--purple-soft:oklch(49.6% 0.265 301.924 / .2);--blue:oklch(70.7% 0.165 254.624);--blue-soft:oklch(38% 0.145 265.5 / .22); | |
| 28 | +--add-bg:oklch(39.3% 0.096 152.3 / .2);--add-ln:oklch(39.3% 0.096 152.3 / .4);--del-bg:oklch(42.9% 0.176 28.7 / .17);--del-ln:oklch(42.9% 0.176 28.7 / .35);--hunk-bg:oklch(38% 0.145 265.5 / .22);--hl:oklch(55.4% 0.135 66.442 / .5); | |
| 29 | +--code-bg:oklch(15% 0 0);--shadow:0 0 0 1px oklch(100% 0 0 / .1),0 8px 24px oklch(0% 0 0 / .3)}} | |
| 20 | 30 | *{box-sizing:border-box} |
| 21 | 31 | html{-webkit-text-size-adjust:100%} |
| 22 | -body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 var(--sans)} | |
| 23 | -a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline} | |
| 32 | +body{margin:0;background:var(--canvas);color:var(--fg);font:14px/1.5 var(--sans)} | |
| 33 | +a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline} | |
| 24 | 34 | code,pre,kbd,.mono{font-family:var(--mono);font-size:12.5px} |
| 25 | 35 | kbd{border:1px solid var(--border);border-bottom-width:2px;border-radius:4px;padding:0 5px;background:var(--bg2);font-size:11px;color:var(--fg2)} |
| 26 | 36 | h1,h2,h3,h4{line-height:1.25;margin:0 0 8px}h1{font-size:22px}h2{font-size:18px}h3{font-size:15px} |
| @@ -31,7 +41,7 @@ | ||
| 31 | 41 | .top{position:sticky;top:0;z-index:20;background:color-mix(in srgb,var(--bg) 88%,transparent);backdrop-filter:saturate(1.6) blur(10px);border-bottom:1px solid var(--border)} |
| 32 | 42 | .top .wrap{display:flex;align-items:center;gap:14px;height:52px} |
| 33 | 43 | .brand{display:flex;align-items:center;gap:8px;font-weight:650;color:var(--fg);letter-spacing:-.01em} |
| 34 | -.brand:hover{text-decoration:none}.brand svg{color:var(--accent)} | |
| 44 | +.brand:hover{text-decoration:none}.brand svg{color:var(--brand)} | |
| 35 | 45 | .top nav{display:flex;align-items:center;gap:4px;margin-left:auto} |
| 36 | 46 | .top nav a{color:var(--fg2);padding:6px 10px;border-radius:6px}.top nav a:hover{background:var(--bg-hover);color:var(--fg);text-decoration:none} |
| 37 | 47 | .pal-btn{display:flex;align-items:center;gap:8px;min-width:220px;padding:5px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg2);color:var(--fg3);font:inherit;cursor:pointer} |
| @@ -43,17 +53,18 @@ | ||
| 43 | 53 | .flash.error{border-color:color-mix(in srgb,var(--red) 40%,transparent);background:var(--red-soft);color:var(--red)} |
| 44 | 54 | .flash.notice{border-color:color-mix(in srgb,var(--green) 40%,transparent);background:var(--green-soft);color:var(--green)} |
| 45 | 55 | .flash.info{border-color:color-mix(in srgb,var(--blue) 40%,transparent);background:var(--blue-soft)} |
| 46 | -.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important} | |
| 56 | +.btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:6px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:500 13px/1.4 var(--sans);cursor:pointer;white-space:nowrap;text-decoration:none!important} | |
| 47 | 57 | .btn:hover{background:var(--bg-hover);border-color:var(--fg3)} |
| 48 | -.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.primary:hover{background:var(--accent-hover)} | |
| 49 | -.btn.green{background:var(--green);border-color:var(--green);color:#fff} | |
| 58 | +.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--accent-fg);box-shadow:inset 0 1px 0 0 oklch(100% 0 0 / .15)}.btn.primary:hover{background:var(--accent-hover)} | |
| 59 | +.btn.green{background:var(--accent);border-color:var(--accent);color:var(--accent-fg)}.btn.green:hover{background:var(--accent-hover)} | |
| 50 | 60 | .btn.danger{color:var(--red)}.btn.danger:hover{background:var(--red);border-color:var(--red);color:#fff} |
| 51 | 61 | .btn.sm{padding:3px 8px;font-size:12px}.btn.link{border-color:transparent;background:none;color:var(--accent);padding:0} |
| 52 | 62 | .btn[disabled]{opacity:.5;cursor:not-allowed} |
| 53 | 63 | input[type=text],input[type=search],input[type=email],input[type=number],input[type=url],input[type=password],input:not([type]),select,textarea{ |
| 54 | -width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:7px;background:var(--bg);color:var(--fg);font:inherit} | |
| 64 | +width:100%;padding:7px 10px;border:1px solid var(--border);border-radius:var(--r);background:var(--bg);color:var(--fg);font:inherit} | |
| 55 | 65 | textarea{min-height:120px;resize:vertical;font-family:var(--sans)}textarea.code{font-family:var(--mono);font-size:12.5px;min-height:420px;tab-size:4;white-space:pre} |
| 56 | -input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--accent) 45%,transparent);outline-offset:0;border-color:var(--accent)} | |
| 66 | +input:focus,select:focus,textarea:focus{outline:2px solid color-mix(in srgb,var(--focus) 25%,transparent);outline-offset:0;border-color:color-mix(in srgb,var(--focus) 50%,transparent)} | |
| 67 | +:focus-visible{outline:2px solid color-mix(in srgb,var(--focus) 35%,transparent);outline-offset:2px} | |
| 57 | 68 | input[type=checkbox],input[type=radio]{accent-color:var(--accent)} |
| 58 | 69 | label{display:block;font-weight:550;margin:0 0 4px}label.inline{display:inline-flex;align-items:center;gap:6px;font-weight:400} |
| 59 | 70 | .field{margin:0 0 14px}.field .hint{font-size:12px;color:var(--fg2);margin-top:4px;font-weight:400} |
| @@ -213,6 +224,11 @@ | ||
| 213 | 224 | a.sha:hover{text-decoration:none;background:var(--accent-soft);color:var(--accent)} |
| 214 | 225 | .right{text-align:right}.mt{margin-top:16px}.mb{margin-bottom:16px}.m0{margin:0} |
| 215 | 226 | form.inline{display:inline} |
| 227 | +.navbtn{background:none;border:0;color:var(--fg2);padding:6px 10px;border-radius:6px;font:inherit;cursor:pointer}.navbtn:hover{background:var(--bg-hover);color:var(--fg)} | |
| 228 | +.btn.block{display:flex;width:100%;justify-content:center} | |
| 229 | +.authcard{max-width:440px;margin:56px auto}.authcard .body{padding:28px}.authcard h1{font-size:24px;margin:12px 0 8px} | |
| 230 | +.authmark{width:44px;height:44px;border-radius:var(--r);display:grid;place-items:center;background:color-mix(in srgb,var(--brand) 14%,transparent);color:var(--brand)}.authmark svg{width:22px;height:22px} | |
| 231 | +.authfoot{margin-top:20px;padding-top:14px;border-top:1px solid var(--border)}.authfoot p{margin:0 0 6px} | |
| 216 | 232 | .sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0)} |
| 217 | 233 | `; |
| 218 | 234 | |
| @@ -229,9 +245,29 @@ | ||
| 229 | 245 | const x=e.target.closest('[data-cancel-line]');if(x){e.preventDefault();const tr=x.closest('tr');tr&&tr.remove()} |
| 230 | 246 | if(!e.target.closest('details.menu'))$$('details.menu[open]').forEach(m=>m.removeAttribute('open')); |
| 231 | 247 | }); |
| 232 | -d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return} | |
| 248 | +d.addEventListener('submit',e=>{const f=e.target;if(f.dataset.passkey){e.preventDefault();passkey(f);return} | |
| 249 | + if(f.dataset.confirm&&!confirm(f.dataset.confirm)){e.preventDefault();return} | |
| 233 | 250 | if(f.dataset.match){const i=f.querySelector('[name=confirm]');if(i&&i.value!==f.dataset.match){e.preventDefault();alert('Type '+f.dataset.match+' to confirm.');return}} |
| 234 | 251 | $$('button[type=submit],button:not([type])',f).forEach(x=>setTimeout(()=>{x.disabled=true},0));},true); |
| 252 | +// Passkeys (WebAuthn): forms with data-passkey="login" or "register" | |
| 253 | +const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')}; | |
| 254 | +const u2b=s=>{s=s.replaceAll('-','+').replaceAll('_','/');while(s.length%4)s+='=';const bin=atob(s);const out=new Uint8Array(bin.length);for(let i=0;i<bin.length;i++)out[i]=bin.charCodeAt(i);return out.buffer}; | |
| 255 | +async function pkPost(url,body){const r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); | |
| 256 | + let j={};try{j=await r.json()}catch(_){}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j} | |
| 257 | +async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f); | |
| 258 | + const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)}; | |
| 259 | + if(!window.PublicKeyCredential||!navigator.credentials){show('This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox.');return} | |
| 260 | + show('');btns.forEach(x=>{x.disabled=true}); | |
| 261 | + const data={};new FormData(f).forEach((v,k)=>{if(typeof v==='string')data[k]=v});data.next=f.dataset.next||'/'; | |
| 262 | + try{const o=await pkPost('/auth/passkey/'+mode+'/options',data);o.challenge=u2b(o.challenge);let cred,res; | |
| 263 | + if(mode==='login'){(o.allowCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.get({publicKey:o});const r=cred.response; | |
| 264 | + res={authenticatorData:b2u(r.authenticatorData),clientDataJSON:b2u(r.clientDataJSON),signature:b2u(r.signature)};if(r.userHandle)res.userHandle=b2u(r.userHandle)} | |
| 265 | + else{o.user.id=u2b(o.user.id);(o.excludeCredentials||[]).forEach(c=>{c.id=u2b(c.id)});cred=await navigator.credentials.create({publicKey:o});const r=cred.response; | |
| 266 | + res={attestationObject:b2u(r.attestationObject),clientDataJSON:b2u(r.clientDataJSON),transports:r.getTransports?r.getTransports():[]}} | |
| 267 | + const v=await pkPost('/auth/passkey/'+mode+'/verify',Object.assign({},data,{response:{id:cred.id,rawId:b2u(cred.rawId),type:cred.type,response:res,clientExtensionResults:cred.getClientExtensionResults(),authenticatorAttachment:cred.authenticatorAttachment||undefined}})); | |
| 268 | + location.assign(v.redirect||'/')} | |
| 269 | + catch(e){btns.forEach(x=>{x.disabled=false}); | |
| 270 | + show(e&&e.name==='NotAllowedError'?'The passkey prompt was closed or timed out. Try again when you are ready.':e&&e.name==='InvalidStateError'?'This device already has a passkey for this account.':(e&&e.message)||String(e))}} | |
| 235 | 271 | function lineComment(btn){ |
| 236 | 272 | const tpl=$('#line-comment-tpl');if(!tpl)return; |
| 237 | 273 | const tr=btn.closest('tr');const next=tr.nextElementSibling; |
addedapps/diffinery/src/web/auth.tsx+424−0View file
| @@ -1,0 +1,424 @@ | ||
| 1 | +/** @jsxImportSource hono/jsx */ | |
| 2 | +import { | |
| 3 | + generateAuthenticationOptions, | |
| 4 | + generateRegistrationOptions, | |
| 5 | + verifyAuthenticationResponse, | |
| 6 | + verifyRegistrationResponse, | |
| 7 | + type AuthenticationResponseJSON, | |
| 8 | + type AuthenticatorTransport, | |
| 9 | + type RegistrationResponseJSON, | |
| 10 | +} from "@simplewebauthn/server"; | |
| 11 | +import { deleteCookie, getCookie, setCookie } from "hono/cookie"; | |
| 12 | +import { raw } from "hono/html"; | |
| 13 | +import type { FC, PropsWithChildren } from "hono/jsx"; | |
| 14 | +import { accessToken, type AppContext } from "../auth/principal.js"; | |
| 15 | +import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js"; | |
| 16 | +import type { Account } from "../lib/directory.js"; | |
| 17 | +import { verifyAccessJwt } from "../lib/jwt.js"; | |
| 18 | +import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js"; | |
| 19 | +import { ICONS } from "./assets.js"; | |
| 20 | +import { flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 21 | + | |
| 22 | +/** | |
| 23 | + * Passkey (WebAuthn) sign-in. Browsers sign in with a passkey and get a | |
| 24 | + * session cookie. New people create their passkey from an invite link, and | |
| 25 | + * admins can issue one-time sign-in links to add a passkey to an existing | |
| 26 | + * account. Cloudflare Access, when configured, protects only /auth/access and | |
| 27 | + * serves as an email-based recovery path. | |
| 28 | + */ | |
| 29 | + | |
| 30 | +const CEREMONY_TIMEOUT = 120_000; | |
| 31 | + | |
| 32 | +/** Only same-site relative paths are allowed as post-sign-in destinations. */ | |
| 33 | +export function safeNext(next: unknown): string { | |
| 34 | + return typeof next === "string" && next.startsWith("/") && !next.startsWith("//") && !next.startsWith("/\\") ? next : "/"; | |
| 35 | +} | |
| 36 | + | |
| 37 | +const str = (v: unknown): string => (typeof v === "string" ? v : ""); | |
| 38 | + | |
| 39 | +/** A login suggestion from an email address. */ | |
| 40 | +export function loginFromEmail(email: string): string { | |
| 41 | + return (email.split("@")[0] ?? "").replace(/[^A-Za-z0-9-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 39) || "user"; | |
| 42 | +} | |
| 43 | + | |
| 44 | +/** A friendly default name for a new passkey, from the browser's user agent. */ | |
| 45 | +export function deviceLabel(ua: string | undefined): string { | |
| 46 | + const u = ua ?? ""; | |
| 47 | + const os = /iPhone/.test(u) ? "iPhone" : /iPad/.test(u) ? "iPad" : /Android/.test(u) ? "Android" : /Mac OS X|Macintosh/.test(u) ? "Mac" : /Windows/.test(u) ? "Windows" : /CrOS/.test(u) ? "ChromeOS" : /Linux/.test(u) ? "Linux" : ""; | |
| 48 | + const browser = /Edg\//.test(u) ? "Edge" : /Firefox\//.test(u) ? "Firefox" : /Chrome\//.test(u) ? "Chrome" : /Safari\//.test(u) ? "Safari" : ""; | |
| 49 | + return [os, browser].filter(Boolean).join(" · ") || "Passkey"; | |
| 50 | +} | |
| 51 | + | |
| 52 | +async function startSession(c: AppContext, auth: Auth, accountId: string, method: "passkey" | "access" | "link", passkeyId: string | null): Promise<void> { | |
| 53 | + const old = getCookie(c, SESSION_COOKIE); | |
| 54 | + if (old) await auth.endSession(old); | |
| 55 | + const token = await auth.createSession(accountId, method, passkeyId, c.req.header("user-agent") ?? null); | |
| 56 | + setCookie(c, SESSION_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Lax", maxAge: SESSION_DAYS * 86400 }); | |
| 57 | +} | |
| 58 | + | |
| 59 | +function setChallenge(c: AppContext, token: string): void { | |
| 60 | + setCookie(c, CHALLENGE_COOKIE, token, { path: "/", httpOnly: true, secure: true, sameSite: "Strict", maxAge: 300 }); | |
| 61 | +} | |
| 62 | + | |
| 63 | +function takeChallengeCookie(c: AppContext): string | undefined { | |
| 64 | + const v = getCookie(c, CHALLENGE_COOKIE); | |
| 65 | + deleteCookie(c, CHALLENGE_COOKIE, { path: "/", secure: true }); | |
| 66 | + return v; | |
| 67 | +} | |
| 68 | + | |
| 69 | +/** JSON endpoint for the browser's passkey script: JSON in, JSON out, JSON errors. */ | |
| 70 | +function jsonPost(web: WebApp, path: string, handler: (c: AppContext, body: Record<string, unknown>) => Promise<Response>): void { | |
| 71 | + web.post(path, async (c) => { | |
| 72 | + try { | |
| 73 | + // A JSON content type forces a CORS preflight, so other sites cannot drive these endpoints. | |
| 74 | + if (!/^application\/json\b/i.test(c.req.header("content-type") ?? "")) throw new HttpError(415, "expected JSON"); | |
| 75 | + const body = await c.req.json<Record<string, unknown>>().catch(() => { | |
| 76 | + throw new HttpError(400, "invalid JSON"); | |
| 77 | + }); | |
| 78 | + if (!body || typeof body !== "object") throw new HttpError(400, "invalid JSON"); | |
| 79 | + return await handler(c, body); | |
| 80 | + } catch (e) { | |
| 81 | + const s = statusOf(e); | |
| 82 | + if (s.status >= 500) console.error(e); | |
| 83 | + return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message }, s.status as 400); | |
| 84 | + } | |
| 85 | + }); | |
| 86 | +} | |
| 87 | + | |
| 88 | +const transportsOf = (k: PasskeyRow): AuthenticatorTransport[] | undefined => (k.transports ? (JSON.parse(k.transports) as AuthenticatorTransport[]) : undefined); | |
| 89 | + | |
| 90 | +// ---------------------------------------------------------------- pages | |
| 91 | + | |
| 92 | +const KeyMark: FC = () => ( | |
| 93 | + <div class="authmark" aria-hidden="true"> | |
| 94 | + {raw(ICONS.key)} | |
| 95 | + </div> | |
| 96 | +); | |
| 97 | + | |
| 98 | +/** Error slot that the passkey script fills in. */ | |
| 99 | +const PasskeyMessage: FC = () => <div class="flash error" data-passkey-msg role="alert" hidden></div>; | |
| 100 | + | |
| 101 | +const AuthCard: FC<PropsWithChildren<{ title: string }>> = ({ title, children }) => ( | |
| 102 | + <div class="authcard box"> | |
| 103 | + <div class="body"> | |
| 104 | + <KeyMark /> | |
| 105 | + <h1>{title}</h1> | |
| 106 | + {children} | |
| 107 | + </div> | |
| 108 | + </div> | |
| 109 | +); | |
| 110 | + | |
| 111 | +function accessConfigured(c: AppContext): boolean { | |
| 112 | + return !!(c.env.ACCESS_TEAM_DOMAIN && c.env.ACCESS_AUD); | |
| 113 | +} | |
| 114 | + | |
| 115 | +/** Invite landing for someone without an account: create a passkey and join in one step. */ | |
| 116 | +export async function invitePasskeyPage(c: AppContext, token: string): Promise<Response> { | |
| 117 | + const auth = new Auth(c.env); | |
| 118 | + const inv = await auth.invite(token); | |
| 119 | + if (!inv) throw new HttpError(404, "this invite is invalid, already used, or expired"); | |
| 120 | + const exists = inv.email ? await c.get("dir").accountByEmail(inv.email) : null; | |
| 121 | + const next = `/invite/${encodeURIComponent(token)}`; | |
| 122 | + if (!inv.email || exists) { | |
| 123 | + return render( | |
| 124 | + c, | |
| 125 | + { title: "Accept invite" }, | |
| 126 | + <AuthCard title="You have been invited"> | |
| 127 | + <p class="muted"> | |
| 128 | + {inv.email ? ( | |
| 129 | + <> | |
| 130 | + <b>{inv.email}</b> already has a Diffinery account. Sign in with its passkey, and this page will let you accept the invite. | |
| 131 | + </> | |
| 132 | + ) : ( | |
| 133 | + "Sign in to accept this invite." | |
| 134 | + )} | |
| 135 | + </p> | |
| 136 | + <a class="btn primary block" href={`/login?next=${encodeURIComponent(next)}`}> | |
| 137 | + Sign in | |
| 138 | + </a> | |
| 139 | + </AuthCard>, | |
| 140 | + ); | |
| 141 | + } | |
| 142 | + return render( | |
| 143 | + c, | |
| 144 | + { title: "Join Diffinery" }, | |
| 145 | + <AuthCard title="Join Diffinery"> | |
| 146 | + <p class="muted"> | |
| 147 | + You were invited as <b>{inv.email}</b>. Pick a username, then create a passkey. Your device keeps it and unlocks it with your fingerprint, face or screen lock, so there is no password to remember. | |
| 148 | + </p> | |
| 149 | + <form data-passkey="register" data-next="/"> | |
| 150 | + <input type="hidden" name="invite" value={token} /> | |
| 151 | + <div class="field"> | |
| 152 | + <label for="login">Username</label> | |
| 153 | + <input id="login" name="login" type="text" value={loginFromEmail(inv.email)} required pattern="[A-Za-z0-9][A-Za-z0-9_\-]{0,38}" autocomplete="username" /> | |
| 154 | + <div class="hint">Your repositories live at /{"{username}"}/…</div> | |
| 155 | + </div> | |
| 156 | + <div class="field"> | |
| 157 | + <label for="name">Display name</label> | |
| 158 | + <input id="name" name="name" type="text" maxlength={100} autocomplete="name" /> | |
| 159 | + </div> | |
| 160 | + <PasskeyMessage /> | |
| 161 | + <button class="btn primary block">Create a passkey and join</button> | |
| 162 | + </form> | |
| 163 | + </AuthCard>, | |
| 164 | + ); | |
| 165 | +} | |
| 166 | + | |
| 167 | +const PasskeyUnsupported: FC = () => ( | |
| 168 | + <noscript> | |
| 169 | + <p class="flash error">Passkeys need JavaScript.</p> | |
| 170 | + </noscript> | |
| 171 | +); | |
| 172 | + | |
| 173 | +export function registerAuth(web: WebApp): void { | |
| 174 | + web.get("/login", async (c) => { | |
| 175 | + const next = safeNext(c.req.query("next")); | |
| 176 | + const p = c.get("principal"); | |
| 177 | + if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302); | |
| 178 | + return render( | |
| 179 | + c, | |
| 180 | + { title: "Sign in" }, | |
| 181 | + <AuthCard title="Sign in to Diffinery"> | |
| 182 | + <p class="muted">Use the passkey saved on this device, your phone or your password manager.</p> | |
| 183 | + <PasskeyUnsupported /> | |
| 184 | + <form data-passkey="login" data-next={next}> | |
| 185 | + <PasskeyMessage /> | |
| 186 | + <button class="btn primary block">Sign in with a passkey</button> | |
| 187 | + </form> | |
| 188 | + <div class="authfoot muted small"> | |
| 189 | + <p>New here? Diffinery is invite-only. Open the invite link someone sent you to create your passkey.</p> | |
| 190 | + {accessConfigured(c) ? ( | |
| 191 | + <p> | |
| 192 | + Lost your passkey? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Recover your account by email</a>. | |
| 193 | + </p> | |
| 194 | + ) : ( | |
| 195 | + <p>Lost your passkey? Ask an administrator for a sign-in link.</p> | |
| 196 | + )} | |
| 197 | + </div> | |
| 198 | + </AuthCard>, | |
| 199 | + ); | |
| 200 | + }); | |
| 201 | + | |
| 202 | + // Admin-issued sign-in link: add a passkey to an existing account. | |
| 203 | + web.get("/signin/:token", async (c) => { | |
| 204 | + const token = prm(c, "token"); | |
| 205 | + const link = await new Auth(c.env).signinLink(token); | |
| 206 | + if (!link) throw new HttpError(404, "this sign-in link is invalid, already used, or expired"); | |
| 207 | + return render( | |
| 208 | + c, | |
| 209 | + { title: "Add a passkey" }, | |
| 210 | + <AuthCard title="Add a passkey"> | |
| 211 | + <p class="muted"> | |
| 212 | + This link lets you add a passkey to <b>{link.account.login}</b> | |
| 213 | + {link.account.email ? ` (${link.account.email})` : ""}. It works once. After that, sign in with the passkey. | |
| 214 | + </p> | |
| 215 | + <form data-passkey="register" data-next="/settings/security"> | |
| 216 | + <input type="hidden" name="link" value={token} /> | |
| 217 | + <div class="field"> | |
| 218 | + <label for="label">Name for this passkey</label> | |
| 219 | + <input id="label" name="label" type="text" maxlength={100} placeholder={deviceLabel(c.req.header("user-agent"))} /> | |
| 220 | + </div> | |
| 221 | + <PasskeyMessage /> | |
| 222 | + <button class="btn primary block">Create a passkey</button> | |
| 223 | + </form> | |
| 224 | + </AuthCard>, | |
| 225 | + ); | |
| 226 | + }); | |
| 227 | + | |
| 228 | + // ------------------------------------------------------------ sign in | |
| 229 | + | |
| 230 | + jsonPost(web, "/auth/passkey/login/options", async (c) => { | |
| 231 | + const auth = new Auth(c.env); | |
| 232 | + const rp = auth.rp(); | |
| 233 | + // No allowCredentials: the browser offers any passkey it holds for this site (discoverable credentials). | |
| 234 | + const opts = await generateAuthenticationOptions({ rpID: rp.id, userVerification: "required", timeout: CEREMONY_TIMEOUT }); | |
| 235 | + setChallenge(c, await auth.saveChallenge("login", opts.challenge, { kind: "login" })); | |
| 236 | + return c.json(opts); | |
| 237 | + }); | |
| 238 | + | |
| 239 | + jsonPost(web, "/auth/passkey/login/verify", async (c, body) => { | |
| 240 | + const auth = new Auth(c.env); | |
| 241 | + const dir = c.get("dir"); | |
| 242 | + const rp = auth.rp(); | |
| 243 | + const ch = await auth.takeChallenge(takeChallengeCookie(c), "login"); | |
| 244 | + if (!ch) throw new HttpError(400, "This sign-in attempt expired. Please try again."); | |
| 245 | + const response = body.response as AuthenticationResponseJSON | undefined; | |
| 246 | + if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response"); | |
| 247 | + const key = await auth.passkey(response.id); | |
| 248 | + if (!key) throw new HttpError(401, "This passkey is not registered with Diffinery. It may have been removed."); | |
| 249 | + const result = await verifyAuthenticationResponse({ | |
| 250 | + response, | |
| 251 | + expectedChallenge: ch.challenge, | |
| 252 | + expectedOrigin: rp.origin, | |
| 253 | + expectedRPID: rp.id, | |
| 254 | + requireUserVerification: true, | |
| 255 | + credential: { id: key.id, publicKey: new Uint8Array(fromB64url(key.public_key)), counter: key.counter, transports: transportsOf(key) }, | |
| 256 | + }).catch((e: Error) => { | |
| 257 | + throw new HttpError(401, `Passkey check failed: ${e.message}`); | |
| 258 | + }); | |
| 259 | + if (!result.verified) throw new HttpError(401, "Passkey check failed."); | |
| 260 | + const account = await dir.account(key.account_id); | |
| 261 | + if (!account) throw new HttpError(401, "This passkey's account no longer exists."); | |
| 262 | + if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); | |
| 263 | + await auth.passkeyUsed(key.id, result.authenticationInfo.newCounter, result.authenticationInfo.credentialBackedUp); | |
| 264 | + await startSession(c, auth, account.id, "passkey", key.id); | |
| 265 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "passkey", passkey: key.name } })); | |
| 266 | + return c.json({ redirect: safeNext(body.next) }); | |
| 267 | + }); | |
| 268 | + | |
| 269 | + // ------------------------------------------------------------ register a passkey | |
| 270 | + | |
| 271 | + jsonPost(web, "/auth/passkey/register/options", async (c, body) => { | |
| 272 | + const auth = new Auth(c.env); | |
| 273 | + const dir = c.get("dir"); | |
| 274 | + const rp = auth.rp(); | |
| 275 | + let subject: Subject; | |
| 276 | + let user: { id: string; name: string; displayName: string }; | |
| 277 | + let existing: PasskeyRow[] = []; | |
| 278 | + const inviteToken = str(body.invite); | |
| 279 | + const linkToken = str(body.link); | |
| 280 | + if (inviteToken) { | |
| 281 | + const inv = await auth.invite(inviteToken); | |
| 282 | + if (!inv) throw new HttpError(404, "This invite is invalid, already used, or expired."); | |
| 283 | + if (!inv.email) throw new HttpError(422, "This invite has no email address. Sign in first, then open it again."); | |
| 284 | + if (await dir.accountByEmail(inv.email)) throw new HttpError(409, `${inv.email} already has an account. Sign in with its passkey, then open this invite again.`); | |
| 285 | + const login = str(body.login).trim() || loginFromEmail(inv.email); | |
| 286 | + if (!isValidLogin(login) || login.length > 39) throw new HttpError(422, "Usernames use letters, numbers and dashes (up to 39), and some names are reserved."); | |
| 287 | + if (await dir.accountByLogin(login)) throw new HttpError(409, `The username ${login} is taken. Pick another one.`); | |
| 288 | + const name = str(body.name).trim().slice(0, 100); | |
| 289 | + const id = newId(); | |
| 290 | + subject = { kind: "invite", inviteHash: inv.hash, email: inv.email, login, name, accountId: id }; | |
| 291 | + user = { id, name: login, displayName: name || login }; | |
| 292 | + } else if (linkToken) { | |
| 293 | + const link = await auth.signinLink(linkToken); | |
| 294 | + if (!link) throw new HttpError(404, "This sign-in link is invalid, already used, or expired."); | |
| 295 | + if (link.account.status === "disabled") throw new HttpError(403, "This account is disabled."); | |
| 296 | + subject = { kind: "link", linkHash: link.hash, accountId: link.account.id }; | |
| 297 | + user = { id: link.account.id, name: link.account.login, displayName: link.account.name ?? link.account.login }; | |
| 298 | + existing = await auth.passkeys(link.account.id); | |
| 299 | + } else { | |
| 300 | + const me = browserAccount(c); | |
| 301 | + subject = { kind: "account", accountId: me.id }; | |
| 302 | + user = { id: me.id, name: me.login, displayName: me.name ?? me.login }; | |
| 303 | + existing = await auth.passkeys(me.id); | |
| 304 | + } | |
| 305 | + const opts = await generateRegistrationOptions({ | |
| 306 | + rpName: rp.name, | |
| 307 | + rpID: rp.id, | |
| 308 | + userName: user.name, | |
| 309 | + userDisplayName: user.displayName, | |
| 310 | + userID: new Uint8Array(new TextEncoder().encode(user.id)), | |
| 311 | + attestationType: "none", | |
| 312 | + excludeCredentials: existing.map((k) => ({ id: k.id, transports: transportsOf(k) })), | |
| 313 | + authenticatorSelection: { residentKey: "required", userVerification: "required" }, | |
| 314 | + timeout: CEREMONY_TIMEOUT, | |
| 315 | + }); | |
| 316 | + setChallenge(c, await auth.saveChallenge("register", opts.challenge, subject)); | |
| 317 | + return c.json(opts); | |
| 318 | + }); | |
| 319 | + | |
| 320 | + jsonPost(web, "/auth/passkey/register/verify", async (c, body) => { | |
| 321 | + const auth = new Auth(c.env); | |
| 322 | + const dir = c.get("dir"); | |
| 323 | + const rp = auth.rp(); | |
| 324 | + const ch = await auth.takeChallenge(takeChallengeCookie(c), "register"); | |
| 325 | + if (!ch) throw new HttpError(400, "This attempt expired. Please try again."); | |
| 326 | + const response = body.response as RegistrationResponseJSON | undefined; | |
| 327 | + if (!response || typeof response.id !== "string") throw new HttpError(400, "missing passkey response"); | |
| 328 | + const result = await verifyRegistrationResponse({ response, expectedChallenge: ch.challenge, expectedOrigin: rp.origin, expectedRPID: rp.id, requireUserVerification: true }).catch((e: Error) => { | |
| 329 | + throw new HttpError(400, `The passkey could not be verified: ${e.message}`); | |
| 330 | + }); | |
| 331 | + if (!result.verified) throw new HttpError(400, "The passkey could not be verified."); | |
| 332 | + const info = result.registrationInfo; | |
| 333 | + if (await auth.passkey(info.credential.id)) throw new HttpError(409, "This passkey is already registered."); | |
| 334 | + const label = str(body.label).trim() || deviceLabel(c.req.header("user-agent")); | |
| 335 | + const key = { | |
| 336 | + id: info.credential.id, | |
| 337 | + public_key: b64url(info.credential.publicKey), | |
| 338 | + counter: info.credential.counter, | |
| 339 | + transports: info.credential.transports?.length ? JSON.stringify(info.credential.transports) : null, | |
| 340 | + device_type: info.credentialDeviceType, | |
| 341 | + backed_up: info.credentialBackedUp ? 1 : 0, | |
| 342 | + name: label, | |
| 343 | + }; | |
| 344 | + const s = ch.subject; | |
| 345 | + let account: Account; | |
| 346 | + let redirect: string; | |
| 347 | + if (s.kind === "account") { | |
| 348 | + const me = browserAccount(c); | |
| 349 | + if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey."); | |
| 350 | + await auth.addPasskey({ ...key, account_id: me.id }); | |
| 351 | + c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label } })); | |
| 352 | + flash(c, "notice", `Passkey "${label}" added.`); | |
| 353 | + return c.json({ redirect: "/settings/security" }); | |
| 354 | + } else if (s.kind === "invite") { | |
| 355 | + // Re-check: the invite or the username may have been taken while the browser was busy. | |
| 356 | + if (!(await auth.inviteByHash(s.inviteHash))) throw new HttpError(404, "This invite was used or expired in the meantime."); | |
| 357 | + if (await dir.accountByEmail(s.email)) throw new HttpError(409, `${s.email} already has an account.`); | |
| 358 | + if (await dir.accountByLogin(s.login)) throw new HttpError(409, `The username ${s.login} was just taken. Pick another one.`); | |
| 359 | + // Creating the account accepts every open invite for this email, including this one. | |
| 360 | + account = await dir.userForEmail(s.email, { login: s.login, name: s.name || undefined, id: s.accountId }); | |
| 361 | + await auth.addPasskey({ ...key, account_id: account.id }); | |
| 362 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "invite.accept", detail: { method: "passkey" } })); | |
| 363 | + flash(c, "notice", `Welcome to Diffinery, ${account.login}. You can sign in with this passkey from now on.`); | |
| 364 | + redirect = "/"; | |
| 365 | + } else if (s.kind === "link") { | |
| 366 | + if (!(await auth.useSigninLink(s.linkHash))) throw new HttpError(404, "This sign-in link was used or expired in the meantime."); | |
| 367 | + const found = await dir.account(s.accountId); | |
| 368 | + if (!found || found.status === "disabled") throw new HttpError(403, "This account is disabled."); | |
| 369 | + account = found; | |
| 370 | + await auth.addPasskey({ ...key, account_id: account.id }); | |
| 371 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "passkey.add", detail: { name: label, via: "signin-link" } })); | |
| 372 | + flash(c, "notice", `Passkey "${label}" added. Use it to sign in from now on.`); | |
| 373 | + redirect = "/settings/security"; | |
| 374 | + } else { | |
| 375 | + throw new HttpError(400, "unexpected sign-in attempt"); | |
| 376 | + } | |
| 377 | + await startSession(c, auth, account.id, s.kind === "link" ? "link" : "passkey", key.id); | |
| 378 | + return c.json({ redirect }); | |
| 379 | + }); | |
| 380 | + | |
| 381 | + // ------------------------------------------------------------ sign out | |
| 382 | + | |
| 383 | + post(web, "/auth/logout", async (c) => { | |
| 384 | + const token = getCookie(c, SESSION_COOKIE); | |
| 385 | + if (token) await new Auth(c.env).endSession(token); | |
| 386 | + deleteCookie(c, SESSION_COOKIE, { path: "/", secure: true }); | |
| 387 | + return c.redirect("/login", 303); | |
| 388 | + }); | |
| 389 | + | |
| 390 | + // ------------------------------------------------------------ recovery through Cloudflare Access | |
| 391 | + | |
| 392 | + // Cloudflare Access protects only this path. It verifies the person's email, | |
| 393 | + // and this handler turns that into a session so they can add a new passkey. | |
| 394 | + web.get("/auth/access", async (c) => { | |
| 395 | + if (!accessConfigured(c)) throw new HttpError(404, "Account recovery by email is not set up on this instance. Ask an administrator for a sign-in link."); | |
| 396 | + const jwt = accessToken(c.req.raw); | |
| 397 | + if (!jwt) throw new HttpError(401, "Cloudflare Access did not sign this request. Open this page again to start the email check."); | |
| 398 | + const claims = await verifyAccessJwt(jwt, c.env.ACCESS_TEAM_DOMAIN, c.env.ACCESS_AUD).catch((e: Error) => { | |
| 399 | + throw new HttpError(401, `Cloudflare Access sign-in was rejected: ${e.message}`); | |
| 400 | + }); | |
| 401 | + const email = typeof claims.email === "string" ? claims.email : null; | |
| 402 | + if (!email) throw new HttpError(401, "Cloudflare Access did not provide an email address."); | |
| 403 | + const dir = c.get("dir"); | |
| 404 | + const account = await dir.userForEmail(email, { name: typeof claims.name === "string" ? claims.name : undefined }); | |
| 405 | + if (account.status === "disabled") throw new HttpError(403, "Your account is disabled."); | |
| 406 | + const auth = new Auth(c.env); | |
| 407 | + await startSession(c, auth, account.id, "access", null); | |
| 408 | + c.executionCtx.waitUntil(dir.audit({ actorId: account.id, actorLogin: account.login, action: "session.create", detail: { method: "access" } })); | |
| 409 | + if (account.status === "pending") return c.redirect("/", 302); | |
| 410 | + const keys = await auth.passkeys(account.id); | |
| 411 | + if (keys.length) return c.redirect(safeNext(c.req.query("next")), 302); | |
| 412 | + flash(c, "notice", "You're signed in. Add a passkey now so you can sign in directly next time."); | |
| 413 | + return c.redirect("/settings/security", 302); | |
| 414 | + }); | |
| 415 | +} | |
| 416 | + | |
| 417 | +/** The account behind a browser session (or dev sign-in). API tokens cannot manage passkeys. */ | |
| 418 | +export function browserAccount(c: AppContext): Account { | |
| 419 | + const p = c.get("principal"); | |
| 420 | + if (p.type !== "account" || p.via === "token") throw new HttpError(401, "Sign in with your passkey first."); | |
| 421 | + if (p.account.kind !== "user") throw new HttpError(403, "only people can have passkeys"); | |
| 422 | + if (p.account.status === "disabled") throw new HttpError(403, "your account is disabled"); | |
| 423 | + return p.account; | |
| 424 | +} |
modifiedapps/diffinery/src/web/repo-settings.tsx+1−1View file
| @@ -235,7 +235,7 @@ | ||
| 235 | 235 | </> |
| 236 | 236 | )} |
| 237 | 237 | </p> |
| 238 | - {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days and only works for that email address. The person must also be allowed by your Cloudflare Access policy." /> : null} | |
| 238 | + {invite ? <Once title={`Invite created for ${invite.email}`} value={invite.url} note="Send this link. It expires in 14 days. Opening it lets them create a passkey and join." /> : null} | |
| 239 | 239 | <div class="box"> |
| 240 | 240 | {collaborators.length ? ( |
| 241 | 241 | <table class="tbl"> |
modifiedapps/diffinery/src/web/routes.tsx+4−2View file
| @@ -3,6 +3,7 @@ | ||
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | 4 | import type { AppVars } from "../auth/principal.js"; |
| 5 | 5 | import { registerAccount } from "./account.js"; |
| 6 | +import { registerAuth } from "./auth.js"; | |
| 6 | 7 | import { registerActions } from "./actions.js"; |
| 7 | 8 | import { registerCode } from "./code.js"; |
| 8 | 9 | import { registerCollab } from "./collab.js"; |
| @@ -12,7 +13,7 @@ | ||
| 12 | 13 | |
| 13 | 14 | /** |
| 14 | 15 | * Server-rendered web UI. Mount at "/" after the API, Git and runner routes: |
| 15 | - * it owns `/`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 16 | + * it owns `/`, `/login`, `/auth/*`, `/signin/*`, `/new`, `/settings/*`, `/notifications/*`, `/invite/*`, `/-/*`, | |
| 16 | 17 | * `/:owner` and `/:owner/:repo/...` (including `/:owner/:repo/raw/*`). |
| 17 | 18 | * |
| 18 | 19 | * Expects the `principal` and `dir` variables to be set by an earlier middleware. |
| @@ -23,11 +24,12 @@ | ||
| 23 | 24 | web.onError((err, c) => errorPage(c, err)); |
| 24 | 25 | |
| 25 | 26 | // Browsers and crawlers ask for these on every origin; keep them off the /:owner route. |
| 26 | -web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#5e6ad2"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" })); | |
| 27 | +web.get("/favicon.ico", (c) => c.body(ICONS.logo.replace(/currentColor/g, "#f6821f"), 200, { "content-type": "image/svg+xml", "cache-control": "public, max-age=86400" })); | |
| 27 | 28 | web.get("/robots.txt", (c) => c.text("User-agent: *\nDisallow: /\n", 200, { "cache-control": "public, max-age=86400" })); |
| 28 | 29 | |
| 29 | 30 | // Order matters: fixed paths (/settings/tokens, /notifications/:id, …) must be |
| 30 | 31 | // registered before the /:owner/:repo patterns they would otherwise match. |
| 32 | +registerAuth(web); | |
| 31 | 33 | registerAccount(web); |
| 32 | 34 | registerRepoSettings(web); |
| 33 | 35 | registerActions(web); |
modifiedapps/diffinery/src/web/ui.tsx+28−6View file
| @@ -335,7 +335,7 @@ | ||
| 335 | 335 | <meta name="viewport" content="width=device-width, initial-scale=1" /> |
| 336 | 336 | <meta name="color-scheme" content="light dark" /> |
| 337 | 337 | <title>{opts.title ? `${opts.title} · Diffinery` : "Diffinery"}</title> |
| 338 | - <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#5e6ad2"))}`} /> | |
| 338 | + <link rel="icon" href={`data:image/svg+xml,${encodeURIComponent(ICONS.logo.replace("currentColor", "#f6821f"))}`} /> | |
| 339 | 339 | <style>{raw(CSS)}</style> |
| 340 | 340 | </head> |
| 341 | 341 | <body |
| @@ -369,9 +369,21 @@ | ||
| 369 | 369 | <a href="/settings" title="Settings" class="who"> |
| 370 | 370 | <Avatar login={me.login} /> <span>{me.login}</span> |
| 371 | 371 | </a> |
| 372 | - {p?.type === "account" && p.via === "access" ? <a href="/cdn-cgi/access/logout">Sign out</a> : null} | |
| 373 | 372 | </> |
| 374 | 373 | ) : null} |
| 374 | + {p?.type === "account" && p.via === "session" ? ( | |
| 375 | + <form method="post" action="/auth/logout" class="inline"> | |
| 376 | + <input type="hidden" name={CSRF_FIELD} value={csrfToken(c)} /> | |
| 377 | + <button class="navbtn">Sign out</button> | |
| 378 | + </form> | |
| 379 | + ) : null} | |
| 380 | + {!p || p.type === "anonymous" ? ( | |
| 381 | + c.req.path === "/login" ? null : ( | |
| 382 | + <a href={`/login${c.req.path === "/" ? "" : `?next=${encodeURIComponent(c.req.path)}`}`} class="btn primary sm"> | |
| 383 | + Sign in | |
| 384 | + </a> | |
| 385 | + ) | |
| 386 | + ) : null} | |
| 375 | 387 | </nav> |
| 376 | 388 | </div> |
| 377 | 389 | </header> |
| @@ -408,6 +420,11 @@ | ||
| 408 | 420 | export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> { |
| 409 | 421 | const s = statusOf(e); |
| 410 | 422 | if (s.status >= 500) console.error(e); |
| 423 | + // Signed-out visitors who open a page go straight to sign-in, and come back afterwards. | |
| 424 | + if (s.status === 401 && c.req.method === "GET" && c.get("principal")?.type === "anonymous" && !c.req.path.startsWith("/auth/")) { | |
| 425 | + const url = new URL(c.req.url); | |
| 426 | + return c.redirect(`/login?next=${encodeURIComponent(url.pathname + url.search)}`, 302); | |
| 427 | + } | |
| 411 | 428 | const titles: Record<number, string> = { 401: "Sign in required", 403: "Access denied", 404: "Not found", 405: "Not allowed", 409: "Conflict", 422: "Could not complete the request" }; |
| 412 | 429 | const title = titles[s.status] ?? (s.status >= 500 ? "Something went wrong" : "Request failed"); |
| 413 | 430 | const p = c.get("principal"); |
| @@ -418,9 +435,14 @@ | ||
| 418 | 435 | <div class="faint" style="font-size:48px;font-weight:700;letter-spacing:-.03em">{s.status}</div> |
| 419 | 436 | <h1>{title}</h1> |
| 420 | 437 | {s.status === 401 ? ( |
| 421 | - <p class="muted"> | |
| 422 | - Sign in through Cloudflare Access to continue. If you were just signed out, <a href={c.req.path}>reload this page</a> to start the Access sign-in flow. | |
| 423 | - </p> | |
| 438 | + <> | |
| 439 | + <p class="muted">{p?.type === "account" ? s.message : "Sign in with your passkey to continue."}</p> | |
| 440 | + <p> | |
| 441 | + <a class="btn primary" href={`/login?next=${encodeURIComponent(c.req.path)}`}> | |
| 442 | + Sign in | |
| 443 | + </a> | |
| 444 | + </p> | |
| 445 | + </> | |
| 424 | 446 | ) : s.status === 404 ? ( |
| 425 | 447 | <p class="muted">The page or repository does not exist, or you do not have access to it.</p> |
| 426 | 448 | ) : ( |
| @@ -432,7 +454,7 @@ | ||
| 432 | 454 | Request access |
| 433 | 455 | </a> |
| 434 | 456 | </p> |
| 435 | - ) : ( | |
| 457 | + ) : s.status === 401 ? null : ( | |
| 436 | 458 | <p> |
| 437 | 459 | <a class="btn" href="/"> |
| 438 | 460 | Go to dashboard |
addedapps/diffinery/test/e2e/passkeys.test.mjs+236−0View file
| @@ -1,0 +1,236 @@ | ||
| 1 | +// End-to-end passkey sign-in: a real `wrangler dev` Worker and a real Chromium | |
| 2 | +// with a virtual WebAuthn authenticator (Chrome DevTools Protocol). | |
| 3 | +// | |
| 4 | +// pnpm --filter @diffinery/app e2e | |
| 5 | +// | |
| 6 | +// Covers: signed-out redirects, joining from an invite link with a new passkey | |
| 7 | +// (which also applies the invite's org membership), adding and removing | |
| 8 | +// passkeys, signing out and back in, admin-issued one-time sign-in links, | |
| 9 | +// single-use invites and links, and refusing cross-site and token-driven calls. | |
| 10 | +import { spawn, execFileSync } from "node:child_process"; | |
| 11 | +import { createHash } from "node:crypto"; | |
| 12 | +import { mkdtempSync, rmSync } from "node:fs"; | |
| 13 | +import { tmpdir } from "node:os"; | |
| 14 | +import { join, dirname, resolve } from "node:path"; | |
| 15 | +import { fileURLToPath } from "node:url"; | |
| 16 | +import { createServer } from "node:net"; | |
| 17 | +import { test, before, after } from "node:test"; | |
| 18 | +import assert from "node:assert/strict"; | |
| 19 | +import { chromium } from "playwright"; | |
| 20 | + | |
| 21 | +const APP = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); | |
| 22 | +const work = mkdtempSync(join(tmpdir(), "diffinery-passkeys-")); | |
| 23 | +let base; | |
| 24 | +let dev; | |
| 25 | +let devLog = ""; | |
| 26 | +let browser; | |
| 27 | + | |
| 28 | +const INVITE = "invite-token-for-pal-0123456789abcdef"; | |
| 29 | +const ADMIN_LINK = "signin-link-for-boss-0123456789abcdef"; | |
| 30 | +const hash = (s) => createHash("sha256").update(s).digest("hex"); | |
| 31 | + | |
| 32 | +const freePort = () => | |
| 33 | + new Promise((res) => { | |
| 34 | + const s = createServer(); | |
| 35 | + s.listen(0, "127.0.0.1", () => { | |
| 36 | + const { port } = s.address(); | |
| 37 | + s.close(() => res(port)); | |
| 38 | + }); | |
| 39 | + }); | |
| 40 | + | |
| 41 | +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); | |
| 42 | +async function until(what, fn, timeoutMs = 60_000) { | |
| 43 | + const end = Date.now() + timeoutMs; | |
| 44 | + while (Date.now() < end) { | |
| 45 | + const v = await fn(); | |
| 46 | + if (v) return v; | |
| 47 | + await sleep(500); | |
| 48 | + } | |
| 49 | + assert.fail(`timed out waiting for ${what}\n--- wrangler log tail ---\n${devLog.slice(-4000)}`); | |
| 50 | +} | |
| 51 | + | |
| 52 | +/** A fresh browser profile with its own virtual platform authenticator (Touch ID-like, user-verifying). */ | |
| 53 | +async function person() { | |
| 54 | + const context = await browser.newContext(); | |
| 55 | + const page = await context.newPage(); | |
| 56 | + page.on("dialog", (d) => d.accept()); | |
| 57 | + const cdp = await context.newCDPSession(page); | |
| 58 | + await cdp.send("WebAuthn.enable"); | |
| 59 | + const addAuthenticator = async () => | |
| 60 | + ( | |
| 61 | + await cdp.send("WebAuthn.addVirtualAuthenticator", { | |
| 62 | + options: { protocol: "ctap2", transport: "internal", hasResidentKey: true, hasUserVerification: true, isUserVerified: true, automaticPresenceSimulation: true }, | |
| 63 | + }) | |
| 64 | + ).authenticatorId; | |
| 65 | + let authenticatorId = await addAuthenticator(); | |
| 66 | + return { | |
| 67 | + context, | |
| 68 | + page, | |
| 69 | + /** Swap in a new, empty authenticator (another device). */ | |
| 70 | + async newDevice() { | |
| 71 | + await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); | |
| 72 | + authenticatorId = await addAuthenticator(); | |
| 73 | + }, | |
| 74 | + async credentials() { | |
| 75 | + return (await cdp.send("WebAuthn.getCredentials", { authenticatorId })).credentials; | |
| 76 | + }, | |
| 77 | + }; | |
| 78 | +} | |
| 79 | + | |
| 80 | +const text = async (page) => (await page.textContent("main")) ?? ""; | |
| 81 | + | |
| 82 | +before(async () => { | |
| 83 | + const port = await freePort(); | |
| 84 | + // WebAuthn needs a secure context and a domain RP ID: localhost qualifies, 127.0.0.1 does not. | |
| 85 | + base = `http://localhost:${port}`; | |
| 86 | + const persist = join(work, "state"); | |
| 87 | + const wrangler = (...args) => execFileSync("pnpm", ["exec", "wrangler", ...args], { cwd: APP, stdio: "pipe", env: { ...process.env, CI: "1" } }); | |
| 88 | + wrangler("d1", "migrations", "apply", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist); | |
| 89 | + const at = new Date().toISOString(); | |
| 90 | + const later = new Date(Date.now() + 86400_000).toISOString(); | |
| 91 | + wrangler( | |
| 92 | + "d1", "execute", "diffinery-dev", "--local", "-e", "dev", "--persist-to", persist, "--command", | |
| 93 | + [ | |
| 94 | + `INSERT INTO accounts (id, login, kind, name, email, status, site_admin, created_at) VALUES ('boss1', 'boss', 'user', 'The Boss', 'boss@example.com', 'active', 1, '${at}')`, | |
| 95 | + `INSERT INTO accounts (id, login, kind, name, status, created_at) VALUES ('crew1', 'crew', 'org', 'Crew', 'active', '${at}')`, | |
| 96 | + `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('crew1', 'boss1', 'owner', '${at}')`, | |
| 97 | + `INSERT INTO invites (id, org_id, email, role, token_hash, invited_by, created_at, expires_at) VALUES ('inv1', 'crew1', 'pal@example.com', 'member', '${hash(INVITE)}', 'boss1', '${at}', '${later}')`, | |
| 98 | + `INSERT INTO signin_links (id, token_hash, account_id, created_by, created_at, expires_at) VALUES ('link1', '${hash(ADMIN_LINK)}', 'boss1', 'boss1', '${at}', '${later}')`, | |
| 99 | + ].join("; "), | |
| 100 | + ); | |
| 101 | + dev = spawn( | |
| 102 | + "pnpm", | |
| 103 | + ["exec", "wrangler", "dev", "-e", "dev", "--ip", "127.0.0.1", "--port", String(port), "--persist-to", persist, "--var", `PUBLIC_URL:${base}`, "--var", "DEV_AUTH:", "--show-interactive-dev-session=false"], | |
| 104 | + { cwd: APP, env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, stdio: ["ignore", "pipe", "pipe"], detached: true }, | |
| 105 | + ); | |
| 106 | + dev.stdout.on("data", (d) => (devLog += d)); | |
| 107 | + dev.stderr.on("data", (d) => (devLog += d)); | |
| 108 | + await until( | |
| 109 | + "wrangler dev", | |
| 110 | + async () => { | |
| 111 | + try { | |
| 112 | + return (await fetch(`http://127.0.0.1:${port}/healthz`)).ok; | |
| 113 | + } catch { | |
| 114 | + return false; | |
| 115 | + } | |
| 116 | + }, | |
| 117 | + 120_000, | |
| 118 | + ); | |
| 119 | + browser = await chromium.launch(); | |
| 120 | +}); | |
| 121 | + | |
| 122 | +after(async () => { | |
| 123 | + await browser?.close(); | |
| 124 | + if (dev?.pid) { | |
| 125 | + try { | |
| 126 | + process.kill(-dev.pid, "SIGTERM"); | |
| 127 | + } catch {} | |
| 128 | + } | |
| 129 | + if (process.env.E2E_KEEP !== "1") rmSync(work, { recursive: true, force: true }); | |
| 130 | +}); | |
| 131 | + | |
| 132 | +test("passkey sign-in, invites and sign-in links", async () => { | |
| 133 | + // ------------------------------------------------------------ signed out | |
| 134 | + const pal = await person(); | |
| 135 | + const { page } = pal; | |
| 136 | + await page.goto(`${base}/settings`); | |
| 137 | + assert.equal(new URL(page.url()).pathname, "/login"); | |
| 138 | + assert.equal(new URL(page.url()).searchParams.get("next"), "/settings"); | |
| 139 | + assert.match(await text(page), /Sign in with a passkey/); | |
| 140 | + | |
| 141 | + // A passkey nobody registered is refused. | |
| 142 | + await page.click("text=Sign in with a passkey"); | |
| 143 | + await page.waitForSelector("[data-passkey-msg]:not([hidden])"); | |
| 144 | + assert.match(await page.textContent("[data-passkey-msg]"), /passkey|cancelled|closed/i); | |
| 145 | + | |
| 146 | + // ------------------------------------------------------------ join from an invite link | |
| 147 | + await page.goto(`${base}/invite/${INVITE}`); | |
| 148 | + assert.match(await text(page), /invited as pal@example\.com/); | |
| 149 | + await page.fill("#login", "pal"); | |
| 150 | + await page.fill("#name", "Pal Friend"); | |
| 151 | + await Promise.all([page.waitForURL(`${base}/`), page.click("text=Create a passkey and join")]); | |
| 152 | + assert.match(await text(page), /Welcome to Diffinery, pal/); | |
| 153 | + assert.equal((await pal.credentials()).length, 1); | |
| 154 | + const cookies = await pal.context.cookies(base); | |
| 155 | + const session = cookies.find((c) => c.name === "__Host-dfy_session"); | |
| 156 | + assert.ok(session, `session cookie missing: ${JSON.stringify(cookies.map((c) => c.name))}`); | |
| 157 | + assert.equal(session.httpOnly, true); | |
| 158 | + assert.equal(session.sameSite, "Lax"); | |
| 159 | + // The invite's org membership was applied. | |
| 160 | + await page.goto(`${base}/settings/orgs`); | |
| 161 | + assert.match(await text(page), /crew/); | |
| 162 | + | |
| 163 | + // Invites work once. | |
| 164 | + const stranger = await person(); | |
| 165 | + const reused = await stranger.page.goto(`${base}/invite/${INVITE}`); | |
| 166 | + assert.equal(reused.status(), 404); | |
| 167 | + | |
| 168 | + // ------------------------------------------------------------ manage passkeys | |
| 169 | + await page.goto(`${base}/settings/security`); | |
| 170 | + let body = await text(page); | |
| 171 | + assert.match(body, /Passkeys and sessions/); | |
| 172 | + assert.match(body, /this browser/); | |
| 173 | + assert.match(body, /Only passkey/); | |
| 174 | + await pal.newDevice(); | |
| 175 | + await page.fill("#pklabel", "Laptop"); | |
| 176 | + await Promise.all([page.waitForLoadState("load"), page.click("button:has-text('Add a passkey')")]); | |
| 177 | + await page.waitForSelector("text=Passkey \"Laptop\" added."); | |
| 178 | + body = await text(page); | |
| 179 | + assert.match(body, /Laptop/); | |
| 180 | + assert.equal(await page.locator("button:has-text('Remove')").count(), 2); | |
| 181 | + // Remove the first device's passkey (the one this browser no longer holds). This browser stays signed in. | |
| 182 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.locator("li:not(:has-text('Laptop')) button:has-text('Remove')").first().click()]); | |
| 183 | + await page.waitForSelector("text=Passkey removed."); | |
| 184 | + body = await text(page); | |
| 185 | + assert.match(body, /Only passkey/); | |
| 186 | + assert.match(body, /Laptop/); | |
| 187 | + | |
| 188 | + // ------------------------------------------------------------ sign out, then in with the remaining passkey | |
| 189 | + await Promise.all([page.waitForURL(/\/login$/), page.click("header button:has-text('Sign out')")]); | |
| 190 | + await page.goto(`${base}/settings`); | |
| 191 | + assert.equal(new URL(page.url()).pathname, "/login"); | |
| 192 | + await page.goto(`${base}/login?next=/settings/security`); | |
| 193 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("text=Sign in with a passkey")]); | |
| 194 | + assert.match(await text(page), /Laptop/); | |
| 195 | + | |
| 196 | + // ------------------------------------------------------------ admin sign-in links | |
| 197 | + const boss = await person(); | |
| 198 | + await boss.page.goto(`${base}/signin/${ADMIN_LINK}`); | |
| 199 | + assert.match(await text(boss.page), /add a passkey to boss/); | |
| 200 | + await Promise.all([boss.page.waitForURL(`${base}/settings/security`), boss.page.click("button:has-text('Create a passkey')")]); | |
| 201 | + assert.match(await text(boss.page), /Use it to sign in from now on/); | |
| 202 | + const usedLink = await stranger.page.goto(`${base}/signin/${ADMIN_LINK}`); | |
| 203 | + assert.equal(usedLink.status(), 404); | |
| 204 | + | |
| 205 | + // The admin issues a link for pal (say pal lost the laptop), and pal uses it on a new device. | |
| 206 | + await boss.page.goto(`${base}/settings/admin`); | |
| 207 | + await Promise.all([boss.page.waitForLoadState("load"), boss.page.locator("tr:has-text('pal@example.com') button:has-text('Sign-in link')").click()]); | |
| 208 | + await boss.page.waitForSelector("text=Sign-in link for pal"); | |
| 209 | + const link = (await boss.page.textContent(".secret")).trim(); | |
| 210 | + assert.match(link, new RegExp(`^${base}/signin/`)); | |
| 211 | + await pal.newDevice(); | |
| 212 | + await page.goto(link); | |
| 213 | + await Promise.all([page.waitForURL(`${base}/settings/security`), page.click("button:has-text('Create a passkey')")]); | |
| 214 | + assert.equal(await page.locator("button:has-text('Remove')").count(), 2); | |
| 215 | + | |
| 216 | + // ------------------------------------------------------------ refused requests | |
| 217 | + const cross = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", origin: "https://evil.example", "sec-fetch-site": "cross-site" }, body: "{}" }); | |
| 218 | + assert.equal(cross.status, 403); | |
| 219 | + const form = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: "a=b" }); | |
| 220 | + assert.equal(form.status, 415); | |
| 221 | + const noChallenge = await fetch(`${base}/auth/passkey/login/verify`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ response: { id: "x" } }) }); | |
| 222 | + assert.equal(noChallenge.status, 400); | |
| 223 | + const anonymous = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }); | |
| 224 | + assert.equal(anonymous.status, 401); | |
| 225 | + | |
| 226 | + // Screenshots for review (kept with E2E_KEEP=1). | |
| 227 | + if (process.env.E2E_SCREENSHOTS) { | |
| 228 | + const out = process.env.E2E_SCREENSHOTS; | |
| 229 | + await stranger.page.goto(`${base}/login`); | |
| 230 | + await stranger.page.screenshot({ path: join(out, "login.png"), fullPage: true }); | |
| 231 | + await page.goto(`${base}/settings/security`); | |
| 232 | + await page.screenshot({ path: join(out, "security.png"), fullPage: true }); | |
| 233 | + await boss.page.goto(`${base}/settings/admin`); | |
| 234 | + await boss.page.screenshot({ path: join(out, "admin.png"), fullPage: true }); | |
| 235 | + } | |
| 236 | +}); |
addeddocs/PREFERENCES.md+24−0View file
| @@ -1,0 +1,24 @@ | ||
| 1 | +# Team preferences | |
| 2 | + | |
| 3 | +These are strong defaults from the project owners. Follow them where they fit, | |
| 4 | +and use judgment where they don't. | |
| 5 | + | |
| 6 | +## Sign-in: passkeys first | |
| 7 | + | |
| 8 | +- People sign in with passkeys (WebAuthn). Don't add passwords. | |
| 9 | +- New people create their passkey from their invite link. | |
| 10 | +- Recovery goes through a one-time sign-in link from an admin, or email | |
| 11 | + verification through Cloudflare Access at `/auth/access`. Access guards only | |
| 12 | + that path. | |
| 13 | +- Machines (git, the CLI, MCP clients, runners) use Diffinery tokens, never | |
| 14 | + browser sessions. | |
| 15 | + | |
| 16 | +## UI: Kumo | |
| 17 | + | |
| 18 | +- The look follows [Kumo](https://kumo-ui.com), Cloudflare's design system: | |
| 19 | + its color tokens (light and dark), radii, type and focus styles. The tokens | |
| 20 | + live as CSS variables in `apps/diffinery/src/web/assets.ts`. | |
| 21 | +- Kumo's components are React. Our pages are rendered on the server with | |
| 22 | + `hono/jsx`, so for now we match Kumo's tokens and patterns rather than | |
| 23 | + shipping its components. If a page grows into a client-side app, build it | |
| 24 | + with Kumo components. |
modifiedpnpm-lock.yaml+276−2View file
| @@ -48,6 +48,9 @@ | ||
| 48 | 48 | '@diffinery/workflow-yaml': |
| 49 | 49 | specifier: workspace:* |
| 50 | 50 | version: link:../../packages/workflow-yaml |
| 51 | + '@simplewebauthn/server': | |
| 52 | + specifier: 14.0.3 | |
| 53 | + version: 14.0.3 | |
| 51 | 54 | hono: |
| 52 | 55 | specifier: ^4.13.0 |
| 53 | 56 | version: 4.13.13 |
| @@ -58,6 +61,9 @@ | ||
| 58 | 61 | '@cloudflare/workers-types': |
| 59 | 62 | specifier: ^5.20261012.1 |
| 60 | 63 | version: 5.20261012.1 |
| 64 | + playwright: | |
| 65 | + specifier: 1.56.1 | |
| 66 | + version: 1.56.1 | |
| 61 | 67 | typescript: |
| 62 | 68 | specifier: ^5.9.3 |
| 63 | 69 | version: 5.9.3 |
| @@ -704,6 +710,9 @@ | ||
| 704 | 710 | cpu: [x64] |
| 705 | 711 | os: [win32] |
| 706 | 712 | |
| 713 | + '@hexagon/base64@1.1.28': | |
| 714 | + resolution: {integrity: sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw==} | |
| 715 | + | |
| 707 | 716 | '@img/colour@1.1.0': |
| 708 | 717 | resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} |
| 709 | 718 | engines: {node: '>=18'} |
| @@ -1002,9 +1011,71 @@ | ||
| 1002 | 1011 | '@jridgewell/trace-mapping@0.3.9': |
| 1003 | 1012 | resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==} |
| 1004 | 1013 | |
| 1014 | + '@levischuck/tiny-cbor@0.2.11': | |
| 1015 | + resolution: {integrity: sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow==} | |
| 1016 | + | |
| 1005 | 1017 | '@oxc-project/types@0.153.0': |
| 1006 | 1018 | resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} |
| 1007 | 1019 | |
| 1020 | + '@peculiar/asn1-android@2.10.0': | |
| 1021 | + resolution: {integrity: sha512-IHKdYL5MRW76DOQCv2HSE/vUMOf2jqH4E7yhtQYe/xx+RxpPiXfkUdhex0N4/s5crsnXCGftRNzy4RJn1s6CZA==} | |
| 1022 | + engines: {node: '>=14'} | |
| 1023 | + | |
| 1024 | + '@peculiar/asn1-asym-key@2.10.0': | |
| 1025 | + resolution: {integrity: sha512-1R8xUTeqcUTSMLFTTCzJyFuKiq3y25EQKs9DezUetJ8qMqbJyxo3XkzykiwwT/Xzlt5/vRzS/sW4M1r+pq1FTg==} | |
| 1026 | + engines: {node: '>=14'} | |
| 1027 | + | |
| 1028 | + '@peculiar/asn1-cms@2.10.0': | |
| 1029 | + resolution: {integrity: sha512-CkX0H4NCIOMHOU3rh2xXZywinYZ/EnJlaOlJiGI0e5L4XjFqHf4iH30LMbXWChVppdA9ljbanjtzQFOhDNfTWg==} | |
| 1030 | + engines: {node: '>=14'} | |
| 1031 | + | |
| 1032 | + '@peculiar/asn1-csr@2.10.0': | |
| 1033 | + resolution: {integrity: sha512-jTPTr/9rxKM+niQLMF3jiAMb0lWHUhUHIuSOhdGCIfpB4FAQYf9SoiXOgxP8bS/68IB+bj+1OHvVnDVCQfVUZw==} | |
| 1034 | + engines: {node: '>=14'} | |
| 1035 | + | |
| 1036 | + '@peculiar/asn1-ecc@2.10.0': | |
| 1037 | + resolution: {integrity: sha512-GFd3iOjFrWX+QWH2R2dO5QSJyyRGv9CIBKtRlGlPNCvb2RvmCbBDexe91MJgV76c69d998Xsa+CvuQ98seoiPg==} | |
| 1038 | + engines: {node: '>=14'} | |
| 1039 | + | |
| 1040 | + '@peculiar/asn1-pfx@2.10.0': | |
| 1041 | + resolution: {integrity: sha512-1y3QK9ZH1IPleAMmRoJlPS10eGkAWnULETW8zDFNUK1ffqpG7zmDY+kYBMYQgwlcQZ2iitLm2z2EBP0xzRXpaA==} | |
| 1042 | + engines: {node: '>=14'} | |
| 1043 | + | |
| 1044 | + '@peculiar/asn1-pkcs8@2.10.0': | |
| 1045 | + resolution: {integrity: sha512-Ri+BZT9bnwqlHWmhs7lvWjvJRxKev2hA2+4EbZajgeYWCbR8hyv0znF4DhsFouOhMj2nSDV/iGJ6DMQtwITnCw==} | |
| 1046 | + engines: {node: '>=14'} | |
| 1047 | + | |
| 1048 | + '@peculiar/asn1-pkcs9@2.10.0': | |
| 1049 | + resolution: {integrity: sha512-XIXsbDQFezYk6fudczkuvawkRD4GNpISGCqfYhdkJlvcGF/RFknU+0DDJagOaJqBf+PdPzk4J9oMqDGcvfR9HQ==} | |
| 1050 | + engines: {node: '>=14'} | |
| 1051 | + | |
| 1052 | + '@peculiar/asn1-rsa@2.10.0': | |
| 1053 | + resolution: {integrity: sha512-4Jvmwlh3gZAhNZ4/u7JSyLuce9hofOFZ4o3PYKAccCImGnyaT5CMym/ATgvAvqpOYFNW531oPZvJlEBhJy9VfA==} | |
| 1054 | + engines: {node: '>=14'} | |
| 1055 | + | |
| 1056 | + '@peculiar/asn1-schema@2.10.0': | |
| 1057 | + resolution: {integrity: sha512-GhokD41lV4gQrrLYm3wCkHfBOnJrnhDMgt4XeMW8gzfE1UdJqIuSwsE+ggf82XBjUXRJylcm+KIGQFa4utIVLw==} | |
| 1058 | + engines: {node: '>=14'} | |
| 1059 | + | |
| 1060 | + '@peculiar/asn1-x509-attr@2.10.0': | |
| 1061 | + resolution: {integrity: sha512-/85GtKOKmgvuSJNlaFfwGWNdRSZZ+hpF02NyM01XiCdzpaZONiBltDyfluFPvFx966CR+ZHNSG1jniwpy07oGg==} | |
| 1062 | + engines: {node: '>=14'} | |
| 1063 | + | |
| 1064 | + '@peculiar/asn1-x509-post-quantum@2.10.0': | |
| 1065 | + resolution: {integrity: sha512-cXKcZ30wKbUkRK5RK4Z3W1imewXvHAB9apEAuIqJ0OOkOQCpJrwLOuN38QlsEXqOI+3gtUv8Kf3UZohUmCbf3w==} | |
| 1066 | + engines: {node: '>=14'} | |
| 1067 | + | |
| 1068 | + '@peculiar/asn1-x509@2.10.0': | |
| 1069 | + resolution: {integrity: sha512-ucNVg8+ANveTpMN3fy9lA2alryONdXc2A4cEG2hMniWbvQt+YOZoe8BI80YYNO8FBcuDY1qbDQ4uQGQVraHxGA==} | |
| 1070 | + engines: {node: '>=14'} | |
| 1071 | + | |
| 1072 | + '@peculiar/utils@2.0.3': | |
| 1073 | + resolution: {integrity: sha512-+oL3HPFRIZ1St2K50lWCXiioIgSoxzz7R1J3uF6neO2yl1sgmpgY6XXJH4BdpoDkMWznQTeYF6oWNDZLCdQ4eQ==} | |
| 1074 | + | |
| 1075 | + '@peculiar/x509@2.1.0': | |
| 1076 | + resolution: {integrity: sha512-IYbg1R03CSQGWwl24kGyqrdVtixNSbRaDvBg1r5wyYjTP+VwPQkka1BzTgU5+vxiuwqg04OxdvdJ1xYYFIdUSA==} | |
| 1077 | + engines: {node: '>=20.0.0'} | |
| 1078 | + | |
| 1008 | 1079 | '@poppinss/colors@4.1.6': |
| 1009 | 1080 | resolution: {integrity: sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg==} |
| 1010 | 1081 | |
| @@ -1107,6 +1178,10 @@ | ||
| 1107 | 1178 | '@rolldown/pluginutils@1.0.1': |
| 1108 | 1179 | resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} |
| 1109 | 1180 | |
| 1181 | + '@simplewebauthn/server@14.0.3': | |
| 1182 | + resolution: {integrity: sha512-D39/0fiDiYmGV+nraY9s63D1Ho2AX/waMLy22/9gUadTUuvbAnstfb4bpbHYbwZuV5ZLDYN1SmzDKuiHzKAA4Q==} | |
| 1183 | + engines: {node: '>=20.0.0'} | |
| 1184 | + | |
| 1110 | 1185 | '@sindresorhus/is@7.2.0': |
| 1111 | 1186 | resolution: {integrity: sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw==} |
| 1112 | 1187 | engines: {node: '>=18'} |
| @@ -1191,6 +1266,10 @@ | ||
| 1191 | 1266 | '@vitest/utils@4.1.11': |
| 1192 | 1267 | resolution: {integrity: sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==} |
| 1193 | 1268 | |
| 1269 | + asn1js@3.0.10: | |
| 1270 | + resolution: {integrity: sha512-S2s3aOytiKdFRdulw2qPE51MzjzVOisppcVv7jVFR+Kw0kxwvFrDcYA0h7Ndqbmj0HkMIXYWaoj7fli8kgx1eg==} | |
| 1271 | + engines: {node: '>=12.0.0'} | |
| 1272 | + | |
| 1194 | 1273 | assertion-error@2.0.1: |
| 1195 | 1274 | resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} |
| 1196 | 1275 | engines: {node: '>=12'} |
| @@ -1257,6 +1336,11 @@ | ||
| 1257 | 1336 | picomatch: |
| 1258 | 1337 | optional: true |
| 1259 | 1338 | |
| 1339 | + fsevents@2.3.2: | |
| 1340 | + resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} | |
| 1341 | + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} | |
| 1342 | + os: [darwin] | |
| 1343 | + | |
| 1260 | 1344 | fsevents@2.3.3: |
| 1261 | 1345 | resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} |
| 1262 | 1346 | engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} |
| @@ -1376,10 +1460,30 @@ | ||
| 1376 | 1460 | resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} |
| 1377 | 1461 | engines: {node: '>=12'} |
| 1378 | 1462 | |
| 1463 | + playwright-core@1.56.1: | |
| 1464 | + resolution: {integrity: sha512-hutraynyn31F+Bifme+Ps9Vq59hKuUCz7H1kDOcBs+2oGguKkWTU50bBWrtz34OUWmIwpBTWDxaRPXrIXkgvmQ==} | |
| 1465 | + engines: {node: '>=18'} | |
| 1466 | + hasBin: true | |
| 1467 | + | |
| 1468 | + playwright@1.56.1: | |
| 1469 | + resolution: {integrity: sha512-aFi5B0WovBHTEvpM3DzXTUaeN6eN0qWnTkKx4NQaH4Wvcmc153PdaY2UBdSYKaGYw+UyWXSVyxDUg5DoPEttjw==} | |
| 1470 | + engines: {node: '>=18'} | |
| 1471 | + hasBin: true | |
| 1472 | + | |
| 1379 | 1473 | postcss@8.5.29: |
| 1380 | 1474 | resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} |
| 1381 | 1475 | engines: {node: ^10 || ^12 || >=14} |
| 1382 | 1476 | |
| 1477 | + pvtsutils@1.3.6: | |
| 1478 | + resolution: {integrity: sha512-PLgQXQ6H2FWCaeRak8vvk1GW462lMxB5s3Jm673N82zI4vqtVUPuZdffdZbPDFRoU8kAhItWFtPCWiPpp4/EDg==} | |
| 1479 | + | |
| 1480 | + pvutils@1.2.0: | |
| 1481 | + resolution: {integrity: sha512-BbubeCEyTuQjVMakvJQ/Sxbc93F2pwmbsxONT/ZRrwU7Ua38d8unYTwXpTVLAKJ4BDuH9IGztCjQcd/N/39Dvg==} | |
| 1482 | + engines: {node: '>=16.0.0'} | |
| 1483 | + | |
| 1484 | + reflect-metadata@0.2.2: | |
| 1485 | + resolution: {integrity: sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==} | |
| 1486 | + | |
| 1383 | 1487 | rolldown@1.2.13: |
| 1384 | 1488 | resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} |
| 1385 | 1489 | engines: {node: ^20.19.0 || >=22.12.0} |
| @@ -1435,9 +1539,16 @@ | ||
| 1435 | 1539 | resolution: {integrity: sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==} |
| 1436 | 1540 | engines: {node: '>=14.0.0'} |
| 1437 | 1541 | |
| 1542 | + tslib@1.14.1: | |
| 1543 | + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} | |
| 1544 | + | |
| 1438 | 1545 | tslib@2.8.1: |
| 1439 | 1546 | resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} |
| 1440 | 1547 | |
| 1548 | + tsyringe@4.10.0: | |
| 1549 | + resolution: {integrity: sha512-axr3IdNuVIxnaK5XGEUFTu3YmAQ6lllgrvqfEoR16g/HGnYY/6We4oWENtAnzK6/LpJ2ur9PAb80RBt7/U4ugw==} | |
| 1550 | + engines: {node: '>= 6.0.0'} | |
| 1551 | + | |
| 1441 | 1552 | turbo@2.11.7: |
| 1442 | 1553 | resolution: {integrity: sha512-RhXHbqfvY6o3xt/zSHCwSNwLrZlXiqHzewi4eAj9PtZ1T1CqgMJ2yIDb7SKPTG9IikTm925mfzL6hldQVdyNWw==} |
| 1443 | 1554 | hasBin: true |
| @@ -1914,6 +2025,8 @@ | ||
| 1914 | 2025 | '@esbuild/win32-x64@0.28.2': |
| 1915 | 2026 | optional: true |
| 1916 | 2027 | |
| 2028 | + '@hexagon/base64@1.1.28': {} | |
| 2029 | + | |
| 1917 | 2030 | '@img/colour@1.1.0': {} |
| 1918 | 2031 | |
| 1919 | 2032 | '@img/sharp-darwin-arm64@0.35.2': |
| @@ -2133,8 +2246,126 @@ | ||
| 2133 | 2246 | '@jridgewell/resolve-uri': 3.1.2 |
| 2134 | 2247 | '@jridgewell/sourcemap-codec': 1.6.0 |
| 2135 | 2248 | |
| 2249 | + '@levischuck/tiny-cbor@0.2.11': {} | |
| 2250 | + | |
| 2136 | 2251 | '@oxc-project/types@0.153.0': {} |
| 2137 | 2252 | |
| 2253 | + '@peculiar/asn1-android@2.10.0': | |
| 2254 | + dependencies: | |
| 2255 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2256 | + asn1js: 3.0.10 | |
| 2257 | + tslib: 2.8.1 | |
| 2258 | + | |
| 2259 | + '@peculiar/asn1-asym-key@2.10.0': | |
| 2260 | + dependencies: | |
| 2261 | + '@peculiar/asn1-pkcs8': 2.10.0 | |
| 2262 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2263 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2264 | + asn1js: 3.0.10 | |
| 2265 | + tslib: 2.8.1 | |
| 2266 | + | |
| 2267 | + '@peculiar/asn1-cms@2.10.0': | |
| 2268 | + dependencies: | |
| 2269 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2270 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2271 | + '@peculiar/asn1-x509-attr': 2.10.0 | |
| 2272 | + asn1js: 3.0.10 | |
| 2273 | + tslib: 2.8.1 | |
| 2274 | + | |
| 2275 | + '@peculiar/asn1-csr@2.10.0': | |
| 2276 | + dependencies: | |
| 2277 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2278 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2279 | + asn1js: 3.0.10 | |
| 2280 | + tslib: 2.8.1 | |
| 2281 | + | |
| 2282 | + '@peculiar/asn1-ecc@2.10.0': | |
| 2283 | + dependencies: | |
| 2284 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2285 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2286 | + asn1js: 3.0.10 | |
| 2287 | + tslib: 2.8.1 | |
| 2288 | + | |
| 2289 | + '@peculiar/asn1-pfx@2.10.0': | |
| 2290 | + dependencies: | |
| 2291 | + '@peculiar/asn1-cms': 2.10.0 | |
| 2292 | + '@peculiar/asn1-pkcs8': 2.10.0 | |
| 2293 | + '@peculiar/asn1-rsa': 2.10.0 | |
| 2294 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2295 | + asn1js: 3.0.10 | |
| 2296 | + tslib: 2.8.1 | |
| 2297 | + | |
| 2298 | + '@peculiar/asn1-pkcs8@2.10.0': | |
| 2299 | + dependencies: | |
| 2300 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2301 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2302 | + asn1js: 3.0.10 | |
| 2303 | + tslib: 2.8.1 | |
| 2304 | + | |
| 2305 | + '@peculiar/asn1-pkcs9@2.10.0': | |
| 2306 | + dependencies: | |
| 2307 | + '@peculiar/asn1-cms': 2.10.0 | |
| 2308 | + '@peculiar/asn1-pfx': 2.10.0 | |
| 2309 | + '@peculiar/asn1-pkcs8': 2.10.0 | |
| 2310 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2311 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2312 | + '@peculiar/asn1-x509-attr': 2.10.0 | |
| 2313 | + asn1js: 3.0.10 | |
| 2314 | + tslib: 2.8.1 | |
| 2315 | + | |
| 2316 | + '@peculiar/asn1-rsa@2.10.0': | |
| 2317 | + dependencies: | |
| 2318 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2319 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2320 | + asn1js: 3.0.10 | |
| 2321 | + tslib: 2.8.1 | |
| 2322 | + | |
| 2323 | + '@peculiar/asn1-schema@2.10.0': | |
| 2324 | + dependencies: | |
| 2325 | + '@peculiar/utils': 2.0.3 | |
| 2326 | + asn1js: 3.0.10 | |
| 2327 | + tslib: 2.8.1 | |
| 2328 | + | |
| 2329 | + '@peculiar/asn1-x509-attr@2.10.0': | |
| 2330 | + dependencies: | |
| 2331 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2332 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2333 | + asn1js: 3.0.10 | |
| 2334 | + tslib: 2.8.1 | |
| 2335 | + | |
| 2336 | + '@peculiar/asn1-x509-post-quantum@2.10.0': | |
| 2337 | + dependencies: | |
| 2338 | + '@peculiar/asn1-asym-key': 2.10.0 | |
| 2339 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2340 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2341 | + asn1js: 3.0.10 | |
| 2342 | + tslib: 2.8.1 | |
| 2343 | + | |
| 2344 | + '@peculiar/asn1-x509@2.10.0': | |
| 2345 | + dependencies: | |
| 2346 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2347 | + '@peculiar/utils': 2.0.3 | |
| 2348 | + asn1js: 3.0.10 | |
| 2349 | + tslib: 2.8.1 | |
| 2350 | + | |
| 2351 | + '@peculiar/utils@2.0.3': | |
| 2352 | + dependencies: | |
| 2353 | + tslib: 2.8.1 | |
| 2354 | + | |
| 2355 | + '@peculiar/x509@2.1.0': | |
| 2356 | + dependencies: | |
| 2357 | + '@peculiar/asn1-cms': 2.10.0 | |
| 2358 | + '@peculiar/asn1-csr': 2.10.0 | |
| 2359 | + '@peculiar/asn1-ecc': 2.10.0 | |
| 2360 | + '@peculiar/asn1-pkcs9': 2.10.0 | |
| 2361 | + '@peculiar/asn1-rsa': 2.10.0 | |
| 2362 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2363 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2364 | + '@peculiar/asn1-x509-post-quantum': 2.10.0 | |
| 2365 | + pvtsutils: 1.3.6 | |
| 2366 | + tslib: 2.8.1 | |
| 2367 | + tsyringe: 4.10.0 | |
| 2368 | + | |
| 2138 | 2369 | '@poppinss/colors@4.1.6': |
| 2139 | 2370 | dependencies: |
| 2140 | 2371 | kleur: 4.1.5 |
| @@ -2194,6 +2425,19 @@ | ||
| 2194 | 2425 | |
| 2195 | 2426 | '@rolldown/pluginutils@1.0.1': {} |
| 2196 | 2427 | |
| 2428 | + '@simplewebauthn/server@14.0.3': | |
| 2429 | + dependencies: | |
| 2430 | + '@hexagon/base64': 1.1.28 | |
| 2431 | + '@levischuck/tiny-cbor': 0.2.11 | |
| 2432 | + '@peculiar/asn1-android': 2.10.0 | |
| 2433 | + '@peculiar/asn1-ecc': 2.10.0 | |
| 2434 | + '@peculiar/asn1-rsa': 2.10.0 | |
| 2435 | + '@peculiar/asn1-schema': 2.10.0 | |
| 2436 | + '@peculiar/asn1-x509': 2.10.0 | |
| 2437 | + '@peculiar/asn1-x509-post-quantum': 2.10.0 | |
| 2438 | + '@peculiar/x509': 2.1.0 | |
| 2439 | + reflect-metadata: 0.2.2 | |
| 2440 | + | |
| 2197 | 2441 | '@sindresorhus/is@7.2.0': {} |
| 2198 | 2442 | |
| 2199 | 2443 | '@speed-highlight/core@1.2.24': {} |
| @@ -2282,6 +2526,12 @@ | ||
| 2282 | 2526 | convert-source-map: 2.0.0 |
| 2283 | 2527 | tinyrainbow: 3.2.0 |
| 2284 | 2528 | |
| 2529 | + asn1js@3.0.10: | |
| 2530 | + dependencies: | |
| 2531 | + pvtsutils: 1.3.6 | |
| 2532 | + pvutils: 1.2.0 | |
| 2533 | + tslib: 2.8.1 | |
| 2534 | + | |
| 2285 | 2535 | assertion-error@2.0.1: {} |
| 2286 | 2536 | |
| 2287 | 2537 | blake3-wasm@2.1.5: {} |
| @@ -2399,6 +2649,9 @@ | ||
| 2399 | 2649 | optionalDependencies: |
| 2400 | 2650 | picomatch: 4.0.7 |
| 2401 | 2651 | |
| 2652 | + fsevents@2.3.2: | |
| 2653 | + optional: true | |
| 2654 | + | |
| 2402 | 2655 | fsevents@2.3.3: |
| 2403 | 2656 | optional: true |
| 2404 | 2657 | |
| @@ -2498,12 +2751,28 @@ | ||
| 2498 | 2751 | |
| 2499 | 2752 | picomatch@4.0.7: {} |
| 2500 | 2753 | |
| 2754 | + playwright-core@1.56.1: {} | |
| 2755 | + | |
| 2756 | + playwright@1.56.1: | |
| 2757 | + dependencies: | |
| 2758 | + playwright-core: 1.56.1 | |
| 2759 | + optionalDependencies: | |
| 2760 | + fsevents: 2.3.2 | |
| 2761 | + | |
| 2501 | 2762 | postcss@8.5.29: |
| 2502 | 2763 | dependencies: |
| 2503 | 2764 | nanoid: 3.3.20 |
| 2504 | 2765 | picocolors: 1.1.1 |
| 2505 | 2766 | source-map-js: 1.2.2 |
| 2506 | 2767 | |
| 2768 | + pvtsutils@1.3.6: | |
| 2769 | + dependencies: | |
| 2770 | + tslib: 2.8.1 | |
| 2771 | + | |
| 2772 | + pvutils@1.2.0: {} | |
| 2773 | + | |
| 2774 | + reflect-metadata@0.2.2: {} | |
| 2775 | + | |
| 2507 | 2776 | rolldown@1.2.13: |
| 2508 | 2777 | dependencies: |
| 2509 | 2778 | '@oxc-project/types': 0.153.0 |
| @@ -2613,9 +2882,14 @@ | ||
| 2613 | 2882 | |
| 2614 | 2883 | tinyrainbow@3.2.0: {} |
| 2615 | 2884 | |
| 2616 | - tslib@2.8.1: | |
| 2617 | - optional: true | |
| 2885 | + tslib@1.14.1: {} | |
| 2618 | 2886 | |
| 2887 | + tslib@2.8.1: {} | |
| 2888 | + | |
| 2889 | + tsyringe@4.10.0: | |
| 2890 | + dependencies: | |
| 2891 | + tslib: 1.14.1 | |
| 2892 | + | |
| 2619 | 2893 | turbo@2.11.7: |
| 2620 | 2894 | optionalDependencies: |
| 2621 | 2895 | '@turbo/darwin-64': 2.11.7 |
modifiedscripts/bootstrap.sh+22−29View file
| @@ -2,8 +2,9 @@ | ||
| 2 | 2 | # Idempotent production setup for Diffinery on a Cloudflare account. |
| 3 | 3 | # |
| 4 | 4 | # Creates (only when missing): the D1 database, R2 bucket, event queues and the |
| 5 | -# Cloudflare Access applications that put sign-in in front of the web UI while | |
| 6 | -# letting git, the API, MCP and runners authenticate with Diffinery tokens. | |
| 5 | +# Cloudflare Access application for /auth/access, the email-based account | |
| 6 | +# recovery path. Everyday sign-in uses passkeys; git, the API, MCP and runners | |
| 7 | +# use Diffinery tokens. | |
| 7 | 8 | # Then renders apps/diffinery/wrangler.deploy.jsonc and applies D1 migrations. |
| 8 | 9 | # |
| 9 | 10 | # Requires: CLOUDFLARE_API_TOKEN, CLOUDFLARE_ACCOUNT_ID, node, jq, curl. |
| @@ -80,43 +81,35 @@ | ||
| 80 | 81 | APPS="$(cf GET "/access/apps?per_page=100" || echo '{}')" |
| 81 | 82 | AUD="" |
| 82 | 83 | if ! jq -e '.success' >/dev/null <<<"$APPS"; then |
| 83 | - warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Sign-in stays disabled until Access is configured." | |
| 84 | + warn "Could not read Access applications (token needs Access: Apps and Policies Edit). Passkeys work; email recovery stays off." | |
| 84 | 85 | else |
| 85 | - WEB_APP="$(jq -c --arg n "Diffinery" '.result[] | select(.name==$n)' <<<"$APPS" | head -n1)" | |
| 86 | - if [[ -z "$WEB_APP" ]]; then | |
| 87 | - WEB_APP="$(cf POST /access/apps "$(jq -nc --arg d "$DOMAIN" '{ | |
| 88 | - name: "Diffinery", type: "self_hosted", domain: $d, | |
| 89 | - destinations: [{type: "public", uri: $d}], | |
| 90 | - session_duration: "168h", app_launcher_visible: true, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax", | |
| 91 | - policies: [{name: "Anyone who signs in (Diffinery invites decide access)", decision: "allow", include: [{everyone: {}}], precedence: 1}] | |
| 92 | - }')" | jq -c '.result // empty')" | |
| 93 | - [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN" || warn "Could not create the Access application for $DOMAIN." | |
| 86 | + # People sign in with passkeys. Access guards only /auth/access, the email-based | |
| 87 | + # recovery path, so everything else (web, git, API, MCP, runners) reaches the Worker directly. | |
| 88 | + WEB_BODY="$(jq -nc --arg d "$DOMAIN" '{ | |
| 89 | + name: "Diffinery account recovery", type: "self_hosted", domain: ($d + "/auth/access"), | |
| 90 | + destinations: [{type: "public", uri: ($d + "/auth/access")}], | |
| 91 | + session_duration: "15m", app_launcher_visible: false, http_only_cookie_attribute: true, same_site_cookie_attribute: "lax" | |
| 92 | + }')" | |
| 93 | + POLICY='[{"name": "Anyone who proves their email (Diffinery decides access)", "decision": "allow", "include": [{"everyone": {}}], "precedence": 1}]' | |
| 94 | + WEB_ID="$(jq -r '.result[] | select(.name=="Diffinery account recovery" or .name=="Diffinery") | .id' <<<"$APPS" | head -n1)" | |
| 95 | + if [[ -z "$WEB_ID" ]]; then | |
| 96 | + WEB_APP="$(cf POST /access/apps "$(jq -c --argjson p "$POLICY" '. + {policies: $p}' <<<"$WEB_BODY")" | jq -c '.result // empty')" | |
| 97 | + [[ -n "$WEB_APP" ]] && echo "created Access app for $DOMAIN/auth/access" || warn "Could not create the Access application; email recovery stays off." | |
| 94 | 98 | else |
| 95 | - echo "Access app exists" | |
| 99 | + WEB_APP="$(cf PUT "/access/apps/$WEB_ID" "$WEB_BODY" | jq -c '.result // empty')" | |
| 100 | + [[ -n "$WEB_APP" ]] && echo "Access app covers $DOMAIN/auth/access" || warn "Could not update the Access application." | |
| 96 | 101 | fi |
| 97 | 102 | AUD="$(jq -r '.aud // empty' <<<"${WEB_APP:-{}}")" |
| 98 | 103 | |
| 99 | - # Machine endpoints authenticate with Diffinery tokens, so Access must not intercept them. | |
| 100 | - BYPASS_DESTS="$(jq -nc --arg d "$DOMAIN" '[ | |
| 101 | - "/api/*", "/mcp", "/mcp/*", "/runner/*", "/.well-known/*", "/healthz", | |
| 102 | - "/*/*/info/refs", "/*/*/git-upload-pack", "/*/*/git-receive-pack", "/*/*/info/lfs/*", | |
| 103 | - "/*/*/actions/runs/*/artifacts/*" | |
| 104 | - ] | map({type: "public", uri: ($d + .)})')" | |
| 105 | - BYPASS_BODY="$(jq -nc --arg d "$DOMAIN" --argjson dests "$BYPASS_DESTS" '{ | |
| 106 | - name: "Diffinery machine endpoints", type: "self_hosted", domain: ($d + "/api/*"), destinations: $dests, | |
| 107 | - app_launcher_visible: false, | |
| 108 | - policies: [{name: "Bypass: Diffinery verifies tokens itself", decision: "bypass", include: [{everyone: {}}], precedence: 1}] | |
| 109 | - }')" | |
| 104 | + # Earlier versions put Access in front of the whole site and needed a bypass app; remove it. | |
| 110 | 105 | BYPASS_ID="$(jq -r '.result[] | select(.name=="Diffinery machine endpoints") | .id' <<<"$APPS" | head -n1)" |
| 111 | - if [[ -z "$BYPASS_ID" ]]; then | |
| 112 | - cf POST /access/apps "$BYPASS_BODY" | jq -e '.success' >/dev/null && echo "created Access bypass for git/API/MCP/runner paths" || warn "Could not create the Access bypass application; git and API calls will be redirected to sign-in." | |
| 113 | - else | |
| 114 | - cf PUT "/access/apps/$BYPASS_ID" "$(jq -c 'del(.policies)' <<<"$BYPASS_BODY")" | jq -e '.success' >/dev/null && echo "updated Access bypass paths" || warn "Could not update the Access bypass application." | |
| 106 | + if [[ -n "$BYPASS_ID" ]]; then | |
| 107 | + cf DELETE "/access/apps/$BYPASS_ID" | jq -e '.success' >/dev/null && echo "removed the old Access bypass app" || warn "Could not remove the old Access bypass application." | |
| 115 | 108 | fi |
| 116 | 109 | fi |
| 117 | 110 | if [[ -z "$TEAM" ]]; then |
| 118 | 111 | # Fall back to the sign-in redirect, which names the team domain. |
| 119 | - TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)" | |
| 112 | + TEAM="$(curl -sS -o /dev/null -w '%{redirect_url}' "https://$DOMAIN/auth/access" | sed -nE 's#^https://([^/]+\.cloudflareaccess\.com)/.*#\1#p' || true)" | |
| 120 | 113 | fi |
| 121 | 114 | [[ -n "$TEAM" ]] || warn "Access team domain unknown; set the ACCESS_TEAM_DOMAIN repository variable (Zero Trust → Settings → Custom pages shows it)." |
| 122 | 115 | echo "team=${TEAM:-?} aud=${AUD:+set}" |