@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.
@@ -0,0 +1,110 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, readFile, rm, stat } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { loadOwlmeansEnv, parseEnv, readCredentialsFile, resolveEnvFile, setEnvValues } from '../src/env-file.js'
6
+ import { ENV_CREDENTIALS_FILE } from '../src/consts.js'
7
+
8
+ let dir: string
9
+
10
+ beforeEach(async () => { dir = await mkdtemp(join(tmpdir(), 'cli-auth-test-')) })
11
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
12
+
13
+ describe('resolveEnvFile', () => {
14
+ test('defaults to ~/.owlmeans', () => {
15
+ expect(resolveEnvFile({})).toMatch(/\.owlmeans$/)
16
+ })
17
+
18
+ test('honors OWLMEANS_CREDENTIALS', () => {
19
+ expect(resolveEnvFile({ [ENV_CREDENTIALS_FILE]: '/custom/path' })).toBe('/custom/path')
20
+ })
21
+
22
+ test('ignores an empty override', () => {
23
+ expect(resolveEnvFile({ [ENV_CREDENTIALS_FILE]: '' })).toMatch(/\.owlmeans$/)
24
+ })
25
+ })
26
+
27
+ describe('parseEnv', () => {
28
+ test('reads KEY=VALUE, quotes, comments and export prefixes', () => {
29
+ const parsed = parseEnv([
30
+ '# a comment', '', 'FOO=bar', 'export BAZ=qux', 'QUOTED="has spaces"', "SINGLE='also quoted'",
31
+ ].join('\n'))
32
+ expect(parsed).toEqual({ FOO: 'bar', BAZ: 'qux', QUOTED: 'has spaces', SINGLE: 'also quoted' })
33
+ })
34
+ })
35
+
36
+ describe('loadOwlmeansEnv', () => {
37
+ test('the environment wins over the file', async () => {
38
+ const path = join(dir, '.owlmeans')
39
+ await setEnvValues(path, { VIABLE_API_TOKEN: 'from-file' })
40
+
41
+ const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: path, VIABLE_API_TOKEN: 'from-env' })
42
+ expect(merged.VIABLE_API_TOKEN).toBe('from-env')
43
+ })
44
+
45
+ test('an empty environment value does not shadow the file', async () => {
46
+ const path = join(dir, '.owlmeans')
47
+ await setEnvValues(path, { VIABLE_API_TOKEN: 'from-file' })
48
+
49
+ const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: path, VIABLE_API_TOKEN: '' })
50
+ expect(merged.VIABLE_API_TOKEN).toBe('from-file')
51
+ })
52
+
53
+ test('a missing file is not an error', async () => {
54
+ const merged = await loadOwlmeansEnv({ [ENV_CREDENTIALS_FILE]: join(dir, 'nope') })
55
+ expect(merged.VIABLE_API_TOKEN).toBeUndefined()
56
+ })
57
+ })
58
+
59
+ describe('setEnvValues', () => {
60
+ test('creates the file with mode 0600', async () => {
61
+ const path = join(dir, '.owlmeans')
62
+ const result = await setEnvValues(path, { A: '1' })
63
+ expect(result.insecurePermissions).toBe(false)
64
+
65
+ const info = await stat(path)
66
+ expect(info.mode & 0o777).toBe(0o600)
67
+ })
68
+
69
+ test('preserves comments and untouched keys, replaces the named one in place', async () => {
70
+ const path = join(dir, '.owlmeans')
71
+ await Bun.write(path, '# a note\nKEEP=1\nCHANGE=old\n')
72
+
73
+ await setEnvValues(path, { CHANGE: 'new' })
74
+ const content = await readFile(path, 'utf-8')
75
+ expect(content).toContain('# a note')
76
+ expect(content).toContain('KEEP=1')
77
+ expect(content).toContain('CHANGE=new')
78
+ expect(content).not.toContain('CHANGE=old')
79
+ })
80
+
81
+ test('appends a key that was not there before', async () => {
82
+ const path = join(dir, '.owlmeans')
83
+ await Bun.write(path, 'A=1\n')
84
+ await setEnvValues(path, { B: '2' })
85
+
86
+ const parsed = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: path })
87
+ expect(parsed).toEqual({ A: '1', B: '2' })
88
+ })
89
+
90
+ test('an undefined value removes the key rather than writing it', async () => {
91
+ const path = join(dir, '.owlmeans')
92
+ await Bun.write(path, 'A=1\nB=2\n')
93
+ await setEnvValues(path, { A: undefined })
94
+
95
+ const parsed = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: path })
96
+ expect(parsed).toEqual({ B: '2' })
97
+ })
98
+
99
+ test('a concurrent rewrite never leaves a half-written file — the temp file lands atomically', async () => {
100
+ const path = join(dir, '.owlmeans')
101
+ await Promise.all([
102
+ setEnvValues(path, { A: '1' }),
103
+ setEnvValues(path, { B: '2' }),
104
+ ])
105
+ // Whichever write landed last, the file must be one complete, parseable write — never a
106
+ // truncated interleaving of the two.
107
+ const content = await readFile(path, 'utf-8')
108
+ expect(content.endsWith('\n')).toBe(true)
109
+ })
110
+ })
@@ -0,0 +1,242 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, rm } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { makeCliCredentials } from '../src/holder.js'
6
+ import { readCredentialsFile } from '../src/env-file.js'
7
+ import { ENV_CREDENTIALS_FILE } from '../src/consts.js'
8
+
9
+ const originalFetch = globalThis.fetch
10
+ const CLIENT_ID = 'test-cli'
11
+ let ISSUER: string
12
+ let testSeq = 0
13
+
14
+ let dir: string
15
+ let credentialsPath: string
16
+
17
+ const asMetadata = (issuer: string) => ({
18
+ issuer,
19
+ authorization_endpoint: `${issuer}/oauth/authorize`,
20
+ token_endpoint: `${issuer}/oauth/token`,
21
+ device_authorization_endpoint: `${issuer}/oauth/device_authorization`,
22
+ revocation_endpoint: `${issuer}/oauth/revoke`,
23
+ scopes_supported: ['*'],
24
+ response_types_supported: ['code'],
25
+ grant_types_supported: ['authorization_code', 'urn:ietf:params:oauth:grant-type:device_code'],
26
+ code_challenge_methods_supported: ['S256'],
27
+ token_endpoint_auth_methods_supported: ['none'],
28
+ client_id_metadata_document_supported: true,
29
+ authorization_response_iss_parameter_supported: true,
30
+ })
31
+
32
+ const json = (body: unknown, status = 200): Response =>
33
+ new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
34
+
35
+ beforeEach(async () => {
36
+ dir = await mkdtemp(join(tmpdir(), 'cli-auth-holder-'))
37
+ credentialsPath = join(dir, '.owlmeans')
38
+ // A distinct host per test: `inFlightByApiUrl` is module-level state, and a test that
39
+ // deliberately times out (SignInRequired) leaves its background poll running past its own
40
+ // `afterEach` — a shared apiUrl would let that poll's later activity leak into the next test.
41
+ ISSUER = `https://api-${++testSeq}.example.com`
42
+ })
43
+ afterEach(async () => {
44
+ globalThis.fetch = originalFetch
45
+ await rm(dir, { recursive: true, force: true })
46
+ })
47
+
48
+ /** A fake server: metadata, one device_authorization call, and a token endpoint whose answer
49
+ * `pendingUntil` polls can advance past `authorization_pending` on demand. */
50
+ const fakeServer = (opts: { authorizeAfterPolls?: number } = {}) => {
51
+ let deviceCalls = 0
52
+ let tokenCalls = 0
53
+ const authorizeAfterPolls = opts.authorizeAfterPolls ?? 0
54
+
55
+ globalThis.fetch = (async (url: string | URL, init?: RequestInit) => {
56
+ const href = url.toString()
57
+ if (href.endsWith('/.well-known/oauth-authorization-server')) return json(asMetadata(ISSUER))
58
+ if (href.endsWith('/oauth/device_authorization')) {
59
+ deviceCalls += 1
60
+
61
+ return json({
62
+ device_code: `dc-${deviceCalls}`, user_code: `CODE-${deviceCalls}`,
63
+ verification_uri: `${ISSUER}/oauth/device`, expires_in: 600, interval: 0.001,
64
+ })
65
+ }
66
+ if (href.endsWith('/oauth/token')) {
67
+ tokenCalls += 1
68
+
69
+ return tokenCalls > authorizeAfterPolls
70
+ ? json({ access_token: 'vib_new-token', token_type: 'Bearer', expires_in: 100 })
71
+ : json({ error: 'authorization_pending' }, 400)
72
+ }
73
+ if (href.endsWith('/oauth/revoke')) return new Response(null, { status: 200 })
74
+
75
+ throw new Error(`unexpected fetch: ${href} ${init?.method ?? 'GET'}`)
76
+ }) as typeof fetch
77
+
78
+ return { deviceCallCount: () => deviceCalls }
79
+ }
80
+
81
+ const opts = (extra: Record<string, unknown> = {}) => ({
82
+ apiUrl: ISSUER, clientId: CLIENT_ID, tokenEnvKey: 'VIABLE_API_TOKEN', apiUrlEnvKey: 'VIABLE_API_URL',
83
+ env: { [ENV_CREDENTIALS_FILE]: credentialsPath } as unknown as NodeJS.ProcessEnv,
84
+ ...extra,
85
+ })
86
+
87
+ describe('token()', () => {
88
+ test('prefers the environment over the file', async () => {
89
+ const credentials = makeCliCredentials(opts({
90
+ env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'from-env' } as unknown as NodeJS.ProcessEnv,
91
+ }))
92
+ expect(await credentials.token()).toBe('from-env')
93
+ })
94
+
95
+ test('falls back to a bound file token', async () => {
96
+ fakeServer()
97
+ const credentials = makeCliCredentials(opts())
98
+ await credentials.require()
99
+ expect(await credentials.token()).toBe('vib_new-token')
100
+ })
101
+
102
+ test('refuses a file token bound to a different API URL', async () => {
103
+ const { setEnvValues } = await import('../src/env-file.js')
104
+ await setEnvValues(credentialsPath, { VIABLE_API_TOKEN: 'vib_other', VIABLE_API_URL: 'https://other.example.com' })
105
+
106
+ const credentials = makeCliCredentials(opts())
107
+ expect(await credentials.token()).toBeNull()
108
+ })
109
+
110
+ test('accepts a file token when no URL was ever recorded', async () => {
111
+ const { setEnvValues } = await import('../src/env-file.js')
112
+ await setEnvValues(credentialsPath, { VIABLE_API_TOKEN: 'vib_no-url-recorded' })
113
+
114
+ const credentials = makeCliCredentials(opts())
115
+ expect(await credentials.token()).toBe('vib_no-url-recorded')
116
+ })
117
+ })
118
+
119
+ describe('require()', () => {
120
+ test('leaves no ceiling timer behind once signed in — a pending timer would keep `login` alive', async () => {
121
+ fakeServer()
122
+ const CEILING = 987_654
123
+ const armed = new Set<unknown>()
124
+ const realSet = globalThis.setTimeout
125
+ const realClear = globalThis.clearTimeout
126
+ globalThis.setTimeout = ((fn: () => void, ms?: number, ...rest: unknown[]) => {
127
+ const handle = realSet(fn, ms, ...rest)
128
+ if (ms === CEILING) armed.add(handle)
129
+
130
+ return handle
131
+ }) as typeof setTimeout
132
+ globalThis.clearTimeout = ((handle: Parameters<typeof clearTimeout>[0]) => {
133
+ armed.delete(handle)
134
+ realClear(handle)
135
+ }) as typeof clearTimeout
136
+
137
+ try {
138
+ const credentials = makeCliCredentials(opts())
139
+ expect(await credentials.require(CEILING)).toBe('vib_new-token')
140
+ expect(armed.size).toBe(0)
141
+ } finally {
142
+ globalThis.setTimeout = realSet
143
+ globalThis.clearTimeout = realClear
144
+ }
145
+ })
146
+
147
+ test('signs in and persists the token bound to this API URL', async () => {
148
+ fakeServer()
149
+ const notifications: string[] = []
150
+ const credentials = makeCliCredentials(opts({ onNotify: (m: string) => notifications.push(m) }))
151
+
152
+ const token = await credentials.require(5_000)
153
+ expect(token).toBe('vib_new-token')
154
+ expect(notifications.some(m => m.includes('CODE-1'))).toBe(true)
155
+ expect(notifications).toContain('Signed in.')
156
+
157
+ const file = await readCredentialsFile({ [ENV_CREDENTIALS_FILE]: credentialsPath })
158
+ expect(file.VIABLE_API_TOKEN).toBe('vib_new-token')
159
+ expect(file.VIABLE_API_URL).toBe(ISSUER)
160
+ })
161
+
162
+ test('polls through authorization_pending until approved', async () => {
163
+ fakeServer({ authorizeAfterPolls: 3 })
164
+ const credentials = makeCliCredentials(opts())
165
+ expect(await credentials.require(5_000)).toBe('vib_new-token')
166
+ })
167
+
168
+ test('returns immediately once a token already exists — no network call at all', async () => {
169
+ let called = false
170
+ globalThis.fetch = (async () => { called = true; throw new Error('should not be called') }) as typeof fetch
171
+ const credentials = makeCliCredentials(opts({
172
+ env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'already-here' } as unknown as NodeJS.ProcessEnv,
173
+ }))
174
+
175
+ expect(await credentials.require()).toBe('already-here')
176
+ expect(called).toBe(false)
177
+ })
178
+
179
+ test('throws SignInRequired when nobody approves within the wait, naming the URL and code', async () => {
180
+ fakeServer({ authorizeAfterPolls: 1_000_000 })
181
+ const credentials = makeCliCredentials(opts())
182
+
183
+ let caught: any
184
+ try {
185
+ await credentials.require(50)
186
+ } catch (e) {
187
+ caught = e
188
+ }
189
+ expect(caught).toBeDefined()
190
+ expect(caught.url).toContain('/oauth/device')
191
+ expect(caught.code).toBe('CODE-1')
192
+ })
193
+
194
+ test('a second require() call while the first is pending only requests ONE device code', async () => {
195
+ const server = fakeServer({ authorizeAfterPolls: 2 })
196
+ const credentials = makeCliCredentials(opts())
197
+
198
+ const [a, b] = await Promise.all([credentials.require(5_000), credentials.require(5_000)])
199
+ expect(a).toBe('vib_new-token')
200
+ expect(b).toBe('vib_new-token')
201
+ expect(server.deviceCallCount()).toBe(1)
202
+ })
203
+ })
204
+
205
+ describe('invalidate()', () => {
206
+ test('forgets a rejected file token so the next require() signs in again', async () => {
207
+ fakeServer()
208
+ const credentials = makeCliCredentials(opts())
209
+ await credentials.require()
210
+
211
+ await credentials.invalidate('vib_new-token')
212
+ expect(await credentials.token()).toBeNull()
213
+ })
214
+
215
+ test('reports (never silently switches away from) a rejected environment token', async () => {
216
+ const credentials = makeCliCredentials(opts({
217
+ env: { [ENV_CREDENTIALS_FILE]: credentialsPath, VIABLE_API_TOKEN: 'from-env' } as unknown as NodeJS.ProcessEnv,
218
+ }))
219
+ await expect(credentials.invalidate('from-env')).rejects.toThrow()
220
+ })
221
+
222
+ test('does nothing for a token that is neither the current file nor env value', async () => {
223
+ const credentials = makeCliCredentials(opts())
224
+ await expect(credentials.invalidate('unrelated-token')).resolves.toBeUndefined()
225
+ })
226
+ })
227
+
228
+ describe('signOut()', () => {
229
+ test('revokes at the server and clears the file', async () => {
230
+ fakeServer()
231
+ const credentials = makeCliCredentials(opts())
232
+ await credentials.require()
233
+
234
+ await credentials.signOut()
235
+ expect(await credentials.token()).toBeNull()
236
+ })
237
+
238
+ test('is a no-op with nothing signed in', async () => {
239
+ const credentials = makeCliCredentials(opts())
240
+ await expect(credentials.signOut()).resolves.toBeUndefined()
241
+ })
242
+ })
@@ -0,0 +1,85 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { tmpdir } from 'node:os'
4
+ import { join } from 'node:path'
5
+ import { claimOrJoinLock, readLock, releaseLock } from '../src/lock.js'
6
+
7
+ let dir: string
8
+ let path: string
9
+
10
+ beforeEach(async () => {
11
+ dir = await mkdtemp(join(tmpdir(), 'cli-auth-lock-'))
12
+ path = join(dir, '.owlmeans.lock')
13
+ })
14
+ afterEach(async () => { await rm(dir, { recursive: true, force: true }) })
15
+
16
+ const info = (overrides: Record<string, unknown> = {}) => ({
17
+ verificationUri: 'https://app.example.com/oauth/device', userCode: 'ABCD-EFGH',
18
+ deviceCode: 'secret-device-code', interval: 5, expiresAt: Date.now() + 60_000, ...overrides,
19
+ })
20
+
21
+ describe('claimOrJoinLock', () => {
22
+ test('claims an absent lock', async () => {
23
+ const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
24
+ expect(claim.owner).toBe(true)
25
+ expect(claim.info.apiUrl).toBe('https://api.example.com')
26
+ expect(claim.info.pid).toBe(process.pid)
27
+
28
+ expect(await readLock(path)).toEqual(claim.info)
29
+ })
30
+
31
+ test('joins a live lock for the same API URL instead of overwriting it', async () => {
32
+ const first = await claimOrJoinLock(path, 'https://api.example.com', info({ userCode: 'FIRST-CODE' }))
33
+ expect(first.owner).toBe(true)
34
+
35
+ const second = await claimOrJoinLock(path, 'https://api.example.com', info({ userCode: 'SECOND-CODE' }))
36
+ expect(second.owner).toBe(false)
37
+ expect(second.info.userCode).toBe('FIRST-CODE')
38
+ })
39
+
40
+ test('reclaims a lock for a different API URL', async () => {
41
+ await claimOrJoinLock(path, 'https://one.example.com', info())
42
+ const claim = await claimOrJoinLock(path, 'https://two.example.com', info())
43
+ expect(claim.owner).toBe(true)
44
+ expect(claim.info.apiUrl).toBe('https://two.example.com')
45
+ })
46
+
47
+ test('reclaims an expired lock', async () => {
48
+ await claimOrJoinLock(path, 'https://api.example.com', info({ expiresAt: Date.now() - 1 }))
49
+ const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
50
+ expect(claim.owner).toBe(true)
51
+ })
52
+
53
+ test('reclaims a lock left by a process that is no longer running', async () => {
54
+ // A pid essentially guaranteed not to be alive in this test's own pid namespace.
55
+ await writeFile(path, JSON.stringify({
56
+ ...info(), apiUrl: 'https://api.example.com', pid: 999_999, nonce: 'stale',
57
+ }))
58
+ const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
59
+ expect(claim.owner).toBe(true)
60
+ })
61
+ })
62
+
63
+ describe('releaseLock', () => {
64
+ test('removes only the lock this process itself wrote', async () => {
65
+ const claim = await claimOrJoinLock(path, 'https://api.example.com', info())
66
+ await releaseLock(path, claim.info.nonce)
67
+ expect(await readLock(path)).toBeNull()
68
+ })
69
+
70
+ test('never removes a lock somebody else has since claimed', async () => {
71
+ const first = await claimOrJoinLock(path, 'https://one.example.com', info())
72
+ await claimOrJoinLock(path, 'https://two.example.com', info()) // reclaims the path, new nonce
73
+
74
+ await releaseLock(path, first.info.nonce)
75
+ expect((await readLock(path))?.apiUrl).toBe('https://two.example.com')
76
+ })
77
+ })
78
+
79
+ describe('readLock', () => {
80
+ test('answers null for a missing or malformed file', async () => {
81
+ expect(await readLock(path)).toBeNull()
82
+ await writeFile(path, 'not json')
83
+ expect(await readLock(path)).toBeNull()
84
+ })
85
+ })
@@ -0,0 +1,14 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { openBrowser } from '../src/open-browser.js'
3
+
4
+ describe('openBrowser suppression', () => {
5
+ test('OWLMEANS_NO_BROWSER and BROWSER=none refuse without spawning anything', () => {
6
+ expect(openBrowser('https://example.test/', { OWLMEANS_NO_BROWSER: '1' })).toBe(false)
7
+ expect(openBrowser('https://example.test/', { BROWSER: 'none' })).toBe(false)
8
+ })
9
+
10
+ test('a Linux session with no display refuses', () => {
11
+ if (process.platform !== 'linux') return
12
+ expect(openBrowser('https://example.test/', {})).toBe(false)
13
+ })
14
+ })
@@ -0,0 +1,12 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "types": ["bun"],
8
+ "rootDir": "../",
9
+ "noEmit": true
10
+ },
11
+ "include": ["./**/*", "../src/**/*"]
12
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json",
4
+ "@owlmeans/dep-config/tsconfig.node.json"
5
+ ],
6
+ "compilerOptions": {
7
+ "rootDir": "./src/",
8
+ "outDir": "./build/"
9
+ },
10
+ "exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
11
+ }