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 {