@aooth/login-client 0.1.8
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/LICENSE +21 -0
- package/README.md +70 -0
- package/dist/index.cjs +203 -0
- package/dist/index.d.cts +80 -0
- package/dist/index.d.mts +80 -0
- package/dist/index.mjs +201 -0
- package/package.json +57 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2023 aoothjs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @aooth/login-client
|
|
2
|
+
|
|
3
|
+
Zero-dependency helper that logs a user in through their **browser** and hands a
|
|
4
|
+
token back to a local process — the `gh auth login` pattern, for any
|
|
5
|
+
[aoothjs](https://github.com/moostjs/aoothjs) authorization server.
|
|
6
|
+
|
|
7
|
+
It runs the authorization-code + PKCE flow against a one-shot **loopback**
|
|
8
|
+
redirect (`http://127.0.0.1:<ephemeral-port>/callback`), so nothing is ever
|
|
9
|
+
copy-pasted. Built on Node built-ins (`node:http`, `node:crypto`,
|
|
10
|
+
`node:child_process`) and global `fetch` only — **no runtime dependencies**.
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm i @aooth/login-client # Node >= 18
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { authorize } from "@aooth/login-client";
|
|
22
|
+
|
|
23
|
+
const { accessToken, expiresIn, userId } = await authorize({
|
|
24
|
+
authorizeUrl: "https://main.example.com/auth/authorize",
|
|
25
|
+
tokenUrl: "https://main.example.com/auth/token",
|
|
26
|
+
// clientId, // omit for a public/loopback client (PKCE is the binding)
|
|
27
|
+
// scope: ["api"],
|
|
28
|
+
// statusUrl: "https://main.example.com/auth/status", // optional: confirm the token works
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// send it: Authorization: Bearer <accessToken>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
What it does: opens a one-shot `127.0.0.1` listener, generates `state` + PKCE,
|
|
35
|
+
opens the browser to `authorizeUrl`, awaits the single callback, **verifies
|
|
36
|
+
`state`** (the CSRF check), then `POST tokenUrl { code, code_verifier }` and
|
|
37
|
+
returns the token. It does **not** crypto-validate the token — that is the
|
|
38
|
+
server's opaque credential; TLS + PKCE + `state` are the binding.
|
|
39
|
+
|
|
40
|
+
### Headless / SSH
|
|
41
|
+
|
|
42
|
+
```ts
|
|
43
|
+
await authorize({
|
|
44
|
+
authorizeUrl,
|
|
45
|
+
tokenUrl,
|
|
46
|
+
openBrowser: false,
|
|
47
|
+
onUrl: (url) => console.log(`Open this URL to sign in:\n${url}`),
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The loopback listener still catches the callback once the user opens the URL
|
|
52
|
+
elsewhere on the same machine.
|
|
53
|
+
|
|
54
|
+
## Options
|
|
55
|
+
|
|
56
|
+
| Option | Default | Notes |
|
|
57
|
+
| -------------- | -------- | ------------------------------------------------------------ |
|
|
58
|
+
| `authorizeUrl` | — | the server's `GET /auth/authorize` |
|
|
59
|
+
| `tokenUrl` | — | the server's `POST /auth/token` |
|
|
60
|
+
| `clientId` | — | omit for a public/loopback client |
|
|
61
|
+
| `scope` | — | `string[]`, joined with spaces |
|
|
62
|
+
| `openBrowser` | `true` | set `false` + `onUrl` for headless |
|
|
63
|
+
| `onUrl` | — | always called with the authorize URL (print a fallback line) |
|
|
64
|
+
| `statusUrl` | — | optional bearer-confirm `GET`; adopts its `userId` |
|
|
65
|
+
| `timeoutMs` | `300000` | wait for the browser callback |
|
|
66
|
+
| `signal` | — | `AbortSignal` to cancel (e.g. on SIGINT) |
|
|
67
|
+
|
|
68
|
+
Failures throw an `AuthorizeError` with a `.code`
|
|
69
|
+
(`provider_denied` · `state_mismatch` · `exchange_failed` · `timeout` ·
|
|
70
|
+
`status_check_failed`).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let node_child_process = require("node:child_process");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
let node_http = require("node:http");
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
/** A typed failure of the loopback login flow. */
|
|
7
|
+
var AuthorizeError = class extends Error {
|
|
8
|
+
code;
|
|
9
|
+
constructor(code, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "AuthorizeError";
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
const DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
16
|
+
/** RFC 4648 §5 base64url (no padding) of raw bytes. */
|
|
17
|
+
function base64url(bytes) {
|
|
18
|
+
return bytes.toString("base64url");
|
|
19
|
+
}
|
|
20
|
+
/** A PKCE verifier + its S256 challenge, plus a fresh anti-CSRF `state`. */
|
|
21
|
+
function newPkceAndState() {
|
|
22
|
+
const verifier = base64url((0, node_crypto.randomBytes)(32));
|
|
23
|
+
return {
|
|
24
|
+
verifier,
|
|
25
|
+
challenge: base64url((0, node_crypto.createHash)("sha256").update(verifier).digest()),
|
|
26
|
+
state: base64url((0, node_crypto.randomBytes)(16))
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function buildAuthorizeUrl(opts, redirectUri, p) {
|
|
30
|
+
const url = new URL(opts.authorizeUrl);
|
|
31
|
+
url.searchParams.set("response_type", "code");
|
|
32
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
33
|
+
url.searchParams.set("state", p.state);
|
|
34
|
+
url.searchParams.set("code_challenge", p.challenge);
|
|
35
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
36
|
+
if (opts.clientId !== void 0) url.searchParams.set("client_id", opts.clientId);
|
|
37
|
+
if (opts.scope && opts.scope.length > 0) url.searchParams.set("scope", opts.scope.join(" "));
|
|
38
|
+
return url.toString();
|
|
39
|
+
}
|
|
40
|
+
/** Spawn the platform browser opener, detached; never throws (best-effort). */
|
|
41
|
+
function openInBrowser(url) {
|
|
42
|
+
try {
|
|
43
|
+
const platform = process.platform;
|
|
44
|
+
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
|
|
45
|
+
"/c",
|
|
46
|
+
"start",
|
|
47
|
+
"",
|
|
48
|
+
url
|
|
49
|
+
]] : ["xdg-open", [url]];
|
|
50
|
+
const child = (0, node_child_process.spawn)(cmd, [...args], {
|
|
51
|
+
stdio: "ignore",
|
|
52
|
+
detached: true
|
|
53
|
+
});
|
|
54
|
+
child.on("error", () => {});
|
|
55
|
+
child.unref();
|
|
56
|
+
} catch {}
|
|
57
|
+
}
|
|
58
|
+
const DONE_HTML = "<!doctype html><meta charset=utf-8><title>Signed in</title><body style=\"font:16px system-ui;margin:3rem;text-align:center\"><h1>✓ Signed in</h1><p>You can close this tab and return to the terminal.</p></body>";
|
|
59
|
+
/**
|
|
60
|
+
* Stand up a one-shot loopback listener, return its `redirect_uri` and a promise
|
|
61
|
+
* that resolves with the `{ code }` from the first `/callback` hit (after the
|
|
62
|
+
* `state` CSRF check) or rejects with an {@link AuthorizeError}.
|
|
63
|
+
*/
|
|
64
|
+
function awaitLoopbackCallback(expectedState, timeoutMs, signal) {
|
|
65
|
+
return new Promise((resolveSetup, rejectSetup) => {
|
|
66
|
+
let settle;
|
|
67
|
+
let fail;
|
|
68
|
+
const ready = new Promise((res, rej) => {
|
|
69
|
+
settle = res;
|
|
70
|
+
fail = rej;
|
|
71
|
+
});
|
|
72
|
+
const server = (0, node_http.createServer)((req, res) => {
|
|
73
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
74
|
+
if (url.pathname !== "/callback") {
|
|
75
|
+
res.writeHead(404).end();
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const error = url.searchParams.get("error");
|
|
79
|
+
const code = url.searchParams.get("code");
|
|
80
|
+
const state = url.searchParams.get("state");
|
|
81
|
+
if (error) {
|
|
82
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
83
|
+
fail?.(new AuthorizeError("provider_denied", `Authorization server returned error=${error}`));
|
|
84
|
+
} else if (state !== expectedState) {
|
|
85
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
86
|
+
fail?.(new AuthorizeError("state_mismatch", "Callback state did not match the request"));
|
|
87
|
+
} else if (!code) {
|
|
88
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
89
|
+
fail?.(new AuthorizeError("exchange_failed", "Callback carried no authorization code"));
|
|
90
|
+
} else {
|
|
91
|
+
res.writeHead(200, { "content-type": "text/html" }).end(DONE_HTML);
|
|
92
|
+
settle?.(code);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
const cleanup = () => {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
signal?.removeEventListener("abort", onAbort);
|
|
98
|
+
server.close();
|
|
99
|
+
};
|
|
100
|
+
const onAbort = () => fail?.(new AuthorizeError("timeout", "Login aborted"));
|
|
101
|
+
const timer = setTimeout(() => fail?.(new AuthorizeError("timeout", `No callback within ${timeoutMs}ms`)), timeoutMs);
|
|
102
|
+
timer.unref?.();
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
ready.then(cleanup, cleanup);
|
|
105
|
+
server.on("error", rejectSetup);
|
|
106
|
+
server.listen(0, "127.0.0.1", () => {
|
|
107
|
+
resolveSetup({
|
|
108
|
+
redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
|
|
109
|
+
ready
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** Back-channel `code` → token exchange (PKCE-verified at the server). */
|
|
115
|
+
async function exchangeCode(opts, code, verifier) {
|
|
116
|
+
const body = new URLSearchParams({
|
|
117
|
+
grant_type: "authorization_code",
|
|
118
|
+
code,
|
|
119
|
+
code_verifier: verifier
|
|
120
|
+
});
|
|
121
|
+
if (opts.clientId !== void 0) body.set("client_id", opts.clientId);
|
|
122
|
+
let resp;
|
|
123
|
+
try {
|
|
124
|
+
resp = await fetch(opts.tokenUrl, {
|
|
125
|
+
method: "POST",
|
|
126
|
+
headers: {
|
|
127
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
128
|
+
accept: "application/json"
|
|
129
|
+
},
|
|
130
|
+
body,
|
|
131
|
+
signal: opts.signal
|
|
132
|
+
});
|
|
133
|
+
} catch (e) {
|
|
134
|
+
throw new AuthorizeError("exchange_failed", `Token endpoint unreachable: ${String(e)}`);
|
|
135
|
+
}
|
|
136
|
+
if (!resp.ok) throw new AuthorizeError("exchange_failed", `Token endpoint returned ${resp.status}`);
|
|
137
|
+
let json;
|
|
138
|
+
try {
|
|
139
|
+
json = await resp.json();
|
|
140
|
+
} catch {
|
|
141
|
+
throw new AuthorizeError("exchange_failed", "Token endpoint returned a non-JSON body");
|
|
142
|
+
}
|
|
143
|
+
if (!json.access_token) throw new AuthorizeError("exchange_failed", "Token endpoint returned no access_token");
|
|
144
|
+
return json;
|
|
145
|
+
}
|
|
146
|
+
/** Optional `GET statusUrl` confirmation; returns the reported `userId` if any. */
|
|
147
|
+
async function confirmStatus(statusUrl, accessToken, signal) {
|
|
148
|
+
let resp;
|
|
149
|
+
try {
|
|
150
|
+
resp = await fetch(statusUrl, {
|
|
151
|
+
headers: {
|
|
152
|
+
authorization: `Bearer ${accessToken}`,
|
|
153
|
+
accept: "application/json"
|
|
154
|
+
},
|
|
155
|
+
signal
|
|
156
|
+
});
|
|
157
|
+
} catch (e) {
|
|
158
|
+
throw new AuthorizeError("status_check_failed", `Status endpoint unreachable: ${String(e)}`);
|
|
159
|
+
}
|
|
160
|
+
if (!resp.ok) throw new AuthorizeError("status_check_failed", `Status endpoint returned ${resp.status}`);
|
|
161
|
+
try {
|
|
162
|
+
return (await resp.json()).userId;
|
|
163
|
+
} catch {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Run the full browser-login round trip and return a token:
|
|
169
|
+
*
|
|
170
|
+
* 1. open a one-shot `127.0.0.1` loopback listener (ephemeral port),
|
|
171
|
+
* 2. generate `state` + PKCE, open the browser to `authorizeUrl?...`,
|
|
172
|
+
* 3. await the single loopback callback and verify `state` (the CSRF check),
|
|
173
|
+
* 4. `POST tokenUrl { code, code_verifier, ... }` and return the token,
|
|
174
|
+
* 5. optionally confirm it against `statusUrl`.
|
|
175
|
+
*
|
|
176
|
+
* The helper does NOT cryptographically validate the token — it is an opaque,
|
|
177
|
+
* server-owned credential; the TLS token endpoint + PKCE + the `state` check are
|
|
178
|
+
* the binding. (Tier-2 `id_token` signature verification is the relying
|
|
179
|
+
* service's job via an OIDC client, not a CLI's.)
|
|
180
|
+
*/
|
|
181
|
+
async function authorize(opts) {
|
|
182
|
+
const pkce = newPkceAndState();
|
|
183
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
184
|
+
const { redirectUri, ready } = await awaitLoopbackCallback(pkce.state, timeoutMs, opts.signal);
|
|
185
|
+
const authUrl = buildAuthorizeUrl(opts, redirectUri, pkce);
|
|
186
|
+
opts.onUrl?.(authUrl);
|
|
187
|
+
if (opts.openBrowser !== false) openInBrowser(authUrl);
|
|
188
|
+
const token = await exchangeCode(opts, await ready, pkce.verifier);
|
|
189
|
+
let userId = token.userId;
|
|
190
|
+
if (opts.statusUrl) {
|
|
191
|
+
const confirmed = await confirmStatus(opts.statusUrl, token.access_token, opts.signal);
|
|
192
|
+
userId = userId ?? confirmed;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
accessToken: token.access_token,
|
|
196
|
+
...token.expires_in !== void 0 && { expiresIn: token.expires_in },
|
|
197
|
+
...token.id_token !== void 0 && { idToken: token.id_token },
|
|
198
|
+
...userId !== void 0 && { userId }
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
//#endregion
|
|
202
|
+
exports.AuthorizeError = AuthorizeError;
|
|
203
|
+
exports.authorize = authorize;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@aooth/login-client` — a zero-dependency helper that obtains a token from an
|
|
4
|
+
* aoothjs authorization server by driving the user through a **browser login**
|
|
5
|
+
* and catching the result on an ephemeral **loopback** redirect (the
|
|
6
|
+
* `gh auth login` pattern). Built on Node built-ins + global `fetch` only, so it
|
|
7
|
+
* drops into any CLI with no transitive deps.
|
|
8
|
+
*
|
|
9
|
+
* It is also usable as a generic "Sign in with <main app>" hatch for a
|
|
10
|
+
* first-party service: only the `redirect_uri` differs (a real loopback URL on a
|
|
11
|
+
* developer machine vs. — in a hosted relying service — that service's own
|
|
12
|
+
* callback). The flow (authorization-code + PKCE + back-channel token exchange)
|
|
13
|
+
* is identical.
|
|
14
|
+
*/
|
|
15
|
+
/** Error codes surfaced by {@link authorize}. */
|
|
16
|
+
type AuthorizeErrorCode = /** The provider returned `?error=` on the callback (e.g. the user declined). */"provider_denied" /** The callback `state` did not match the one we generated (CSRF / instance mismatch). */ | "state_mismatch" /** The `POST /token` exchange failed (non-2xx, network, or malformed body). */ | "exchange_failed" /** No callback arrived before `timeoutMs` (or the `signal` aborted). */ | "timeout" /** The optional `statusUrl` confirmation did not return 200. */ | "status_check_failed";
|
|
17
|
+
/** A typed failure of the loopback login flow. */
|
|
18
|
+
declare class AuthorizeError extends Error {
|
|
19
|
+
readonly code: AuthorizeErrorCode;
|
|
20
|
+
constructor(code: AuthorizeErrorCode, message: string);
|
|
21
|
+
}
|
|
22
|
+
interface AuthorizeOptions {
|
|
23
|
+
/** The authorization server's `GET /auth/authorize` URL. */
|
|
24
|
+
authorizeUrl: string;
|
|
25
|
+
/** The authorization server's `POST /auth/token` URL. */
|
|
26
|
+
tokenUrl: string;
|
|
27
|
+
/** Client id for a registered client; omit for a public/loopback client (PKCE is the binding). */
|
|
28
|
+
clientId?: string;
|
|
29
|
+
/** Requested scopes, joined with spaces into the `scope` param. */
|
|
30
|
+
scope?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Open the system browser to the authorize URL automatically. Default `true`.
|
|
33
|
+
* Set `false` for headless/SSH and use {@link onUrl} to surface the URL.
|
|
34
|
+
*/
|
|
35
|
+
openBrowser?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Called with the full authorize URL before (or instead of) opening a browser.
|
|
38
|
+
* Use it to print the URL for the user to open elsewhere — the loopback
|
|
39
|
+
* listener still catches the callback. Always invoked, even when
|
|
40
|
+
* `openBrowser` is `true`, so a CLI can print a fallback line.
|
|
41
|
+
*/
|
|
42
|
+
onUrl?: (url: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Optional `GET` URL hit with the returned bearer to confirm the token works
|
|
45
|
+
* (e.g. `/auth/status`). On a 200 the helper adopts its `userId` when the
|
|
46
|
+
* token response did not carry one. A non-200 throws `status_check_failed`.
|
|
47
|
+
*/
|
|
48
|
+
statusUrl?: string;
|
|
49
|
+
/** How long to wait for the browser callback before failing. Default 300_000 (5 min). */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
/** Abort the wait early (e.g. on SIGINT). */
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
interface AuthorizeResult {
|
|
55
|
+
/** The bearer access token to send as `Authorization: Bearer <accessToken>`. */
|
|
56
|
+
accessToken: string;
|
|
57
|
+
/** Access-token lifetime in seconds, as reported by the token endpoint. */
|
|
58
|
+
expiresIn?: number;
|
|
59
|
+
/** An OIDC id_token, when the server is acting as a Tier-2 OIDC provider. Opaque here. */
|
|
60
|
+
idToken?: string;
|
|
61
|
+
/** The authenticated user id, from the token response or the `statusUrl` confirmation. */
|
|
62
|
+
userId?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run the full browser-login round trip and return a token:
|
|
66
|
+
*
|
|
67
|
+
* 1. open a one-shot `127.0.0.1` loopback listener (ephemeral port),
|
|
68
|
+
* 2. generate `state` + PKCE, open the browser to `authorizeUrl?...`,
|
|
69
|
+
* 3. await the single loopback callback and verify `state` (the CSRF check),
|
|
70
|
+
* 4. `POST tokenUrl { code, code_verifier, ... }` and return the token,
|
|
71
|
+
* 5. optionally confirm it against `statusUrl`.
|
|
72
|
+
*
|
|
73
|
+
* The helper does NOT cryptographically validate the token — it is an opaque,
|
|
74
|
+
* server-owned credential; the TLS token endpoint + PKCE + the `state` check are
|
|
75
|
+
* the binding. (Tier-2 `id_token` signature verification is the relying
|
|
76
|
+
* service's job via an OIDC client, not a CLI's.)
|
|
77
|
+
*/
|
|
78
|
+
declare function authorize(opts: AuthorizeOptions): Promise<AuthorizeResult>;
|
|
79
|
+
//#endregion
|
|
80
|
+
export { AuthorizeError, AuthorizeErrorCode, AuthorizeOptions, AuthorizeResult, authorize };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
//#region src/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@aooth/login-client` — a zero-dependency helper that obtains a token from an
|
|
4
|
+
* aoothjs authorization server by driving the user through a **browser login**
|
|
5
|
+
* and catching the result on an ephemeral **loopback** redirect (the
|
|
6
|
+
* `gh auth login` pattern). Built on Node built-ins + global `fetch` only, so it
|
|
7
|
+
* drops into any CLI with no transitive deps.
|
|
8
|
+
*
|
|
9
|
+
* It is also usable as a generic "Sign in with <main app>" hatch for a
|
|
10
|
+
* first-party service: only the `redirect_uri` differs (a real loopback URL on a
|
|
11
|
+
* developer machine vs. — in a hosted relying service — that service's own
|
|
12
|
+
* callback). The flow (authorization-code + PKCE + back-channel token exchange)
|
|
13
|
+
* is identical.
|
|
14
|
+
*/
|
|
15
|
+
/** Error codes surfaced by {@link authorize}. */
|
|
16
|
+
type AuthorizeErrorCode = /** The provider returned `?error=` on the callback (e.g. the user declined). */"provider_denied" /** The callback `state` did not match the one we generated (CSRF / instance mismatch). */ | "state_mismatch" /** The `POST /token` exchange failed (non-2xx, network, or malformed body). */ | "exchange_failed" /** No callback arrived before `timeoutMs` (or the `signal` aborted). */ | "timeout" /** The optional `statusUrl` confirmation did not return 200. */ | "status_check_failed";
|
|
17
|
+
/** A typed failure of the loopback login flow. */
|
|
18
|
+
declare class AuthorizeError extends Error {
|
|
19
|
+
readonly code: AuthorizeErrorCode;
|
|
20
|
+
constructor(code: AuthorizeErrorCode, message: string);
|
|
21
|
+
}
|
|
22
|
+
interface AuthorizeOptions {
|
|
23
|
+
/** The authorization server's `GET /auth/authorize` URL. */
|
|
24
|
+
authorizeUrl: string;
|
|
25
|
+
/** The authorization server's `POST /auth/token` URL. */
|
|
26
|
+
tokenUrl: string;
|
|
27
|
+
/** Client id for a registered client; omit for a public/loopback client (PKCE is the binding). */
|
|
28
|
+
clientId?: string;
|
|
29
|
+
/** Requested scopes, joined with spaces into the `scope` param. */
|
|
30
|
+
scope?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Open the system browser to the authorize URL automatically. Default `true`.
|
|
33
|
+
* Set `false` for headless/SSH and use {@link onUrl} to surface the URL.
|
|
34
|
+
*/
|
|
35
|
+
openBrowser?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Called with the full authorize URL before (or instead of) opening a browser.
|
|
38
|
+
* Use it to print the URL for the user to open elsewhere — the loopback
|
|
39
|
+
* listener still catches the callback. Always invoked, even when
|
|
40
|
+
* `openBrowser` is `true`, so a CLI can print a fallback line.
|
|
41
|
+
*/
|
|
42
|
+
onUrl?: (url: string) => void;
|
|
43
|
+
/**
|
|
44
|
+
* Optional `GET` URL hit with the returned bearer to confirm the token works
|
|
45
|
+
* (e.g. `/auth/status`). On a 200 the helper adopts its `userId` when the
|
|
46
|
+
* token response did not carry one. A non-200 throws `status_check_failed`.
|
|
47
|
+
*/
|
|
48
|
+
statusUrl?: string;
|
|
49
|
+
/** How long to wait for the browser callback before failing. Default 300_000 (5 min). */
|
|
50
|
+
timeoutMs?: number;
|
|
51
|
+
/** Abort the wait early (e.g. on SIGINT). */
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
}
|
|
54
|
+
interface AuthorizeResult {
|
|
55
|
+
/** The bearer access token to send as `Authorization: Bearer <accessToken>`. */
|
|
56
|
+
accessToken: string;
|
|
57
|
+
/** Access-token lifetime in seconds, as reported by the token endpoint. */
|
|
58
|
+
expiresIn?: number;
|
|
59
|
+
/** An OIDC id_token, when the server is acting as a Tier-2 OIDC provider. Opaque here. */
|
|
60
|
+
idToken?: string;
|
|
61
|
+
/** The authenticated user id, from the token response or the `statusUrl` confirmation. */
|
|
62
|
+
userId?: string;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Run the full browser-login round trip and return a token:
|
|
66
|
+
*
|
|
67
|
+
* 1. open a one-shot `127.0.0.1` loopback listener (ephemeral port),
|
|
68
|
+
* 2. generate `state` + PKCE, open the browser to `authorizeUrl?...`,
|
|
69
|
+
* 3. await the single loopback callback and verify `state` (the CSRF check),
|
|
70
|
+
* 4. `POST tokenUrl { code, code_verifier, ... }` and return the token,
|
|
71
|
+
* 5. optionally confirm it against `statusUrl`.
|
|
72
|
+
*
|
|
73
|
+
* The helper does NOT cryptographically validate the token — it is an opaque,
|
|
74
|
+
* server-owned credential; the TLS token endpoint + PKCE + the `state` check are
|
|
75
|
+
* the binding. (Tier-2 `id_token` signature verification is the relying
|
|
76
|
+
* service's job via an OIDC client, not a CLI's.)
|
|
77
|
+
*/
|
|
78
|
+
declare function authorize(opts: AuthorizeOptions): Promise<AuthorizeResult>;
|
|
79
|
+
//#endregion
|
|
80
|
+
export { AuthorizeError, AuthorizeErrorCode, AuthorizeOptions, AuthorizeResult, authorize };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
//#region src/index.ts
|
|
5
|
+
/** A typed failure of the loopback login flow. */
|
|
6
|
+
var AuthorizeError = class extends Error {
|
|
7
|
+
code;
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.name = "AuthorizeError";
|
|
11
|
+
this.code = code;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 5 * 6e4;
|
|
15
|
+
/** RFC 4648 §5 base64url (no padding) of raw bytes. */
|
|
16
|
+
function base64url(bytes) {
|
|
17
|
+
return bytes.toString("base64url");
|
|
18
|
+
}
|
|
19
|
+
/** A PKCE verifier + its S256 challenge, plus a fresh anti-CSRF `state`. */
|
|
20
|
+
function newPkceAndState() {
|
|
21
|
+
const verifier = base64url(randomBytes(32));
|
|
22
|
+
return {
|
|
23
|
+
verifier,
|
|
24
|
+
challenge: base64url(createHash("sha256").update(verifier).digest()),
|
|
25
|
+
state: base64url(randomBytes(16))
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function buildAuthorizeUrl(opts, redirectUri, p) {
|
|
29
|
+
const url = new URL(opts.authorizeUrl);
|
|
30
|
+
url.searchParams.set("response_type", "code");
|
|
31
|
+
url.searchParams.set("redirect_uri", redirectUri);
|
|
32
|
+
url.searchParams.set("state", p.state);
|
|
33
|
+
url.searchParams.set("code_challenge", p.challenge);
|
|
34
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
35
|
+
if (opts.clientId !== void 0) url.searchParams.set("client_id", opts.clientId);
|
|
36
|
+
if (opts.scope && opts.scope.length > 0) url.searchParams.set("scope", opts.scope.join(" "));
|
|
37
|
+
return url.toString();
|
|
38
|
+
}
|
|
39
|
+
/** Spawn the platform browser opener, detached; never throws (best-effort). */
|
|
40
|
+
function openInBrowser(url) {
|
|
41
|
+
try {
|
|
42
|
+
const platform = process.platform;
|
|
43
|
+
const [cmd, args] = platform === "darwin" ? ["open", [url]] : platform === "win32" ? ["cmd", [
|
|
44
|
+
"/c",
|
|
45
|
+
"start",
|
|
46
|
+
"",
|
|
47
|
+
url
|
|
48
|
+
]] : ["xdg-open", [url]];
|
|
49
|
+
const child = spawn(cmd, [...args], {
|
|
50
|
+
stdio: "ignore",
|
|
51
|
+
detached: true
|
|
52
|
+
});
|
|
53
|
+
child.on("error", () => {});
|
|
54
|
+
child.unref();
|
|
55
|
+
} catch {}
|
|
56
|
+
}
|
|
57
|
+
const DONE_HTML = "<!doctype html><meta charset=utf-8><title>Signed in</title><body style=\"font:16px system-ui;margin:3rem;text-align:center\"><h1>✓ Signed in</h1><p>You can close this tab and return to the terminal.</p></body>";
|
|
58
|
+
/**
|
|
59
|
+
* Stand up a one-shot loopback listener, return its `redirect_uri` and a promise
|
|
60
|
+
* that resolves with the `{ code }` from the first `/callback` hit (after the
|
|
61
|
+
* `state` CSRF check) or rejects with an {@link AuthorizeError}.
|
|
62
|
+
*/
|
|
63
|
+
function awaitLoopbackCallback(expectedState, timeoutMs, signal) {
|
|
64
|
+
return new Promise((resolveSetup, rejectSetup) => {
|
|
65
|
+
let settle;
|
|
66
|
+
let fail;
|
|
67
|
+
const ready = new Promise((res, rej) => {
|
|
68
|
+
settle = res;
|
|
69
|
+
fail = rej;
|
|
70
|
+
});
|
|
71
|
+
const server = createServer((req, res) => {
|
|
72
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
73
|
+
if (url.pathname !== "/callback") {
|
|
74
|
+
res.writeHead(404).end();
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const error = url.searchParams.get("error");
|
|
78
|
+
const code = url.searchParams.get("code");
|
|
79
|
+
const state = url.searchParams.get("state");
|
|
80
|
+
if (error) {
|
|
81
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
82
|
+
fail?.(new AuthorizeError("provider_denied", `Authorization server returned error=${error}`));
|
|
83
|
+
} else if (state !== expectedState) {
|
|
84
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
85
|
+
fail?.(new AuthorizeError("state_mismatch", "Callback state did not match the request"));
|
|
86
|
+
} else if (!code) {
|
|
87
|
+
res.writeHead(400, { "content-type": "text/plain" }).end("Sign-in failed.");
|
|
88
|
+
fail?.(new AuthorizeError("exchange_failed", "Callback carried no authorization code"));
|
|
89
|
+
} else {
|
|
90
|
+
res.writeHead(200, { "content-type": "text/html" }).end(DONE_HTML);
|
|
91
|
+
settle?.(code);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
const cleanup = () => {
|
|
95
|
+
clearTimeout(timer);
|
|
96
|
+
signal?.removeEventListener("abort", onAbort);
|
|
97
|
+
server.close();
|
|
98
|
+
};
|
|
99
|
+
const onAbort = () => fail?.(new AuthorizeError("timeout", "Login aborted"));
|
|
100
|
+
const timer = setTimeout(() => fail?.(new AuthorizeError("timeout", `No callback within ${timeoutMs}ms`)), timeoutMs);
|
|
101
|
+
timer.unref?.();
|
|
102
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
103
|
+
ready.then(cleanup, cleanup);
|
|
104
|
+
server.on("error", rejectSetup);
|
|
105
|
+
server.listen(0, "127.0.0.1", () => {
|
|
106
|
+
resolveSetup({
|
|
107
|
+
redirectUri: `http://127.0.0.1:${server.address().port}/callback`,
|
|
108
|
+
ready
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
/** Back-channel `code` → token exchange (PKCE-verified at the server). */
|
|
114
|
+
async function exchangeCode(opts, code, verifier) {
|
|
115
|
+
const body = new URLSearchParams({
|
|
116
|
+
grant_type: "authorization_code",
|
|
117
|
+
code,
|
|
118
|
+
code_verifier: verifier
|
|
119
|
+
});
|
|
120
|
+
if (opts.clientId !== void 0) body.set("client_id", opts.clientId);
|
|
121
|
+
let resp;
|
|
122
|
+
try {
|
|
123
|
+
resp = await fetch(opts.tokenUrl, {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
127
|
+
accept: "application/json"
|
|
128
|
+
},
|
|
129
|
+
body,
|
|
130
|
+
signal: opts.signal
|
|
131
|
+
});
|
|
132
|
+
} catch (e) {
|
|
133
|
+
throw new AuthorizeError("exchange_failed", `Token endpoint unreachable: ${String(e)}`);
|
|
134
|
+
}
|
|
135
|
+
if (!resp.ok) throw new AuthorizeError("exchange_failed", `Token endpoint returned ${resp.status}`);
|
|
136
|
+
let json;
|
|
137
|
+
try {
|
|
138
|
+
json = await resp.json();
|
|
139
|
+
} catch {
|
|
140
|
+
throw new AuthorizeError("exchange_failed", "Token endpoint returned a non-JSON body");
|
|
141
|
+
}
|
|
142
|
+
if (!json.access_token) throw new AuthorizeError("exchange_failed", "Token endpoint returned no access_token");
|
|
143
|
+
return json;
|
|
144
|
+
}
|
|
145
|
+
/** Optional `GET statusUrl` confirmation; returns the reported `userId` if any. */
|
|
146
|
+
async function confirmStatus(statusUrl, accessToken, signal) {
|
|
147
|
+
let resp;
|
|
148
|
+
try {
|
|
149
|
+
resp = await fetch(statusUrl, {
|
|
150
|
+
headers: {
|
|
151
|
+
authorization: `Bearer ${accessToken}`,
|
|
152
|
+
accept: "application/json"
|
|
153
|
+
},
|
|
154
|
+
signal
|
|
155
|
+
});
|
|
156
|
+
} catch (e) {
|
|
157
|
+
throw new AuthorizeError("status_check_failed", `Status endpoint unreachable: ${String(e)}`);
|
|
158
|
+
}
|
|
159
|
+
if (!resp.ok) throw new AuthorizeError("status_check_failed", `Status endpoint returned ${resp.status}`);
|
|
160
|
+
try {
|
|
161
|
+
return (await resp.json()).userId;
|
|
162
|
+
} catch {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Run the full browser-login round trip and return a token:
|
|
168
|
+
*
|
|
169
|
+
* 1. open a one-shot `127.0.0.1` loopback listener (ephemeral port),
|
|
170
|
+
* 2. generate `state` + PKCE, open the browser to `authorizeUrl?...`,
|
|
171
|
+
* 3. await the single loopback callback and verify `state` (the CSRF check),
|
|
172
|
+
* 4. `POST tokenUrl { code, code_verifier, ... }` and return the token,
|
|
173
|
+
* 5. optionally confirm it against `statusUrl`.
|
|
174
|
+
*
|
|
175
|
+
* The helper does NOT cryptographically validate the token — it is an opaque,
|
|
176
|
+
* server-owned credential; the TLS token endpoint + PKCE + the `state` check are
|
|
177
|
+
* the binding. (Tier-2 `id_token` signature verification is the relying
|
|
178
|
+
* service's job via an OIDC client, not a CLI's.)
|
|
179
|
+
*/
|
|
180
|
+
async function authorize(opts) {
|
|
181
|
+
const pkce = newPkceAndState();
|
|
182
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
183
|
+
const { redirectUri, ready } = await awaitLoopbackCallback(pkce.state, timeoutMs, opts.signal);
|
|
184
|
+
const authUrl = buildAuthorizeUrl(opts, redirectUri, pkce);
|
|
185
|
+
opts.onUrl?.(authUrl);
|
|
186
|
+
if (opts.openBrowser !== false) openInBrowser(authUrl);
|
|
187
|
+
const token = await exchangeCode(opts, await ready, pkce.verifier);
|
|
188
|
+
let userId = token.userId;
|
|
189
|
+
if (opts.statusUrl) {
|
|
190
|
+
const confirmed = await confirmStatus(opts.statusUrl, token.access_token, opts.signal);
|
|
191
|
+
userId = userId ?? confirmed;
|
|
192
|
+
}
|
|
193
|
+
return {
|
|
194
|
+
accessToken: token.access_token,
|
|
195
|
+
...token.expires_in !== void 0 && { expiresIn: token.expires_in },
|
|
196
|
+
...token.id_token !== void 0 && { idToken: token.id_token },
|
|
197
|
+
...userId !== void 0 && { userId }
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
//#endregion
|
|
201
|
+
export { AuthorizeError, authorize };
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aooth/login-client",
|
|
3
|
+
"version": "0.1.8",
|
|
4
|
+
"description": "Zero-dependency CLI/loopback login helper for an aoothjs authorization server (browser round-trip + PKCE, returns a token)",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"aoothjs",
|
|
7
|
+
"authorization-code",
|
|
8
|
+
"cli",
|
|
9
|
+
"login",
|
|
10
|
+
"loopback",
|
|
11
|
+
"oauth2",
|
|
12
|
+
"pkce"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/moostjs/aoothjs/tree/main/packages/login-client#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/moostjs/aoothjs/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "Artem Maltsev",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/moostjs/aoothjs.git",
|
|
23
|
+
"directory": "packages/login-client"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"dist"
|
|
27
|
+
],
|
|
28
|
+
"type": "module",
|
|
29
|
+
"sideEffects": false,
|
|
30
|
+
"main": "dist/index.mjs",
|
|
31
|
+
"module": "./dist/index.mjs",
|
|
32
|
+
"types": "dist/index.d.mts",
|
|
33
|
+
"exports": {
|
|
34
|
+
".": {
|
|
35
|
+
"types": "./dist/index.d.mts",
|
|
36
|
+
"import": "./dist/index.mjs",
|
|
37
|
+
"require": "./dist/index.cjs"
|
|
38
|
+
},
|
|
39
|
+
"./package.json": "./package.json"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"vitest": "npm:@voidzero-dev/vite-plus-test@latest"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=18"
|
|
50
|
+
},
|
|
51
|
+
"scripts": {
|
|
52
|
+
"build": "vp pack",
|
|
53
|
+
"dev": "vp pack --watch",
|
|
54
|
+
"test": "vp test",
|
|
55
|
+
"check": "vp check"
|
|
56
|
+
}
|
|
57
|
+
}
|