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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/8428-a-token-a-person-deletes-stays-deleted-i → main 1 commit fork
3 files
changed +75
−6
docs/features/oauth-refresh.md +3
| @@ -14,5 +14,8 @@ curl -X POST https://<forge>/oauth/token \ | ||
| 14 | 14 | and leaves a `token.deleted` event with `via: oauth_refresh_reuse`. |
| 15 | 15 | - `scope` may only narrow (`rw` to `ro`); more is `invalid_scope` and spends nothing. |
| 16 | 16 | - `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. | |
| 17 | 20 | - Refresh tokens live 90 days from their issue, are stored hashed (`oauth_refresh`), |
| 18 | 21 | 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( | ||
| 181 | 181 | userId: string, |
| 182 | 182 | tokenId: string, |
| 183 | 183 | ): 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); | |
| 189 | 185 | } |
| 190 | 186 | |
| 191 | 187 | /** |
| @@ -194,7 +190,40 @@ export function deleteToken( | ||
| 194 | 190 | * a person revoking their own goes through `deleteToken`. |
| 195 | 191 | */ |
| 196 | 192 | 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 | })(); | |
| 198 | 227 | } |
| 199 | 228 | |
| 200 | 229 | /** |
test/oauth-refresh.test.ts +37
| @@ -8,6 +8,7 @@ import { tmpdir } from "node:os"; | ||
| 8 | 8 | import { join } from "node:path"; |
| 9 | 9 | import { createApp, type App } from "../src/app.ts"; |
| 10 | 10 | import { createClient, issueCode, parseAuthRequest, type OAuthClient } from "../src/core/oauth.ts"; |
| 11 | import { deleteToken, pruneTokens, revokeToken } from "../src/core/tokens.ts"; | |
| 11 | 12 | import { createUser, type User } from "../src/core/users.ts"; |
| 12 | 13 | import { serve, type Servers } from "../src/http/server.ts"; |
| 13 | 14 | |
| @@ -166,6 +167,42 @@ describe("oauth refresh", () => { | ||
| 166 | 167 | expect((await refresh(b.body["refresh_token"])).body["error"]).toBe("invalid_grant"); |
| 167 | 168 | }); |
| 168 | 169 | |
| 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 | ||
| 169 | 206 | test("spent rows stay bounded: a family keeps one predecessor however often it refreshes", async () => { |
| 170 | 207 | const a = await exchange("rw", 86_400); |
| 171 | 208 | let token = a.body["refresh_token"]; |
new above
offline