Skip to content

chore(shared-ini-file-loader): add utility getSSOTokenFromFile #3421

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
Mar 15, 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
@@ -1,23 +1,13 @@
import { GetRoleCredentialsCommand, SSOClient } from "@aws-sdk/client-sso";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { getHomeDir } from "@aws-sdk/shared-ini-file-loader";
import { createHash } from "crypto";
import { promises } from "fs";
import { join } from "path";
import { getSSOTokenFromFile } from "@aws-sdk/shared-ini-file-loader";

import { resolveSSOCredentials } from "./resolveSSOCredentials";

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

describe(resolveSSOCredentials.name, () => {
const mockCacheName = "mockCacheName";
const mockDigest = jest.fn().mockReturnValue(mockCacheName);
const mockUpdate = jest.fn().mockReturnValue({ digest: mockDigest });
const mockHomeDir = "/home/dir";

const mockToken = {
accessToken: "mockAccessToken",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
Expand All @@ -44,45 +34,27 @@ describe(resolveSSOCredentials.name, () => {
};

beforeEach(() => {
(createHash as jest.Mock).mockReturnValue({ update: mockUpdate });
(getHomeDir as jest.Mock).mockReturnValue(mockHomeDir);
(promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockToken));
(getSSOTokenFromFile as jest.Mock).mockResolvedValue(mockToken);
mockSsoSend.mockResolvedValue({ roleCredentials: mockCreds });
});

afterEach(() => {
expect(createHash).toHaveBeenCalledWith("sha1");
expect(mockUpdate).toHaveBeenCalledWith(mockOptions.ssoStartUrl);
expect(mockDigest).toHaveBeenCalledWith("hex");
expect(promises.readFile).toHaveBeenCalledWith(
join(mockHomeDir, ".aws", "sso", "cache", `${mockCacheName}.json`),
"utf8"
);
jest.clearAllMocks();
});

describe("throws error for invalid token", () => {
afterEach(async () => {
const expectedError = new CredentialsProviderError(
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
SHOULD_FAIL_CREDENTIAL_CHAIN
);

try {
await resolveSSOCredentials(mockOptions);
fail(`expected ${expectedError}`);
} catch (error) {
expect(error).toStrictEqual(expectedError);
}
});

it("throws error if readFile fails", async () => {
(promises.readFile as jest.Mock).mockRejectedValue(new Error("error"));
});

it("throws error if token is not a valid JSON", async () => {
(promises.readFile as jest.Mock).mockReturnValue("invalid JSON");
});
it("throws error if getSSOTokenFromFile fails", async () => {
const expectedError = new CredentialsProviderError(
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
SHOULD_FAIL_CREDENTIAL_CHAIN
);
(getSSOTokenFromFile as jest.Mock).mockRejectedValue(new Error("error"));

try {
await resolveSSOCredentials(mockOptions);
fail(`expected ${expectedError}`);
} catch (error) {
expect(error).toStrictEqual(expectedError);
}
});

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

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

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

Expand Down
15 changes: 2 additions & 13 deletions packages/credential-provider-sso/src/resolveSSOCredentials.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,9 @@
import { GetRoleCredentialsCommand, GetRoleCredentialsCommandOutput, SSOClient } from "@aws-sdk/client-sso";
import { CredentialsProviderError } from "@aws-sdk/property-provider";
import { getHomeDir } from "@aws-sdk/shared-ini-file-loader";
import { getSSOTokenFromFile, SSOToken } from "@aws-sdk/shared-ini-file-loader";
import { Credentials } from "@aws-sdk/types";
import { createHash } from "crypto";
// ToDo: Change to "fs/promises" when supporting nodejs>=14
import { promises as fsPromises } from "fs";
import { join } from "path";

import { FromSSOInit, SsoCredentialsParameters } from "./fromSSO";
import { SSOToken } from "./types";

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

const SHOULD_FAIL_CREDENTIAL_CHAIN = false;

const { readFile } = fsPromises;

export const resolveSSOCredentials = async ({
ssoStartUrl,
ssoAccountId,
ssoRegion,
ssoRoleName,
ssoClient,
}: FromSSOInit & SsoCredentialsParameters): Promise<Credentials> => {
const hasher = createHash("sha1");
const cacheName = hasher.update(ssoStartUrl).digest("hex");
const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);

let token: SSOToken;
const refreshMessage = `To refresh this SSO session run aws sso login with the corresponding profile.`;
try {
token = JSON.parse(await readFile(tokenFile, "utf8"));
token = await getSSOTokenFromFile(ssoStartUrl);
} catch (e) {
throw new CredentialsProviderError(
`The SSO session associated with this profile is invalid. ${refreshMessage}`,
Expand Down
68 changes: 68 additions & 0 deletions packages/shared-ini-file-loader/src/getSSOTokenFromFile.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { createHash } from "crypto";
// ToDo: Change to "fs/promises" when supporting nodejs>=14
import { promises } from "fs";
import { join } from "path";

import { getHomeDir } from "./getHomeDir";
import { getSSOTokenFromFile } from "./getSSOTokenFromFile";

jest.mock("crypto");
jest.mock("fs", () => ({ promises: { readFile: jest.fn() } }));
jest.mock("./getHomeDir");

describe(getSSOTokenFromFile.name, () => {
const mockCacheName = "mockCacheName";
const mockDigest = jest.fn().mockReturnValue(mockCacheName);
const mockUpdate = jest.fn().mockReturnValue({ digest: mockDigest });
const mockHomeDir = "/home/dir";
const mockSsoStartUrl = "mock_sso_start_url";

const mockToken = {
accessToken: "mockAccessToken",
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(),
};

beforeEach(() => {
(createHash as jest.Mock).mockReturnValue({ update: mockUpdate });
(getHomeDir as jest.Mock).mockReturnValue(mockHomeDir);
(promises.readFile as jest.Mock).mockResolvedValue(JSON.stringify(mockToken));
});

afterEach(() => {
expect(createHash).toHaveBeenCalledWith("sha1");
expect(promises.readFile).toHaveBeenCalledWith(
join(mockHomeDir, ".aws", "sso", "cache", `${mockCacheName}.json`),
"utf8"
);
jest.clearAllMocks();
});

it("re-throws if readFile fails", async () => {
const expectedError = new Error("error");
(promises.readFile as jest.Mock).mockRejectedValue(expectedError);

try {
await getSSOTokenFromFile(mockSsoStartUrl);
fail(`expected ${expectedError}`);
} catch (error) {
expect(error).toStrictEqual(expectedError);
}
});

it("re-throws if token is not a valid JSON", async () => {
const errMsg = "Unexpected token";
(promises.readFile as jest.Mock).mockReturnValue("invalid JSON");

try {
await getSSOTokenFromFile(mockSsoStartUrl);
fail(`expected '${errMsg}'`);
} catch (error) {
expect(error.message).toContain(errMsg);
}
});

it("returns token when it's valid", async () => {
const token = await getSSOTokenFromFile(mockSsoStartUrl);
expect(token).toStrictEqual(mockToken);
});
});
64 changes: 64 additions & 0 deletions packages/shared-ini-file-loader/src/getSSOTokenFromFile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createHash } from "crypto";
// ToDo: Change to "fs/promises" when supporting nodejs>=14
import { promises as fsPromises } from "fs";
import { join } from "path";

import { getHomeDir } from "./getHomeDir";

/**
* Cached SSO token retrieved from SSO login flow.
*/
export interface SSOToken {
/**
* A base64 encoded string returned by the sso-oidc service.
*/
accessToken: string;

/**
* The expiration time of the accessToken as an RFC 3339 formatted timestamp.
*/
expiresAt: string;

/**
* The token used to obtain an access token in the event that the accessToken is invalid or expired.
*/
refreshToken?: string;

/**
* The unique identifier string for each client. The client ID generated when performing the registration
* portion of the OIDC authorization flow. This is used to refresh the accessToken.
*/
clientId?: string;

/**
* A secret string generated when performing the registration portion of the OIDC authorization flow.
* This is used to refresh the accessToken.
*/
clientSecret?: string;

/**
* The expiration time of the client registration (clientId and clientSecret) as an RFC 3339 formatted timestamp.
*/
registrationExpiresAt?: string;

/**
* The configured sso_region for the profile that credentials are being resolved for.
*/
region?: string;

/**
* The configured sso_start_url for the profile that credentials are being resolved for.
*/
startUrl?: string;
}

const { readFile } = fsPromises;

export const getSSOTokenFromFile = async (ssoStartUrl: string) => {
const hasher = createHash("sha1");
const cacheName = hasher.update(ssoStartUrl).digest("hex");
const tokenFile = join(getHomeDir(), ".aws", "sso", "cache", `${cacheName}.json`);

const tokenText = await readFile(tokenFile, "utf8");
return JSON.parse(tokenText) as SSOToken;
};
1 change: 1 addition & 0 deletions packages/shared-ini-file-loader/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./getHomeDir";
export * from "./getProfileName";
export * from "./getSSOTokenFromFile";
export * from "./loadSharedConfigFiles";
export * from "./parseKnownFiles";
export * from "./types";