@knpkv/atlassian-common 1.5.0 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +31 -0
- package/dist/cli-auth/AtlassianCliAuth.d.ts +102 -0
- package/dist/cli-auth/AtlassianCliAuth.d.ts.map +1 -0
- package/dist/cli-auth/AtlassianCliAuth.js +298 -0
- package/dist/cli-auth/AtlassianCliAuth.js.map +1 -0
- package/dist/cli-auth/index.d.ts +13 -0
- package/dist/cli-auth/index.d.ts.map +1 -0
- package/dist/cli-auth/index.js +13 -0
- package/dist/cli-auth/index.js.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts +5 -0
- package/dist/cli-auth/internal/NodeLayers.d.ts.map +1 -0
- package/dist/cli-auth/internal/NodeLayers.js +17 -0
- package/dist/cli-auth/internal/NodeLayers.js.map +1 -0
- package/dist/cli-auth/internal/oauthServer.d.ts +54 -0
- package/dist/cli-auth/internal/oauthServer.d.ts.map +1 -0
- package/dist/cli-auth/internal/oauthServer.js +94 -0
- package/dist/cli-auth/internal/oauthServer.js.map +1 -0
- package/dist/cli-auth/openBrowser.d.ts +16 -0
- package/dist/cli-auth/openBrowser.d.ts.map +1 -0
- package/dist/cli-auth/openBrowser.js +30 -0
- package/dist/cli-auth/openBrowser.js.map +1 -0
- package/package.json +5 -1
- package/src/cli-auth/AtlassianCliAuth.ts +530 -0
- package/src/cli-auth/index.ts +18 -0
- package/src/cli-auth/internal/NodeLayers.ts +25 -0
- package/src/cli-auth/internal/oauthServer.ts +168 -0
- package/src/cli-auth/openBrowser.ts +47 -0
- package/test/cliAuth.guardrails.test.ts +76 -0
- package/test/cliAuth.login.test.ts +319 -0
- package/test/cliAuth.oauthServer.test.ts +276 -0
- package/test/cliAuth.openBrowser.test.ts +70 -0
- package/test/cliAuth.refresh.test.ts +304 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local HTTP callback server for OAuth2 authorization code capture.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Scope-owned lifecycle**: {@link startCallbackServer} returns a `codePromise`
|
|
7
|
+
* (Deferred). The server validates the CSRF `state` parameter, resolves the
|
|
8
|
+
* Deferred with the authorization code, and stops when its enclosing scope closes.
|
|
9
|
+
* - **Port auto-discovery**: Tries default port 8585, increments on conflict up to 8594.
|
|
10
|
+
* - **Ready before return**: the handler is installed before the port is handed back,
|
|
11
|
+
* so the caller never advertises a callback URL nothing answers.
|
|
12
|
+
*
|
|
13
|
+
* @internal
|
|
14
|
+
*/
|
|
15
|
+
import * as Context from "effect/Context"
|
|
16
|
+
import * as Deferred from "effect/Deferred"
|
|
17
|
+
import * as Effect from "effect/Effect"
|
|
18
|
+
import { HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/http"
|
|
19
|
+
import type * as HttpServerError from "effect/http/HttpServerError"
|
|
20
|
+
import * as Layer from "effect/Layer"
|
|
21
|
+
import { NetAddress } from "effect/net"
|
|
22
|
+
import * as Schema from "effect/Schema"
|
|
23
|
+
import * as Scope from "effect/Scope"
|
|
24
|
+
import { OAuthError } from "../../auth/OAuthErrors.js"
|
|
25
|
+
|
|
26
|
+
const DEFAULT_PORT = 8585
|
|
27
|
+
const MAX_PORT = 8594
|
|
28
|
+
type HttpServerInstance = Effect.Success<typeof HttpServer.HttpServer>
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Creates the platform HTTP server for one listen attempt. The Node adapter is
|
|
32
|
+
* {@link HttpServerFactoryLive}; tests substitute ephemeral or failing servers.
|
|
33
|
+
*/
|
|
34
|
+
export interface HttpServerFactory {
|
|
35
|
+
readonly createServerLayer: (options: CallbackServerListenOptions) => Layer.Layer<
|
|
36
|
+
HttpServer.HttpServer,
|
|
37
|
+
HttpServerError.ServeError,
|
|
38
|
+
never
|
|
39
|
+
>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface CallbackServerListenOptions {
|
|
43
|
+
readonly host: "localhost"
|
|
44
|
+
readonly port: number
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const callbackServerListenOptions = (port: number): CallbackServerListenOptions => ({
|
|
48
|
+
host: "localhost",
|
|
49
|
+
port
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
export const callbackUrl = (port: number): string => `http://localhost:${port}/callback`
|
|
53
|
+
|
|
54
|
+
export class HttpServerFactoryTag extends Context.Service<
|
|
55
|
+
HttpServerFactoryTag,
|
|
56
|
+
HttpServerFactory
|
|
57
|
+
>()("@knpkv/atlassian-common/cli-auth/HttpServerFactory") {}
|
|
58
|
+
|
|
59
|
+
/** Builds a {@link HttpServerFactoryTag} layer from a per-attempt server layer. */
|
|
60
|
+
export const makeHttpServerFactory = (
|
|
61
|
+
createLayerFn: (
|
|
62
|
+
options: CallbackServerListenOptions
|
|
63
|
+
) => Layer.Layer<HttpServer.HttpServer, HttpServerError.ServeError, never>
|
|
64
|
+
): Layer.Layer<HttpServerFactoryTag> =>
|
|
65
|
+
Layer.succeed(HttpServerFactoryTag, {
|
|
66
|
+
createServerLayer: createLayerFn
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
export interface CallbackServerResult {
|
|
70
|
+
/** Resolves with the authorization code, or fails with the provider's error. */
|
|
71
|
+
readonly codePromise: Effect.Effect<string, OAuthError>
|
|
72
|
+
/** The port the server is listening on */
|
|
73
|
+
readonly port: number
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const AddressInUseCause = Schema.Struct({
|
|
77
|
+
code: Schema.Literal("EADDRINUSE")
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const isAddressInUse = (error: HttpServerError.ServeError): boolean => Schema.is(AddressInUseCause)(error.cause)
|
|
81
|
+
|
|
82
|
+
const page = (heading: string, body: string) =>
|
|
83
|
+
HttpServerResponse.html(`<html><body><h1>${heading}</h1><p>${body}</p></body></html>`)
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Start a local HTTP server to receive the OAuth callback for `expectedState`.
|
|
87
|
+
*
|
|
88
|
+
* Fails with one `OAuthError` (step `authorize`) when no port in range can be
|
|
89
|
+
* bound; the server stops when the enclosing scope closes.
|
|
90
|
+
*/
|
|
91
|
+
export const startCallbackServer = (
|
|
92
|
+
expectedState: string
|
|
93
|
+
): Effect.Effect<CallbackServerResult, OAuthError, HttpServerFactoryTag | Scope.Scope> =>
|
|
94
|
+
Effect.gen(function*() {
|
|
95
|
+
const factory = yield* HttpServerFactoryTag
|
|
96
|
+
const deferred = yield* Deferred.make<string, OAuthError>()
|
|
97
|
+
const scope = yield* Effect.scope
|
|
98
|
+
|
|
99
|
+
// Map to OAuthError once, outside the retry, so a run of occupied ports
|
|
100
|
+
// does not nest one OAuthError inside another.
|
|
101
|
+
const buildServer = (port: number): Effect.Effect<HttpServerInstance, HttpServerError.ServeError> =>
|
|
102
|
+
Layer.build(factory.createServerLayer(callbackServerListenOptions(port))).pipe(
|
|
103
|
+
Scope.provide(scope),
|
|
104
|
+
Effect.map((context) => Context.get(context, HttpServer.HttpServer)),
|
|
105
|
+
Effect.catchIf(
|
|
106
|
+
(error) => isAddressInUse(error) && port < MAX_PORT,
|
|
107
|
+
() => buildServer(port + 1)
|
|
108
|
+
)
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
const server = yield* buildServer(DEFAULT_PORT).pipe(
|
|
112
|
+
Effect.mapError((cause) => new OAuthError({ step: "authorize", cause }))
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if (!NetAddress.isInetAddress(server.address)) {
|
|
116
|
+
return yield* new OAuthError({ step: "authorize", cause: "OAuth callback server must listen on a TCP port" })
|
|
117
|
+
}
|
|
118
|
+
const port = server.address.port
|
|
119
|
+
|
|
120
|
+
const router = yield* HttpRouter.make
|
|
121
|
+
yield* router.add(
|
|
122
|
+
"GET",
|
|
123
|
+
"/callback",
|
|
124
|
+
Effect.gen(function*() {
|
|
125
|
+
const req = yield* HttpServerRequest.HttpServerRequest
|
|
126
|
+
const url = new URL(req.url, callbackUrl(port))
|
|
127
|
+
const code = url.searchParams.get("code")
|
|
128
|
+
const state = url.searchParams.get("state")
|
|
129
|
+
const error = url.searchParams.get("error")
|
|
130
|
+
const errorDescription = url.searchParams.get("error_description")
|
|
131
|
+
|
|
132
|
+
if (state !== expectedState) {
|
|
133
|
+
return page("Security Error", "State verification failed.").pipe(HttpServerResponse.setStatus(403))
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (error !== null && error !== "") {
|
|
137
|
+
// An empty description is no description: report the error code instead.
|
|
138
|
+
yield* Deferred.fail(
|
|
139
|
+
deferred,
|
|
140
|
+
new OAuthError({
|
|
141
|
+
step: "authorize",
|
|
142
|
+
cause: errorDescription === null || errorDescription === "" ? error : errorDescription
|
|
143
|
+
})
|
|
144
|
+
)
|
|
145
|
+
return page("Authorization Failed", "You can close this window.")
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (code === null || code === "") {
|
|
149
|
+
yield* Deferred.fail(deferred, new OAuthError({ step: "authorize", cause: "No authorization code received" }))
|
|
150
|
+
return page("Error", "No authorization code received.")
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
yield* Deferred.succeed(deferred, code)
|
|
154
|
+
return page("Success!", "You can close this window and return to the terminal.")
|
|
155
|
+
})
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
// Installs the handler in this scope and returns; a failure to install
|
|
159
|
+
// surfaces here rather than leaving the caller waiting on a dead server.
|
|
160
|
+
yield* HttpServer.serveEffect(router.asHttpEffect()).pipe(
|
|
161
|
+
Effect.provideService(HttpServer.HttpServer, server)
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
codePromise: Deferred.await(deferred),
|
|
166
|
+
port
|
|
167
|
+
}
|
|
168
|
+
})
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-platform browser launcher backed by Effect child process services.
|
|
3
|
+
*
|
|
4
|
+
* Tries `open`, then `xdg-open`, then `rundll32.exe`, stopping at the first that
|
|
5
|
+
* exits 0. A launcher that is missing or exits non-zero falls through to the next;
|
|
6
|
+
* when none succeeds the last failure is reported, typed, so the caller decides
|
|
7
|
+
* whether a browser that never opened is fatal.
|
|
8
|
+
*
|
|
9
|
+
* @module
|
|
10
|
+
*/
|
|
11
|
+
import * as Data from "effect/Data"
|
|
12
|
+
import * as Effect from "effect/Effect"
|
|
13
|
+
import type * as PlatformError from "effect/PlatformError"
|
|
14
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process"
|
|
15
|
+
|
|
16
|
+
/** A launcher ran but reported failure (for example `xdg-open` with no browser on a headless host). */
|
|
17
|
+
export class BrowserOpenError extends Data.TaggedError("BrowserOpenError")<{
|
|
18
|
+
readonly command: string
|
|
19
|
+
readonly exitCode: number
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
const run = (
|
|
23
|
+
command: string,
|
|
24
|
+
args: ReadonlyArray<string>
|
|
25
|
+
): Effect.Effect<void, BrowserOpenError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
26
|
+
Effect.gen(function*() {
|
|
27
|
+
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
|
|
28
|
+
const exitCode = yield* spawner.exitCode(
|
|
29
|
+
ChildProcess.make(command, args, {
|
|
30
|
+
stdin: "ignore",
|
|
31
|
+
stdout: "ignore",
|
|
32
|
+
stderr: "ignore"
|
|
33
|
+
})
|
|
34
|
+
)
|
|
35
|
+
if (exitCode !== 0) {
|
|
36
|
+
return yield* new BrowserOpenError({ command, exitCode })
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** Open `url` in the user's browser. */
|
|
41
|
+
export const openBrowser = (
|
|
42
|
+
url: string
|
|
43
|
+
): Effect.Effect<void, BrowserOpenError | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner> =>
|
|
44
|
+
run("open", [url]).pipe(
|
|
45
|
+
Effect.catch(() => run("xdg-open", [url])),
|
|
46
|
+
Effect.catch(() => run("rundll32.exe", ["url.dll,FileProtocolHandler", url]))
|
|
47
|
+
)
|
|
@@ -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
|
+
})
|