Skip to content

Commit 2f17b06

Browse files
committed
test: resolveSSOCredentials.spec.ts
1 parent aa1ca18 commit 2f17b06

File tree

2 files changed

+198
-7
lines changed

2 files changed

+198
-7
lines changed
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
import { GetRoleCredentialsCommand, SSOClient } from "@aws-sdk/client-sso";
2+
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";
7+
8+
import { resolveSSOCredentials } from "./resolveSSOCredentials";
9+
10+
jest.mock("crypto");
11+
jest.mock("@aws-sdk/shared-ini-file-loader");
12+
jest.mock("fs", () => ({ promises: { readFile: jest.fn() } }));
13+
jest.mock("@aws-sdk/client-sso");
14+
15+
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+
21+
const mockToken = {
22+
accessToken: "mockAccessToken",
23+
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
24+
};
25+
26+
const SHOULD_FAIL_CREDENTIAL_CHAIN = false;
27+
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
28+
29+
const mockSsoSend = jest.fn();
30+
const mockSsoClient = { send: mockSsoSend };
31+
const mockOptions = {
32+
ssoStartUrl: "mock_sso_start_url",
33+
ssoAccountId: "mock_sso_account_id",
34+
ssoRegion: "mock_sso_region",
35+
ssoRoleName: "mock_sso_role_name",
36+
ssoClient: mockSsoClient as unknown as SSOClient,
37+
};
38+
39+
const mockCreds = {
40+
accessKeyId: "mockAccessKeyId",
41+
secretAccessKey: "mockSecretAccessKey",
42+
sessionToken: "mockSessionToken",
43+
expiration: Date.now(),
44+
};
45+
46+
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));
50+
mockSsoSend.mockResolvedValue({ roleCredentials: mockCreds });
51+
});
52+
53+
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+
);
61+
jest.clearAllMocks();
62+
});
63+
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+
});
86+
});
87+
88+
describe("throws error on expiration", () => {
89+
afterEach(async () => {
90+
const expectedError = new CredentialsProviderError(
91+
`The SSO session associated with this profile has expired. ${refreshMessage}`,
92+
SHOULD_FAIL_CREDENTIAL_CHAIN
93+
);
94+
95+
try {
96+
await resolveSSOCredentials(mockOptions);
97+
fail(`expected ${expectedError}`);
98+
} catch (error) {
99+
expect(error).toStrictEqual(expectedError);
100+
}
101+
});
102+
103+
it("throws error if SSO session has expired", async () => {
104+
const mockExpiredToken = { ...mockToken, expiresAt: new Date(Date.now() - 60 * 1000).toISOString() };
105+
(promises.readFile as jest.Mock).mockReturnValue(JSON.stringify(mockExpiredToken));
106+
});
107+
108+
it("throws error if SSO session expires in <15 mins", async () => {
109+
const mockExpiredToken = { ...mockToken, expiresAt: new Date(Date.now() + 899 * 1000).toISOString() };
110+
(promises.readFile as jest.Mock).mockReturnValue(JSON.stringify(mockExpiredToken));
111+
});
112+
});
113+
114+
describe("throws error on sso.getRoleCredentials call", () => {
115+
afterEach(() => {
116+
expect(mockSsoSend).toHaveBeenCalledTimes(1);
117+
expect(GetRoleCredentialsCommand).toHaveBeenCalledWith({
118+
accountId: mockOptions.ssoAccountId,
119+
roleName: mockOptions.ssoRoleName,
120+
accessToken: mockToken.accessToken,
121+
});
122+
});
123+
124+
it("if call fails", async () => {
125+
const expectedError = new Error("error from GetRoleCredentialsCommand");
126+
mockSsoSend.mockRejectedValue(expectedError);
127+
128+
try {
129+
await resolveSSOCredentials(mockOptions);
130+
fail(`expected ${expectedError}`);
131+
} catch (error) {
132+
expect(error).toStrictEqual(CredentialsProviderError.from(expectedError, SHOULD_FAIL_CREDENTIAL_CHAIN));
133+
}
134+
});
135+
136+
it.each(["accessKeyId", "secretAccessKey", "sessionToken", "expiration"])(
137+
"returns creds missing '%s'",
138+
async (key) => {
139+
mockSsoSend.mockResolvedValue({ roleCredentials: { ...mockCreds, [key]: undefined } });
140+
141+
const expectedError = new CredentialsProviderError(
142+
"SSO returns an invalid temporary credential.",
143+
SHOULD_FAIL_CREDENTIAL_CHAIN
144+
);
145+
try {
146+
await resolveSSOCredentials(mockOptions);
147+
fail(`expected ${expectedError}`);
148+
} catch (error) {
149+
expect(error).toStrictEqual(expectedError);
150+
}
151+
}
152+
);
153+
});
154+
155+
describe("returns valid credentials", () => {
156+
afterEach(() => {
157+
expect(GetRoleCredentialsCommand).toHaveBeenCalledWith({
158+
accountId: mockOptions.ssoAccountId,
159+
roleName: mockOptions.ssoRoleName,
160+
accessToken: mockToken.accessToken,
161+
});
162+
});
163+
164+
it("returns valid credentials from sso.getRoleCredentials", async () => {
165+
const receivedCreds = await resolveSSOCredentials(mockOptions);
166+
expect(receivedCreds).toStrictEqual(receivedCreds);
167+
expect(mockSsoSend).toHaveBeenCalledTimes(1);
168+
});
169+
170+
it("creates SSO client with provided region, if client is not passed", async () => {
171+
const mockCustomSsoSend = jest.fn().mockResolvedValue({ roleCredentials: mockCreds });
172+
(SSOClient as jest.Mock).mockReturnValue({ send: mockCustomSsoSend });
173+
174+
const receivedCreds = await resolveSSOCredentials({ ...mockOptions, ssoClient: undefined });
175+
expect(receivedCreds).toStrictEqual(receivedCreds);
176+
177+
expect(mockCustomSsoSend).toHaveBeenCalledTimes(1);
178+
});
179+
});
180+
});

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { CredentialsProviderError } from "@aws-sdk/property-provider";
33
import { getHomeDir } from "@aws-sdk/shared-ini-file-loader";
44
import { Credentials } from "@aws-sdk/types";
55
import { createHash } from "crypto";
6-
import { readFileSync } from "fs";
6+
// ToDo: Change to "fs/promises" when supporting nodejs>=14
7+
import { promises as fsPromises } from "fs";
78
import { join } from "path";
89

910
import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
@@ -19,6 +20,8 @@ const EXPIRE_WINDOW_MS = 15 * 60 * 1000;
1920

2021
const SHOULD_FAIL_CREDENTIAL_CHAIN = false;
2122

23+
const { readFile } = fsPromises;
24+
2225
export const resolveSSOCredentials = async ({
2326
ssoStartUrl,
2427
ssoAccountId,
@@ -29,19 +32,25 @@ export const resolveSSOCredentials = async ({
2932
const hasher = createHash("sha1");
3033
const cacheName = hasher.update(ssoStartUrl).digest("hex");
3134
const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);
35+
3236
let token: SSOToken;
37+
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
3338
try {
34-
token = JSON.parse(readFileSync(tokenFile, { encoding: "utf-8" }));
35-
if (new Date(token.expiresAt).getTime() - Date.now() <= EXPIRE_WINDOW_MS) {
36-
throw new Error("SSO token is expired.");
37-
}
39+
token = JSON.parse(await readFile(tokenFile, "utf8"));
3840
} catch (e) {
3941
throw new CredentialsProviderError(
40-
`The SSO session associated with this profile has expired or is otherwise invalid. To refresh this SSO session ` +
41-
`run aws sso login with the corresponding profile.`,
42+
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
43+
SHOULD_FAIL_CREDENTIAL_CHAIN
44+
);
45+
}
46+
47+
if (new Date(token.expiresAt).getTime() - Date.now() <= EXPIRE_WINDOW_MS) {
48+
throw new CredentialsProviderError(
49+
`The SSO session associated with this profile has expired. ${refreshMessage}`,
4250
SHOULD_FAIL_CREDENTIAL_CHAIN
4351
);
4452
}
53+
4554
const { accessToken } = token;
4655
const sso = ssoClient || new SSOClient({ region: ssoRegion });
4756
let ssoResp: GetRoleCredentialsCommandOutput;
@@ -56,9 +65,11 @@ export const resolveSSOCredentials = async ({
5665
} catch (e) {
5766
throw CredentialsProviderError.from(e, SHOULD_FAIL_CREDENTIAL_CHAIN);
5867
}
68+
5969
const { roleCredentials: { accessKeyId, secretAccessKey, sessionToken, expiration } = {} } = ssoResp;
6070
if (!accessKeyId || !secretAccessKey || !sessionToken || !expiration) {
6171
throw new CredentialsProviderError("SSO returns an invalid temporary credential.", SHOULD_FAIL_CREDENTIAL_CHAIN);
6272
}
73+
6374
return { accessKeyId, secretAccessKey, sessionToken, expiration: new Date(expiration) };
6475
};

0 commit comments

Comments
 (0)