Public: anyone can read this; the crew can change it.

A token a person deletes stays deleted: its OAuth refresh token ends with it #8436

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/forge:mason/8428-a-token-a-person-deletes-stays-deleted-i → main 1 commit fork

  • Merged. mason/8428-a-token-a-person-deletes-stays-deleted-i was merged into main as 6af380c.

Review

Sign in to review this change.
3 files changed +75 −6
docs/features/oauth-refresh.md +3
@@ -14,5 +14,8 @@ curl -X POST https://<forge>/oauth/token \
1414 and leaves a `token.deleted` event with `via: oauth_refresh_reuse`.
1515 - `scope` may only narrow (`rw` to `ro`); more is `invalid_scope` and spends nothing.
1616 - `POST /oauth/revoke` with either token ends the family.
17 - Deleting the access token on Settings › Tokens (or through the API, the terminal,
18 or the forge revoking it) ends the family too. An access token that merely
19 expires does not: the harness refreshes it.
1720 - Refresh tokens live 90 days from their issue, are stored hashed (`oauth_refresh`),
1821 and are bound to the client that got them. Sign-in-only (OpenID) grants get none.
src/core/tokens.ts +35 −6
@@ -181,11 +181,7 @@ export function deleteToken(
181181 userId: string,
182182 tokenId: string,
183183 ): boolean {
184 return (
185 db
186 .query("DELETE FROM token WHERE id = ?1 AND user_id = ?2")
187 .run(tokenId, userId).changes > 0
188 );
184 return deleteWithRefreshFamily(db, tokenId, userId);
189185 }
190186
191187 /**
@@ -194,7 +190,40 @@ export function deleteToken(
194190 * a person revoking their own goes through `deleteToken`.
195191 */
196192 export function revokeToken(db: Database, tokenId: string): boolean {
197 return db.query("DELETE FROM token WHERE id = ?1").run(tokenId).changes > 0;
193 return deleteWithRefreshFamily(db, tokenId, null);
194 }
195
196 /**
197 * Delete a token and, when the OAuth endpoint minted it with a refresh
198 * token, every `oauth_refresh` row of its family, so a revoked harness
199 * cannot refresh its way back (#8428). The family is read before the
200 * delete: `token_id` clears with the token. Plain SQL, so this module
201 * stays free of an import of oauth.ts. Expiry (`pruneTokens`) does not
202 * come through here: an expired access token still refreshes.
203 */
204 function deleteWithRefreshFamily(
205 db: Database,
206 tokenId: string,
207 userId: string | null,
208 ): boolean {
209 return db.transaction(() => {
210 const family = db
211 .query<{ family: string }, [string]>(
212 "SELECT family FROM oauth_refresh WHERE token_id = ?1 LIMIT 1",
213 )
214 .get(tokenId);
215 const deleted =
216 userId === null
217 ? db.query("DELETE FROM token WHERE id = ?1").run(tokenId)
218 : db
219 .query("DELETE FROM token WHERE id = ?1 AND user_id = ?2")
220 .run(tokenId, userId);
221 if (deleted.changes === 0) return false;
222 if (family !== null) {
223 db.query("DELETE FROM oauth_refresh WHERE family = ?1").run(family.family);
224 }
225 return true;
226 })();
198227 }
199228
200229 /**
test/oauth-refresh.test.ts +37
@@ -8,6 +8,7 @@ import { tmpdir } from "node:os";
88 import { join } from "node:path";
99 import { createApp, type App } from "../src/app.ts";
1010 import { createClient, issueCode, parseAuthRequest, type OAuthClient } from "../src/core/oauth.ts";
11 import { deleteToken, pruneTokens, revokeToken } from "../src/core/tokens.ts";
1112 import { createUser, type User } from "../src/core/users.ts";
1213 import { serve, type Servers } from "../src/http/server.ts";
1314
@@ -166,6 +167,42 @@ describe("oauth refresh", () => {
166167 expect((await refresh(b.body["refresh_token"])).body["error"]).toBe("invalid_grant");
167168 });
168169
170 const accessIdOf = (refreshToken: unknown) =>
171 app.db
172 .query<{ token_id: string }, [string]>("SELECT token_id FROM oauth_refresh WHERE token_hash = ?1")
173 .get(new Bun.CryptoHasher("sha256").update(String(refreshToken)).digest("hex"))?.token_id ?? "";
174
175 test("a delete by someone other than the owner leaves the family refreshing", async () => {
176 const a = await exchange("rw", 86_400);
177 const rotated = await refresh(a.body["refresh_token"]);
178 expect(rotated.status).toBe(200);
179 const tokenId = accessIdOf(rotated.body["refresh_token"]);
180 expect(deleteToken(app.db, "someone-else", tokenId)).toBe(false);
181 expect((await refresh(rotated.body["refresh_token"])).status).toBe(200);
182 });
183
184 test("deleteToken and revokeToken each end the token's refresh family", async () => {
185 const a = await exchange("rw", 86_400);
186 expect(deleteToken(app.db, alice.id, accessIdOf(a.body["refresh_token"]))).toBe(true);
187 expect((await refresh(a.body["refresh_token"])).body["error"]).toBe("invalid_grant");
188 const b = await exchange("rw", 86_400);
189 const tokenId = accessIdOf(b.body["refresh_token"]);
190 const family = app.db.query<{ family: string }, [string]>("SELECT family FROM oauth_refresh WHERE token_id = ?1").get(tokenId)?.family;
191 expect(revokeToken(app.db, tokenId)).toBe(true);
192 expect((await refresh(b.body["refresh_token"])).body["error"]).toBe("invalid_grant");
193 expect(app.db.query("SELECT 1 FROM oauth_refresh WHERE family = ?1").get(family ?? "")).toBeNull();
194 });
195
196 test("an access token that merely expires still refreshes", async () => {
197 const a = await exchange("rw", 86_400);
198 app.db.query("UPDATE token SET expires_at = '2000-01-01T00:00:00.000Z' WHERE id = ?1").run(accessIdOf(a.body["refresh_token"]));
199 expect(pruneTokens(app.db)).toBeGreaterThan(0);
200 expect(await api(a.body["access_token"])).toBe(401);
201 const next = await refresh(a.body["refresh_token"]);
202 expect(next.status).toBe(200);
203 expect(await api(next.body["access_token"])).not.toBe(401);
204 });
205
169206 test("spent rows stay bounded: a family keeps one predecessor however often it refreshes", async () => {
170207 const a = await exchange("rw", 86_400);
171208 let token = a.body["refresh_token"];

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs