Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

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)
modifiedapps/diffinery/docs/accounts/passkeys-and-sign-in.md+7−3View file
@@ -20,7 +20,7 @@
2020 | User verification | Required. The device must unlock the passkey with a biometric, PIN or screen lock, not just a tap. |
2121 | Attestation | None requested. Diffinery accepts any authenticator that can create a discoverable, user-verifying passkey. |
2222 | 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. |
2424
2525 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)).
2626
@@ -75,13 +75,17 @@
7575
7676 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."
7777
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+
7882 ## Sessions and signing out other browsers
7983
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.
8185
8286 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.
8387
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.
8589
8690 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.
8791
modifiedapps/diffinery/docs/security/audit-log.md+1−0View file
@@ -51,6 +51,7 @@
5151 | `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` |
5252 | `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 |
5353 | `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 |
5455 | `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` |
5556 | `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 |
5657 | `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 @@
4949 |---|---|
5050 | Name | `__Host-dfy_session` (the `__Host-` prefix pins it to this origin, `Path=/`, no `Domain`) |
5151 | 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 |
5353 | Storage | Only a SHA-256 hash of the session token is stored |
5454
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.
5656
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+
5769 ## Protection against cross-site requests
5870
5971 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 @@
99 import { taskPersonAllowed } from "../agentic/access.js";
1010
1111 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 }
1313 | { type: "repo-token"; repoId: string; token: string }
1414 | { type: "anonymous" };
1515
@@ -85,7 +85,7 @@
8585 const session = cookieFrom(req, SESSION_COOKIE);
8686 if (session) {
8787 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 };
8989 }
9090 if (devAuth(env, req.url)) {
9191 const account = await dir.userForEmail(env.DEV_AUTH!);
@@ -129,11 +129,41 @@
129129 */
130130 export function requireBrowserSession(c: AppContext): Account {
131131 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+ }
133136 if (p.type === "anonymous") throw new HttpError(401, "sign in required");
134137 throw new HttpError(403, `creating tokens needs a browser session: sign in at ${c.env.PUBLIC_URL}/settings/tokens`);
135138 }
136139
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+
137167 /** Validates scopes sent by a client: known names only, default write. */
138168 export function parseScopes(input: unknown): TokenScope[] {
139169 if (input === undefined || input === null) return ["write"];
modifiedapps/diffinery/src/auth/sessions.ts+16−3View file
@@ -8,6 +8,8 @@
88 /** Binds an in-flight WebAuthn ceremony to the browser that started it. */
99 export const CHALLENGE_COOKIE = "__Host-dfy_webauthn";
1010 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;
1113 const CHALLENGE_MINUTES = 5;
1214 const LINK_HOURS = 24;
1315
@@ -46,6 +48,9 @@
4648
4749 const days = (n: number): string => new Date(Date.now() + n * 86400_000).toISOString();
4850
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+
4954 /** Sessions, passkeys, WebAuthn challenges and sign-in links in D1. */
5055 export class Auth {
5156 constructor(private env: Env) {}
@@ -72,15 +77,17 @@
7277 return token;
7378 }
7479
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. */
7681 async session(token: string): Promise<{ session: SessionRow; account: Account } | null> {
7782 if (!/^[A-Za-z0-9_-]{20,64}$/.test(token)) return null;
7883 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;
8085 const account = await this.db.prepare("SELECT * FROM accounts WHERE id = ?").bind(row.account_id).first<Account>();
8186 if (!account || account.kind !== "user" || account.status === "disabled") return null;
8287 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();
8491 }
8592 return { session: row, account };
8693 }
@@ -94,6 +101,12 @@
94101 return r.results;
95102 }
96103
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+
97110 async endOtherSessions(accountId: string, keepId: string | null): Promise<void> {
98111 await this.db.prepare("DELETE FROM sessions WHERE account_id = ? AND id != ?").bind(accountId, keepId ?? "").run();
99112 }
modifiedapps/diffinery/src/http/api.ts+4−1View file
@@ -1,7 +1,7 @@
11 import { Hono } from "hono";
22 import { fromUtf8 } from "@diffinery/git-core";
33 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";
55 import { baseRole, type Account, type Directory, type Repo, type TokenScope } from "../lib/directory.js";
66 import { encryptSecret } from "../lib/crypto.js";
77 import { publicJwk } from "../lib/jwt.js";
@@ -119,6 +119,7 @@
119119 const role = await dir.orgRole(org.id, a.id);
120120 if (!role && !a.site_admin) throw new HttpError(404, "not found");
121121 if (role !== "owner" && !a.site_admin) throw new HttpError(403, "only organization owners can delete it");
122+ requireRecentSignIn(c, `/${org.login}?tab=settings`);
122123 await dir.deleteOrg(org);
123124 c.executionCtx.waitUntil(dir.audit({ actorId: a.id, actorLogin: a.login, action: "org.delete", target: org.login }));
124125 return c.json({}, 202);
@@ -263,6 +264,7 @@
263264 api.delete("/repos/:owner/:repo", async (c) => {
264265 const { repo, caller } = await loadRepo(c, P(c).owner, P(c).repo, "admin", { whileArchived: true });
265266 if (caller.kind === "agent") throw new HttpError(403, "agents cannot delete repositories");
267+ requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`);
266268 await c.get("dir").deleteRepo(repo);
267269 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: caller.id, actorLogin: caller.login, action: "repo.delete", target: `${repo.owner_login}/${repo.name}` }));
268270 return c.body(null, 204);
@@ -274,6 +276,7 @@
274276 const p = c.get("principal");
275277 if (p.type !== "account" || caller.kind !== "user" || caller.permissions) throw new HttpError(403, "agents and workflow tokens cannot transfer repositories");
276278 requireAccountScope(c, "admin");
279+ requireRecentSignIn(c, `/${repo.owner_login}/${repo.name}/settings`);
277280 const b = await body<{ new_owner?: unknown; new_name?: unknown }>(c);
278281 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" }] });
279282 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 @@
11 /** @jsxImportSource hono/jsx */
22 import { raw } from "hono/html";
33 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";
55 import { baseRole, type Account, type Repo, type TokenScope } from "../lib/directory.js";
66 import { loadRepo } from "../http/common.js";
77 import { movedLoginPath } from "../lib/redirects.js";
@@ -743,6 +743,7 @@
743743 "/settings",
744744 async () => {
745745 const login = f.s("login").trim();
746+ if (login && login !== me.login) requireRecentSignIn(c, "/settings");
746747 await c.get("dir").updateAccount(me.id, { login: login && login !== me.login ? login : undefined, name: f.s("name").trim() });
747748 // The old login's cached picture must not outlive the rename.
748749 if (login && login !== me.login) c.executionCtx.waitUntil(purgeAvatarCache(c, me.login, [me.avatar_version]));
@@ -887,12 +888,20 @@
887888 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} />
888889 </div>
889890 </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}
890896 </li>
891897 ))}
892898 </ul>
893899 ) : (
894900 <div class="empty">No browser sessions.</div>
895901 )}
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>
896905 {sessions.some((s) => s.id !== current) ? (
897906 <div class="body" style="border-top:1px solid var(--border)">
898907 <Form action="/settings/security/sessions/others" t={t} class="inline" confirm="Sign out every other browser?">
@@ -920,6 +929,7 @@
920929 const key = keys.find((k) => k.id === id);
921930 if (!key) throw new HttpError(404, "passkey not found");
922931 if (keys.length < 2) throw new HttpError(422, "Add another passkey before removing your last one.");
932+ requireRecentSignIn(c, "/settings/security");
923933 const p = c.get("principal");
924934 await auth.deletePasskey(me.id, id, p.type === "account" ? (p.sessionId ?? null) : null);
925935 c.executionCtx.waitUntil(c.get("dir").audit({ actorId: me.id, actorLogin: me.login, action: "passkey.remove", detail: { name: key.name } }));
@@ -928,6 +938,20 @@
928938 );
929939 });
930940
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+
931955 post(web, "/settings/security/sessions/others", async (c) => {
932956 const me = browserAccount(c);
933957 const p = c.get("principal");
@@ -1035,6 +1059,7 @@
10351059
10361060 post(web, "/settings/tokens", async (c, f) => {
10371061 const me = requireAccount(c);
1062+ requireRecentSignIn(c, "/settings/tokens");
10381063 const dir = c.get("dir");
10391064 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
10401065 const visible = new Set((await dir.reposVisibleTo(me)).map((r) => r.id));
@@ -1184,6 +1209,7 @@
11841209
11851210 post(web, "/settings/agents", async (c, f) => {
11861211 const me = requireAccount(c);
1212+ requireRecentSignIn(c, "/settings/agents");
11871213 const dir = c.get("dir");
11881214 return act(
11891215 c,
@@ -1205,6 +1231,7 @@
12051231
12061232 post(web, "/settings/agents/:id/token", async (c, f) => {
12071233 const me = requireAccount(c);
1234+ requireRecentSignIn(c, "/settings/agents");
12081235 const agent = await loadAgent(c, me);
12091236 const dir = c.get("dir");
12101237 const scope = SCOPES.find((s) => s === f.s("scope")) ?? "write";
@@ -1442,6 +1469,7 @@
14421469 const dir = c.get("dir");
14431470 const target = await dir.account(prm(c, "id"));
14441471 if (!target || target.kind !== "user") throw new HttpError(404, "user not found");
1472+ requireRecentSignIn(c, "/settings/admin");
14451473 if (target.status === "disabled") throw new HttpError(422, "enable this account first");
14461474 const link = await new Auth(c.env).createSigninLink(me, target);
14471475 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "signin_link.create", target: target.login }));
@@ -1965,6 +1993,7 @@
19651993 async () => {
19661994 if (!isOwner) throw new HttpError(403, "organization owners only");
19671995 if (f.s("confirm").trim() !== org.login) throw new HttpError(422, `type ${org.login} to confirm deletion`);
1996+ requireRecentSignIn(c, `/${org.login}?tab=settings`);
19681997 await dir.deleteOrg(org);
19691998 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "org.delete", target: org.login }));
19701999 return "/";
modifiedapps/diffinery/src/web/assets.ts+1−1View file
@@ -364,7 +364,7 @@
364364 const b2u=buf=>{let s='';new Uint8Array(buf).forEach(x=>{s+=String.fromCharCode(x)});return btoa(s).replaceAll('+','-').replaceAll('/','_').replaceAll('=','')};
365365 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};
366366 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}
368368 async function passkey(f){const mode=f.dataset.passkey,msg=$('[data-passkey-msg]',f)||$('[data-passkey-msg]'),btns=$$('button',f);
369369 const show=t=>{if(msg){msg.textContent=t;msg.hidden=!t}else if(t)alert(t)};
370370 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 @@
1111 import { deleteCookie, getCookie, setCookie } from "hono/cookie";
1212 import { raw } from "hono/html";
1313 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";
1515 import { Auth, CHALLENGE_COOKIE, SESSION_COOKIE, SESSION_DAYS, type PasskeyRow, type Subject } from "../auth/sessions.js";
1616 import type { Account } from "../lib/directory.js";
1717 import { verifyAccessJwt } from "../lib/jwt.js";
1818 import { HttpError, b64url, fromB64url, isValidLogin, newId } from "../lib/util.js";
1919 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";
2121 import { csrfToken } from "./csrf.js";
2222 import { validInviteProof } from "../auth/invite-proof.js";
2323
@@ -98,7 +98,8 @@
9898 } catch (e) {
9999 const s = statusOf(e);
100100 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);
102103 }
103104 });
104105 }
@@ -213,6 +214,33 @@
213214 web.get("/login", async (c) => {
214215 const next = safeNext(c.req.query("next"));
215216 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+ }
216244 if (p.type === "account" && p.via !== "dev") return c.redirect(next, 302);
217245 return render(
218246 c,
@@ -338,6 +366,7 @@
338366 existing = await auth.passkeys(link.account.id);
339367 } else {
340368 const me = browserAccount(c);
369+ requireRecentSignIn(c, "/settings/security");
341370 subject = { kind: "account", accountId: me.id };
342371 user = { id: me.id, name: me.login, displayName: me.name || me.login };
343372 existing = await auth.passkeys(me.id);
@@ -387,6 +416,7 @@
387416 if (s.kind === "account") {
388417 const me = browserAccount(c);
389418 if (me.id !== s.accountId) throw new HttpError(403, "You signed in as someone else while adding this passkey.");
419+ requireRecentSignIn(c, "/settings/security");
390420 await auth.addPasskey({ ...key, account_id: me.id });
391421 c.executionCtx.waitUntil(dir.audit({ actorId: me.id, actorLogin: me.login, action: "passkey.add", detail: { name: label, passkeyId: key.id, ...signinContext(c) } }));
392422 flash(c, "notice", `Passkey "${label}" added.`);
modifiedapps/diffinery/src/web/repo-settings.tsx+3−1View file
@@ -1,7 +1,7 @@
11 /** @jsxImportSource hono/jsx */
22 import { raw } from "hono/html";
33 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";
55 import { loadRepo } from "../http/common.js";
66 import { HttpError } from "../lib/util.js";
77 import type { ProtectionRules, Role } from "../repo/types.js";
@@ -753,6 +753,7 @@
753753 `${repoUrl(l.repo)}/settings`,
754754 async () => {
755755 if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm deletion`);
756+ requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`);
756757 await c.get("dir").deleteRepo(l.repo);
757758 audit(c, l, "repo.delete");
758759 return "/";
@@ -770,6 +771,7 @@
770771 `${repoUrl(l.repo)}/settings`,
771772 async () => {
772773 if (f.s("confirm").trim() !== full) throw new HttpError(422, `type ${full} to confirm the transfer`);
774+ requireRecentSignIn(c, `${repoUrl(l.repo)}/settings`);
773775 const dir = c.get("dir");
774776 const target = await dir.accountByLogin(f.s("new_owner").trim());
775777 if (!target) throw new HttpError(422, "choose the new owner");
modifiedapps/diffinery/src/web/ui.tsx+9−0View file
@@ -233,12 +233,19 @@
233233 }
234234
235235 /** 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+
236241 export async function act(c: AppContext, back: string, fn: () => Promise<string | void>, notice?: string): Promise<Response> {
237242 try {
238243 const to = await fn();
239244 if (notice) flash(c, "notice", notice);
240245 return c.redirect(to ?? back, 303);
241246 } catch (e) {
247+ const confirm = confirmUrl(e);
248+ if (confirm) return c.redirect(confirm, 303);
242249 const s = statusOf(e);
243250 if (s.status >= 500) throw e;
244251 flash(c, "error", s.message);
@@ -520,6 +527,8 @@
520527 export function errorPage(c: AppContext, e: unknown): Response | Promise<Response> {
521528 // A transferred or renamed repository: its old URLs lead to the new ones.
522529 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);
523532 const s = statusOf(e);
524533 if (s.status >= 500) console.error(e);
525534 // 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 @@
4040 const hash = (s) => createHash("sha256").update(s).digest("hex");
4141 const tok = (name) => `dfy_${name}_${randomBytes(12).toString("hex")}`;
4242 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")}` };
4444 const OWNER = "olive";
4545
4646 const freePort = () =>
@@ -136,6 +136,10 @@
136136 `INSERT INTO org_members (org_id, user_id, role, created_at) VALUES ('o-shop', 'u-olive', 'owner', '${at}'), ('o-shop', 'u-sam', 'member', '${at}')`,
137137 session("s4", "u-sam", S.sam),
138138 "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),
139143 ].join("; "),
140144 );
141145 dev = spawn(
@@ -629,7 +633,7 @@
629633 const pair = /dfy_csrf=([^;]+)/.exec(res.headers.get("set-cookie") ?? "")?.[1];
630634 assert.ok(csrf && pair, `no CSRF pair on ${page} (${res.status})`);
631635 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() };
633637 }
634638
635639 /** Call an MCP tool with a token. */
@@ -1838,3 +1842,51 @@
18381842 assert.equal(tokens.plain.repository_owner_id, String(repo.owner.id));
18391843 assert.match(tokens.plain.actor_id, /^\d+$/);
18401844 });
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&#39;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+});