Skip to content

Commit e4ea7d0

Browse files
kuhetrivikr
andauthored
feat(sso): use SSOTokenProvider in SSOCredentialProvider (#4145)
* feat(sso): use SSOTokenProvider in SSOCredentialProvider * feat(sso): use SSOTokenProvider in SSOCredentialProvider implementation * feat(credential-provider-sso): use SSOTokenProvider when new config format is detected * feat(credential-provider-sso): accept suggestion Co-authored-by: Trivikram Kamat <[email protected]> * feat(credential-provider-sso): accept suggestion Co-authored-by: Trivikram Kamat <[email protected]> * feat(credential-provider-sso): accept suggestion Co-authored-by: Trivikram Kamat <[email protected]> * feat(credential-provider-sso): update error message in sso token retrieval Co-authored-by: Trivikram Kamat <[email protected]>
1 parent a5acf74 commit e4ea7d0

19 files changed

+212
-52
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-ini/src/resolveSsoCredentials.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import { SsoProfile } from "@aws-sdk/credential-provider-sso";
44
export { isSsoProfile } from "@aws-sdk/credential-provider-sso";
55

66
export const resolveSsoCredentials = (data: Partial<SsoProfile>) => {
7-
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(data);
7+
const { sso_start_url, sso_account_id, sso_session, sso_region, sso_role_name } = validateSsoProfile(data);
88
return fromSSO({
99
ssoStartUrl: sso_start_url,
1010
ssoAccountId: sso_account_id,
11+
ssoSession: sso_session,
1112
ssoRegion: sso_region,
1213
ssoRoleName: sso_role_name,
1314
})();

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: 69 additions & 8 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";
@@ -13,6 +18,12 @@ export interface SsoCredentialsParameters {
1318
*/
1419
ssoStartUrl: string;
1520

21+
/**
22+
* SSO session identifier.
23+
* Presence implies usage of the SSOTokenProvider.
24+
*/
25+
ssoSession?: string;
26+
1627
/**
1728
* The ID of the AWS account to use for temporary credentials.
1829
*/
@@ -36,35 +47,85 @@ export interface FromSSOInit extends SourceProfileInit {
3647
/**
3748
* Creates a credential provider that will read from a credential_process specified
3849
* in ini files.
50+
*
51+
* The SSO credential provider must support both
52+
*
53+
* 1. the legacy profile format,
54+
* @example
55+
* ```
56+
* [profile sample-profile]
57+
* sso_account_id = 012345678901
58+
* sso_region = us-east-1
59+
* sso_role_name = SampleRole
60+
* sso_start_url = https://www.....com/start
61+
* ```
62+
*
63+
* 2. and the profile format for SSO Token Providers.
64+
* @example
65+
* ```
66+
* [profile sso-profile]
67+
* sso_session = dev
68+
* sso_account_id = 012345678901
69+
* sso_role_name = SampleRole
70+
*
71+
* [sso-session dev]
72+
* sso_region = us-east-1
73+
* sso_start_url = https://www.....com/start
74+
* ```
3975
*/
4076
export const fromSSO =
4177
(init: FromSSOInit & Partial<SsoCredentialsParameters> = {}): CredentialProvider =>
4278
async () => {
43-
const { ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient } = init;
44-
if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName) {
79+
const { ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient, ssoSession } = init;
80+
const profileName = getProfileName(init);
81+
82+
if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName && !ssoSession) {
4583
// Load the SSO config from shared AWS config file.
4684
const profiles = await parseKnownFiles(init);
47-
const profileName = getProfileName(init);
4885
const profile = profiles[profileName];
4986

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 CredentialsProviderError(`Conflicting SSO region` + conflictMsg, false);
93+
}
94+
if (ssoStartUrl && ssoStartUrl !== session.sso_start_url) {
95+
throw new CredentialsProviderError(`Conflicting SSO start_url` + conflictMsg, false);
96+
}
97+
profile.sso_region = session.sso_region;
98+
profile.sso_start_url = session.sso_start_url;
99+
}
100+
50101
if (!isSsoProfile(profile)) {
51102
throw new CredentialsProviderError(`Profile ${profileName} is not configured with SSO credentials.`);
52103
}
53104

54-
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(profile);
105+
const { sso_start_url, sso_account_id, sso_region, sso_role_name, sso_session } = validateSsoProfile(profile);
55106
return resolveSSOCredentials({
56107
ssoStartUrl: sso_start_url,
108+
ssoSession: sso_session,
57109
ssoAccountId: sso_account_id,
58110
ssoRegion: sso_region,
59111
ssoRoleName: sso_role_name,
60112
ssoClient: ssoClient,
113+
profile: profileName,
61114
});
62115
} else if (!ssoStartUrl || !ssoAccountId || !ssoRegion || !ssoRoleName) {
63116
throw new CredentialsProviderError(
64-
'Incomplete configuration. The fromSSO() argument hash must include "ssoStartUrl",' +
65-
' "ssoAccountId", "ssoRegion", "ssoRoleName"'
117+
"Incomplete configuration. The fromSSO() argument hash must include " +
118+
'"ssoStartUrl", "ssoAccountId", "ssoRegion", "ssoRoleName"'
66119
);
67120
} else {
68-
return resolveSSOCredentials({ ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient });
121+
return resolveSSOCredentials({
122+
ssoStartUrl,
123+
ssoSession,
124+
ssoAccountId,
125+
ssoRegion,
126+
ssoRoleName,
127+
ssoClient,
128+
profile: profileName,
129+
});
69130
}
70131
};

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/isSsoProfile.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,6 @@ export const isSsoProfile = (arg: Profile): arg is Partial<SsoProfile> =>
99
arg &&
1010
(typeof arg.sso_start_url === "string" ||
1111
typeof arg.sso_account_id === "string" ||
12+
typeof arg.sso_session === "string" ||
1213
typeof arg.sso_region === "string" ||
1314
typeof arg.sso_role_name === "string");

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 & 8 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";
@@ -9,28 +10,46 @@ import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
910
* The time window (15 mins) that SDK will treat the SSO token expires in before the defined expiration date in token.
1011
* This is needed because server side may have invalidated the token before the defined expiration date.
1112
*
12-
* @internal
13+
* @private
1314
*/
1415
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,
24+
ssoSession,
2025
ssoAccountId,
2126
ssoRegion,
2227
ssoRoleName,
2328
ssoClient,
29+
profile,
2430
}: FromSSOInit & SsoCredentialsParameters): Promise<Credentials> => {
2531
let token: SSOToken;
2632
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
27-
try {
28-
token = await getSSOTokenFromFile(ssoStartUrl);
29-
} catch (e) {
30-
throw new CredentialsProviderError(
31-
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
32-
SHOULD_FAIL_CREDENTIAL_CHAIN
33-
);
33+
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(e.message, SHOULD_FAIL_CREDENTIAL_CHAIN);
43+
}
44+
} else {
45+
try {
46+
token = await getSSOTokenFromFile(ssoStartUrl);
47+
} catch (e) {
48+
throw new CredentialsProviderError(
49+
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
50+
SHOULD_FAIL_CREDENTIAL_CHAIN
51+
);
52+
}
3453
}
3554

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface SSOToken {
1717
*/
1818
export interface SsoProfile extends Profile {
1919
sso_start_url: string;
20+
sso_session?: string;
2021
sso_account_id: string;
2122
sso_region: string;
2223
sso_role_name: string;

0 commit comments

Comments
 (0)