Skip to content
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

feat(ProfileInfo): JWT token expiration detection #2298

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
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
1 change: 1 addition & 0 deletions packages/imperative/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to the Imperative package will be documented in this file.
## Recent Changes

- BugFix: Fixed issues flagged by Coverity [#2291](https://github.com/zowe/zowe-cli/pull/2291)
- Enhancement: Added a new SDK method (`ProfileInfo.hasTokenExpiredForProfile`) that allows developers to verify whether a token has expired for a given profile. [#2298](https://github.com/zowe/zowe-cli/pull/2298)

## `8.1.0`

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1852,4 +1852,180 @@ describe("TeamConfig ProfileInfo tests", () => {
});
// end schema management tests
});

describe("hasTokenExpiredForProfile", () => {
function getBlockMocks() {
const profileInfo = createNewProfInfo(teamProjDir);
const getAllProfiles = jest.spyOn(profileInfo, "getAllProfiles")
.mockReturnValue([
{
profName: "zosmf",
profType: "zosmf",
isDefaultProfile: false,
profLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf",
},
},
]);
const mergeArgsForProfile = jest.spyOn(profileInfo, "mergeArgsForProfile");

return {
getAllProfiles,
mergeArgsForProfile,
profileInfo
};
}

it("returns false if the profile uses LTPA for token type", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse");
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenValue",
argValue: "SOMELTPA2TOKENTHATCANNOTBEDECODED",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenValue",
}
},
{
argName: "tokenType",
argValue: "LtpaToken2",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenType",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(false);
expect(jsonParseSpy).not.toHaveBeenCalled();
});

it("returns false if no tokenValue is present", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse");
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenType",
argValue: "apimlAuthenticationToken",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenType",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(false);
expect(jsonParseSpy).not.toHaveBeenCalled();
});

it("returns false if an error occurred during parsing", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse").mockImplementation(() => {
throw new Error("Unknown error while parsing JSON");
});
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenValue",
argValue: "FAKE_HEADER.FAKE_PAYLOAD.FAKE_SIGNATURE",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenValue",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(false);
expect(jsonParseSpy).toHaveBeenCalled();
});

it("returns true if a JWT token is present and has expired", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse").mockReturnValue({
exp: 1000000000,
});
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenValue",
argValue: "FAKE_HEADER.FAKE_PAYLOAD.FAKE_SIGNATURE",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenValue",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(true);
expect(jsonParseSpy).toHaveBeenCalled();
});

it("returns false if a JWT payload can be parsed, but doesn't contain the exp property", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse").mockReturnValue({
iat: 1000000000,
});
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenValue",
argValue: "FAKE_HEADER.FAKE_PAYLOAD.FAKE_SIGNATURE",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenValue",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(false);
expect(jsonParseSpy).toHaveBeenCalled();
});

it("returns false if a JWT token is present and has not expired", async () => {
const blockMocks = getBlockMocks();
const jsonParseSpy = jest.spyOn(JSON, "parse").mockReturnValue({
exp: 5000000000,
});
blockMocks.mergeArgsForProfile.mockReturnValue({
knownArgs: [
{
argName: "tokenValue",
argValue: "FAKE_HEADER.FAKE_PAYLOAD.FAKE_SIGNATURE",
dataType: "string",
argLoc: {
locType: ProfLocType.TEAM_CONFIG,
osLoc: ["/a/b/c/zowe.config.json"],
jsonLoc: "profiles.zosmf.properties.tokenValue",
}
}
],
missingArgs: []
});
expect(blockMocks.profileInfo.hasTokenExpiredForProfile("zosmf")).toBe(false);
expect(jsonParseSpy).toHaveBeenCalled();
});
});
});
42 changes: 42 additions & 0 deletions packages/imperative/src/config/src/ProfileInfo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,48 @@ export class ProfileInfo {
this.mImpLogger = ConfigUtils.initImpUtils(this.mAppName);
}

/**
* Checks if a JWT token is used for authenticating the given profile name. If so, it will decode the token to determine whether it has expired.
*
* @param {string | IProfileLoaded} profile - The name of the profile or the profile object to check the JWT token for
* @returns {boolean} Whether the token has expired for the given profile. Returns `false` if a token value is not set or the token type is LTPA2.
*/
public hasTokenExpiredForProfile(profile: string | IProfileLoaded): boolean {
const profName = typeof profile === "string" ? profile : profile.name;
const profAttrs = this.getAllProfiles().find((prof) => prof.profName === profName);
const knownProps = this.mergeArgsForProfile(profAttrs, { getSecureVals: true }).knownArgs;
const tokenValueProp = knownProps.find((arg) => arg.argName === "tokenValue" && arg.argValue != null);

// Ignore if tokenValue is not a prop
if (tokenValueProp == null) {
return false;
}

const tokenTypeProp = knownProps.find((arg) => arg.argName === "tokenType");
// Cannot decode LTPA tokens without private key
if (tokenTypeProp?.argValue == "LtpaToken2") {
return false;
}

const fullToken = tokenValueProp.argValue.toString();
// JWT format: [header].[payload].[signature]
const tokenParts = fullToken.split(".");
try {
const payloadJson = JSON.parse(Buffer.from(tokenParts[1], "base64url").toString("utf8"));
if ("exp" in payloadJson) {
// The expire time is stored in seconds since UNIX epoch.
// The Date constructor expects a timestamp in milliseconds.
const msPerSec = 1000;
const expireDate = new Date(payloadJson.exp * msPerSec);
return expireDate < new Date();
}
} catch (err) {
return false;
}

return false;
}

/**
* Update a given property in the config file.
* @param options Set of options needed to update a given property
Expand Down