@infrarates/cli 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +248 -2
- package/browser-login.d.mts +14 -0
- package/browser-login.mjs +244 -0
- package/commands.mjs +183 -0
- package/format-history.mjs +162 -0
- package/oimo.d.mts +15 -0
- package/oimo.mjs +506 -0
- package/package.json +33 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Oimo Inc.
|
|
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
CHANGED
|
@@ -1,3 +1,249 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Oimo CLI
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Requires Node.js 22.11 or newer, with no runtime dependencies. The npm
|
|
4
|
+
package is `@infrarates/cli` and the command is `oimo`. MIT licence.
|
|
5
|
+
|
|
6
|
+
Install it from public npm (see [Releases](#releases)):
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -g @infrarates/cli # install
|
|
10
|
+
npm install -g @infrarates/cli@latest # update to the newest release
|
|
11
|
+
oimo --version
|
|
12
|
+
npm uninstall -g @infrarates/cli # remove
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
From a checkout, `npm install -g .` in this directory or `node oimo.mjs`
|
|
16
|
+
runs the unreleased code. Run `npm test` for its tests.
|
|
17
|
+
|
|
18
|
+
## Read data with an organization API key
|
|
19
|
+
|
|
20
|
+
Create a key on the Oimo **API keys** page. The server accepts keys for
|
|
21
|
+
reviewed price history, customer price/terms/status reads, providers, catalog,
|
|
22
|
+
activity and comparisons. These additional key routes require deploying the
|
|
23
|
+
accompanying providers-tracker-ui changes.
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
export OIMO_API_KEY='your-organization-api-key'
|
|
27
|
+
oimo providers list
|
|
28
|
+
oimo prices latest --provider openai --limit 100
|
|
29
|
+
oimo prices history --providers openai,anthropic --all > prices.json
|
|
30
|
+
oimo terms latest --provider openai
|
|
31
|
+
oimo terms diff --provider openai --doc terms-of-use
|
|
32
|
+
oimo status
|
|
33
|
+
oimo catalog --provider openai
|
|
34
|
+
oimo compare resources
|
|
35
|
+
oimo compare prices --resource 'resource-id-from-compare-resources'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Keys always get the customer view, even if a staff session also exists. Pending
|
|
39
|
+
rows are hidden and status responses omit internal details. Keys carry an
|
|
40
|
+
organization identity and no user role; management and personal commands require
|
|
41
|
+
a user session.
|
|
42
|
+
|
|
43
|
+
`OIMO_API_KEY` is not tied to an origin. A command that accepts a key
|
|
44
|
+
sends it, as a Bearer header, to the origin that command targets:
|
|
45
|
+
`https://oimo.com` by default, otherwise the one named by `--base-url` or
|
|
46
|
+
`OIMO_BASE_URL`. This differs from saved sessions, which are stored per
|
|
47
|
+
origin and never sent anywhere else. The origin must be HTTPS (HTTP only on
|
|
48
|
+
loopback) and redirects are refused, so the key reaches the origin you named
|
|
49
|
+
and no other. When a key is set, no session is sent with it; session commands
|
|
50
|
+
never send the key.
|
|
51
|
+
|
|
52
|
+
A key works only in the environment that issued it, so create one per
|
|
53
|
+
environment and set it for the command instead of exporting a production key
|
|
54
|
+
in a shell that also targets other origins:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
OIMO_API_KEY="$OIMO_DEVELOP_KEY" oimo prices latest --base-url https://oimo.dev
|
|
58
|
+
OIMO_API_KEY="$OIMO_DEVELOP_KEY" OIMO_BASE_URL=https://oimo.dev oimo status
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Do not point `--base-url` or `OIMO_BASE_URL` at an origin you do not
|
|
62
|
+
trust with the key in your environment.
|
|
63
|
+
|
|
64
|
+
## Customer operations with a session
|
|
65
|
+
|
|
66
|
+
Run `oimo login`. It opens your default browser, signs you in through the
|
|
67
|
+
website's existing WorkOS flow, and asks you to approve CLI access for the shown
|
|
68
|
+
account. Approval starts a fresh WorkOS authorization-code exchange for the CLI;
|
|
69
|
+
AuthKit requires a fresh sign-in (`max_age=0`). The new session has its own
|
|
70
|
+
refresh token, so browser and CLI refreshes do not invalidate each other. The callback leaves
|
|
71
|
+
the website's session and staff organization selection in place. No cookie
|
|
72
|
+
copying is needed.
|
|
73
|
+
The CLI validates the returned session with `/v1/me` before saving it.
|
|
74
|
+
|
|
75
|
+
Automatic login requires deploying the accompanying `/auth/cli` server routes.
|
|
76
|
+
Use `--base-url https://oimo.dev` to sign in against the develop deployment.
|
|
77
|
+
For local development, start the service with `AUTH_DEV_USER=you@example.com`
|
|
78
|
+
and run `oimo login --base-url http://localhost:8080`.
|
|
79
|
+
|
|
80
|
+
If your browser cannot be launched automatically, the terminal prints a URL.
|
|
81
|
+
Open it on the same computer. `login --no-browser` prints the URL without trying
|
|
82
|
+
to launch a browser. Login waits up to five minutes; `--timeout <seconds>` sets
|
|
83
|
+
a deadline from 1 to 600 seconds. Ctrl-C or the browser's Cancel button ends it.
|
|
84
|
+
|
|
85
|
+
The handoff uses a temporary listener bound to `127.0.0.1`, a random state value,
|
|
86
|
+
and an ephemeral X25519 key. The server requires same-origin browser consent,
|
|
87
|
+
binds the new code exchange to the approved account with a separate signed
|
|
88
|
+
OAuth state cookie, and encrypts the new CLI session with
|
|
89
|
+
X25519/HKDF-SHA256/AES-256-GCM. The encrypted result is POSTed to loopback,
|
|
90
|
+
with no credentials in URLs or the terminal. The CLI checks
|
|
91
|
+
the browser Origin, callback state, encryption tag and expiry, then closes the
|
|
92
|
+
listener. The handshake uses no persistent server records and works across
|
|
93
|
+
service instances.
|
|
94
|
+
|
|
95
|
+
`login --session-stdin` retains manual session import for scripts: it reads
|
|
96
|
+
`OIMO_SESSION` or a session value from stdin. Automatic browser login
|
|
97
|
+
always obtains a fresh session even if that environment variable is set.
|
|
98
|
+
Manually importing a browser session shares its refresh token; use browser
|
|
99
|
+
login to obtain independent credentials. Existing CLI sessions created before
|
|
100
|
+
this change must log in again to gain that independence.
|
|
101
|
+
|
|
102
|
+
```sh
|
|
103
|
+
oimo login
|
|
104
|
+
oimo me
|
|
105
|
+
oimo organizations get
|
|
106
|
+
oimo organizations create --data '{"name":"My workspace"}'
|
|
107
|
+
oimo organizations switch --data '{"organizationId":"org_..."}'
|
|
108
|
+
oimo webhooks list
|
|
109
|
+
oimo webhooks create --data @webhook.json
|
|
110
|
+
oimo webhooks deliveries webhook-id
|
|
111
|
+
oimo webhooks test webhook-id
|
|
112
|
+
oimo members list
|
|
113
|
+
oimo members update membership-id --data '{"role":"member"}'
|
|
114
|
+
oimo invitations create --data '{"email":"person@example.com","role":"member"}'
|
|
115
|
+
oimo api-keys create --data '{"name":"My integration"}'
|
|
116
|
+
oimo preferences get
|
|
117
|
+
oimo provider-requests list
|
|
118
|
+
oimo logout
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The CLI sends the session cookie and target Origin; existing role checks, current
|
|
122
|
+
membership checks, tenant boundaries and last-admin protection remain in force.
|
|
123
|
+
Admins manage people; admins and members manage integrations. Creation and
|
|
124
|
+
rotation commands print newly issued API keys or signing secrets in their JSON
|
|
125
|
+
responses, as the API does. JSON bodies may be inline, `@file.json`, or `-` for
|
|
126
|
+
stdin. See the service README for request-body schemas and `oimo --help`
|
|
127
|
+
for every command and its options.
|
|
128
|
+
|
|
129
|
+
Sessions are stored per origin at `~/.config/oimo/sessions.json` with
|
|
130
|
+
permissions 0600. `--config <file>` overrides the location. Responses that rotate
|
|
131
|
+
or switch sessions update the saved value, and one that ends the session removes
|
|
132
|
+
it; when a response sets the cookie twice, the last one is kept, as in a
|
|
133
|
+
browser. Each update is applied to the file's current contents under a lock
|
|
134
|
+
file (`sessions.json.lock`), so commands running at the same time, against the
|
|
135
|
+
same or different origins, do not undo each other's rotations or logouts. The
|
|
136
|
+
organization a staff user is viewing is saved beside the session
|
|
137
|
+
and sent with later commands, until a switch into an organization they belong
|
|
138
|
+
to, a new login or `logout` clears it. An environment session overrides the
|
|
139
|
+
saved value and keeps nothing between commands, so a staff viewer selection
|
|
140
|
+
does not carry over; unset it after login to use the saved session and
|
|
141
|
+
rotations. `logout` removes only the selected origin's local session; it does
|
|
142
|
+
not revoke either remote WorkOS session or unset environment credentials.
|
|
143
|
+
Website logout ends the browser's WorkOS session and leaves an independently
|
|
144
|
+
created CLI session usable. Revoking the CLI's WorkOS session leaves the browser
|
|
145
|
+
session usable; revoking all sessions for the user ends both. Revocation is
|
|
146
|
+
enforced when the affected access token expires and its next refresh fails.
|
|
147
|
+
|
|
148
|
+
## Output and paging
|
|
149
|
+
|
|
150
|
+
`prices history` shows a readable table in an interactive terminal: provider,
|
|
151
|
+
resource, dimension, exact price with currency, and billing unit. Each observation
|
|
152
|
+
also shows its tier, source and recorded timestamp, plus effective dates and
|
|
153
|
+
renames when present. Names wrap rather than being truncated; very narrow
|
|
154
|
+
terminals use stacked observations. The header shows the pinned snapshot and
|
|
155
|
+
providers, and a paginated result prints a command to read the next page with
|
|
156
|
+
the same `--base-url`, `--limit` and `--config`.
|
|
157
|
+
|
|
158
|
+
Piping or redirecting history output defaults to JSON with all original fields.
|
|
159
|
+
Use `--format json` to get JSON in the terminal, or `--format table` to redirect
|
|
160
|
+
the readable display. Rates retain their decimal strings and native currencies
|
|
161
|
+
and units, without conversions or rounding. Other commands print JSON; empty
|
|
162
|
+
responses print `null`, and `terms content` prints the upstream text.
|
|
163
|
+
Terms are vendor-written, so in an interactive terminal their control
|
|
164
|
+
characters (escape sequences, BEL, bidirectional overrides) are shown escaped
|
|
165
|
+
as `\uXXXX` instead of being interpreted; line breaks and tabs are kept. Piped
|
|
166
|
+
or redirected output is the text as served.
|
|
167
|
+
Errors go to stderr with exit code 1.
|
|
168
|
+
|
|
169
|
+
`prices history` reads `/v1/prices/history`, including older rates. By default,
|
|
170
|
+
one page is read. JSON includes `asOf`, `providers`, `prices` and `nextCursor`. `--all` combines
|
|
171
|
+
all pages in memory and prints only after every page succeeds. The cursor pins
|
|
172
|
+
the snapshot and providers; subsequent requests send only cursor and limit.
|
|
173
|
+
`--alias` takes an alias group's id or comparison name (`compare aliases` lists
|
|
174
|
+
them) and reads that resource at every provider that sells it; the answer adds
|
|
175
|
+
`alias` with the group's revision and members, and the cursor pins that revision.
|
|
176
|
+
Keep `asOf` to reproduce a reading. Default page size is 5000, maximum 10000.
|
|
177
|
+
Credit-denominated providers require explicit selection under the API defaults.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
oimo prices history --as-of 2026-10-01T00:00:00Z --limit 100
|
|
181
|
+
oimo prices history --providers openai --limit 20
|
|
182
|
+
oimo prices history --providers openai --format json
|
|
183
|
+
oimo prices history --alias claude-opus-5.5 --all
|
|
184
|
+
oimo prices history --all > prices.json
|
|
185
|
+
oimo prices history --cursor 'cursor-from-previous-response'
|
|
186
|
+
oimo prices resource-history --provider openai --resource gpt-5
|
|
187
|
+
oimo prices latest --provider openai --offset 100 --limit 100 --sort price --dir asc
|
|
188
|
+
oimo prices history --base-url https://oimo.dev --all
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
`prices latest` uses offset paging (maximum 1000 rows per page; without
|
|
192
|
+
`--limit`, an API key or a customer session gets the first 1000); it is a current
|
|
193
|
+
reading, not a pinned export, and has no automatic paging. `resource-history`
|
|
194
|
+
reads the existing per-resource `/api/prices/history` endpoint. Other list
|
|
195
|
+
commands follow their existing API bounds; use `--after` where shown in help.
|
|
196
|
+
Timestamps and provider names are validated by the API.
|
|
197
|
+
|
|
198
|
+
`OIMO_BASE_URL` sets a default origin; `--base-url` overrides it. Saved
|
|
199
|
+
sessions are never sent to another origin; an environment API key goes to the
|
|
200
|
+
selected origin (see above). HTTP is supported only on loopback
|
|
201
|
+
for local development. Redirects are refused and each request times out after
|
|
202
|
+
30 seconds. Requests are not automatically retried, including mutations.
|
|
203
|
+
|
|
204
|
+
## Releases
|
|
205
|
+
|
|
206
|
+
The package is `@infrarates/cli` on public npm, first released as 0.1.0
|
|
207
|
+
(OIM-511). The `@infrarates` scope is the one Oimo can already publish to: its packages
|
|
208
|
+
are maintained by Jorge's personal npm account (`jtkm6`), which is also the
|
|
209
|
+
account that links the package to the publish workflow. The owner of `@oimo`
|
|
210
|
+
is unconfirmed. The command stays `oimo`.
|
|
211
|
+
|
|
212
|
+
- `oimo --version` prints the installed package's `package.json` version, so
|
|
213
|
+
a release changes only that file. Versions follow semver: a removed or
|
|
214
|
+
renamed command or option is a major bump while the version is 1.x or
|
|
215
|
+
later (a minor one while it is 0.x).
|
|
216
|
+
- `install-smoke.mjs` (part of `npm test`) packs the package, checks the
|
|
217
|
+
tarball holds exactly `package.json` and the `files` list (which includes
|
|
218
|
+
`LICENSE`), installs it globally into an empty prefix with an empty npm
|
|
219
|
+
cache and config, runs the installed `oimo` from outside the repository,
|
|
220
|
+
installs the next patch release's tarball over it (the upgrade path, e.g.
|
|
221
|
+
0.1.0 → 0.1.1), then uninstalls it. Release versions are plain
|
|
222
|
+
`MAJOR.MINOR.PATCH`; the test refuses any other `package.json` version. It
|
|
223
|
+
has passed on `node:22.11.0-slim` (the `engines` floor) and `node:24-slim`
|
|
224
|
+
with only this directory copied in:
|
|
225
|
+
|
|
226
|
+
```sh
|
|
227
|
+
podman run --rm -v "$PWD:/src:ro,Z" node:22.11.0-slim \
|
|
228
|
+
sh -c 'cp -r /src /cli && cd /cli && npm test'
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
- `.github/workflows/publish-cli.yml` publishes. A pull request that touches
|
|
232
|
+
this directory runs the tests and `npm publish --dry-run`. To release:
|
|
233
|
+
1. merge a PR that bumps `version` in `package.json`;
|
|
234
|
+
2. tag that merged commit `cli-v<version>` and push the tag
|
|
235
|
+
(`git tag cli-v0.1.1 <sha> && git push origin cli-v0.1.1`);
|
|
236
|
+
3. a platform reviewer approves the run in the `npm-publish` environment.
|
|
237
|
+
|
|
238
|
+
The workflow refuses a tag that does not match `package.json` or a commit
|
|
239
|
+
that is not on `main`. It publishes with npm trusted publishing, where the
|
|
240
|
+
job's GitHub OIDC token buys a one-time publish token, so no npm token is
|
|
241
|
+
stored. It then installs the published version from npm on Node 22.11 and
|
|
242
|
+
checks `oimo --version`. There is no provenance attestation because the
|
|
243
|
+
repository is private.
|
|
244
|
+
|
|
245
|
+
The package's Trusted Publisher settings on npmjs.com link it to
|
|
246
|
+
`oimo-ai/oimo`, `publish-cli.yml` and the `npm-publish` environment, which
|
|
247
|
+
is what lets that one job publish without a token. npm also requires
|
|
248
|
+
`repository.url` in `package.json` to name `oimo-ai/oimo` exactly;
|
|
249
|
+
without it the publish fails with only `ENEEDAUTH`.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export function openBrowser(
|
|
2
|
+
url: string,
|
|
3
|
+
platform?: NodeJS.Platform,
|
|
4
|
+
): Promise<void>;
|
|
5
|
+
export function browserLogin(
|
|
6
|
+
origin: string,
|
|
7
|
+
options?: {
|
|
8
|
+
opener?: (url: string) => Promise<unknown>;
|
|
9
|
+
noBrowser?: boolean;
|
|
10
|
+
timeoutMs?: number;
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
report?: (text: string) => void;
|
|
13
|
+
},
|
|
14
|
+
): Promise<string>;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createServer } from "node:http";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import {
|
|
4
|
+
createDecipheriv,
|
|
5
|
+
createPublicKey,
|
|
6
|
+
diffieHellman,
|
|
7
|
+
generateKeyPairSync,
|
|
8
|
+
hkdfSync,
|
|
9
|
+
randomBytes,
|
|
10
|
+
timingSafeEqual,
|
|
11
|
+
} from "node:crypto";
|
|
12
|
+
|
|
13
|
+
export function openBrowser(url, platform = process.platform) {
|
|
14
|
+
const [command, args] =
|
|
15
|
+
platform === "darwin"
|
|
16
|
+
? ["open", [url]]
|
|
17
|
+
: platform === "win32"
|
|
18
|
+
? ["rundll32", ["url.dll,FileProtocolHandler", url]]
|
|
19
|
+
: ["xdg-open", [url]];
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = spawn(command, args, { stdio: "ignore", shell: false });
|
|
22
|
+
child.once("error", reject);
|
|
23
|
+
child.once("exit", (code) =>
|
|
24
|
+
code === 0 ? resolve() : reject(new Error("Browser launch failed")),
|
|
25
|
+
);
|
|
26
|
+
child.unref();
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const matches = (left, right) => {
|
|
30
|
+
if (typeof left !== "string") return false;
|
|
31
|
+
const a = Buffer.from(left),
|
|
32
|
+
b = Buffer.from(right);
|
|
33
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
function decrypt(payload, privateKey, origin, callback, state) {
|
|
37
|
+
if (typeof payload !== "string" || !/^[A-Za-z0-9_-]+$/.test(payload))
|
|
38
|
+
throw new Error();
|
|
39
|
+
const envelope = JSON.parse(
|
|
40
|
+
Buffer.from(payload, "base64url").toString("utf8"),
|
|
41
|
+
);
|
|
42
|
+
if (
|
|
43
|
+
envelope.v !== 1 ||
|
|
44
|
+
typeof envelope.key !== "string" ||
|
|
45
|
+
!/^[A-Za-z0-9_-]{43}$/.test(envelope.key)
|
|
46
|
+
)
|
|
47
|
+
throw new Error();
|
|
48
|
+
for (const field of ["iv", "ciphertext", "tag"])
|
|
49
|
+
if (
|
|
50
|
+
typeof envelope[field] !== "string" ||
|
|
51
|
+
!/^[A-Za-z0-9_-]+$/.test(envelope[field])
|
|
52
|
+
)
|
|
53
|
+
throw new Error();
|
|
54
|
+
const publicKey = createPublicKey({
|
|
55
|
+
key: { kty: "OKP", crv: "X25519", x: envelope.key },
|
|
56
|
+
format: "jwk",
|
|
57
|
+
});
|
|
58
|
+
const info = Buffer.from(
|
|
59
|
+
JSON.stringify(["oimo-cli-login-v1", origin, callback, state]),
|
|
60
|
+
);
|
|
61
|
+
const key = Buffer.from(
|
|
62
|
+
hkdfSync(
|
|
63
|
+
"sha256",
|
|
64
|
+
diffieHellman({ privateKey, publicKey }),
|
|
65
|
+
Buffer.from(state, "base64url"),
|
|
66
|
+
info,
|
|
67
|
+
32,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
const iv = Buffer.from(envelope.iv, "base64url"),
|
|
71
|
+
tag = Buffer.from(envelope.tag, "base64url");
|
|
72
|
+
if (iv.length !== 12 || tag.length !== 16) throw new Error();
|
|
73
|
+
const decipher = createDecipheriv("aes-256-gcm", key, iv);
|
|
74
|
+
decipher.setAAD(info);
|
|
75
|
+
decipher.setAuthTag(tag);
|
|
76
|
+
const value = JSON.parse(
|
|
77
|
+
Buffer.concat([
|
|
78
|
+
decipher.update(Buffer.from(envelope.ciphertext, "base64url")),
|
|
79
|
+
decipher.final(),
|
|
80
|
+
]).toString("utf8"),
|
|
81
|
+
);
|
|
82
|
+
if (
|
|
83
|
+
value.origin !== origin ||
|
|
84
|
+
!matches(value.state, state) ||
|
|
85
|
+
!Number.isSafeInteger(value.expiresAt) ||
|
|
86
|
+
value.expiresAt <= Date.now() ||
|
|
87
|
+
value.expiresAt > Date.now() + 120_000
|
|
88
|
+
)
|
|
89
|
+
throw new Error();
|
|
90
|
+
if (value.cancelled === true) return null;
|
|
91
|
+
if (
|
|
92
|
+
typeof value.session !== "string" ||
|
|
93
|
+
!value.session ||
|
|
94
|
+
/[\s;,\x00-\x1f\x7f]/.test(value.session)
|
|
95
|
+
)
|
|
96
|
+
throw new Error();
|
|
97
|
+
return value.session;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Bind before launching the browser; accept one authenticated handoff only. */
|
|
101
|
+
export async function browserLogin(
|
|
102
|
+
origin,
|
|
103
|
+
{
|
|
104
|
+
opener = openBrowser,
|
|
105
|
+
noBrowser = false,
|
|
106
|
+
timeoutMs = 300_000,
|
|
107
|
+
signal,
|
|
108
|
+
report = (text) => process.stderr.write(text),
|
|
109
|
+
} = {},
|
|
110
|
+
) {
|
|
111
|
+
const { privateKey, publicKey } = generateKeyPairSync("x25519");
|
|
112
|
+
const state = randomBytes(32).toString("base64url");
|
|
113
|
+
let callback,
|
|
114
|
+
host,
|
|
115
|
+
completed = false,
|
|
116
|
+
timer;
|
|
117
|
+
let resolveLogin, rejectLogin;
|
|
118
|
+
const received = new Promise((resolve, reject) => {
|
|
119
|
+
resolveLogin = resolve;
|
|
120
|
+
rejectLogin = reject;
|
|
121
|
+
});
|
|
122
|
+
// Install a rejection handler while the browser-launch promise is pending.
|
|
123
|
+
received.catch(() => {});
|
|
124
|
+
const server = createServer(async (request, response) => {
|
|
125
|
+
response.setHeader("Cache-Control", "no-store");
|
|
126
|
+
response.setHeader("Content-Type", "text/plain; charset=utf-8");
|
|
127
|
+
response.setHeader("X-Content-Type-Options", "nosniff");
|
|
128
|
+
response.setHeader(
|
|
129
|
+
"Content-Security-Policy",
|
|
130
|
+
"default-src 'none'; frame-ancestors 'none'",
|
|
131
|
+
);
|
|
132
|
+
response.setHeader("Referrer-Policy", "no-referrer");
|
|
133
|
+
const fail = (status) => {
|
|
134
|
+
response.writeHead(status);
|
|
135
|
+
response.end("Invalid sign-in callback.");
|
|
136
|
+
};
|
|
137
|
+
if (
|
|
138
|
+
completed ||
|
|
139
|
+
request.method !== "POST" ||
|
|
140
|
+
request.url !== "/callback" ||
|
|
141
|
+
request.headers.host !== host ||
|
|
142
|
+
request.headers.origin !== origin ||
|
|
143
|
+
request.headers["content-type"]?.split(";", 1)[0] !==
|
|
144
|
+
"application/x-www-form-urlencoded"
|
|
145
|
+
) {
|
|
146
|
+
fail(403);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
let size = 0;
|
|
151
|
+
const chunks = [];
|
|
152
|
+
for await (const chunk of request) {
|
|
153
|
+
size += chunk.length;
|
|
154
|
+
if (size > 65_536) {
|
|
155
|
+
fail(413);
|
|
156
|
+
request.resume();
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
chunks.push(chunk);
|
|
160
|
+
}
|
|
161
|
+
const form = new URLSearchParams(Buffer.concat(chunks).toString("utf8"));
|
|
162
|
+
if (
|
|
163
|
+
form.getAll("state").length !== 1 ||
|
|
164
|
+
form.getAll("payload").length !== 1 ||
|
|
165
|
+
!matches(form.get("state"), state)
|
|
166
|
+
) {
|
|
167
|
+
fail(403);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const session = decrypt(
|
|
171
|
+
form.get("payload"),
|
|
172
|
+
privateKey,
|
|
173
|
+
origin,
|
|
174
|
+
callback,
|
|
175
|
+
state,
|
|
176
|
+
);
|
|
177
|
+
if (completed) {
|
|
178
|
+
fail(409);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
completed = true;
|
|
182
|
+
response.end(
|
|
183
|
+
session
|
|
184
|
+
? "Signed in to Oimo CLI. You can close this tab."
|
|
185
|
+
: "CLI sign-in cancelled. You can close this tab.",
|
|
186
|
+
() => {
|
|
187
|
+
if (session) resolveLogin(session);
|
|
188
|
+
else rejectLogin(new Error("CLI sign-in cancelled."));
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
} catch {
|
|
192
|
+
if (!response.headersSent) fail(400);
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
server.requestTimeout = 15_000;
|
|
196
|
+
server.headersTimeout = 10_000;
|
|
197
|
+
server.keepAliveTimeout = 1_000;
|
|
198
|
+
const abort = () => rejectLogin(new Error("CLI sign-in cancelled."));
|
|
199
|
+
try {
|
|
200
|
+
await new Promise((resolve, reject) => {
|
|
201
|
+
server.once("error", reject);
|
|
202
|
+
server.listen(0, "127.0.0.1", resolve);
|
|
203
|
+
});
|
|
204
|
+
host = `127.0.0.1:${server.address().port}`;
|
|
205
|
+
callback = `http://${host}/callback`;
|
|
206
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
207
|
+
if (signal?.aborted) {
|
|
208
|
+
abort();
|
|
209
|
+
return await received;
|
|
210
|
+
}
|
|
211
|
+
timer = setTimeout(
|
|
212
|
+
() =>
|
|
213
|
+
rejectLogin(
|
|
214
|
+
new Error(
|
|
215
|
+
"CLI sign-in timed out. Run oimo login again. New to Oimo? Accounts open from the waitlist: run oimo login once your invitation email has arrived and you have finished signing up.",
|
|
216
|
+
),
|
|
217
|
+
),
|
|
218
|
+
timeoutMs,
|
|
219
|
+
);
|
|
220
|
+
const url = new URL("/auth/cli", origin);
|
|
221
|
+
url.search = new URLSearchParams({
|
|
222
|
+
callback,
|
|
223
|
+
state,
|
|
224
|
+
key: publicKey.export({ format: "jwk" }).x,
|
|
225
|
+
}).toString();
|
|
226
|
+
report(`Finish sign-in in your browser:\n${url.href}\n`);
|
|
227
|
+
if (!noBrowser) {
|
|
228
|
+
// Launching may hang on a headless system; it must not extend the login deadline.
|
|
229
|
+
void Promise.resolve()
|
|
230
|
+
.then(() => opener(url.href))
|
|
231
|
+
.catch(() =>
|
|
232
|
+
report(
|
|
233
|
+
"Could not open a browser automatically. Open the link above on this computer.\n",
|
|
234
|
+
),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return await received;
|
|
238
|
+
} finally {
|
|
239
|
+
clearTimeout(timer);
|
|
240
|
+
signal?.removeEventListener("abort", abort);
|
|
241
|
+
server.close();
|
|
242
|
+
server.closeAllConnections();
|
|
243
|
+
}
|
|
244
|
+
}
|