@zapqr/cli 0.1.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 +112 -0
- package/package.json +45 -0
- package/src/index.js +267 -0
- package/src/oauth.js +134 -0
- package/src/store.js +65 -0
package/README.md
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# `zapqr` — sign in with ZapQR from a terminal
|
|
2
|
+
|
|
3
|
+
**Not published to npm yet** — `npx @zapqr/cli` will 404. Run it from a checkout:
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
git clone -b feat/cli-device-login https://github.com/dasecure/zapqr-auth-idp.git
|
|
7
|
+
cd zapqr-auth-idp/cli
|
|
8
|
+
npm install # optional: pulls qrcode, for the terminal QR
|
|
9
|
+
node src/index.js login
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Once it is published, that becomes:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npx @zapqr/cli login
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Either way: a QR appears in your terminal. Scan it with your phone, approve, and this
|
|
19
|
+
machine holds tokens. No password to type, no browser needed on this box —
|
|
20
|
+
which is the point: the machine may be one you SSH'd into, and it has no
|
|
21
|
+
business holding your passkey.
|
|
22
|
+
|
|
23
|
+
Under the hood it's the OAuth 2.0 Device Authorization Grant (RFC 8628) — the
|
|
24
|
+
same grant `gh auth login`, Apple TV and Roku use — against `auth.zapqr.ai`.
|
|
25
|
+
|
|
26
|
+
## Commands
|
|
27
|
+
|
|
28
|
+
| | |
|
|
29
|
+
|---|---|
|
|
30
|
+
| `zapqr login` | Approve on your phone; tokens land here |
|
|
31
|
+
| `zapqr whoami` | Who this machine is signed in as (`--json` for scripts) |
|
|
32
|
+
| `zapqr token` | Print a fresh access token to stdout |
|
|
33
|
+
| `zapqr logout` | Revoke at the provider, then forget locally |
|
|
34
|
+
|
|
35
|
+
`zapqr token` refreshes automatically when the access token has expired, so it
|
|
36
|
+
is safe in a script:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
curl -H "Authorization: Bearer $(zapqr token)" https://api.example.com/thing
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Options
|
|
43
|
+
|
|
44
|
+
| Flag | Env | Default |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `--issuer <url>` | `ZAPQR_ISSUER` | `https://auth.zapqr.ai` |
|
|
47
|
+
| `--client-id <id>` | `ZAPQR_CLIENT_ID` | `zq-cli` |
|
|
48
|
+
| `--scope <scope>` | `ZAPQR_SCOPE` | `openid email offline_access` |
|
|
49
|
+
| `--no-qr` | | show the code without the QR |
|
|
50
|
+
|
|
51
|
+
## Registering the client
|
|
52
|
+
|
|
53
|
+
The device grant is opt-in per client. Before `zapqr login` works against a
|
|
54
|
+
provider, register a client there of type **device** with the id `zq-cli`.
|
|
55
|
+
Device clients are public — a CLI on someone else's laptop cannot keep a secret
|
|
56
|
+
— and register with no redirect URIs.
|
|
57
|
+
|
|
58
|
+
Until that client exists you'll get:
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
unauthorized_client
|
|
62
|
+
"zq-cli" is not registered for the device grant.
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
To try it before that client exists, borrow the demo one:
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
ZAPQR_CLIENT_ID=zq-unihiker-demo node src/index.js login
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Where tokens are stored
|
|
72
|
+
|
|
73
|
+
`$XDG_CONFIG_HOME/zapqr/credentials.json` (or `~/.config/...`), mode `0600`,
|
|
74
|
+
keyed by issuer so a staging provider can't overwrite production.
|
|
75
|
+
|
|
76
|
+
Not the OS keychain, deliberately. The obvious macOS route is
|
|
77
|
+
`security add-generic-password -w <secret>`, which puts the refresh token in
|
|
78
|
+
argv where any process running `ps` can read it. Trading a file only you can
|
|
79
|
+
read for a token that briefly leaks to every process on the machine is not an
|
|
80
|
+
upgrade. A proper keychain integration needs a native binding — a dependency
|
|
81
|
+
this CLI doesn't currently pay for.
|
|
82
|
+
|
|
83
|
+
## Dependencies
|
|
84
|
+
|
|
85
|
+
None required. `qrcode` is an *optional* dependency: with it you get the
|
|
86
|
+
scannable QR, without it you get the URL and code, and the flow is identical.
|
|
87
|
+
That keeps `npx` fast and lets the CLI run from a bare checkout.
|
|
88
|
+
|
|
89
|
+
## What you'll see
|
|
90
|
+
|
|
91
|
+
```
|
|
92
|
+
█▀▀▀▀▀█ ▄▀ ▄▄▀█ █▀▀▀▀▀█
|
|
93
|
+
█ ███ █ ▀█▄▀▄ █ █ ███ █
|
|
94
|
+
█ ▀▀▀ █ █▄▀▄█▀▀ █ ▀▀▀ █
|
|
95
|
+
▀▀▀▀▀▀▀ █▄█▄█▄█ ▀▀▀▀▀▀▀
|
|
96
|
+
|
|
97
|
+
Scan with your phone, or open auth.zapqr.ai/device
|
|
98
|
+
and enter the code WXTM-9K4P
|
|
99
|
+
|
|
100
|
+
Only approve this if you started it. Nobody legitimate will
|
|
101
|
+
ever send you a code to enter here.
|
|
102
|
+
|
|
103
|
+
waiting for approval… 04:31 left
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
That last warning is not decoration. A code someone else sends you, with a
|
|
107
|
+
plausible reason to type it in, is exactly how this flow gets stolen — the
|
|
108
|
+
same attack the device-link ceremony's copy defends against.
|
|
109
|
+
|
|
110
|
+
## Exit codes
|
|
111
|
+
|
|
112
|
+
`0` success · `1` failure or declined · `2` the code expired (retry)
|
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zapqr/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Sign in with ZapQR from a terminal — OAuth 2.0 Device Authorization Grant (RFC 8628)",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"zapqr": "src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"README.md"
|
|
12
|
+
],
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=20"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "node --test test/*.test.js",
|
|
18
|
+
"prepublishOnly": "node --test test/*.test.js"
|
|
19
|
+
},
|
|
20
|
+
"optionalDependencies": {
|
|
21
|
+
"qrcode": "^1.5.4"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"zapqr",
|
|
25
|
+
"oidc",
|
|
26
|
+
"oauth",
|
|
27
|
+
"device-flow",
|
|
28
|
+
"rfc8628",
|
|
29
|
+
"passwordless",
|
|
30
|
+
"cli"
|
|
31
|
+
],
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/dasecure/zapqr-auth-idp.git",
|
|
39
|
+
"directory": "cli"
|
|
40
|
+
},
|
|
41
|
+
"homepage": "https://zapqr.ai",
|
|
42
|
+
"bugs": {
|
|
43
|
+
"url": "https://github.com/dasecure/zapqr-auth-idp/issues"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sign in with ZapQR from a terminal.
|
|
3
|
+
//
|
|
4
|
+
// zapqr login approve on your phone, tokens land here
|
|
5
|
+
// zapqr whoami who this machine is signed in as
|
|
6
|
+
// zapqr token print a fresh access token (for scripts)
|
|
7
|
+
// zapqr logout revoke at the provider, then forget locally
|
|
8
|
+
//
|
|
9
|
+
// The point of the device grant here: the machine running this command may be
|
|
10
|
+
// a box you SSH'd into, with no browser and no business holding your passkey.
|
|
11
|
+
// It shows a code; the phone in your pocket does the authenticating.
|
|
12
|
+
|
|
13
|
+
import { discover, pollForToken, refresh, requestDeviceCode, revoke, userinfo } from './oauth.js';
|
|
14
|
+
import { clear, load, save, storePath } from './store.js';
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = {
|
|
17
|
+
issuer: process.env.ZAPQR_ISSUER || 'https://auth.zapqr.ai',
|
|
18
|
+
clientId: process.env.ZAPQR_CLIENT_ID || 'zq-cli',
|
|
19
|
+
scope: process.env.ZAPQR_SCOPE || 'openid email offline_access',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const isTTY = process.stderr.isTTY;
|
|
23
|
+
const bold = (s) => (isTTY ? `\x1b[1m${s}\x1b[0m` : s);
|
|
24
|
+
const dim = (s) => (isTTY ? `\x1b[2m${s}\x1b[0m` : s);
|
|
25
|
+
const red = (s) => (isTTY ? `\x1b[31m${s}\x1b[0m` : s);
|
|
26
|
+
const green = (s) => (isTTY ? `\x1b[32m${s}\x1b[0m` : s);
|
|
27
|
+
|
|
28
|
+
/** Human output goes to stderr; only machine-readable values go to stdout, so
|
|
29
|
+
* `zapqr token` stays pipeable. */
|
|
30
|
+
const say = (...a) => console.error(...a);
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const flags = {};
|
|
34
|
+
const rest = [];
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const a = argv[i];
|
|
37
|
+
if (a === '--no-qr') flags.noQr = true;
|
|
38
|
+
else if (a === '--json') flags.json = true;
|
|
39
|
+
else if (a === '--issuer') flags.issuer = argv[++i];
|
|
40
|
+
else if (a === '--client-id') flags.clientId = argv[++i];
|
|
41
|
+
else if (a === '--scope') flags.scope = argv[++i];
|
|
42
|
+
else if (a === '-h' || a === '--help') flags.help = true;
|
|
43
|
+
else rest.push(a);
|
|
44
|
+
}
|
|
45
|
+
return { flags, rest };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function config(flags) {
|
|
49
|
+
return {
|
|
50
|
+
issuer: (flags.issuer || DEFAULTS.issuer).replace(/\/+$/, ''),
|
|
51
|
+
clientId: flags.clientId || DEFAULTS.clientId,
|
|
52
|
+
scope: flags.scope || DEFAULTS.scope,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The QR is what makes this feel like ZapQR rather than code-typing: scan the
|
|
57
|
+
* terminal, approve, done. It is optional on purpose — the CLI has no required
|
|
58
|
+
* dependencies, so a bare `node src/index.js` still signs you in. */
|
|
59
|
+
async function renderQR(url) {
|
|
60
|
+
try {
|
|
61
|
+
const { default: QRCode } = await import('qrcode');
|
|
62
|
+
return await QRCode.toString(url, { type: 'terminal', small: true, errorCorrectionLevel: 'M' });
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function cmdLogin(flags) {
|
|
69
|
+
const { issuer, clientId, scope } = config(flags);
|
|
70
|
+
const doc = await discover(issuer);
|
|
71
|
+
const auth = await requestDeviceCode({ doc, clientId, scope });
|
|
72
|
+
|
|
73
|
+
const verifyFull = auth.verification_uri_complete || auth.verification_uri;
|
|
74
|
+
const qr = flags.noQr ? null : await renderQR(verifyFull);
|
|
75
|
+
|
|
76
|
+
say('');
|
|
77
|
+
if (qr) {
|
|
78
|
+
say(qr);
|
|
79
|
+
say(` ${dim('Scan with your phone, or open')} ${bold(auth.verification_uri)}`);
|
|
80
|
+
} else {
|
|
81
|
+
say(` ${dim('On your phone, open')} ${bold(auth.verification_uri)}`);
|
|
82
|
+
}
|
|
83
|
+
say(` ${dim('and enter the code')} ${bold(auth.user_code)}`);
|
|
84
|
+
say('');
|
|
85
|
+
say(` ${dim('Only approve this if you started it. Nobody legitimate will')}`);
|
|
86
|
+
say(` ${dim('ever send you a code to enter here.')}`);
|
|
87
|
+
say('');
|
|
88
|
+
|
|
89
|
+
const result = await pollForToken({
|
|
90
|
+
doc,
|
|
91
|
+
clientId,
|
|
92
|
+
deviceCode: auth.device_code,
|
|
93
|
+
interval: auth.interval,
|
|
94
|
+
expiresIn: auth.expires_in,
|
|
95
|
+
onTick: (left) => {
|
|
96
|
+
if (!isTTY) return;
|
|
97
|
+
const m = String(Math.floor(left / 60)).padStart(2, '0');
|
|
98
|
+
const s = String(left % 60).padStart(2, '0');
|
|
99
|
+
process.stderr.write(`\r waiting for approval… ${m}:${s} left `);
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
if (isTTY) process.stderr.write('\r' + ' '.repeat(40) + '\r');
|
|
103
|
+
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
const msg = {
|
|
106
|
+
declined: 'Declined on the phone.',
|
|
107
|
+
expired: 'The code expired. Run `zapqr login` again.',
|
|
108
|
+
}[result.reason] || `Sign-in failed: ${result.reason}`;
|
|
109
|
+
say(red(` ${msg}`));
|
|
110
|
+
return result.reason === 'expired' ? 2 : 1;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const tokens = result.tokens;
|
|
114
|
+
let email = null;
|
|
115
|
+
try {
|
|
116
|
+
email = (await userinfo({ doc, accessToken: tokens.access_token })).email ?? null;
|
|
117
|
+
} catch {
|
|
118
|
+
// A userinfo hiccup does not undo a successful sign-in.
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
save(issuer, {
|
|
122
|
+
client_id: clientId,
|
|
123
|
+
scope,
|
|
124
|
+
email,
|
|
125
|
+
access_token: tokens.access_token,
|
|
126
|
+
refresh_token: tokens.refresh_token ?? null,
|
|
127
|
+
id_token: tokens.id_token ?? null,
|
|
128
|
+
// 30s of slack so a token that dies mid-request is refreshed beforehand.
|
|
129
|
+
expires_at: Date.now() + ((tokens.expires_in ?? 3600) - 30) * 1000,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
say(green(` ✓ Signed in${email ? ` as ${bold(email)}` : ''}`));
|
|
133
|
+
say(dim(` Tokens stored in ${storePath()} (0600)`));
|
|
134
|
+
if (!tokens.refresh_token) {
|
|
135
|
+
say(dim(' No refresh token — add the offline_access scope to stay signed in.'));
|
|
136
|
+
}
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Anything needing a live token comes through here, so refresh-on-expiry is
|
|
141
|
+
* in exactly one place. rotateRefreshToken is on at the provider: the refresh
|
|
142
|
+
* token that comes back replaces the one we sent, and losing it means the
|
|
143
|
+
* session is stranded. */
|
|
144
|
+
async function freshAccessToken(issuer, flags) {
|
|
145
|
+
const rec = load(issuer);
|
|
146
|
+
if (!rec) return null;
|
|
147
|
+
if (Date.now() < rec.expires_at) return rec;
|
|
148
|
+
if (!rec.refresh_token) return null;
|
|
149
|
+
|
|
150
|
+
const doc = await discover(issuer);
|
|
151
|
+
const tokens = await refresh({ doc, clientId: rec.client_id, refreshToken: rec.refresh_token });
|
|
152
|
+
const updated = {
|
|
153
|
+
...rec,
|
|
154
|
+
access_token: tokens.access_token,
|
|
155
|
+
refresh_token: tokens.refresh_token ?? rec.refresh_token,
|
|
156
|
+
id_token: tokens.id_token ?? rec.id_token,
|
|
157
|
+
expires_at: Date.now() + ((tokens.expires_in ?? 3600) - 30) * 1000,
|
|
158
|
+
};
|
|
159
|
+
save(issuer, updated);
|
|
160
|
+
return updated;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function cmdWhoami(flags) {
|
|
164
|
+
const { issuer } = config(flags);
|
|
165
|
+
let rec;
|
|
166
|
+
try {
|
|
167
|
+
rec = await freshAccessToken(issuer, flags);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
say(red(` Session could not be refreshed: ${err.message}`));
|
|
170
|
+
say(dim(' Run `zapqr login` again.'));
|
|
171
|
+
return 1;
|
|
172
|
+
}
|
|
173
|
+
if (!rec) {
|
|
174
|
+
say(dim(` Not signed in to ${issuer}. Run \`zapqr login\`.`));
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const doc = await discover(issuer);
|
|
179
|
+
let info = { email: rec.email };
|
|
180
|
+
try {
|
|
181
|
+
info = await userinfo({ doc, accessToken: rec.access_token });
|
|
182
|
+
} catch {
|
|
183
|
+
// Fall back to what we stored at login rather than failing the command.
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (flags.json) {
|
|
187
|
+
console.log(JSON.stringify({ issuer, client_id: rec.client_id, ...info }, null, 2));
|
|
188
|
+
} else {
|
|
189
|
+
say(` ${bold(info.email ?? '(unknown)')}`);
|
|
190
|
+
say(dim(` ${issuer} · client ${rec.client_id}${info.sub ? ` · sub ${info.sub}` : ''}`));
|
|
191
|
+
}
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async function cmdToken(flags) {
|
|
196
|
+
const { issuer } = config(flags);
|
|
197
|
+
const rec = await freshAccessToken(issuer, flags);
|
|
198
|
+
if (!rec) {
|
|
199
|
+
say(dim(' Not signed in. Run `zapqr login`.'));
|
|
200
|
+
return 1;
|
|
201
|
+
}
|
|
202
|
+
console.log(rec.access_token); // stdout: the whole point is `$(zapqr token)`
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function cmdLogout(flags) {
|
|
207
|
+
const { issuer } = config(flags);
|
|
208
|
+
const rec = load(issuer);
|
|
209
|
+
if (!rec) {
|
|
210
|
+
say(dim(' Not signed in.'));
|
|
211
|
+
return 0;
|
|
212
|
+
}
|
|
213
|
+
try {
|
|
214
|
+
const doc = await discover(issuer);
|
|
215
|
+
// Revoking the refresh token is what actually ends the grant; the access
|
|
216
|
+
// token expires on its own within the hour either way.
|
|
217
|
+
if (rec.refresh_token) {
|
|
218
|
+
await revoke({ doc, clientId: rec.client_id, token: rec.refresh_token, hint: 'refresh_token' });
|
|
219
|
+
}
|
|
220
|
+
await revoke({ doc, clientId: rec.client_id, token: rec.access_token, hint: 'access_token' });
|
|
221
|
+
} catch {
|
|
222
|
+
// Offline, or the provider is down: still forget the local copy.
|
|
223
|
+
}
|
|
224
|
+
clear(issuer);
|
|
225
|
+
say(green(' ✓ Signed out'));
|
|
226
|
+
return 0;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function usage() {
|
|
230
|
+
say(`${bold('zapqr')} — sign in with ZapQR from a terminal
|
|
231
|
+
|
|
232
|
+
${bold('zapqr login')} approve on your phone, tokens land here
|
|
233
|
+
${bold('zapqr whoami')} who this machine is signed in as ${dim('[--json]')}
|
|
234
|
+
${bold('zapqr token')} print a fresh access token, for scripts
|
|
235
|
+
${bold('zapqr logout')} revoke at the provider, then forget locally
|
|
236
|
+
|
|
237
|
+
${dim('Options')}
|
|
238
|
+
--issuer <url> default ${DEFAULTS.issuer} ${dim('(ZAPQR_ISSUER)')}
|
|
239
|
+
--client-id <id> default ${DEFAULTS.clientId} ${dim('(ZAPQR_CLIENT_ID)')}
|
|
240
|
+
--scope <scope> default "${DEFAULTS.scope}" ${dim('(ZAPQR_SCOPE)')}
|
|
241
|
+
--no-qr show the code without the QR
|
|
242
|
+
`);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const COMMANDS = { login: cmdLogin, whoami: cmdWhoami, token: cmdToken, logout: cmdLogout };
|
|
246
|
+
|
|
247
|
+
const { flags, rest } = parseArgs(process.argv.slice(2));
|
|
248
|
+
const name = rest[0];
|
|
249
|
+
|
|
250
|
+
if (flags.help || !name) {
|
|
251
|
+
usage();
|
|
252
|
+
// Asking for help is a success; being given no command at all is not.
|
|
253
|
+
process.exit(flags.help ? 0 : 1);
|
|
254
|
+
}
|
|
255
|
+
const run = COMMANDS[name];
|
|
256
|
+
if (!run) {
|
|
257
|
+
say(red(` Unknown command "${name}"`));
|
|
258
|
+
usage();
|
|
259
|
+
process.exit(1);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
try {
|
|
263
|
+
process.exit(await run(flags));
|
|
264
|
+
} catch (err) {
|
|
265
|
+
say(red(` ${err.message}`));
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
package/src/oauth.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// The protocol half of the CLI: RFC 8628 device grant against auth.zapqr.ai.
|
|
2
|
+
//
|
|
3
|
+
// Three moves, and none of them involve a browser on this machine: ask the
|
|
4
|
+
// provider for a code, show it to the human, poll until they approve on their
|
|
5
|
+
// phone. The terminal never sees a credential — only a code that is worthless
|
|
6
|
+
// without the person holding the phone.
|
|
7
|
+
|
|
8
|
+
/** Endpoints move. Discovery is the only URL this file is allowed to know. */
|
|
9
|
+
export async function discover(issuer) {
|
|
10
|
+
const url = issuer.replace(/\/+$/, '') + '/.well-known/openid-configuration';
|
|
11
|
+
const res = await fetch(url, { headers: { accept: 'application/json' } });
|
|
12
|
+
if (!res.ok) throw new Error(`discovery failed: ${res.status} ${url}`);
|
|
13
|
+
const doc = await res.json();
|
|
14
|
+
if (!doc.device_authorization_endpoint) {
|
|
15
|
+
throw new Error(`${issuer} does not advertise a device_authorization_endpoint`);
|
|
16
|
+
}
|
|
17
|
+
return doc;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OAuth returns its errors as JSON with a 4xx, so a non-2xx is data, not an
|
|
22
|
+
* exception — the poll loop below depends on reading the error code out of it.
|
|
23
|
+
*/
|
|
24
|
+
async function postForm(url, fields) {
|
|
25
|
+
const res = await fetch(url, {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
29
|
+
accept: 'application/json',
|
|
30
|
+
},
|
|
31
|
+
body: new URLSearchParams(fields).toString(),
|
|
32
|
+
});
|
|
33
|
+
let body;
|
|
34
|
+
try {
|
|
35
|
+
body = await res.json();
|
|
36
|
+
} catch {
|
|
37
|
+
body = { error: `http_${res.status}` };
|
|
38
|
+
}
|
|
39
|
+
return { status: res.status, body };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function requestDeviceCode({ doc, clientId, scope }) {
|
|
43
|
+
const { status, body } = await postForm(doc.device_authorization_endpoint, {
|
|
44
|
+
client_id: clientId,
|
|
45
|
+
scope,
|
|
46
|
+
});
|
|
47
|
+
if (status >= 400) {
|
|
48
|
+
const hint = body.error === 'unauthorized_client'
|
|
49
|
+
? `\n "${clientId}" is not registered for the device grant. Register it at ` +
|
|
50
|
+
`${doc.issuer}/account as a client of type "device", or set ZAPQR_CLIENT_ID.`
|
|
51
|
+
: '';
|
|
52
|
+
throw new Error(`${body.error ?? status}${body.error_description ? `: ${body.error_description}` : ''}${hint}`);
|
|
53
|
+
}
|
|
54
|
+
return body;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Poll until the human decides. Two rules that are easy to get wrong:
|
|
59
|
+
* - the interval belongs to the SERVER. `slow_down` means back off and stay
|
|
60
|
+
* backed off; a client that ignores it gets throttled or blocked.
|
|
61
|
+
* - `expired_token` must leave the loop by returning, not by breaking out
|
|
62
|
+
* into the success path with no tokens in hand.
|
|
63
|
+
* onTick reports remaining seconds so the caller can redraw a countdown.
|
|
64
|
+
* `sleep` and `now` are injectable so the back-off can be asserted in a test
|
|
65
|
+
* without the test actually sitting there for five seconds.
|
|
66
|
+
*/
|
|
67
|
+
export async function pollForToken({
|
|
68
|
+
doc, clientId, deviceCode, interval, expiresIn, onTick,
|
|
69
|
+
sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
70
|
+
now = () => Date.now(),
|
|
71
|
+
}) {
|
|
72
|
+
let wait = (interval ?? 5) * 1000;
|
|
73
|
+
const deadline = now() + (expiresIn ?? 300) * 1000;
|
|
74
|
+
|
|
75
|
+
while (now() < deadline) {
|
|
76
|
+
await sleep(wait);
|
|
77
|
+
const { status, body } = await postForm(doc.token_endpoint, {
|
|
78
|
+
client_id: clientId,
|
|
79
|
+
device_code: deviceCode,
|
|
80
|
+
grant_type: 'urn:ietf:params:oauth:grant-type:device_code',
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (status === 200) return { ok: true, tokens: body };
|
|
84
|
+
|
|
85
|
+
switch (body.error) {
|
|
86
|
+
case 'authorization_pending':
|
|
87
|
+
onTick?.(Math.max(0, Math.round((deadline - now()) / 1000)));
|
|
88
|
+
continue;
|
|
89
|
+
case 'slow_down':
|
|
90
|
+
wait += 5000;
|
|
91
|
+
continue;
|
|
92
|
+
case 'access_denied':
|
|
93
|
+
return { ok: false, reason: 'declined' };
|
|
94
|
+
case 'expired_token':
|
|
95
|
+
return { ok: false, reason: 'expired' };
|
|
96
|
+
default:
|
|
97
|
+
return { ok: false, reason: body.error_description || body.error || `http_${status}` };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return { ok: false, reason: 'expired' };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function refresh({ doc, clientId, refreshToken }) {
|
|
104
|
+
const { status, body } = await postForm(doc.token_endpoint, {
|
|
105
|
+
client_id: clientId,
|
|
106
|
+
grant_type: 'refresh_token',
|
|
107
|
+
refresh_token: refreshToken,
|
|
108
|
+
});
|
|
109
|
+
if (status !== 200) throw new Error(body.error_description || body.error || `refresh failed (${status})`);
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Best-effort: a revoke that fails must not stop us deleting the local copy. */
|
|
114
|
+
export async function revoke({ doc, clientId, token, hint }) {
|
|
115
|
+
if (!doc.revocation_endpoint) return false;
|
|
116
|
+
try {
|
|
117
|
+
const { status } = await postForm(doc.revocation_endpoint, {
|
|
118
|
+
client_id: clientId,
|
|
119
|
+
token,
|
|
120
|
+
token_type_hint: hint,
|
|
121
|
+
});
|
|
122
|
+
return status < 400;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function userinfo({ doc, accessToken }) {
|
|
129
|
+
const res = await fetch(doc.userinfo_endpoint, {
|
|
130
|
+
headers: { authorization: `Bearer ${accessToken}`, accept: 'application/json' },
|
|
131
|
+
});
|
|
132
|
+
if (!res.ok) throw new Error(`userinfo failed: ${res.status}`);
|
|
133
|
+
return res.json();
|
|
134
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Where the tokens live between commands.
|
|
2
|
+
//
|
|
3
|
+
// A 0600 file under $XDG_CONFIG_HOME (or ~/.config), not the OS keychain, and
|
|
4
|
+
// that is a deliberate choice rather than a shortcut. The obvious keychain
|
|
5
|
+
// route on macOS is `security add-generic-password -w <secret>`, which puts the
|
|
6
|
+
// refresh token in argv — readable by any process that can run `ps` for as long
|
|
7
|
+
// as the call lasts. Trading a file only this user can read for a token that
|
|
8
|
+
// briefly leaks to every process on the box is not an upgrade. A real keychain
|
|
9
|
+
// integration wants a native binding, which is a dependency this CLI does not
|
|
10
|
+
// currently pay for.
|
|
11
|
+
|
|
12
|
+
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
|
|
16
|
+
function configPath() {
|
|
17
|
+
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
18
|
+
return join(base, 'zapqr', 'credentials.json');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Keyed by issuer, so a staging provider cannot quietly overwrite production. */
|
|
22
|
+
export function load(issuer) {
|
|
23
|
+
try {
|
|
24
|
+
const all = JSON.parse(readFileSync(configPath(), 'utf8'));
|
|
25
|
+
return all[issuer] ?? null;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function save(issuer, record) {
|
|
32
|
+
const path = configPath();
|
|
33
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
34
|
+
|
|
35
|
+
let all = {};
|
|
36
|
+
try {
|
|
37
|
+
all = JSON.parse(readFileSync(path, 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
// First write, or a file we cannot parse: start clean rather than refuse.
|
|
40
|
+
}
|
|
41
|
+
all[issuer] = record;
|
|
42
|
+
|
|
43
|
+
// Create with 0600 from the outset — writing then chmod'ing leaves a window
|
|
44
|
+
// where another user on the machine can open the file.
|
|
45
|
+
writeFileSync(path, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
46
|
+
chmodSync(path, 0o600); // pre-existing file: writeFileSync's mode is ignored
|
|
47
|
+
return path;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function clear(issuer) {
|
|
51
|
+
const path = configPath();
|
|
52
|
+
try {
|
|
53
|
+
const all = JSON.parse(readFileSync(path, 'utf8'));
|
|
54
|
+
delete all[issuer];
|
|
55
|
+
if (Object.keys(all).length === 0) rmSync(path, { force: true });
|
|
56
|
+
else writeFileSync(path, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function storePath() {
|
|
64
|
+
return configPath();
|
|
65
|
+
}
|