From aaf341a2d853e465c68a86ad83e0a73e9f065992 Mon Sep 17 00:00:00 2001 From: jochen Date: Thu, 24 Sep 2026 11:43:46 +0200 Subject: [PATCH] gitea: the token needs read:user, not just write:repository and write:issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed #49 and the watcher immediately broke: GET /user/repos answered 403, 'required=[read:user]' — confirmed live against the running forge (1.27.3). That route sits under gitea's user scope category despite listing repositories, not repository as assumed. Also gives the fake forge real scope enforcement on /user/repos, which is why the original PR's test suite didn't catch this: it only checked the token's value was valid, never that it carried the required scope. --- modules/gitea/test/token.test.ts | 15 ++++++++++++++- modules/gitea/token.ts | 13 ++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/modules/gitea/test/token.test.ts b/modules/gitea/test/token.test.ts index 4327cd6..c634378 100644 --- a/modules/gitea/test/token.test.ts +++ b/modules/gitea/test/token.test.ts @@ -38,8 +38,12 @@ function fakeForge(): Promise { mints: 0, lastScopes: null as string[] | null, tokens: new Map(), // name -> value + scopesOf: new Map(), // value -> scopes, so a route can enforce them like gitea does admins: new Map([[ADMIN, PASSWORD]]), }; + // write:X implies read:X — gitea's own rule (models/auth/access_token_scope.go). + const covers = (scopes: string[], required: string): boolean => + scopes.includes(required) || scopes.includes(`write:${required.split(":")[1]}`); const json = (res: ServerResponse, status: number, body: unknown): void => { res.writeHead(status, { "Content-Type": "application/json" }); res.end(body === null ? "" : JSON.stringify(body)); @@ -70,6 +74,7 @@ function fakeForge(): Promise { forge.lastScopes = scopes; const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`; forge.tokens.set(name, sha1); + forge.scopesOf.set(sha1, scopes); return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) }); } if (req.method === "DELETE" && tokens[2]) { @@ -84,6 +89,14 @@ function fakeForge(): Promise { const h = req.headers.authorization ?? ""; const value = h.startsWith("token ") ? h.slice(6) : ""; if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" }); + // gitea 1.27.3: GET /user/repos sits under the `user` scope category, not `repository` — + // confirmed against the live forge. A token without read:user (or write:user) is refused here. + const scopes = forge.scopesOf.get(value) ?? []; + if (!covers(scopes, "read:user")) { + return json(res, 403, { + message: `token does not have at least one of required scope(s), required=[read:user]`, + }); + } return json(res, 200, [ { full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, ]); @@ -146,7 +159,7 @@ test("first start: mints with the admin account, keeps the token at 0600, asks f assert.equal(repos[0]?.full_name, "novox/hq"); assert.equal(forge.mints, 1); - assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue"]); + assert.deepEqual(forge.lastScopes, ["write:repository", "write:issue", "read:user"]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]); const token = forge.tokens.get("mesh-tools")!; assert.equal(await readFile(file, "utf8"), token + "\n"); diff --git a/modules/gitea/token.ts b/modules/gitea/token.ts index 732391b..e511eb9 100644 --- a/modules/gitea/token.ts +++ b/modules/gitea/token.ts @@ -28,12 +28,15 @@ export const TOKEN_NAME = "mesh-tools"; /** * The least the fifteen tools and the watcher need (gitea's route groups, 1.20+ scoped tokens): - * write:repository — list/create/delete repositories, pull requests (list/get/open/merge), and - * the watcher's /user/repos poll; - * write:issue — issues, comments, labels. - * Nothing under /admin, /orgs or /users — the escape-hatch tool reaches only what these two cover. + * write:repository — create/delete repositories, pull requests (list/get/open/merge); + * write:issue — issues, comments, labels; + * read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call. + * It sits under the `user` category despite listing repositories, not `repository` + * — confirmed against the running forge (1.27.3), which answered + * `required=[read:user]` to a token carrying only the other two. + * Nothing under /admin, /orgs or write:user — the escape-hatch tool reaches only what these three cover. */ -export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue"]; +export const TOKEN_SCOPES: readonly string[] = ["write:repository", "write:issue", "read:user"]; /** Where a client's token comes from, and what to do when the forge says it is wrong. */ export interface TokenSource { -- 2.54.0