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

Merged
jschoubben merged 1 commits from fix/gitea-user-repos-scope into main 2026-09-24 09:49:37 +00:00
2 changed files with 22 additions and 6 deletions
+14 -1
View File
@@ -38,8 +38,12 @@ function fakeForge(): Promise<Forge> {
mints: 0, mints: 0,
lastScopes: null as string[] | null, lastScopes: null as string[] | null,
tokens: new Map<string, string>(), // name -> value 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]]), 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 => { const json = (res: ServerResponse, status: number, body: unknown): void => {
res.writeHead(status, { "Content-Type": "application/json" }); res.writeHead(status, { "Content-Type": "application/json" });
res.end(body === null ? "" : JSON.stringify(body)); res.end(body === null ? "" : JSON.stringify(body));
@@ -70,6 +74,7 @@ function fakeForge(): Promise<Forge> {
forge.lastScopes = scopes; forge.lastScopes = scopes;
const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`; const sha1 = `minted-${forge.mints}-${Math.random().toString(36).slice(2)}`;
forge.tokens.set(name, sha1); 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) }); return json(res, 201, { id: forge.mints, name, sha1, scopes, token_last_eight: sha1.slice(-8) });
} }
if (req.method === "DELETE" && tokens[2]) { if (req.method === "DELETE" && tokens[2]) {
@@ -84,6 +89,14 @@ function fakeForge(): Promise<Forge> {
const h = req.headers.authorization ?? ""; const h = req.headers.authorization ?? "";
const value = h.startsWith("token ") ? h.slice(6) : ""; const value = h.startsWith("token ") ? h.slice(6) : "";
if (![...forge.tokens.values()].includes(value)) return json(res, 401, { message: "token is required" }); 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, [ return json(res, 200, [
{ full_name: "novox/hq", name: "hq", owner: { login: "novox" }, private: true, html_url: "http://fake/novox/hq" }, { 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(repos[0]?.full_name, "novox/hq");
assert.equal(forge.mints, 1); 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]); assert.deepEqual(forge.lastScopes, [...TOKEN_SCOPES]);
const token = forge.tokens.get("mesh-tools")!; const token = forge.tokens.get("mesh-tools")!;
assert.equal(await readFile(file, "utf8"), token + "\n"); assert.equal(await readFile(file, "utf8"), token + "\n");
+8 -5
View File
@@ -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): * 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 * write:repository — create/delete repositories, pull requests (list/get/open/merge);
* the watcher's /user/repos poll; * write:issue — issues, comments, labels;
* write:issue — issues, comments, labels. * read:user — GET /user/repos, which the watcher's poll and gitea_list_repos both call.
* Nothing under /admin, /orgs or /users — the escape-hatch tool reaches only what these two cover. * 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. */ /** Where a client's token comes from, and what to do when the forge says it is wrong. */
export interface TokenSource { export interface TokenSource {