Skip to content

Commit c850b15

Browse files
committed
feat(sso): use SSOTokenProvider in SSOCredentialProvider implementation
1 parent 421e62f commit c850b15

File tree

11 files changed

+148
-44
lines changed

11 files changed

+148
-44
lines changed

packages/credential-provider-ini/src/resolveSsoCredentials.spec.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,14 @@ describe(resolveSsoCredentials.name, () => {
2424
sso_role_name: "mock_sso_role_name",
2525
});
2626

27-
const getMockValidatedSsoProfile = () => ({
28-
sso_start_url: "mock_validated_sso_start_url",
29-
sso_account_id: "mock_validated_sso_account_id",
30-
sso_region: "mock_validated_sso_region",
31-
sso_role_name: "mock_validated_sso_role_name",
32-
});
27+
const getMockValidatedSsoProfile = <T>(add: T = {} as T) =>
28+
({
29+
sso_start_url: "mock_validated_sso_start_url",
30+
sso_account_id: "mock_validated_sso_account_id",
31+
sso_region: "mock_validated_sso_region",
32+
sso_role_name: "mock_validated_sso_role_name",
33+
...add,
34+
});
3335

3436
afterEach(() => {
3537
jest.clearAllMocks();
@@ -95,4 +97,28 @@ describe(resolveSsoCredentials.name, () => {
9597
ssoRoleName: mockValidatedProfile.sso_role_name,
9698
});
9799
});
100+
101+
it("calls fromSSO with optional sso session name", async () => {
102+
const mockProfile = getMockOriginalSsoProfile();
103+
const mockValidatedProfile = getMockValidatedSsoProfile({
104+
sso_session: "test-session",
105+
});
106+
107+
const mockCreds: Credentials = {
108+
accessKeyId: "mockAccessKeyId",
109+
secretAccessKey: "mockSecretAccessKey",
110+
};
111+
112+
(validateSsoProfile as jest.Mock).mockReturnValue(mockValidatedProfile);
113+
(fromSSO as jest.Mock).mockReturnValue(() => Promise.resolve(mockCreds));
114+
115+
await resolveSsoCredentials(mockProfile);
116+
expect(fromSSO).toHaveBeenCalledWith({
117+
ssoStartUrl: mockValidatedProfile.sso_start_url,
118+
ssoAccountId: mockValidatedProfile.sso_account_id,
119+
ssoRegion: mockValidatedProfile.sso_region,
120+
ssoRoleName: mockValidatedProfile.sso_role_name,
121+
ssoSession: mockValidatedProfile.sso_session,
122+
});
123+
});
98124
});

packages/credential-provider-sso/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
"@aws-sdk/client-sso": "*",
2828
"@aws-sdk/property-provider": "*",
2929
"@aws-sdk/shared-ini-file-loader": "*",
30+
"@aws-sdk/token-providers": "*",
3031
"@aws-sdk/types": "*",
3132
"tslib": "^2.3.1"
3233
},

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ describe(fromSSO.name, () => {
2727
secretAccessKey: "mockSecretAccessKey",
2828
};
2929

30+
const mockProfileName = "mockProfileName";
31+
3032
beforeEach(() => {
3133
(resolveSSOCredentials as jest.Mock).mockResolvedValue(mockCreds);
3234
});
@@ -36,7 +38,6 @@ describe(fromSSO.name, () => {
3638
});
3739

3840
describe("all sso* values are not set", () => {
39-
const mockProfileName = "mockProfileName";
4041
const mockInit = { profile: mockProfileName };
4142
const mockProfiles = { [mockProfileName]: mockSsoProfile };
4243

@@ -97,6 +98,8 @@ describe(fromSSO.name, () => {
9798
ssoAccountId: mockValidatedSsoProfile.sso_account_id,
9899
ssoRegion: mockValidatedSsoProfile.sso_region,
99100
ssoRoleName: mockValidatedSsoProfile.sso_role_name,
101+
profile: mockProfileName,
102+
ssoSession: undefined,
100103
});
101104
});
102105
});
@@ -117,7 +120,12 @@ describe(fromSSO.name, () => {
117120
});
118121

119122
it("calls resolveSSOCredentials if all sso* values are set", async () => {
120-
const mockOptions = { ...mockSsoProfile, ssoClient: mockSsoClient };
123+
const mockOptions = {
124+
...mockSsoProfile,
125+
ssoClient: mockSsoClient,
126+
profile: mockProfileName,
127+
ssoSession: "sso-session-name",
128+
};
121129
const receivedCreds = await fromSSO(mockOptions)();
122130
expect(receivedCreds).toStrictEqual(mockCreds);
123131
expect(resolveSSOCredentials).toHaveBeenCalledWith(mockOptions);

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

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { SSOClient } from "@aws-sdk/client-sso";
22
import { CredentialsProviderError } from "@aws-sdk/property-provider";
3-
import { getProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/shared-ini-file-loader";
3+
import {
4+
getProfileName,
5+
loadSsoSessionData,
6+
parseKnownFiles,
7+
SourceProfileInit,
8+
} from "@aws-sdk/shared-ini-file-loader";
49
import { CredentialProvider } from "@aws-sdk/types";
510

611
import { isSsoProfile } from "./isSsoProfile";
@@ -72,15 +77,26 @@ export const fromSSO =
7277
(init: FromSSOInit & Partial<SsoCredentialsParameters> = {}): CredentialProvider =>
7378
async () => {
7479
const { ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient, ssoSession } = init;
80+
const profileName = getProfileName(init);
81+
7582
if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName && !ssoSession) {
7683
// Load the SSO config from shared AWS config file.
7784
const profiles = await parseKnownFiles(init);
78-
const profileName = getProfileName(init);
7985
const profile = profiles[profileName];
8086

81-
// TODO(sso): merge [sso-session X] data into the profile if sso_session exists in it.
82-
// TODO(sso): if the sso profile and the sso-session both have region and start URL,
83-
// TODO(sso): they must match or an error shall be thrown.
87+
if (profile.sso_session) {
88+
const ssoSessions = await loadSsoSessionData(init);
89+
const session = ssoSessions[profile.sso_session];
90+
const conflictMsg = ` configurations in profile ${profileName} and sso-session ${profile.sso_session}`;
91+
if (ssoRegion && ssoRegion !== session.sso_region) {
92+
throw new Error(`Conflicting SSO region` + conflictMsg);
93+
}
94+
if (ssoStartUrl && ssoStartUrl !== session.sso_start_url) {
95+
throw new Error(`Conflicting SSO start url` + conflictMsg);
96+
}
97+
profile.sso_region = session.sso_region;
98+
profile.sso_start_url = session.sso_start_url;
99+
}
84100

85101
if (!isSsoProfile(profile)) {
86102
throw new CredentialsProviderError(`Profile ${profileName} is not configured with SSO credentials.`);
@@ -94,13 +110,22 @@ export const fromSSO =
94110
ssoRegion: sso_region,
95111
ssoRoleName: sso_role_name,
96112
ssoClient: ssoClient,
113+
profile: profileName,
97114
});
98-
} else if (!ssoStartUrl || !ssoAccountId || !ssoRegion || !ssoRoleName || !ssoSession) {
115+
} else if (!ssoStartUrl || !ssoAccountId || !ssoRegion || !ssoRoleName) {
99116
throw new CredentialsProviderError(
100-
'Incomplete configuration. The fromSSO() argument hash must include "ssoAccountId",' +
101-
' "ssoRegion", "ssoRoleName", and one of "ssoStartUrl" or "ssoSession".'
117+
"Incomplete configuration. The fromSSO() argument hash must include " +
118+
'"ssoStartUrl", "ssoAccountId", "ssoRegion", "ssoRoleName"'
102119
);
103120
} else {
104-
return resolveSSOCredentials({ ssoStartUrl, ssoSession, ssoAccountId, ssoRegion, ssoRoleName, ssoClient });
121+
return resolveSSOCredentials({
122+
ssoStartUrl,
123+
ssoSession,
124+
ssoAccountId,
125+
ssoRegion,
126+
ssoRoleName,
127+
ssoClient,
128+
profile: profileName,
129+
});
105130
}
106131
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ describe(isSsoProfile.name, () => {
55
expect(isSsoProfile({})).toEqual(false);
66
});
77

8-
it.each(["sso_start_url", "sso_account_id", "sso_region", "sso_role_name"])(
8+
it.each(["sso_start_url", "sso_account_id", "sso_region", "sso_session", "sso_role_name"])(
99
"returns true if value at '%s' is of type string",
1010
(key) => {
1111
expect(isSsoProfile({ [key]: "string" })).toEqual(true);

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

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
import { GetRoleCredentialsCommand, SSOClient } from "@aws-sdk/client-sso";
22
import { CredentialsProviderError } from "@aws-sdk/property-provider";
33
import { getSSOTokenFromFile } from "@aws-sdk/shared-ini-file-loader";
4+
import * as tokenProviders from "@aws-sdk/token-providers";
45

56
import { resolveSSOCredentials } from "./resolveSSOCredentials";
67

78
jest.mock("@aws-sdk/shared-ini-file-loader");
89
jest.mock("@aws-sdk/client-sso");
10+
jest.mock("@aws-sdk/token-providers", () => {
11+
return {
12+
fromSso: jest.fn(() => async () => {
13+
return {
14+
token: "mockAccessToken",
15+
expiration: new Date(Date.now() + 6_000_000),
16+
};
17+
}),
18+
};
19+
});
920

1021
describe(resolveSSOCredentials.name, () => {
1122
const mockToken = {
@@ -57,6 +68,16 @@ describe(resolveSSOCredentials.name, () => {
5768
}
5869
});
5970

71+
it("uses the SSOTokenProvider if SSO Session name is present", async () => {
72+
await resolveSSOCredentials({
73+
...mockOptions,
74+
ssoSession: "test-sso-session",
75+
});
76+
expect(tokenProviders.fromSso).toHaveBeenCalledWith({
77+
profile: undefined,
78+
});
79+
});
80+
6081
describe("throws error on expiration", () => {
6182
afterEach(async () => {
6283
const expectedError = new CredentialsProviderError(
@@ -134,18 +155,15 @@ describe(resolveSSOCredentials.name, () => {
134155
});
135156

136157
it("returns valid credentials from sso.getRoleCredentials", async () => {
137-
const receivedCreds = await resolveSSOCredentials(mockOptions);
138-
expect(receivedCreds).toStrictEqual(receivedCreds);
158+
await resolveSSOCredentials(mockOptions);
139159
expect(mockSsoSend).toHaveBeenCalledTimes(1);
140160
});
141161

142162
it("creates SSO client with provided region, if client is not passed", async () => {
143163
const mockCustomSsoSend = jest.fn().mockResolvedValue({ roleCredentials: mockCreds });
144164
(SSOClient as jest.Mock).mockReturnValue({ send: mockCustomSsoSend });
145165

146-
const receivedCreds = await resolveSSOCredentials({ ...mockOptions, ssoClient: undefined });
147-
expect(receivedCreds).toStrictEqual(receivedCreds);
148-
166+
await resolveSSOCredentials({ ...mockOptions, ssoClient: undefined });
149167
expect(mockCustomSsoSend).toHaveBeenCalledTimes(1);
150168
});
151169
});

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

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { GetRoleCredentialsCommand, GetRoleCredentialsCommandOutput, SSOClient } from "@aws-sdk/client-sso";
22
import { CredentialsProviderError } from "@aws-sdk/property-provider";
33
import { getSSOTokenFromFile, SSOToken } from "@aws-sdk/shared-ini-file-loader";
4+
import { fromSso as getSsoTokenProvider } from "@aws-sdk/token-providers";
45
import { Credentials } from "@aws-sdk/types";
56

67
import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
@@ -15,27 +16,43 @@ const EXPIRE_WINDOW_MS = 15 * 60 * 1000;
1516

1617
const SHOULD_FAIL_CREDENTIAL_CHAIN = false;
1718

19+
/**
20+
* @private
21+
*/
1822
export const resolveSSOCredentials = async ({
1923
ssoStartUrl,
2024
ssoSession,
2125
ssoAccountId,
2226
ssoRegion,
2327
ssoRoleName,
2428
ssoClient,
29+
profile,
2530
}: FromSSOInit & SsoCredentialsParameters): Promise<Credentials> => {
2631
let token: SSOToken;
2732
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
28-
try {
29-
// TODO(sso): if (ssoSession)
30-
// TODO(sso): { use SSOTokenProvider }
3133

32-
// TODO(sso): else
33-
token = await getSSOTokenFromFile(ssoStartUrl);
34-
} catch (e) {
35-
throw new CredentialsProviderError(
36-
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
37-
SHOULD_FAIL_CREDENTIAL_CHAIN
38-
);
34+
if (ssoSession) {
35+
try {
36+
const _token = await getSsoTokenProvider({ profile })();
37+
token = {
38+
accessToken: _token.token,
39+
expiresAt: new Date(_token.expiration!).toISOString(),
40+
};
41+
} catch (e) {
42+
throw new CredentialsProviderError(
43+
`The SSO session ${ssoSession} for this profile is invalid. ${refreshMessage}\n` + String(e),
44+
SHOULD_FAIL_CREDENTIAL_CHAIN
45+
);
46+
}
47+
} else {
48+
try {
49+
token = await getSSOTokenFromFile(ssoStartUrl);
50+
} catch (e) {
51+
throw new CredentialsProviderError(
52+
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
53+
SHOULD_FAIL_CREDENTIAL_CHAIN
54+
);
55+
}
3956
}
4057

4158
if (new Date(token.expiresAt).getTime() - Date.now() <= EXPIRE_WINDOW_MS) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export interface SSOToken {
1616
* @internal
1717
*/
1818
export interface SsoProfile extends Profile {
19-
sso_start_url?: string;
19+
sso_start_url: string;
2020
sso_session?: string;
2121
sso_account_id: string;
2222
sso_region: string;

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

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ describe(validateSsoProfile.name, () => {
1717
});
1818

1919
it.each(["sso_start_url", "sso_account_id", "sso_region", "sso_role_name"])(
20-
"throws is '%s' is missing from profile",
20+
"throws if '%s' is missing from profile",
2121
(key) => {
2222
const profileToVerify = getMockSsoProfile();
2323
delete profileToVerify[key];
@@ -26,13 +26,22 @@ describe(validateSsoProfile.name, () => {
2626
validateSsoProfile(profileToVerify);
2727
}).toThrowError(
2828
new CredentialsProviderError(
29-
`Profile is configured with invalid SSO credentials. Required parameters "sso_account_id", "sso_region", ` +
30-
`"sso_role_name", "sso_start_url". Got ${Object.keys(profileToVerify).join(
29+
`Profile is configured with invalid SSO credentials. Required parameters ` +
30+
`"sso_account_id", "sso_region", "sso_role_name", "sso_start_url". Got ${Object.keys(profileToVerify).join(
3131
", "
3232
)}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`,
3333
false
3434
)
3535
);
3636
}
3737
);
38+
39+
it.each(["sso_session"])("does not throw if '%s' is missing from profile", (key) => {
40+
const profileToVerify = getMockSsoProfile();
41+
delete profileToVerify[key];
42+
43+
expect(() => {
44+
validateSsoProfile(profileToVerify);
45+
}).not.toThrowError();
46+
});
3847
});

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import { SsoProfile } from "./types";
66
* @internal
77
*/
88
export const validateSsoProfile = (profile: Partial<SsoProfile>): SsoProfile => {
9-
const { sso_start_url, sso_account_id, sso_session, sso_region, sso_role_name } = profile;
10-
if ((!sso_start_url && !sso_session) || !sso_account_id || !sso_region || !sso_role_name) {
9+
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = profile;
10+
if (!sso_start_url || !sso_account_id || !sso_region || !sso_role_name) {
1111
throw new CredentialsProviderError(
12-
`Profile is configured with invalid SSO credentials. Required parameters "sso_region", ` +
13-
`"sso_role_name", "sso_account_id", and one of "sso_start_url" or "sso_session". Got ${Object.keys(
14-
profile
15-
).join(", ")}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`,
12+
`Profile is configured with invalid SSO credentials. Required parameters "sso_account_id", ` +
13+
`"sso_region", "sso_role_name", "sso_start_url". Got ${Object.keys(profile).join(
14+
", "
15+
)}\nReference: https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sso.html`,
1616
false
1717
);
1818
}

packages/token-providers/src/fromSso.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ export const fromSso =
6868

6969
let ssoToken: SSOToken;
7070
try {
71-
ssoToken = await getSSOTokenFromFile(ssoSessionName);
71+
ssoToken = await getSSOTokenFromFile(ssoStartUrl);
7272
} catch (e) {
7373
throw new TokenProviderError(
7474
`The SSO session associated with this profile is invalid. ${REFRESH_MESSAGE}`,

0 commit comments

Comments
 (0)