diff --git a/README.md b/README.md index 4da74e0..aadb8a4 100644 --- a/README.md +++ b/README.md @@ -125,6 +125,8 @@ Options: -t, --token use an existing personal access token instead of generating one --token-file read an existing personal access token from a file --token-name name for the generated token + --full-scopes grant full read/write access on all non-admin scopes + --scopes 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 ` 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. diff --git a/src/cli.js b/src/cli.js index e7357fa..2edb2a5 100755 --- a/src/cli.js +++ b/src/cli.js @@ -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 ', 'use an existing personal access token instead of generating one') .option('--token-file ', 'read an existing personal access token from a file') .option('--token-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 ', '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); diff --git a/test/cli.test.js b/test/cli.test.js index 02ce46b..42bd9e2 100644 --- a/test/cli.test.js +++ b/test/cli.test.js @@ -421,4 +421,102 @@ test('pr review prints the review URL from the API response', async () => { } finally { fs.unlinkSync(cfg); } -}); \ No newline at end of file +}); + +// 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/); +});