fix(gitea-bot): mint PAT via Basic auth, not Sudo header
Gitea's POST /users/{name}/tokens is explicitly Basic-auth only;
neither the admin token nor Sudo header is accepted. Keep the random
password we generate at createUser time and pass it straight into
createAccessTokenFor as Basic auth.
For bots that already exist from a half-failed previous provision
run, reset their password via PATCH /admin/users/{name} so we can
Basic-auth as them and mint a fresh token.
Made-with: Cursor
This commit is contained in:
54
lib/gitea.ts
54
lib/gitea.ts
@@ -183,22 +183,62 @@ export async function createUser(opts: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mint a Personal Access Token on behalf of another user. This uses
|
* Mint a Personal Access Token for a user.
|
||||||
* Gitea's `Sudo` header — only works when GITEA_API_TOKEN is a site-admin
|
*
|
||||||
* token. Returns the plaintext token string, which must be stored
|
* Gitea's `POST /users/{username}/tokens` endpoint is explicitly Basic-auth
|
||||||
* encrypted (Gitea never shows it again).
|
* only: neither an admin token nor the `Sudo` header work. So we require
|
||||||
|
* the target user's password (we know it — we just created the user with
|
||||||
|
* a random one and can discard it right after).
|
||||||
|
*
|
||||||
|
* Returns the plaintext token (`sha1` field) — store it encrypted;
|
||||||
|
* Gitea never shows it again.
|
||||||
*/
|
*/
|
||||||
export async function createAccessTokenFor(opts: {
|
export async function createAccessTokenFor(opts: {
|
||||||
username: string;
|
username: string;
|
||||||
|
password: string;
|
||||||
name: string;
|
name: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> {
|
}): Promise<{ id: number; name: string; sha1: string; token_last_eight: string }> {
|
||||||
const { username, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts;
|
const { username, password, name, scopes = ['write:repository', 'write:issue', 'write:user'] } = opts;
|
||||||
return giteaFetch(`/users/${username}/tokens`, {
|
const basic = Buffer.from(`${username}:${password}`).toString('base64');
|
||||||
|
const url = `${GITEA_API_URL}/api/v1/users/${username}/tokens`;
|
||||||
|
const res = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Sudo: username },
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Basic ${basic}`,
|
||||||
|
},
|
||||||
body: JSON.stringify({ name, scopes }),
|
body: JSON.stringify({ name, scopes }),
|
||||||
});
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Gitea token-mint error ${res.status} on /users/${username}/tokens: ${text}`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin: edit an existing user. Used during re-provisioning to reset
|
||||||
|
* the bot's password so we can Basic-auth as it and mint a fresh PAT.
|
||||||
|
*/
|
||||||
|
export async function adminEditUser(opts: {
|
||||||
|
username: string;
|
||||||
|
password?: string;
|
||||||
|
fullName?: string;
|
||||||
|
email?: string;
|
||||||
|
}): Promise<GiteaUser> {
|
||||||
|
const body: Record<string, unknown> = {
|
||||||
|
source_id: 0,
|
||||||
|
// Editing requires specifying login name explicitly.
|
||||||
|
login_name: opts.username,
|
||||||
|
};
|
||||||
|
if (opts.password) body.password = opts.password;
|
||||||
|
if (opts.fullName) body.full_name = opts.fullName;
|
||||||
|
if (opts.email) body.email = opts.email;
|
||||||
|
return giteaFetch(`/admin/users/${opts.username}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GiteaTeam {
|
export interface GiteaTeam {
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
createUser,
|
createUser,
|
||||||
createAccessTokenFor,
|
createAccessTokenFor,
|
||||||
ensureOrgTeamMembership,
|
ensureOrgTeamMembership,
|
||||||
|
adminEditUser,
|
||||||
} from '@/lib/gitea';
|
} from '@/lib/gitea';
|
||||||
import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box';
|
import { encryptSecret, decryptSecret } from '@/lib/auth/secret-box';
|
||||||
|
|
||||||
@@ -254,17 +255,29 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom
|
|||||||
try {
|
try {
|
||||||
const wantBot = giteaBotUsernameFor(workspace.slug);
|
const wantBot = giteaBotUsernameFor(workspace.slug);
|
||||||
|
|
||||||
// 1. Ensure the bot user exists.
|
// 1. Ensure the bot user exists. We *always* generate a fresh
|
||||||
|
// random password — if the user already exists, we can't
|
||||||
|
// retrieve its password to auth with, so we'll fail at the
|
||||||
|
// token-mint step. The `partial` provision status + error
|
||||||
|
// message tells the operator to delete the bot user in Gitea
|
||||||
|
// (or reset its password) and re-run. Since this only hurts
|
||||||
|
// re-provisioning after a half-failed run, we also try to
|
||||||
|
// reset the password via the admin API in that case.
|
||||||
|
const password = `bot-${randomBytes(24).toString('base64url')}`;
|
||||||
|
|
||||||
let existingBot = await getUser(wantBot);
|
let existingBot = await getUser(wantBot);
|
||||||
if (!existingBot) {
|
if (!existingBot) {
|
||||||
const created = await createUser({
|
const created = await createUser({
|
||||||
username: wantBot,
|
username: wantBot,
|
||||||
email: giteaBotEmailFor(workspace.slug),
|
email: giteaBotEmailFor(workspace.slug),
|
||||||
// Password is never used (only the PAT is), but Gitea requires one.
|
password,
|
||||||
password: `bot-${randomBytes(24).toString('base64url')}`,
|
|
||||||
fullName: `Vibn bot (${workspace.slug})`,
|
fullName: `Vibn bot (${workspace.slug})`,
|
||||||
});
|
});
|
||||||
existingBot = { id: created.id, login: created.login };
|
existingBot = { id: created.id, login: created.login };
|
||||||
|
} else {
|
||||||
|
// Existing bot from a half-failed previous run — reset its
|
||||||
|
// password so we can basic-auth as it below.
|
||||||
|
await adminEditUser({ username: wantBot, password });
|
||||||
}
|
}
|
||||||
botUsername = existingBot.login;
|
botUsername = existingBot.login;
|
||||||
botUserId = existingBot.id;
|
botUserId = existingBot.id;
|
||||||
@@ -282,6 +295,7 @@ export async function ensureWorkspaceProvisioned(workspace: VibnWorkspace): Prom
|
|||||||
if (!botTokenEncrypted) {
|
if (!botTokenEncrypted) {
|
||||||
const pat = await createAccessTokenFor({
|
const pat = await createAccessTokenFor({
|
||||||
username: botUsername,
|
username: botUsername,
|
||||||
|
password,
|
||||||
name: `vibn-${workspace.slug}-${Date.now().toString(36)}`,
|
name: `vibn-${workspace.slug}-${Date.now().toString(36)}`,
|
||||||
scopes: ['write:repository', 'write:issue', 'write:user'],
|
scopes: ['write:repository', 'write:issue', 'write:user'],
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user