@knpkv/atlassian-common 0.3.0 → 1.0.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 (56) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +29 -13
  3. package/dist/Hash.d.ts +4 -4
  4. package/dist/Hash.js +6 -6
  5. package/dist/Hash.js.map +1 -1
  6. package/dist/auth/OAuthEndpoints.d.ts +6 -4
  7. package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
  8. package/dist/auth/OAuthEndpoints.js +12 -7
  9. package/dist/auth/OAuthEndpoints.js.map +1 -1
  10. package/dist/auth/OAuthErrors.d.ts.map +1 -1
  11. package/dist/auth/OAuthErrors.js +4 -3
  12. package/dist/auth/OAuthErrors.js.map +1 -1
  13. package/dist/auth/OAuthOperations.d.ts +0 -20
  14. package/dist/auth/OAuthOperations.d.ts.map +1 -1
  15. package/dist/auth/OAuthOperations.js +3 -1
  16. package/dist/auth/OAuthOperations.js.map +1 -1
  17. package/dist/auth/uuid.d.ts +5 -3
  18. package/dist/auth/uuid.d.ts.map +1 -1
  19. package/dist/auth/uuid.js +7 -3
  20. package/dist/auth/uuid.js.map +1 -1
  21. package/dist/bin.d.ts +3 -0
  22. package/dist/bin.d.ts.map +1 -0
  23. package/dist/bin.js +58 -0
  24. package/dist/bin.js.map +1 -0
  25. package/dist/config/AuthProfiles.d.ts +342 -0
  26. package/dist/config/AuthProfiles.d.ts.map +1 -0
  27. package/dist/config/AuthProfiles.js +275 -0
  28. package/dist/config/AuthProfiles.js.map +1 -0
  29. package/dist/config/ConfigPaths.d.ts +6 -0
  30. package/dist/config/ConfigPaths.d.ts.map +1 -1
  31. package/dist/config/ConfigPaths.js +10 -0
  32. package/dist/config/ConfigPaths.js.map +1 -1
  33. package/dist/config/ProfileManager.d.ts +92 -0
  34. package/dist/config/ProfileManager.d.ts.map +1 -0
  35. package/dist/config/ProfileManager.js +175 -0
  36. package/dist/config/ProfileManager.js.map +1 -0
  37. package/dist/config/index.d.ts +3 -1
  38. package/dist/config/index.d.ts.map +1 -1
  39. package/dist/config/index.js +5 -1
  40. package/dist/config/index.js.map +1 -1
  41. package/package.json +11 -3
  42. package/src/Hash.ts +6 -6
  43. package/src/auth/OAuthEndpoints.ts +18 -11
  44. package/src/auth/OAuthErrors.ts +4 -3
  45. package/src/auth/OAuthOperations.ts +3 -1
  46. package/src/auth/uuid.ts +9 -3
  47. package/src/bin.ts +115 -0
  48. package/src/config/AuthProfiles.ts +384 -0
  49. package/src/config/ConfigPaths.ts +14 -0
  50. package/src/config/ProfileManager.ts +268 -0
  51. package/src/config/index.ts +42 -0
  52. package/test/AuthProfiles.test.ts +131 -0
  53. package/test/OAuthEndpoints.test.ts +20 -15
  54. package/test/OAuthOperations.test.ts +5 -1
  55. package/test/ProfileManager.test.ts +188 -0
  56. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Unified Atlassian profile management helpers shared by Atlassian CLIs.
3
+ *
4
+ * @module
5
+ */
6
+ import * as Data from "effect/Data"
7
+ import * as Effect from "effect/Effect"
8
+ import * as FileSystem from "effect/FileSystem"
9
+ import * as Path from "effect/Path"
10
+ import type * as PlatformError from "effect/PlatformError"
11
+ import * as Schema from "effect/Schema"
12
+ import type { HttpClient } from "effect/unstable/http"
13
+ import type { OAuthError } from "../auth/OAuthErrors.js"
14
+ import { refreshToken } from "../auth/OAuthOperations.js"
15
+ import { type AuthProfile, findProfile, loadProfiles, saveProfiles, saveProfileToken } from "./AuthProfiles.js"
16
+ import { getProfilesPath, type HomeDirectoryError, HomeDirectoryTag } from "./ConfigPaths.js"
17
+ import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
18
+ import { FileSystemError, isTokenExpired, loadOAuthConfig } from "./TokenStorage.js"
19
+
20
+ export interface AtlassianToolDefinition {
21
+ readonly toolName: string
22
+ readonly authStoreName?: string
23
+ readonly legacyAuthPath?: ReadonlyArray<string>
24
+ readonly label: string
25
+ readonly loginHint: string
26
+ readonly requiredScopes: ReadonlyArray<string>
27
+ }
28
+
29
+ export const JIRA_REQUIRED_SCOPES = [
30
+ "read:jira-work",
31
+ "write:jira-work",
32
+ "manage:jira-project",
33
+ "read:jira-user",
34
+ "read:me",
35
+ "offline_access"
36
+ ] as const
37
+
38
+ export const CONFLUENCE_REQUIRED_SCOPES = [
39
+ "read:page:confluence",
40
+ "write:page:confluence",
41
+ "delete:page:confluence",
42
+ "read:me",
43
+ "offline_access"
44
+ ] as const
45
+
46
+ export const ATLASSIAN_TOOLS = [
47
+ {
48
+ toolName: "jira-cli",
49
+ label: "Jira CLI",
50
+ loginHint: "jira auth login",
51
+ requiredScopes: JIRA_REQUIRED_SCOPES
52
+ },
53
+ {
54
+ toolName: "confluence-to-markdown",
55
+ legacyAuthPath: [".confluence", "auth.json"],
56
+ label: "Confluence to Markdown",
57
+ loginHint: "confluence auth login",
58
+ requiredScopes: CONFLUENCE_REQUIRED_SCOPES
59
+ },
60
+ {
61
+ toolName: "jira-clockify",
62
+ authStoreName: "jira-cli",
63
+ label: "Jira Clockify",
64
+ loginHint: "jcf auth jira login",
65
+ requiredScopes: JIRA_REQUIRED_SCOPES
66
+ }
67
+ ] as const satisfies ReadonlyArray<AtlassianToolDefinition>
68
+
69
+ export type ProfileTokenStatus = "valid" | "expired"
70
+
71
+ export class ProfileNotFoundError extends Data.TaggedError("ProfileNotFoundError")<{
72
+ readonly selector: string
73
+ }> {
74
+ override get message(): string {
75
+ return `Profile not found: ${this.selector}`
76
+ }
77
+ }
78
+
79
+ export class MissingOAuthConfigError extends Data.TaggedError("MissingOAuthConfigError")<{
80
+ readonly authStoreName: string
81
+ readonly profileId: string
82
+ }> {
83
+ override get message(): string {
84
+ return `Cannot refresh expired profile ${this.profileId}: missing OAuth config for ${this.authStoreName}`
85
+ }
86
+ }
87
+
88
+ export interface ToolProfileStatus {
89
+ readonly tool: AtlassianToolDefinition
90
+ readonly authStoreName: string
91
+ readonly activeProfile: AuthProfile | null
92
+ readonly profiles: ReadonlyArray<AuthProfile>
93
+ readonly tokenStatus: ProfileTokenStatus | "missing"
94
+ readonly missingScopes: ReadonlyArray<string>
95
+ readonly oauthConfigured: boolean
96
+ }
97
+
98
+ const authStoreName = (tool: AtlassianToolDefinition): string => tool.authStoreName ?? tool.toolName
99
+
100
+ const uniqueAuthTools = (tools: ReadonlyArray<AtlassianToolDefinition>): ReadonlyArray<AtlassianToolDefinition> =>
101
+ tools.filter((tool, index) =>
102
+ tools.findIndex((candidate) => authStoreName(candidate) === authStoreName(tool)) === index
103
+ )
104
+
105
+ const loadLegacyToken = (
106
+ tool: AtlassianToolDefinition
107
+ ): Effect.Effect<
108
+ OAuthToken | null,
109
+ FileSystemError | HomeDirectoryError,
110
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
111
+ > =>
112
+ Effect.gen(function*() {
113
+ if (!tool.legacyAuthPath) return null
114
+ const fs = yield* FileSystem.FileSystem
115
+ const path = yield* Path.Path
116
+ const homeDirectory = yield* HomeDirectoryTag
117
+ const home = yield* homeDirectory.get()
118
+ const authPath = path.join(home, ...tool.legacyAuthPath)
119
+ const exists = yield* fs.exists(authPath).pipe(Effect.catch(() => Effect.succeed(false)))
120
+ if (!exists) return null
121
+ const content = yield* fs.readFileString(authPath).pipe(
122
+ Effect.mapError((cause) => new FileSystemError({ operation: "read", path: authPath, cause }))
123
+ )
124
+ const parsed = yield* Effect.try({
125
+ try: () => JSON.parse(content) as unknown,
126
+ catch: (cause) => cause
127
+ }).pipe(Effect.catch(() => Effect.succeed(null)))
128
+ if (parsed === null) return null
129
+ return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
130
+ Effect.catch(() => Effect.succeed(null))
131
+ )
132
+ })
133
+
134
+ export const tokenScopes = (token: OAuthToken): ReadonlySet<string> =>
135
+ new Set(token.scope.split(/\s+/).filter((scope: string) => scope.length > 0))
136
+
137
+ export const missingScopes = (
138
+ token: OAuthToken,
139
+ requiredScopes: ReadonlyArray<string>
140
+ ): ReadonlyArray<string> => {
141
+ const granted = tokenScopes(token)
142
+ return requiredScopes.filter((scope) => !granted.has(scope))
143
+ }
144
+
145
+ export const inspectToolProfiles = (
146
+ tool: AtlassianToolDefinition
147
+ ): Effect.Effect<
148
+ ToolProfileStatus,
149
+ FileSystemError | HomeDirectoryError,
150
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
151
+ > =>
152
+ Effect.gen(function*() {
153
+ const storeName = authStoreName(tool)
154
+ const [store, config] = yield* Effect.all([loadProfiles(storeName), loadOAuthConfig(storeName)])
155
+ const activeProfile = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
156
+ null
157
+ return {
158
+ tool,
159
+ authStoreName: storeName,
160
+ activeProfile,
161
+ profiles: store.profiles,
162
+ tokenStatus: activeProfile === null ? "missing" : isTokenExpired(activeProfile.token, 0) ? "expired" : "valid",
163
+ missingScopes: activeProfile === null ? [] : missingScopes(activeProfile.token, tool.requiredScopes),
164
+ oauthConfigured: config !== null
165
+ }
166
+ })
167
+
168
+ export const inspectAllToolProfiles = (
169
+ tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
170
+ ) => Effect.forEach(tools, inspectToolProfiles)
171
+
172
+ /**
173
+ * Switch matching existing profiles across auth stores.
174
+ *
175
+ * Fails when the selector is not present in any store so callers do not
176
+ * mistake an unchanged profile list for a successful switch.
177
+ */
178
+ export const useProfileForAllTools = (
179
+ selector: string,
180
+ tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
181
+ ): Effect.Effect<
182
+ ReadonlyArray<ToolProfileStatus>,
183
+ ProfileNotFoundError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
184
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
185
+ > =>
186
+ Effect.gen(function*() {
187
+ const authTools = uniqueAuthTools(tools)
188
+ const stores = yield* Effect.forEach(authTools, (tool) =>
189
+ loadProfiles(authStoreName(tool)).pipe(Effect.map((store) => [tool, store] as const)))
190
+ const selected = stores.map(([, store]) =>
191
+ findProfile(store.profiles, selector)
192
+ ).find((profile): profile is AuthProfile =>
193
+ profile !== null
194
+ )
195
+ if (!selected) return yield* Effect.fail(new ProfileNotFoundError({ selector }))
196
+
197
+ yield* Effect.forEach(stores, ([tool, store]) => {
198
+ const matching = findProfile(store.profiles, selector) ?? findProfile(store.profiles, selected.id)
199
+ if (!matching) return Effect.void
200
+ return saveProfiles(authStoreName(tool), { ...store, activeProfileId: matching.id })
201
+ })
202
+ return yield* inspectAllToolProfiles(tools)
203
+ })
204
+
205
+ /**
206
+ * Persist legacy single-token auth files into shared profile stores.
207
+ *
208
+ * Supports both XDG Atlassian auth files and tool-specific legacy paths such
209
+ * as Confluence's historical `~/.confluence/auth.json`.
210
+ */
211
+ export const migrateLegacyProfiles = (
212
+ tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
213
+ ): Effect.Effect<
214
+ ReadonlyArray<ToolProfileStatus>,
215
+ FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
216
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag
217
+ > =>
218
+ Effect.gen(function*() {
219
+ const fs = yield* FileSystem.FileSystem
220
+ yield* Effect.forEach(uniqueAuthTools(tools), (tool) =>
221
+ Effect.gen(function*() {
222
+ const storeName = authStoreName(tool)
223
+ const profilesPath = yield* getProfilesPath(storeName)
224
+ const hasProfilesFile = yield* fs.exists(profilesPath).pipe(Effect.catch(() => Effect.succeed(false)))
225
+ if (hasProfilesFile) return
226
+ const store = yield* loadProfiles(storeName)
227
+ if (store.profiles.length > 0) {
228
+ yield* saveProfiles(storeName, store)
229
+ return
230
+ }
231
+ const legacyToken = yield* loadLegacyToken(tool)
232
+ if (legacyToken !== null) {
233
+ yield* saveProfileToken(storeName, legacyToken)
234
+ }
235
+ }))
236
+ return yield* inspectAllToolProfiles(tools)
237
+ })
238
+
239
+ /**
240
+ * Refresh expired active profiles.
241
+ *
242
+ * Fails when an expired profile lacks OAuth client configuration so refresh
243
+ * callers cannot report success while leaving a token expired.
244
+ */
245
+ export const refreshActiveProfiles = (
246
+ tools: ReadonlyArray<AtlassianToolDefinition> = ATLASSIAN_TOOLS
247
+ ): Effect.Effect<
248
+ ReadonlyArray<ToolProfileStatus>,
249
+ MissingOAuthConfigError | OAuthError | FileSystemError | HomeDirectoryError | PlatformError.PlatformError,
250
+ FileSystem.FileSystem | Path.Path | HomeDirectoryTag | HttpClient.HttpClient
251
+ > =>
252
+ Effect.gen(function*() {
253
+ yield* Effect.forEach(uniqueAuthTools(tools), (tool) =>
254
+ Effect.gen(function*() {
255
+ const storeName = authStoreName(tool)
256
+ const store = yield* loadProfiles(storeName)
257
+ const active = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
258
+ null
259
+ if (!active || !isTokenExpired(active.token, 0)) return
260
+ const config = yield* loadOAuthConfig(storeName)
261
+ if (!config) {
262
+ return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
263
+ }
264
+ const refreshed = yield* refreshToken(active.token, config)
265
+ yield* saveProfileToken(storeName, refreshed)
266
+ }))
267
+ return yield* inspectAllToolProfiles(tools)
268
+ })
@@ -10,6 +10,7 @@ export {
10
10
  getAuthPath,
11
11
  getConfigDir,
12
12
  getOAuthConfigPath,
13
+ getProfilesPath,
13
14
  type HomeDirectory,
14
15
  HomeDirectoryError,
15
16
  HomeDirectoryLive,
@@ -41,3 +42,44 @@ export {
41
42
  saveOAuthConfig,
42
43
  saveToken
43
44
  } from "./TokenStorage.js"
45
+
46
+ // Auth profiles
47
+ export {
48
+ type AuthProfile,
49
+ AuthProfileSchema,
50
+ type AuthProfilesFile,
51
+ AuthProfilesFileSchema,
52
+ deleteActiveProfile,
53
+ deleteProfile,
54
+ deleteProfileBySelector,
55
+ findProfile,
56
+ loadActiveProfile,
57
+ loadActiveProfileToken,
58
+ loadProfiles,
59
+ profileFromToken,
60
+ profileIdFromToken,
61
+ profileNameFromToken,
62
+ saveProfiles,
63
+ saveProfileToken,
64
+ setActiveProfile,
65
+ setActiveProfileBySelector
66
+ } from "./AuthProfiles.js"
67
+
68
+ // Unified profile manager
69
+ export {
70
+ ATLASSIAN_TOOLS,
71
+ type AtlassianToolDefinition,
72
+ CONFLUENCE_REQUIRED_SCOPES,
73
+ inspectAllToolProfiles,
74
+ inspectToolProfiles,
75
+ JIRA_REQUIRED_SCOPES,
76
+ migrateLegacyProfiles,
77
+ MissingOAuthConfigError,
78
+ missingScopes,
79
+ ProfileNotFoundError,
80
+ type ProfileTokenStatus,
81
+ refreshActiveProfiles,
82
+ tokenScopes,
83
+ type ToolProfileStatus,
84
+ useProfileForAllTools
85
+ } from "./ProfileManager.js"
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "@effect/vitest"
2
+ import { ConfigProvider, Effect, Layer } from "effect"
3
+ import * as FileSystem from "effect/FileSystem"
4
+ import * as Path from "effect/Path"
5
+ import { SystemError } from "effect/PlatformError"
6
+ import {
7
+ deleteProfileBySelector,
8
+ HomeDirectoryLive,
9
+ loadActiveProfileToken,
10
+ loadProfiles,
11
+ type OAuthToken,
12
+ saveProfileToken,
13
+ setActiveProfileBySelector
14
+ } from "../src/config/index.js"
15
+
16
+ const TEST_HOME = "/tmp/atlassian-common-profile-test"
17
+ const TOOL_NAME = "test-tool"
18
+ const basePath = `${TEST_HOME}/.config/atlassian/${TOOL_NAME}`
19
+ const authPath = `${basePath}/auth.json`
20
+ const profilesPath = `${basePath}/profiles.json`
21
+
22
+ const fsError = (method: string) =>
23
+ Effect.fail(
24
+ new SystemError({ _tag: "NotFound", module: "FileSystem", method, description: "mock not found" })
25
+ )
26
+
27
+ const makeToken = (n: number): OAuthToken => ({
28
+ access_token: `access-${n}`,
29
+ refresh_token: `refresh-${n}`,
30
+ expires_at: Date.now() + 60_000,
31
+ scope: "read:me offline_access",
32
+ cloud_id: `cloud-${n}`,
33
+ site_url: `https://site-${n}.atlassian.net`,
34
+ user: {
35
+ account_id: `account-${n}`,
36
+ name: `User ${n}`,
37
+ email: `user-${n}@example.com`
38
+ }
39
+ })
40
+
41
+ const makeMockFS = (files: Record<string, string> = {}) => {
42
+ const store = { ...files }
43
+ const partial: Partial<FileSystem.FileSystem> = {
44
+ exists: (path) => Effect.succeed(path in store),
45
+ readFileString: (path) => path in store ? Effect.succeed(store[path]!) : fsError("readFileString"),
46
+ writeFileString: (path: string, content: string) => {
47
+ store[path] = content
48
+ return Effect.void
49
+ },
50
+ makeDirectory: () => Effect.void,
51
+ chmod: () => Effect.void,
52
+ remove: (path: string) => {
53
+ delete store[path]
54
+ return Effect.void
55
+ }
56
+ }
57
+
58
+ return {
59
+ store,
60
+ layer: Layer.succeed(FileSystem.FileSystem, FileSystem.FileSystem.of(partial as FileSystem.FileSystem))
61
+ }
62
+ }
63
+
64
+ const ConfigProviderLive = ConfigProvider.layer(
65
+ ConfigProvider.fromEnv({ env: { HOME: TEST_HOME } })
66
+ )
67
+
68
+ const run = <A>(
69
+ effect: Effect.Effect<A, unknown, FileSystem.FileSystem | Path.Path>,
70
+ files?: Record<string, string>
71
+ ) => {
72
+ const mock = makeMockFS(files)
73
+ const layer = Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive)
74
+ return Effect.runPromise(effect.pipe(Effect.provide(layer))).then((result) => ({ result, store: mock.store }))
75
+ }
76
+
77
+ describe("AuthProfiles", () => {
78
+ it("upserts profiles and mirrors the active profile to auth.json", async () => {
79
+ const first = makeToken(1)
80
+ const second = makeToken(2)
81
+
82
+ const { result: profiles, store } = await run(
83
+ Effect.gen(function*() {
84
+ yield* saveProfileToken(TOOL_NAME, first)
85
+ yield* saveProfileToken(TOOL_NAME, second)
86
+ return yield* loadProfiles(TOOL_NAME)
87
+ })
88
+ )
89
+
90
+ expect(profiles.profiles.map((profile) => profile.id)).toEqual(["account-2@cloud-2", "account-1@cloud-1"])
91
+ expect(profiles.activeProfileId).toBe("account-2@cloud-2")
92
+ expect(JSON.parse(store[authPath]!)["access_token"]).toBe("access-2")
93
+ expect(JSON.parse(store[profilesPath]!)["profiles"]).toHaveLength(2)
94
+ })
95
+
96
+ it("switches and removes profiles by flexible selector", async () => {
97
+ const first = makeToken(1)
98
+ const second = makeToken(2)
99
+
100
+ const { result, store } = await run(
101
+ Effect.gen(function*() {
102
+ yield* saveProfileToken(TOOL_NAME, first)
103
+ yield* saveProfileToken(TOOL_NAME, second)
104
+ const selected = yield* setActiveProfileBySelector(TOOL_NAME, first.site_url)
105
+ const activeAfterSwitch = yield* loadActiveProfileToken(TOOL_NAME)
106
+ const removed = yield* deleteProfileBySelector(TOOL_NAME, first.user!.account_id)
107
+ const activeAfterRemove = yield* loadActiveProfileToken(TOOL_NAME)
108
+ return { selected, activeAfterSwitch, removed, activeAfterRemove }
109
+ })
110
+ )
111
+
112
+ expect(result.selected?.id).toBe("account-1@cloud-1")
113
+ expect(result.activeAfterSwitch?.access_token).toBe("access-1")
114
+ expect(result.removed?.id).toBe("account-1@cloud-1")
115
+ expect(result.activeAfterRemove?.access_token).toBe("access-2")
116
+ expect(JSON.parse(store[authPath]!)["access_token"]).toBe("access-2")
117
+ })
118
+
119
+ it("loads a legacy auth.json token as a single active profile", async () => {
120
+ const token = makeToken(1)
121
+
122
+ const { result } = await run(
123
+ loadProfiles(TOOL_NAME),
124
+ { [authPath]: JSON.stringify(token, null, 2) }
125
+ )
126
+
127
+ expect(result.activeProfileId).toBe("account-1@cloud-1")
128
+ expect(result.profiles).toHaveLength(1)
129
+ expect(result.profiles[0]?.token.access_token).toBe("access-1")
130
+ })
131
+ })
@@ -1,25 +1,30 @@
1
+ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"
1
2
  import { it as effectIt } from "@effect/vitest"
2
3
  import * as Effect from "effect/Effect"
3
4
  import { describe, expect, it } from "vitest"
4
5
  import { AUTH_URL, buildAuthUrl, computeCodeChallenge, generateCodeVerifier } from "../src/auth/OAuthEndpoints.js"
5
6
 
7
+ const withCrypto = <A, E, R>(effect: Effect.Effect<A, E, R>) => effect.pipe(Effect.provide(NodeCrypto.layer))
8
+
6
9
  describe("OAuthEndpoints", () => {
7
10
  // ---- PKCE ----
8
11
 
9
12
  describe("generateCodeVerifier", () => {
10
13
  // 32 random bytes base64url-encoded = 43 chars; verifies output shape for RFC 7636 compliance
11
- it("returns a base64url string of 43 characters (32 random bytes)", () => {
12
- const verifier = generateCodeVerifier()
13
- expect(verifier).toHaveLength(43)
14
- expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
15
- })
14
+ effectIt.effect("returns a base64url string of 43 characters (32 random bytes)", () =>
15
+ Effect.gen(function*() {
16
+ const verifier = yield* generateCodeVerifier()
17
+ expect(verifier).toHaveLength(43)
18
+ expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
19
+ }).pipe(withCrypto))
16
20
 
17
21
  // PKCE verifiers must be unique per auth request — reuse enables replay attacks
18
- it("produces unique values", () => {
19
- const a = generateCodeVerifier()
20
- const b = generateCodeVerifier()
21
- expect(a).not.toBe(b)
22
- })
22
+ effectIt.effect("produces unique values", () =>
23
+ Effect.gen(function*() {
24
+ const a = yield* generateCodeVerifier()
25
+ const b = yield* generateCodeVerifier()
26
+ expect(a).not.toBe(b)
27
+ }).pipe(withCrypto))
23
28
  })
24
29
 
25
30
  describe("computeCodeChallenge", () => {
@@ -30,23 +35,23 @@ describe("OAuthEndpoints", () => {
30
35
  const expected = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
31
36
  const challenge = yield* computeCodeChallenge(verifier)
32
37
  expect(challenge).toBe(expected)
33
- }))
38
+ }).pipe(withCrypto))
34
39
 
35
40
  // Same verifier must always produce same challenge — server verifies this on token exchange
36
41
  effectIt.effect("is deterministic", () =>
37
42
  Effect.gen(function*() {
38
- const v = generateCodeVerifier()
43
+ const v = yield* generateCodeVerifier()
39
44
  const a = yield* computeCodeChallenge(v)
40
45
  const b = yield* computeCodeChallenge(v)
41
46
  expect(a).toBe(b)
42
- }))
47
+ }).pipe(withCrypto))
43
48
 
44
49
  // base64url must not contain +, /, = — these break URL query params
45
50
  effectIt.effect("produces base64url output (no +, /, =)", () =>
46
51
  Effect.gen(function*() {
47
52
  const challenge = yield* computeCodeChallenge("test-verifier-string")
48
53
  expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/)
49
- }))
54
+ }).pipe(withCrypto))
50
55
  })
51
56
 
52
57
  // ---- buildAuthUrl ----
@@ -103,7 +108,7 @@ describe("OAuthEndpoints", () => {
103
108
  const parsed = new URL(url)
104
109
  expect(parsed.searchParams.get("code_challenge")).toBe(challenge)
105
110
  expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
106
- })
111
+ }).pipe(withCrypto)
107
112
  )
108
113
  })
109
114
  })
@@ -1,6 +1,7 @@
1
1
  import { describe, it } from "@effect/vitest"
2
2
  import * as Effect from "effect/Effect"
3
3
  import * as Layer from "effect/Layer"
4
+ import { TestClock } from "effect/testing"
4
5
  import type { HttpClientRequest } from "effect/unstable/http"
5
6
  import { HttpClient, HttpClientResponse } from "effect/unstable/http"
6
7
  import { expect } from "vitest"
@@ -58,6 +59,8 @@ const oauthConfig: OAuthConfig = {
58
59
  clientSecret: "csecret"
59
60
  }
60
61
 
62
+ const TEST_NOW = Date.parse("2026-01-01T00:00:00.000Z")
63
+
61
64
  const oauthToken: OAuthToken = {
62
65
  access_token: "old-access",
63
66
  refresh_token: "old-refresh",
@@ -221,6 +224,7 @@ describe("OAuthOperations", () => {
221
224
  // Verifies grant_type=refresh_token is sent and returned token preserves cloud_id/site_url from original
222
225
  it.effect("sends refresh_token and returns updated OAuthToken", () =>
223
226
  Effect.gen(function*() {
227
+ yield* TestClock.setTime(TEST_NOW)
224
228
  const { capturedRequests, mockClient } = createMockHttpClient([
225
229
  { status: 200, body: { ...validTokenBody, access_token: "new-access", refresh_token: "new-refresh" } }
226
230
  ])
@@ -232,7 +236,7 @@ describe("OAuthOperations", () => {
232
236
  // preserves existing fields
233
237
  expect(result.cloud_id).toBe("cloud-1")
234
238
  expect(result.site_url).toBe("https://site.atlassian.net")
235
- expect(result.expires_at).toBeGreaterThan(Date.now())
239
+ expect(result.expires_at).toBe(TEST_NOW + validTokenBody.expires_in * 1000)
236
240
 
237
241
  const req = capturedRequests[0]!
238
242
  const body = yield* req.body._tag === "Uint8Array"