gitea: the token needs read:user, not just write:repository and write:issue

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.
This commit is contained in:
2026-09-24 11:43:46 +02:00
parent 8a458aa5c4
commit aaf341a2d8
2 changed files with 22 additions and 6 deletions
+14 -1
View File
@@ -38,8 +38,12 @@ function fakeForge(): Promise<Forge> {
mints: 0,
lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value
scopesOf: new Map<string, string[]>(), // 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> {
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<Forge> {
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");