auth login: default to least-privilege token scopes (#9)

Tokens minted by stoke auth login previously got read/write on every
non-admin scope. Default to the reduced set the common issue/PR/repo
commands need (read/write issue + repository, read user + organization),
add --full-scopes to restore the old behavior and --scopes <csv> for a
custom list, and print the granted scopes after login.
This commit is contained in:
kimi-reviewer-andresmgsl 2026-07-26 21:43:06 +00:00
parent f4b0bdbe4e
commit 955ce393fc
3 changed files with 158 additions and 3 deletions

View file

@ -125,6 +125,8 @@ Options:
-t, --token <token> use an existing personal access token instead of generating one
--token-file <path> read an existing personal access token from a file
--token-name <name> name for the generated token
--full-scopes grant full read/write access on all non-admin scopes
--scopes <csv> comma-separated list of scopes for the generated token
```
Interactive example:
@ -159,9 +161,31 @@ Flow:
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name.
2. Calls `POST /api/v1/users/{login}/tokens` to generate a personal access token.
3. Requests the standard non-admin scopes: `read/write` for `activitypub`, `issue`, `misc`, `organization`, `package`, `repository`, and `user`.
3. Requests the default least-privilege scopes (see "Token scopes" below).
4. Writes the token, token id, user details and URL to the config file.
Token scopes:
By default the generated token is least-privilege, covering the common
issue/PR/repository commands:
- `read:issue`, `write:issue` — issues, PR comments/reviews, labels
- `read:repository`, `write:repository` — repositories, branches, releases, collaborators, pull requests
- `read:user``auth status`, `user list`, `user show`
- `read:organization``org repos`, `org team list`, `org team member-list`
Organization administration (`org create`, `org avatar`, `org team create`,
`org team member-add`, `org team member-remove`) and package publishing need
broader access. Pass `--full-scopes` for the previous all-scopes behavior
(`read`/`write` on `activitypub`, `issue`, `misc`, `organization`, `package`,
`repository`, `user`), or `--scopes <csv>` for a custom list:
```bash
stoke auth login --scopes read:issue,write:issue,read:repository
```
The scopes the token was created with are printed after a successful login.
### `stoke auth logout`
Revoke the stored token remotely and delete the local config.

View file

@ -89,7 +89,18 @@ function makeTokenName() {
return `stoke-${host}-${Date.now()}`;
}
// Least-privilege default: enough for the daily issue/PR/repository commands.
// Org administration (org create/avatar, team create/member-*) and anything
// else outside this set needs --full-scopes or an explicit --scopes list.
const DEFAULT_TOKEN_SCOPES = [
'read:issue', 'write:issue',
'read:repository', 'write:repository',
'read:user',
'read:organization',
];
// The previous behavior: full read/write on every non-admin scope.
const FULL_TOKEN_SCOPES = [
'read:activitypub', 'write:activitypub',
'read:issue', 'write:issue',
'read:misc', 'write:misc',
@ -99,6 +110,10 @@ const DEFAULT_TOKEN_SCOPES = [
'read:user', 'write:user',
];
function parseScopesOption(csv) {
return csv.split(',').map((s) => s.trim()).filter(Boolean);
}
function printErrorAndExit(err) {
console.error(`Authentication failed: ${err.message}`);
if (err.status) {
@ -124,10 +139,27 @@ auth
.option('-t, --token <token>', 'use an existing personal access token instead of generating one')
.option('--token-file <path>', 'read an existing personal access token from a file')
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName())
.option('--full-scopes', 'grant full read/write access on all non-admin scopes', false)
.option('--scopes <csv>', 'comma-separated list of scopes for the generated token')
.action(async (options) => {
try {
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options;
if (options.fullScopes && options.scopes) {
console.error('Use either --full-scopes or --scopes, not both.');
process.exit(1);
}
let scopes = DEFAULT_TOKEN_SCOPES;
if (options.fullScopes) {
scopes = FULL_TOKEN_SCOPES;
} else if (options.scopes) {
scopes = parseScopesOption(options.scopes);
if (!scopes.length) {
console.error('--scopes produced an empty scope list.');
process.exit(1);
}
}
if (tokenFile) token = readSecretFile(tokenFile, 'token');
if (passwordFile) password = readSecretFile(passwordFile, 'password');
@ -159,7 +191,7 @@ auth
const me = await client.verifyBasicAuth(username, password);
const login = me.login;
const tokenRes = await client.createToken(login, password, tokenName, DEFAULT_TOKEN_SCOPES);
const tokenRes = await client.createToken(login, password, tokenName, scopes);
if (!tokenRes.sha1) {
throw new Error('Token generation succeeded but no token value was returned.');
}
@ -173,6 +205,7 @@ auth
tokenId: tokenRes.id,
};
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
console.log(`Scopes: ${scopes.join(', ')}`);
}
saveConfig(config);

View file

@ -421,4 +421,102 @@ test('pr review prints the review URL from the API response', async () => {
} finally {
fs.unlinkSync(cfg);
}
});
});
// Runs `auth login` against a stub Forgejo server and captures the body of
// the token-creation request. GETs answer as /user; the POST to
// /users/{name}/tokens is what carries the scopes under test.
function runLoginWithServer(extraArgs) {
const cfg = path.join(os.tmpdir(), `stoke-cfg-${process.pid}-${runLoginWithServer.n}.json`);
runLoginWithServer.n += 1;
const TIMEOUT_MS = 5000;
let timer;
return new Promise((resolve, reject) => {
const fail = (err) => {
clearTimeout(timer);
try { server.close(); } catch { /* already closed */ }
reject(err instanceof Error ? err : new Error(String(err)));
};
let tokenBody = null;
const server = http.createServer((req, res) => {
if (req.method === 'POST' && req.url.startsWith('/api/v1/users/')) {
let data = '';
req.on('data', (c) => { data += c; });
req.on('end', () => {
tokenBody = JSON.parse(data);
res.writeHead(201, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: 1, name: tokenBody.name, sha1: 'tok123' }));
});
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ login: 'alice', username: 'alice', email: 'alice@forge.test' }));
});
timer = setTimeout(() => fail(new Error('timeout')), TIMEOUT_MS);
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address();
try {
const res = await spawnAsync(
['auth', 'login', '-u', `http://127.0.0.1:${port}`, '-n', 'alice', '-p', 'secret', ...extraArgs],
{ STOKE_CONFIG_FILE: cfg },
);
clearTimeout(timer);
server.close(() => resolve({ res, tokenBody, cfg }));
} catch (err) {
fail(err);
}
});
}).finally(() => clearTimeout(timer));
}
runLoginWithServer.n = 0;
test('auth login creates a token with the reduced default scopes', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer([]);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:issue', 'write:issue',
'read:repository', 'write:repository',
'read:user',
'read:organization',
]);
assert.match(res.stdout, /Scopes: read:issue, write:issue, read:repository, write:repository, read:user, read:organization/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --full-scopes restores the full scope set', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--full-scopes']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, [
'read:activitypub', 'write:activitypub',
'read:issue', 'write:issue',
'read:misc', 'write:misc',
'read:organization', 'write:organization',
'read:package', 'write:package',
'read:repository', 'write:repository',
'read:user', 'write:user',
]);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login --scopes parses a comma-separated list', async () => {
const { res, tokenBody, cfg } = await runLoginWithServer(['--scopes', 'read:issue, write:repository ,read:user']);
try {
assert.equal(res.status, 0, res.stderr);
assert.deepEqual(tokenBody.scopes, ['read:issue', 'write:repository', 'read:user']);
assert.match(res.stdout, /Scopes: read:issue, write:repository, read:user/);
} finally {
fs.unlinkSync(cfg);
}
});
test('auth login rejects --full-scopes together with --scopes before any network call', () => {
const res = run(['auth', 'login', '--full-scopes', '--scopes', 'read:issue']);
assert.equal(res.status, 1);
assert.match(res.stderr, /either --full-scopes or --scopes, not both/);
});