| @@ -27,6 +27,8 @@ import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts · 26 unmodified lines |
| 27 | 27 | const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token"; |
| 28 | 28 | const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); |
| 29 | 29 | const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); |
| 30 | +const CLOUD_CLI_OAUTH_CALLBACK_HOST = "127.0.0.1"; |
| 31 | +const CLOUD_CLI_OAUTH_CALLBACK_PORT = 34338; |
| 30 | 32 | |
| 31 | 33 | const PersistedToken = Schema.Struct({ |
| 32 | 34 | accessToken: Schema.String, |
| @@ -46,48 +48,136 @@ const OAuthTokenResponse = Schema.Struct({ · 13 unmodified lines |
| 46 | 48 | token_type: Schema.String, |
| 47 | 49 | }); |
| 48 | 50 | |
| 51 | +type CredentialReadFailure = ServerSecretStore.SecretStoreReadError | Schema.SchemaError; |
| 52 | + |
| 53 | +type CredentialPersistFailure = |
| 54 | + | Schema.SchemaError |
| 55 | + | ServerSecretStore.SecretStoreTemporaryPathGenerationError |
| 56 | + | ServerSecretStore.SecretStorePersistError; |
| 57 | + |
| 58 | +const CloudCliCredentialRefreshStage = Schema.Literals([ |
| 59 | + "read-credential", |
| 60 | + "decode-credential", |
| 61 | + "load-oauth-config", |
| 62 | + "exchange-token", |
| 63 | + "encode-credential", |
| 64 | + "persist-credential", |
| 65 | +]); |
| 66 | + |
| 67 | +const CloudCliAuthorizationStage = Schema.Literals([ |
| 68 | + "load-oauth-config", |
| 69 | + "prepare-pkce", |
| 70 | + "start-callback-server", |
| 71 | + "exchange-token", |
| 72 | + "encode-credential", |
| 73 | + "persist-credential", |
| 74 | +]); |
| 75 | + |
| 49 | 76 | export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()( |
| 50 | 77 | "CloudCliCredentialRemovalError", |
| 51 | | − { cause: Schema.Defect() }, |
| 78 | + { |
| 79 | + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), |
| 80 | + cause: Schema.Defect(), |
| 81 | + }, |
| 52 | 82 | ) { |
| 53 | 83 | override get message(): string { |
| 54 | | − return "Could not remove the stored T3 Connect CLI credential."; |
| 84 | + return `Could not remove the stored T3 Connect CLI credential ${this.secretName}.`; |
| 55 | 85 | } |
| 56 | 86 | } |
| 57 | 87 | |
| 58 | 88 | export class CloudCliCredentialRefreshError extends Schema.TaggedErrorClass<CloudCliCredentialRefreshError>()( |
| 59 | 89 | "CloudCliCredentialRefreshError", |
| 60 | | − { cause: Schema.Defect() }, |
| 90 | + { |
| 91 | + stage: CloudCliCredentialRefreshStage, |
| 92 | + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), |
| 93 | + tokenEndpoint: Schema.optional(Schema.String), |
| 94 | + cause: Schema.Defect(), |
| 95 | + }, |
| 61 | 96 | ) { |
| 97 | + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialRefreshError { |
| 98 | + return new CloudCliCredentialRefreshError({ |
| 99 | + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", |
| 100 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 101 | + cause, |
| 102 | + }); |
| 103 | + } |
| 104 | + |
| 105 | + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliCredentialRefreshError { |
| 106 | + return new CloudCliCredentialRefreshError({ |
| 107 | + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", |
| 108 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 109 | + cause, |
| 110 | + }); |
| 111 | + } |
| 112 | + |
| 62 | 113 | override get message(): string { |
| 63 | | − return "Could not refresh the T3 Connect CLI credential."; |
| 114 | + const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : ""; |
| 115 | + return `Could not refresh the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}.`; |
| 64 | 116 | } |
| 65 | 117 | } |
| 66 | 118 | |
| 67 | 119 | export class CloudCliCredentialReadError extends Schema.TaggedErrorClass<CloudCliCredentialReadError>()( |
| 68 | 120 | "CloudCliCredentialReadError", |
| 69 | | − { cause: Schema.Defect() }, |
| 121 | + { |
| 122 | + stage: Schema.Literals(["read-credential", "decode-credential"]), |
| 123 | + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), |
| 124 | + cause: Schema.Defect(), |
| 125 | + }, |
| 70 | 126 | ) { |
| 127 | + static fromCredentialRead(cause: CredentialReadFailure): CloudCliCredentialReadError { |
| 128 | + return new CloudCliCredentialReadError({ |
| 129 | + stage: cause._tag === "SecretStoreReadError" ? "read-credential" : "decode-credential", |
| 130 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 131 | + cause, |
| 132 | + }); |
| 133 | + } |
| 134 | + |
| 71 | 135 | override get message(): string { |
| 72 | | − return "Could not read the stored T3 Connect CLI credential."; |
| 136 | + return `Could not inspect the stored T3 Connect CLI credential ${this.secretName} during ${this.stage}.`; |
| 73 | 137 | } |
| 74 | 138 | } |
| 75 | 139 | |
| 76 | 140 | export class CloudCliAuthorizationError extends Schema.TaggedErrorClass<CloudCliAuthorizationError>()( |
| 77 | 141 | "CloudCliAuthorizationError", |
| 78 | | − { cause: Schema.Defect() }, |
| 142 | + { |
| 143 | + stage: CloudCliAuthorizationStage, |
| 144 | + secretName: Schema.Literal(CLOUD_CLI_OAUTH_TOKEN_SECRET), |
| 145 | + tokenEndpoint: Schema.optional(Schema.String), |
| 146 | + redirectUri: Schema.optional(Schema.String), |
| 147 | + callbackHost: Schema.optional(Schema.String), |
| 148 | + callbackPort: Schema.optional(Schema.Number), |
| 149 | + cause: Schema.Defect(), |
| 150 | + }, |
| 79 | 151 | ) { |
| 152 | + static fromCredentialPersist(cause: CredentialPersistFailure): CloudCliAuthorizationError { |
| 153 | + return new CloudCliAuthorizationError({ |
| 154 | + stage: cause._tag === "SchemaError" ? "encode-credential" : "persist-credential", |
| 155 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 156 | + cause, |
| 157 | + }); |
| 158 | + } |
| 159 | + |
| 80 | 160 | override get message(): string { |
| 81 | | − return "Could not authorize the T3 Connect CLI."; |
| 161 | + const tokenEndpoint = this.tokenEndpoint ? ` using ${this.tokenEndpoint}` : ""; |
| 162 | + const redirectUri = this.redirectUri ? ` with callback ${this.redirectUri}` : ""; |
| 163 | + const callbackAddress = |
| 164 | + this.callbackHost && this.callbackPort !== undefined |
| 165 | + ? ` on ${this.callbackHost}:${this.callbackPort}` |
| 166 | + : ""; |
| 167 | + return `Could not authorize the T3 Connect CLI credential ${this.secretName} during ${this.stage}${tokenEndpoint}${redirectUri}${callbackAddress}.`; |
| 82 | 168 | } |
| 83 | 169 | } |
| 84 | 170 | |
| 85 | 171 | export class CloudCliAuthorizationTimeoutError extends Schema.TaggedErrorClass<CloudCliAuthorizationTimeoutError>()( |
| 86 | 172 | "CloudCliAuthorizationTimeoutError", |
| 87 | | − { cause: Schema.Defect() }, |
| 173 | + { |
| 174 | + redirectUri: Schema.String, |
| 175 | + timeoutMillis: Schema.Number, |
| 176 | + cause: Schema.Defect(), |
| 177 | + }, |
| 88 | 178 | ) { |
| 89 | 179 | override get message(): string { |
| 90 | | − return "Timed out waiting for T3 Connect authorization."; |
| 180 | + return `Timed out after ${this.timeoutMillis}ms waiting for T3 Connect authorization at ${this.redirectUri}.`; |
| 91 | 181 | } |
| 92 | 182 | } |
| 93 | 183 | |
| @@ -103,18 +193,21 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; · 9 unmodified lines |
| 103 | 193 | export class CloudCliTokenManager extends Context.Service< |
| 104 | 194 | CloudCliTokenManager, |
| 105 | 195 | { |
| 106 | | − readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>; |
| 107 | | − readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>; |
| 108 | | − readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>; |
| 109 | | − readonly clear: Effect.Effect<void, CloudCliTokenManagerError>; |
| 196 | + readonly get: Effect.Effect< |
| 197 | + PersistedToken, |
| 198 | + | CloudCliCredentialRefreshError |
| 199 | + | CloudCliAuthorizationError |
| 200 | + | CloudCliAuthorizationTimeoutError |
| 201 | + >; |
| 202 | + readonly getExisting: Effect.Effect< |
| 203 | + Option.Option<PersistedToken>, |
| 204 | + CloudCliCredentialRefreshError |
| 205 | + >; |
| 206 | + readonly hasCredential: Effect.Effect<boolean, CloudCliCredentialReadError>; |
| 207 | + readonly clear: Effect.Effect<void, CloudCliCredentialRemovalError>; |
| 110 | 208 | } |
| 111 | 209 | >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {} |
| 112 | 210 | |
| 113 | | −const wrapError = |
| 114 | | − <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) => |
| 115 | | − <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> => |
| 116 | | − effect.pipe(Effect.mapError(makeError)); |
| 117 | | − |
| 118 | 211 | function stringToBytes(value: string): Uint8Array { |
| 119 | 212 | return new TextEncoder().encode(value); |
| 120 | 213 | } |
| @@ -134,9 +227,15 @@ export const make = Effect.gen(function* () { · 13 unmodified lines |
| 134 | 227 | return token; |
| 135 | 228 | }); |
| 136 | 229 | |
| 137 | | − const clear = secrets |
| 138 | | − .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET) |
| 139 | | − .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause }))); |
| 230 | + const clear = secrets.remove(CLOUD_CLI_OAUTH_TOKEN_SECRET).pipe( |
| 231 | + Effect.mapError( |
| 232 | + (cause) => |
| 233 | + new CloudCliCredentialRemovalError({ |
| 234 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 235 | + cause, |
| 236 | + }), |
| 237 | + ), |
| 238 | + ); |
| 140 | 239 | |
| 141 | 240 | const read = Effect.fn("cloud.cli_token.read")(function* () { |
| 142 | 241 | const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET); |
| @@ -162,21 +261,62 @@ export const make = Effect.gen(function* () { · 19 unmodified lines |
| 162 | 261 | }); |
| 163 | 262 | |
| 164 | 263 | const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) { |
| 165 | | − const metadata = yield* cloudCliOAuthConfig; |
| 264 | + const metadata = yield* cloudCliOAuthConfig.pipe( |
| 265 | + Effect.mapError( |
| 266 | + (cause) => |
| 267 | + new CloudCliCredentialRefreshError({ |
| 268 | + stage: "load-oauth-config", |
| 269 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 270 | + cause, |
| 271 | + }), |
| 272 | + ), |
| 273 | + ); |
| 166 | 274 | return yield* exchangeToken(metadata, { |
| 167 | 275 | grant_type: "refresh_token", |
| 168 | 276 | refresh_token: token.refreshToken, |
| 169 | 277 | client_id: metadata.clientId, |
| 170 | | − }); |
| 278 | + }).pipe( |
| 279 | + Effect.mapError( |
| 280 | + (cause) => |
| 281 | + new CloudCliCredentialRefreshError({ |
| 282 | + stage: "exchange-token", |
| 283 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 284 | + tokenEndpoint: metadata.tokenEndpoint, |
| 285 | + cause, |
| 286 | + }), |
| 287 | + ), |
| 288 | + ); |
| 171 | 289 | }); |
| 172 | 290 | |
| 173 | 291 | const login = Effect.fn("cloud.cli_token.login")(function* () { |
| 174 | | − const metadata = yield* cloudCliOAuthConfig; |
| 175 | | − const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); |
| 176 | | − const challenge = Encoding.encodeBase64Url( |
| 177 | | − yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), |
| 292 | + const metadata = yield* cloudCliOAuthConfig.pipe( |
| 293 | + Effect.mapError( |
| 294 | + (cause) => |
| 295 | + new CloudCliAuthorizationError({ |
| 296 | + stage: "load-oauth-config", |
| 297 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 298 | + cause, |
| 299 | + }), |
| 300 | + ), |
| 301 | + ); |
| 302 | + const { challenge, state, verifier } = yield* Effect.gen(function* () { |
| 303 | + const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32)); |
| 304 | + const challenge = Encoding.encodeBase64Url( |
| 305 | + yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)), |
| 306 | + ); |
| 307 | + const state = yield* crypto.randomUUIDv4; |
| 308 | + return { challenge, state, verifier }; |
| 309 | + }).pipe( |
| 310 | + Effect.mapError( |
| 311 | + (cause) => |
| 312 | + new CloudCliAuthorizationError({ |
| 313 | + stage: "prepare-pkce", |
| 314 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 315 | + redirectUri: metadata.redirectUri, |
| 316 | + cause, |
| 317 | + }), |
| 318 | + ), |
| 178 | 319 | ); |
| 179 | | − const state = yield* crypto.randomUUIDv4; |
| 180 | 320 | const callback = yield* Deferred.make<string>(); |
| 181 | 321 | const callbackRoute = HttpRouter.add( |
| 182 | 322 | "GET", |
| @@ -207,12 +347,23 @@ export const make = Effect.gen(function* () { · 24 unmodified lines |
| 207 | 347 | }).pipe( |
| 208 | 348 | Layer.provide( |
| 209 | 349 | NodeHttpServer.layer(NodeHttp.createServer, { |
| 210 | | − host: "127.0.0.1", |
| 211 | | − port: 34338, |
| 350 | + host: CLOUD_CLI_OAUTH_CALLBACK_HOST, |
| 351 | + port: CLOUD_CLI_OAUTH_CALLBACK_PORT, |
| 212 | 352 | disablePreemptiveShutdown: true, |
| 213 | 353 | }), |
| 214 | 354 | ), |
| 215 | 355 | Layer.build, |
| 356 | + Effect.mapError( |
| 357 | + (cause) => |
| 358 | + new CloudCliAuthorizationError({ |
| 359 | + stage: "start-callback-server", |
| 360 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 361 | + redirectUri: metadata.redirectUri, |
| 362 | + callbackHost: CLOUD_CLI_OAUTH_CALLBACK_HOST, |
| 363 | + callbackPort: CLOUD_CLI_OAUTH_CALLBACK_PORT, |
| 364 | + cause, |
| 365 | + }), |
| 366 | + ), |
| 216 | 367 | ); |
| 217 | 368 | const authorizationUrl = new URL(metadata.authorizationEndpoint); |
| 218 | 369 | authorizationUrl.searchParams.set("client_id", metadata.clientId); |
| @@ -225,13 +376,16 @@ export const make = Effect.gen(function* () { · 6 unmodified lines |
| 225 | 376 | yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`); |
| 226 | 377 | const code = yield* Deferred.await(callback).pipe( |
| 227 | 378 | Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), |
| 228 | | − Effect.catchTag("TimeoutError", (cause) => |
| 229 | | − Effect.fail( |
| 230 | | − new CloudCliAuthorizationTimeoutError({ |
| 231 | | − cause, |
| 232 | | − }), |
| 233 | | − ), |
| 234 | | − ), |
| 379 | + Effect.catchTags({ |
| 380 | + TimeoutError: (cause) => |
| 381 | + Effect.fail( |
| 382 | + new CloudCliAuthorizationTimeoutError({ |
| 383 | + redirectUri: metadata.redirectUri, |
| 384 | + timeoutMillis: Duration.toMillis(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT), |
| 385 | + cause, |
| 386 | + }), |
| 387 | + ), |
| 388 | + }), |
| 235 | 389 | ); |
| 236 | 390 | return yield* exchangeToken(metadata, { |
| 237 | 391 | grant_type: "authorization_code", |
| @@ -239,26 +393,45 @@ export const make = Effect.gen(function* () { · 1 unmodified line |
| 239 | 393 | redirect_uri: metadata.redirectUri, |
| 240 | 394 | client_id: metadata.clientId, |
| 241 | 395 | code_verifier: verifier, |
| 242 | | − }); |
| 396 | + }).pipe( |
| 397 | + Effect.mapError( |
| 398 | + (cause) => |
| 399 | + new CloudCliAuthorizationError({ |
| 400 | + stage: "exchange-token", |
| 401 | + secretName: CLOUD_CLI_OAUTH_TOKEN_SECRET, |
| 402 | + tokenEndpoint: metadata.tokenEndpoint, |
| 403 | + redirectUri: metadata.redirectUri, |
| 404 | + cause, |
| 405 | + }), |
| 406 | + ), |
| 407 | + ); |
| 243 | 408 | }); |
| 244 | 409 | |
| 245 | 410 | const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () { |
| 246 | | − const token = yield* read(); |
| 411 | + const token = yield* read().pipe( |
| 412 | + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialRead), |
| 413 | + ); |
| 247 | 414 | if (Option.isNone(token)) return token; |
| 248 | 415 | const now = yield* Clock.currentTimeMillis; |
| 249 | 416 | if (token.value.expiresAtEpochMs - CLOUD_CLI_OAUTH_REFRESH_EARLY_MS > now) { |
| 250 | 417 | return token; |
| 251 | 418 | } |
| 252 | | − return Option.some(yield* refresh(token.value).pipe(Effect.flatMap(persist))); |
| 419 | + return Option.some( |
| 420 | + yield* refresh(token.value).pipe( |
| 421 | + Effect.flatMap((refreshed) => |
| 422 | + persist(refreshed).pipe( |
| 423 | + Effect.mapError(CloudCliCredentialRefreshError.fromCredentialPersist), |
| 424 | + ), |
| 425 | + ), |
| 426 | + ), |
| 427 | + ); |
| 253 | 428 | }); |
| 254 | 429 | |
| 255 | | − const getExisting = semaphore.withPermits(1)( |
| 256 | | − getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))), |
| 257 | | − ); |
| 430 | + const getExisting = semaphore.withPermits(1)(getExistingNoLock()); |
| 258 | 431 | const hasCredential = semaphore.withPermits(1)( |
| 259 | 432 | read().pipe( |
| 260 | 433 | Effect.map(Option.isSome), |
| 261 | | − wrapError((cause) => new CloudCliCredentialReadError({ cause })), |
| 434 | + Effect.mapError(CloudCliCredentialReadError.fromCredentialRead), |
| 262 | 435 | ), |
| 263 | 436 | ); |
| 264 | 437 | const get = semaphore.withPermits(1)( |
| @@ -266,8 +439,14 @@ export const make = Effect.gen(function* () { · 1 unmodified line |
| 266 | 439 | const token = yield* getExistingNoLock(); |
| 267 | 440 | return Option.isSome(token) |
| 268 | 441 | ? token.value |
| 269 | | − : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist)); |
| 270 | | − }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))), |
| 442 | + : yield* Effect.scoped(login()).pipe( |
| 443 | + Effect.flatMap((authorized) => |
| 444 | + persist(authorized).pipe( |
| 445 | + Effect.mapError(CloudCliAuthorizationError.fromCredentialPersist), |
| 446 | + ), |
| 447 | + ), |
| 448 | + ); |
| 449 | + }), |
| 271 | 450 | ); |
| 272 | 451 | |
| 273 | 452 | return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear }); |