@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,530 @@
1
+ /**
2
+ * OAuth2 login, token refresh and profile management for an Atlassian CLI.
3
+ *
4
+ * **Mental model**
5
+ *
6
+ * - **One flow, many CLIs**: `jira` and `confluence` differ only in the storage
7
+ * namespace, scopes, the names in their messages and their "not logged in"
8
+ * error. {@link makeAtlassianCliAuth} takes those as options; each CLI keeps
9
+ * its own service tag and public interface and delegates to this.
10
+ * - **Refresh lock**: a `Ref<Option<Deferred>>` prevents concurrent refreshes —
11
+ * the first caller refreshes, the others await the same Deferred.
12
+ * - **Browser-based login**: starts a local callback server, opens the browser,
13
+ * and awaits the OAuth code with a 5-minute timeout. A browser that will not
14
+ * open is reported on stderr and the wait continues: the URL is printed first.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const layer = Layer.effect(
19
+ * JiraAuth,
20
+ * makeAtlassianCliAuth({ toolName: "jira-cli", commandName: "jira", productName: "Jira", scopes, authMissing })
21
+ * ).pipe(Layer.provide(NodeCliAuthLive))
22
+ * ```
23
+ *
24
+ * @module
25
+ */
26
+ import * as Clock from "effect/Clock"
27
+ import * as Console from "effect/Console"
28
+ import * as Crypto from "effect/Crypto"
29
+ import * as Deferred from "effect/Deferred"
30
+ import * as Effect from "effect/Effect"
31
+ import * as FileSystem from "effect/FileSystem"
32
+ import * as HttpClient from "effect/http/HttpClient"
33
+ import * as Option from "effect/Option"
34
+ import * as Path from "effect/Path"
35
+ import type * as PlatformError from "effect/PlatformError"
36
+ import { ChildProcessSpawner } from "effect/process"
37
+ import * as Redacted from "effect/Redacted"
38
+ import * as Ref from "effect/Ref"
39
+ import { buildAuthUrl, computeCodeChallenge, generateCodeVerifier } from "../auth/OAuthEndpoints.js"
40
+ import { OAuthError } from "../auth/OAuthErrors.js"
41
+ import {
42
+ buildOAuthTokenAt,
43
+ exchangeCodeForTokens,
44
+ getAccessibleResources,
45
+ getUserInfo,
46
+ refreshToken,
47
+ revokeToken
48
+ } from "../auth/OAuthOperations.js"
49
+ import { generateUUID } from "../auth/uuid.js"
50
+ import type { AuthProfile } from "../config/AuthProfiles.js"
51
+ import {
52
+ deleteActiveProfile,
53
+ deleteProfileBySelector,
54
+ loadActiveProfile,
55
+ loadActiveProfileToken,
56
+ loadProfiles,
57
+ saveProfileToken,
58
+ setActiveProfileBySelector
59
+ } from "../config/AuthProfiles.js"
60
+ import type { HomeDirectoryError } from "../config/ConfigPaths.js"
61
+ import { HomeDirectoryTag } from "../config/ConfigPaths.js"
62
+ import type { OAuthConfig, OAuthToken, OAuthUser } from "../config/OAuthSchemas.js"
63
+ import type { FileSystemError } from "../config/TokenStorage.js"
64
+ import { isTokenExpiredAt, loadOAuthConfig, saveOAuthConfig } from "../config/TokenStorage.js"
65
+ import { callbackUrl, HttpServerFactoryTag, startCallbackServer } from "./internal/oauthServer.js"
66
+ import { openBrowser } from "./openBrowser.js"
67
+
68
+ type StorageError = FileSystemError | HomeDirectoryError | PlatformError.PlatformError
69
+
70
+ /**
71
+ * What distinguishes one Atlassian CLI's auth from another's.
72
+ *
73
+ * @category Types
74
+ */
75
+ export interface AtlassianCliAuthOptions<MissingError> {
76
+ /** Storage namespace under `~/.config/atlassian/` (e.g. `"jira-cli"`). */
77
+ readonly toolName: string
78
+ /** Binary name used in hints such as "Run 'jira auth login'". */
79
+ readonly commandName: string
80
+ /** Product name used in "No Jira sites found for this account". */
81
+ readonly productName: string
82
+ /** OAuth scopes requested at login. */
83
+ readonly scopes: ReadonlyArray<string>
84
+ /** The CLI's own "not logged in" error, so callers keep catching their tag. */
85
+ readonly authMissing: () => MissingError
86
+ /**
87
+ * Consulted only when the tool has no stored OAuth config; a config it returns
88
+ * is saved under {@link toolName}, migrating it.
89
+ */
90
+ readonly legacyOAuthConfig?: Effect.Effect<
91
+ OAuthConfig | null,
92
+ FileSystemError | HomeDirectoryError,
93
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
94
+ >
95
+ }
96
+
97
+ /**
98
+ * Options for the login method.
99
+ *
100
+ * @category Types
101
+ */
102
+ export interface LoginOptions {
103
+ /** Site URL to select (for accounts with multiple sites) */
104
+ readonly siteUrl?: string
105
+ }
106
+
107
+ /**
108
+ * Information about an accessible Atlassian site.
109
+ *
110
+ * @category Types
111
+ */
112
+ export interface AccessibleSite {
113
+ readonly id: string
114
+ readonly name: string
115
+ readonly url: string
116
+ }
117
+
118
+ /**
119
+ * The auth operations an Atlassian CLI exposes.
120
+ *
121
+ * @category Types
122
+ */
123
+ export interface AtlassianCliAuth<MissingError> {
124
+ /** Configure OAuth client credentials */
125
+ readonly configure: (config: OAuthConfig) => Effect.Effect<void, StorageError>
126
+ /** Check if OAuth is configured */
127
+ readonly isConfigured: () => Effect.Effect<boolean, StorageError>
128
+ /** Start OAuth login flow. Returns list of sites if multiple are available and none was chosen. */
129
+ readonly login: (
130
+ options?: LoginOptions
131
+ ) => Effect.Effect<ReadonlyArray<AccessibleSite> | void, OAuthError | StorageError>
132
+ /** Revoke (best effort) and remove stored authentication */
133
+ readonly logout: () => Effect.Effect<void, OAuthError | StorageError>
134
+ /** Get access token, refreshing if needed */
135
+ readonly getAccessToken: () => Effect.Effect<Redacted.Redacted<string>, MissingError | OAuthError | StorageError>
136
+ /** Get cloud ID from stored token */
137
+ readonly getCloudId: () => Effect.Effect<string, MissingError | StorageError>
138
+ /** Get site URL from stored token */
139
+ readonly getSiteUrl: () => Effect.Effect<string, MissingError | StorageError>
140
+ /** Get current user info from stored token */
141
+ readonly getCurrentUser: () => Effect.Effect<OAuthUser | null, StorageError>
142
+ /** Get active auth profile */
143
+ readonly getActiveProfile: () => Effect.Effect<AuthProfile | null, StorageError>
144
+ /** List stored auth profiles */
145
+ readonly listProfiles: () => Effect.Effect<ReadonlyArray<AuthProfile>, StorageError>
146
+ /** Switch active profile by ID, name, site URL, cloud ID, or account ID */
147
+ readonly switchProfile: (selector: string) => Effect.Effect<AuthProfile | null, StorageError>
148
+ /** Remove stored profile by ID, name, site URL, cloud ID, or account ID */
149
+ readonly removeProfile: (selector: string) => Effect.Effect<AuthProfile | null, StorageError>
150
+ /** Check if user is logged in */
151
+ readonly isLoggedIn: () => Effect.Effect<boolean, StorageError>
152
+ }
153
+
154
+ type RefreshDeferred = Deferred.Deferred<OAuthToken, OAuthError | StorageError>
155
+
156
+ // Atlassian rotates refresh tokens: the response carries a replacement and the
157
+ // one we sent is consumed server-side. Interrupting between the round-trip and
158
+ // the save therefore destroys the credential — the next refresh fails and
159
+ // `getAccessToken` reacts by deleting the token file, so the user is silently
160
+ // logged out and has to log in again.
161
+ //
162
+ // Interruption is routine here, not hypothetical: this runs during layer
163
+ // construction on every CLI invocation, and jcf's nvim statusline kills the
164
+ // process on `VimLeave`. So the grant and the persist are atomic.
165
+ //
166
+ // The region carries its own deadline rather than relying on a caller's.
167
+ // `Effect.timeout` is a race, and racing an uninterruptible loser means waiting
168
+ // for it — an outer bound would go inert and, worse, an uninterruptible region
169
+ // with no deadline of its own absorbs SIGINT/SIGTERM entirely
170
+ // (`NodeRuntime.runMain`'s signal handlers only interrupt the main fiber),
171
+ // leaving a process that ignores Ctrl-C. The deadline forked inside the region
172
+ // is itself interruptible, so it does bound this.
173
+ //
174
+ // Abandoning the round-trip cannot prove the grant did not land: Atlassian may
175
+ // consume the refresh token and rotate it after we have stopped listening, and
176
+ // no client-side deadline changes that. So the deadline is paired with the rule
177
+ // in `getAccessToken` — a refresh that fails without a verdict never deletes the
178
+ // stored token. The credential survives to be retried, and only a real
179
+ // rejection ends the session.
180
+ const REFRESH_TIMEOUT = "30 seconds"
181
+
182
+ /**
183
+ * Build an Atlassian CLI's auth operations.
184
+ *
185
+ * Requires an `HttpClient`, a `ChildProcessSpawner` (browser launch), `Crypto`
186
+ * (PKCE), the callback-server factory, and the file system and home directory the
187
+ * tokens are stored under. `NodeCliAuthLive` supplies the last three on Node.
188
+ *
189
+ * @category Constructors
190
+ */
191
+ export const makeAtlassianCliAuth = Effect.fn("AtlassianCliAuth.make")(<MissingError>(
192
+ options: AtlassianCliAuthOptions<MissingError>
193
+ ): Effect.Effect<
194
+ AtlassianCliAuth<MissingError>,
195
+ never,
196
+ | HttpClient.HttpClient
197
+ | ChildProcessSpawner.ChildProcessSpawner
198
+ | Crypto.Crypto
199
+ | HttpServerFactoryTag
200
+ | FileSystem.FileSystem
201
+ | Path.Path
202
+ | HomeDirectoryTag
203
+ > =>
204
+ Effect.gen(function*() {
205
+ const { authMissing, commandName, productName, scopes, toolName } = options
206
+ const httpClient = yield* HttpClient.HttpClient
207
+ const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner
208
+ const cryptoService = yield* Crypto.Crypto
209
+ const serverFactory = yield* HttpServerFactoryTag
210
+
211
+ const withHttp = Effect.provideService(HttpClient.HttpClient, httpClient)
212
+ const withCrypto = Effect.provideService(Crypto.Crypto, cryptoService)
213
+ const fileSystem = yield* FileSystem.FileSystem
214
+ const path = yield* Path.Path
215
+ const homeDirectory = yield* HomeDirectoryTag
216
+ const withStorage = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
217
+ effect.pipe(
218
+ Effect.provideService(FileSystem.FileSystem, fileSystem),
219
+ Effect.provideService(Path.Path, path),
220
+ Effect.provideService(HomeDirectoryTag, homeDirectory)
221
+ )
222
+
223
+ const loadToken = () => loadActiveProfileToken(toolName).pipe(withStorage)
224
+ const saveToken = (token: OAuthToken) => saveProfileToken(toolName, token).pipe(withStorage)
225
+ const deleteToken = () => deleteActiveProfile(toolName).pipe(withStorage)
226
+ const saveConfig = (config: OAuthConfig) => saveOAuthConfig(toolName, config).pipe(withStorage)
227
+ const legacyOAuthConfig = options.legacyOAuthConfig
228
+ const loadConfig = Effect.fn("AtlassianCliAuth.loadConfig")(() =>
229
+ Effect.gen(function*() {
230
+ const config = yield* loadOAuthConfig(toolName)
231
+ if (config !== null || legacyOAuthConfig === undefined) return config
232
+ const legacy = yield* legacyOAuthConfig
233
+ if (legacy !== null) {
234
+ yield* saveOAuthConfig(toolName, legacy)
235
+ }
236
+ return legacy
237
+ }).pipe(withStorage)
238
+ )
239
+
240
+ const refreshLock = yield* Ref.make<Option.Option<RefreshDeferred>>(Option.none())
241
+
242
+ // The URL is printed before this runs, so a browser that will not open is
243
+ // reported and the wait for the callback continues.
244
+ const openBrowserOrWarn = Effect.fn("AtlassianCliAuth.openBrowserOrWarn")((url: string): Effect.Effect<void> =>
245
+ openBrowser(url).pipe(
246
+ Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner),
247
+ Effect.catchTags({
248
+ BrowserOpenError: (error) =>
249
+ Console.error(`Could not open a browser (${error.command} exited ${error.exitCode}); visit the URL above.`),
250
+ PlatformError: () => Console.error("Could not open a browser; visit the URL above.")
251
+ })
252
+ )
253
+ )
254
+
255
+ const getConfig = Effect.fn("AtlassianCliAuth.getConfig")((): Effect.Effect<
256
+ OAuthConfig,
257
+ OAuthError | StorageError
258
+ > =>
259
+ Effect.gen(function*() {
260
+ const config = yield* loadConfig()
261
+ if (config === null) {
262
+ return yield* new OAuthError({
263
+ step: "authorize",
264
+ cause: `OAuth not configured. Run '${commandName} auth configure' first.`
265
+ })
266
+ }
267
+ return config
268
+ })
269
+ )
270
+
271
+ const refreshAndPersist = Effect.fn("AtlassianCliAuth.refreshAndPersist")((
272
+ token: OAuthToken,
273
+ config: OAuthConfig
274
+ ): Effect.Effect<OAuthToken, OAuthError | StorageError> =>
275
+ Effect.uninterruptible(
276
+ Effect.gen(function*() {
277
+ const updated = yield* refreshToken(token, config).pipe(
278
+ withHttp,
279
+ Effect.timeout(REFRESH_TIMEOUT),
280
+ Effect.catchTag(
281
+ "TimeoutError",
282
+ () => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
283
+ )
284
+ )
285
+ yield* saveToken(updated)
286
+ return updated
287
+ })
288
+ )
289
+ )
290
+
291
+ const isConfigured = Effect.fn("AtlassianCliAuth.isConfigured")(() =>
292
+ loadConfig().pipe(Effect.map((config) => config !== null))
293
+ )
294
+
295
+ const login = Effect.fn("AtlassianCliAuth.login")((
296
+ loginOptions?: LoginOptions
297
+ ): Effect.Effect<ReadonlyArray<AccessibleSite> | void, OAuthError | StorageError> =>
298
+ Effect.gen(function*() {
299
+ const config = yield* getConfig()
300
+ const state = yield* generateUUID().pipe(withCrypto)
301
+ const codeVerifier = yield* generateCodeVerifier().pipe(withCrypto)
302
+ const codeChallenge = yield* computeCodeChallenge(codeVerifier).pipe(withCrypto)
303
+
304
+ const { code, port } = yield* Effect.scoped(
305
+ Effect.gen(function*() {
306
+ const { codePromise, port } = yield* startCallbackServer(state).pipe(
307
+ Effect.provideService(HttpServerFactoryTag, serverFactory)
308
+ )
309
+ const authUrl = buildAuthUrl({
310
+ clientId: config.clientId,
311
+ state,
312
+ port,
313
+ redirectUri: callbackUrl(port),
314
+ scopes: [...scopes],
315
+ codeChallenge
316
+ })
317
+
318
+ yield* Console.log(`Opening browser for Atlassian login (callback on port ${port})...`)
319
+ yield* Console.log(`If browser doesn't open, visit: ${authUrl}`)
320
+ yield* openBrowserOrWarn(authUrl)
321
+ yield* Console.log("Waiting for authorization (press Ctrl+C to cancel)...")
322
+
323
+ const code = yield* codePromise.pipe(
324
+ Effect.timeout("5 minutes"),
325
+ Effect.catchTag(
326
+ "TimeoutError",
327
+ () => Effect.fail(new OAuthError({ step: "authorize", cause: "Authorization timed out" }))
328
+ )
329
+ )
330
+ return { code, port }
331
+ })
332
+ )
333
+
334
+ yield* Console.log("Exchanging code for tokens...")
335
+ const tokens = yield* exchangeCodeForTokens(code, config, {
336
+ port,
337
+ redirectUri: callbackUrl(port),
338
+ codeVerifier
339
+ }).pipe(withHttp)
340
+
341
+ yield* Console.log("Fetching accessible sites...")
342
+ const sites = yield* getAccessibleResources(tokens.access_token).pipe(withHttp)
343
+
344
+ const [first, ...others] = sites
345
+ if (first === undefined) {
346
+ return yield* new OAuthError({
347
+ step: "authorize",
348
+ cause: `No ${productName} sites found for this account`
349
+ })
350
+ }
351
+
352
+ let site = first
353
+ if (others.length > 0) {
354
+ const siteUrl = loginOptions?.siteUrl
355
+ if (siteUrl === undefined || siteUrl === "") {
356
+ yield* Console.log(`Multiple ${productName} sites found. Please select one:`)
357
+ for (const s of sites) {
358
+ yield* Console.log(` - ${s.name}: ${s.url}`)
359
+ }
360
+ yield* Console.log(`\nRun '${commandName} auth login --site <url>' to select a site`)
361
+ return sites.map((s) => ({ id: s.id, name: s.name, url: s.url }))
362
+ }
363
+ const matched = sites.find((s) => s.url === siteUrl)
364
+ if (matched === undefined) {
365
+ const available = sites.map((s) => ` - ${s.name}: ${s.url}`).join("\n")
366
+ return yield* new OAuthError({
367
+ step: "authorize",
368
+ cause: `Site '${siteUrl}' not found. Available sites:\n${available}`
369
+ })
370
+ }
371
+ site = matched
372
+ }
373
+
374
+ yield* Console.log("Fetching user info...")
375
+ const user = yield* getUserInfo(tokens.access_token).pipe(withHttp)
376
+
377
+ const nowMs = yield* Clock.currentTimeMillis
378
+ yield* saveToken(buildOAuthTokenAt(tokens, site, user, nowMs))
379
+ yield* Console.log(`Logged in as ${user.name} (${user.email})`)
380
+ return undefined
381
+ })
382
+ )
383
+
384
+ const logout = Effect.fn("AtlassianCliAuth.logout")((): Effect.Effect<void, OAuthError | StorageError> =>
385
+ Effect.gen(function*() {
386
+ const token = yield* loadToken()
387
+ if (token === null) {
388
+ yield* Console.log("Not logged in")
389
+ return
390
+ }
391
+
392
+ const config = yield* loadConfig()
393
+ if (config !== null) {
394
+ yield* revokeToken(token, config).pipe(
395
+ withHttp,
396
+ Effect.tap(() => Effect.log("Token revoked with Atlassian")),
397
+ Effect.catch((error) => Effect.log(`Warning: Failed to revoke token: ${error.message}`))
398
+ )
399
+ }
400
+
401
+ yield* deleteToken()
402
+ })
403
+ )
404
+
405
+ const getAccessToken = Effect.fn("AtlassianCliAuth.getAccessToken")((): Effect.Effect<
406
+ Redacted.Redacted<string>,
407
+ MissingError | OAuthError | StorageError
408
+ > =>
409
+ Effect.gen(function*() {
410
+ const token = yield* loadToken()
411
+ if (token === null) {
412
+ return yield* Effect.fail(authMissing())
413
+ }
414
+
415
+ const nowMs = yield* Clock.currentTimeMillis
416
+ if (!isTokenExpiredAt(token, nowMs)) {
417
+ return Redacted.make(token.access_token)
418
+ }
419
+
420
+ // Atomically check-then-set refresh lock to avoid TOCTOU race
421
+ const deferred = yield* Deferred.make<OAuthToken, OAuthError | StorageError>()
422
+ const existing = yield* Ref.modify(refreshLock, (current) =>
423
+ Option.isSome(current)
424
+ ? ([current.value, current] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>])
425
+ : ([deferred, Option.some(deferred)] satisfies readonly [RefreshDeferred, Option.Option<RefreshDeferred>]))
426
+
427
+ // Another fiber is already refreshing — just await its result
428
+ if (existing !== deferred) {
429
+ const refreshed = yield* Deferred.await(existing)
430
+ return Redacted.make(refreshed.access_token)
431
+ }
432
+
433
+ const refresh = Effect.gen(function*() {
434
+ const config = yield* getConfig()
435
+ // stderr, not stdout: this fires from inside layer construction, so it
436
+ // lands ahead of whatever the command prints. On `--json` that used to
437
+ // put a line of prose in front of the document, and the release
438
+ // automation parsing it failed *after* the remote writes had happened.
439
+ yield* Console.error("Token expired, refreshing...")
440
+ return yield* refreshAndPersist(token, config)
441
+ }).pipe(
442
+ Effect.catchTag("OAuthError", (error) => {
443
+ // Discard the stored credential only when Atlassian actually rejected
444
+ // it. A refresh can fail without saying anything about the token —
445
+ // transport error, timeout, interruption — and in those cases the
446
+ // grant may even have been consumed server-side, so the one thing we
447
+ // must not do is delete the replacement's only trail. Deleting on any
448
+ // `step === "refresh"` failure turned a flaky network into a silent
449
+ // logout, which is worse than retrying and is unrecoverable.
450
+ //
451
+ // Not every 4xx is a verdict either. `429` is the one this most needs
452
+ // to survive — several `jira`/`jcf` processes on an expired token hit
453
+ // the endpoint together, one wins the rotation and the rest are
454
+ // rate-limited — while `408`/`425` restate the timeout case and `407`
455
+ // and other middlebox replies never came from Atlassian at all.
456
+ //
457
+ // Status alone is still too coarse. `refreshToken` sends the client
458
+ // credentials in the body, so a wrong or rotated `clientSecret` comes
459
+ // back as `400 invalid_client`; deleting the token there destroys a
460
+ // working credential over a config problem, and the re-login it forces
461
+ // would fail the same way until `auth configure` is run. Only
462
+ // `invalid_grant` means the stored token itself is spent, and that is
463
+ // the only thing that ends the session — on a `403` too, since a bare
464
+ // 403 is just as likely to come from a proxy or WAF as from Atlassian
465
+ // revoking anything. An unparseable body leaves `errorCode` absent and
466
+ // the token in place.
467
+ const { errorCode, status } = error
468
+ const rejected = errorCode === "invalid_grant" && (status === 400 || status === 403)
469
+ if (error.step === "refresh" && rejected) {
470
+ return Effect.gen(function*() {
471
+ yield* deleteToken()
472
+ return yield* new OAuthError({
473
+ step: "refresh",
474
+ cause: `Refresh token expired. Please run '${commandName} auth login' to re-authenticate.`,
475
+ status,
476
+ errorCode
477
+ })
478
+ })
479
+ }
480
+ return Effect.fail(error)
481
+ })
482
+ )
483
+
484
+ // This fiber owns the refresh. Publish every exit to the waiters —
485
+ // success, failure, and interruption of this fiber alike — so none of
486
+ // them blocks on a result that would otherwise never arrive.
487
+ const result = yield* refresh.pipe(
488
+ Effect.onExit((exit) =>
489
+ Ref.set(refreshLock, Option.none()).pipe(Effect.andThen(Deferred.done(deferred, exit)))
490
+ )
491
+ )
492
+
493
+ return Redacted.make(result.access_token)
494
+ })
495
+ )
496
+
497
+ const storedTokenField = <A>(read: (token: OAuthToken) => A) => (): Effect.Effect<A, MissingError | StorageError> =>
498
+ loadToken().pipe(
499
+ Effect.flatMap((token) => token === null ? Effect.fail(authMissing()) : Effect.succeed(read(token)))
500
+ )
501
+
502
+ return {
503
+ configure: Effect.fn("AtlassianCliAuth.configure")(saveConfig),
504
+ isConfigured,
505
+ login,
506
+ logout,
507
+ getAccessToken,
508
+ getCloudId: Effect.fn("AtlassianCliAuth.getCloudId")(storedTokenField((token) => token.cloud_id)),
509
+ getSiteUrl: Effect.fn("AtlassianCliAuth.getSiteUrl")(storedTokenField((token) => token.site_url)),
510
+ getCurrentUser: Effect.fn("AtlassianCliAuth.getCurrentUser")(() =>
511
+ loadToken().pipe(Effect.map((token) => token?.user ?? null))
512
+ ),
513
+ getActiveProfile: Effect.fn("AtlassianCliAuth.getActiveProfile")(() =>
514
+ loadActiveProfile(toolName).pipe(withStorage)
515
+ ),
516
+ listProfiles: Effect.fn("AtlassianCliAuth.listProfiles")(() =>
517
+ loadProfiles(toolName).pipe(Effect.map((store) => store.profiles), withStorage)
518
+ ),
519
+ switchProfile: Effect.fn("AtlassianCliAuth.switchProfile")((selector) =>
520
+ setActiveProfileBySelector(toolName, selector).pipe(withStorage)
521
+ ),
522
+ removeProfile: Effect.fn("AtlassianCliAuth.removeProfile")((selector) =>
523
+ deleteProfileBySelector(toolName, selector).pipe(withStorage)
524
+ ),
525
+ isLoggedIn: Effect.fn("AtlassianCliAuth.isLoggedIn")(() =>
526
+ loadToken().pipe(Effect.map((token) => token !== null))
527
+ )
528
+ }
529
+ })
530
+ )
@@ -0,0 +1,18 @@
1
+ /**
2
+ * OAuth2 login and token refresh for Atlassian command-line tools.
3
+ *
4
+ * Separate from `./auth` because it carries a Node callback server and a
5
+ * browser launcher; `./auth` stays platform-neutral for server-side consumers.
6
+ *
7
+ * @module
8
+ */
9
+ export {
10
+ type AccessibleSite,
11
+ type AtlassianCliAuth,
12
+ type AtlassianCliAuthOptions,
13
+ type LoginOptions,
14
+ makeAtlassianCliAuth
15
+ } from "./AtlassianCliAuth.js"
16
+ export { HttpServerFactoryLive, NodeCliAuthLive } from "./internal/NodeLayers.js"
17
+ export { type HttpServerFactory, HttpServerFactoryTag, makeHttpServerFactory } from "./internal/oauthServer.js"
18
+ export { BrowserOpenError, openBrowser } from "./openBrowser.js"
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Node.js adapters for cli-auth: the OAuth callback server and token storage — the
3
+ * only cli-auth file importing `@effect/platform-node`.
4
+ *
5
+ * @internal
6
+ */
7
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
8
+ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"
9
+ import * as NodePath from "@effect/platform-node/NodePath"
10
+ import * as Layer from "effect/Layer"
11
+ import { createServer } from "node:http"
12
+ import { HomeDirectoryLive } from "../../config/ConfigPaths.js"
13
+ import { makeHttpServerFactory } from "./oauthServer.js"
14
+
15
+ export const HttpServerFactoryLive = makeHttpServerFactory(
16
+ (options) => NodeHttpServer.layerServer(createServer, options)
17
+ )
18
+
19
+ /** Everything `makeAtlassianCliAuth` needs on Node besides HTTP client, spawner and Crypto. */
20
+ export const NodeCliAuthLive = Layer.mergeAll(
21
+ HttpServerFactoryLive,
22
+ NodeFileSystem.layer,
23
+ NodePath.layer,
24
+ HomeDirectoryLive
25
+ )