@distilled.cloud/aws 0.2.0-alpha → 0.2.4
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/README.md +61 -0
- package/lib/auth.d.ts +79 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/auth.js +148 -0
- package/lib/auth.js.map +1 -0
- package/lib/client/api.js +1 -1
- package/lib/client/api.js.map +1 -1
- package/lib/credentials.d.ts +52 -69
- package/lib/credentials.d.ts.map +1 -1
- package/lib/credentials.js +76 -162
- package/lib/credentials.js.map +1 -1
- package/lib/eventstream/codec.d.ts +3 -3
- package/lib/index.d.ts +6 -0
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +6 -0
- package/lib/index.js.map +1 -1
- package/lib/traits.d.ts +1 -1
- package/lib/traits.d.ts.map +1 -1
- package/package.json +12 -7
- package/src/auth.ts +315 -0
- package/src/client/api.ts +1 -1
- package/src/credentials.ts +148 -300
- package/src/index.ts +7 -0
package/src/auth.ts
ADDED
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import * as ini from "@smithy/shared-ini-file-loader";
|
|
2
|
+
import * as Console from "effect/Console";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as FileSystem from "effect/FileSystem";
|
|
5
|
+
import * as Option from "effect/Option";
|
|
6
|
+
import * as Path from "effect/Path";
|
|
7
|
+
import * as Redacted from "effect/Redacted";
|
|
8
|
+
import * as ServiceMap from "effect/ServiceMap";
|
|
9
|
+
import * as HttpClient from "effect/unstable/http/HttpClient";
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import {
|
|
12
|
+
ConflictingSSORegion,
|
|
13
|
+
ConflictingSSOStartUrl,
|
|
14
|
+
ExpiredSSOToken,
|
|
15
|
+
InvalidSSOProfile,
|
|
16
|
+
InvalidSSOToken,
|
|
17
|
+
ProfileNotFound,
|
|
18
|
+
SsoRegion,
|
|
19
|
+
SsoStartUrl,
|
|
20
|
+
type CredentialsError,
|
|
21
|
+
type ResolvedCredentials,
|
|
22
|
+
} from "./credentials.ts";
|
|
23
|
+
import { parseIni, parseSSOSessionData } from "./util/parse-ini.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The time window (5 mins) that SDK will treat the SSO token expires in before the defined expiration date in token.
|
|
27
|
+
* This is needed because server side may have invalidated the token before the defined expiration date.
|
|
28
|
+
*/
|
|
29
|
+
const EXPIRE_WINDOW_MS = 5 * 60 * 1000;
|
|
30
|
+
|
|
31
|
+
const REFRESH_MESSAGE = `To refresh this SSO session run 'aws sso login' with the corresponding profile.`;
|
|
32
|
+
|
|
33
|
+
export class Auth extends ServiceMap.Service<
|
|
34
|
+
Auth,
|
|
35
|
+
{
|
|
36
|
+
loadProfile: (
|
|
37
|
+
profileName: string,
|
|
38
|
+
) => Effect.Effect<AwsProfileConfig, CredentialsError>;
|
|
39
|
+
loadProfileCredentials: (
|
|
40
|
+
profileName: string,
|
|
41
|
+
) => Effect.Effect<ResolvedCredentials, CredentialsError>;
|
|
42
|
+
}
|
|
43
|
+
>()("distilled-aws/AWS/Auth") {}
|
|
44
|
+
|
|
45
|
+
export const Default = Effect.serviceOption(Auth).pipe(
|
|
46
|
+
Effect.map(Option.getOrUndefined),
|
|
47
|
+
Effect.flatMap((c) => (c ? Effect.succeed(c) : makeAuthService())),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
export const loadProfile = (profile: string) =>
|
|
51
|
+
Effect.flatMap(Default, (auth) => auth.loadProfile(profile));
|
|
52
|
+
|
|
53
|
+
export const loadProfileCredentials = (profile: string) =>
|
|
54
|
+
Effect.flatMap(Default, (auth) => auth.loadProfileCredentials(profile));
|
|
55
|
+
|
|
56
|
+
export const makeAuthService = () =>
|
|
57
|
+
Effect.gen(function* () {
|
|
58
|
+
const fs = yield* FileSystem.FileSystem;
|
|
59
|
+
const path = yield* Path.Path;
|
|
60
|
+
const client = yield* HttpClient.HttpClient;
|
|
61
|
+
|
|
62
|
+
const loadProfile = Effect.fn(function* (profileName: string) {
|
|
63
|
+
const profiles: {
|
|
64
|
+
[profileName: string]: AwsProfileConfig;
|
|
65
|
+
} = yield* Effect.promise(() =>
|
|
66
|
+
ini.parseKnownFiles({ profile: profileName }),
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const profile = profiles[profileName];
|
|
70
|
+
|
|
71
|
+
if (!profile) {
|
|
72
|
+
return yield* new ProfileNotFound({
|
|
73
|
+
message: `Profile ${profileName} not found`,
|
|
74
|
+
profile: profileName,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const awsDir = path.join(ini.getHomeDir(), ".aws");
|
|
79
|
+
const configPath = path.join(awsDir, "config");
|
|
80
|
+
|
|
81
|
+
if (profile.sso_session) {
|
|
82
|
+
const ssoRegion = Option.getOrUndefined(
|
|
83
|
+
yield* Effect.serviceOption(SsoRegion),
|
|
84
|
+
);
|
|
85
|
+
const ssoStartUrl = Option.getOrElse(
|
|
86
|
+
yield* Effect.serviceOption(SsoStartUrl),
|
|
87
|
+
() => profile.sso_start_url,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const ssoSessions = yield* fs.readFileString(configPath).pipe(
|
|
91
|
+
Effect.flatMap((config) =>
|
|
92
|
+
Effect.promise(async () => parseIni(config)),
|
|
93
|
+
),
|
|
94
|
+
Effect.map(parseSSOSessionData),
|
|
95
|
+
);
|
|
96
|
+
const session = ssoSessions[profile.sso_session];
|
|
97
|
+
if (ssoRegion && ssoRegion !== session.sso_region) {
|
|
98
|
+
return yield* new ConflictingSSORegion({
|
|
99
|
+
message: `Conflicting SSO region`,
|
|
100
|
+
ssoRegion: ssoRegion,
|
|
101
|
+
profile: profile.sso_session,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
if (ssoStartUrl && ssoStartUrl !== session.sso_start_url) {
|
|
105
|
+
return yield* new ConflictingSSOStartUrl({
|
|
106
|
+
message: `Conflicting SSO start url`,
|
|
107
|
+
ssoStartUrl: ssoStartUrl,
|
|
108
|
+
profile: profile.sso_session,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
profile.sso_region = session.sso_region;
|
|
112
|
+
profile.sso_start_url = session.sso_start_url;
|
|
113
|
+
|
|
114
|
+
const ssoFields = [
|
|
115
|
+
"sso_start_url",
|
|
116
|
+
"sso_account_id",
|
|
117
|
+
"sso_region",
|
|
118
|
+
"sso_role_name",
|
|
119
|
+
] as const satisfies (keyof SsoProfileConfig)[];
|
|
120
|
+
const missingFields = ssoFields.filter((field) => !profile[field]);
|
|
121
|
+
if (missingFields.length > 0) {
|
|
122
|
+
yield* new InvalidSSOProfile({
|
|
123
|
+
profile: profileName,
|
|
124
|
+
missingFields,
|
|
125
|
+
message:
|
|
126
|
+
`Profile is configured with invalid SSO credentials. Required parameters "sso_account_id", ` +
|
|
127
|
+
`"sso_region", "sso_role_name", "sso_start_url". Got ${Object.keys(
|
|
128
|
+
profile,
|
|
129
|
+
).join(
|
|
130
|
+
", ",
|
|
131
|
+
)}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return profile;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return yield* new ProfileNotFound({
|
|
138
|
+
message: `Profile ${profileName} not found`,
|
|
139
|
+
profile: profileName,
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const loadProfileCredentials = Effect.fn(function* (profileName: string) {
|
|
144
|
+
const awsDir = path.join(ini.getHomeDir(), ".aws");
|
|
145
|
+
const cachePath = path.join(awsDir, "sso", "cache");
|
|
146
|
+
|
|
147
|
+
const profile = yield* loadProfile(profileName);
|
|
148
|
+
|
|
149
|
+
if (profile.sso_session) {
|
|
150
|
+
const hasher = createHash("sha1");
|
|
151
|
+
const cacheName = hasher.update(profile.sso_session).digest("hex");
|
|
152
|
+
const ssoTokenFilepath = path.join(cachePath, `${cacheName}.json`);
|
|
153
|
+
const cachedCredsFilePath = path.join(
|
|
154
|
+
cachePath,
|
|
155
|
+
`${cacheName}.credentials.json`,
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
const cachedCreds = yield* fs.readFileString(cachedCredsFilePath).pipe(
|
|
159
|
+
Effect.map((text) => JSON.parse(text)),
|
|
160
|
+
Effect.catch(() => Effect.void),
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const isExpired = (expiry: number | string | undefined) => {
|
|
164
|
+
return (
|
|
165
|
+
expiry === undefined ||
|
|
166
|
+
new Date(expiry).getTime() - Date.now() <= EXPIRE_WINDOW_MS
|
|
167
|
+
);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
if (cachedCreds && !isExpired(cachedCreds.expiry)) {
|
|
171
|
+
return {
|
|
172
|
+
accessKeyId: Redacted.make(cachedCreds.accessKeyId),
|
|
173
|
+
secretAccessKey: Redacted.make(cachedCreds.secretAccessKey),
|
|
174
|
+
sessionToken: cachedCreds.sessionToken
|
|
175
|
+
? Redacted.make(cachedCreds.sessionToken)
|
|
176
|
+
: undefined,
|
|
177
|
+
expiration: cachedCreds.expiry,
|
|
178
|
+
} satisfies ResolvedCredentials;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const ssoToken = yield* fs.readFileString(ssoTokenFilepath).pipe(
|
|
182
|
+
Effect.map((text) => JSON.parse(text) as SSOToken),
|
|
183
|
+
Effect.catch(() =>
|
|
184
|
+
new InvalidSSOToken({
|
|
185
|
+
message: `The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
|
|
186
|
+
sso_session: profile.sso_session!,
|
|
187
|
+
}).asEffect(),
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
if (isExpired(ssoToken.expiresAt)) {
|
|
192
|
+
yield* Console.log(
|
|
193
|
+
`The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
|
|
194
|
+
);
|
|
195
|
+
return yield* new ExpiredSSOToken({
|
|
196
|
+
message: `The SSO session token associated with profile=${profileName} was not found or is invalid. ${REFRESH_MESSAGE}`,
|
|
197
|
+
profile: profileName,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const response = yield* client.get(
|
|
202
|
+
`https://portal.sso.${profile.sso_region}.amazonaws.com/federation/credentials?account_id=${profile.sso_account_id}&role_name=${profile.sso_role_name}`,
|
|
203
|
+
{
|
|
204
|
+
headers: {
|
|
205
|
+
"User-Agent": "alchemy.run",
|
|
206
|
+
"Content-Type": "application/json",
|
|
207
|
+
"x-amz-sso_bearer_token": ssoToken.accessToken,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const credentials = (
|
|
213
|
+
(yield* response.json) as {
|
|
214
|
+
roleCredentials: {
|
|
215
|
+
accessKeyId: string;
|
|
216
|
+
secretAccessKey: string;
|
|
217
|
+
sessionToken: string;
|
|
218
|
+
expiration: number;
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
).roleCredentials;
|
|
222
|
+
|
|
223
|
+
yield* fs.writeFileString(
|
|
224
|
+
cachedCredsFilePath,
|
|
225
|
+
JSON.stringify({
|
|
226
|
+
accessKeyId: credentials.accessKeyId,
|
|
227
|
+
secretAccessKey: credentials.secretAccessKey,
|
|
228
|
+
sessionToken: credentials.sessionToken,
|
|
229
|
+
expiry: credentials.expiration,
|
|
230
|
+
}),
|
|
231
|
+
);
|
|
232
|
+
|
|
233
|
+
return {
|
|
234
|
+
accessKeyId: Redacted.make(credentials.accessKeyId),
|
|
235
|
+
secretAccessKey: Redacted.make(credentials.secretAccessKey),
|
|
236
|
+
sessionToken: Redacted.make(credentials.sessionToken),
|
|
237
|
+
expiration: credentials.expiration,
|
|
238
|
+
} satisfies ResolvedCredentials;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return yield* new ProfileNotFound({
|
|
242
|
+
message: `Profile ${profileName} not found`,
|
|
243
|
+
profile: profileName,
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
return Auth.of({
|
|
248
|
+
loadProfile,
|
|
249
|
+
loadProfileCredentials,
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
export interface AwsProfileConfig {
|
|
254
|
+
sso_session?: string;
|
|
255
|
+
sso_account_id?: string;
|
|
256
|
+
sso_role_name?: string;
|
|
257
|
+
region?: string;
|
|
258
|
+
output?: string;
|
|
259
|
+
sso_start_url?: string;
|
|
260
|
+
sso_region?: string;
|
|
261
|
+
}
|
|
262
|
+
export interface SsoProfileConfig extends AwsProfileConfig {
|
|
263
|
+
sso_start_url: string;
|
|
264
|
+
sso_region: string;
|
|
265
|
+
sso_account_id: string;
|
|
266
|
+
sso_role_name: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Cached SSO token retrieved from SSO login flow.
|
|
271
|
+
* @public
|
|
272
|
+
*/
|
|
273
|
+
export interface SSOToken {
|
|
274
|
+
/**
|
|
275
|
+
* A base64 encoded string returned by the sso-oidc service.
|
|
276
|
+
*/
|
|
277
|
+
accessToken: string;
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* The expiration time of the accessToken as an RFC 3339 formatted timestamp.
|
|
281
|
+
*/
|
|
282
|
+
expiresAt: string;
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The token used to obtain an access token in the event that the accessToken is invalid or expired.
|
|
286
|
+
*/
|
|
287
|
+
refreshToken?: string;
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The unique identifier string for each client. The client ID generated when performing the registration
|
|
291
|
+
* portion of the OIDC authorization flow. This is used to refresh the accessToken.
|
|
292
|
+
*/
|
|
293
|
+
clientId?: string;
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* A secret string generated when performing the registration portion of the OIDC authorization flow.
|
|
297
|
+
* This is used to refresh the accessToken.
|
|
298
|
+
*/
|
|
299
|
+
clientSecret?: string;
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* The expiration time of the client registration (clientId and clientSecret) as an RFC 3339 formatted timestamp.
|
|
303
|
+
*/
|
|
304
|
+
registrationExpiresAt?: string;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* The configured sso_region for the profile that credentials are being resolved for.
|
|
308
|
+
*/
|
|
309
|
+
region?: string;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The configured sso_start_url for the profile that credentials are being resolved for.
|
|
313
|
+
*/
|
|
314
|
+
startUrl?: string;
|
|
315
|
+
}
|
package/src/client/api.ts
CHANGED
|
@@ -96,7 +96,7 @@ export const make = <Op extends Operation<any, any, any>>(
|
|
|
96
96
|
yield* Effect.logDebug("Built Request", request);
|
|
97
97
|
|
|
98
98
|
// Sign the request
|
|
99
|
-
const credentials = yield* Credentials.Credentials;
|
|
99
|
+
const credentials = yield* yield* Credentials.Credentials;
|
|
100
100
|
const region = yield* Region.Region;
|
|
101
101
|
const serviceName = sigv4?.name ?? "s3";
|
|
102
102
|
|