Merge pull request 'auth login: default to least-privilege token scopes' (#19) from fix/auth-login-scopes into main

This commit is contained in:
grok-reviewer-andresmgsl 2026-07-26 22:00:12 +00:00
commit 87b3cf98d9
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 -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-file <path> read an existing personal access token from a file
--token-name <name> name for the generated token --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: Interactive example:
@ -159,9 +161,31 @@ Flow:
1. Calls `GET /api/v1/user` to verify credentials and resolve the canonical `login` name. 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. 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. 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` ### `stoke auth logout`
Revoke the stored token remotely and delete the local config. Revoke the stored token remotely and delete the local config.

View file

@ -97,7 +97,18 @@ function makeTokenName() {
return `stoke-${host}-${Date.now()}`; 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 = [ 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:activitypub', 'write:activitypub',
'read:issue', 'write:issue', 'read:issue', 'write:issue',
'read:misc', 'write:misc', 'read:misc', 'write:misc',
@ -107,6 +118,10 @@ const DEFAULT_TOKEN_SCOPES = [
'read:user', 'write:user', 'read:user', 'write:user',
]; ];
function parseScopesOption(csv) {
return csv.split(',').map((s) => s.trim()).filter(Boolean);
}
function printErrorAndExit(err) { function printErrorAndExit(err) {
console.error(`Authentication failed: ${err.message}`); console.error(`Authentication failed: ${err.message}`);
if (err.status) { if (err.status) {
@ -132,10 +147,27 @@ auth
.option('-t, --token <token>', 'use an existing personal access token instead of generating one') .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-file <path>', 'read an existing personal access token from a file')
.option('--token-name <name>', 'name for the generated personal access token', makeTokenName()) .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) => { .action(async (options) => {
try { try {
let { url, username, password, passwordFile, token, tokenFile, tokenName } = options; 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 (tokenFile) token = readSecretFile(tokenFile, 'token');
if (passwordFile) password = readSecretFile(passwordFile, 'password'); if (passwordFile) password = readSecretFile(passwordFile, 'password');
@ -167,7 +199,7 @@ auth
const me = await client.verifyBasicAuth(username, password); const me = await client.verifyBasicAuth(username, password);
const login = me.login; 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) { if (!tokenRes.sha1) {
throw new Error('Token generation succeeded but no token value was returned.'); throw new Error('Token generation succeeded but no token value was returned.');
} }
@ -181,6 +213,7 @@ auth
tokenId: tokenRes.id, tokenId: tokenRes.id,
}; };
console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`); console.log(`Authenticated as ${login}. Token "${tokenRes.name}" created.`);
console.log(`Scopes: ${scopes.join(', ')}`);
} }
saveConfig(config); saveConfig(config);

View file

@ -421,4 +421,102 @@ test('pr review prints the review URL from the API response', async () => {
} finally { } finally {
fs.unlinkSync(cfg); 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/);
});