Skip to content

feat(sso): use SSOTokenProvider in SSOCredentialProvider #4145

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
Nov 11, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,14 @@ describe(resolveSsoCredentials.name, () => {
sso_role_name: "mock_sso_role_name",
});

const getMockValidatedSsoProfile = () => ({
sso_start_url: "mock_validated_sso_start_url",
sso_account_id: "mock_validated_sso_account_id",
sso_region: "mock_validated_sso_region",
sso_role_name: "mock_validated_sso_role_name",
});
const getMockValidatedSsoProfile = <T>(add: T = {} as T) =>
({
sso_start_url: "mock_validated_sso_start_url",
sso_account_id: "mock_validated_sso_account_id",
sso_region: "mock_validated_sso_region",
sso_role_name: "mock_validated_sso_role_name",
...add,
});

afterEach(() => {
jest.clearAllMocks();
Expand Down Expand Up @@ -95,4 +97,28 @@ describe(resolveSsoCredentials.name, () => {
ssoRoleName: mockValidatedProfile.sso_role_name,
});
});

it("calls fromSSO with optional sso session name", async () => {
const mockProfile = getMockOriginalSsoProfile();
const mockValidatedProfile = getMockValidatedSsoProfile({
sso_session: "test-session",
});

const mockCreds: Credentials = {
accessKeyId: "mockAccessKeyId",
secretAccessKey: "mockSecretAccessKey",
};

(validateSsoProfile as jest.Mock).mockReturnValue(mockValidatedProfile);
(fromSSO as jest.Mock).mockReturnValue(() => Promise.resolve(mockCreds));

await resolveSsoCredentials(mockProfile);
expect(fromSSO).toHaveBeenCalledWith({
ssoStartUrl: mockValidatedProfile.sso_start_url,
ssoAccountId: mockValidatedProfile.sso_account_id,
ssoRegion: mockValidatedProfile.sso_region,
ssoRoleName: mockValidatedProfile.sso_role_name,
ssoSession: mockValidatedProfile.sso_session,
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { SsoProfile } from "@aws-sdk/credential-provider-sso";
export { isSsoProfile } from "@aws-sdk/credential-provider-sso";

export const resolveSsoCredentials = (data: Partial<SsoProfile>) => {
const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(data);
const { sso_start_url, sso_account_id, sso_session, sso_region, sso_role_name } = validateSsoProfile(data);
return fromSSO({
ssoStartUrl: sso_start_url,
ssoAccountId: sso_account_id,
ssoSession: sso_session,
ssoRegion: sso_region,
ssoRoleName: sso_role_name,
})();
Expand Down
1 change: 1 addition & 0 deletions packages/credential-provider-sso/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"@aws-sdk/client-sso": "*",
"@aws-sdk/property-provider": "*",
"@aws-sdk/shared-ini-file-loader": "*",
"@aws-sdk/token-providers": "*",
"@aws-sdk/types": "*",
"tslib": "^2.3.1"
},
Expand Down
12 changes: 10 additions & 2 deletions packages/credential-provider-sso/src/fromSSO.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe(fromSSO.name, () => {
secretAccessKey: "mockSecretAccessKey",
};

const mockProfileName = "mockProfileName";

beforeEach(() => {
(resolveSSOCredentials as jest.Mock).mockResolvedValue(mockCreds);
});
Expand All @@ -36,7 +38,6 @@ describe(fromSSO.name, () => {
});

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

Expand Down Expand Up @@ -97,6 +98,8 @@ describe(fromSSO.name, () => {
ssoAccountId: mockValidatedSsoProfile.sso_account_id,
ssoRegion: mockValidatedSsoProfile.sso_region,
ssoRoleName: mockValidatedSsoProfile.sso_role_name,
profile: mockProfileName,
ssoSession: undefined,
});
});
});
Expand All @@ -117,7 +120,12 @@ describe(fromSSO.name, () => {
});

it("calls resolveSSOCredentials if all sso* values are set", async () => {
const mockOptions = { ...mockSsoProfile, ssoClient: mockSsoClient };
const mockOptions = {
...mockSsoProfile,
ssoClient: mockSsoClient,
profile: mockProfileName,
ssoSession: "sso-session-name",
};
const receivedCreds = await fromSSO(mockOptions)();
expect(receivedCreds).toStrictEqual(mockCreds);
expect(resolveSSOCredentials).toHaveBeenCalledWith(mockOptions);
Expand Down
77 changes: 69 additions & 8 deletions packages/credential-provider-sso/src/fromSSO.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { SSOClient } from "@aws-sdk/client-sso";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { getProfileName, parseKnownFiles, SourceProfileInit } from "@aws-sdk/shared-ini-file-loader";
import {
getProfileName,
loadSsoSessionData,
parseKnownFiles,
SourceProfileInit,
} from "@aws-sdk/shared-ini-file-loader";
import { CredentialProvider } from "@aws-sdk/types";

import { isSsoProfile } from "./isSsoProfile";
Expand All @@ -13,6 +18,12 @@ export interface SsoCredentialsParameters {
*/
ssoStartUrl: string;

/**
* SSO session identifier.
* Presence implies usage of the SSOTokenProvider.
*/
ssoSession?: string;

/**
* The ID of the AWS account to use for temporary credentials.
*/
Expand All @@ -36,35 +47,85 @@ export interface FromSSOInit extends SourceProfileInit {
/**
* Creates a credential provider that will read from a credential_process specified
* in ini files.
*
* The SSO credential provider must support both
*
* 1. the legacy profile format,
* @example
* ```
* [profile sample-profile]
* sso_account_id = 012345678901
* sso_region = us-east-1
* sso_role_name = SampleRole
* sso_start_url = https://www.....com/start
* ```
*
* 2. and the profile format for SSO Token Providers.
* @example
* ```
* [profile sso-profile]
* sso_session = dev
* sso_account_id = 012345678901
* sso_role_name = SampleRole
*
* [sso-session dev]
* sso_region = us-east-1
* sso_start_url = https://www.....com/start
* ```
*/
export const fromSSO =
(init: FromSSOInit & Partial<SsoCredentialsParameters> = {}): CredentialProvider =>
async () => {
const { ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient } = init;
if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName) {
const { ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient, ssoSession } = init;
const profileName = getProfileName(init);

if (!ssoStartUrl && !ssoAccountId && !ssoRegion && !ssoRoleName && !ssoSession) {
// Load the SSO config from shared AWS config file.
const profiles = await parseKnownFiles(init);
const profileName = getProfileName(init);
const profile = profiles[profileName];

if (profile.sso_session) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kuhe I am getting the error Cannot read properties of undefined (reading 'sso_session') with code running inside an ECS task after this change. I believe this change here does not account for when profile is undefined.

I believe this should be:

if (profile && profile.sso_session) {

Previously this function would have exited with if (!isSsoProfile(profile)) below, since that checks for undefined.

Copy link

@niranjan94 niranjan94 Nov 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 I'm getting the same error Cannot read properties of undefined (reading 'sso_session') when running inside AWS EKS. (Version 3.209.0)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 getting same error when running inside AWS Elasticbeanstalk (Version 3.209.0):

Nov 13 22:30:24 ip-10-0-2-9 web: TypeError: Cannot read properties of undefined (reading 'sso_session')
Nov 13 22:30:24 ip-10-0-2-9 web: at /var/app/current/node_modules/@aws-sdk/credential-provider-sso/dist-cjs/fromSSO.js:15:21
Nov 13 22:30:24 ip-10-0-2-9 web: at async coalesceProvider (/var/app/current/node_modules/@aws-sdk/property-provider/dist-cjs/memoize.js:14:24)
Nov 13 22:30:24 ip-10-0-2-9 web: at async SignatureV4.credentialProvider (/var/app/current/node_modules/@aws-sdk/property-provider/dist-cjs/memoize.js:33:24)
Nov 13 22:30:24 ip-10-0-2-9 web: at async SignatureV4.signRequest (/var/app/current/node_modules/@aws-sdk/signature-v4/dist-cjs/SignatureV4.js:86:29)
Nov 13 22:30:24 ip-10-0-2-9 web: at async /var/app/current/node_modules/@aws-sdk/middleware-signing/dist-cjs/middleware.js:16:18
Nov 13 22:30:24 ip-10-0-2-9 web: at async StandardRetryStrategy.retry (/var/app/current/node_modules/@aws-sdk/middleware-retry/dist-cjs/StandardRetryStrategy.js:51:46)
Nov 13 22:30:24 ip-10-0-2-9 web: at async /var/app/current/node_modules/@aws-sdk/middleware-logger/dist-cjs/loggerMiddleware.js:6:22
Nov 13 22:30:24 ip-10-0-2-9 web: at async getAWSSecrets (/var/app/current/packages/server/dist/infrastructure/settings/config.factory.js:104:21)
Nov 13 22:30:24 ip-10-0-2-9 web: at async InstanceWrapper.ConfigAWSFactory [as metatype] (/var/app/current/packages/server/dist/infrastructure/settings/config.factory.js:61:21)
Nov 13 22:30:24 ip-10-0-2-9 web: at async Injector.instantiateClass (/var/app/current/node_modules/@nestjs/core/injector/injector.js:344:37)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Submitted PR to fix #4186

const ssoSessions = await loadSsoSessionData(init);
const session = ssoSessions[profile.sso_session];
const conflictMsg = ` configurations in profile ${profileName} and sso-session ${profile.sso_session}`;
if (ssoRegion && ssoRegion !== session.sso_region) {
throw new CredentialsProviderError(`Conflicting SSO region` + conflictMsg, false);
}
if (ssoStartUrl && ssoStartUrl !== session.sso_start_url) {
throw new CredentialsProviderError(`Conflicting SSO start_url` + conflictMsg, false);
}
profile.sso_region = session.sso_region;
profile.sso_start_url = session.sso_start_url;
}

if (!isSsoProfile(profile)) {
throw new CredentialsProviderError(`Profile ${profileName} is not configured with SSO credentials.`);
}

const { sso_start_url, sso_account_id, sso_region, sso_role_name } = validateSsoProfile(profile);
const { sso_start_url, sso_account_id, sso_region, sso_role_name, sso_session } = validateSsoProfile(profile);
return resolveSSOCredentials({
ssoStartUrl: sso_start_url,
ssoSession: sso_session,
ssoAccountId: sso_account_id,
ssoRegion: sso_region,
ssoRoleName: sso_role_name,
ssoClient: ssoClient,
profile: profileName,
});
} else if (!ssoStartUrl || !ssoAccountId || !ssoRegion || !ssoRoleName) {
throw new CredentialsProviderError(
'Incomplete configuration. The fromSSO() argument hash must include "ssoStartUrl",' +
' "ssoAccountId", "ssoRegion", "ssoRoleName"'
"Incomplete configuration. The fromSSO() argument hash must include " +
'"ssoStartUrl", "ssoAccountId", "ssoRegion", "ssoRoleName"'
);
} else {
return resolveSSOCredentials({ ssoStartUrl, ssoAccountId, ssoRegion, ssoRoleName, ssoClient });
return resolveSSOCredentials({
ssoStartUrl,
ssoSession,
ssoAccountId,
ssoRegion,
ssoRoleName,
ssoClient,
profile: profileName,
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ describe(isSsoProfile.name, () => {
expect(isSsoProfile({})).toEqual(false);
});

it.each(["sso_start_url", "sso_account_id", "sso_region", "sso_role_name"])(
it.each(["sso_start_url", "sso_account_id", "sso_region", "sso_session", "sso_role_name"])(
"returns true if value at '%s' is of type string",
(key) => {
expect(isSsoProfile({ [key]: "string" })).toEqual(true);
Expand Down
1 change: 1 addition & 0 deletions packages/credential-provider-sso/src/isSsoProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ export const isSsoProfile = (arg: Profile): arg is Partial<SsoProfile> =>
arg &&
(typeof arg.sso_start_url === "string" ||
typeof arg.sso_account_id === "string" ||
typeof arg.sso_session === "string" ||
typeof arg.sso_region === "string" ||
typeof arg.sso_role_name === "string");
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { GetRoleCredentialsCommand, SSOClient } from "@aws-sdk/client-sso";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { getSSOTokenFromFile } from "@aws-sdk/shared-ini-file-loader";
import * as tokenProviders from "@aws-sdk/token-providers";

import { resolveSSOCredentials } from "./resolveSSOCredentials";

jest.mock("@aws-sdk/shared-ini-file-loader");
jest.mock("@aws-sdk/client-sso");
jest.mock("@aws-sdk/token-providers", () => {
return {
fromSso: jest.fn(() => async () => {
return {
token: "mockAccessToken",
expiration: new Date(Date.now() + 6_000_000),
};
}),
};
});

describe(resolveSSOCredentials.name, () => {
const mockToken = {
Expand Down Expand Up @@ -57,6 +68,16 @@ describe(resolveSSOCredentials.name, () => {
}
});

it("uses the SSOTokenProvider if SSO Session name is present", async () => {
await resolveSSOCredentials({
...mockOptions,
ssoSession: "test-sso-session",
});
expect(tokenProviders.fromSso).toHaveBeenCalledWith({
profile: undefined,
});
});

describe("throws error on expiration", () => {
afterEach(async () => {
const expectedError = new CredentialsProviderError(
Expand Down Expand Up @@ -134,18 +155,15 @@ describe(resolveSSOCredentials.name, () => {
});

it("returns valid credentials from sso.getRoleCredentials", async () => {
const receivedCreds = await resolveSSOCredentials(mockOptions);
expect(receivedCreds).toStrictEqual(receivedCreds);
await resolveSSOCredentials(mockOptions);
expect(mockSsoSend).toHaveBeenCalledTimes(1);
});

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

const receivedCreds = await resolveSSOCredentials({ ...mockOptions, ssoClient: undefined });
expect(receivedCreds).toStrictEqual(receivedCreds);

await resolveSSOCredentials({ ...mockOptions, ssoClient: undefined });
expect(mockCustomSsoSend).toHaveBeenCalledTimes(1);
});
});
Expand Down
35 changes: 27 additions & 8 deletions packages/credential-provider-sso/src/resolveSSOCredentials.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { GetRoleCredentialsCommand, GetRoleCredentialsCommandOutput, SSOClient } from "@aws-sdk/client-sso";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { getSSOTokenFromFile, SSOToken } from "@aws-sdk/shared-ini-file-loader";
import { fromSso as getSsoTokenProvider } from "@aws-sdk/token-providers";
import { Credentials } from "@aws-sdk/types";

import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
Expand All @@ -9,28 +10,46 @@ import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
* The time window (15 mins) that SDK will treat the SSO token expires in before the defined expiration date in token.
* This is needed because server side may have invalidated the token before the defined expiration date.
*
* @internal
* @private
*/
const EXPIRE_WINDOW_MS = 15 * 60 * 1000;

const SHOULD_FAIL_CREDENTIAL_CHAIN = false;

/**
* @private
*/
export const resolveSSOCredentials = async ({
ssoStartUrl,
ssoSession,
ssoAccountId,
ssoRegion,
ssoRoleName,
ssoClient,
profile,
}: FromSSOInit & SsoCredentialsParameters): Promise<Credentials> => {
let token: SSOToken;
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
try {
token = await getSSOTokenFromFile(ssoStartUrl);
} catch (e) {
throw new CredentialsProviderError(
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
SHOULD_FAIL_CREDENTIAL_CHAIN
);

if (ssoSession) {
try {
const _token = await getSsoTokenProvider({ profile })();
token = {
accessToken: _token.token,
expiresAt: new Date(_token.expiration!).toISOString(),
};
} catch (e) {
throw new CredentialsProviderError(e.message, SHOULD_FAIL_CREDENTIAL_CHAIN);
}
} else {
try {
token = await getSSOTokenFromFile(ssoStartUrl);
} catch (e) {
throw new CredentialsProviderError(
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
SHOULD_FAIL_CREDENTIAL_CHAIN
);
}
}

if (new Date(token.expiresAt).getTime() - Date.now() <= EXPIRE_WINDOW_MS) {
Expand Down
1 change: 1 addition & 0 deletions packages/credential-provider-sso/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface SSOToken {
*/
export interface SsoProfile extends Profile {
sso_start_url: string;
sso_session?: string;
sso_account_id: string;
sso_region: string;
sso_role_name: string;
Expand Down
Loading