@knpkv/atlassian-common 1.2.0 → 1.4.1
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 +79 -0
- package/README.md +6 -0
- package/dist/Brand.d.ts.map +1 -1
- package/dist/Hash.d.ts.map +1 -1
- package/dist/ast/Document.d.ts +3 -19
- package/dist/ast/Document.d.ts.map +1 -1
- package/dist/ast/Document.js +4 -3
- package/dist/ast/Document.js.map +1 -1
- package/dist/ast/MacroNode.d.ts +3 -3
- package/dist/attachments.d.ts.map +1 -1
- package/dist/attachments.js +2 -6
- package/dist/attachments.js.map +1 -1
- package/dist/auth/OAuthEndpoints.d.ts +23 -0
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -1
- package/dist/auth/OAuthEndpoints.js +36 -0
- package/dist/auth/OAuthEndpoints.js.map +1 -1
- package/dist/auth/OAuthErrors.d.ts +22 -0
- package/dist/auth/OAuthErrors.d.ts.map +1 -1
- package/dist/auth/OAuthErrors.js +1 -1
- package/dist/auth/OAuthErrors.js.map +1 -1
- package/dist/auth/OAuthOperations.d.ts +15 -1
- package/dist/auth/OAuthOperations.d.ts.map +1 -1
- package/dist/auth/OAuthOperations.js +23 -4
- package/dist/auth/OAuthOperations.js.map +1 -1
- package/dist/auth/OAuthResponseSchemas.d.ts +11 -0
- package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -1
- package/dist/auth/OAuthResponseSchemas.js +11 -0
- package/dist/auth/OAuthResponseSchemas.js.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +5 -3
- package/dist/auth/index.js.map +1 -1
- package/dist/bin.js +2 -2
- package/dist/bin.js.map +1 -1
- package/dist/config/AuthProfiles.d.ts +140 -175
- package/dist/config/AuthProfiles.d.ts.map +1 -1
- package/dist/config/AuthProfiles.js +2 -1
- package/dist/config/AuthProfiles.js.map +1 -1
- package/dist/config/ConfigPaths.d.ts.map +1 -1
- package/dist/config/ProfileManager.d.ts +15 -2
- package/dist/config/ProfileManager.d.ts.map +1 -1
- package/dist/config/ProfileManager.js +46 -6
- package/dist/config/ProfileManager.js.map +1 -1
- package/dist/config/TokenStorage.d.ts +8 -0
- package/dist/config/TokenStorage.d.ts.map +1 -1
- package/dist/config/TokenStorage.js +17 -7
- package/dist/config/TokenStorage.js.map +1 -1
- package/dist/config/index.d.ts +2 -2
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +2 -2
- package/dist/config/index.js.map +1 -1
- package/dist/internal/legacyWallClock.d.ts +11 -0
- package/dist/internal/legacyWallClock.d.ts.map +1 -0
- package/dist/internal/legacyWallClock.js +11 -0
- package/dist/internal/legacyWallClock.js.map +1 -0
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -1
- package/dist/serializers/MarkdownSerializer.js.map +1 -1
- package/package.json +7 -7
- package/src/ast/Document.ts +5 -4
- package/src/attachments.ts +2 -1
- package/src/auth/OAuthEndpoints.ts +38 -0
- package/src/auth/OAuthErrors.ts +23 -1
- package/src/auth/OAuthOperations.ts +44 -6
- package/src/auth/OAuthResponseSchemas.ts +12 -0
- package/src/auth/index.ts +6 -1
- package/src/bin.ts +4 -5
- package/src/config/AuthProfiles.ts +4 -2
- package/src/config/ProfileManager.ts +56 -7
- package/src/config/TokenStorage.ts +24 -8
- package/src/config/index.ts +2 -0
- package/src/internal/legacyWallClock.ts +10 -0
- package/test/ApiUpdateWorkflows.test.ts +80 -0
- package/test/OAuthEndpoints.test.ts +23 -0
- package/test/ProfileManager.test.ts +61 -0
- package/test/PublicExports.test.ts +16 -0
|
@@ -23,11 +23,13 @@ import * as Effect from "effect/Effect"
|
|
|
23
23
|
import * as Schema from "effect/Schema"
|
|
24
24
|
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
|
|
25
25
|
import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
|
|
26
|
+
import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
|
|
26
27
|
import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
|
|
27
28
|
import { OAuthError } from "./OAuthErrors.js"
|
|
28
29
|
import {
|
|
29
30
|
type AccessibleResource,
|
|
30
31
|
AccessibleResourceSchema,
|
|
32
|
+
TokenErrorSchema,
|
|
31
33
|
type TokenResponse,
|
|
32
34
|
TokenResponseSchema,
|
|
33
35
|
type UserInfo,
|
|
@@ -48,6 +50,15 @@ export interface ExchangeCodeOptions {
|
|
|
48
50
|
readonly codeVerifier?: string | undefined
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
interface AuthorizationCodeTokenBody {
|
|
54
|
+
readonly grant_type: string
|
|
55
|
+
readonly client_id: string
|
|
56
|
+
readonly client_secret: string
|
|
57
|
+
readonly code: string
|
|
58
|
+
readonly redirect_uri: string
|
|
59
|
+
code_verifier?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
51
62
|
/**
|
|
52
63
|
* Exchange authorization code for tokens.
|
|
53
64
|
*
|
|
@@ -57,10 +68,10 @@ export const exchangeCodeForTokens = (
|
|
|
57
68
|
code: string,
|
|
58
69
|
config: OAuthConfig,
|
|
59
70
|
options: ExchangeCodeOptions
|
|
60
|
-
)
|
|
71
|
+
) =>
|
|
61
72
|
Effect.gen(function*() {
|
|
62
73
|
const httpClient = yield* HttpClient.HttpClient
|
|
63
|
-
const tokenBody:
|
|
74
|
+
const tokenBody: AuthorizationCodeTokenBody = {
|
|
64
75
|
grant_type: "authorization_code",
|
|
65
76
|
client_id: config.clientId,
|
|
66
77
|
client_secret: config.clientSecret,
|
|
@@ -191,8 +202,21 @@ export const refreshToken = (
|
|
|
191
202
|
const text = yield* response.text.pipe(
|
|
192
203
|
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
193
204
|
)
|
|
205
|
+
// The OAuth `error` code is what distinguishes "this grant is spent" from
|
|
206
|
+
// "your request was malformed", and callers key credential deletion off
|
|
207
|
+
// it. A body that is not JSON, or not shaped like an OAuth error, simply
|
|
208
|
+
// leaves it absent — which callers read as "no verdict", not "rejected".
|
|
209
|
+
const errorCode = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(TokenErrorSchema))(text).pipe(
|
|
210
|
+
Effect.map((decoded) => decoded.error),
|
|
211
|
+
Effect.catch(() => Effect.succeed(undefined))
|
|
212
|
+
)
|
|
194
213
|
return yield* Effect.fail(
|
|
195
|
-
new OAuthError({
|
|
214
|
+
new OAuthError({
|
|
215
|
+
step: "refresh",
|
|
216
|
+
cause: `HTTP ${response.status}: ${text}`,
|
|
217
|
+
status: response.status,
|
|
218
|
+
...(!(errorCode === undefined) && { errorCode })
|
|
219
|
+
})
|
|
196
220
|
)
|
|
197
221
|
}
|
|
198
222
|
|
|
@@ -260,14 +284,15 @@ export const revokeToken = (
|
|
|
260
284
|
*
|
|
261
285
|
* @category Utilities
|
|
262
286
|
*/
|
|
263
|
-
export const
|
|
287
|
+
export const buildOAuthTokenAt = (
|
|
264
288
|
tokenResponse: TokenResponse,
|
|
265
289
|
site: AccessibleResource,
|
|
266
|
-
user: UserInfo
|
|
290
|
+
user: UserInfo,
|
|
291
|
+
nowMs: number
|
|
267
292
|
): OAuthToken => ({
|
|
268
293
|
access_token: tokenResponse.access_token,
|
|
269
294
|
refresh_token: tokenResponse.refresh_token,
|
|
270
|
-
expires_at:
|
|
295
|
+
expires_at: nowMs + tokenResponse.expires_in * 1000,
|
|
271
296
|
scope: tokenResponse.scope,
|
|
272
297
|
cloud_id: site.id,
|
|
273
298
|
site_url: site.url,
|
|
@@ -277,3 +302,16 @@ export const buildOAuthToken = (
|
|
|
277
302
|
email: user.email
|
|
278
303
|
}
|
|
279
304
|
})
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Build an OAuth token using the host clock.
|
|
308
|
+
*
|
|
309
|
+
* @deprecated Effect workflows should call {@link buildOAuthTokenAt} with
|
|
310
|
+
* `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
|
|
311
|
+
* public API until the next major release.
|
|
312
|
+
*/
|
|
313
|
+
export const buildOAuthToken = (
|
|
314
|
+
tokenResponse: TokenResponse,
|
|
315
|
+
site: AccessibleResource,
|
|
316
|
+
user: UserInfo
|
|
317
|
+
): OAuthToken => buildOAuthTokenAt(tokenResponse, site, user, unsafeCurrentTimeMillis())
|
|
@@ -11,6 +11,18 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as Schema from "effect/Schema"
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* OAuth 2.0 error response body (RFC 6749 §5.2).
|
|
16
|
+
*
|
|
17
|
+
* Only `error` is required; providers vary on the rest, so nothing else is
|
|
18
|
+
* modelled — the code is the part callers act on.
|
|
19
|
+
*
|
|
20
|
+
* @category Schema
|
|
21
|
+
*/
|
|
22
|
+
export const TokenErrorSchema = Schema.Struct({
|
|
23
|
+
error: Schema.String
|
|
24
|
+
})
|
|
25
|
+
|
|
14
26
|
/**
|
|
15
27
|
* Schema for OAuth2 token response from Atlassian.
|
|
16
28
|
*
|
package/src/auth/index.ts
CHANGED
|
@@ -4,14 +4,18 @@
|
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
// Endpoints and
|
|
7
|
+
// Endpoints and legacy public scope sets. Control Center owns a separate
|
|
8
|
+
// least-privilege release-publication set; proposal-only consumers can use
|
|
9
|
+
// JIRA_PROPOSAL_SCOPES and other write-capable consumers can use JIRA_SCOPES.
|
|
8
10
|
export {
|
|
9
11
|
AUTH_URL,
|
|
10
12
|
buildAuthUrl,
|
|
11
13
|
type BuildAuthUrlOptions,
|
|
12
14
|
computeCodeChallenge,
|
|
15
|
+
CONFLUENCE_FOLDER_SCOPES,
|
|
13
16
|
CONFLUENCE_SCOPES,
|
|
14
17
|
generateCodeVerifier,
|
|
18
|
+
JIRA_PROPOSAL_SCOPES,
|
|
15
19
|
JIRA_SCOPES,
|
|
16
20
|
ME_URL,
|
|
17
21
|
RESOURCES_URL,
|
|
@@ -25,6 +29,7 @@ export { AuthMissingError, OAuthError, OAuthNotConfiguredError, type OAuthStep }
|
|
|
25
29
|
// Operations
|
|
26
30
|
export {
|
|
27
31
|
buildOAuthToken,
|
|
32
|
+
buildOAuthTokenAt,
|
|
28
33
|
exchangeCodeForTokens,
|
|
29
34
|
type ExchangeCodeOptions,
|
|
30
35
|
getAccessibleResources,
|
package/src/bin.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { NodeHttpClient, NodeRuntime, NodeServices } from "@effect/platform-node"
|
|
3
3
|
import * as Console from "effect/Console"
|
|
4
4
|
import * as Effect from "effect/Effect"
|
|
5
5
|
import * as Layer from "effect/Layer"
|
|
@@ -99,17 +99,16 @@ const program = Effect.gen(function*() {
|
|
|
99
99
|
const args = yield* stdio.args
|
|
100
100
|
return yield* cli(args)
|
|
101
101
|
}).pipe(
|
|
102
|
-
Effect.provide(NodeStdio.layer),
|
|
103
102
|
Effect.provide(
|
|
104
103
|
Layer.mergeAll(
|
|
105
104
|
NodeServices.layer,
|
|
106
|
-
NodeFileSystem.layer,
|
|
107
|
-
NodePath.layer,
|
|
108
105
|
NodeHttpClient.layerFetch,
|
|
109
106
|
HomeDirectoryLive
|
|
110
107
|
)
|
|
111
108
|
),
|
|
112
|
-
Effect.catch((error:
|
|
109
|
+
Effect.catch(<UnparsedInput>(error: UnparsedInput) =>
|
|
110
|
+
Console.error(String(error)).pipe(Effect.andThen(Effect.fail(error)))
|
|
111
|
+
)
|
|
113
112
|
)
|
|
114
113
|
|
|
115
114
|
NodeRuntime.runMain(program, { disableErrorReporting: true })
|
|
@@ -71,9 +71,11 @@ export type AuthProfilesFile = Schema.Schema.Type<typeof AuthProfilesFileSchema>
|
|
|
71
71
|
|
|
72
72
|
const emptyProfiles = (): AuthProfilesFile => ({ profiles: [] })
|
|
73
73
|
|
|
74
|
-
const
|
|
74
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
|
|
75
|
+
|
|
76
|
+
const parseJsonOrNull = (content: string): Schema.Json | null => {
|
|
75
77
|
try {
|
|
76
|
-
return
|
|
78
|
+
return decodeJson(content)
|
|
77
79
|
} catch {
|
|
78
80
|
return null
|
|
79
81
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module
|
|
5
5
|
*/
|
|
6
|
+
import * as Clock from "effect/Clock"
|
|
6
7
|
import * as Data from "effect/Data"
|
|
7
8
|
import * as Effect from "effect/Effect"
|
|
8
9
|
import * as FileSystem from "effect/FileSystem"
|
|
@@ -10,7 +11,7 @@ import * as Path from "effect/Path"
|
|
|
10
11
|
import type * as PlatformError from "effect/PlatformError"
|
|
11
12
|
import * as Schema from "effect/Schema"
|
|
12
13
|
import type { HttpClient } from "effect/unstable/http"
|
|
13
|
-
import
|
|
14
|
+
import { OAuthError } from "../auth/OAuthErrors.js"
|
|
14
15
|
import { refreshToken } from "../auth/OAuthOperations.js"
|
|
15
16
|
import {
|
|
16
17
|
type AuthProfile,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
} from "./AuthProfiles.js"
|
|
23
24
|
import { getProfilesPath, type HomeDirectoryError, HomeDirectoryTag } from "./ConfigPaths.js"
|
|
24
25
|
import { type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
|
|
25
|
-
import { FileSystemError,
|
|
26
|
+
import { FileSystemError, isTokenExpiredAt, loadOAuthConfig } from "./TokenStorage.js"
|
|
26
27
|
|
|
27
28
|
export interface AtlassianToolDefinition {
|
|
28
29
|
readonly toolName: string
|
|
@@ -42,6 +43,25 @@ export const JIRA_REQUIRED_SCOPES: ReadonlyArray<string> = [
|
|
|
42
43
|
"offline_access"
|
|
43
44
|
]
|
|
44
45
|
|
|
46
|
+
/** Legacy read-only Jira requirements retained for proposal-only consumers. */
|
|
47
|
+
export const JIRA_PROPOSAL_REQUIRED_SCOPES: ReadonlyArray<string> = [
|
|
48
|
+
"read:jira-work",
|
|
49
|
+
"read:jira-user",
|
|
50
|
+
"read:me",
|
|
51
|
+
"offline_access"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Scopes a Confluence profile must hold to be considered valid.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately page-only. The CLI also requests the folder and CQL-search scopes
|
|
58
|
+
* (`CONFLUENCE_FOLDER_SCOPES`), but they are not listed here: this constant also
|
|
59
|
+
* gates control-center's per-site scope check, and a profile that can read and
|
|
60
|
+
* write pages is legitimately valid for everything except `folder`/`search`.
|
|
61
|
+
* The cost is that `atlassian profiles doctor` reports a pre-folder profile as
|
|
62
|
+
* valid and those two commands then fail with 401/403 until the OAuth app is
|
|
63
|
+
* updated and the user logs in again — see the confluence-to-markdown README.
|
|
64
|
+
*/
|
|
45
65
|
export const CONFLUENCE_REQUIRED_SCOPES: ReadonlyArray<string> = [
|
|
46
66
|
"read:page:confluence",
|
|
47
67
|
"write:page:confluence",
|
|
@@ -135,7 +155,7 @@ const loadLegacyToken = (
|
|
|
135
155
|
)
|
|
136
156
|
const parsed = yield* Effect.try({
|
|
137
157
|
try: () => JSON.parse(content),
|
|
138
|
-
catch: (
|
|
158
|
+
catch: (): "invalid-json" => "invalid-json"
|
|
139
159
|
}).pipe(Effect.catch(() => Effect.succeed(null)))
|
|
140
160
|
if (parsed === null) return null
|
|
141
161
|
return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
|
|
@@ -166,12 +186,15 @@ export const inspectToolProfiles = (
|
|
|
166
186
|
const [store, config] = yield* Effect.all([loadProfiles(storeName), loadOAuthConfig(storeName)])
|
|
167
187
|
const activeProfile = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
|
|
168
188
|
null
|
|
189
|
+
const nowMs = yield* Clock.currentTimeMillis
|
|
169
190
|
return {
|
|
170
191
|
tool,
|
|
171
192
|
authStoreName: storeName,
|
|
172
193
|
activeProfile,
|
|
173
194
|
profiles: store.profiles,
|
|
174
|
-
tokenStatus: activeProfile === null ? "missing" :
|
|
195
|
+
tokenStatus: activeProfile === null ? "missing" : isTokenExpiredAt(activeProfile.token, nowMs, 0)
|
|
196
|
+
? "expired"
|
|
197
|
+
: "valid",
|
|
175
198
|
missingScopes: activeProfile === null ? [] : missingScopes(activeProfile.token, tool.requiredScopes),
|
|
176
199
|
oauthConfigured: config !== null
|
|
177
200
|
}
|
|
@@ -248,6 +271,9 @@ export const migrateLegacyProfiles = (
|
|
|
248
271
|
return yield* inspectAllToolProfiles(tools)
|
|
249
272
|
})
|
|
250
273
|
|
|
274
|
+
/** Matches `JiraAuth`'s refresh deadline; see the rotation comment below. */
|
|
275
|
+
const REFRESH_TIMEOUT = "30 seconds"
|
|
276
|
+
|
|
251
277
|
/**
|
|
252
278
|
* Refresh expired active profiles.
|
|
253
279
|
*
|
|
@@ -268,13 +294,36 @@ export const refreshActiveProfiles = (
|
|
|
268
294
|
const store = yield* loadProfiles(storeName)
|
|
269
295
|
const active = store.profiles.find((profile) => profile.id === store.activeProfileId) ?? store.profiles[0] ??
|
|
270
296
|
null
|
|
271
|
-
|
|
297
|
+
const nowMs = yield* Clock.currentTimeMillis
|
|
298
|
+
if (!active || !isTokenExpiredAt(active.token, nowMs, 0)) return
|
|
272
299
|
const config = yield* loadOAuthConfig(storeName)
|
|
273
300
|
if (!config) {
|
|
274
301
|
return yield* Effect.fail(new MissingOAuthConfigError({ authStoreName: storeName, profileId: active.id }))
|
|
275
302
|
}
|
|
276
|
-
|
|
277
|
-
|
|
303
|
+
// Rotating refresh tokens: the grant consumes the stored token
|
|
304
|
+
// server-side and the response carries its replacement, so an interrupt
|
|
305
|
+
// between the two spends the credential without persisting what
|
|
306
|
+
// replaced it — the next refresh 4xxs and the user is silently logged
|
|
307
|
+
// out. Keep the grant and the persist atomic, as `JiraAuth` does.
|
|
308
|
+
// The deadline is inside the region on purpose: an uninterruptible
|
|
309
|
+
// region with no bound of its own absorbs SIGINT/SIGTERM entirely
|
|
310
|
+
// (`runMain`'s handlers only interrupt the main fiber), so `atlassian
|
|
311
|
+
// auth refresh` would stop answering Ctrl-C against a stalled token
|
|
312
|
+
// endpoint. A deadline forked inside the region is still interruptible,
|
|
313
|
+
// so it does bound this. Failing here leaves the stored token in place
|
|
314
|
+
// — a refresh that never answered says nothing about its validity.
|
|
315
|
+
yield* Effect.uninterruptible(
|
|
316
|
+
Effect.gen(function*() {
|
|
317
|
+
const refreshed = yield* refreshToken(active.token, config).pipe(
|
|
318
|
+
Effect.timeout(REFRESH_TIMEOUT),
|
|
319
|
+
Effect.catchTag(
|
|
320
|
+
"TimeoutError",
|
|
321
|
+
() => Effect.fail(new OAuthError({ step: "refresh", cause: `no response within ${REFRESH_TIMEOUT}` }))
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
yield* saveProfileToken(storeName, refreshed)
|
|
325
|
+
})
|
|
326
|
+
)
|
|
278
327
|
}))
|
|
279
328
|
return yield* inspectAllToolProfiles(tools)
|
|
280
329
|
})
|
|
@@ -23,13 +23,16 @@ import * as FileSystem from "effect/FileSystem"
|
|
|
23
23
|
import type * as Path from "effect/Path"
|
|
24
24
|
import type * as PlatformError from "effect/PlatformError"
|
|
25
25
|
import * as Schema from "effect/Schema"
|
|
26
|
+
import { unsafeCurrentTimeMillis } from "../internal/legacyWallClock.js"
|
|
26
27
|
import { type HomeDirectoryError, type HomeDirectoryTag } from "./ConfigPaths.js"
|
|
27
28
|
import { ensureConfigDir, getAuthPath, getOAuthConfigPath, writeSecureFile } from "./ConfigPaths.js"
|
|
28
29
|
import { type OAuthConfig, OAuthConfigSchema, type OAuthToken, OAuthTokenSchema } from "./OAuthSchemas.js"
|
|
29
30
|
|
|
30
|
-
const
|
|
31
|
+
const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Json))
|
|
32
|
+
|
|
33
|
+
const parseJsonOrNull = (content: string): Schema.Json | null => {
|
|
31
34
|
try {
|
|
32
|
-
return
|
|
35
|
+
return decodeJson(content)
|
|
33
36
|
} catch {
|
|
34
37
|
return null
|
|
35
38
|
}
|
|
@@ -70,7 +73,7 @@ export const loadToken = (
|
|
|
70
73
|
const tokenPath = yield* getAuthPath(toolName)
|
|
71
74
|
|
|
72
75
|
const exists = yield* fs.exists(tokenPath).pipe(
|
|
73
|
-
Effect.
|
|
76
|
+
Effect.catch(() => Effect.succeed(false))
|
|
74
77
|
)
|
|
75
78
|
if (!exists) {
|
|
76
79
|
return null
|
|
@@ -87,7 +90,7 @@ export const loadToken = (
|
|
|
87
90
|
}
|
|
88
91
|
|
|
89
92
|
return yield* Schema.decodeUnknownEffect(OAuthTokenSchema)(parsed).pipe(
|
|
90
|
-
Effect.
|
|
93
|
+
Effect.catch((e) =>
|
|
91
94
|
Effect.logWarning(`Invalid token schema in ${tokenPath}: ${e}`).pipe(
|
|
92
95
|
Effect.map(() => null)
|
|
93
96
|
)
|
|
@@ -136,7 +139,7 @@ export const deleteToken = (
|
|
|
136
139
|
const tokenPath = yield* getAuthPath(toolName)
|
|
137
140
|
|
|
138
141
|
yield* fs.remove(tokenPath).pipe(
|
|
139
|
-
Effect.
|
|
142
|
+
Effect.catch(() => Effect.void)
|
|
140
143
|
)
|
|
141
144
|
})
|
|
142
145
|
|
|
@@ -160,7 +163,7 @@ export const loadOAuthConfig = (
|
|
|
160
163
|
const configPath = yield* getOAuthConfigPath(toolName)
|
|
161
164
|
|
|
162
165
|
const exists = yield* fs.exists(configPath).pipe(
|
|
163
|
-
Effect.
|
|
166
|
+
Effect.catch(() => Effect.succeed(false))
|
|
164
167
|
)
|
|
165
168
|
if (!exists) {
|
|
166
169
|
return null
|
|
@@ -177,7 +180,7 @@ export const loadOAuthConfig = (
|
|
|
177
180
|
}
|
|
178
181
|
|
|
179
182
|
return yield* Schema.decodeUnknownEffect(OAuthConfigSchema)(parsed).pipe(
|
|
180
|
-
Effect.
|
|
183
|
+
Effect.catch((e) =>
|
|
181
184
|
Effect.logWarning(`Invalid OAuth config schema in ${configPath}: ${e}`).pipe(
|
|
182
185
|
Effect.map(() => null)
|
|
183
186
|
)
|
|
@@ -220,5 +223,18 @@ export const saveOAuthConfig = (
|
|
|
220
223
|
*
|
|
221
224
|
* @category Utilities
|
|
222
225
|
*/
|
|
226
|
+
export const isTokenExpiredAt = (
|
|
227
|
+
token: OAuthToken,
|
|
228
|
+
nowMs: number,
|
|
229
|
+
bufferMs: number = 5 * 60 * 1000
|
|
230
|
+
): boolean => nowMs >= token.expires_at - bufferMs
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Check token expiration using the host clock.
|
|
234
|
+
*
|
|
235
|
+
* @deprecated Effect workflows should call {@link isTokenExpiredAt} with
|
|
236
|
+
* `Clock.currentTimeMillis`. This wrapper preserves the existing synchronous
|
|
237
|
+
* public API until the next major release.
|
|
238
|
+
*/
|
|
223
239
|
export const isTokenExpired = (token: OAuthToken, bufferMs: number = 5 * 60 * 1000): boolean =>
|
|
224
|
-
|
|
240
|
+
isTokenExpiredAt(token, unsafeCurrentTimeMillis(), bufferMs)
|
package/src/config/index.ts
CHANGED
|
@@ -37,6 +37,7 @@ export {
|
|
|
37
37
|
deleteToken,
|
|
38
38
|
FileSystemError,
|
|
39
39
|
isTokenExpired,
|
|
40
|
+
isTokenExpiredAt,
|
|
40
41
|
loadOAuthConfig,
|
|
41
42
|
loadToken,
|
|
42
43
|
saveOAuthConfig,
|
|
@@ -72,6 +73,7 @@ export {
|
|
|
72
73
|
CONFLUENCE_REQUIRED_SCOPES,
|
|
73
74
|
inspectAllToolProfiles,
|
|
74
75
|
inspectToolProfiles,
|
|
76
|
+
JIRA_PROPOSAL_REQUIRED_SCOPES,
|
|
75
77
|
JIRA_REQUIRED_SCOPES,
|
|
76
78
|
migrateLegacyProfiles,
|
|
77
79
|
MissingOAuthConfigError,
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compatibility boundary for legacy synchronous OAuth helpers.
|
|
3
|
+
*
|
|
4
|
+
* New Effect workflows must obtain time from `Clock.currentTimeMillis` and call
|
|
5
|
+
* the corresponding `*At` helper. This host-clock read remains only so the
|
|
6
|
+
* existing synchronous public API does not change without a major release.
|
|
7
|
+
*
|
|
8
|
+
* @internal
|
|
9
|
+
*/
|
|
10
|
+
export const unsafeCurrentTimeMillis = (): number => Date.now()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import * as NodeServices from "@effect/platform-node/NodeServices"
|
|
2
|
+
import { describe, expect, it } from "@effect/vitest"
|
|
3
|
+
import * as Effect from "effect/Effect"
|
|
4
|
+
import * as FileSystem from "effect/FileSystem"
|
|
5
|
+
import * as Path from "effect/Path"
|
|
6
|
+
import * as Predicate from "effect/Predicate"
|
|
7
|
+
import type * as Schema from "effect/Schema"
|
|
8
|
+
import { parse } from "yaml"
|
|
9
|
+
|
|
10
|
+
const isRecord = <UnparsedInput>(value: UnparsedInput): value is UnparsedInput & Record<string, Schema.Json> =>
|
|
11
|
+
Predicate.isObjectOrArray(value) && value !== null && !Array.isArray(value)
|
|
12
|
+
|
|
13
|
+
const buildCommands = (source: string): ReadonlyArray<string> => {
|
|
14
|
+
const workflow: unknown = parse(source)
|
|
15
|
+
if (!isRecord(workflow) || !isRecord(workflow.jobs)) return []
|
|
16
|
+
|
|
17
|
+
return Object.values(workflow.jobs).flatMap((job) => {
|
|
18
|
+
if (!isRecord(job) || !Array.isArray(job.steps)) return []
|
|
19
|
+
return job.steps.flatMap((step) => {
|
|
20
|
+
if (!isRecord(step) || !Predicate.isString(step.name) || !step.name.startsWith("Build ")) return []
|
|
21
|
+
return Predicate.isString(step.run) ? [step.run] : []
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const dependencyClosureDiagnostics = (
|
|
27
|
+
source: string,
|
|
28
|
+
expectedConsumers: ReadonlyArray<string>
|
|
29
|
+
): ReadonlyArray<string> => {
|
|
30
|
+
const commands = buildCommands(source)
|
|
31
|
+
return expectedConsumers.flatMap((consumer) => {
|
|
32
|
+
const dependencyClosedFilter = `--filter ${consumer}...`
|
|
33
|
+
return commands.some((command) => command.includes(dependencyClosedFilter) && /\bbuild\b/u.test(command))
|
|
34
|
+
? []
|
|
35
|
+
: [`API update workflow must build ${consumer} with its workspace dependency closure`]
|
|
36
|
+
})
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const loadWorkflow = (name: string) =>
|
|
40
|
+
Effect.gen(function*() {
|
|
41
|
+
const fileSystem = yield* FileSystem.FileSystem
|
|
42
|
+
const path = yield* Path.Path
|
|
43
|
+
const workflowPath = yield* path.fromFileUrl(new URL(`../../../.github/workflows/${name}`, import.meta.url))
|
|
44
|
+
return yield* fileSystem.readFileString(workflowPath)
|
|
45
|
+
}).pipe(Effect.provide(NodeServices.layer))
|
|
46
|
+
|
|
47
|
+
describe("API update workflow build closure", () => {
|
|
48
|
+
it("rejects a bare consumer build and accepts a dependency-closed build", () => {
|
|
49
|
+
const invalid = `
|
|
50
|
+
jobs:
|
|
51
|
+
update:
|
|
52
|
+
steps:
|
|
53
|
+
- name: Build generated client and consumer
|
|
54
|
+
run: pnpm --filter @knpkv/confluence-to-markdown build
|
|
55
|
+
`
|
|
56
|
+
const valid = `
|
|
57
|
+
jobs:
|
|
58
|
+
update:
|
|
59
|
+
steps:
|
|
60
|
+
- name: Build generated client and consumer
|
|
61
|
+
run: pnpm --filter @knpkv/confluence-to-markdown... build
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
expect(dependencyClosureDiagnostics(invalid, ["@knpkv/confluence-to-markdown"])).toEqual([
|
|
65
|
+
"API update workflow must build @knpkv/confluence-to-markdown with its workspace dependency closure"
|
|
66
|
+
])
|
|
67
|
+
expect(dependencyClosureDiagnostics(valid, ["@knpkv/confluence-to-markdown"])).toEqual([])
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it.effect("builds every API consumer with its workspace dependencies", () =>
|
|
71
|
+
Effect.gen(function*() {
|
|
72
|
+
const confluence = yield* loadWorkflow("confluence-api-update.yml")
|
|
73
|
+
const jira = yield* loadWorkflow("jira-api-update.yml")
|
|
74
|
+
const clockify = yield* loadWorkflow("clockify-api-update.yml")
|
|
75
|
+
|
|
76
|
+
expect(dependencyClosureDiagnostics(confluence, ["@knpkv/confluence-to-markdown"])).toEqual([])
|
|
77
|
+
expect(dependencyClosureDiagnostics(jira, ["@knpkv/jira-cli", "@knpkv/jira-clockify"])).toEqual([])
|
|
78
|
+
expect(dependencyClosureDiagnostics(clockify, ["@knpkv/jira-clockify"])).toEqual([])
|
|
79
|
+
}))
|
|
80
|
+
})
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
AUTH_URL,
|
|
7
7
|
buildAuthUrl,
|
|
8
8
|
computeCodeChallenge,
|
|
9
|
+
CONFLUENCE_FOLDER_SCOPES,
|
|
9
10
|
CONFLUENCE_SCOPES,
|
|
10
11
|
generateCodeVerifier
|
|
11
12
|
} from "../src/auth/OAuthEndpoints.js"
|
|
@@ -18,6 +19,28 @@ describe("OAuthEndpoints", () => {
|
|
|
18
19
|
expect(CONFLUENCE_SCOPES).toContain("read:attachment:confluence")
|
|
19
20
|
expect(CONFLUENCE_SCOPES).toContain("write:attachment:confluence")
|
|
20
21
|
})
|
|
22
|
+
|
|
23
|
+
// Control Center feeds this constant straight into its own authorize
|
|
24
|
+
// request, so folder authority must not leak in here — it would widen every
|
|
25
|
+
// Control Center sign-in to a scope its documented OAuth app never enables.
|
|
26
|
+
it("carries no folder or search authority", () => {
|
|
27
|
+
for (const scope of CONFLUENCE_FOLDER_SCOPES) {
|
|
28
|
+
expect(CONFLUENCE_SCOPES).not.toContain(scope)
|
|
29
|
+
}
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
describe("CONFLUENCE_FOLDER_SCOPES", () => {
|
|
34
|
+
// Exact strings: Atlassian rejects the whole authorize request for one
|
|
35
|
+
// unrecognized scope, and a typo surfaces only on a real consent screen.
|
|
36
|
+
it("names the scopes the folder and CQL-search endpoints require", () => {
|
|
37
|
+
expect([...CONFLUENCE_FOLDER_SCOPES].sort()).toEqual([
|
|
38
|
+
"read:content-details:confluence",
|
|
39
|
+
"read:folder:confluence",
|
|
40
|
+
"read:hierarchical-content:confluence",
|
|
41
|
+
"write:folder:confluence"
|
|
42
|
+
])
|
|
43
|
+
})
|
|
21
44
|
})
|
|
22
45
|
|
|
23
46
|
// ---- PKCE ----
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { describe, expect, it } from "@effect/vitest"
|
|
2
2
|
import { ConfigProvider, Effect, Layer } from "effect"
|
|
3
|
+
import * as Clock from "effect/Clock"
|
|
4
|
+
import * as Deferred from "effect/Deferred"
|
|
5
|
+
import * as Fiber from "effect/Fiber"
|
|
3
6
|
import * as FileSystem from "effect/FileSystem"
|
|
4
7
|
import * as Path from "effect/Path"
|
|
5
8
|
import { SystemError } from "effect/PlatformError"
|
|
9
|
+
import { HttpClient, HttpClientResponse } from "effect/unstable/http"
|
|
6
10
|
import {
|
|
7
11
|
type AtlassianToolDefinition,
|
|
8
12
|
HomeDirectoryLive,
|
|
@@ -13,6 +17,7 @@ import {
|
|
|
13
17
|
type OAuthToken,
|
|
14
18
|
ProfileNotFoundError,
|
|
15
19
|
refreshActiveProfiles,
|
|
20
|
+
saveOAuthConfig,
|
|
16
21
|
saveProfileToken,
|
|
17
22
|
useProfileForAllTools
|
|
18
23
|
} from "../src/config/index.js"
|
|
@@ -175,6 +180,62 @@ describe("ProfileManager", () => {
|
|
|
175
180
|
).rejects.toBeInstanceOf(ProfileNotFoundError)
|
|
176
181
|
})
|
|
177
182
|
|
|
183
|
+
// Atlassian rotates refresh tokens, so the grant consumes the stored one and
|
|
184
|
+
// the response carries its replacement. If an interrupt can land between the
|
|
185
|
+
// two, the credential is spent with nothing saved and the profile is silently
|
|
186
|
+
// logged out — hence the uninterruptible region around grant-and-persist.
|
|
187
|
+
it("persists the rotated token even when interrupted mid-rotation", async () => {
|
|
188
|
+
const mock = makeMockFS()
|
|
189
|
+
const program = Effect.gen(function*() {
|
|
190
|
+
yield* saveOAuthConfig("tool-a", { clientId: "client-1", clientSecret: "secret-1" })
|
|
191
|
+
const nowMs = yield* Clock.currentTimeMillis
|
|
192
|
+
yield* saveProfileToken("tool-a", makeToken(1, "read:me offline_access", nowMs - 1_000))
|
|
193
|
+
|
|
194
|
+
const issued = yield* Deferred.make<void>()
|
|
195
|
+
const release = yield* Deferred.make<void>()
|
|
196
|
+
const client = Layer.succeed(
|
|
197
|
+
HttpClient.HttpClient,
|
|
198
|
+
HttpClient.make((request) =>
|
|
199
|
+
Effect.gen(function*() {
|
|
200
|
+
yield* Deferred.succeed(issued, undefined)
|
|
201
|
+
yield* Deferred.await(release)
|
|
202
|
+
return HttpClientResponse.fromWeb(
|
|
203
|
+
request,
|
|
204
|
+
new Response(
|
|
205
|
+
JSON.stringify({
|
|
206
|
+
access_token: "fresh-access",
|
|
207
|
+
refresh_token: "rotated-refresh",
|
|
208
|
+
expires_in: 3600,
|
|
209
|
+
scope: "read:me offline_access",
|
|
210
|
+
token_type: "Bearer"
|
|
211
|
+
}),
|
|
212
|
+
{ status: 200, headers: { "content-type": "application/json" } }
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
})
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const fiber = yield* refreshActiveProfiles([tools[0]!]).pipe(
|
|
220
|
+
Effect.provide(client),
|
|
221
|
+
Effect.exit,
|
|
222
|
+
Effect.forkChild({ startImmediately: true })
|
|
223
|
+
)
|
|
224
|
+
yield* Deferred.await(issued)
|
|
225
|
+
const interrupting = yield* Fiber.interrupt(fiber).pipe(Effect.forkChild({ startImmediately: true }))
|
|
226
|
+
yield* Deferred.succeed(release, undefined)
|
|
227
|
+
yield* Fiber.join(interrupting)
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
await Effect.runPromise(
|
|
231
|
+
program.pipe(
|
|
232
|
+
Effect.provide(Layer.mergeAll(mock.layer, Path.layer, HomeDirectoryLive, ConfigProviderLive))
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
expect(JSON.stringify(mock.store)).toContain("rotated-refresh")
|
|
237
|
+
})
|
|
238
|
+
|
|
178
239
|
it("fails expired-token refresh when OAuth config is missing", async () => {
|
|
179
240
|
await expect(
|
|
180
241
|
run(
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { JIRA_PROPOSAL_SCOPES, JIRA_SCOPES } from "../src/auth/index.js"
|
|
2
|
+
import { JIRA_PROPOSAL_REQUIRED_SCOPES, JIRA_REQUIRED_SCOPES } from "../src/config/index.js"
|
|
3
|
+
|
|
4
|
+
describe("public compatibility exports", () => {
|
|
5
|
+
it("retains proposal-only Jira scopes beside the write-capable release scopes", () => {
|
|
6
|
+
expect(JIRA_PROPOSAL_SCOPES).toEqual([
|
|
7
|
+
"read:jira-work",
|
|
8
|
+
"read:jira-user",
|
|
9
|
+
"read:me",
|
|
10
|
+
"offline_access"
|
|
11
|
+
])
|
|
12
|
+
expect(JIRA_PROPOSAL_REQUIRED_SCOPES).toEqual(JIRA_PROPOSAL_SCOPES)
|
|
13
|
+
expect(JIRA_SCOPES).toContain("manage:jira-configuration")
|
|
14
|
+
expect(JIRA_REQUIRED_SCOPES).toContain("manage:jira-project")
|
|
15
|
+
})
|
|
16
|
+
})
|