Skip to content

Commit a2ba7a3

Browse files
authored
chore(shared-ini-file-loader): add utility getSSOTokenFromFile (#3421)
1 parent debcfed commit a2ba7a3

File tree

5 files changed

+152
-58
lines changed

5 files changed

+152
-58
lines changed

packages/credential-provider-sso/src/resolveSSOCredentials.spec.ts

Lines changed: 17 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,13 @@
11
import { GetRoleCredentialsCommand, SSOClient } from "@aws-sdk/client-sso";
22
import { CredentialsProviderError } from "@aws-sdk/property-provider";
3-
import { getHomeDir } from "@aws-sdk/shared-ini-file-loader";
4-
import { createHash } from "crypto";
5-
import { promises } from "fs";
6-
import { join } from "path";
3+
import { getSSOTokenFromFile } from "@aws-sdk/shared-ini-file-loader";
74

85
import { resolveSSOCredentials } from "./resolveSSOCredentials";
96

10-
jest.mock("crypto");
117
jest.mock("@aws-sdk/shared-ini-file-loader");
12-
jest.mock("fs", () => ({ promises: { readFile: jest.fn() } }));
138
jest.mock("@aws-sdk/client-sso");
149

1510
describe(resolveSSOCredentials.name, () => {
16-
const mockCacheName = "mockCacheName";
17-
const mockDigest = jest.fn().mockReturnValue(mockCacheName);
18-
const mockUpdate = jest.fn().mockReturnValue({ digest: mockDigest });
19-
const mockHomeDir = "/home/dir";
20-
2111
const mockToken = {
2212
accessToken: "mockAccessToken",
2313
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
@@ -44,45 +34,27 @@ describe(resolveSSOCredentials.name, () => {
4434
};
4535

4636
beforeEach(() => {
47-
(createHash as jest.Mock).mockReturnValue({ update: mockUpdate });
48-
(getHomeDir as jest.Mock).mockReturnValue(mockHomeDir);
49-
(promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockToken));
37+
(getSSOTokenFromFile as jest.Mock).mockResolvedValue(mockToken);
5038
mockSsoSend.mockResolvedValue({ roleCredentials: mockCreds });
5139
});
5240

5341
afterEach(() => {
54-
expect(createHash).toHaveBeenCalledWith("sha1");
55-
expect(mockUpdate).toHaveBeenCalledWith(mockOptions.ssoStartUrl);
56-
expect(mockDigest).toHaveBeenCalledWith("hex");
57-
expect(promises.readFile).toHaveBeenCalledWith(
58-
join(mockHomeDir, ".aws", "sso", "cache", `${mockCacheName}.json`),
59-
"utf8"
60-
);
6142
jest.clearAllMocks();
6243
});
6344

64-
describe("throws error for invalid token", () => {
65-
afterEach(async () => {
66-
const expectedError = new CredentialsProviderError(
67-
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
68-
SHOULD_FAIL_CREDENTIAL_CHAIN
69-
);
70-
71-
try {
72-
await resolveSSOCredentials(mockOptions);
73-
fail(`expected ${expectedError}`);
74-
} catch (error) {
75-
expect(error).toStrictEqual(expectedError);
76-
}
77-
});
78-
79-
it("throws error if readFile fails", async () => {
80-
(promises.readFile as jest.Mock).mockRejectedValue(new Error("error"));
81-
});
82-
83-
it("throws error if token is not a valid JSON", async () => {
84-
(promises.readFile as jest.Mock).mockReturnValue("invalid JSON");
85-
});
45+
it("throws error if getSSOTokenFromFile fails", async () => {
46+
const expectedError = new CredentialsProviderError(
47+
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
48+
SHOULD_FAIL_CREDENTIAL_CHAIN
49+
);
50+
(getSSOTokenFromFile as jest.Mock).mockRejectedValue(new Error("error"));
51+
52+
try {
53+
await resolveSSOCredentials(mockOptions);
54+
fail(`expected ${expectedError}`);
55+
} catch (error) {
56+
expect(error).toStrictEqual(expectedError);
57+
}
8658
});
8759

8860
describe("throws error on expiration", () => {
@@ -102,12 +74,12 @@ describe(resolveSSOCredentials.name, () => {
10274

10375
it("throws error if SSO session has expired", async () => {
10476
const mockExpiredToken = { ...mockToken, expiresAt: new Date(Date.now() - 60 * 1000).toISOString() };
105-
(promises.readFile as jest.Mock).mockReturnValue(JSON.stringify(mockExpiredToken));
77+
(getSSOTokenFromFile as jest.Mock).mockResolvedValue(mockExpiredToken);
10678
});
10779

10880
it("throws error if SSO session expires in <15 mins", async () => {
10981
const mockExpiredToken = { ...mockToken, expiresAt: new Date(Date.now() + 899 * 1000).toISOString() };
110-
(promises.readFile as jest.Mock).mockReturnValue(JSON.stringify(mockExpiredToken));
82+
(getSSOTokenFromFile as jest.Mock).mockResolvedValue(mockExpiredToken);
11183
});
11284
});
11385

packages/credential-provider-sso/src/resolveSSOCredentials.ts

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,9 @@
11
import { GetRoleCredentialsCommand, GetRoleCredentialsCommandOutput, SSOClient } from "@aws-sdk/client-sso";
22
import { CredentialsProviderError } from "@aws-sdk/property-provider";
3-
import { getHomeDir } from "@aws-sdk/shared-ini-file-loader";
3+
import { getSSOTokenFromFile, SSOToken } from "@aws-sdk/shared-ini-file-loader";
44
import { Credentials } from "@aws-sdk/types";
5-
import { createHash } from "crypto";
6-
// ToDo: Change to "fs/promises" when supporting nodejs>=14
7-
import { promises as fsPromises } from "fs";
8-
import { join } from "path";
95

106
import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
11-
import { SSOToken } from "./types";
127

138
/**
149
* The time window (15 mins) that SDK will treat the SSO token expires in before the defined expiration date in token.
@@ -20,23 +15,17 @@ const EXPIRE_WINDOW_MS = 15 * 60 * 1000;
2015

2116
const SHOULD_FAIL_CREDENTIAL_CHAIN = false;
2217

23-
const { readFile } = fsPromises;
24-
2518
export const resolveSSOCredentials = async ({
2619
ssoStartUrl,
2720
ssoAccountId,
2821
ssoRegion,
2922
ssoRoleName,
3023
ssoClient,
3124
}: FromSSOInit & SsoCredentialsParameters): Promise<Credentials> => {
32-
const hasher = createHash("sha1");
33-
const cacheName = hasher.update(ssoStartUrl).digest("hex");
34-
const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
35-
3625
let token: SSOToken;
3726
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
3827
try {
39-
token = JSON.parse(await readFile(tokenFile, "utf8"));
28+
token = await getSSOTokenFromFile(ssoStartUrl);
4029
} catch (e) {
4130
throw new CredentialsProviderError(
4231
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { createHash } from "crypto";
2+
// ToDo: Change to "fs/promises" when supporting nodejs>=14
3+
import { promises } from "fs";
4+
import { join } from "path";
5+
6+
import { getHomeDir } from "./getHomeDir";
7+
import { getSSOTokenFromFile } from "./getSSOTokenFromFile";
8+
9+
jest.mock("crypto");
10+
jest.mock("fs", () => ({ promises: { readFile: jest.fn() } }));
11+
jest.mock("./getHomeDir");
12+
13+
describe(getSSOTokenFromFile.name, () => {
14+
const mockCacheName = "mockCacheName";
15+
const mockDigest = jest.fn().mockReturnValue(mockCacheName);
16+
const mockUpdate = jest.fn().mockReturnValue({ digest: mockDigest });
17+
const mockHomeDir = "/home/dir";
18+
const mockSsoStartUrl = "mock_sso_start_url";
19+
20+
const mockToken = {
21+
accessToken: "mockAccessToken",
22+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
23+
};
24+
25+
beforeEach(() => {
26+
(createHash as jest.Mock).mockReturnValue({ update: mockUpdate });
27+
(getHomeDir as jest.Mock).mockReturnValue(mockHomeDir);
28+
(promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockToken));
29+
});
30+
31+
afterEach(() => {
32+
expect(createHash).toHaveBeenCalledWith("sha1");
33+
expect(promises.readFile).toHaveBeenCalledWith(
34+
join(mockHomeDir, ".aws", "sso", "cache", `${mockCacheName}.json`),
35+
"utf8"
36+
);
37+
jest.clearAllMocks();
38+
});
39+
40+
it("re-throws if readFile fails", async () => {
41+
const expectedError = new Error("error");
42+
(promises.readFile as jest.Mock).mockRejectedValue(expectedError);
43+
44+
try {
45+
await getSSOTokenFromFile(mockSsoStartUrl);
46+
fail(`expected ${expectedError}`);
47+
} catch (error) {
48+
expect(error).toStrictEqual(expectedError);
49+
}
50+
});
51+
52+
it("re-throws if token is not a valid JSON", async () => {
53+
const errMsg = "Unexpected token";
54+
(promises.readFile as jest.Mock).mockReturnValue("invalid JSON");
55+
56+
try {
57+
await getSSOTokenFromFile(mockSsoStartUrl);
58+
fail(`expected '${errMsg}'`);
59+
} catch (error) {
60+
expect(error.message).toContain(errMsg);
61+
}
62+
});
63+
64+
it("returns token when it's valid", async () => {
65+
const token = await getSSOTokenFromFile(mockSsoStartUrl);
66+
expect(token).toStrictEqual(mockToken);
67+
});
68+
});
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import { createHash } from "crypto";
2+
// ToDo: Change to "fs/promises" when supporting nodejs>=14
3+
import { promises as fsPromises } from "fs";
4+
import { join } from "path";
5+
6+
import { getHomeDir } from "./getHomeDir";
7+
8+
/**
9+
* Cached SSO token retrieved from SSO login flow.
10+
*/
11+
export interface SSOToken {
12+
/**
13+
* A base64 encoded string returned by the sso-oidc service.
14+
*/
15+
accessToken: string;
16+
17+
/**
18+
* The expiration time of the accessToken as an RFC 3339 formatted timestamp.
19+
*/
20+
expiresAt: string;
21+
22+
/**
23+
* The token used to obtain an access token in the event that the accessToken is invalid or expired.
24+
*/
25+
refreshToken?: string;
26+
27+
/**
28+
* The unique identifier string for each client. The client ID generated when performing the registration
29+
* portion of the OIDC authorization flow. This is used to refresh the accessToken.
30+
*/
31+
clientId?: string;
32+
33+
/**
34+
* A secret string generated when performing the registration portion of the OIDC authorization flow.
35+
* This is used to refresh the accessToken.
36+
*/
37+
clientSecret?: string;
38+
39+
/**
40+
* The expiration time of the client registration (clientId and clientSecret) as an RFC 3339 formatted timestamp.
41+
*/
42+
registrationExpiresAt?: string;
43+
44+
/**
45+
* The configured sso_region for the profile that credentials are being resolved for.
46+
*/
47+
region?: string;
48+
49+
/**
50+
* The configured sso_start_url for the profile that credentials are being resolved for.
51+
*/
52+
startUrl?: string;
53+
}
54+
55+
const { readFile } = fsPromises;
56+
57+
export const getSSOTokenFromFile = async (ssoStartUrl: string) => {
58+
const hasher = createHash("sha1");
59+
const cacheName = hasher.update(ssoStartUrl).digest("hex");
60+
const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
61+
62+
const tokenText = await readFile(tokenFile, "utf8");
63+
return JSON.parse(tokenText) as SSOToken;
64+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export * from "./getHomeDir";
22
export * from "./getProfileName";
3+
export * from "./getSSOTokenFromFile";
34
export * from "./loadSharedConfigFiles";
45
export * from "./parseKnownFiles";
56
export * from "./types";

0 commit comments

Comments
 (0)