@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.
- package/CHANGELOG.md +23 -0
- package/README.md +29 -13
- package/dist/Hash.d.ts +4 -4
- package/dist/Hash.js +6 -6
- package/dist/Hash.js.map +1 -1
- package/dist/auth/OAuthEndpoints.d.ts +6 -4
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
- package/dist/auth/OAuthEndpoints.js +12 -7
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthErrors.d.ts.map +1 -1
- package/dist/auth/OAuthErrors.js +4 -3
- package/dist/auth/OAuthErrors.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +0 -20
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +3 -1
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/uuid.d.ts +5 -3
- package/dist/auth/uuid.d.ts.map +1 -1
- package/dist/auth/uuid.js +7 -3
- package/dist/auth/uuid.js.map +1 -1
- package/dist/bin.d.ts +3 -0
- package/dist/bin.d.ts.map +1 -0
- package/dist/bin.js +58 -0
- package/dist/bin.js.map +1 -0
- package/dist/config/AuthProfiles.d.ts +342 -0
- package/dist/config/AuthProfiles.d.ts.map +1 -0
- package/dist/config/AuthProfiles.js +275 -0
- package/dist/config/AuthProfiles.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +6 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ConfigPaths.js +10 -0
- package/dist/config/ConfigPaths.js.map +1 -1
- package/dist/config/ProfileManager.d.ts +92 -0
- package/dist/config/ProfileManager.d.ts.map +1 -0
- package/dist/config/ProfileManager.js +175 -0
- package/dist/config/ProfileManager.js.map +1 -0
- package/dist/config/index.d.ts +3 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +5 -1
- package/dist/config/index.js.map +1 -1
- package/package.json +11 -3
- package/src/Hash.ts +6 -6
- package/src/auth/OAuthEndpoints.ts +18 -11
- package/src/auth/OAuthErrors.ts +4 -3
- package/src/auth/OAuthOperations.ts +3 -1
- package/src/auth/uuid.ts +9 -3
- package/src/bin.ts +115 -0
- package/src/config/AuthProfiles.ts +384 -0
- package/src/config/ConfigPaths.ts +14 -0
- package/src/config/ProfileManager.ts +268 -0
- package/src/config/index.ts +42 -0
- package/test/AuthProfiles.test.ts +131 -0
- package/test/OAuthEndpoints.test.ts +20 -15
- package/test/OAuthOperations.test.ts +5 -1
- package/test/ProfileManager.test.ts +188 -0
- 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
|
+
})
|
package/src/config/index.ts
CHANGED
|
@@ -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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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).
|
|
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"
|