@knpkv/atlassian-common 0.2.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 +11 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/Brand.d.ts +118 -0
- package/dist/Brand.d.ts.map +1 -0
- package/dist/Brand.js +107 -0
- package/dist/Brand.js.map +1 -0
- package/dist/Hash.d.ts +53 -0
- package/dist/Hash.d.ts.map +1 -0
- package/dist/Hash.js +63 -0
- package/dist/Hash.js.map +1 -0
- package/dist/SerializeError.d.ts +48 -0
- package/dist/SerializeError.d.ts.map +1 -0
- package/dist/SerializeError.js +42 -0
- package/dist/SerializeError.js.map +1 -0
- package/dist/ast/BlockNode.d.ts +429 -0
- package/dist/ast/BlockNode.d.ts.map +1 -0
- package/dist/ast/BlockNode.js +279 -0
- package/dist/ast/BlockNode.js.map +1 -0
- package/dist/ast/Document.d.ts +255 -0
- package/dist/ast/Document.d.ts.map +1 -0
- package/dist/ast/Document.js +80 -0
- package/dist/ast/Document.js.map +1 -0
- package/dist/ast/InlineNode.d.ts +483 -0
- package/dist/ast/InlineNode.d.ts.map +1 -0
- package/dist/ast/InlineNode.js +268 -0
- package/dist/ast/InlineNode.js.map +1 -0
- package/dist/ast/MacroNode.d.ts +214 -0
- package/dist/ast/MacroNode.d.ts.map +1 -0
- package/dist/ast/MacroNode.js +110 -0
- package/dist/ast/MacroNode.js.map +1 -0
- package/dist/ast/index.d.ts +10 -0
- package/dist/ast/index.d.ts.map +1 -0
- package/dist/ast/index.js +14 -0
- package/dist/ast/index.js.map +1 -0
- package/dist/auth/OAuthEndpoints.d.ts +95 -0
- package/dist/auth/OAuthEndpoints.d.ts.map +1 -0
- package/dist/auth/OAuthEndpoints.js +112 -0
- package/dist/auth/OAuthEndpoints.js.map +1 -0
- package/dist/auth/OAuthErrors.d.ts +76 -0
- package/dist/auth/OAuthErrors.d.ts.map +1 -0
- package/dist/auth/OAuthErrors.js +85 -0
- package/dist/auth/OAuthErrors.js.map +1 -0
- package/dist/auth/OAuthOperations.d.ts +87 -0
- package/dist/auth/OAuthOperations.d.ts.map +1 -0
- package/dist/auth/OAuthOperations.js +169 -0
- package/dist/auth/OAuthOperations.js.map +1 -0
- package/dist/auth/OAuthResponseSchemas.d.ts +65 -0
- package/dist/auth/OAuthResponseSchemas.d.ts.map +1 -0
- package/dist/auth/OAuthResponseSchemas.js +47 -0
- package/dist/auth/OAuthResponseSchemas.js.map +1 -0
- package/dist/auth/index.d.ts +11 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +16 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/uuid.d.ts +14 -0
- package/dist/auth/uuid.d.ts.map +1 -0
- package/dist/auth/uuid.js +14 -0
- package/dist/auth/uuid.js.map +1 -0
- package/dist/config/ConfigPaths.d.ts +95 -0
- package/dist/config/ConfigPaths.d.ts.map +1 -0
- package/dist/config/ConfigPaths.js +102 -0
- package/dist/config/ConfigPaths.js.map +1 -0
- package/dist/config/OAuthSchemas.d.ts +144 -0
- package/dist/config/OAuthSchemas.d.ts.map +1 -0
- package/dist/config/OAuthSchemas.js +107 -0
- package/dist/config/OAuthSchemas.js.map +1 -0
- package/dist/config/TokenStorage.d.ts +96 -0
- package/dist/config/TokenStorage.d.ts.map +1 -0
- package/dist/config/TokenStorage.js +128 -0
- package/dist/config/TokenStorage.js.map +1 -0
- package/dist/config/index.d.ts +9 -0
- package/dist/config/index.d.ts.map +1 -0
- package/dist/config/index.js +12 -0
- package/dist/config/index.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/parsers/index.d.ts +7 -0
- package/dist/parsers/index.d.ts.map +1 -0
- package/dist/parsers/index.js +8 -0
- package/dist/parsers/index.js.map +1 -0
- package/dist/serializers/MarkdownSerializer.d.ts +63 -0
- package/dist/serializers/MarkdownSerializer.d.ts.map +1 -0
- package/dist/serializers/MarkdownSerializer.js +367 -0
- package/dist/serializers/MarkdownSerializer.js.map +1 -0
- package/dist/serializers/index.d.ts +7 -0
- package/dist/serializers/index.d.ts.map +1 -0
- package/dist/serializers/index.js +7 -0
- package/dist/serializers/index.js.map +1 -0
- package/package.json +86 -0
- package/src/Brand.ts +142 -0
- package/src/Hash.ts +68 -0
- package/src/SerializeError.ts +50 -0
- package/src/ast/BlockNode.ts +386 -0
- package/src/ast/Document.ts +101 -0
- package/src/ast/InlineNode.ts +328 -0
- package/src/ast/MacroNode.ts +172 -0
- package/src/ast/index.ts +87 -0
- package/src/auth/OAuthEndpoints.ts +142 -0
- package/src/auth/OAuthErrors.ts +101 -0
- package/src/auth/OAuthOperations.ts +276 -0
- package/src/auth/OAuthResponseSchemas.ts +70 -0
- package/src/auth/index.ts +47 -0
- package/src/auth/uuid.ts +14 -0
- package/src/config/ConfigPaths.ts +179 -0
- package/src/config/OAuthSchemas.ts +146 -0
- package/src/config/TokenStorage.ts +228 -0
- package/src/config/index.ts +43 -0
- package/src/index.ts +34 -0
- package/src/parsers/index.ts +8 -0
- package/src/serializers/MarkdownSerializer.ts +498 -0
- package/src/serializers/index.ts +7 -0
- package/test/Brand.test.ts +90 -0
- package/test/MarkdownSerializer.test.ts +82 -0
- package/test/OAuthEndpoints.test.ts +109 -0
- package/test/OAuthOperations.test.ts +315 -0
- package/tsconfig.json +11 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vitest.config.ts +12 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tagged error types for Atlassian OAuth2 flows.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Step-scoped errors**: {@link OAuthError} carries a `step` field (`"configure" | "authorize" |
|
|
7
|
+
* "token" | "refresh" | "revoke"`) so callers can handle failures per-phase.
|
|
8
|
+
* - **Companion errors**: {@link AuthMissingError} (not logged in) and
|
|
9
|
+
* {@link OAuthNotConfiguredError} (no client credentials) represent pre-flow failures.
|
|
10
|
+
*
|
|
11
|
+
* **Gotchas**
|
|
12
|
+
*
|
|
13
|
+
* - `OAuthError.message` is a computed getter derived from `step` + `cause` — it's not
|
|
14
|
+
* a stored field, so don't destructure it from the constructor.
|
|
15
|
+
*
|
|
16
|
+
* @module
|
|
17
|
+
*/
|
|
18
|
+
import * as Data from "effect/Data"
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* OAuth flow step for error context.
|
|
22
|
+
*
|
|
23
|
+
* @category Types
|
|
24
|
+
*/
|
|
25
|
+
export type OAuthStep = "configure" | "authorize" | "token" | "resources" | "user-info" | "refresh" | "revoke"
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Error during OAuth2 flow.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* import { Effect } from "effect"
|
|
33
|
+
* import { OAuthError } from "@knpkv/atlassian-common/auth"
|
|
34
|
+
*
|
|
35
|
+
* Effect.gen(function* () {
|
|
36
|
+
* // ... oauth operation
|
|
37
|
+
* }).pipe(
|
|
38
|
+
* Effect.catchTag("OAuthError", (error) =>
|
|
39
|
+
* Effect.sync(() => console.error(`OAuth error at ${error.step}: ${error.message}`))
|
|
40
|
+
* )
|
|
41
|
+
* )
|
|
42
|
+
* ```
|
|
43
|
+
*
|
|
44
|
+
* @category Errors
|
|
45
|
+
*/
|
|
46
|
+
export class OAuthError extends Data.TaggedError("OAuthError")<{
|
|
47
|
+
readonly step: OAuthStep
|
|
48
|
+
readonly cause?: unknown
|
|
49
|
+
}> {
|
|
50
|
+
override get message(): string {
|
|
51
|
+
if (this.cause instanceof Error) {
|
|
52
|
+
return `OAuth ${this.step} failed: ${this.cause.message}`
|
|
53
|
+
}
|
|
54
|
+
if (typeof this.cause === "string") {
|
|
55
|
+
return `OAuth ${this.step} failed: ${this.cause}`
|
|
56
|
+
}
|
|
57
|
+
return `OAuth ${this.step} failed`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Error when authentication is missing.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* ```typescript
|
|
66
|
+
* import { Effect } from "effect"
|
|
67
|
+
* import { AuthMissingError } from "@knpkv/atlassian-common/auth"
|
|
68
|
+
*
|
|
69
|
+
* Effect.gen(function* () {
|
|
70
|
+
* // ... requires auth
|
|
71
|
+
* }).pipe(
|
|
72
|
+
* Effect.catchTag("AuthMissingError", () =>
|
|
73
|
+
* Effect.sync(() => console.error("Please login first"))
|
|
74
|
+
* )
|
|
75
|
+
* )
|
|
76
|
+
* ```
|
|
77
|
+
*
|
|
78
|
+
* @category Errors
|
|
79
|
+
*/
|
|
80
|
+
export class AuthMissingError extends Data.TaggedError("AuthMissingError")<{
|
|
81
|
+
readonly tool?: string
|
|
82
|
+
}> {
|
|
83
|
+
override get message(): string {
|
|
84
|
+
const toolPart = this.tool ? ` for ${this.tool}` : ""
|
|
85
|
+
return `Not logged in${toolPart}. Please run 'auth login' first.`
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Error when OAuth is not configured.
|
|
91
|
+
*
|
|
92
|
+
* @category Errors
|
|
93
|
+
*/
|
|
94
|
+
export class OAuthNotConfiguredError extends Data.TaggedError("OAuthNotConfiguredError")<{
|
|
95
|
+
readonly tool?: string
|
|
96
|
+
}> {
|
|
97
|
+
override get message(): string {
|
|
98
|
+
const toolPart = this.tool ? `'${this.tool} auth configure'` : "'auth configure'"
|
|
99
|
+
return `OAuth not configured. Run ${toolPart} first.`
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core OAuth2 operations against Atlassian's token and resource endpoints.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Effect-native HTTP**: Every operation takes an `HttpClient` from the Effect
|
|
7
|
+
* context and returns `Effect<A, OAuthError, HttpClient>`. Callers provide the
|
|
8
|
+
* client via `Layer.succeed`.
|
|
9
|
+
* - **Schema-validated responses**: Token and user payloads are decoded through
|
|
10
|
+
* Effect Schema before returning, catching API contract drift at runtime.
|
|
11
|
+
*
|
|
12
|
+
* **Common tasks**
|
|
13
|
+
*
|
|
14
|
+
* - Exchange auth code: {@link exchangeCodeForTokens}
|
|
15
|
+
* - List user sites: {@link getAccessibleResources}
|
|
16
|
+
* - Refresh expired token: {@link refreshToken}
|
|
17
|
+
* - Build storable token: {@link buildOAuthToken}
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import * as HttpClient from "@effect/platform/HttpClient"
|
|
22
|
+
import * as HttpClientRequest from "@effect/platform/HttpClientRequest"
|
|
23
|
+
import * as Effect from "effect/Effect"
|
|
24
|
+
import * as Schema from "effect/Schema"
|
|
25
|
+
import { type OAuthConfig, type OAuthToken } from "../config/OAuthSchemas.js"
|
|
26
|
+
import { ME_URL, RESOURCES_URL, REVOKE_URL, TOKEN_URL } from "./OAuthEndpoints.js"
|
|
27
|
+
import { OAuthError } from "./OAuthErrors.js"
|
|
28
|
+
import {
|
|
29
|
+
type AccessibleResource,
|
|
30
|
+
AccessibleResourceSchema,
|
|
31
|
+
type TokenResponse,
|
|
32
|
+
TokenResponseSchema,
|
|
33
|
+
type UserInfo,
|
|
34
|
+
UserInfoSchema
|
|
35
|
+
} from "./OAuthResponseSchemas.js"
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Options for exchanging an authorization code for tokens.
|
|
39
|
+
*
|
|
40
|
+
* @category Types
|
|
41
|
+
*/
|
|
42
|
+
export interface ExchangeCodeOptions {
|
|
43
|
+
/** Local callback server port (for redirect_uri) */
|
|
44
|
+
readonly port: number
|
|
45
|
+
/** PKCE code verifier (if PKCE was used in auth request) */
|
|
46
|
+
readonly codeVerifier?: string | undefined
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Exchange authorization code for tokens.
|
|
51
|
+
*
|
|
52
|
+
* @category Operations
|
|
53
|
+
*/
|
|
54
|
+
export const exchangeCodeForTokens = (
|
|
55
|
+
code: string,
|
|
56
|
+
config: OAuthConfig,
|
|
57
|
+
options: ExchangeCodeOptions
|
|
58
|
+
): Effect.Effect<TokenResponse, OAuthError, HttpClient.HttpClient> =>
|
|
59
|
+
Effect.gen(function*() {
|
|
60
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
61
|
+
const tokenBody: Record<string, string> = {
|
|
62
|
+
grant_type: "authorization_code",
|
|
63
|
+
client_id: config.clientId,
|
|
64
|
+
client_secret: config.clientSecret,
|
|
65
|
+
code,
|
|
66
|
+
redirect_uri: `http://localhost:${options.port}/callback`
|
|
67
|
+
}
|
|
68
|
+
if (options.codeVerifier) tokenBody.code_verifier = options.codeVerifier
|
|
69
|
+
const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
|
|
70
|
+
HttpClientRequest.setHeader("Content-Type", "application/json"),
|
|
71
|
+
HttpClientRequest.bodyJson(tokenBody)
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
const response = yield* httpClient.execute(request)
|
|
75
|
+
|
|
76
|
+
if (response.status >= 400) {
|
|
77
|
+
const text = yield* response.text
|
|
78
|
+
yield* Effect.logDebug(`Token exchange failed (${response.status}): ${text}`)
|
|
79
|
+
return yield* Effect.fail(
|
|
80
|
+
new OAuthError({ step: "token", cause: `HTTP ${response.status}` })
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const body = yield* response.json
|
|
85
|
+
|
|
86
|
+
return yield* Schema.decodeUnknown(TokenResponseSchema)(body)
|
|
87
|
+
}).pipe(
|
|
88
|
+
Effect.mapError((cause) => new OAuthError({ step: "token", cause }))
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Get accessible resources (sites) for the authenticated user.
|
|
93
|
+
*
|
|
94
|
+
* @param accessToken - OAuth access token
|
|
95
|
+
*
|
|
96
|
+
* @category Operations
|
|
97
|
+
*/
|
|
98
|
+
export const getAccessibleResources = (
|
|
99
|
+
accessToken: string
|
|
100
|
+
): Effect.Effect<ReadonlyArray<AccessibleResource>, OAuthError, HttpClient.HttpClient> =>
|
|
101
|
+
Effect.gen(function*() {
|
|
102
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
103
|
+
const request = HttpClientRequest.get(RESOURCES_URL).pipe(
|
|
104
|
+
HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
|
|
105
|
+
HttpClientRequest.setHeader("Accept", "application/json")
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
const response = yield* httpClient.execute(request)
|
|
109
|
+
|
|
110
|
+
if (response.status >= 400) {
|
|
111
|
+
const text = yield* response.text
|
|
112
|
+
yield* Effect.logDebug(`Accessible resources failed (${response.status}): ${text}`)
|
|
113
|
+
return yield* Effect.fail(
|
|
114
|
+
new OAuthError({ step: "resources", cause: `HTTP ${response.status}` })
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const body = yield* response.json
|
|
119
|
+
|
|
120
|
+
return yield* Schema.decodeUnknown(Schema.Array(AccessibleResourceSchema))(body)
|
|
121
|
+
}).pipe(
|
|
122
|
+
Effect.mapError((cause) => new OAuthError({ step: "resources", cause }))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get user info from /me endpoint.
|
|
127
|
+
*
|
|
128
|
+
* @param accessToken - OAuth access token
|
|
129
|
+
*
|
|
130
|
+
* @category Operations
|
|
131
|
+
*/
|
|
132
|
+
export const getUserInfo = (
|
|
133
|
+
accessToken: string
|
|
134
|
+
): Effect.Effect<UserInfo, OAuthError, HttpClient.HttpClient> =>
|
|
135
|
+
Effect.gen(function*() {
|
|
136
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
137
|
+
const request = HttpClientRequest.get(ME_URL).pipe(
|
|
138
|
+
HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`),
|
|
139
|
+
HttpClientRequest.setHeader("Accept", "application/json")
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
const response = yield* httpClient.execute(request)
|
|
143
|
+
|
|
144
|
+
if (response.status >= 400) {
|
|
145
|
+
const text = yield* response.text
|
|
146
|
+
yield* Effect.logDebug(`User info failed (${response.status}): ${text}`)
|
|
147
|
+
return yield* Effect.fail(
|
|
148
|
+
new OAuthError({ step: "user-info", cause: `HTTP ${response.status}` })
|
|
149
|
+
)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const body = yield* response.json
|
|
153
|
+
|
|
154
|
+
return yield* Schema.decodeUnknown(UserInfoSchema)(body)
|
|
155
|
+
}).pipe(
|
|
156
|
+
Effect.mapError((cause) => new OAuthError({ step: "user-info", cause }))
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Refresh an expired OAuth token.
|
|
161
|
+
*
|
|
162
|
+
* @param token - Current OAuth token (with refresh_token)
|
|
163
|
+
* @param config - OAuth client configuration
|
|
164
|
+
*
|
|
165
|
+
* @category Operations
|
|
166
|
+
*/
|
|
167
|
+
export const refreshToken = (
|
|
168
|
+
token: OAuthToken,
|
|
169
|
+
config: OAuthConfig
|
|
170
|
+
): Effect.Effect<OAuthToken, OAuthError, HttpClient.HttpClient> =>
|
|
171
|
+
Effect.gen(function*() {
|
|
172
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
173
|
+
const request = yield* HttpClientRequest.post(TOKEN_URL).pipe(
|
|
174
|
+
HttpClientRequest.setHeader("Content-Type", "application/json"),
|
|
175
|
+
HttpClientRequest.bodyJson({
|
|
176
|
+
grant_type: "refresh_token",
|
|
177
|
+
client_id: config.clientId,
|
|
178
|
+
client_secret: config.clientSecret,
|
|
179
|
+
refresh_token: token.refresh_token
|
|
180
|
+
}),
|
|
181
|
+
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
const response = yield* httpClient.execute(request).pipe(
|
|
185
|
+
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if (response.status >= 400) {
|
|
189
|
+
const text = yield* response.text.pipe(
|
|
190
|
+
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
191
|
+
)
|
|
192
|
+
return yield* Effect.fail(
|
|
193
|
+
new OAuthError({ step: "refresh", cause: `HTTP ${response.status}: ${text}` })
|
|
194
|
+
)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const body = yield* response.json.pipe(
|
|
198
|
+
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
199
|
+
)
|
|
200
|
+
const tokenResponse = yield* Schema.decodeUnknown(TokenResponseSchema)(body).pipe(
|
|
201
|
+
Effect.mapError((cause) => new OAuthError({ step: "refresh", cause }))
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...token,
|
|
206
|
+
access_token: tokenResponse.access_token,
|
|
207
|
+
refresh_token: tokenResponse.refresh_token,
|
|
208
|
+
expires_at: Date.now() + tokenResponse.expires_in * 1000,
|
|
209
|
+
scope: tokenResponse.scope
|
|
210
|
+
}
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Revoke an OAuth token.
|
|
215
|
+
*
|
|
216
|
+
* @param token - OAuth token to revoke
|
|
217
|
+
* @param config - OAuth client configuration
|
|
218
|
+
*
|
|
219
|
+
* @category Operations
|
|
220
|
+
*/
|
|
221
|
+
export const revokeToken = (
|
|
222
|
+
token: OAuthToken,
|
|
223
|
+
config: OAuthConfig
|
|
224
|
+
): Effect.Effect<void, OAuthError, HttpClient.HttpClient> =>
|
|
225
|
+
Effect.gen(function*() {
|
|
226
|
+
const httpClient = yield* HttpClient.HttpClient
|
|
227
|
+
const request = yield* HttpClientRequest.post(REVOKE_URL).pipe(
|
|
228
|
+
HttpClientRequest.setHeader("Content-Type", "application/json"),
|
|
229
|
+
HttpClientRequest.bodyJson({
|
|
230
|
+
client_id: config.clientId,
|
|
231
|
+
client_secret: config.clientSecret,
|
|
232
|
+
token: token.refresh_token
|
|
233
|
+
}),
|
|
234
|
+
Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
const response = yield* httpClient.execute(request).pipe(
|
|
238
|
+
Effect.mapError((cause) => new OAuthError({ step: "revoke", cause }))
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
if (response.status >= 400) {
|
|
242
|
+
return yield* Effect.fail(
|
|
243
|
+
new OAuthError({
|
|
244
|
+
step: "revoke",
|
|
245
|
+
cause: `Token revocation failed with status ${response.status}`
|
|
246
|
+
})
|
|
247
|
+
)
|
|
248
|
+
}
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Build OAuthToken from token response and site info.
|
|
253
|
+
*
|
|
254
|
+
* @param tokenResponse - Token response from exchange
|
|
255
|
+
* @param site - Selected accessible resource
|
|
256
|
+
* @param user - User info
|
|
257
|
+
*
|
|
258
|
+
* @category Utilities
|
|
259
|
+
*/
|
|
260
|
+
export const buildOAuthToken = (
|
|
261
|
+
tokenResponse: TokenResponse,
|
|
262
|
+
site: AccessibleResource,
|
|
263
|
+
user: UserInfo
|
|
264
|
+
): OAuthToken => ({
|
|
265
|
+
access_token: tokenResponse.access_token,
|
|
266
|
+
refresh_token: tokenResponse.refresh_token,
|
|
267
|
+
expires_at: Date.now() + tokenResponse.expires_in * 1000,
|
|
268
|
+
scope: tokenResponse.scope,
|
|
269
|
+
cloud_id: site.id,
|
|
270
|
+
site_url: site.url,
|
|
271
|
+
user: {
|
|
272
|
+
account_id: user.account_id,
|
|
273
|
+
name: user.name,
|
|
274
|
+
email: user.email
|
|
275
|
+
}
|
|
276
|
+
})
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Effect Schema definitions for Atlassian OAuth2 API response payloads.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Schema = source of truth**: {@link TokenResponseSchema}, {@link AccessibleResourceSchema},
|
|
7
|
+
* and {@link UserInfoSchema} define the wire format. Companion `type` aliases are derived
|
|
8
|
+
* via `Schema.Schema.Type` — never hand-written.
|
|
9
|
+
*
|
|
10
|
+
* @module
|
|
11
|
+
*/
|
|
12
|
+
import * as Schema from "effect/Schema"
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Schema for OAuth2 token response from Atlassian.
|
|
16
|
+
*
|
|
17
|
+
* @category Schema
|
|
18
|
+
*/
|
|
19
|
+
export const TokenResponseSchema = Schema.Struct({
|
|
20
|
+
access_token: Schema.String,
|
|
21
|
+
refresh_token: Schema.String,
|
|
22
|
+
expires_in: Schema.Number,
|
|
23
|
+
scope: Schema.String,
|
|
24
|
+
token_type: Schema.String
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Type for OAuth2 token response.
|
|
29
|
+
*
|
|
30
|
+
* @category Types
|
|
31
|
+
*/
|
|
32
|
+
export type TokenResponse = Schema.Schema.Type<typeof TokenResponseSchema>
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Schema for accessible resource (site) from Atlassian.
|
|
36
|
+
*
|
|
37
|
+
* @category Schema
|
|
38
|
+
*/
|
|
39
|
+
export const AccessibleResourceSchema = Schema.Struct({
|
|
40
|
+
id: Schema.String,
|
|
41
|
+
name: Schema.String,
|
|
42
|
+
url: Schema.String,
|
|
43
|
+
scopes: Schema.Array(Schema.String),
|
|
44
|
+
avatarUrl: Schema.optional(Schema.String)
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Type for accessible resource.
|
|
49
|
+
*
|
|
50
|
+
* @category Types
|
|
51
|
+
*/
|
|
52
|
+
export type AccessibleResource = Schema.Schema.Type<typeof AccessibleResourceSchema>
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Schema for user info from /me endpoint.
|
|
56
|
+
*
|
|
57
|
+
* @category Schema
|
|
58
|
+
*/
|
|
59
|
+
export const UserInfoSchema = Schema.Struct({
|
|
60
|
+
account_id: Schema.String,
|
|
61
|
+
name: Schema.String,
|
|
62
|
+
email: Schema.String
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Type for user info.
|
|
67
|
+
*
|
|
68
|
+
* @category Types
|
|
69
|
+
*/
|
|
70
|
+
export type UserInfo = Schema.Schema.Type<typeof UserInfoSchema>
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Barrel export for Atlassian OAuth2 auth utilities.
|
|
3
|
+
*
|
|
4
|
+
* @module
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// Endpoints and scopes
|
|
8
|
+
export {
|
|
9
|
+
AUTH_URL,
|
|
10
|
+
buildAuthUrl,
|
|
11
|
+
type BuildAuthUrlOptions,
|
|
12
|
+
computeCodeChallenge,
|
|
13
|
+
CONFLUENCE_SCOPES,
|
|
14
|
+
generateCodeVerifier,
|
|
15
|
+
JIRA_SCOPES,
|
|
16
|
+
ME_URL,
|
|
17
|
+
RESOURCES_URL,
|
|
18
|
+
REVOKE_URL,
|
|
19
|
+
TOKEN_URL
|
|
20
|
+
} from "./OAuthEndpoints.js"
|
|
21
|
+
|
|
22
|
+
// Errors
|
|
23
|
+
export { AuthMissingError, OAuthError, OAuthNotConfiguredError, type OAuthStep } from "./OAuthErrors.js"
|
|
24
|
+
|
|
25
|
+
// Operations
|
|
26
|
+
export {
|
|
27
|
+
buildOAuthToken,
|
|
28
|
+
exchangeCodeForTokens,
|
|
29
|
+
type ExchangeCodeOptions,
|
|
30
|
+
getAccessibleResources,
|
|
31
|
+
getUserInfo,
|
|
32
|
+
refreshToken,
|
|
33
|
+
revokeToken
|
|
34
|
+
} from "./OAuthOperations.js"
|
|
35
|
+
|
|
36
|
+
// Response schemas
|
|
37
|
+
export {
|
|
38
|
+
type AccessibleResource,
|
|
39
|
+
AccessibleResourceSchema,
|
|
40
|
+
type TokenResponse,
|
|
41
|
+
TokenResponseSchema,
|
|
42
|
+
type UserInfo,
|
|
43
|
+
UserInfoSchema
|
|
44
|
+
} from "./OAuthResponseSchemas.js"
|
|
45
|
+
|
|
46
|
+
// Utilities
|
|
47
|
+
export { generateUUID } from "./uuid.js"
|
package/src/auth/uuid.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic UUID v4 generation via Web Crypto API.
|
|
3
|
+
*
|
|
4
|
+
* @internal
|
|
5
|
+
*/
|
|
6
|
+
import * as Effect from "effect/Effect"
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Generate a cryptographically secure UUID v4.
|
|
10
|
+
* Uses Web Crypto API randomUUID (Node.js 19+, all modern browsers).
|
|
11
|
+
*
|
|
12
|
+
* @category Utilities
|
|
13
|
+
*/
|
|
14
|
+
export const generateUUID = (): Effect.Effect<string> => Effect.sync(() => globalThis.crypto.randomUUID())
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XDG-compliant config path resolution and secure file I/O for Atlassian tools.
|
|
3
|
+
*
|
|
4
|
+
* **Mental model**
|
|
5
|
+
*
|
|
6
|
+
* - **Injectable home directory**: {@link HomeDirectoryTag} is an Effect service so tests
|
|
7
|
+
* can substitute a temp dir without touching env vars. {@link HomeDirectoryLive} reads
|
|
8
|
+
* `HOME` / `USERPROFILE` via Effect `Config`.
|
|
9
|
+
* - **XDG hierarchy**: `~/.config/atlassian/<toolName>/` — respects `XDG_CONFIG_HOME` override.
|
|
10
|
+
* - **Secure by default**: {@link ensureConfigDir} creates dirs at `0o700`;
|
|
11
|
+
* {@link writeSecureFile} writes at `0o600`.
|
|
12
|
+
*
|
|
13
|
+
* **Common tasks**
|
|
14
|
+
*
|
|
15
|
+
* - Resolve config dir: {@link getConfigDir}
|
|
16
|
+
* - Get auth file path: {@link getAuthPath}
|
|
17
|
+
* - Write credential files: {@link writeSecureFile}
|
|
18
|
+
*
|
|
19
|
+
* @module
|
|
20
|
+
*/
|
|
21
|
+
import type * as Error from "@effect/platform/Error"
|
|
22
|
+
import * as FileSystem from "@effect/platform/FileSystem"
|
|
23
|
+
import * as Path from "@effect/platform/Path"
|
|
24
|
+
import * as Config from "effect/Config"
|
|
25
|
+
import * as Context from "effect/Context"
|
|
26
|
+
import * as Data from "effect/Data"
|
|
27
|
+
import * as Effect from "effect/Effect"
|
|
28
|
+
import * as Layer from "effect/Layer"
|
|
29
|
+
import * as Option from "effect/Option"
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Error when home directory cannot be determined.
|
|
33
|
+
*
|
|
34
|
+
* @category Errors
|
|
35
|
+
*/
|
|
36
|
+
export class HomeDirectoryError extends Data.TaggedError("HomeDirectoryError")<{
|
|
37
|
+
readonly cause?: unknown
|
|
38
|
+
}> {
|
|
39
|
+
override get message(): string {
|
|
40
|
+
return "Cannot determine home directory: HOME/USERPROFILE not set"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Service for getting the home directory.
|
|
46
|
+
* Allows mocking in tests.
|
|
47
|
+
*
|
|
48
|
+
* @category Services
|
|
49
|
+
*/
|
|
50
|
+
export interface HomeDirectory {
|
|
51
|
+
readonly get: () => Effect.Effect<string, HomeDirectoryError>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tag for the HomeDirectory service.
|
|
56
|
+
*
|
|
57
|
+
* @category Services
|
|
58
|
+
*/
|
|
59
|
+
export class HomeDirectoryTag extends Context.Tag("@knpkv/atlassian-common/HomeDirectory")<
|
|
60
|
+
HomeDirectoryTag,
|
|
61
|
+
HomeDirectory
|
|
62
|
+
>() {}
|
|
63
|
+
|
|
64
|
+
const HomeConfig = Config.option(Config.string("HOME")).pipe(
|
|
65
|
+
Config.orElse(() => Config.option(Config.string("USERPROFILE")))
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Default implementation using HOME/USERPROFILE env vars.
|
|
70
|
+
*
|
|
71
|
+
* @category Layers
|
|
72
|
+
*/
|
|
73
|
+
export const HomeDirectoryLive: Layer.Layer<HomeDirectoryTag> = Layer.succeed(
|
|
74
|
+
HomeDirectoryTag,
|
|
75
|
+
{
|
|
76
|
+
get: () =>
|
|
77
|
+
Effect.gen(function*() {
|
|
78
|
+
const opt = yield* Effect.orDie(HomeConfig)
|
|
79
|
+
return yield* Option.match(opt, {
|
|
80
|
+
onNone: () => Effect.fail(new HomeDirectoryError({})),
|
|
81
|
+
onSome: (home) => Effect.succeed(home)
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const XdgConfigHome = Config.option(Config.string("XDG_CONFIG_HOME"))
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* XDG config directory for Atlassian tools.
|
|
91
|
+
* Returns ~/.config/atlassian by default.
|
|
92
|
+
*
|
|
93
|
+
* @category Utilities
|
|
94
|
+
*/
|
|
95
|
+
export const getConfigDir = (
|
|
96
|
+
toolName?: string
|
|
97
|
+
): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
|
|
98
|
+
Effect.gen(function*() {
|
|
99
|
+
const homeDir = yield* HomeDirectoryTag
|
|
100
|
+
const pathSvc = yield* Path.Path
|
|
101
|
+
const home = yield* homeDir.get()
|
|
102
|
+
|
|
103
|
+
// Use XDG_CONFIG_HOME if set, otherwise ~/.config
|
|
104
|
+
const xdgOpt = yield* Effect.orDie(XdgConfigHome)
|
|
105
|
+
const xdgConfig = Option.getOrElse(xdgOpt, () => pathSvc.join(home, ".config"))
|
|
106
|
+
const baseDir = pathSvc.join(xdgConfig, "atlassian")
|
|
107
|
+
|
|
108
|
+
return toolName ? pathSvc.join(baseDir, toolName) : baseDir
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get auth file path for a specific tool.
|
|
113
|
+
*
|
|
114
|
+
* @category Utilities
|
|
115
|
+
*/
|
|
116
|
+
export const getAuthPath = (
|
|
117
|
+
toolName: string
|
|
118
|
+
): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
|
|
119
|
+
Effect.gen(function*() {
|
|
120
|
+
const path = yield* Path.Path
|
|
121
|
+
const configDir = yield* getConfigDir(toolName)
|
|
122
|
+
return path.join(configDir, "auth.json")
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Get OAuth config file path for a specific tool.
|
|
127
|
+
*
|
|
128
|
+
* @category Utilities
|
|
129
|
+
*/
|
|
130
|
+
export const getOAuthConfigPath = (
|
|
131
|
+
toolName: string
|
|
132
|
+
): Effect.Effect<string, HomeDirectoryError, HomeDirectoryTag | Path.Path> =>
|
|
133
|
+
Effect.gen(function*() {
|
|
134
|
+
const path = yield* Path.Path
|
|
135
|
+
const configDir = yield* getConfigDir(toolName)
|
|
136
|
+
return path.join(configDir, "oauth.json")
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Ensure config directory exists with secure permissions.
|
|
141
|
+
*
|
|
142
|
+
* @category Utilities
|
|
143
|
+
*/
|
|
144
|
+
export const ensureConfigDir = (
|
|
145
|
+
toolName: string
|
|
146
|
+
): Effect.Effect<
|
|
147
|
+
string,
|
|
148
|
+
HomeDirectoryError | Error.PlatformError,
|
|
149
|
+
FileSystem.FileSystem | Path.Path | HomeDirectoryTag
|
|
150
|
+
> =>
|
|
151
|
+
Effect.gen(function*() {
|
|
152
|
+
const fs = yield* FileSystem.FileSystem
|
|
153
|
+
const configDir = yield* getConfigDir(toolName)
|
|
154
|
+
|
|
155
|
+
yield* fs.makeDirectory(configDir, { recursive: true })
|
|
156
|
+
|
|
157
|
+
// Set secure permissions (owner only)
|
|
158
|
+
yield* fs.chmod(configDir, 0o700)
|
|
159
|
+
|
|
160
|
+
return configDir
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Write file with secure permissions (600).
|
|
165
|
+
*
|
|
166
|
+
* @category Utilities
|
|
167
|
+
*/
|
|
168
|
+
export const writeSecureFile = (
|
|
169
|
+
filePath: string,
|
|
170
|
+
content: string
|
|
171
|
+
): Effect.Effect<void, Error.PlatformError, FileSystem.FileSystem> =>
|
|
172
|
+
Effect.gen(function*() {
|
|
173
|
+
const fs = yield* FileSystem.FileSystem
|
|
174
|
+
|
|
175
|
+
yield* fs.writeFileString(filePath, content)
|
|
176
|
+
|
|
177
|
+
// Set secure permissions (owner only)
|
|
178
|
+
yield* fs.chmod(filePath, 0o600)
|
|
179
|
+
})
|