1import * as Floci from "@alchemy.run/floci";
2import * as DistilledAuth from "@distilled.cloud/aws/Auth";
3import type { CredentialsError } from "@distilled.cloud/aws/Credentials";
4import {
5 Credentials,
6 ExpiredSSOToken,
7 InvalidSSOToken,
8} from "@distilled.cloud/aws/Credentials";
9import * as STS from "@distilled.cloud/aws/sts";
10import * as EffectConsole from "effect/Console";
11import * as Deferred from "effect/Deferred";
12import * as Effect from "effect/Effect";
13import * as Fiber from "effect/Fiber";
14import * as FileSystem from "effect/FileSystem";
15import * as Layer from "effect/Layer";
16import * as Match from "effect/Match";
17import * as Option from "effect/Option";
18import * as Path from "effect/Path";
19import * as Redacted from "effect/Redacted";
20import * as Ref from "effect/Ref";
21import * as Schema from "effect/Schema";
22import * as Stream from "effect/Stream";
23import type * as HttpClient from "effect/http/HttpClient";
24import { ChildProcess } from "effect/process";
25import type { ChildProcessSpawner } from "effect/process/ChildProcessSpawner";
26import * as NodeCrypto from "node:crypto";
27import * as NodeOs from "node:os";
28import {
29 AuthError,
30 AuthProviderLayer,
31 NeedsReauth,
32 reconfigureHint,
33 refreshHint,
34 type ConfigureField,
35 type ConfigureMethod,
36 type ProviderDetailLine,
37} from "../Auth/AuthProvider.ts";
38import { displayRedacted } from "../Auth/Credentials.ts";
39import {
40 getEnv,
41 getEnvRedacted,
42 getEnvRedactedRequired,
43 getEnvRequired,
44 mapPromptCancellation,
45} from "../Auth/Env.ts";
46import {
47 storedSecret,
48 storedValueText,
49 validateFieldValues,
50} from "../Auth/StoredAuthProvider.ts";
51import * as Interaction from "../Interaction.ts";
52import * as Endpoint from "./Endpoint.ts";
53import * as Region from "./Region.ts";
54
55export const AWS_AUTH_PROVIDER_NAME = "AWS";
56export const DEFAULT_LOCAL_ENDPOINT = `http://localhost:${Floci.DEFAULT_FLOCI_PORT}`;
57
58/**
59 * Dummy account stamped on every floci environment.
60 * A custom `AWS_ENDPOINT_URL` on env/sso credentials does NOT use this —
61 * those keep the real account from STS / `AWS_ACCOUNT_ID`.
62 */
63export const LOCAL_ACCOUNT_ID = "000000000000";
64
65/**
66 * distilled's SSO credential loader `Console.log`s its expired-token
67 * diagnostic in addition to failing with the typed `ExpiredSSOToken`. The
68 * typed error already carries the same message (and the profile UI surfaces
69 * it as a reauth diagnostic), so the raw console write only smears over the
70 * Interaction renderer. Provide this no-op Console on every distilled
71 * `loadProfileCredentials` call so distilled has nowhere to print.
72 */
73const noop = () => {};
74export const silentConsole: EffectConsole.Console = {
75 assert: noop,
76 clear: noop,
77 count: noop,
78 countReset: noop,
79 debug: noop,
80 dir: noop,
81 dirxml: noop,
82 error: noop,
83 group: noop,
84 groupCollapsed: noop,
85 groupEnd: noop,
86 info: noop,
87 log: noop,
88 table: noop,
89 time: noop,
90 timeEnd: noop,
91 timeLog: noop,
92 trace: noop,
93 warn: noop,
94};
95
96/** Typed values stored in an AWS provider profile document. */
97export const AwsAuthConfigSchema = Schema.Union([
98 Schema.Struct({
99 method: Schema.Literal("sso"),
100 ssoProfile: Schema.String,
101 authorizationMethod: Schema.optional(
102 Schema.Union([Schema.Literal("oauth"), Schema.Literal("device")]),
103 ),
104 }),
105 Schema.Struct({
106 method: Schema.Literal("stored"),
107 accountId: Schema.optional(Schema.String),
108 accessKeyId: Schema.String,
109 secretAccessKey: Schema.String,
110 sessionToken: Schema.optional(Schema.String),
111 region: Schema.String,
112 }),
113]);
114export type AwsAuthConfig = typeof AwsAuthConfigSchema.Type;
115
116const options: Array<{
117 value: AwsAuthConfig["method"];
118 label: string;
119 description?: string;
120}> = [
121 {
122 value: "sso",
123 label: "SSO",
124 description: "sign in with an AWS IAM Identity Center profile",
125 },
126 {
127 value: "stored",
128 label: "Access Keys",
129 description: "enter an access key and secret directly",
130 },
131];
132
133/** `--set` fields for `--method stored` (static access keys, persisted). */
134const keysFields: ReadonlyArray<ConfigureField> = [
135 { name: "accessKeyId", label: "AWS Access Key ID" },
136 { name: "secretAccessKey", label: "AWS Secret Access Key", secret: true },
137 {
138 name: "sessionToken",
139 label: "AWS Session Token",
140 secret: true,
141 optional: true,
142 },
143 { name: "region", label: "AWS Region", placeholder: "us-east-1" },
144];
145
146/** `--set` fields for `--method sso` (nothing persisted; profile validated). */
147const ssoFields: ReadonlyArray<ConfigureField> = [
148 { name: "ssoProfile", label: "AWS profile name (from ~/.aws/config)" },
149];
150
151const configureMethods: ReadonlyArray<ConfigureMethod> = [
152 { method: "stored", fields: keysFields },
153 { method: "sso", fields: ssoFields },
154];
155
156export interface AwsResolvedCredentials {
157 accountId: string;
158 credentials: Effect.Effect<AwsCredentials, CredentialsError>;
159 region: string;
160 endpoint?: string;
161 source: {
162 type: AwsAuthConfig["method"] | "env";
163 details?: string;
164 };
165}
166
167interface AwsCredentials {
168 accessKeyId: Redacted.Redacted<string>;
169 secretAccessKey: Redacted.Redacted<string>;
170 sessionToken: Redacted.Redacted<string> | undefined;
171 region: string;
172}
173
174/**
175 * An explicitly-set `AWS_REGION` env var wins over the region recorded in an
176 * SSO profile (`~/.aws/config`) or in stored credentials. `AWS_DEFAULT_REGION`
177 * deliberately does NOT override — it is a *default* for when no region is
178 * configured anywhere, and the profile's region is explicit configuration.
179 */
180export const applyEnvRegionOverride = <C extends { region: string }>(
181 creds: C,
182): Effect.Effect<C, AuthError> =>
183 getEnv("AWS_REGION").pipe(
184 Effect.map((envRegion) =>
185 envRegion ? { ...creds, region: envRegion } : creds,
186 ),
187 );
188
189/**
190 * Layer that registers the AWS {@link AuthProvider} into the
191 * {@link AuthProviders} registry when built. Include this in the AWS
192 * `providers()` layer so the alchemy CLI can discover it.
193 */
194export const AwsAuth = AuthProviderLayer<
195 AwsAuthConfig,
196 AwsResolvedCredentials
197>()(
198 AWS_AUTH_PROVIDER_NAME,
199 Effect.gen(function* () {
200 const interaction = Interaction.accessors;
201 const getAccountId = ({
202 accessKeyId,
203 secretAccessKey,
204 sessionToken,
205 region,
206 }: {
207 accessKeyId: Redacted.Redacted<string>;
208 secretAccessKey: Redacted.Redacted<string>;
209 sessionToken?: Redacted.Redacted<string>;
210 region: string;
211 }) =>
212 STS.getCallerIdentity({}).pipe(
213 Effect.provide(
214 Layer.mergeAll(
215 Layer.succeed(
216 Credentials,
217 Effect.succeed({
218 accessKeyId,
219 secretAccessKey,
220 sessionToken,
221 region,
222 }),
223 ),
224 // Provide Region directly from the resolved inputs. Relying on the
225 // ambient Region provider (Region.fromEnvironment) here would
226 // deadlock: it derives the region from AWSEnvironment, which is the
227 // very service still being constructed by this STS call.
228 Region.of(region),
229 // Provide `Endpoint.none` rather than leaving it unset: falling
230 // through to the ambient `Endpoint.fromEnvironment` reads it off
231 // AWSEnvironment — the very service this STS call is
232 // constructing — and deadlocks the fiber on its own in-flight
233 // cache (no I/O, no timer, no output). Same reason as
234 // `Region.of` above.
235 Endpoint.none,
236 ),
237 ),
238 Effect.flatMap((self) =>
239 self.Account
240 ? Effect.succeed(self.Account)
241 : Effect.die(new Error("No account ID found")),
242 ),
243 );
244
245 const loginStored = Effect.fn(function* (profileName: string) {
246 const accessKeyId = yield* interaction.prompt
247 .text({
248 message: "AWS Access Key ID",
249 validate: (v) => (v.length === 0 ? "Required" : undefined),
250 })
251 .pipe(mapPromptCancellation);
252
253 const secretAccessKey = yield* interaction.prompt
254 .password({
255 message: "AWS Secret Access Key",
256 validate: (v) => (v.length === 0 ? "Required" : undefined),
257 })
258 .pipe(mapPromptCancellation);
259
260 const sessionToken = yield* interaction.prompt
261 .password({
262 message: "AWS Session Token (optional; press Enter to skip)",
263 placeholder: "(none)",
264 })
265 .pipe(mapPromptCancellation);
266
267 const region = yield* interaction.prompt
268 .text({
269 message: "AWS Region",
270 placeholder: "us-east-1",
271 defaultValue: "us-east-1",
272 })
273 .pipe(mapPromptCancellation);
274
275 const accountId = yield* getAccountId({
276 accessKeyId: Redacted.make(accessKeyId),
277 secretAccessKey: Redacted.make(secretAccessKey),
278 sessionToken: sessionToken ? Redacted.make(sessionToken) : undefined,
279 region,
280 });
281
282 yield* interaction.output.success("AWS credentials saved.");
283
284 return {
285 method: "stored" as const,
286 accountId,
287 accessKeyId,
288 secretAccessKey,
289 sessionToken: sessionToken || undefined,
290 region,
291 };
292 });
293
294 const configureInteractive = (profileName: string) =>
295 interaction.prompt
296 .select({
297 message: "AWS authentication method",
298 options,
299 })
300 .pipe(
301 Effect.flatMap((method) =>
302 Match.value(method).pipe(
303 Match.when("sso", () =>
304 Effect.gen(function* () {
305 const ssoProfile = yield* interaction.prompt.text({
306 message: "AWS profile name (from ~/.aws/config)",
307 placeholder: "default",
308 defaultValue: "default",
309 });
310 const authorizationMethod = yield* interaction.prompt.select({
311 message: "AWS SSO authorization method",
312 options: [
313 {
314 value: "oauth" as const,
315 label: "Browser OAuth",
316 description: "authorize in this device's browser",
317 },
318 {
319 value: "device" as const,
320 label: "Device code",
321 description:
322 "authorize with a short code on any device",
323 },
324 ],
325 });
326
327 const config = {
328 method: "sso" as const,
329 ssoProfile: ssoProfile ?? "default",
330 authorizationMethod,
331 };
332
333 yield* loginSSO(config, authorizationMethod);
334
335 return config;
336 }),
337 ),
338 Match.when("stored", () => loginStored(profileName)),
339 Match.exhaustive,
340 ),
341 ),
342 );
343
344 // The declared requirements are the union of the interactive path
345 // (ChildProcessSpawner for `aws sso login`) and `configureWith`'s
346 // (FileSystem/Path for ~/.aws/config probing) — the contract shares one
347 // ConfigureReq type parameter between the two entry points.
348 const configureCredentials = (
349 profileName: string,
350 ): Effect.Effect<
351 AwsAuthConfig,
352 AuthError,
353 | ChildProcessSpawner
354 | HttpClient.HttpClient
355 | FileSystem.FileSystem
356 | Path.Path
357 | Interaction.Interaction
358 > =>
359 configureInteractive(profileName).pipe(
360 Effect.mapError((e) =>
361 e instanceof AuthError
362 ? e
363 : new AuthError({
364 message: "failed to configure credentials",
365 cause: e,
366 }),
367 ),
368 );
369
370 const configureWith = (
371 profileName: string,
372 input: {
373 readonly method: string;
374 readonly values: Record<string, string>;
375 },
376 ) =>
377 Match.value(input.method).pipe(
378 Match.when("stored", () =>
379 Effect.gen(function* () {
380 const values = yield* validateFieldValues(
381 AWS_AUTH_PROVIDER_NAME,
382 keysFields,
383 input.values,
384 );
385 // validateFieldValues guarantees the required fields are present.
386 const accessKeyId = storedSecret(values.accessKeyId);
387 const secretAccessKey = storedSecret(values.secretAccessKey);
388 const sessionToken = storedSecret(values.sessionToken);
389 const region = storedValueText(values.region) ?? "";
390 if (accessKeyId === undefined || secretAccessKey === undefined) {
391 return yield* Effect.fail(
392 new AuthError({
393 message: "AWS: required key fields are missing.",
394 }),
395 );
396 }
397 const accountId = yield* getAccountId({
398 accessKeyId,
399 secretAccessKey,
400 sessionToken,
401 region,
402 }).pipe(
403 Effect.mapError(
404 (cause) =>
405 new AuthError({
406 message:
407 "AWS: failed to verify credentials via STS GetCallerIdentity.",
408 cause,
409 }),
410 ),
411 );
412 return {
413 method: "stored" as const,
414 accountId,
415 accessKeyId: Redacted.value(accessKeyId),
416 secretAccessKey: Redacted.value(secretAccessKey),
417 sessionToken:
418 sessionToken === undefined
419 ? undefined
420 : Redacted.value(sessionToken),
421 region,
422 };
423 }),
424 ),
425 Match.when("sso", () =>
426 Effect.gen(function* () {
427 const values = yield* validateFieldValues(
428 AWS_AUTH_PROVIDER_NAME,
429 ssoFields,
430 input.values,
431 );
432 const ssoProfile = storedValueText(values.ssoProfile) ?? "";
433 const auth = yield* DistilledAuth.Default;
434 const profile = yield* auth
435 .loadProfile(ssoProfile)
436 .pipe(Effect.catch(() => Effect.succeed(undefined)));
437 if (profile == null) {
438 return yield* Effect.fail(
439 new AuthError({
440 message: `AWS SSO profile '${ssoProfile}' was not found in ~/.aws/config. Configure it with \`aws configure sso\` first, then log in. ${refreshHint(AWS_AUTH_PROVIDER_NAME, profileName)}`,
441 }),
442 );
443 }
444 // Nothing is persisted for SSO — credentials come from the AWS
445 // SSO cache. `aws sso login` is interactive, so it is NOT run
446 // here; the user runs `alchemy profile refresh` afterwards.
447 return { method: "sso" as const, ssoProfile };
448 }),
449 ),
450 Match.orElse(() =>
451 Effect.fail(
452 new AuthError({
453 message: `AWS: unknown method '${input.method}'. Valid methods: stored, sso.`,
454 }),
455 ),
456 ),
457 );
458
459 const resolveCredentials = (
460 profileName: string,
461 config: AwsAuthConfig,
462 updateConfig?: (config: AwsAuthConfig) => Effect.Effect<void, AuthError>,
463 ) =>
464 Effect.gen(function* () {
465 const reauth = refreshHint(AWS_AUTH_PROVIDER_NAME, profileName);
466 return yield* Match.value(config)
467 .pipe(
468 Match.when({ method: "stored" }, (config) =>
469 Effect.gen(function* () {
470 const credentials: AwsCredentials = {
471 accessKeyId: Redacted.make(config.accessKeyId),
472 secretAccessKey: Redacted.make(config.secretAccessKey),
473 sessionToken:
474 config.sessionToken === undefined
475 ? undefined
476 : Redacted.make(config.sessionToken),
477 region: config.region,
478 };
479 const accountId =
480 config.accountId ?? (yield* getAccountId(credentials));
481 if (
482 config.accountId === undefined &&
483 updateConfig !== undefined
484 ) {
485 yield* updateConfig({ ...config, accountId });
486 }
487 return {
488 accountId,
489 credentials: Effect.succeed(credentials),
490 region: config.region,
491 source: { type: "stored" as const },
492 } satisfies AwsResolvedCredentials;
493 }),
494 ),
495 Match.when({ method: "sso" }, (config) =>
496 Effect.gen(function* () {
497 const auth = yield* DistilledAuth.Default;
498 const profile = yield* auth
499 .loadProfile(config.ssoProfile)
500 .pipe(Effect.catch(() => Effect.succeed(undefined)));
501 if (profile?.sso_account_id == null) {
502 const reconfigure = reconfigureHint(
503 AWS_AUTH_PROVIDER_NAME,
504 profileName,
505 );
506 return yield* Effect.fail(
507 new AuthError({
508 message:
509 profile == null
510 ? `AWS SSO profile '${config.ssoProfile}' was not found in ~/.aws/config. Configure it with \`aws configure sso\`. ${reconfigure}`
511 : `AWS SSO profile '${config.ssoProfile}' has no sso_account_id in ~/.aws/config. Add it. ${reconfigure}`,
512 }),
513 );
514 }
515 // `applyEnvRegionOverride` below only overrides an existing
516 // region, so an env-provided region must be consulted here for
517 // profiles that don't record one.
518 const region = profile.region ?? (yield* getEnv("AWS_REGION"));
519 if (!region) {
520 return yield* Effect.fail(
521 new AuthError({
522 message: `AWS SSO profile '${config.ssoProfile}' has no region in ~/.aws/config and AWS_REGION is not set.`,
523 }),
524 );
525 }
526 return {
527 accountId: profile.sso_account_id,
528 // Rewrite the message of an expired/invalid SSO token to the
529 // alchemy refresh hint, but PRESERVE the error tags: the inner
530 // effect must stay a `CredentialsError` for downstream
531 // consumers (AWSEnvironment), while `details` and other
532 // in-provider consumers match these tags to surface a typed
533 // `NeedsReauth` instead of a generic failure.
534 credentials: auth
535 .loadProfileCredentials(config.ssoProfile)
536 .pipe(
537 Effect.provideService(
538 EffectConsole.Console,
539 silentConsole,
540 ),
541 Effect.mapError((error) => {
542 if (error._tag === "Alchemy::AWS::ExpiredSSOToken") {
543 return new ExpiredSSOToken({
544 message: `AWS SSO credentials need to be refreshed. ${reauth}`,
545 profile: error.profile,
546 });
547 }
548 if (error._tag === "Alchemy::AWS::InvalidSSOToken") {
549 return new InvalidSSOToken({
550 message: `AWS SSO credentials need to be refreshed. ${reauth}`,
551 sso_session: error.sso_session,
552 });
553 }
554 return error;
555 }),
556 ),
557 region,
558 source: { type: "sso" as const, details: config.ssoProfile },
559 } satisfies AwsResolvedCredentials;
560 }),
561 ),
562 Match.exhaustive,
563 )
564 .pipe(
565 // Pass diagnosable failures through untouched: NeedsReauth (stored
566 // credentials missing) and the specific AuthErrors raised above
567 // (missing SSO profile / sso_account_id / region) carry the real
568 // diagnosis. Only genuinely unexpected failures (store I/O, the
569 // STS accountId backfill) get wrapped.
570 Effect.mapError((e) =>
571 e._tag === "AuthError"
572 ? e
573 : new AuthError({
574 message: "failed to resolve AWS credentials",
575 cause: e,
576 }),
577 ),
578 Effect.flatMap(applyEnvRegionOverride),
579 Effect.map((creds): AwsResolvedCredentials => ({
580 ...creds,
581 credentials: creds.credentials.pipe(
582 Effect.map((credentials) => ({
583 ...credentials,
584 region: creds.region,
585 })),
586 ),
587 })),
588 );
589 });
590
591 const details = (
592 profileName: string,
593 config: AwsAuthConfig,
594 updateConfig?: (config: AwsAuthConfig) => Effect.Effect<void, AuthError>,
595 ) =>
596 Effect.gen(function* () {
597 const creds = yield* resolveCredentials(
598 profileName,
599 config,
600 updateConfig,
601 );
602 const reauth = refreshHint(AWS_AUTH_PROVIDER_NAME, profileName);
603 // Resolve the live credentials. An expired/invalid SSO token only
604 // surfaces here (the inner effect is lazy), so convert those tags
605 // into a typed NeedsReauth instead of a generic error line.
606 const { accessKeyId, secretAccessKey, sessionToken } =
607 yield* creds.credentials.pipe(
608 Effect.mapError((error) =>
609 error._tag === "Alchemy::AWS::ExpiredSSOToken" ||
610 error._tag === "Alchemy::AWS::InvalidSSOToken"
611 ? new NeedsReauth({
612 provider: AWS_AUTH_PROVIDER_NAME,
613 profile: profileName,
614 message: `AWS SSO credentials need to be refreshed. ${reauth}`,
615 cause: error,
616 })
617 : new AuthError({
618 message: "failed to load AWS credentials",
619 cause: error,
620 }),
621 ),
622 );
623 const lines: Array<ProviderDetailLine> = [
624 { key: "accessKeyId", value: displayRedacted(accessKeyId) },
625 { key: "secretAccessKey", value: displayRedacted(secretAccessKey) },
626 ];
627 if (sessionToken) {
628 lines.push({
629 key: "sessionToken",
630 value: displayRedacted(sessionToken),
631 });
632 }
633 if (creds.region) {
634 lines.push({ key: "region", value: creds.region });
635 }
636 const source = creds.source;
637 lines.push({
638 key: "source",
639 value:
640 "details" in source
641 ? `${source.type} - ${source.details}`
642 : source.type,
643 });
644 return { lines };
645 });
646
647 const logout = (_profileName: string, config: AwsAuthConfig) =>
648 Match.value(config).pipe(
649 Match.when({ method: "sso" }, (config) =>
650 interaction.output
651 .info(
652 `AWS: running 'aws sso logout --profile ${config.ssoProfile}'...`,
653 )
654 .pipe(
655 Effect.zip(runSsoCommand("logout", config.ssoProfile)),
656 Effect.zip(clearDistilledSsoCache(config.ssoProfile)),
657 Effect.match({
658 onSuccess: () =>
659 interaction.output.success("AWS: SSO logout complete"),
660 onFailure: (e) =>
661 interaction.output.warning(
662 `AWS: SSO logout failed: \`${e.message}\``,
663 ),
664 }),
665 ),
666 ),
667 Match.when({ method: "stored" }, () => Effect.void),
668 Match.exhaustive,
669 );
670
671 const login = (profileName: string, config: AwsAuthConfig) =>
672 Match.value(config)
673 .pipe(
674 Match.when({ method: "sso" }, (config) =>
675 loginSSO(config, config.authorizationMethod ?? "oauth"),
676 ),
677 Match.when({ method: "stored" }, (config) => Effect.succeed(config)),
678 Match.exhaustive,
679 )
680 .pipe(
681 Effect.mapError(
682 (e) => new AuthError({ message: "login failed", cause: e }),
683 ),
684 );
685
686 const readEnvironment = Effect.gen(function* () {
687 const accessKeyId = yield* getEnvRedactedRequired("AWS_ACCESS_KEY_ID");
688 const secretAccessKey = yield* getEnvRedactedRequired(
689 "AWS_SECRET_ACCESS_KEY",
690 );
691 const sessionToken = yield* getEnvRedacted("AWS_SESSION_TOKEN");
692 const region = yield* getEnv("AWS_REGION").pipe(
693 Effect.flatMap((value) =>
694 value ? Effect.succeed(value) : getEnv("AWS_DEFAULT_REGION"),
695 ),
696 );
697 if (!region) {
698 return yield* new AuthError({
699 message:
700 "AWS CI region not found. Set AWS_REGION or AWS_DEFAULT_REGION.",
701 });
702 }
703 const accountId = yield* getEnvRequired("AWS_ACCOUNT_ID").pipe(
704 Effect.catch(() =>
705 getAccountId({
706 accessKeyId,
707 secretAccessKey,
708 sessionToken,
709 region,
710 }),
711 ),
712 );
713 return {
714 accountId,
715 credentials: Effect.succeed({
716 accessKeyId,
717 secretAccessKey,
718 sessionToken,
719 region,
720 }),
721 region,
722 source: { type: "env" as const },
723 } satisfies AwsResolvedCredentials;
724 }).pipe(
725 Effect.mapError((cause) =>
726 cause instanceof AuthError
727 ? cause
728 : new AuthError({
729 message:
730 "Failed to resolve AWS credentials from the CI environment.",
731 cause,
732 }),
733 ),
734 );
735
736 return {
737 configSchema: AwsAuthConfigSchema,
738 configure: configureCredentials,
739 configureWith,
740 configureMethods,
741 login,
742 logout,
743 details,
744 read: resolveCredentials,
745 readEnvironment,
746 environment: [
747 {
748 name: "AWS_ACCESS_KEY_ID",
749 required: true,
750 secret: true,
751 },
752 {
753 name: "AWS_SECRET_ACCESS_KEY",
754 required: true,
755 secret: true,
756 },
757 {
758 name: "AWS_SESSION_TOKEN",
759 required: false,
760 secret: true,
761 description: "Required when the access key is a temporary STS key.",
762 },
763 {
764 name: "AWS_REGION",
765 required: true,
766 alternatives: ["AWS_DEFAULT_REGION"],
767 description: "Region the stack deploys into.",
768 },
769 {
770 name: "AWS_ACCOUNT_ID",
771 required: false,
772 description: "Derived via STS GetCallerIdentity when unset.",
773 },
774 ],
775 };
776 }),
777);
778
779const runSsoCommand = (command: "login" | "logout", ssoProfile: string) =>
780 Effect.gen(function* () {
781 const handle = yield* ChildProcess.make(
782 "aws",
783 ["sso", command, "--profile", ssoProfile],
784 {
785 shell: false,
786 stdin: "inherit",
787 stdout: "inherit",
788 stderr: "inherit",
789 },
790 );
791 const exit = yield* handle.exitCode;
792 if (exit !== 0) {
793 return yield* new AuthError({
794 message: `aws sso ${command} exited with code ${exit}`,
795 });
796 }
797 }).pipe(Effect.scoped);
798
799const AwsSsoLoginOutput = Schema.Struct({
800 url: Schema.optional(Schema.String),
801 authUrl: Schema.optional(Schema.String),
802 authorizationUrl: Schema.optional(Schema.String),
803 verificationUri: Schema.optional(Schema.String),
804 verificationUriComplete: Schema.optional(Schema.String),
805 code: Schema.optional(Schema.String),
806 userCode: Schema.optional(Schema.String),
807});
808
809export const parseAwsSsoLoginOutput = (output: string) => {
810 try {
811 const decoded = Schema.decodeUnknownOption(AwsSsoLoginOutput)(
812 JSON.parse(output) as unknown,
813 );
814 if (Option.isSome(decoded)) {
815 const event = decoded.value;
816 return {
817 url:
818 event.authorizationUrl ??
819 event.authUrl ??
820 event.verificationUriComplete ??
821 event.verificationUri ??
822 event.url,
823 code: event.userCode ?? event.code,
824 };
825 }
826 } catch {
827 // `sso login` accepts the global `--output json` option but current AWS
828 // CLI releases still render this custom command's authorization prompt as
829 // text. Keep accepting structured output if AWS starts honoring the flag.
830 }
831 return {
832 url: output.match(/https?:\/\/[^\s]+/)?.[0],
833 code: output.match(/Then enter the code:\s*([A-Z0-9-]+)/i)?.[1],
834 };
835};
836
837const loginSSO = (
838 config: Extract<AwsAuthConfig, { method: "sso" }>,
839 authorizationMethod: "oauth" | "device" = "oauth",
840) =>
841 Effect.scoped(
842 Effect.gen(function* () {
843 const interaction = yield* Interaction.Interaction;
844 const services = yield* Effect.context<ChildProcessSpawner>();
845 const runOpenUrl = Effect.runPromiseWith(services);
846 const authorizationUrl = yield* Deferred.make<string>();
847 const deviceCode = yield* Deferred.make<string>();
848 const stdout = yield* Ref.make("");
849 const stderr = yield* Ref.make("");
850 const handle = yield* ChildProcess.make(
851 "aws",
852 [
853 "sso",
854 "login",
855 "--profile",
856 config.ssoProfile,
857 "--no-browser",
858 "--output",
859 "json",
860 ...(authorizationMethod === "device" ? ["--use-device-code"] : []),
861 ],
862 {
863 shell: false,
864 stdin: "ignore",
865 stdout: "pipe",
866 stderr: "pipe",
867 },
868 );
869 const collectStdout = handle.stdout.pipe(
870 Stream.decodeText(),
871 Stream.runForEach((chunk) =>
872 Effect.gen(function* () {
873 const combined = yield* Ref.updateAndGet(
874 stdout,
875 (current) => current + chunk,
876 );
877 const { url, code } = parseAwsSsoLoginOutput(combined);
878 if (url !== undefined) {
879 yield* Deferred.succeed(authorizationUrl, url);
880 }
881 if (code !== undefined) {
882 yield* Deferred.succeed(deviceCode, code);
883 }
884 }),
885 ),
886 );
887 const collectStderr = handle.stderr.pipe(
888 Stream.decodeText(),
889 Stream.runForEach((chunk) =>
890 Ref.update(stderr, (current) => current + chunk),
891 ),
892 );
893 const process = Effect.gen(function* () {
894 const [exitCode] = yield* Effect.all(
895 [handle.exitCode, collectStdout, collectStderr],
896 { concurrency: 3 },
897 );
898 if (exitCode !== 0) {
899 const detail = (yield* Ref.get(stderr)).trim();
900 return yield* Effect.fail(
901 new AuthError({
902 message: `aws sso login exited with code ${exitCode}${detail === "" ? "" : `: ${detail}`}`,
903 }),
904 );
905 }
906 });
907 const processFiber = yield* Effect.forkScoped(process);
908 const url = yield* Deferred.await(authorizationUrl).pipe(
909 Effect.raceFirst(
910 Fiber.join(processFiber).pipe(
911 Effect.flatMap(() =>
912 Effect.fail(
913 new AuthError({
914 message:
915 "AWS SSO login completed without providing an authorization URL.",
916 }),
917 ),
918 ),
919 ),
920 ),
921 );
922 const code =
923 authorizationMethod === "device"
924 ? yield* Deferred.await(deviceCode).pipe(
925 Effect.raceFirst(
926 Fiber.join(processFiber).pipe(
927 Effect.flatMap(() =>
928 Effect.fail(
929 new AuthError({
930 message:
931 "AWS SSO device login completed without providing an authorization code.",
932 }),
933 ),
934 ),
935 ),
936 ),
937 )
938 : undefined;
939 const openFailed = yield* Interaction.openUrl(url).pipe(
940 Effect.as(false),
941 Effect.catch(() => Effect.succeed(true)),
942 );
943 yield* Fiber.join(processFiber).pipe(
944 Effect.raceFirst(
945 interaction.prompt
946 .awaitExternal({
947 message: "AWS authorization",
948 waitingLabel:
949 authorizationMethod === "device"
950 ? "waiting for device authorization (up to 5 minutes)…"
951 : "waiting for browser authorization (up to 5 minutes)…",
952 url,
953 code,
954 openFailed,
955 onOpen: () => runOpenUrl(Interaction.openUrl(url)),
956 allowManualInput: false,
957 })
958 .pipe(Effect.asVoid),
959 ),
960 );
961 yield* interaction.output.success("AWS SSO: login complete");
962 }),
963 );
964
965/**
966 * `aws sso logout` only clears AWS CLI's own caches — it does not know about the
967 * `<sha1(sso_session)>.credentials.json` file that `@distilled.cloud/aws`
968 * writes alongside the SSO token. Without this cleanup, `loadProfileCredentials`
969 * short-circuits on the stale distilled cache file after logout and appears to
970 * stay logged in until the role creds hit their TTL.
971 */
972const clearDistilledSsoCache = (ssoProfile: string) =>
973 Effect.gen(function* () {
974 const auth = yield* DistilledAuth.Default;
975 const profile = yield* auth.loadProfile(ssoProfile);
976 const ssoSession = (profile as { sso_session?: string }).sso_session;
977 if (!ssoSession) return;
978 const fs = yield* FileSystem.FileSystem;
979 const path = yield* Path.Path;
980 const hash = NodeCrypto.createHash("sha1").update(ssoSession).digest("hex");
981 const cacheFile = path.join(
982 NodeOs.homedir(),
983 ".aws",
984 "sso",
985 "cache",
986 `${hash}.credentials.json`,
987 );
988 yield* fs.remove(cacheFile).pipe(Effect.catch(() => Effect.void));
989 }).pipe(Effect.catch(() => Effect.void));