Confirm it's you before sensitive actions; sessions end 90 days after sign-in; sign out one browser (#86)
* Sensitive actions need a sign-in from the last 2 hours; sessions end 90 days after sign-in; sign out one browser Adding or removing a passkey, minting a token or an agent, issuing a sign-in link, and deleting or transferring a repository or organization ask a browser whose sign-in is older than 2 hours to confirm it's them with a passkey (or email recovery). The API answers 403 with a confirm URL for the same calls made with a session cookie. A session slides by 30 days per day of use, but ends 90 days after its sign-in. Each other browser on Passkeys and sessions can be signed out alone. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo * Changing your username also needs a recent sign-in Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016yhgAu6oKfqes812Yd4qjo --------- Co-authored-by: Claude <noreply@anthropic.com>
Canberk Demirci authored · GitHub committed signed1 parent ecf0293commit 06c3bc3fb67233ee18863204845c3995423d0638Browse files
12 files changed+205−20
Files (12)
- apps/diffinery/docs/accounts/passkeys-and-sign-in.md+7−3
- apps/diffinery/docs/security/audit-log.md+1−0
- apps/diffinery/docs/security/overview.md+14−2
- apps/diffinery/src/auth/principal.ts+33−3
- apps/diffinery/src/auth/sessions.ts+16−3
- apps/diffinery/src/http/api.ts+4−1
- apps/diffinery/src/web/account.tsx+30−1
- apps/diffinery/src/web/assets.ts+1−1
- apps/diffinery/src/web/auth.tsx+33−3
- apps/diffinery/src/web/repo-settings.tsx+3−1
- apps/diffinery/src/web/ui.tsx+9−0
- apps/diffinery/test/e2e/security.test.mjs+54−2
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+7−3View file
| @@ -20,7 +20,7 @@ | ||
| 20 | 20 | | User verification | Required. The device must unlock the passkey with a biometric, PIN or screen lock, not just a tap. | |
| 21 | 21 | | Attestation | None requested. Diffinery accepts any authenticator that can create a discoverable, user-verifying passkey. | |
| 22 | 22 | | Time to complete the browser prompt | 2 minutes. The sign-in attempt itself expires after 5 minutes. | |
| 23 | -| Browser session length | 30 days, extended by another 30 days on each day you use it. | | |
| 23 | +| Browser session length | 30 days, extended by another 30 days on each day you use it, up to 90 days after you signed in. | | |
| 24 | 24 | |
| 25 | 25 | Diffinery is invite-only. You can't create an account from the sign-in page: new people create their first passkey from an invite link (see [Join from an invite](#join-from-an-invite)). |
| 26 | 26 | |
| @@ -75,13 +75,17 @@ | ||
| 75 | 75 | |
| 76 | 76 | Passkeys can only be managed from a signed-in browser. An API request made with an access token gets "Sign in with your passkey first." |
| 77 | 77 | |
| 78 | +## Confirming it's you | |
| 79 | + | |
| 80 | +Adding or removing a passkey, creating a token or an agent, changing your username, and deleting or transferring a repository or organization need a sign-in from the last 2 hours. If you signed in earlier, Diffinery shows **Confirm it's you**. Use your passkey (or **Confirm by email**, where email recovery is set up), and you go back to the page you were on. Then do the action again. See [Security overview](/docs/security/overview#confirming-its-you). | |
| 81 | + | |
| 78 | 82 | ## Sessions and signing out other browsers |
| 79 | 83 | |
| 80 | -Each successful sign-in creates a browser session, stored in an HTTP-only, secure cookie (`__Host-dfy_session`) that only `diffinery.com` can read. A session lasts 30 days and is extended by another 30 days on each day you use it, so a browser you use regularly stays signed in. | |
| 84 | +Each successful sign-in creates a browser session, stored in an HTTP-only, secure cookie (`__Host-dfy_session`) that only `diffinery.com` can read. A session lasts 30 days and is extended by another 30 days on each day you use it, so a browser you use regularly stays signed in. However often you use it, a session ends 90 days after you signed in, and you sign in with your passkey again. | |
| 81 | 85 | |
| 82 | 86 | The **Signed-in browsers** box on **Settings → Passkeys and sessions** lists your active sessions (up to 100), newest activity first. Each row shows the device and browser, a **this browser** badge for the one you are using, when it signed in, how (a passkey, email recovery or a sign-in link) and when it was last active. |
| 83 | 87 | |
| 84 | -To end every session except the current one, click **Sign out everywhere else** and confirm "Sign out every other browser?". You see "Signed out of every other browser." Those browsers need a passkey to sign in again. | |
| 88 | +To end one session, click **Sign out** on its row and confirm. You see "Signed out of that browser." To end every session except the current one, click **Sign out everywhere else** and confirm "Sign out every other browser?". You see "Signed out of every other browser." Those browsers need a passkey to sign in again. | |
| 85 | 89 | |
| 86 | 90 | When a site administrator disables your account, every browser session ends at once, and signing in again is refused with "Your account is disabled." until the account is enabled. |
| 87 | 91 |
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
| @@ -51,6 +51,7 @@ | ||
| 51 | 51 | | `session.create` | Someone signs in with a passkey, or through the email recovery path | none | `method` (`passkey` or `access`), `device`, `ip`, `location`, and for passkeys `passkey` (name) and `passkeyId` | |
| 52 | 52 | | `passkey.add` | A passkey is added from **Passkeys and sessions**, or through a one-time sign-in link | none | `name`, `passkeyId`, `device`, `ip`, `location`; `via: "signin-link"` when added through a link | |
| 53 | 53 | | `passkey.remove` | A passkey is removed | none | `name` | |
| 54 | +| `session.revoke` | You sign out one of your other browsers with **Sign out** on **Passkeys and sessions** | none | none | | |
| 54 | 55 | | `invite.accept` | Someone creates an account and passkey from an invite link, or an existing user accepts an invite | for an existing user, the path the invite led to (such as `/acme/website`) | for a new account: `method` (`passkey`), `passkeyId`, `device`, `ip`, `location` | |
| 55 | 56 | | `token.create` | A personal access token is created on **Access tokens** or with `POST /api/v3/diffinery/tokens` | your login (web) | `name` and `scope`/`scopes`, and for the web the number of repositories it is restricted to | |
| 56 | 57 | | `token.revoke` | A token is revoked on **Access tokens** or with `DELETE /api/v3/diffinery/tokens/{id}` | the token id | `via: "api"` for the API | |
modifiedapps/diffinery/docs/security/overview.md+14−2View file
| @@ -49,11 +49,23 @@ | ||
| 49 | 49 | |---|---| |
| 50 | 50 | | Name | `__Host-dfy_session` (the `__Host-` prefix pins it to this origin, `Path=/`, no `Domain`) | |
| 51 | 51 | | Flags | `HttpOnly`, `Secure`, `SameSite=Lax` | |
| 52 | -| Lifetime | 30 days, extended by another 30 days on the first use of each day | | |
| 52 | +| Lifetime | 30 days, extended by another 30 days on the first use of each day, and never more than 90 days after the sign-in | | |
| 53 | 53 | | Storage | Only a SHA-256 hash of the session token is stored | |
| 54 | 54 | |
| 55 | -Signing in again replaces the browser's previous session. **Settings**, **Passkeys and sessions** lists your **Signed-in browsers**; **Sign out everywhere else** ends every session except the current one. A disabled account cannot use its sessions or tokens. | |
| 55 | +Signing in again replaces the browser's previous session. **Settings**, **Passkeys and sessions** lists your **Signed-in browsers**. **Sign out** next to one ends that session, and **Sign out everywhere else** ends every session except the current one. A disabled account cannot use its sessions or tokens. | |
| 56 | 56 | |
| 57 | +### Confirming it's you | |
| 58 | + | |
| 59 | +Some actions need a sign-in from the last 2 hours, much like GitHub's sudo mode: | |
| 60 | +- adding or removing a passkey; | |
| 61 | +- creating a personal access token, an agent, or an agent token; | |
| 62 | +- changing your username; | |
| 63 | +- issuing a sign-in link (site administrators); | |
| 64 | +- deleting or transferring a repository; | |
| 65 | +- deleting an organization. | |
| 66 | + | |
| 67 | +When a browser's sign-in is older than that, Diffinery asks you to **Confirm it's you** with your passkey (or by email recovery, where that is set up), then takes you back to the page. Then submit the form again. The same calls through the API with a browser session get `403 Confirm it's you: sign in with your passkey again to do this.`, with a `confirm` URL in the body. So a session left open on a shared computer, or a stolen session cookie, can't add its own passkey, mint a lasting token or delete your work. Tokens aren't affected: their scopes bound what they can do. | |
| 68 | + | |
| 57 | 69 | ## Protection against cross-site requests |
| 58 | 70 | |
| 59 | 71 | Browser sessions ride on cookies, so Diffinery applies two independent defenses against cross-site request forgery. |
modifiedapps/diffinery/src/auth/principal.ts+33−3View file
| @@ -9,7 +9,7 @@ | ||
| 9 | 9 | import { taskPersonAllowed } from "../agentic/access.js"; |
| 10 | 10 | |
| 11 | 11 | export type Principal = |
| 12 | - | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string; importId?: string; tokenId?: string } | |
| 12 | + | { type: "account"; account: Account; via: "session" | "token" | "dev"; scopes: TokenScope[]; repoIds: string[] | null; sessionId?: string; sessionAt?: string; importId?: string; tokenId?: string } | |
| 13 | 13 | | { type: "repo-token"; repoId: string; token: string } |
| 14 | 14 | | { type: "anonymous" }; |
| 15 | 15 | |
| @@ -85,7 +85,7 @@ | ||
| 85 | 85 | const session = cookieFrom(req, SESSION_COOKIE); |
| 86 | 86 | if (session) { |
| 87 | 87 | const s = await new Auth(env).session(session); |
| 88 | - if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id }; | |
| 88 | + if (s) return { type: "account", account: s.account, via: "session", scopes: ["admin"], repoIds: null, sessionId: s.session.id, sessionAt: s.session.created_at }; | |
| 89 | 89 | } |
| 90 | 90 | if (devAuth(env, req.url)) { |
| 91 | 91 | const account = await dir.userForEmail(env.DEV_AUTH!); |
| @@ -129,11 +129,41 @@ | ||
| 129 | 129 | */ |
| 130 | 130 | export function requireBrowserSession(c: AppContext): Account { |
| 131 | 131 | const p = c.get("principal"); |
| 132 | - if (p.type === "account" && (p.via === "session" || p.via === "dev")) return requireAccount(c); | |
| 132 | + if (p.type === "account" && (p.via === "session" || p.via === "dev")) { | |
| 133 | + requireRecentSignIn(c, "/settings/tokens"); | |
| 134 | + return requireAccount(c); | |
| 135 | + } | |
| 133 | 136 | if (p.type === "anonymous") throw new HttpError(401, "sign in required"); |
| 134 | 137 | throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`); |
| 135 | 138 | } |
| 136 | 139 | |
| 140 | +/** How long after a sign-in a browser may do sensitive things, as in GitHub's sudo mode. */ | |
| 141 | +export const RECENT_SIGNIN_MS = 2 * 3600_000; | |
| 142 | + | |
| 143 | +/** A sensitive action from a browser whose sign-in isn't recent: the person signs in again, then comes back to `next`. */ | |
| 144 | +export class SignInAgain extends HttpError { | |
| 145 | + readonly confirm: string; | |
| 146 | + constructor(next: string) { | |
| 147 | + const confirm = `/login?confirm=1&next=${encodeURIComponent(next)}`; | |
| 148 | + super(403, "Confirm it's you: sign in with your passkey again to do this.", { confirm }); | |
| 149 | + this.confirm = confirm; | |
| 150 | + } | |
| 151 | +} | |
| 152 | + | |
| 153 | +/** | |
| 154 | + * Adding a passkey, minting a token or an agent, issuing a sign-in link, changing your | |
| 155 | + * username, and deleting or transferring a repository or organization need a sign-in from | |
| 156 | + * the last 2 hours. So a | |
| 157 | + * session left open on a shared computer, or a stolen session cookie, can't turn itself | |
| 158 | + * into a lasting credential or destroy work. Tokens are bounded by their scopes instead. | |
| 159 | + */ | |
| 160 | +export function requireRecentSignIn(c: AppContext, next: string): void { | |
| 161 | + const p = c.get("principal"); | |
| 162 | + if (p.type !== "account" || p.via !== "session") return; | |
| 163 | + if (p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS) return; | |
| 164 | + throw new SignInAgain(next); | |
| 165 | +} | |
| 166 | + | |
| 137 | 167 | /** Validates scopes sent by a client: known names only, default write. */ |
| 138 | 168 | export function parseScopes(input: unknown): TokenScope[] { |
| 139 | 169 | if (input === undefined || input === null) return ["write"]; |
modifiedapps/diffinery/src/auth/sessions.ts+16−3View file
| @@ -8,6 +8,8 @@ | ||
| 8 | 8 | /** Binds an in-flight WebAuthn ceremony to the browser that started it. */ |
| 9 | 9 | export const CHALLENGE_COOKIE = "__Host-dfy_webauthn"; |
| 10 | 10 | export const SESSION_DAYS = 30; |
| 11 | +/** However often it is used, a session ends this long after its sign-in. */ | |
| 12 | +export const SESSION_MAX_DAYS = 90; | |
| 11 | 13 | const CHALLENGE_MINUTES = 5; |
| 12 | 14 | const LINK_HOURS = 24; |
| 13 | 15 | |
| @@ -46,6 +48,9 @@ | ||
| 46 | 48 | |
| 47 | 49 | const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString(); |
| 48 | 50 | |
| 51 | +/** When a session signed in at `createdAt` ends at the latest. */ | |
| 52 | +const lastDay = (createdAt: string): string => new Date(Date.parse(createdAt) + SESSION_MAX_DAYS * 86400_000).toISOString(); | |
| 53 | + | |
| 49 | 54 | /** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */ |
| 50 | 55 | export class Auth { |
| 51 | 56 | constructor(private env: Env) {} |
| @@ -72,15 +77,17 @@ | ||
| 72 | 77 | return token; |
| 73 | 78 | } |
| 74 | 79 | |
| 75 | - /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days. */ | |
| 80 | + /** Resolve a session cookie. Sessions slide: each day of use extends them by another 30 days, up to 90 days after the sign-in. */ | |
| 76 | 81 | async session(token: string): Promise<{ session: SessionRow; account: Account } | null> { |
| 77 | 82 | if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null; |
| 78 | 83 | const row = await this.db.prepare("SELECT * FROM sessions WHERE id = ?").bind(await sha256(token)).first<SessionRow>(); |
| 79 | - if (!row || row.expires_at < now()) return null; | |
| 84 | + if (!row || row.expires_at < now() || lastDay(row.created_at) < now()) return null; | |
| 80 | 85 | const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>(); |
| 81 | 86 | if (!account || account.kind !== "user" || account.status === "disabled") return null; |
| 82 | 87 | if (Date.now() - Date.parse(row.last_seen_at) > 86400_000) { |
| 83 | - await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), days(SESSION_DAYS), row.id).run(); | |
| 88 | + const until = days(SESSION_DAYS); | |
| 89 | + const last = lastDay(row.created_at); | |
| 90 | + await this.db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").bind(now(), until < last ? until : last, row.id).run(); | |
| 84 | 91 | } |
| 85 | 92 | return { session: row, account }; |
| 86 | 93 | } |
| @@ -94,6 +101,12 @@ | ||
| 94 | 101 | return r.results; |
| 95 | 102 | } |
| 96 | 103 | |
| 104 | + /** End one of the account's own sessions; false when it has none by that id. */ | |
| 105 | + async endSessionById(accountId: string, id: string): Promise<boolean> { | |
| 106 | + const r = await this.db.prepare("DELETE FROM sessions WHERE id = ? AND account_id = ?").bind(id, accountId).run(); | |
| 107 | + return (r.meta.changes ?? 0) > 0; | |
| 108 | + } | |
| 109 | + | |
| 97 | 110 | async endOtherSessions(accountId: string, keepId: string | null): Promise<void> { |
| 98 | 111 | await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run(); |
| 99 | 112 | } |
modifiedapps/diffinery/src/http/api.ts+4−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { Hono } from "hono"; |
| 2 | 2 | import { fromUtf8 } from "@diffinery/git-core"; |
| 3 | 3 | import type { Env } from "../env.js"; |
| 4 | -import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 4 | +import { callerFor, parseScopes, requireAccount, requireAccountScope, requireBrowserSession, requireRecentSignIn, type AppContext, type AppVars } from "../auth/principal.js"; | |
| 5 | 5 | import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { encryptSecret } from "../lib/crypto.js"; |
| 7 | 7 | import { publicJwk } from "../lib/jwt.js"; |
| @@ -119,6 +119,7 @@ | ||
| 119 | 119 | const role = await dir.orgRole(org.id, a.id); |
| 120 | 120 | if (!role && !a.site_admin) throw new HttpError(404, "not found"); |
| 121 | 121 | if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can delete it"); |
| 122 | + requireRecentSignIn(c, `/${org.login}?tab=settings`); | |
| 122 | 123 | await dir.deleteOrg(org); |
| 123 | 124 | c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.delete", target: org.login })); |
| 124 | 125 | return c.json({}, 202); |
| @@ -263,6 +264,7 @@ | ||
| 263 | 264 | api.delete("/repos/:owner/:repo", async (c) => { |
| 264 | 265 | const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true }); |
| 265 | 266 | if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories"); |
| 267 | + requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`); | |
| 266 | 268 | await c.get("dir").deleteRepo(repo); |
| 267 | 269 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "repo.delete", target: `${repo.owner_login}/${repo.name}` })); |
| 268 | 270 | return c.body(null, 204); |
| @@ -274,6 +276,7 @@ | ||
| 274 | 276 | const p = c.get("principal"); |
| 275 | 277 | if (p.type !== "account" || caller.kind !== "user" || caller.permissions) throw new HttpError(403, "agents and workflow tokens cannot transfer repositories"); |
| 276 | 278 | requireAccountScope(c, "admin"); |
| 279 | + requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`); | |
| 277 | 280 | const b = await body<{ new_owner?: unknown; new_name?: unknown }>(c); |
| 278 | 281 | if (typeof b.new_owner !== "string" || !b.new_owner.trim()) throw new HttpError(422, "new_owner is required", { errors: [{ resource: "Repository", code: "missing_field", field: "new_owner" }] }); |
| 279 | 282 | if (b.new_name !== undefined && b.new_name !== null && typeof b.new_name !== "string") throw new HttpError(422, "new_name must be a string", { errors: [{ resource: "Repository", code: "invalid", field: "new_name" }] }); |
modifiedapps/diffinery/src/web/account.tsx+30−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import { raw } from "hono/html"; |
| 3 | 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | -import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 4 | +import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 5 | 5 | import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js"; |
| 6 | 6 | import { loadRepo } from "../http/common.js"; |
| 7 | 7 | import { movedLoginPath } from "../lib/redirects.js"; |
| @@ -743,6 +743,7 @@ | ||
| 743 | 743 | "/settings", |
| 744 | 744 | async () => { |
| 745 | 745 | const login = f.s("login").trim(); |
| 746 | + if (login && login !== me.login) requireRecentSignIn(c, "/settings"); | |
| 746 | 747 | await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() }); |
| 747 | 748 | // The old login's cached picture must not outlive the rename. |
| 748 | 749 | if (login && login !== me.login) c.executionCtx.waitUntil(purgeAvatarCache(c, me.login, [me.avatar_version])); |
| @@ -887,12 +888,20 @@ | ||
| 887 | 888 | 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} /> |
| 888 | 889 | </div> |
| 889 | 890 | </div> |
| 891 | + {s.id !== current ? ( | |
| 892 | + <Form action={`/settings/security/sessions/${encodeURIComponent(s.id)}/revoke`} t={t} class="inline" confirm={`Sign out ${deviceLabel(s.user_agent ?? undefined)}?`}> | |
| 893 | + <button class="btn sm">Sign out</button> | |
| 894 | + </Form> | |
| 895 | + ) : null} | |
| 890 | 896 | </li> |
| 891 | 897 | ))} |
| 892 | 898 | </ul> |
| 893 | 899 | ) : ( |
| 894 | 900 | <div class="empty">No browser sessions.</div> |
| 895 | 901 | )} |
| 902 | + <div class="body small muted" style="border-top:1px solid var(--border)"> | |
| 903 | + A browser stays signed in for 30 days after you last use it, and at most 90 days after you sign in. Adding passkeys, creating tokens and agents, and deleting or transferring repositories ask you to sign in again when your sign-in is more than 2 hours old. | |
| 904 | + </div> | |
| 896 | 905 | {sessions.some((s) => s.id !== current) ? ( |
| 897 | 906 | <div class="body" style="border-top:1px solid var(--border)"> |
| 898 | 907 | <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?"> |
| @@ -920,6 +929,7 @@ | ||
| 920 | 929 | const key = keys.find((k) => k.id === id); |
| 921 | 930 | if (!key) throw new HttpError(404, "passkey not found"); |
| 922 | 931 | if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one."); |
| 932 | + requireRecentSignIn(c, "/settings/security"); | |
| 923 | 933 | const p = c.get("principal"); |
| 924 | 934 | await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null); |
| 925 | 935 | c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } })); |
| @@ -928,6 +938,20 @@ | ||
| 928 | 938 | ); |
| 929 | 939 | }); |
| 930 | 940 | |
| 941 | + post(web, "/settings/security/sessions/:id/revoke", async (c) => { | |
| 942 | + const me = browserAccount(c); | |
| 943 | + const id = prm(c, "id"); | |
| 944 | + return act( | |
| 945 | + c, | |
| 946 | + "/settings/security", | |
| 947 | + async () => { | |
| 948 | + if (!(await new Auth(c.env).endSessionById(me.id, id))) throw new HttpError(404, "That browser is already signed out."); | |
| 949 | + c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "session.revoke" })); | |
| 950 | + }, | |
| 951 | + "Signed out of that browser.", | |
| 952 | + ); | |
| 953 | + }); | |
| 954 | + | |
| 931 | 955 | post(web, "/settings/security/sessions/others", async (c) => { |
| 932 | 956 | const me = browserAccount(c); |
| 933 | 957 | const p = c.get("principal"); |
| @@ -1035,6 +1059,7 @@ | ||
| 1035 | 1059 | |
| 1036 | 1060 | post(web, "/settings/tokens", async (c, f) => { |
| 1037 | 1061 | const me = requireAccount(c); |
| 1062 | + requireRecentSignIn(c, "/settings/tokens"); | |
| 1038 | 1063 | const dir = c.get("dir"); |
| 1039 | 1064 | const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; |
| 1040 | 1065 | const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id)); |
| @@ -1184,6 +1209,7 @@ | ||
| 1184 | 1209 | |
| 1185 | 1210 | post(web, "/settings/agents", async (c, f) => { |
| 1186 | 1211 | const me = requireAccount(c); |
| 1212 | + requireRecentSignIn(c, "/settings/agents"); | |
| 1187 | 1213 | const dir = c.get("dir"); |
| 1188 | 1214 | return act( |
| 1189 | 1215 | c, |
| @@ -1205,6 +1231,7 @@ | ||
| 1205 | 1231 | |
| 1206 | 1232 | post(web, "/settings/agents/:id/token", async (c, f) => { |
| 1207 | 1233 | const me = requireAccount(c); |
| 1234 | + requireRecentSignIn(c, "/settings/agents"); | |
| 1208 | 1235 | const agent = await loadAgent(c, me); |
| 1209 | 1236 | const dir = c.get("dir"); |
| 1210 | 1237 | const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write"; |
| @@ -1442,6 +1469,7 @@ | ||
| 1442 | 1469 | const dir = c.get("dir"); |
| 1443 | 1470 | const target = await dir.account(prm(c, "id")); |
| 1444 | 1471 | if (!target || target.kind !== "user") throw new HttpError(404, "user not found"); |
| 1472 | + requireRecentSignIn(c, "/settings/admin"); | |
| 1445 | 1473 | if (target.status === "disabled") throw new HttpError(422, "enable this account first"); |
| 1446 | 1474 | const link = await new Auth(c.env).createSigninLink(me, target); |
| 1447 | 1475 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login })); |
| @@ -1965,6 +1993,7 @@ | ||
| 1965 | 1993 | async () => { |
| 1966 | 1994 | if (!isOwner) throw new HttpError(403, "organization owners only"); |
| 1967 | 1995 | if (f.s("confirm").trim() !== org.login) throw new HttpError(422, `type ${org.login} to confirm deletion`); |
| 1996 | + requireRecentSignIn(c, `/${org.login}?tab=settings`); | |
| 1968 | 1997 | await dir.deleteOrg(org); |
| 1969 | 1998 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.delete", target: org.login })); |
| 1970 | 1999 | return "/"; |
modifiedapps/diffinery/src/web/assets.ts+1−1View file
| @@ -364,7 +364,7 @@ | ||
| 364 | 364 | const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')}; |
| 365 | 365 | 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}; |
| 366 | 366 | async function pkPost(url,body){const r=await fetch(url,{method:'POST',credentials:'same-origin',headers:{'content-type':'application/json'},body:JSON.stringify(body)}); |
| 367 | - let j={};try{j=await r.json()}catch(_){}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j} | |
| 367 | + let j={};try{j=await r.json()}catch(_){}if(!r.ok&&j.confirm){location.assign(j.confirm);await new Promise(()=>{})}if(!r.ok)throw new Error(j.message||('Request failed ('+r.status+')'));return j} | |
| 368 | 368 | async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f); |
| 369 | 369 | const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)}; |
| 370 | 370 | if(!window.PublicKeyCredential||!navigator.credentials){show('This browser does not support passkeys. Try a current version of Chrome, Safari, Edge or Firefox.');return} |
modifiedapps/diffinery/src/web/auth.tsx+33−3View file
| @@ -11,13 +11,13 @@ | ||
| 11 | 11 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 12 | 12 | import { raw } from "hono/html"; |
| 13 | 13 | import type { FC, PropsWithChildren } from "hono/jsx"; |
| 14 | -import { accessToken, type AppContext } from "../auth/principal.js"; | |
| 14 | +import { RECENT_SIGNIN_MS, accessToken, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 15 | 15 | import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js"; |
| 16 | 16 | import type { Account } from "../lib/directory.js"; |
| 17 | 17 | import { verifyAccessJwt } from "../lib/jwt.js"; |
| 18 | 18 | import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js"; |
| 19 | 19 | import { ICONS } from "./assets.js"; |
| 20 | -import { Form, act, flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 20 | +import { Form, act, confirmUrl, flash, post, prm, render, statusOf, type WebApp } from "./ui.js"; | |
| 21 | 21 | import { csrfToken } from "./csrf.js"; |
| 22 | 22 | import { validInviteProof } from "../auth/invite-proof.js"; |
| 23 | 23 | |
| @@ -98,7 +98,8 @@ | ||
| 98 | 98 | } catch (e) { |
| 99 | 99 | const s = statusOf(e); |
| 100 | 100 | if (s.status >= 500) console.error(e); |
| 101 | - return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message }, s.status as 400); | |
| 101 | + const confirm = confirmUrl(e); | |
| 102 | + return c.json({ message: s.status >= 500 ? "Something went wrong. Please try again." : s.message, ...(confirm ? { confirm } : {}) }, s.status as 400); | |
| 102 | 103 | } |
| 103 | 104 | }); |
| 104 | 105 | } |
| @@ -213,6 +214,33 @@ | ||
| 213 | 214 | web.get("/login", async (c) => { |
| 214 | 215 | const next = safeNext(c.req.query("next")); |
| 215 | 216 | const p = c.get("principal"); |
| 217 | + // A sensitive action asked a signed-in person to confirm it's them (requireRecentSignIn). | |
| 218 | + if (c.req.query("confirm") === "1" && p.type === "account" && p.via === "session" && !(p.sessionAt && Date.now() - Date.parse(p.sessionAt) < RECENT_SIGNIN_MS)) { | |
| 219 | + return render( | |
| 220 | + c, | |
| 221 | + { title: "Confirm it's you" }, | |
| 222 | + <AuthCard title="Confirm it's you"> | |
| 223 | + <p class="muted"> | |
| 224 | + You're signed in as <b>{p.account.login}</b>. Adding passkeys, creating tokens and agents, and deleting or transferring repositories need a sign-in from the last 2 hours. Use your passkey to continue. | |
| 225 | + </p> | |
| 226 | + <PasskeyUnsupported /> | |
| 227 | + <form data-passkey="login" data-next={next}> | |
| 228 | + <PasskeyMessage /> | |
| 229 | + <button class="btn primary block">Confirm with a passkey</button> | |
| 230 | + </form> | |
| 231 | + <div class="authfoot muted small"> | |
| 232 | + {accessConfigured(c) ? ( | |
| 233 | + <p> | |
| 234 | + No passkey on this device? <a href={`/auth/access?next=${encodeURIComponent(next)}`}>Confirm by email</a>. | |
| 235 | + </p> | |
| 236 | + ) : null} | |
| 237 | + <p> | |
| 238 | + <a href={next}>Cancel</a> | |
| 239 | + </p> | |
| 240 | + </div> | |
| 241 | + </AuthCard>, | |
| 242 | + ); | |
| 243 | + } | |
| 216 | 244 | if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302); |
| 217 | 245 | return render( |
| 218 | 246 | c, |
| @@ -338,6 +366,7 @@ | ||
| 338 | 366 | existing = await auth.passkeys(link.account.id); |
| 339 | 367 | } else { |
| 340 | 368 | const me = browserAccount(c); |
| 369 | + requireRecentSignIn(c, "/settings/security"); | |
| 341 | 370 | subject = { kind: "account", accountId: me.id }; |
| 342 | 371 | user = { id: me.id, name: me.login, displayName: me.name || me.login }; |
| 343 | 372 | existing = await auth.passkeys(me.id); |
| @@ -387,6 +416,7 @@ | ||
| 387 | 416 | if (s.kind === "account") { |
| 388 | 417 | const me = browserAccount(c); |
| 389 | 418 | if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey."); |
| 419 | + requireRecentSignIn(c, "/settings/security"); | |
| 390 | 420 | await auth.addPasskey({ ...key, account_id: me.id }); |
| 391 | 421 | c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label, passkeyId: key.id, ...signinContext(c) } })); |
| 392 | 422 | flash(c, "notice", `Passkey "${label}" added.`); |
modifiedapps/diffinery/src/web/repo-settings.tsx+3−1View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** @jsxImportSource hono/jsx */ |
| 2 | 2 | import { raw } from "hono/html"; |
| 3 | 3 | import type { Child, FC } from "hono/jsx"; |
| 4 | -import { requireAccount, type AppContext } from "../auth/principal.js"; | |
| 4 | +import { requireAccount, requireRecentSignIn, type AppContext } from "../auth/principal.js"; | |
| 5 | 5 | import { loadRepo } from "../http/common.js"; |
| 6 | 6 | import { HttpError } from "../lib/util.js"; |
| 7 | 7 | import type { ProtectionRules, Role } from "../repo/types.js"; |
| @@ -753,6 +753,7 @@ | ||
| 753 | 753 | `${repoUrl(l.repo)}/settings`, |
| 754 | 754 | async () => { |
| 755 | 755 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`); |
| 756 | + requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); | |
| 756 | 757 | await c.get("dir").deleteRepo(l.repo); |
| 757 | 758 | audit(c, l, "repo.delete"); |
| 758 | 759 | return "/"; |
| @@ -770,6 +771,7 @@ | ||
| 770 | 771 | `${repoUrl(l.repo)}/settings`, |
| 771 | 772 | async () => { |
| 772 | 773 | if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm the transfer`); |
| 774 | + requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`); | |
| 773 | 775 | const dir = c.get("dir"); |
| 774 | 776 | const target = await dir.accountByLogin(f.s("new_owner").trim()); |
| 775 | 777 | if (!target) throw new HttpError(422, "choose the new owner"); |
modifiedapps/diffinery/src/web/ui.tsx+9−0View file
| @@ -233,12 +233,19 @@ | ||
| 233 | 233 | } |
| 234 | 234 | |
| 235 | 235 | /** Run a state-changing action and redirect (303); client errors become a flash message on `back`. */ |
| 236 | +/** Where to confirm a sign-in before a sensitive action (see requireRecentSignIn), when `e` asks for that. */ | |
| 237 | +export function confirmUrl(e: unknown): string | null { | |
| 238 | + return e instanceof HttpError && typeof e.extra?.confirm === "string" ? e.extra.confirm : null; | |
| 239 | +} | |
| 240 | + | |
| 236 | 241 | export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> { |
| 237 | 242 | try { |
| 238 | 243 | const to = await fn(); |
| 239 | 244 | if (notice) flash(c, "notice", notice); |
| 240 | 245 | return c.redirect(to ?? back, 303); |
| 241 | 246 | } catch (e) { |
| 247 | + const confirm = confirmUrl(e); | |
| 248 | + if (confirm) return c.redirect(confirm, 303); | |
| 242 | 249 | const s = statusOf(e); |
| 243 | 250 | if (s.status >= 500) throw e; |
| 244 | 251 | flash(c, "error", s.message); |
| @@ -520,6 +527,8 @@ | ||
| 520 | 527 | export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> { |
| 521 | 528 | // A transferred or renamed repository: its old URLs lead to the new ones. |
| 522 | 529 | if (e instanceof RepoMoved) return c.redirect(e.location, e.status as 301 | 307); |
| 530 | + const confirm = confirmUrl(e); | |
| 531 | + if (confirm) return c.redirect(confirm, 303); | |
| 523 | 532 | const s = statusOf(e); |
| 524 | 533 | if (s.status >= 500) console.error(e); |
| 525 | 534 | // Signed-out visitors who open a page go straight to sign-in, and come back afterwards. |
modifiedapps/diffinery/test/e2e/security.test.mjs+54−2View file
| @@ -40,7 +40,7 @@ | ||
| 40 | 40 | const hash = (s) => createHash("sha256").update(s).digest("hex"); |
| 41 | 41 | const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`; |
| 42 | 42 | const T = { olive: tok("olive"), bot: tok("bot"), rita: tok("rita"), dora: tok("dora"), orphan: tok("orphan"), reader: tok("reader"), ritaAdmin: tok("ritaAdmin"), milo: tok("milo"), sam: tok("sam"), helper: tok("helper") }; |
| 43 | -const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}` }; | |
| 43 | +const S = { penny: `penny-session-${randomBytes(12).toString("hex")}`, dora: `dora-session-${randomBytes(12).toString("hex")}`, olive: `olive-session-${randomBytes(12).toString("hex")}`, sam: `sam-session-${randomBytes(12).toString("hex")}`, oliveOld: `olive-old-${randomBytes(12).toString("hex")}`, oliveStale: `olive-stale-${randomBytes(12).toString("hex")}`, oliveSpare: `olive-spare-${randomBytes(12).toString("hex")}` }; | |
| 44 | 44 | const OWNER = "olive"; |
| 45 | 45 | |
| 46 | 46 | const freePort = () => |
| @@ -136,6 +136,10 @@ | ||
| 136 | 136 | `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`, |
| 137 | 137 | session("s4", "u-sam", S.sam), |
| 138 | 138 | "UPDATE accounts SET email = 'sam@example.com' WHERE id = 'u-sam'", |
| 139 | + // olive's other browsers: one signed in 3 hours ago, one 100 days ago (used every day since), one spare. | |
| 140 | + session("s5", "u-olive", S.oliveOld).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 3 * 3600_000).toISOString()}', '${at}'`), | |
| 141 | + session("s6", "u-olive", S.oliveStale).replace(`'${at}', '${at}'`, `'${new Date(Date.now() - 100 * 86400_000).toISOString()}', '${at}'`), | |
| 142 | + session("s7", "u-olive", S.oliveSpare), | |
| 139 | 143 | ].join("; "), |
| 140 | 144 | ); |
| 141 | 145 | dev = spawn( |
| @@ -629,7 +633,7 @@ | ||
| 629 | 633 | const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1]; |
| 630 | 634 | assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`); |
| 631 | 635 | const r = await fetch(`${base}${action}`, { method: "POST", body: new URLSearchParams({ _csrf: csrf, ...fields }), redirect: "manual", headers: { cookie: `${cookie}; dfy_csrf=${pair}`, "sec-fetch-site": "same-origin" } }); |
| 632 | - return { status: r.status, flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() }; | |
| 636 | + return { status: r.status, location: r.headers.get("location"), flash: decodeURIComponent(decodeURIComponent(r.headers.get("set-cookie") ?? "")), text: await r.text() }; | |
| 633 | 637 | } |
| 634 | 638 | |
| 635 | 639 | /** Call an MCP tool with a token. */ |
| @@ -1838,3 +1842,51 @@ | ||
| 1838 | 1842 | assert.equal(tokens.plain.repository_owner_id, String(repo.owner.id)); |
| 1839 | 1843 | assert.match(tokens.plain.actor_id, /^\d+$/); |
| 1840 | 1844 | }); |
| 1845 | + | |
| 1846 | +test("security: sensitive actions need a recent sign-in, sessions end 90 days after it, and one browser can be signed out", async () => { | |
| 1847 | + const confirm = (next) => `/login?confirm=1&next=${encodeURIComponent(next)}`; | |
| 1848 | + // A session signed in 100 days ago has ended, however often it was used since. | |
| 1849 | + await status(401, "GET", "/user", undefined, { cookie: S.oliveStale }); | |
| 1850 | + assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveOld })).login, "olive"); | |
| 1851 | + | |
| 1852 | + // Signed in 3 hours ago: minting a token, adding a passkey or deleting a repository asks olive to sign in again. | |
| 1853 | + const minted = await status(403, "POST", "/diffinery/tokens", { name: "late", scopes: ["admin"] }, { cookie: S.oliveOld }); | |
| 1854 | + assert.equal(minted.confirm, confirm("/settings/tokens")); | |
| 1855 | + const form = await submitsAs(S.oliveOld, "/settings/tokens", "/settings/tokens", { name: "late", scope: "admin", days: "30" }); | |
| 1856 | + assert.equal(form.status, 303); | |
| 1857 | + assert.equal(form.location, confirm("/settings/tokens")); | |
| 1858 | + const passkey = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", cookie: `__Host-dfy_session=${S.oliveOld}` }, body: "{}" }); | |
| 1859 | + assert.equal(passkey.status, 403); | |
| 1860 | + assert.equal((await passkey.json()).confirm, confirm("/settings/security")); | |
| 1861 | + await ok("POST", "/user/repos", { name: "sudo-check", private: true }); | |
| 1862 | + await status(403, "DELETE", `/repos/${OWNER}/sudo-check`, undefined, { cookie: S.oliveOld }); | |
| 1863 | + const del = await submitsAs(S.oliveOld, `/${OWNER}/sudo-check/settings`, `/${OWNER}/sudo-check/settings/delete`, { confirm: `${OWNER}/sudo-check` }); | |
| 1864 | + assert.equal(del.location, confirm(`/${OWNER}/sudo-check/settings`)); | |
| 1865 | + await ok("GET", `/repos/${OWNER}/sudo-check`); | |
| 1866 | + const rename = await submitsAs(S.oliveOld, "/settings", "/settings/profile", { login: "olive-renamed", name: "olive" }); | |
| 1867 | + assert.equal(rename.location, confirm("/settings")); | |
| 1868 | + assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveOld })).login, "olive"); | |
| 1869 | + assert.equal((await ok("GET", "/diffinery/tokens")).filter((t) => t.name === "late").length, 0); | |
| 1870 | + | |
| 1871 | + // The sign-in page asks the old session to confirm; a fresh one goes straight back. | |
| 1872 | + const page = await fetch(`${base}${confirm("/settings/tokens")}`, { headers: { cookie: `__Host-dfy_session=${S.oliveOld}` }, redirect: "manual" }); | |
| 1873 | + assert.equal(page.status, 200); | |
| 1874 | + assert.match(await page.text(), /Confirm it's you|Confirm it's you/); | |
| 1875 | + const fresh = await fetch(`${base}${confirm("/settings/tokens")}`, { headers: { cookie: `__Host-dfy_session=${S.olive}` }, redirect: "manual" }); | |
| 1876 | + assert.equal(fresh.status, 302); | |
| 1877 | + assert.equal(new URL(fresh.headers.get("location"), base).pathname, "/settings/tokens"); | |
| 1878 | + // A fresh sign-in may; a token is bounded by its scope instead. | |
| 1879 | + await ok("POST", "/diffinery/tokens", { name: "fresh", scopes: ["read"] }, { cookie: S.olive }); | |
| 1880 | + const opts = await fetch(`${base}/auth/passkey/register/options`, { method: "POST", headers: { "content-type": "application/json", cookie: `__Host-dfy_session=${S.olive}` }, body: "{}" }); | |
| 1881 | + assert.equal(opts.status, 200); | |
| 1882 | + await status(204, "DELETE", `/repos/${OWNER}/sudo-check`); | |
| 1883 | + | |
| 1884 | + // Sign out one browser: only your own. | |
| 1885 | + const sams = await submitsAs(S.sam, "/settings/security", `/settings/security/sessions/${hash(S.oliveSpare)}/revoke`, {}); | |
| 1886 | + assert.match(sams.flash, /already signed out/); | |
| 1887 | + assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveSpare })).login, "olive"); | |
| 1888 | + const mine = await submitsAs(S.olive, "/settings/security", `/settings/security/sessions/${hash(S.oliveSpare)}/revoke`, {}); | |
| 1889 | + assert.match(mine.flash, /Signed out of that browser/); | |
| 1890 | + await status(401, "GET", "/user", undefined, { cookie: S.oliveSpare }); | |
| 1891 | + assert.equal((await ok("GET", "/user", undefined, { cookie: S.oliveOld })).login, "olive"); | |
| 1892 | +}); |