@apex-actions/cli 0.74.0 → 0.75.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -11
- package/dist/cli/login/main.js +18 -6
- package/dist/cli/logout/main.js +5 -3
- package/dist/cli/reference.js +197 -38
- package/dist/cli/spend/main.js +1 -1
- package/dist/cli/token/main.js +132 -29
- package/dist/cli/whoami/main.js +44 -2
- package/dist/src/application/authenticate.js +24 -8
- package/dist/src/application/manage-tokens.js +83 -3
- package/dist/src/domain/credentials.js +115 -16
- package/dist/src/infrastructure/control-plane-http.js +73 -16
- package/dist/src/infrastructure/credentials.js +30 -8
- package/dist/src/infrastructure/loopback-login.js +43 -11
- package/package.json +1 -1
package/dist/cli/token/main.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
|
-
import { createToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
|
|
4
|
-
import { MAX_TOKEN_DAYS, TOKENS_PAGE, webBaseFor } from '../../src/domain/credentials.js';
|
|
3
|
+
import { createToken, editToken, listTokens, revokeTokens } from '../../src/application/manage-tokens.js';
|
|
4
|
+
import { MAX_TOKEN_DAYS, TOKENS_PAGE, TOKEN_RESOURCES, describeGrants, webBaseFor } from '../../src/domain/credentials.js';
|
|
5
5
|
import { TOKEN_ENV, WEB_URL_ENV, apiUrlFrom } from '../../src/infrastructure/credentials.js';
|
|
6
6
|
import { HttpLoginExchange, listenForCallback, newPkcePair, openInBrowser } from '../../src/infrastructure/loopback-login.js';
|
|
7
7
|
import { api, collect, usage } from '../secret/shared.js';
|
|
@@ -13,10 +13,31 @@ import { api, collect, usage } from '../secret/shared.js';
|
|
|
13
13
|
* login` does, and prints the token once — the token alone on stdout, so
|
|
14
14
|
* `$(apex token create …)` captures it, and everything a person reads on
|
|
15
15
|
* stderr. It is never saved, and the CLI's own login is left as it was.
|
|
16
|
+
* A token carries the permissions asked for with `--permission` and belongs
|
|
17
|
+
* to one Account (control-plane ADR-0071).
|
|
18
|
+
* `edit` narrows a token at once with whichever credential the CLI has, and
|
|
19
|
+
* widens one only through the same browser approval.
|
|
16
20
|
* `list` and `revoke` use whichever credential the CLI already has.
|
|
17
21
|
*
|
|
18
22
|
* Exit codes follow §7: 0 done · 1 refused or declined · 2 usage · 3 no such token.
|
|
19
23
|
*/
|
|
24
|
+
const PERMISSION_HELP = `resource:read or resource:write (write implies read); resource is one of ${TOKEN_RESOURCES.join(', ')}`;
|
|
25
|
+
/** The browser half `create` and a widening `edit` share. */
|
|
26
|
+
const approvalDeps = (browser, what) => {
|
|
27
|
+
const apiUrl = apiUrlFrom();
|
|
28
|
+
return {
|
|
29
|
+
pkce: newPkcePair,
|
|
30
|
+
listen: (state) => listenForCallback(state),
|
|
31
|
+
openBrowser: browser ? (url) => openInBrowser(url) : undefined,
|
|
32
|
+
announce: (url) => {
|
|
33
|
+
console.error(browser ? `Opening the browser to approve ${what}. If it does not open, visit:` : `Open this URL to approve ${what}:`);
|
|
34
|
+
console.error(` ${url}`);
|
|
35
|
+
console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
|
|
36
|
+
},
|
|
37
|
+
exchange: new HttpLoginExchange(apiUrl),
|
|
38
|
+
webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
|
|
39
|
+
};
|
|
40
|
+
};
|
|
20
41
|
const refused = (message) => {
|
|
21
42
|
console.error(chalk.red(message));
|
|
22
43
|
process.exitCode = 1;
|
|
@@ -24,35 +45,41 @@ const refused = (message) => {
|
|
|
24
45
|
const createCommand = new Command('create')
|
|
25
46
|
.description('Create an API token for a script or CI system, approved in the browser; the token is printed once and not saved')
|
|
26
47
|
.argument('<name>', 'what the token is for, as the token list will show it (1–100 characters)')
|
|
48
|
+
.option('--permission <permission>', `what the token may do, repeatable and at least one: ${PERMISSION_HELP}`, collect, [])
|
|
49
|
+
.option('--account <id>', 'the Account the token belongs to — needed when you belong to more than one')
|
|
27
50
|
.option('--expires-in-days <days>', `days until the token expires, 1–${MAX_TOKEN_DAYS} (default 30)`)
|
|
28
|
-
.option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you
|
|
51
|
+
.option('--repo <owner/repo>', 'hold the token to this repository (repeatable, up to 100); omit for every repository you reach in the Account', collect, [])
|
|
29
52
|
.option('--no-browser', 'print the approval URL instead of opening a browser')
|
|
30
53
|
.option('--json', 'print the token and its details as JSON on stdout')
|
|
31
54
|
.action(async (name, opts) => {
|
|
32
55
|
const days = opts.expiresInDays === undefined ? undefined : /^[0-9]+$/.test(opts.expiresInDays) ? Number(opts.expiresInDays) : Number.NaN;
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
console.error(` ${url}`);
|
|
41
|
-
console.error(chalk.dim('Waiting for the approval (5 minutes)…'));
|
|
42
|
-
},
|
|
43
|
-
exchange: new HttpLoginExchange(apiUrl),
|
|
44
|
-
webBase: webBaseFor(apiUrl, process.env[WEB_URL_ENV]),
|
|
45
|
-
}, { name, days, repos: opts.repo });
|
|
56
|
+
const outcome = await createToken(approvalDeps(opts.browser, 'this token'), {
|
|
57
|
+
name,
|
|
58
|
+
days,
|
|
59
|
+
repos: opts.repo,
|
|
60
|
+
permissions: opts.permission,
|
|
61
|
+
accountId: opts.account,
|
|
62
|
+
});
|
|
46
63
|
switch (outcome.kind) {
|
|
47
64
|
case 'created': {
|
|
48
65
|
if (opts.json === true) {
|
|
49
|
-
console.log(JSON.stringify({
|
|
66
|
+
console.log(JSON.stringify({
|
|
67
|
+
token: outcome.token,
|
|
68
|
+
id: outcome.id,
|
|
69
|
+
name: outcome.name,
|
|
70
|
+
login: outcome.login,
|
|
71
|
+
expiresAt: outcome.expiresAt,
|
|
72
|
+
repos: outcome.repos ?? null,
|
|
73
|
+
permissions: outcome.permissions ?? [],
|
|
74
|
+
accountId: outcome.accountId ?? null,
|
|
75
|
+
}, null, 2));
|
|
50
76
|
}
|
|
51
77
|
else {
|
|
52
78
|
console.log(outcome.token);
|
|
53
79
|
}
|
|
54
|
-
const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you
|
|
80
|
+
const reach = outcome.repos === undefined || outcome.repos.length === 0 ? 'every repository you reach' : outcome.repos.join(', ');
|
|
55
81
|
console.error(`${chalk.green('✔')} created "${outcome.name}" for ${outcome.login} — ${reach}; expires ${outcome.expiresAt}`);
|
|
82
|
+
console.error(` permissions: ${describeGrants(outcome.permissions)}${outcome.accountId === undefined ? '' : ` · Account ${outcome.accountId}`}`);
|
|
56
83
|
console.error(chalk.yellow('This is the only time the token is shown. Store it now — in your CI system as a secret named APEX_TOKEN.'));
|
|
57
84
|
return;
|
|
58
85
|
}
|
|
@@ -69,17 +96,83 @@ const createCommand = new Command('create')
|
|
|
69
96
|
refused('the browser came back without a code; run apex token create again');
|
|
70
97
|
return;
|
|
71
98
|
case 'timeout':
|
|
72
|
-
refused('no approval arrived within 5 minutes; run apex token create again');
|
|
99
|
+
refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token create again');
|
|
73
100
|
return;
|
|
74
101
|
case 'refused':
|
|
75
102
|
refused(outcome.detail);
|
|
76
103
|
return;
|
|
77
104
|
}
|
|
78
105
|
});
|
|
79
|
-
const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all
|
|
106
|
+
const reachOf = (t) => (t.repos === undefined || t.repos.length === 0 ? 'all' : t.repos.join(', '));
|
|
80
107
|
const day = (iso) => (iso === undefined ? '—' : iso.slice(0, 10));
|
|
108
|
+
const editCommand = new Command('edit')
|
|
109
|
+
.description('Change what a token may do or which repositories it reaches; narrowing applies at once, widening is approved in the browser')
|
|
110
|
+
.argument('<id>', 'the token id, from apex token list')
|
|
111
|
+
.option('--permission <permission>', `the token's whole new set of permissions, repeatable: ${PERMISSION_HELP}`, collect, [])
|
|
112
|
+
.option('--add-permission <permission>', 'add one permission to what the token holds (repeatable)', collect, [])
|
|
113
|
+
.option('--remove-permission <permission>', 'take one permission away (repeatable)', collect, [])
|
|
114
|
+
.option('--repo <owner/repo>', "the token's new list of repositories (repeatable, up to 100)", collect, [])
|
|
115
|
+
.option('--all-repos', 'let the token reach every repository you reach in its Account')
|
|
116
|
+
.option('--no-browser', 'if the edit widens the token, print the approval URL instead of opening a browser')
|
|
117
|
+
.option('--json', 'print the edited token as JSON')
|
|
118
|
+
.action(async (id, opts) => {
|
|
119
|
+
if (opts.allRepos === true && opts.repo.length > 0) {
|
|
120
|
+
usage('pass --repo or --all-repos, not both');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const client = api();
|
|
124
|
+
if (!client)
|
|
125
|
+
return;
|
|
126
|
+
const outcome = await editToken(client, approvalDeps(opts.browser, 'widening this token'), id, {
|
|
127
|
+
...(opts.permission.length > 0 ? { permissions: opts.permission } : {}),
|
|
128
|
+
add: opts.addPermission,
|
|
129
|
+
remove: opts.removePermission,
|
|
130
|
+
...(opts.allRepos === true ? { repos: 'all' } : opts.repo.length > 0 ? { repos: opts.repo } : {}),
|
|
131
|
+
});
|
|
132
|
+
switch (outcome.kind) {
|
|
133
|
+
case 'edited':
|
|
134
|
+
case 'unchanged': {
|
|
135
|
+
const t = outcome.token;
|
|
136
|
+
if (opts.json === true) {
|
|
137
|
+
console.log(JSON.stringify({ token: t, changed: outcome.kind === 'edited', ...(outcome.kind === 'edited' ? { via: outcome.via } : {}) }, null, 2));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (outcome.kind === 'unchanged')
|
|
141
|
+
console.log(chalk.yellow(`"${t.name}" already holds that — nothing changed`));
|
|
142
|
+
else
|
|
143
|
+
console.log(`${chalk.green('✔')} edited "${t.name}"${outcome.via === 'browser' ? ' — the widening was approved in the browser' : ''}`);
|
|
144
|
+
console.log(` permissions ${describeGrants(t.permissions)}`);
|
|
145
|
+
console.log(` repositories ${reachOf(t)}`);
|
|
146
|
+
if (t.accountId !== undefined)
|
|
147
|
+
console.log(` account ${t.accountId}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
case 'invalid':
|
|
151
|
+
usage(outcome.detail);
|
|
152
|
+
return;
|
|
153
|
+
case 'not-found':
|
|
154
|
+
console.error(chalk.red(`no active token with id ${outcome.id} — apex token list shows yours`));
|
|
155
|
+
process.exitCode = 3;
|
|
156
|
+
return;
|
|
157
|
+
case 'denied':
|
|
158
|
+
refused(`declined in the browser: ${outcome.detail}`);
|
|
159
|
+
return;
|
|
160
|
+
case 'state-mismatch':
|
|
161
|
+
refused('a callback arrived that did not belong to this request, so it was refused; run apex token edit again');
|
|
162
|
+
return;
|
|
163
|
+
case 'malformed':
|
|
164
|
+
refused('the browser came back without a code; run apex token edit again');
|
|
165
|
+
return;
|
|
166
|
+
case 'timeout':
|
|
167
|
+
refused('no approval arrived within 5 minutes; the approval page must be opened in a browser on this machine — run apex token edit again');
|
|
168
|
+
return;
|
|
169
|
+
case 'refused':
|
|
170
|
+
refused(outcome.detail);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
});
|
|
81
174
|
const listCommand = new Command('list')
|
|
82
|
-
.description('Your API tokens — names and last four characters, never the tokens')
|
|
175
|
+
.description('Your API tokens by Account, with what each may do — names and last four characters, never the tokens')
|
|
83
176
|
.option('--all', 'include expired and revoked tokens')
|
|
84
177
|
.option('--json', 'machine-readable output')
|
|
85
178
|
.action(async (opts) => {
|
|
@@ -99,23 +192,32 @@ const listCommand = new Command('list')
|
|
|
99
192
|
console.log(chalk.yellow(outcome.hidden > 0 ? `No active tokens (${outcome.hidden} expired or revoked; --all shows them).` : 'You hold no tokens.'));
|
|
100
193
|
return;
|
|
101
194
|
}
|
|
102
|
-
const
|
|
195
|
+
const header = [' NAME', 'ID', 'LAST 4', 'SOURCE', 'PERMISSIONS', 'REPOSITORIES', 'EXPIRES', 'LAST USED', 'STATE'];
|
|
196
|
+
const rowOf = (t) => [
|
|
103
197
|
`${t.current === true ? '* ' : ' '}${t.name}`,
|
|
104
198
|
t.id,
|
|
105
199
|
`…${t.last4}`,
|
|
106
200
|
t.source,
|
|
201
|
+
describeGrants(t.permissions),
|
|
107
202
|
reachOf(t),
|
|
108
|
-
day(t.createdAt),
|
|
109
203
|
day(t.expiresAt),
|
|
110
204
|
day(t.lastUsedAt),
|
|
111
205
|
t.state,
|
|
112
|
-
]
|
|
113
|
-
const
|
|
206
|
+
];
|
|
207
|
+
const rows = outcome.tokens.map(rowOf);
|
|
114
208
|
const widths = header.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length)));
|
|
115
209
|
const line = (cells) => cells.map((c, i) => c.padEnd(widths[i] ?? 0)).join(' ').trimEnd();
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
210
|
+
// One section per Account: a token belongs to one, and reaches only that Account's repositories.
|
|
211
|
+
const accounts = [...new Set(outcome.tokens.map((t) => t.accountId ?? ''))].sort((a, b) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b)));
|
|
212
|
+
accounts.forEach((account, i) => {
|
|
213
|
+
if (i > 0)
|
|
214
|
+
console.log('');
|
|
215
|
+
console.log(chalk.bold(account === '' ? 'No Account — these tokens reach nothing; create a new one' : `Account ${account}`));
|
|
216
|
+
console.log(chalk.dim(line(header)));
|
|
217
|
+
for (const t of outcome.tokens)
|
|
218
|
+
if ((t.accountId ?? '') === account)
|
|
219
|
+
console.log(line(rowOf(t)));
|
|
220
|
+
});
|
|
119
221
|
if (outcome.tokens.some((t) => t.current === true))
|
|
120
222
|
console.log(chalk.dim('* the token this CLI is using'));
|
|
121
223
|
if (outcome.hidden > 0)
|
|
@@ -151,7 +253,8 @@ const revokeCommand = new Command('revoke')
|
|
|
151
253
|
}
|
|
152
254
|
});
|
|
153
255
|
export default new Command('token')
|
|
154
|
-
.description(`API tokens for scripts and CI: create one (approved in the browser), list
|
|
256
|
+
.description(`API tokens for scripts and CI: create one with the permissions it needs (approved in the browser), edit, list or revoke yours. Also on the dashboard at ${TOKENS_PAGE}`)
|
|
155
257
|
.addCommand(createCommand)
|
|
258
|
+
.addCommand(editCommand)
|
|
156
259
|
.addCommand(listCommand)
|
|
157
260
|
.addCommand(revokeCommand);
|
package/dist/cli/whoami/main.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import { whoami } from '../../src/application/authenticate.js';
|
|
4
|
-
import {
|
|
4
|
+
import { listTokens } from '../../src/application/manage-tokens.js';
|
|
5
|
+
import { describeGrants, describeSource } from '../../src/domain/credentials.js';
|
|
5
6
|
import { credentialsPath } from '../../src/infrastructure/credentials.js';
|
|
6
7
|
import { HttpControlPlane, NotConfiguredError } from '../../src/infrastructure/control-plane-http.js';
|
|
7
8
|
const credentialLine = (credential) => {
|
|
@@ -22,9 +23,21 @@ const credentialLine = (credential) => {
|
|
|
22
23
|
* `apex whoami`: who the control plane thinks this CLI is, and which
|
|
23
24
|
* credential got it there. The answer is the server's (`GET /api/v1/me`), not
|
|
24
25
|
* a guess from the local file; the credential itself is never printed.
|
|
26
|
+
*
|
|
27
|
+
* It also names any credential that was present and lost (ADR-0005: the
|
|
28
|
+
* environment outranks the saved login), because the surprise this command
|
|
29
|
+
* exists to prevent is acting as somebody other than the one you meant.
|
|
30
|
+
*
|
|
31
|
+
* When the credential is a token it also says what the token may do and which
|
|
32
|
+
* Account it belongs to — the list of the person's tokens marks it `current`.
|
|
25
33
|
*/
|
|
26
34
|
export default new Command('whoami')
|
|
27
35
|
.description('Who the CLI is acting as, with which credential, and where it found it')
|
|
36
|
+
.addHelpText('after', '\nWhich credential is used, first match wins:\n' +
|
|
37
|
+
' 1. APEX_TOKEN in the environment\n' +
|
|
38
|
+
' 2. APEX_ADMIN_TOKEN in the environment (operators)\n' +
|
|
39
|
+
' 3. the token `apex login` saved\n' +
|
|
40
|
+
'A variable you exported outranks a saved login. Unset it to use the login.')
|
|
28
41
|
.option('--json', 'machine-readable output')
|
|
29
42
|
.action(async (opts) => {
|
|
30
43
|
let client;
|
|
@@ -42,7 +55,14 @@ export default new Command('whoami')
|
|
|
42
55
|
process.exitCode = 1;
|
|
43
56
|
return;
|
|
44
57
|
}
|
|
45
|
-
const { identity, source } = outcome;
|
|
58
|
+
const { identity, source, alsoPresent } = outcome;
|
|
59
|
+
// The token's grants and Account: best effort, since the identity above is the answer this command owes.
|
|
60
|
+
let token;
|
|
61
|
+
if (identity.credential?.kind === 'token') {
|
|
62
|
+
const listed = await listTokens(client, false);
|
|
63
|
+
if (listed.kind === 'listed')
|
|
64
|
+
token = listed.tokens.find((t) => t.current === true);
|
|
65
|
+
}
|
|
46
66
|
const login = identity.actor.kind === 'user' ? identity.actor.login : undefined;
|
|
47
67
|
const foundIn = source === 'login' ? `${describeSource(source)} (${credentialsPath()})` : describeSource(source);
|
|
48
68
|
if (opts.json === true) {
|
|
@@ -57,13 +77,35 @@ export default new Command('whoami')
|
|
|
57
77
|
...(identity.credential.source === undefined ? {} : { source: identity.credential.source }),
|
|
58
78
|
...(identity.credential.expiresAt === undefined ? {} : { expiresAt: identity.credential.expiresAt }),
|
|
59
79
|
},
|
|
80
|
+
...(token === undefined
|
|
81
|
+
? {}
|
|
82
|
+
: {
|
|
83
|
+
token: {
|
|
84
|
+
id: token.id,
|
|
85
|
+
name: token.name,
|
|
86
|
+
permissions: token.permissions ?? [],
|
|
87
|
+
repos: token.repos ?? null,
|
|
88
|
+
accountId: token.accountId ?? null,
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
60
91
|
foundIn: source,
|
|
92
|
+
notUsed: alsoPresent,
|
|
61
93
|
apiUrl: client.baseUrl,
|
|
62
94
|
}, null, 2));
|
|
63
95
|
return;
|
|
64
96
|
}
|
|
65
97
|
console.log(`${'acting as'.padEnd(14)}${login ?? 'the machine principal'}`);
|
|
66
98
|
console.log(`${'credential'.padEnd(14)}${credentialLine(identity.credential)}`);
|
|
99
|
+
if (token !== undefined) {
|
|
100
|
+
console.log(`${'token'.padEnd(14)}${token.name} (${token.id})`);
|
|
101
|
+
console.log(`${'permissions'.padEnd(14)}${describeGrants(token.permissions)}`);
|
|
102
|
+
console.log(`${'repositories'.padEnd(14)}${token.repos === undefined || token.repos.length === 0 ? 'every repository you reach in its Account' : token.repos.join(', ')}`);
|
|
103
|
+
console.log(`${'account'.padEnd(14)}${token.accountId ?? 'none — this token reaches nothing; create a new one'}`);
|
|
104
|
+
}
|
|
67
105
|
console.log(`${'found in'.padEnd(14)}${foundIn}`);
|
|
106
|
+
for (const other of alsoPresent) {
|
|
107
|
+
const where = other === 'login' ? `${describeSource(other)} (${credentialsPath()})` : describeSource(other);
|
|
108
|
+
console.log(`${'not used'.padEnd(14)}${where} — outranked by the credential above`);
|
|
109
|
+
}
|
|
68
110
|
console.log(`${'control plane'.padEnd(14)}${client.baseUrl}`);
|
|
69
111
|
});
|
|
@@ -1,37 +1,53 @@
|
|
|
1
|
-
import { authorizeUrl } from '../domain/credentials.js';
|
|
1
|
+
import { authorizeUrl, doneUrl } from '../domain/credentials.js';
|
|
2
2
|
/**
|
|
3
3
|
* The loopback flow itself: open the consent page, wait for the one callback,
|
|
4
4
|
* redeem its code with the verifier. Shared by `apex login`, which saves what
|
|
5
5
|
* it gets, and `apex token create`, which hands it back to be printed once.
|
|
6
6
|
*/
|
|
7
|
-
export const approveInBrowser = async (deps, token) => {
|
|
7
|
+
export const approveInBrowser = async (deps, token, accountId) => {
|
|
8
8
|
const pair = deps.pkce();
|
|
9
9
|
const listener = await deps.listen(pair.state);
|
|
10
10
|
try {
|
|
11
|
-
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name, token });
|
|
11
|
+
const url = authorizeUrl(deps.webBase, { port: listener.port, state: pair.state, codeChallenge: pair.challenge, name: deps.name, token, accountId });
|
|
12
12
|
deps.announce(url);
|
|
13
13
|
deps.openBrowser?.(url);
|
|
14
14
|
const callback = await listener.wait();
|
|
15
|
+
const purpose = token === undefined ? 'login' : (token.purpose ?? 'token');
|
|
16
|
+
if (callback.kind === 'denied')
|
|
17
|
+
await listener.conclude(doneUrl(deps.webBase, 'denied', purpose));
|
|
15
18
|
if (callback.kind !== 'code')
|
|
16
19
|
return callback;
|
|
20
|
+
// Redeem first, then hand the tab to the done page: its words must match what actually happened.
|
|
21
|
+
let outcome;
|
|
17
22
|
try {
|
|
18
|
-
|
|
23
|
+
outcome = { kind: 'minted', minted: await deps.exchange.redeem(callback.code, pair.verifier) };
|
|
19
24
|
}
|
|
20
25
|
catch (err) {
|
|
21
|
-
|
|
26
|
+
outcome = { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
22
27
|
}
|
|
28
|
+
await listener.conclude(doneUrl(deps.webBase, outcome.kind === 'minted' ? 'approved' : 'failed', purpose));
|
|
29
|
+
return outcome;
|
|
23
30
|
}
|
|
24
31
|
finally {
|
|
25
32
|
await listener.close();
|
|
26
33
|
}
|
|
27
34
|
};
|
|
28
35
|
export const login = async (deps) => {
|
|
29
|
-
const approval = await approveInBrowser(deps);
|
|
36
|
+
const approval = await approveInBrowser(deps, undefined, deps.accountId);
|
|
30
37
|
if (approval.kind !== 'minted')
|
|
31
38
|
return approval;
|
|
32
39
|
const { minted } = approval;
|
|
40
|
+
if (minted.token === undefined)
|
|
41
|
+
return { kind: 'refused', detail: 'the control plane answered the login without a token' };
|
|
33
42
|
await deps.store.save({ token: minted.token, login: minted.login, apiUrl: deps.apiUrl, expiresAt: minted.expiresAt });
|
|
34
|
-
return {
|
|
43
|
+
return {
|
|
44
|
+
kind: 'logged-in',
|
|
45
|
+
login: minted.login,
|
|
46
|
+
expiresAt: minted.expiresAt,
|
|
47
|
+
location: deps.store.location,
|
|
48
|
+
permissions: minted.permissions,
|
|
49
|
+
accountId: minted.accountId,
|
|
50
|
+
};
|
|
35
51
|
};
|
|
36
52
|
/**
|
|
37
53
|
* Revoke the saved token at the control plane that minted it, then forget it
|
|
@@ -58,7 +74,7 @@ export const logout = async (store, revoke) => {
|
|
|
58
74
|
};
|
|
59
75
|
export const whoami = async (api) => {
|
|
60
76
|
try {
|
|
61
|
-
return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource };
|
|
77
|
+
return { kind: 'identified', identity: await api.whoami(), source: api.credentialSource, alsoPresent: api.credentialsAlsoPresent ?? [] };
|
|
62
78
|
}
|
|
63
79
|
catch (err) {
|
|
64
80
|
return { kind: 'refused', detail: err instanceof Error ? err.message : String(err) };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// application layer — see ../../../../docs/conventions.md §1
|
|
2
|
-
import { tokenRequestProblem } from '../domain/credentials.js';
|
|
2
|
+
import { editedPermissions, tokenEditProblem, tokenRequestProblem } from '../domain/credentials.js';
|
|
3
3
|
import { approveInBrowser } from './authenticate.js';
|
|
4
4
|
const detail = (err) => (err instanceof Error ? err.message : String(err));
|
|
5
5
|
/**
|
|
@@ -8,13 +8,21 @@ const detail = (err) => (err instanceof Error ? err.message : String(err));
|
|
|
8
8
|
*/
|
|
9
9
|
export const createToken = async (deps, input) => {
|
|
10
10
|
const name = input.name.trim();
|
|
11
|
-
const problem = tokenRequestProblem(name, input.days, input.repos);
|
|
11
|
+
const problem = tokenRequestProblem(name, input.days, input.repos, input.permissions);
|
|
12
12
|
if (problem !== undefined)
|
|
13
13
|
return { kind: 'invalid', detail: problem };
|
|
14
|
-
const approval = await approveInBrowser({ ...deps, name }, {
|
|
14
|
+
const approval = await approveInBrowser({ ...deps, name }, {
|
|
15
|
+
purpose: 'token',
|
|
16
|
+
permissions: [...new Set(input.permissions)],
|
|
17
|
+
...(input.days === undefined ? {} : { days: input.days }),
|
|
18
|
+
...(input.repos.length === 0 ? {} : { repos: input.repos }),
|
|
19
|
+
...(input.accountId === undefined ? {} : { accountId: input.accountId }),
|
|
20
|
+
});
|
|
15
21
|
if (approval.kind !== 'minted')
|
|
16
22
|
return approval;
|
|
17
23
|
const { minted } = approval;
|
|
24
|
+
if (minted.token === undefined)
|
|
25
|
+
return { kind: 'refused', detail: 'the control plane answered without a token' };
|
|
18
26
|
return {
|
|
19
27
|
kind: 'created',
|
|
20
28
|
token: minted.token,
|
|
@@ -23,6 +31,78 @@ export const createToken = async (deps, input) => {
|
|
|
23
31
|
id: minted.id,
|
|
24
32
|
name: minted.name ?? name,
|
|
25
33
|
repos: minted.repos,
|
|
34
|
+
permissions: minted.permissions,
|
|
35
|
+
accountId: minted.accountId,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
const sameSet = (a, b) => (a ?? []).length === (b ?? []).length && (a ?? []).every((x) => (b ?? []).includes(x));
|
|
39
|
+
/**
|
|
40
|
+
* Edit a token's grants or repositories. The token is read first — `--add-permission` needs its current grants, and a
|
|
41
|
+
* browser approval must carry the repositories unchanged when only the grants change, because an approval that names
|
|
42
|
+
* none means every repository. Then the edit is asked for directly: a narrowing applies at once. The control plane
|
|
43
|
+
* refuses a widening to anything but a signed-in person (403), so that one goes through the approval page, whose
|
|
44
|
+
* redeemed code applies the edit and mints nothing.
|
|
45
|
+
*/
|
|
46
|
+
export const editToken = async (api, approval, id, edit) => {
|
|
47
|
+
const problem = tokenEditProblem(edit);
|
|
48
|
+
if (problem !== undefined)
|
|
49
|
+
return { kind: 'invalid', detail: problem };
|
|
50
|
+
let target;
|
|
51
|
+
try {
|
|
52
|
+
target = (await api.listTokens()).find((t) => t.id === id && t.state === 'active');
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
return { kind: 'refused', detail: detail(err) };
|
|
56
|
+
}
|
|
57
|
+
if (target === undefined)
|
|
58
|
+
return { kind: 'not-found', id };
|
|
59
|
+
const permissions = editedPermissions(target.permissions ?? [], edit);
|
|
60
|
+
if (permissions.length === 0)
|
|
61
|
+
return { kind: 'invalid', detail: 'that would leave the token no permission — revoke it instead' };
|
|
62
|
+
const repos = edit.repos === 'all' ? undefined : (edit.repos ?? target.repos);
|
|
63
|
+
const permissionsChange = !sameSet(permissions, target.permissions);
|
|
64
|
+
const reposChange = edit.repos !== undefined && !(edit.repos === 'all' ? (target.repos ?? []).length === 0 : sameSet(edit.repos, target.repos));
|
|
65
|
+
if (!permissionsChange && !reposChange)
|
|
66
|
+
return { kind: 'unchanged', token: target };
|
|
67
|
+
let answer;
|
|
68
|
+
try {
|
|
69
|
+
answer = await api.editToken(id, {
|
|
70
|
+
...(permissionsChange ? { permissions } : {}),
|
|
71
|
+
...(reposChange ? { repos: repos === undefined || repos.length === 0 ? null : repos } : {}),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
return { kind: 'refused', detail: detail(err) };
|
|
76
|
+
}
|
|
77
|
+
if (answer.kind === 'edited')
|
|
78
|
+
return { kind: 'edited', token: answer.token, via: 'direct' };
|
|
79
|
+
if (answer.kind === 'not-found')
|
|
80
|
+
return { kind: 'not-found', id };
|
|
81
|
+
if (answer.status !== 403)
|
|
82
|
+
return { kind: 'refused', detail: answer.detail };
|
|
83
|
+
// A widening: a person approves it in the browser, within the Account's policy.
|
|
84
|
+
const approved = await approveInBrowser({ ...approval, name: target.name }, {
|
|
85
|
+
purpose: 'edit',
|
|
86
|
+
tokenId: id,
|
|
87
|
+
permissions,
|
|
88
|
+
// No repositories keeps the token's own; every repository has to be asked for outright.
|
|
89
|
+
...(edit.repos === 'all' ? { allRepos: true } : repos === undefined || repos.length === 0 ? {} : { repos }),
|
|
90
|
+
});
|
|
91
|
+
if (approved.kind !== 'minted')
|
|
92
|
+
return approved;
|
|
93
|
+
const m = approved.minted;
|
|
94
|
+
return {
|
|
95
|
+
kind: 'edited',
|
|
96
|
+
via: 'browser',
|
|
97
|
+
token: {
|
|
98
|
+
...target,
|
|
99
|
+
...(m.name === undefined ? {} : { name: m.name }),
|
|
100
|
+
expiresAt: m.expiresAt,
|
|
101
|
+
permissions: m.permissions ?? permissions,
|
|
102
|
+
// The control plane's view is the answer: no repositories there means every repository.
|
|
103
|
+
repos: m.repos,
|
|
104
|
+
...(m.accountId === undefined ? {} : { accountId: m.accountId }),
|
|
105
|
+
},
|
|
26
106
|
};
|
|
27
107
|
};
|
|
28
108
|
/** The person's tokens, newest first; only the active ones unless `all`. */
|