@knpkv/atlassian-common 1.4.1 → 1.6.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.
Files changed (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +32 -1
  3. package/dist/Brand.d.ts +2 -2
  4. package/dist/Brand.d.ts.map +1 -1
  5. package/dist/Brand.js.map +1 -1
  6. package/dist/Hash.js +2 -2
  7. package/dist/Hash.js.map +1 -1
  8. package/dist/auth/OAuthEndpoints.js +3 -3
  9. package/dist/auth/OAuthEndpoints.js.map +1 -1
  10. package/dist/auth/OAuthOperations.d.ts +1 -1
  11. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  12. package/dist/auth/OAuthOperations.js +8 -8
  13. package/dist/auth/OAuthOperations.js.map +1 -1
  14. package/dist/bin.js +2 -2
  15. package/dist/bin.js.map +1 -1
  16. package/dist/cli-auth/AtlassianCliAuth.d.ts +102 -0
  17. package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
  18. package/dist/cli-auth/AtlassianCliAuth.js +298 -0
  19. package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
  20. package/dist/cli-auth/index.d.ts +13 -0
  21. package/dist/cli-auth/index.d.ts.map +1 -0
  22. package/dist/cli-auth/index.js +13 -0
  23. package/dist/cli-auth/index.js.map +1 -0
  24. package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
  25. package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
  26. package/dist/cli-auth/internal/NodeLayers.js +17 -0
  27. package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
  28. package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
  29. package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
  30. package/dist/cli-auth/internal/oauthServer.js +94 -0
  31. package/dist/cli-auth/internal/oauthServer.js.map +1 -0
  32. package/dist/cli-auth/openBrowser.d.ts +16 -0
  33. package/dist/cli-auth/openBrowser.d.ts.map +1 -0
  34. package/dist/cli-auth/openBrowser.js +30 -0
  35. package/dist/cli-auth/openBrowser.js.map +1 -0
  36. package/dist/config/ConfigPaths.js +2 -2
  37. package/dist/config/ProfileManager.d.ts +1 -1
  38. package/dist/config/ProfileManager.d.ts.map +1 -1
  39. package/dist/config/ProfileManager.js +3 -3
  40. package/dist/config/ProfileManager.js.map +1 -1
  41. package/package.json +11 -7
  42. package/src/Brand.ts +5 -5
  43. package/src/Hash.ts +2 -2
  44. package/src/auth/OAuthEndpoints.ts +3 -3
  45. package/src/auth/OAuthOperations.ts +14 -24
  46. package/src/bin.ts +2 -2
  47. package/src/cli-auth/AtlassianCliAuth.ts +530 -0
  48. package/src/cli-auth/index.ts +18 -0
  49. package/src/cli-auth/internal/NodeLayers.ts +25 -0
  50. package/src/cli-auth/internal/oauthServer.ts +168 -0
  51. package/src/cli-auth/openBrowser.ts +47 -0
  52. package/src/config/ConfigPaths.ts +3 -3
  53. package/src/config/ProfileManager.ts +3 -3
  54. package/test/ApiUpdateWorkflows.test.ts +286 -10
  55. package/test/OAuthOperations.test.ts +2 -2
  56. package/test/ProfileManager.test.ts +1 -1
  57. package/test/cliAuth.guardrails.test.ts +76 -0
  58. package/test/cliAuth.login.test.ts +319 -0
  59. package/test/cliAuth.oauthServer.test.ts +276 -0
  60. package/test/cliAuth.openBrowser.test.ts +70 -0
  61. package/test/cliAuth.refresh.test.ts +304 -0
@@ -0,0 +1,76 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ import { NodeServices } from "@effect/platform-node"
3
+ import { describe, expect, it } from "@effect/vitest"
4
+ import * as Effect from "effect/Effect"
5
+ import * as FileSystem from "effect/FileSystem"
6
+ import * as Path from "effect/Path"
7
+ import { ChildProcess, ChildProcessSpawner } from "effect/process"
8
+ import * as Schema from "effect/Schema"
9
+ import * as Stream from "effect/Stream"
10
+ import confluenceIntegration from "../../confluence-to-markdown/vitest.config.integration.js"
11
+ import confluence from "../../confluence-to-markdown/vitest.config.js"
12
+ import jiraIntegration from "../../jira-cli/vitest.config.integration.js"
13
+ import jira from "../../jira-cli/vitest.config.js"
14
+ import clockify from "../../jira-clockify/vitest.config.js"
15
+
16
+ const configs = { jira, jiraIntegration, confluence, confluenceIntegration, clockify }
17
+ const Findings = Schema.fromJsonString(Schema.Array(Schema.Struct({ file: Schema.String })))
18
+
19
+ describe("CLI auth guardrails", () => {
20
+ for (const [name, config] of Object.entries(configs)) {
21
+ it(`${name} resolves CLI auth from source rather than stale build output`, () => {
22
+ expect(config.resolve?.alias).toHaveProperty(
23
+ "@knpkv/atlassian-common/cli-auth",
24
+ new URL("../src/cli-auth/index.ts", import.meta.url).pathname
25
+ )
26
+ })
27
+ }
28
+
29
+ it.effect("limits the reimplementation guard to the Jira and Confluence CLIs", () =>
30
+ Effect.gen(function*() {
31
+ const fs = yield* FileSystem.FileSystem
32
+ const path = yield* Path.Path
33
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
34
+ const root = yield* path.fromFileUrl(new URL("../../../", import.meta.url))
35
+ const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "cli-auth-guard-" })
36
+ yield* fs.writeFileString(
37
+ path.join(fixture, "sgconfig.yml"),
38
+ "ruleDirs: []\nlanguageGlobs:\n tsx:\n - \"**/*.ts\"\n"
39
+ )
40
+ // The same callback-server name and endpoint namespace are legitimate in
41
+ // another integration. Only the two delegated CLI implementations are banned.
42
+ const source = [
43
+ "import * as Auth from \"@knpkv/atlassian-common/auth\"",
44
+ "import { refreshToken } from \"@knpkv/atlassian-common/auth\"",
45
+ "const authUrl = Auth.buildAuthUrl({ clientId: \"client\" })",
46
+ "function startCallbackServer(state: string) { return state }"
47
+ ].join("\n")
48
+ for (const name of ["jira-cli", "confluence-to-markdown", "other-oauth"]) {
49
+ const directory = path.join(fixture, "packages", name, "src")
50
+ yield* fs.makeDirectory(directory, { recursive: true })
51
+ yield* fs.writeFileString(path.join(directory, "auth.ts"), source)
52
+ }
53
+ const handle = yield* spawner.spawn(ChildProcess.make(
54
+ path.join(root, "node_modules", ".bin", "ast-grep"),
55
+ [
56
+ "scan",
57
+ "--rule",
58
+ path.join(root, "ast-grep", "rules", "packaging", "no-atlassian-cli-auth-reimplementation.yml"),
59
+ "--json=compact",
60
+ "packages"
61
+ ],
62
+ { cwd: fixture }
63
+ ))
64
+ const { exitCode, stderr, stdout } = yield* Effect.all({
65
+ exitCode: handle.exitCode,
66
+ stdout: Stream.decodeText(handle.stdout).pipe(Stream.mkString),
67
+ stderr: Stream.decodeText(handle.stderr).pipe(Stream.mkString)
68
+ }, { concurrency: "unbounded" })
69
+ expect(exitCode, `${fixture}: ${stdout} ${stderr}`).toBe(1)
70
+ const findings = yield* Schema.decodeUnknownEffect(Findings)(stdout)
71
+ expect(findings.map(({ file }) => file).sort()).toEqual([
72
+ ...Array<string>(3).fill("packages/confluence-to-markdown/src/auth.ts"),
73
+ ...Array<string>(3).fill("packages/jira-cli/src/auth.ts")
74
+ ])
75
+ }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)))
76
+ })
@@ -0,0 +1,319 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ /**
3
+ * Browser login through the shared Atlassian CLI auth.
4
+ *
5
+ * The login prints the authorization URL before it tries to open a browser, so
6
+ * a browser that will not open must not end the login: the user can still visit
7
+ * the URL. A callback server that cannot bind, on the other hand, must end it
8
+ * before any URL is advertised, and the server must stop whenever the login's
9
+ * scope closes.
10
+ */
11
+ import { NodeFileSystem, NodePath, NodeServices } from "@effect/platform-node"
12
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
13
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
14
+ import { describe, expect, it } from "@effect/vitest"
15
+ import { ConfigProvider } from "effect"
16
+ import * as Console from "effect/Console"
17
+ import * as Data from "effect/Data"
18
+ import * as Deferred from "effect/Deferred"
19
+ import * as Effect from "effect/Effect"
20
+ import * as Fiber from "effect/Fiber"
21
+ import * as FileSystem from "effect/FileSystem"
22
+ import { HttpClient, HttpClientRequest, HttpClientResponse, HttpServer, HttpServerError } from "effect/http"
23
+ import * as Layer from "effect/Layer"
24
+ import { ChildProcessSpawner } from "effect/process"
25
+ import * as Ref from "effect/Ref"
26
+ import type * as Schema from "effect/Schema"
27
+ import * as Sink from "effect/Sink"
28
+ import * as Stream from "effect/Stream"
29
+ import { TestClock } from "effect/testing"
30
+ import { createServer } from "node:http"
31
+ import { makeAtlassianCliAuth, makeHttpServerFactory } from "../src/cli-auth/index.js"
32
+ import { loadActiveProfileToken } from "../src/config/AuthProfiles.js"
33
+ import { HomeDirectoryLive } from "../src/config/ConfigPaths.js"
34
+ import { saveOAuthConfig } from "../src/config/TokenStorage.js"
35
+
36
+ const TOOL = "test-cli"
37
+
38
+ class TestAuthMissing extends Data.TaggedError("TestAuthMissing") {}
39
+
40
+ const authOptions = {
41
+ toolName: TOOL,
42
+ commandName: "test",
43
+ productName: "Test",
44
+ scopes: ["read:me", "offline_access"],
45
+ authMissing: () => new TestAuthMissing()
46
+ }
47
+
48
+ const storage = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer, HomeDirectoryLive)
49
+
50
+ const withHome = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
51
+ Effect.gen(function*() {
52
+ const fs = yield* FileSystem.FileSystem
53
+ const home = yield* fs.makeTempDirectoryScoped({ prefix: "atlassian-cli-login-" })
54
+ return yield* effect.pipe(
55
+ Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })))
56
+ )
57
+ }).pipe(Effect.scoped, Effect.provide(NodeFileSystem.layer))
58
+
59
+ const seedConfig = saveOAuthConfig(TOOL, { clientId: "client-1", clientSecret: "secret-1" }).pipe(
60
+ Effect.provide(storage)
61
+ )
62
+
63
+ const json = (request: HttpClientRequest.HttpClientRequest, body: Schema.Json) =>
64
+ HttpClientResponse.fromWeb(
65
+ request,
66
+ new Response(JSON.stringify(body), { status: 200, headers: { "content-type": "application/json" } })
67
+ )
68
+
69
+ // Atlassian's side of a successful login: token exchange, one site, the user.
70
+ interface FakeSite {
71
+ readonly id: string
72
+ readonly name: string
73
+ readonly url: string
74
+ }
75
+
76
+ const firstSite: FakeSite = { id: "cloud-1", name: "Site", url: "https://site.atlassian.net" }
77
+ const secondSite: FakeSite = { id: "cloud-2", name: "Other", url: "https://other.atlassian.net" }
78
+
79
+ const atlassianClientWith = (sites: ReadonlyArray<FakeSite>) =>
80
+ Layer.succeed(
81
+ HttpClient.HttpClient,
82
+ HttpClient.make((request) => {
83
+ if (request.url.endsWith("/oauth/token")) {
84
+ return Effect.succeed(json(request, {
85
+ access_token: "access-1",
86
+ refresh_token: "refresh-1",
87
+ expires_in: 3600,
88
+ scope: "read:me offline_access",
89
+ token_type: "Bearer"
90
+ }))
91
+ }
92
+ if (request.url.endsWith("/accessible-resources")) {
93
+ return Effect.succeed(json(request, sites.map((site) => ({ ...site, scopes: [] }))))
94
+ }
95
+ return Effect.succeed(json(request, { account_id: "acc-1", name: "Ada", email: "ada@example.com" }))
96
+ })
97
+ )
98
+
99
+ const atlassianClient = atlassianClientWith([firstSite])
100
+
101
+ // Every launcher runs and reports failure — merged after NodeServices so it
102
+ // replaces the real spawner rather than being replaced by it, the way `xdg-open` does on a host
103
+ // with no browser. The first launch hands its URL to the test.
104
+ const failingLaunchers = (launched: Deferred.Deferred<string>, attempts: Ref.Ref<ReadonlyArray<string>>) =>
105
+ Layer.succeed(
106
+ ChildProcessSpawner.ChildProcessSpawner,
107
+ ChildProcessSpawner.make((command) =>
108
+ Effect.gen(function*() {
109
+ if (command._tag === "StandardCommand") {
110
+ yield* Ref.update(attempts, (all) => [...all, command.command])
111
+ yield* Deferred.succeed(launched, command.args[command.args.length - 1] ?? "")
112
+ }
113
+ return ChildProcessSpawner.makeHandle({
114
+ all: Stream.empty,
115
+ exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)),
116
+ getInputFd: () => Sink.drain,
117
+ getOutputFd: () => Stream.empty,
118
+ isRunning: Effect.succeed(false),
119
+ kill: () => Effect.void,
120
+ pid: ChildProcessSpawner.ProcessId(1),
121
+ reref: Effect.void,
122
+ stderr: Stream.empty,
123
+ stdin: Sink.drain,
124
+ stdout: Stream.empty,
125
+ unref: Effect.succeed(Effect.void)
126
+ })
127
+ })
128
+ )
129
+ )
130
+
131
+ // An ephemeral real server whose shutdown the test can observe.
132
+ const observedServerFactory = (stopped: Ref.Ref<boolean>) =>
133
+ makeHttpServerFactory(() =>
134
+ Layer.merge(
135
+ NodeHttpServer.layerServer(createServer, { port: 0 }),
136
+ Layer.effectDiscard(Effect.addFinalizer(() => Ref.set(stopped, true)))
137
+ )
138
+ )
139
+
140
+ const captureConsole = (lines: Array<string>): Console.Console =>
141
+ Object.assign(Object.create(console), {
142
+ log: (...args: ReadonlyArray<unknown>) => lines.push(args.join(" ")),
143
+ error: (...args: ReadonlyArray<unknown>) => lines.push(`stderr: ${args.join(" ")}`)
144
+ })
145
+
146
+ const deliverCallback = (authUrl: string) =>
147
+ Effect.gen(function*() {
148
+ const url = new URL(authUrl)
149
+ const redirect = url.searchParams.get("redirect_uri") ?? ""
150
+ const state = url.searchParams.get("state") ?? ""
151
+ const port = new URL(redirect).port
152
+ const client = yield* HttpClient.HttpClient
153
+ yield* client.execute(
154
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
155
+ HttpClientRequest.setUrlParam("code", "code-1"),
156
+ HttpClientRequest.setUrlParam("state", state)
157
+ )
158
+ )
159
+ }).pipe(Effect.provide(NodeHttpClient.layerUndici))
160
+
161
+ describe("Atlassian CLI login", () => {
162
+ it.live("keeps waiting for the callback when no browser launcher succeeds", () =>
163
+ withHome(Effect.gen(function*() {
164
+ yield* seedConfig
165
+ const launched = yield* Deferred.make<string>()
166
+ const attempts = yield* Ref.make<ReadonlyArray<string>>([])
167
+ const lines: Array<string> = []
168
+ const stopped = yield* Ref.make(false)
169
+
170
+ const login = yield* makeAtlassianCliAuth(authOptions).pipe(
171
+ Effect.flatMap((auth) => auth.login()),
172
+ Effect.provide(Layer.mergeAll(
173
+ NodeServices.layer,
174
+ HomeDirectoryLive,
175
+ atlassianClient,
176
+ failingLaunchers(launched, attempts),
177
+ observedServerFactory(stopped)
178
+ )),
179
+ Effect.provideService(Console.Console, captureConsole(lines)),
180
+ Effect.forkChild({ startImmediately: true })
181
+ )
182
+
183
+ const authUrl = yield* Deferred.await(launched)
184
+ yield* deliverCallback(authUrl)
185
+ const result = yield* Fiber.join(login)
186
+
187
+ expect(result).toBeUndefined()
188
+ expect(yield* Ref.get(attempts)).toEqual(["open", "xdg-open", "rundll32.exe"])
189
+ const printed = lines
190
+ expect(printed.some((line) => line.includes(`visit: ${authUrl}`))).toBe(true)
191
+ expect(printed).toContain("stderr: Could not open a browser (rundll32.exe exited 1); visit the URL above.")
192
+ expect(printed).toContain("Logged in as Ada (ada@example.com)")
193
+ expect(yield* Ref.get(stopped)).toBe(true)
194
+ const stored = yield* loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
195
+ expect(stored?.cloud_id).toBe("cloud-1")
196
+ })))
197
+
198
+ it.effect("fails before advertising a URL when the callback server cannot bind", () =>
199
+ withHome(Effect.gen(function*() {
200
+ yield* seedConfig
201
+ const launched = yield* Deferred.make<string>()
202
+ const attempts = yield* Ref.make<ReadonlyArray<string>>([])
203
+ const lines: Array<string> = []
204
+ const refused = makeHttpServerFactory(() =>
205
+ Layer.effect(
206
+ HttpServer.HttpServer,
207
+ Effect.fail(new HttpServerError.ServeError({ cause: { code: "EACCES" } }))
208
+ )
209
+ )
210
+
211
+ const error = yield* makeAtlassianCliAuth(authOptions).pipe(
212
+ Effect.flatMap((auth) => auth.login()),
213
+ Effect.provide(
214
+ Layer.mergeAll(
215
+ NodeServices.layer,
216
+ HomeDirectoryLive,
217
+ atlassianClient,
218
+ failingLaunchers(launched, attempts),
219
+ refused
220
+ )
221
+ ),
222
+ Effect.provideService(Console.Console, captureConsole(lines)),
223
+ Effect.flip
224
+ )
225
+
226
+ expect(error._tag).toBe("OAuthError")
227
+ expect(yield* Ref.get(attempts)).toEqual([])
228
+ expect(lines).toEqual([])
229
+ })))
230
+
231
+ it.effect("stops the callback server when authorization times out", () =>
232
+ withHome(Effect.gen(function*() {
233
+ yield* seedConfig
234
+ const launched = yield* Deferred.make<string>()
235
+ const attempts = yield* Ref.make<ReadonlyArray<string>>([])
236
+ const lines: Array<string> = []
237
+ const stopped = yield* Ref.make(false)
238
+
239
+ const login = yield* makeAtlassianCliAuth(authOptions).pipe(
240
+ Effect.flatMap((auth) => auth.login()),
241
+ Effect.provide(Layer.mergeAll(
242
+ NodeServices.layer,
243
+ HomeDirectoryLive,
244
+ atlassianClient,
245
+ failingLaunchers(launched, attempts),
246
+ observedServerFactory(stopped)
247
+ )),
248
+ Effect.provideService(Console.Console, captureConsole(lines)),
249
+ Effect.flip,
250
+ Effect.forkChild({ startImmediately: true })
251
+ )
252
+
253
+ yield* Deferred.await(launched)
254
+ // The deadline is registered just after the launch; step the clock past it
255
+ // in increments so it fires wherever in that window it was set.
256
+ for (let minute = 0; minute < 10; minute++) {
257
+ yield* TestClock.adjust("1 minute")
258
+ yield* Effect.yieldNow
259
+ }
260
+ const error = yield* Fiber.join(login)
261
+
262
+ expect(error._tag).toBe("OAuthError")
263
+ expect(String(error.cause)).toBe("Authorization timed out")
264
+ expect(yield* Ref.get(stopped)).toBe(true)
265
+ })))
266
+ // With several sites, the login either lists them for the user to pick from
267
+ // or uses the one named by --site; an unknown --site fails and lists them.
268
+ const loginWithSites = (siteUrl: string | undefined) =>
269
+ Effect.gen(function*() {
270
+ yield* seedConfig
271
+ const launched = yield* Deferred.make<string>()
272
+ const attempts = yield* Ref.make<ReadonlyArray<string>>([])
273
+ const stopped = yield* Ref.make(false)
274
+ const lines: Array<string> = []
275
+ const login = yield* makeAtlassianCliAuth(authOptions).pipe(
276
+ Effect.flatMap((auth) => auth.login(siteUrl === undefined ? undefined : { siteUrl })),
277
+ Effect.provide(Layer.mergeAll(
278
+ NodeServices.layer,
279
+ HomeDirectoryLive,
280
+ atlassianClientWith([firstSite, secondSite]),
281
+ failingLaunchers(launched, attempts),
282
+ observedServerFactory(stopped)
283
+ )),
284
+ Effect.provideService(Console.Console, captureConsole(lines)),
285
+ Effect.result,
286
+ Effect.forkChild({ startImmediately: true })
287
+ )
288
+ yield* deliverCallback(yield* Deferred.await(launched))
289
+ const result = yield* Fiber.join(login)
290
+ const stored = yield* loadActiveProfileToken(TOOL).pipe(Effect.provide(storage))
291
+ return { result, stored }
292
+ })
293
+
294
+ it.live("lists the sites when several are available and none was chosen", () =>
295
+ withHome(Effect.gen(function*() {
296
+ const { result, stored } = yield* loginWithSites(undefined)
297
+ expect(result).toMatchObject({
298
+ success: [
299
+ { id: "cloud-1", name: "Site", url: "https://site.atlassian.net" },
300
+ { id: "cloud-2", name: "Other", url: "https://other.atlassian.net" }
301
+ ]
302
+ })
303
+ expect(stored).toBeNull()
304
+ })))
305
+
306
+ it.live("logs into the site chosen with --site", () =>
307
+ withHome(Effect.gen(function*() {
308
+ const { stored } = yield* loginWithSites("https://other.atlassian.net")
309
+ expect(stored?.cloud_id).toBe("cloud-2")
310
+ })))
311
+
312
+ it.live("fails and lists the sites when --site names none of them", () =>
313
+ withHome(Effect.gen(function*() {
314
+ const { result, stored } = yield* loginWithSites("https://missing.atlassian.net")
315
+ expect(result).toMatchObject({ failure: { _tag: "OAuthError", step: "authorize" } })
316
+ expect(String(result._tag === "Failure" ? result.failure.cause : "")).toContain("https://other.atlassian.net")
317
+ expect(stored).toBeNull()
318
+ })))
319
+ })
@@ -0,0 +1,276 @@
1
+ /** @effect-diagnostics strictEffectProvide:skip-file */
2
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
3
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
4
+ import { describe, expect, it } from "@effect/vitest"
5
+ import * as Cause from "effect/Cause"
6
+ import * as Effect from "effect/Effect"
7
+ import * as Fiber from "effect/Fiber"
8
+ import { HttpClient, HttpClientRequest, HttpServer, HttpServerError } from "effect/http"
9
+ import * as Layer from "effect/Layer"
10
+ import { NetAddress } from "effect/net"
11
+ import * as Predicate from "effect/Predicate"
12
+ import * as Ref from "effect/Ref"
13
+ import { createServer } from "node:http"
14
+ import { HttpServerFactoryLive } from "../src/cli-auth/internal/NodeLayers.js"
15
+ import {
16
+ callbackServerListenOptions,
17
+ callbackUrl,
18
+ HttpServerFactoryTag,
19
+ makeHttpServerFactory,
20
+ startCallbackServer
21
+ } from "../src/cli-auth/internal/oauthServer.js"
22
+
23
+ const HttpClientLive = NodeHttpClient.layerUndici
24
+ const EphemeralHttpServerFactoryLive = makeHttpServerFactory(
25
+ () => NodeHttpServer.layerServer(createServer, { port: 0 })
26
+ )
27
+ const fakeHttpServer = (port: number): HttpServer.HttpServer["Service"] =>
28
+ HttpServer.make({
29
+ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", port),
30
+ serve: () => Effect.void
31
+ })
32
+
33
+ describe("oauthServer", () => {
34
+ describe("startCallbackServer", () => {
35
+ it.effect("binds the same localhost name advertised by the callback URL", () =>
36
+ Effect.scoped(
37
+ Effect.gen(function*() {
38
+ const listenOptions = callbackServerListenOptions(0)
39
+ const factory = yield* HttpServerFactoryTag
40
+ const server = yield* HttpServer.HttpServer.pipe(
41
+ Effect.provide(factory.createServerLayer(listenOptions))
42
+ )
43
+ const advertisedUrl = new URL(callbackUrl(8585))
44
+ const ipv6OnlyLocalhost = (hostname: string) => (hostname === "localhost" ? "::1" : undefined)
45
+ expect(NetAddress.isInetAddress(server.address)).toBe(true)
46
+ expect(listenOptions.host).toBe(advertisedUrl.hostname)
47
+ expect(ipv6OnlyLocalhost(listenOptions.host)).toBe("::1")
48
+ expect(ipv6OnlyLocalhost(advertisedUrl.hostname)).toBe("::1")
49
+ }).pipe(Effect.provide(HttpServerFactoryLive))
50
+ ))
51
+
52
+ it.effect("starts server and returns port", () =>
53
+ Effect.scoped(
54
+ Effect.gen(function*() {
55
+ const expectedState = "test-state-123"
56
+ const result = yield* startCallbackServer(expectedState)
57
+
58
+ expect(result.port).toBeGreaterThan(0)
59
+ expect(Predicate.isObject(result.codePromise)).toBe(true)
60
+ }).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
61
+ ))
62
+
63
+ it.effect("handles successful callback with code", () =>
64
+ Effect.scoped(
65
+ Effect.gen(function*() {
66
+ const expectedState = "test-state-456"
67
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
68
+
69
+ // Make callback request in background
70
+ const codeReceiver = yield* Effect.forkChild(codePromise)
71
+
72
+ // Simulate OAuth callback using Effect HttpClient
73
+ const client = yield* HttpClient.HttpClient
74
+ const request = HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
75
+ HttpClientRequest.setUrlParam("code", "auth_code_123"),
76
+ HttpClientRequest.setUrlParam("state", expectedState)
77
+ )
78
+ yield* client.execute(request)
79
+
80
+ const code = yield* Fiber.join(codeReceiver)
81
+ expect(code).toBe("auth_code_123")
82
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
83
+ ))
84
+
85
+ it.effect("releases the callback port when the owning workflow fails", () =>
86
+ Effect.gen(function*() {
87
+ yield* Effect.scoped(
88
+ startCallbackServer("first").pipe(
89
+ Effect.provide(EphemeralHttpServerFactoryLive),
90
+ Effect.andThen(Effect.fail("browser-open-failed")),
91
+ Effect.flip
92
+ )
93
+ )
94
+
95
+ const port = yield* Effect.scoped(
96
+ startCallbackServer("second").pipe(
97
+ Effect.provide(EphemeralHttpServerFactoryLive),
98
+ Effect.map((server) => server.port)
99
+ )
100
+ )
101
+ expect(port).toBeGreaterThan(0)
102
+ }))
103
+
104
+ it.effect("starts concurrent real callback servers on distinct OS-assigned ports", () =>
105
+ Effect.scoped(
106
+ Effect.gen(function*() {
107
+ const [first, second] = yield* Effect.all(
108
+ [startCallbackServer("first"), startCallbackServer("second")],
109
+ { concurrency: "unbounded" }
110
+ )
111
+ expect(first.port).toBeGreaterThan(0)
112
+ expect(second.port).toBeGreaterThan(0)
113
+ expect(first.port).not.toBe(second.port)
114
+ }).pipe(Effect.provide(EphemeralHttpServerFactoryLive))
115
+ ))
116
+
117
+ it.effect("retries occupied fixed-range ports with the injected factory", () => {
118
+ const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
119
+ const occupiedFactory = makeHttpServerFactory(({ port }) =>
120
+ Layer.effect(
121
+ HttpServer.HttpServer,
122
+ Ref.update(attempts, (ports) => [...ports, port]).pipe(
123
+ Effect.andThen(
124
+ port < 8587
125
+ ? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
126
+ : Effect.succeed(fakeHttpServer(port))
127
+ )
128
+ )
129
+ )
130
+ )
131
+
132
+ return Effect.scoped(
133
+ Effect.gen(function*() {
134
+ const server = yield* startCallbackServer("state")
135
+ expect(server.port).toBe(8587)
136
+ expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
137
+ }).pipe(Effect.provide(occupiedFactory))
138
+ )
139
+ })
140
+
141
+ it.effect("maps a multiply retried server failure to one OAuth error", () => {
142
+ const attempts = Ref.makeUnsafe<ReadonlyArray<number>>([])
143
+ const terminalFailure = new HttpServerError.ServeError({ cause: { code: "EACCES" } })
144
+ const failingFactory = makeHttpServerFactory(({ port }) =>
145
+ Layer.effect(
146
+ HttpServer.HttpServer,
147
+ Ref.update(attempts, (ports) => [...ports, port]).pipe(
148
+ Effect.andThen(
149
+ port < 8587
150
+ ? Effect.fail(new HttpServerError.ServeError({ cause: { code: "EADDRINUSE" } }))
151
+ : Effect.fail(terminalFailure)
152
+ )
153
+ )
154
+ )
155
+ )
156
+
157
+ return Effect.scoped(
158
+ Effect.gen(function*() {
159
+ const error = yield* startCallbackServer("state").pipe(Effect.flip)
160
+ expect(error.cause).toBe(terminalFailure)
161
+ expect(error.message.split("OAuth authorize failed:").length - 1).toBe(1)
162
+ expect(yield* Ref.get(attempts)).toEqual([8585, 8586, 8587])
163
+ }).pipe(Effect.provide(failingFactory))
164
+ )
165
+ })
166
+
167
+ it.effect("does not retry a non-address-in-use server failure", () => {
168
+ const attempts = Ref.makeUnsafe(0)
169
+ const failingFactory = makeHttpServerFactory(() =>
170
+ Layer.effect(
171
+ HttpServer.HttpServer,
172
+ Ref.update(attempts, (count) => count + 1).pipe(
173
+ Effect.andThen(Effect.fail(
174
+ new HttpServerError.ServeError({
175
+ cause: { code: "ECONNREFUSED" }
176
+ })
177
+ ))
178
+ )
179
+ )
180
+ )
181
+
182
+ return Effect.scoped(
183
+ startCallbackServer("state").pipe(
184
+ Effect.provide(failingFactory),
185
+ Effect.flip,
186
+ Effect.tap(() => Ref.get(attempts).pipe(Effect.map((count) => expect(count).toBe(1))))
187
+ )
188
+ )
189
+ })
190
+
191
+ it.effect("ignores a forged error callback before accepting the legitimate state", () =>
192
+ Effect.scoped(
193
+ Effect.gen(function*() {
194
+ const expectedState = "expected-state"
195
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
196
+ const codeReceiver = yield* Effect.forkChild(codePromise)
197
+ const client = yield* HttpClient.HttpClient
198
+
199
+ const forgedResponse = yield* client.execute(
200
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
201
+ HttpClientRequest.setUrlParam("error", "access_denied")
202
+ )
203
+ )
204
+ expect(forgedResponse.status).toBe(403)
205
+ yield* client.execute(
206
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
207
+ HttpClientRequest.setUrlParam("code", "legitimate-code"),
208
+ HttpClientRequest.setUrlParam("state", expectedState)
209
+ )
210
+ )
211
+
212
+ expect(yield* Fiber.join(codeReceiver)).toBe("legitimate-code")
213
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
214
+ ))
215
+
216
+ it.effect("accepts a provider error only after validating state", () =>
217
+ Effect.scoped(
218
+ Effect.gen(function*() {
219
+ const expectedState = "expected-state"
220
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
221
+ const codeReceiver = yield* Effect.forkChild(codePromise)
222
+ const client = yield* HttpClient.HttpClient
223
+
224
+ yield* client.execute(
225
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
226
+ HttpClientRequest.setUrlParam("error", "access_denied"),
227
+ HttpClientRequest.setUrlParam("state", expectedState)
228
+ )
229
+ )
230
+
231
+ const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
232
+ expect(String(error.cause)).toContain("access_denied")
233
+ expect(error.message).toBe("OAuth authorize failed: access_denied")
234
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
235
+ ))
236
+ it.effect("reports the provider error code when its description is empty", () =>
237
+ Effect.scoped(
238
+ Effect.gen(function*() {
239
+ const expectedState = "expected-state"
240
+ const { codePromise, port } = yield* startCallbackServer(expectedState)
241
+ const codeReceiver = yield* Effect.forkChild(codePromise)
242
+ const client = yield* HttpClient.HttpClient
243
+
244
+ yield* client.execute(
245
+ HttpClientRequest.get(`http://127.0.0.1:${port}/callback`).pipe(
246
+ HttpClientRequest.setUrlParam("error", "access_denied"),
247
+ HttpClientRequest.setUrlParam("error_description", ""),
248
+ HttpClientRequest.setUrlParam("state", expectedState)
249
+ )
250
+ )
251
+
252
+ const error = yield* Fiber.join(codeReceiver).pipe(Effect.flip)
253
+ expect(error.cause).toBe("access_denied")
254
+ }).pipe(Effect.provide(Layer.mergeAll(EphemeralHttpServerFactoryLive, HttpClientLive)))
255
+ ))
256
+ it.effect("fails promptly when the handler cannot be installed", () =>
257
+ Effect.scoped(
258
+ Effect.gen(function*() {
259
+ const exit = yield* startCallbackServer("state").pipe(
260
+ Effect.provide(makeHttpServerFactory(() =>
261
+ Layer.succeed(
262
+ HttpServer.HttpServer,
263
+ HttpServer.make({
264
+ address: NetAddress.inetAddressFromIpStringUnsafe("127.0.0.1", 8585),
265
+ serve: () => Effect.die("handler-install-failed")
266
+ })
267
+ )
268
+ )),
269
+ Effect.exit
270
+ )
271
+ expect(exit._tag).toBe("Failure")
272
+ expect(String(exit._tag === "Failure" ? Cause.squash(exit.cause) : "")).toContain("handler-install-failed")
273
+ })
274
+ ))
275
+ })
276
+ })