@distilled.cloud/aws 0.2.0-alpha → 0.2.3

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.
@@ -7,23 +7,19 @@ import {
7
7
  fromProcess as _fromProcess,
8
8
  fromTokenFile as _fromTokenFile,
9
9
  } from "@aws-sdk/credential-providers";
10
- import * as ini from "@smithy/shared-ini-file-loader";
10
+
11
11
  import {
12
12
  type AwsCredentialIdentity,
13
13
  type AwsCredentialIdentityProvider,
14
14
  } from "@smithy/types";
15
- import * as Console from "effect/Console";
16
15
  import * as Data from "effect/Data";
17
16
  import * as Effect from "effect/Effect";
18
- import * as FileSystem from "effect/FileSystem";
19
17
  import * as Layer from "effect/Layer";
20
- import * as Option from "effect/Option";
18
+ import type { PlatformError } from "effect/PlatformError";
21
19
  import * as Redacted from "effect/Redacted";
22
20
  import * as ServiceMap from "effect/ServiceMap";
23
- import * as HttpClient from "effect/unstable/http/HttpClient";
24
- import { createHash } from "node:crypto";
25
- import * as path from "node:path";
26
- import { parseIni, parseSSOSessionData } from "./util/parse-ini.ts";
21
+ import type { HttpClientError } from "effect/unstable/http/HttpClientError";
22
+ import { Auth } from "./auth.ts";
27
23
  export * as AWSTypes from "@aws-sdk/types";
28
24
 
29
25
  export interface AwsCredentials {
@@ -32,31 +28,61 @@ export interface AwsCredentials {
32
28
  readonly sessionToken?: string;
33
29
  }
34
30
 
31
+ /**
32
+ * Resolved credential values ready for request signing.
33
+ */
34
+ export interface ResolvedCredentials {
35
+ readonly accessKeyId: Redacted.Redacted<string>;
36
+ readonly secretAccessKey: Redacted.Redacted<string>;
37
+ readonly sessionToken: Redacted.Redacted<string> | undefined;
38
+ readonly expiration?: number;
39
+ }
40
+
41
+ /**
42
+ * The requirements for resolving credentials (HttpClient for SSO, FileSystem for cache).
43
+ */
44
+
45
+ /**
46
+ * Error types that can occur during credential resolution.
47
+ */
48
+ export type CredentialsError =
49
+ | AwsCredentialProviderError
50
+ | ProfileNotFound
51
+ | InvalidSSOProfile
52
+ | InvalidSSOToken
53
+ | ExpiredSSOToken
54
+ | ConflictingSSORegion
55
+ | ConflictingSSOStartUrl
56
+ | HttpClientError
57
+ | PlatformError;
58
+
35
59
  export class Credentials extends ServiceMap.Service<
36
60
  Credentials,
37
- {
38
- accessKeyId: Redacted.Redacted<string>;
39
- secretAccessKey: Redacted.Redacted<string>;
40
- sessionToken: Redacted.Redacted<string> | undefined;
41
- expiration?: number;
42
- }
61
+ Effect.Effect<ResolvedCredentials, CredentialsError>
43
62
  >()("AWS::Credentials") {}
44
63
 
45
- export const mock = Layer.succeed(Credentials, {
46
- accessKeyId: Redacted.make("test"),
47
- secretAccessKey: Redacted.make("test"),
48
- sessionToken: Redacted.make("test"),
49
- });
64
+ export const mock = Layer.succeed(
65
+ Credentials,
66
+ Effect.succeed({
67
+ accessKeyId: Redacted.make("test"),
68
+ secretAccessKey: Redacted.make("test"),
69
+ sessionToken: Redacted.make("test"),
70
+ }),
71
+ );
50
72
 
51
- export const fromAwsCredentialIdentity = (identity: AwsCredentialIdentity) =>
52
- Credentials.of({
53
- accessKeyId: Redacted.make(identity.accessKeyId),
54
- secretAccessKey: Redacted.make(identity.secretAccessKey),
55
- sessionToken: identity.sessionToken
56
- ? Redacted.make(identity.sessionToken)
57
- : undefined,
58
- expiration: identity.expiration?.getTime(),
59
- });
73
+ /**
74
+ * Create resolved credentials from an AWS credential identity.
75
+ */
76
+ export const fromAwsCredentialIdentity = (
77
+ identity: AwsCredentialIdentity,
78
+ ): ResolvedCredentials => ({
79
+ accessKeyId: Redacted.make(identity.accessKeyId),
80
+ secretAccessKey: Redacted.make(identity.secretAccessKey),
81
+ sessionToken: identity.sessionToken
82
+ ? Redacted.make(identity.sessionToken)
83
+ : undefined,
84
+ expiration: identity.expiration?.getTime(),
85
+ });
60
86
 
61
87
  type ProviderName =
62
88
  | "env"
@@ -101,59 +127,96 @@ const providerHints = (
101
127
 
102
128
  export const _providerHints = providerHints;
103
129
 
104
- const createLayer = (
130
+ /**
131
+ * Time window (5 mins) to refresh credentials before they actually expire.
132
+ * This prevents using credentials that are about to expire.
133
+ */
134
+ const CREDENTIAL_REFRESH_WINDOW_MS = 5 * 60 * 1000;
135
+
136
+ /**
137
+ * Create a credentials effect with lazy resolution and expiration-aware caching.
138
+ * Uses Effect.cachedWithTTL where the TTL is computed from the credentials' expiration.
139
+ */
140
+ const createCachedCredentialsEffect = <E, R>(
141
+ resolve: Effect.Effect<ResolvedCredentials, E, R>,
142
+ ): Effect.Effect<ResolvedCredentials, E, R> => {
143
+ let cachedCreds: ResolvedCredentials | undefined;
144
+ let expiresAt: number | undefined;
145
+
146
+ return Effect.suspend(() => {
147
+ const now = Date.now();
148
+ if (cachedCreds && expiresAt && now < expiresAt) {
149
+ return Effect.succeed(cachedCreds);
150
+ }
151
+ return Effect.map(resolve, (creds) => {
152
+ cachedCreds = creds;
153
+ expiresAt = creds.expiration
154
+ ? creds.expiration - CREDENTIAL_REFRESH_WINDOW_MS
155
+ : undefined;
156
+ return creds;
157
+ });
158
+ });
159
+ };
160
+
161
+ /**
162
+ * Create a lazy, cached credentials provider from an AWS SDK credential provider.
163
+ * Credentials are resolved on first access and cached based on their expiration time.
164
+ */
165
+ const createLazyProvider = (
105
166
  provider: (config: {}) => AwsCredentialIdentityProvider,
106
167
  providerName: ProviderName,
107
- ) =>
108
- Layer.effect(
168
+ ): Layer.Layer<Credentials> => {
169
+ const resolve = Effect.gen(function* () {
170
+ const hints = providerHints(providerName);
171
+ const identity = yield* Effect.tryPromise({
172
+ try: () => provider({})(),
173
+ catch: (cause) =>
174
+ new AwsCredentialProviderError({
175
+ message: `Failed to resolve credentials from ${providerName}.`,
176
+ provider: providerName,
177
+ cause,
178
+ hints,
179
+ }),
180
+ });
181
+ return fromAwsCredentialIdentity(identity);
182
+ });
183
+
184
+ return Layer.succeed(Credentials, createCachedCredentialsEffect(resolve));
185
+ };
186
+
187
+ /**
188
+ * Create a credentials provider from static credentials.
189
+ * No lazy loading or caching needed since credentials are already available.
190
+ */
191
+ export const fromCredentials = (
192
+ credentials: AwsCredentialIdentity,
193
+ ): Layer.Layer<Credentials> =>
194
+ Layer.succeed(
109
195
  Credentials,
110
- Effect.gen(function* () {
111
- const hints = providerHints(providerName);
112
- const identity = yield* Effect.tryPromise({
113
- try: () => provider({})(),
114
- catch: (cause) =>
115
- new AwsCredentialProviderError({
116
- message: `Failed to resolve credentials from ${providerName}.`,
117
- provider: providerName,
118
- cause,
119
- hints,
120
- }),
121
- });
122
- return fromAwsCredentialIdentity(identity);
123
- }),
196
+ Effect.succeed(fromAwsCredentialIdentity(credentials)),
124
197
  );
125
198
 
126
- export const fromCredentials = (credentials: AwsCredentialIdentity) =>
127
- Layer.succeed(Credentials, fromAwsCredentialIdentity(credentials));
128
-
129
- export const fromEnv = () => createLayer(_fromEnv, "env");
199
+ export const fromEnv = () => createLazyProvider(_fromEnv, "env");
130
200
 
131
201
  export const fromChain = () =>
132
- createLayer(() => _fromNodeProviderChain(), "chain");
202
+ createLazyProvider(() => _fromNodeProviderChain(), "chain");
133
203
 
134
- // export const fromSSO = () => createLayer(_fromSSO);
204
+ // export const fromSSO = () => createLazyProvider(_fromSSO);
135
205
 
136
- export const fromIni = () => createLayer(_fromIni, "ini");
206
+ export const fromIni = () => createLazyProvider(_fromIni, "ini");
137
207
 
138
208
  export const fromContainerMetadata = () =>
139
- createLayer(_fromContainerMetadata, "container");
209
+ createLazyProvider(_fromContainerMetadata, "container");
140
210
 
141
- export const fromHttp = () => createLayer(_fromHttp, "http");
211
+ export const fromHttp = () => createLazyProvider(_fromHttp, "http");
142
212
 
143
- export const fromProcess = () => createLayer(_fromProcess, "process");
213
+ export const fromProcess = () => createLazyProvider(_fromProcess, "process");
144
214
 
145
- export const fromTokenFile = () => createLayer(_fromTokenFile, "token-file");
215
+ export const fromTokenFile = () =>
216
+ createLazyProvider(_fromTokenFile, "token-file");
146
217
 
147
218
  export const ssoRegion = (region: string) => Layer.succeed(SsoRegion, region);
148
219
 
149
- /**
150
- * The time window (5 mins) that SDK will treat the SSO token expires in before the defined expiration date in token.
151
- * This is needed because server side may have invalidated the token before the defined expiration date.
152
- */
153
- const EXPIRE_WINDOW_MS = 5 * 60 * 1000;
154
-
155
- const REFRESH_MESSAGE = `To refresh this SSO session run 'aws sso login' with the corresponding profile.`;
156
-
157
220
  export class SsoRegion extends ServiceMap.Service<SsoRegion, string>()(
158
221
  "AWS::SsoRegion",
159
222
  ) {}
@@ -161,13 +224,15 @@ export class SsoStartUrl extends ServiceMap.Service<SsoStartUrl, string>()(
161
224
  "AWS::SsoStartUrl",
162
225
  ) {}
163
226
 
164
- export class ProfileNotFound extends Data.TaggedError("AWS::ProfileNotFound")<{
227
+ export class ProfileNotFound extends Data.TaggedError(
228
+ "Alchemy::AWS::ProfileNotFound",
229
+ )<{
165
230
  message: string;
166
231
  profile: string;
167
232
  }> {}
168
233
 
169
234
  export class ConflictingSSORegion extends Data.TaggedError(
170
- "AWS::ConflictingSSORegion",
235
+ "Alchemy::AWS::ConflictingSSORegion",
171
236
  )<{
172
237
  message: string;
173
238
  ssoRegion: string;
@@ -175,7 +240,7 @@ export class ConflictingSSORegion extends Data.TaggedError(
175
240
  }> {}
176
241
 
177
242
  export class ConflictingSSOStartUrl extends Data.TaggedError(
178
- "AWS::ConflictingSSOStartUrl",
243
+ "Alchemy::AWS::ConflictingSSOStartUrl",
179
244
  )<{
180
245
  message: string;
181
246
  ssoStartUrl: string;
@@ -183,19 +248,23 @@ export class ConflictingSSOStartUrl extends Data.TaggedError(
183
248
  }> {}
184
249
 
185
250
  export class InvalidSSOProfile extends Data.TaggedError(
186
- "AWS::InvalidSSOProfile",
251
+ "Alchemy::AWS::InvalidSSOProfile",
187
252
  )<{
188
253
  message: string;
189
254
  profile: string;
190
255
  missingFields: string[];
191
256
  }> {}
192
257
 
193
- export class InvalidSSOToken extends Data.TaggedError("AWS::InvalidSSOToken")<{
258
+ export class InvalidSSOToken extends Data.TaggedError(
259
+ "Alchemy::AWS::InvalidSSOToken",
260
+ )<{
194
261
  message: string;
195
262
  sso_session: string;
196
263
  }> {}
197
264
 
198
- export class ExpiredSSOToken extends Data.TaggedError("AWS::ExpiredSSOToken")<{
265
+ export class ExpiredSSOToken extends Data.TaggedError(
266
+ "Alchemy::AWS::ExpiredSSOToken",
267
+ )<{
199
268
  message: string;
200
269
  profile: string;
201
270
  }> {}
@@ -209,238 +278,17 @@ export class AwsCredentialProviderError extends Data.TaggedError(
209
278
  hints?: ReadonlyArray<string>;
210
279
  }> {}
211
280
 
212
- export interface AwsProfileConfig {
213
- sso_session?: string;
214
- sso_account_id?: string;
215
- sso_role_name?: string;
216
- region?: string;
217
- output?: string;
218
- sso_start_url?: string;
219
- sso_region?: string;
220
- }
221
- export interface SsoProfileConfig extends AwsProfileConfig {
222
- sso_start_url: string;
223
- sso_region: string;
224
- sso_account_id: string;
225
- sso_role_name: string;
226
- }
227
-
228
281
  /**
229
- * Cached SSO token retrieved from SSO login flow.
230
- * @public
282
+ * Create a lazy, cached SSO credentials provider.
283
+ * SSO credential resolution is deferred until the Effect is run,
284
+ * and credentials are cached until they expire.
231
285
  */
232
- export interface SSOToken {
233
- accessToken: string;
234
- expiresAt: string;
235
- refreshToken?: string;
236
- clientId?: string;
237
- clientSecret?: string;
238
- registrationExpiresAt?: string;
239
- region?: string;
240
- startUrl?: string;
241
- }
242
-
243
286
  export const fromSSO = (profileName: string = "default") =>
244
- Layer.effect(Credentials, loadSSOCredentials(profileName));
245
-
246
- export const loadSSOCredentials = Effect.fn(function* (profileName: string) {
247
- const client = yield* HttpClient.HttpClient;
248
- const fs = yield* FileSystem.FileSystem;
249
- const awsDir = path.join(ini.getHomeDir(), ".aws");
250
- const cachePath = path.join(awsDir, "sso", "cache");
251
-
252
- const profile = yield* loadProfile(profileName);
253
-
254
- if (profile.sso_session) {
255
- const hasher = createHash("sha1");
256
- const cacheName = hasher.update(profile.sso_session).digest("hex");
257
- const ssoTokenFilepath = path.join(cachePath, `${cacheName}.json`);
258
- const cachedCredsFilePath = path.join(
259
- cachePath,
260
- `${cacheName}.credentials.json`,
261
- );
262
-
263
- const cachedCreds = yield* fs.readFileString(cachedCredsFilePath).pipe(
264
- Effect.map((text) => JSON.parse(text)),
265
- Effect.catch(() => Effect.void),
266
- );
267
-
268
- const isExpired = (expiry: number | string | undefined) => {
269
- return (
270
- expiry === undefined ||
271
- new Date(expiry).getTime() - Date.now() <= EXPIRE_WINDOW_MS
272
- );
273
- };
274
-
275
- if (cachedCreds && !isExpired(cachedCreds.expiry)) {
276
- return Credentials.of({
277
- accessKeyId: Redacted.make(cachedCreds.accessKeyId),
278
- secretAccessKey: Redacted.make(cachedCreds.secretAccessKey),
279
- sessionToken: cachedCreds.sessionToken
280
- ? Redacted.make(cachedCreds.sessionToken)
281
- : undefined,
282
- expiration: cachedCreds.expiry,
283
- });
284
- }
285
-
286
- const ssoToken = yield* fs.readFileString(ssoTokenFilepath).pipe(
287
- Effect.map((text) => JSON.parse(text) as SSOToken),
288
- Effect.catch(() =>
289
- Effect.fail(
290
- new InvalidSSOToken({
291
- message: `The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
292
- sso_session: profile.sso_session!,
293
- }),
294
- ),
287
+ Layer.effect(
288
+ Credentials,
289
+ Auth.use((auth) =>
290
+ Effect.succeed(
291
+ createCachedCredentialsEffect(auth.loadProfileCredentials(profileName)),
295
292
  ),
296
- );
297
-
298
- if (isExpired(ssoToken.expiresAt)) {
299
- yield* Console.log(
300
- `The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
301
- );
302
- yield* Effect.fail(
303
- new ExpiredSSOToken({
304
- message: `The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
305
- profile: profileName,
306
- }),
307
- );
308
- }
309
-
310
- const response = yield* client.get(
311
- `https://portal.sso.${profile.sso_region}.amazonaws.com/federation/credentials?account_id=${profile.sso_account_id}&role_name=${profile.sso_role_name}`,
312
- {
313
- headers: {
314
- "User-Agent": "alchemy.run",
315
- "Content-Type": "application/json",
316
- "x-amz-sso_bearer_token": ssoToken.accessToken,
317
- },
318
- },
319
- );
320
-
321
- const credentials = (
322
- (yield* response.json) as {
323
- roleCredentials: {
324
- accessKeyId: string;
325
- secretAccessKey: string;
326
- sessionToken: string;
327
- expiration: number;
328
- };
329
- }
330
- ).roleCredentials;
331
-
332
- yield* fs.writeFileString(
333
- cachedCredsFilePath,
334
- JSON.stringify({
335
- accessKeyId: credentials.accessKeyId,
336
- secretAccessKey: credentials.secretAccessKey,
337
- sessionToken: credentials.sessionToken,
338
- expiry: credentials.expiration,
339
- }),
340
- );
341
-
342
- return Credentials.of({
343
- accessKeyId: Redacted.make(credentials.accessKeyId),
344
- secretAccessKey: Redacted.make(credentials.secretAccessKey),
345
- sessionToken: Redacted.make(credentials.sessionToken),
346
- expiration: credentials.expiration,
347
- });
348
- }
349
-
350
- return yield* Effect.fail(
351
- new ProfileNotFound({
352
- message: `Profile ${profileName} not found`,
353
- profile: profileName,
354
- }),
355
- );
356
- });
357
-
358
- export const loadProfile = Effect.fn(function* (profileName: string) {
359
- const fs = yield* FileSystem.FileSystem;
360
- const profiles: {
361
- [profileName: string]: AwsProfileConfig;
362
- } = yield* Effect.promise(() =>
363
- ini.parseKnownFiles({ profile: profileName }),
293
+ ),
364
294
  );
365
-
366
- const profile = profiles[profileName];
367
-
368
- if (!profile) {
369
- yield* Effect.fail(
370
- new ProfileNotFound({
371
- message: `Profile ${profileName} not found`,
372
- profile: profileName,
373
- }),
374
- );
375
- }
376
-
377
- const awsDir = path.join(ini.getHomeDir(), ".aws");
378
- const configPath = path.join(awsDir, "config");
379
-
380
- if (profile.sso_session) {
381
- const ssoRegion = Option.getOrUndefined(
382
- yield* Effect.serviceOption(SsoRegion),
383
- );
384
- const ssoStartUrl = Option.getOrElse(
385
- yield* Effect.serviceOption(SsoStartUrl),
386
- () => profile.sso_start_url,
387
- );
388
-
389
- const ssoSessions = yield* fs.readFileString(configPath).pipe(
390
- Effect.flatMap((config) => Effect.promise(async () => parseIni(config))),
391
- Effect.map(parseSSOSessionData),
392
- );
393
- const session = ssoSessions[profile.sso_session];
394
- if (ssoRegion && ssoRegion !== session.sso_region) {
395
- yield* Effect.fail(
396
- new ConflictingSSORegion({
397
- message: `Conflicting SSO region`,
398
- ssoRegion: ssoRegion,
399
- profile: profile.sso_session,
400
- }),
401
- );
402
- }
403
- if (ssoStartUrl && ssoStartUrl !== session.sso_start_url) {
404
- yield* Effect.fail(
405
- new ConflictingSSOStartUrl({
406
- message: `Conflicting SSO start url`,
407
- ssoStartUrl: ssoStartUrl,
408
- profile: profile.sso_session,
409
- }),
410
- );
411
- }
412
- profile.sso_region = session.sso_region;
413
- profile.sso_start_url = session.sso_start_url;
414
-
415
- const ssoFields = [
416
- "sso_start_url",
417
- "sso_account_id",
418
- "sso_region",
419
- "sso_role_name",
420
- ] as const satisfies (keyof SsoProfileConfig)[];
421
- const missingFields = ssoFields.filter((field) => !profile[field]);
422
- if (missingFields.length > 0) {
423
- yield* Effect.fail(
424
- new InvalidSSOProfile({
425
- profile: profileName,
426
- missingFields,
427
- message:
428
- `Profile is configured with invalid SSO credentials. Required parameters "sso_account_id", ` +
429
- `"sso_region", "sso_role_name", "sso_start_url". Got ${Object.keys(
430
- profile,
431
- ).join(
432
- ", ",
433
- )}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`,
434
- }),
435
- );
436
- }
437
- return profile;
438
- }
439
-
440
- return yield* Effect.fail(
441
- new ProfileNotFound({
442
- message: `Profile ${profileName} not found`,
443
- profile: profileName,
444
- }),
445
- );
446
- });
package/src/index.ts CHANGED
@@ -1,3 +1,10 @@
1
+ /**
2
+ * AWS Authentication service for loading AWS profiles and credentials.
3
+ *
4
+ * @since 0.0.0
5
+ */
6
+ export * as Auth from "./auth.ts";
7
+
1
8
  /**
2
9
  * AWS Credentials providers for obtaining temporary or long-lived credentials.
3
10
  *