@owlmeans/cli-auth 0.1.18-rc.1

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/build/lock.js ADDED
@@ -0,0 +1,49 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
3
+ import { dirname } from 'node:path';
4
+ export const lockPathFor = (credentialsPath) => `${credentialsPath}.lock`;
5
+ export const readLock = async (path) => {
6
+ try {
7
+ return JSON.parse(await readFile(path, 'utf-8'));
8
+ }
9
+ catch {
10
+ return null;
11
+ }
12
+ };
13
+ const isAlive = (pid) => {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch {
19
+ return false;
20
+ }
21
+ };
22
+ /**
23
+ * Become the one process driving this API URL's sign-in, or find out somebody else already is.
24
+ *
25
+ * Best-effort, not a mutual-exclusion guarantee: two processes racing this at the exact same
26
+ * instant can both conclude they are the owner, and each then drives its own independent device
27
+ * authorization. That costs an extra browser tab, never a corrupted file or a double-spent code —
28
+ * this is a convenience for the ordinary case (a person running two terminal tabs), not a
29
+ * correctness boundary.
30
+ */
31
+ export const claimOrJoinLock = async (path, apiUrl, info) => {
32
+ const existing = await readLock(path);
33
+ if (existing != null && existing.apiUrl === apiUrl && existing.expiresAt > Date.now() && isAlive(existing.pid)) {
34
+ return { owner: false, info: existing };
35
+ }
36
+ const mine = { ...info, apiUrl, pid: process.pid, nonce: randomBytes(8).toString('hex') };
37
+ await mkdir(dirname(path), { recursive: true });
38
+ await writeFile(path, JSON.stringify(mine), { mode: 0o600 });
39
+ return { owner: true, info: mine };
40
+ };
41
+ /** Clear the lock, but only the copy of it this process itself wrote — a stale read after
42
+ * somebody else has already reclaimed the same path must never delete THEIR lock instead. */
43
+ export const releaseLock = async (path, nonce) => {
44
+ const existing = await readLock(path);
45
+ if (existing?.nonce === nonce) {
46
+ await unlink(path).catch(() => undefined);
47
+ }
48
+ };
49
+ //# sourceMappingURL=lock.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"lock.js","sourceRoot":"","sources":["../src/lock.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AACzC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,MAAM,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAA;AACrE,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAA;AAuBnC,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,eAAuB,EAAU,EAAE,CAAC,GAAG,eAAe,OAAO,CAAA;AAEzF,MAAM,CAAC,MAAM,QAAQ,GAAG,KAAK,EAAE,IAAY,EAAkC,EAAE;IAC7E,IAAI,CAAC;QACH,OAAO,IAAI,CAAC,KAAK,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAC,CAAmB,CAAA;IACpE,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC,CAAA;AAED,MAAM,OAAO,GAAG,CAAC,GAAW,EAAW,EAAE;IACvC,IAAI,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC,CAAA;QAEpB,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,eAAe,GAAG,KAAK,EAClC,IAAY,EAAE,MAAc,EAAE,IAAsD,EACjC,EAAE;IACrD,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAA;IACrC,IAAI,QAAQ,IAAI,IAAI,IAAI,QAAQ,CAAC,MAAM,KAAK,MAAM,IAAI,QAAQ,CAAC,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/G,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAA;IACzC,CAAC;IAED,MAAM,IAAI,GAAmB,EAAE,GAAG,IAAI,EAAE,MAAM,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,KAAK,EAAE,WAAW,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAA;IACzG,MAAM,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAA;IAC/C,MAAM,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAA;IAE5D,OAAO,EAAE,KAAK,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,EAAE,CAAA;AACpC,CAAC,CAAA;AAED;6FAC6F;AAC7F,MAAM,CAAC,MAAM,WAAW,GAAG,KAAK,EAAE,IAAY,EAAE,KAAa,EAAiB,EAAE;IAC9E,MAAM,QAAQ,GAAG,MAAM,QAAQ,CAAC,IAAI,CAAC,CAAA;IACrC,IAAI,QAAQ,EAAE,KAAK,KAAK,KAAK,EAAE,CAAC;QAC9B,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA;IAC3C,CAAC;AACH,CAAC,CAAA"}
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
3
+ * whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
4
+ * this never learned to open a browser on, so a failure here is never fatal to the caller.
5
+ *
6
+ * Spawned detached and with every std stream ignored, because this process's stdout may be
7
+ * carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
8
+ * ever reach.
9
+ */
10
+ export declare const openBrowser: (url: string, env?: NodeJS.ProcessEnv) => boolean;
11
+ //# sourceMappingURL=open-browser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-browser.d.ts","sourceRoot":"","sources":["../src/open-browser.ts"],"names":[],"mappings":"AAQA;;;;;;;;GAQG;AACH,eAAO,MAAM,WAAW,QAAS,MAAM,QAAO,MAAM,CAAC,UAAU,KAAiB,OAsB/E,CAAA"}
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process';
2
+ const COMMAND_BY_PLATFORM = {
3
+ darwin: 'open',
4
+ win32: 'start',
5
+ linux: 'xdg-open',
6
+ };
7
+ /**
8
+ * Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
9
+ * whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
10
+ * this never learned to open a browser on, so a failure here is never fatal to the caller.
11
+ *
12
+ * Spawned detached and with every std stream ignored, because this process's stdout may be
13
+ * carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
14
+ * ever reach.
15
+ */
16
+ export const openBrowser = (url, env = process.env) => {
17
+ const platform = process.platform;
18
+ const command = COMMAND_BY_PLATFORM[platform];
19
+ if (command == null)
20
+ return false;
21
+ // `BROWSER=none` is the convention other CLIs already honour; the dedicated variable is for
22
+ // automation (an end-to-end run drives the page itself and must not pop a window on a desktop).
23
+ if (env.OWLMEANS_NO_BROWSER === '1' || env.BROWSER === 'none')
24
+ return false;
25
+ // A Linux session with no display (SSH, a container) has nothing for `xdg-open` to talk to.
26
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY)
27
+ return false;
28
+ try {
29
+ const child = platform === 'win32'
30
+ ? spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true })
31
+ : spawn(command, [url], { detached: true, stdio: 'ignore' });
32
+ child.on('error', () => undefined); // a listener is required or Node throws on the next tick
33
+ child.unref();
34
+ return true;
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ };
40
+ //# sourceMappingURL=open-browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"open-browser.js","sourceRoot":"","sources":["../src/open-browser.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAA;AAE1C,MAAM,mBAAmB,GAA2B;IAClD,MAAM,EAAE,MAAM;IACd,KAAK,EAAE,OAAO;IACd,KAAK,EAAE,UAAU;CAClB,CAAA;AAED;;;;;;;;GAQG;AACH,MAAM,CAAC,MAAM,WAAW,GAAG,CAAC,GAAW,EAAE,GAAG,GAAsB,OAAO,CAAC,GAAG,EAAW,EAAE;IACxF,MAAM,QAAQ,GAAG,OAAO,CAAC,QAAQ,CAAA;IACjC,MAAM,OAAO,GAAG,mBAAmB,CAAC,QAAQ,CAAC,CAAA;IAC7C,IAAI,OAAO,IAAI,IAAI;QAAE,OAAO,KAAK,CAAA;IACjC,4FAA4F;IAC5F,gGAAgG;IAChG,IAAI,GAAG,CAAC,mBAAmB,KAAK,GAAG,IAAI,GAAG,CAAC,OAAO,KAAK,MAAM;QAAE,OAAO,KAAK,CAAA;IAC3E,4FAA4F;IAC5F,IAAI,QAAQ,KAAK,OAAO,IAAI,CAAC,GAAG,CAAC,OAAO,IAAI,CAAC,GAAG,CAAC,eAAe;QAAE,OAAO,KAAK,CAAA;IAE9E,IAAI,CAAC;QACH,MAAM,KAAK,GAAG,QAAQ,KAAK,OAAO;YAChC,CAAC,CAAC,KAAK,CAAC,KAAK,EAAE,CAAC,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC;YAClG,CAAC,CAAC,KAAK,CAAC,OAAO,EAAE,CAAC,GAAG,CAAC,EAAE,EAAE,QAAQ,EAAE,IAAI,EAAE,KAAK,EAAE,QAAQ,EAAE,CAAC,CAAA;QAE9D,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE,CAAC,SAAS,CAAC,CAAA,CAAC,yDAAyD;QAC5F,KAAK,CAAC,KAAK,EAAE,CAAA;QAEb,OAAO,IAAI,CAAA;IACb,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,KAAK,CAAA;IACd,CAAC;AACH,CAAC,CAAA"}
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@owlmeans/cli-auth",
3
+ "version": "0.1.18-rc.1",
4
+ "license": "MIT",
5
+ "description": "OAuth device-authorization sign-in for a command-line tool: a dotenv-style ~/.owlmeans credentials file (overridable, environment always wins), a cross-process sign-in lock so several invocations converge on one browser round trip, a browser opener, and a small credential holder a CLI wraps its API calls with.",
6
+ "type": "module",
7
+ "scripts": {
8
+ "build": "tsc -b",
9
+ "dev": "sleep 2 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
10
+ "watch": "tsc -b -w --preserveWatchOutput --pretty",
11
+ "test": "bun test ./tests"
12
+ },
13
+ "main": "build/index.js",
14
+ "module": "build/index.js",
15
+ "types": "build/index.d.ts",
16
+ "exports": {
17
+ ".": {
18
+ "import": "./build/index.js",
19
+ "require": "./build/index.js",
20
+ "default": "./build/index.js",
21
+ "module": "./build/index.js",
22
+ "types": "./build/index.d.ts"
23
+ }
24
+ },
25
+ "dependencies": {
26
+ "@owlmeans/error": "^0.1.18-rc.27",
27
+ "@owlmeans/oauth": "^0.1.18-rc.1"
28
+ },
29
+ "devDependencies": {
30
+ "@owlmeans/dep-config": "workspace:*",
31
+ "@types/bun": "^1.4.0",
32
+ "nodemon": "^3.1.14",
33
+ "typescript": "^7.0.2"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ }
38
+ }
package/src/consts.ts ADDED
@@ -0,0 +1,12 @@
1
+ /** Overrides where the credentials file lives. Unset means `~/.owlmeans`. */
2
+ export const ENV_CREDENTIALS_FILE = 'OWLMEANS_CREDENTIALS'
3
+
4
+ export const DEFAULT_CREDENTIALS_FILENAME = '.owlmeans'
5
+
6
+ /** How long `require()` waits for a sign-in this call itself started before returning
7
+ * `SignInRequired` and letting the sign-in continue in the background. */
8
+ export const DEFAULT_WAIT_MS = 20_000
9
+
10
+ /** RFC 8628's own ceiling is whatever the server answered with; this is the poller's OWN patience
11
+ * before it gives up entirely, independent of `expires_in`. */
12
+ export const MAX_SIGN_IN_WAIT_MS = 15 * 60 * 1000
@@ -0,0 +1,123 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { chmod, mkdir, readFile, rename, stat, writeFile } from 'node:fs/promises'
3
+ import { homedir } from 'node:os'
4
+ import { dirname, join } from 'node:path'
5
+ import { DEFAULT_CREDENTIALS_FILENAME, ENV_CREDENTIALS_FILE } from './consts.js'
6
+
7
+ /**
8
+ * Where the credentials file lives: `OWLMEANS_CREDENTIALS`, or `~/.owlmeans`.
9
+ *
10
+ * A CLI that talks to more than one deployment (a staging environment, a self-hosted instance)
11
+ * points this at a different file per deployment — the file is never merged with another one, and
12
+ * a token it holds is meaningless anywhere but the API URL it was signed in against.
13
+ */
14
+ export const resolveEnvFile = (env: NodeJS.ProcessEnv = process.env): string =>
15
+ env[ENV_CREDENTIALS_FILE] != null && env[ENV_CREDENTIALS_FILE] !== ''
16
+ ? env[ENV_CREDENTIALS_FILE]
17
+ : join(homedir(), DEFAULT_CREDENTIALS_FILENAME)
18
+
19
+ /**
20
+ * Parse a dotenv-shaped body: `KEY=value`, an optional `export ` prefix, `#` comments, one level
21
+ * of quoting. Deliberately small — a dotenv library would add a dependency for a format this
22
+ * package itself writes, and this is the one shape it ever needs to read back.
23
+ */
24
+ export const parseEnv = (content: string): Record<string, string> => {
25
+ const values: Record<string, string> = {}
26
+ for (const raw of content.split('\n')) {
27
+ const line = raw.trim()
28
+ if (line === '' || line.startsWith('#')) continue
29
+
30
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line)
31
+ if (match == null) continue
32
+
33
+ let value = match[2].trim()
34
+ if ((value.startsWith('"') && value.endsWith('"') && value.length > 1)
35
+ || (value.startsWith("'") && value.endsWith("'") && value.length > 1)) {
36
+ value = value.slice(1, -1)
37
+ }
38
+ values[match[1]] = value
39
+ }
40
+
41
+ return values
42
+ }
43
+
44
+ const readFileIfPresent = async (path: string): Promise<string> =>
45
+ await readFile(path, 'utf-8').catch(() => '')
46
+
47
+ /**
48
+ * The credentials file's values alone, with no environment overlay — what a token is bound to.
49
+ */
50
+ export const readCredentialsFile = async (env: NodeJS.ProcessEnv = process.env): Promise<Record<string, string>> =>
51
+ parseEnv(await readFileIfPresent(resolveEnvFile(env)))
52
+
53
+ /**
54
+ * The file, with the process environment layered over it — environment wins, but an environment
55
+ * value that is the EMPTY STRING is treated as unset.
56
+ *
57
+ * The empty-string rule exists because a harness config commonly expands an unset shell variable
58
+ * to `''` (`${VIABLE_API_TOKEN:-}`), and a literal empty override must not shadow a real value the
59
+ * file holds — that would make "I have not set this" indistinguishable from "I am overriding this
60
+ * to nothing", and the file is always the more deliberate of the two.
61
+ */
62
+ export const loadOwlmeansEnv = async (env: NodeJS.ProcessEnv = process.env): Promise<Record<string, string>> => {
63
+ const file = await readCredentialsFile(env)
64
+ const fromEnv: Record<string, string> = {}
65
+ Object.entries(env).forEach(([key, value]) => {
66
+ if (value != null && value !== '') fromEnv[key] = value
67
+ })
68
+
69
+ return { ...file, ...fromEnv }
70
+ }
71
+
72
+ /**
73
+ * Replace the named keys in the credentials file, keeping every other line — comments, a key this
74
+ * call did not touch, blank lines — exactly where they were.
75
+ *
76
+ * Written atomically (a temp file in the same directory, then a rename) so a process killed
77
+ * mid-write never leaves a half-written credentials file behind, and created with mode `0600`
78
+ * because this file can hold a live access token. An existing file that is readable by anyone but
79
+ * its owner is reported back rather than silently tightened — permissions someone else set on
80
+ * purpose are theirs to change.
81
+ */
82
+ export const setEnvValues = async (
83
+ path: string, values: Record<string, string | undefined>
84
+ ): Promise<{ insecurePermissions: boolean }> => {
85
+ const existing = await readFileIfPresent(path)
86
+ const lines = existing === '' ? [] : existing.split('\n')
87
+ const claimed = new Set<string>()
88
+
89
+ const rewritten = lines.map(line => {
90
+ const trimmed = line.trim()
91
+ const match = /^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(trimmed)
92
+ if (match == null || !(match[1] in values)) return line
93
+
94
+ claimed.add(match[1])
95
+ const value = values[match[1]]
96
+
97
+ return value == null ? null : `${match[1]}=${value}`
98
+ }).filter((line): line is string => line != null)
99
+
100
+ Object.entries(values).forEach(([key, value]) => {
101
+ if (claimed.has(key) || value == null) return
102
+ rewritten.push(`${key}=${value}`)
103
+ })
104
+
105
+ const body = `${rewritten.join('\n').replace(/\n+$/, '')}\n`
106
+
107
+ await mkdir(dirname(path), { recursive: true })
108
+ const tmp = join(dirname(path), `.${DEFAULT_CREDENTIALS_FILENAME}.${randomBytes(6).toString('hex')}.tmp`)
109
+ await writeFile(tmp, body, { mode: 0o600 })
110
+ await rename(tmp, path)
111
+ await chmod(path, 0o600).catch(() => undefined)
112
+
113
+ let insecurePermissions = false
114
+ try {
115
+ const info = await stat(path)
116
+ insecurePermissions = (info.mode & 0o077) !== 0
117
+ } catch {
118
+ // Nothing to report if the stat itself fails right after a successful write — unusual enough
119
+ // that guessing at a permission problem would be noise.
120
+ }
121
+
122
+ return { insecurePermissions }
123
+ }
package/src/holder.ts ADDED
@@ -0,0 +1,215 @@
1
+ import { hostname, userInfo } from 'node:os'
2
+ import {
3
+ discoverAuthorizationServer, OAuthAccessDenied, OAuthError, OAUTH_DEVICE_NAME_MAX,
4
+ pollDeviceToken, requestDeviceAuthorization, revokeToken, signInRequired, TokenRejected
5
+ } from '@owlmeans/oauth'
6
+ import type { AuthorizationServerMetadata, DeviceSignInOutcome } from '@owlmeans/oauth'
7
+ import { DEFAULT_WAIT_MS } from './consts.js'
8
+ import { readCredentialsFile, resolveEnvFile, setEnvValues } from './env-file.js'
9
+ import { claimOrJoinLock, lockPathFor, readLock, releaseLock } from './lock.js'
10
+ import type { SignInLockInfo } from './lock.js'
11
+ import { openBrowser } from './open-browser.js'
12
+
13
+ export interface CliCredentialsOptions {
14
+ /** The API origin this credential set is for, and the OAuth `resource` it is scoped to unless
15
+ * `resource` says otherwise. */
16
+ apiUrl: string
17
+ /** This CLI's OAuth `client_id` — a static one the authorization server declared, or an https
18
+ * Client ID Metadata Document URL. */
19
+ clientId: string
20
+ deviceName?: string
21
+ resource?: string
22
+ scope?: string
23
+ /** Which key in `~/.owlmeans` (and the environment) carries the token. */
24
+ tokenEnvKey: string
25
+ /** Which key records the URL a stored token belongs to. A file naming no URL at all is treated
26
+ * as belonging to whichever `apiUrl` is asked for — only an explicit MISMATCH refuses it. */
27
+ apiUrlEnvKey: string
28
+ env?: NodeJS.ProcessEnv
29
+ /** Best-effort progress — "open this URL and enter this code", "signed in", a failure. A host
30
+ * wires this to stderr, an MCP `notifications/message`, or nothing at all. */
31
+ onNotify?: (message: string) => void
32
+ }
33
+
34
+ export interface CliCredentials {
35
+ /** The token this call site should use right now: the environment, then the bound file value,
36
+ * or `null` when neither has one. */
37
+ token: () => Promise<string | null>
38
+ /** Ensure a usable token exists. Starts or joins a device sign-in when there is none, waits up
39
+ * to `waitMs` for it to be approved, and returns the token. The sign-in keeps running in the
40
+ * background past that wait — a later `require()` call picks up wherever it left off, rather
41
+ * than starting over. */
42
+ require: (waitMs?: number) => Promise<string>
43
+ /** A 401 happened while presenting `rejectedToken`. A token that came from the FILE is forgotten
44
+ * so the next `require()` signs in again; a token that came from the ENVIRONMENT is reported —
45
+ * silently trying another identity behind an operator's back is worse than failing loudly. */
46
+ invalidate: (rejectedToken: string) => Promise<void>
47
+ /** Revoke the current token at the server and remove it from the file. */
48
+ signOut: () => Promise<void>
49
+ }
50
+
51
+
52
+ const defaultDeviceName = (): string => {
53
+ let who = 'cli'
54
+ try {
55
+ who = userInfo().username
56
+ } catch {
57
+ // Some sandboxes have no passwd entry for the running uid; the hostname alone still helps.
58
+ }
59
+
60
+ return `${hostname()} · ${who}`.slice(0, OAUTH_DEVICE_NAME_MAX)
61
+ }
62
+
63
+ /** One in-flight sign-in per API URL, per process — a second `require()` call while the first is
64
+ * still waiting joins the SAME poll rather than requesting a second device code. */
65
+ const inFlightByApiUrl = new Map<string, Promise<DeviceSignInOutcome>>()
66
+
67
+ export const makeCliCredentials = (opts: CliCredentialsOptions): CliCredentials => {
68
+ const env = opts.env ?? process.env
69
+ const notify = (message: string): void => opts.onNotify?.(message)
70
+ const credentialsPath = resolveEnvFile(env)
71
+ const lockPath = lockPathFor(credentialsPath)
72
+
73
+ const token = async (): Promise<string | null> => {
74
+ const envValue = env[opts.tokenEnvKey]
75
+ if (envValue != null && envValue !== '') return envValue
76
+
77
+ const file = await readCredentialsFile(env)
78
+ const boundUrl = file[opts.apiUrlEnvKey]
79
+ if (boundUrl != null && boundUrl !== '' && boundUrl !== opts.apiUrl) return null
80
+
81
+ const fileToken = file[opts.tokenEnvKey]
82
+
83
+ return fileToken != null && fileToken !== '' ? fileToken : null
84
+ }
85
+
86
+ /** Start a fresh device authorization, or adopt another live process's own — either way, claim
87
+ * or join the lock BEFORE requesting one, so the decision and the request agree. */
88
+ const claimJoinOrStart = async (server: AuthorizationServerMetadata): Promise<{ owner: boolean, info: SignInLockInfo }> => {
89
+ const authorization = await requestDeviceAuthorization(server, {
90
+ client_id: opts.clientId, scope: opts.scope, resource: opts.resource ?? opts.apiUrl,
91
+ device_name: opts.deviceName ?? defaultDeviceName(),
92
+ })
93
+
94
+ return await claimOrJoinLock(lockPath, opts.apiUrl, {
95
+ verificationUri: authorization.verification_uri,
96
+ verificationUriComplete: authorization.verification_uri_complete,
97
+ userCode: authorization.user_code,
98
+ deviceCode: authorization.device_code,
99
+ interval: authorization.interval,
100
+ expiresAt: Date.now() + authorization.expires_in * 1000,
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Synchronous on purpose, up to the point it records itself in `inFlightByApiUrl` — that is
106
+ * what makes two `require()` calls racing in the SAME process converge on one sign-in rather
107
+ * than each starting its own before either has had a chance to publish that it is working on
108
+ * it. (Cross-PROCESS concurrency is what the file lock inside `claimJoinOrStart` is for.)
109
+ */
110
+ const beginOrJoin = (): Promise<DeviceSignInOutcome> => {
111
+ const existing = inFlightByApiUrl.get(opts.apiUrl)
112
+ if (existing != null) return existing
113
+
114
+ const promise = (async (): Promise<DeviceSignInOutcome> => {
115
+ const server = await discoverAuthorizationServer(opts.apiUrl)
116
+ const claim = await claimJoinOrStart(server)
117
+
118
+ notify(
119
+ claim.info.userCode != null
120
+ ? `Sign in at ${claim.info.verificationUri} with code ${claim.info.userCode}`
121
+ : `Sign in at ${claim.info.verificationUri}`
122
+ )
123
+ // Only the owner opens a browser — a joining process's own (unused) device authorization is
124
+ // simply left to expire, since a second, un-displayed code would only teach the server's
125
+ // rate limiter that this client polls too eagerly.
126
+ if (claim.owner) {
127
+ openBrowser(claim.info.verificationUriComplete ?? claim.info.verificationUri)
128
+ }
129
+
130
+ try {
131
+ const outcome = await pollDeviceToken(server, {
132
+ clientId: opts.clientId, deviceCode: claim.info.deviceCode, interval: claim.info.interval,
133
+ expiresAt: claim.info.expiresAt,
134
+ })
135
+ if (outcome.status === 'authorized') {
136
+ await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: outcome.token, [opts.apiUrlEnvKey]: opts.apiUrl })
137
+ notify('Signed in.')
138
+ }
139
+
140
+ return outcome
141
+ } finally {
142
+ inFlightByApiUrl.delete(opts.apiUrl)
143
+ if (claim.owner) await releaseLock(lockPath, claim.info.nonce)
144
+ }
145
+ })()
146
+ inFlightByApiUrl.set(opts.apiUrl, promise)
147
+
148
+ return promise
149
+ }
150
+
151
+ return {
152
+ token,
153
+
154
+ require: async (waitMs = DEFAULT_WAIT_MS): Promise<string> => {
155
+ const existing = await token()
156
+ if (existing != null) return existing
157
+
158
+ // Deliberately NOT awaited here — `beginOrJoin()` is the pending sign-in itself, and
159
+ // racing it (rather than awaiting it first) is what lets `require()` return control to
160
+ // its caller after `waitMs` while the sign-in keeps running toward its own resolution.
161
+ const pollPromise = beginOrJoin()
162
+ // The ceiling's timer is cleared once the race is decided: a pending timer keeps a process
163
+ // alive, and `viable-mcp login` (waiting up to 15 minutes) must exit the moment it is signed in.
164
+ let ceiling: ReturnType<typeof setTimeout> | undefined
165
+ const raced = await Promise.race([
166
+ pollPromise.then(outcome => ({ settled: true as const, outcome })),
167
+ new Promise<{ settled: false }>(resolve => {
168
+ ceiling = setTimeout(() => resolve({ settled: false }), waitMs)
169
+ }),
170
+ ]).finally(() => clearTimeout(ceiling))
171
+
172
+ if (!raced.settled) {
173
+ const lock = await readLock(lockPath)
174
+ throw signInRequired({
175
+ url: lock?.verificationUri ?? opts.apiUrl, code: lock?.userCode, expiresAt: lock?.expiresAt,
176
+ })
177
+ }
178
+
179
+ switch (raced.outcome.status) {
180
+ case 'authorized':
181
+ return raced.outcome.token
182
+ case 'denied':
183
+ throw new OAuthAccessDenied('sign-in')
184
+ case 'expired':
185
+ throw new OAuthError('sign-in:expired')
186
+ default:
187
+ throw new OAuthError('sign-in:aborted')
188
+ }
189
+ },
190
+
191
+ invalidate: async (rejectedToken: string): Promise<void> => {
192
+ const file = await readCredentialsFile(env)
193
+ if (file[opts.tokenEnvKey] === rejectedToken) {
194
+ await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined })
195
+ notify('The stored token was refused. Signing in again.')
196
+
197
+ return
198
+ }
199
+ if (env[opts.tokenEnvKey] === rejectedToken) {
200
+ throw new TokenRejected(opts.tokenEnvKey)
201
+ }
202
+ },
203
+
204
+ signOut: async (): Promise<void> => {
205
+ const current = await token()
206
+ if (current == null) return
207
+
208
+ const server = await discoverAuthorizationServer(opts.apiUrl).catch(() => null)
209
+ if (server != null) {
210
+ await revokeToken(server, { token: current, clientId: opts.clientId }).catch(() => undefined)
211
+ }
212
+ await setEnvValues(credentialsPath, { [opts.tokenEnvKey]: undefined })
213
+ },
214
+ }
215
+ }
package/src/index.ts ADDED
@@ -0,0 +1,5 @@
1
+ export * from './consts.js'
2
+ export * from './env-file.js'
3
+ export * from './lock.js'
4
+ export * from './open-browser.js'
5
+ export * from './holder.js'
package/src/lock.ts ADDED
@@ -0,0 +1,77 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
3
+ import { dirname } from 'node:path'
4
+
5
+ /**
6
+ * What one process tells every other one about the sign-in it is driving — the browser URL and
7
+ * code so a SECOND process can show the same "waiting on…" state instead of opening a second
8
+ * browser tab for the same API URL.
9
+ */
10
+ export interface SignInLockInfo {
11
+ pid: number
12
+ apiUrl: string
13
+ verificationUri: string
14
+ verificationUriComplete?: string
15
+ userCode?: string
16
+ /** The device flow's own secret — protected the same way the eventual access token is (mode
17
+ * `0600`, same directory as the credentials file), so a joining process can poll the SAME
18
+ * pending authorization instead of requesting a second one nobody will ever display. */
19
+ deviceCode: string
20
+ interval: number
21
+ expiresAt: number
22
+ /** Proves ownership at release time — a process only clears the lock it itself wrote. */
23
+ nonce: string
24
+ }
25
+
26
+ export const lockPathFor = (credentialsPath: string): string => `${credentialsPath}.lock`
27
+
28
+ export const readLock = async (path: string): Promise<SignInLockInfo | null> => {
29
+ try {
30
+ return JSON.parse(await readFile(path, 'utf-8')) as SignInLockInfo
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ const isAlive = (pid: number): boolean => {
37
+ try {
38
+ process.kill(pid, 0)
39
+
40
+ return true
41
+ } catch {
42
+ return false
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Become the one process driving this API URL's sign-in, or find out somebody else already is.
48
+ *
49
+ * Best-effort, not a mutual-exclusion guarantee: two processes racing this at the exact same
50
+ * instant can both conclude they are the owner, and each then drives its own independent device
51
+ * authorization. That costs an extra browser tab, never a corrupted file or a double-spent code —
52
+ * this is a convenience for the ordinary case (a person running two terminal tabs), not a
53
+ * correctness boundary.
54
+ */
55
+ export const claimOrJoinLock = async (
56
+ path: string, apiUrl: string, info: Omit<SignInLockInfo, 'pid' | 'nonce' | 'apiUrl'>
57
+ ): Promise<{ owner: boolean, info: SignInLockInfo }> => {
58
+ const existing = await readLock(path)
59
+ if (existing != null && existing.apiUrl === apiUrl && existing.expiresAt > Date.now() && isAlive(existing.pid)) {
60
+ return { owner: false, info: existing }
61
+ }
62
+
63
+ const mine: SignInLockInfo = { ...info, apiUrl, pid: process.pid, nonce: randomBytes(8).toString('hex') }
64
+ await mkdir(dirname(path), { recursive: true })
65
+ await writeFile(path, JSON.stringify(mine), { mode: 0o600 })
66
+
67
+ return { owner: true, info: mine }
68
+ }
69
+
70
+ /** Clear the lock, but only the copy of it this process itself wrote — a stale read after
71
+ * somebody else has already reclaimed the same path must never delete THEIR lock instead. */
72
+ export const releaseLock = async (path: string, nonce: string): Promise<void> => {
73
+ const existing = await readLock(path)
74
+ if (existing?.nonce === nonce) {
75
+ await unlink(path).catch(() => undefined)
76
+ }
77
+ }
@@ -0,0 +1,40 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ const COMMAND_BY_PLATFORM: Record<string, string> = {
4
+ darwin: 'open',
5
+ win32: 'start',
6
+ linux: 'xdg-open',
7
+ }
8
+
9
+ /**
10
+ * Best-effort: open `url` in the person's default browser. `false` on any failure — a CLI whose
11
+ * whole job is print-a-URL-and-poll must still work over SSH, in a container, or on a platform
12
+ * this never learned to open a browser on, so a failure here is never fatal to the caller.
13
+ *
14
+ * Spawned detached and with every std stream ignored, because this process's stdout may be
15
+ * carrying a protocol (an MCP server's JSON-RPC stream) that nothing the opened program writes may
16
+ * ever reach.
17
+ */
18
+ export const openBrowser = (url: string, env: NodeJS.ProcessEnv = process.env): boolean => {
19
+ const platform = process.platform
20
+ const command = COMMAND_BY_PLATFORM[platform]
21
+ if (command == null) return false
22
+ // `BROWSER=none` is the convention other CLIs already honour; the dedicated variable is for
23
+ // automation (an end-to-end run drives the page itself and must not pop a window on a desktop).
24
+ if (env.OWLMEANS_NO_BROWSER === '1' || env.BROWSER === 'none') return false
25
+ // A Linux session with no display (SSH, a container) has nothing for `xdg-open` to talk to.
26
+ if (platform === 'linux' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
27
+
28
+ try {
29
+ const child = platform === 'win32'
30
+ ? spawn('cmd', ['/c', 'start', '""', url], { detached: true, stdio: 'ignore', windowsHide: true })
31
+ : spawn(command, [url], { detached: true, stdio: 'ignore' })
32
+
33
+ child.on('error', () => undefined) // a listener is required or Node throws on the next tick
34
+ child.unref()
35
+
36
+ return true
37
+ } catch {
38
+ return false
39
+ }
40
+ }