Skip to content

Commit ffaddf7

Browse files
donna-belsey-nhsanoop-surej-nhs
authored andcommitted
VIA-254 Extract inspecting token for APIM creds and checking expiry into separate method
Previously a private method in getToken file, now called from two places (getToken and the EliD fetch service). Modify method to return existing token if available (this required adding login in specifically for the Edge runtime to ensure existing tokens are not dropped).
1 parent 5bf67bc commit ffaddf7

File tree

6 files changed

+240
-133
lines changed

6 files changed

+240
-133
lines changed

src/utils/auth/apim/get-apim-access-token.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { fetchAPIMAccessToken } from "@src/utils/auth/apim/fetch-apim-access-token";
22
import { getApimAccessToken, retrieveApimCredentials } from "@src/utils/auth/apim/get-apim-access-token";
3-
import { _getOrRefreshApimCredentials } from "@src/utils/auth/callbacks/get-token";
3+
import { _getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh-apim-credentials";
44
import { getJwtToken } from "@src/utils/auth/get-jwt-token";
55
import { AccessToken, IdToken } from "@src/utils/auth/types";
66
import { AppConfig } from "@src/utils/config";
@@ -15,7 +15,7 @@ jest.mock("@src/utils/auth/apim/fetch-apim-access-token", () => ({
1515
}));
1616
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
1717

18-
jest.mock("@src/utils/auth/callbacks/get-token", () => ({
18+
jest.mock("@src/utils/auth/apim/get-or-refresh-apim-credentials", () => ({
1919
_getOrRefreshApimCredentials: jest.fn(),
2020
}));
2121

src/utils/auth/apim/get-apim-access-token.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ApimMissingTokenError } from "@src/utils/auth/apim/exceptions";
22
import { fetchAPIMAccessToken } from "@src/utils/auth/apim/fetch-apim-access-token";
3+
import { _getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh-apim-credentials";
34
import { ApimAccessCredentials, ApimTokenResponse } from "@src/utils/auth/apim/types";
4-
import { _getOrRefreshApimCredentials } from "@src/utils/auth/callbacks/get-token";
55
import { getJwtToken } from "@src/utils/auth/get-jwt-token";
66
import { AccessToken, ExpiresAt, IdToken } from "@src/utils/auth/types";
77
import { AppConfig } from "@src/utils/config";
@@ -25,12 +25,10 @@ const getApimAccessToken = async (config: AppConfig): Promise<AccessToken> => {
2525
throw new ApimMissingTokenError("APIM access token is not present on JWT token");
2626
}
2727

28-
// Extract APIM token from token. Fetch a new one if it's about to expire
2928
const nowInSeconds = Math.floor(Date.now() / 1000);
3029
const apimAccessCredentials = await _getOrRefreshApimCredentials(config, token, nowInSeconds);
3130

3231
if (!apimAccessCredentials) {
33-
// TODO: consider error type, message and re-word if ness
3432
log.error("Error: getOrRefreshApimCredentials returned undefined");
3533
throw new Error("getOrRefreshApimCredentials returned undefined");
3634
} else {
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import { retrieveApimCredentials } from "@src/utils/auth/apim/get-apim-access-token";
2+
import { _getOrRefreshApimCredentials } from "@src/utils/auth/apim/get-or-refresh-apim-credentials";
3+
import { AppConfig } from "@src/utils/config";
4+
import { appConfigBuilder } from "@test-data/config/builders";
5+
import { JWT } from "next-auth/jwt";
6+
7+
jest.mock("@src/utils/auth/apim/get-apim-access-token", () => ({
8+
retrieveApimCredentials: jest.fn(),
9+
}));
10+
11+
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
12+
13+
describe("getOrRefreshApimCredentials", () => {
14+
describe("when AUTH APIM is available", () => {
15+
const oldNEXT_RUNTIME = process.env.NEXT_RUNTIME;
16+
17+
const mockConfig: AppConfig = appConfigBuilder().andIS_APIM_AUTH_ENABLED(true).build();
18+
19+
const nowInSeconds = 1749052001;
20+
21+
beforeEach(() => {
22+
jest.clearAllMocks();
23+
jest.useFakeTimers().setSystemTime(nowInSeconds * 1000);
24+
process.env.NEXT_RUNTIME = "nodejs";
25+
});
26+
27+
beforeEach(async () => {
28+
(retrieveApimCredentials as jest.Mock).mockResolvedValue({
29+
accessToken: "new-apim-access-token",
30+
expiresAt: nowInSeconds + 1111,
31+
});
32+
});
33+
34+
afterEach(() => {
35+
jest.resetAllMocks();
36+
process.env.NEXT_RUNTIME = oldNEXT_RUNTIME;
37+
});
38+
39+
afterAll(() => {
40+
jest.useRealTimers();
41+
});
42+
43+
it("should return undefined and logs error if token does not contain id_token", async () => {
44+
const token = { apim: {}, nhs_login: {} } as JWT;
45+
46+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
47+
48+
expect(result).toBeUndefined();
49+
});
50+
51+
it("should return new APIM creds if stored creds are empty", async () => {
52+
const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT;
53+
54+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
55+
56+
expect(result).toMatchObject({
57+
accessToken: "new-apim-access-token",
58+
expiresAt: nowInSeconds + 1111,
59+
});
60+
});
61+
62+
it("should return stored APIM creds if fresh", async () => {
63+
const token = {
64+
apim: {
65+
access_token: "old-access-token",
66+
expires_at: nowInSeconds + 60,
67+
},
68+
nhs_login: { id_token: "old-id-token" },
69+
} as JWT;
70+
71+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
72+
73+
expect(result).toEqual({
74+
accessToken: "old-access-token",
75+
expiresAt: nowInSeconds + 60,
76+
});
77+
});
78+
79+
it("should return new APIM creds if expired", async () => {
80+
const token = {
81+
apim: { access_token: "old-access-token", expires_at: nowInSeconds - 60 },
82+
nhs_login: { id_token: "id-token" },
83+
} as JWT;
84+
85+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
86+
87+
expect(result).toEqual({
88+
accessToken: "new-apim-access-token",
89+
expiresAt: nowInSeconds + 1111,
90+
});
91+
});
92+
93+
describe("when invoked from Edge runtime", () => {
94+
it("should return stored APIM creds without checking expiry", async () => {
95+
process.env.NEXT_RUNTIME = "edge";
96+
const token = {
97+
apim: { access_token: "stored-access-token", expires_at: 88 },
98+
nhs_login: { id_token: "id-token" },
99+
} as JWT;
100+
101+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
102+
103+
expect(result).toEqual({
104+
accessToken: "stored-access-token",
105+
expiresAt: 88,
106+
});
107+
});
108+
109+
it("should return undefined if APIM creds empty", async () => {
110+
process.env.NEXT_RUNTIME = "edge";
111+
const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT;
112+
113+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
114+
expect(result).toBeUndefined();
115+
});
116+
});
117+
});
118+
119+
describe("when AUTH APIM is not available", () => {
120+
const mockConfig: AppConfig = appConfigBuilder().andIS_APIM_AUTH_ENABLED(false).build();
121+
122+
const nowInSeconds = 1749052001;
123+
124+
beforeEach(() => {
125+
jest.clearAllMocks();
126+
jest.useFakeTimers().setSystemTime(nowInSeconds * 1000);
127+
});
128+
129+
afterEach(() => {
130+
jest.resetAllMocks();
131+
});
132+
133+
afterAll(() => {
134+
jest.useRealTimers();
135+
});
136+
137+
it("should return undefined if APIM auth is not enabled", async () => {
138+
const token = { apim: {}, nhs_login: { id_token: "id-token" } } as JWT;
139+
140+
const result = await _getOrRefreshApimCredentials(mockConfig, token, nowInSeconds);
141+
142+
expect(result).toBeUndefined();
143+
});
144+
});
145+
});
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { retrieveApimCredentials } from "@src/utils/auth/apim/get-apim-access-token";
2+
import { ApimAccessCredentials } from "@src/utils/auth/apim/types";
3+
import { ExpiresSoonAt } from "@src/utils/auth/types";
4+
import { AppConfig } from "@src/utils/config";
5+
import { logger } from "@src/utils/logger";
6+
import { JWT } from "next-auth/jwt";
7+
import { Logger } from "pino";
8+
9+
const log: Logger = logger.child({ module: "get-or-refresh-apim-credentials" });
10+
11+
async function _getOrRefreshApimCredentials(config: AppConfig, token: JWT, nowInSeconds: number) {
12+
// Return the APIM creds from the token if still valid, or fetch new creds from APIM if expiring soon or empty
13+
let apimCredentials: ApimAccessCredentials | undefined;
14+
15+
const cryproAvailable = process.env.NEXT_RUNTIME === "nodejs";
16+
17+
if (!cryproAvailable) {
18+
// edge runtime
19+
if (token?.apim?.access_token && token?.apim?.expires_at) {
20+
return {
21+
accessToken: token?.apim?.access_token,
22+
expiresAt: token?.apim?.expires_at,
23+
};
24+
} else return undefined;
25+
}
26+
27+
if (config.IS_APIM_AUTH_ENABLED && cryproAvailable) {
28+
if (!token.nhs_login?.id_token) {
29+
log.debug("getOrRefreshApimCredentials: No NHS login ID token available. Not getting APIM creds.");
30+
} else if (!token.apim?.access_token || token.apim.access_token === "") {
31+
log.debug(
32+
{ context: { existingApimCredentals: token.apim } },
33+
"getOrRefreshApimCredentials: Getting new APIM creds.",
34+
);
35+
apimCredentials = await retrieveApimCredentials(token.nhs_login.id_token);
36+
log.info(`First APIM token fetched. expiry time: ${apimCredentials.expiresAt}`);
37+
log.debug(
38+
{ context: { updatedApimCredentals: apimCredentials } },
39+
"getOrRefreshApimCredentials: New APIM creds retrieved.",
40+
);
41+
} else {
42+
const expiryWriggleRoom = 30;
43+
const expiresSoonAt: ExpiresSoonAt = (token.apim?.expires_at - expiryWriggleRoom) as ExpiresSoonAt;
44+
45+
if (expiresSoonAt < nowInSeconds) {
46+
log.debug(
47+
{ context: { existingApimCredentals: token.apim } },
48+
"getOrRefreshApimCredentials: Refreshing APIM creds.",
49+
);
50+
51+
log.info(`APIM token expires soon ${token.apim.expires_at} ; fetching new token`);
52+
apimCredentials = await retrieveApimCredentials(token.nhs_login.id_token);
53+
log.debug(
54+
{ context: { updatedApimCredentals: apimCredentials } },
55+
"getOrRefreshApimCredentials: Refreshed APIM creds retrieved.",
56+
);
57+
58+
log.info(`New APIM token fetched. expiry time: ${apimCredentials.expiresAt}`);
59+
} else {
60+
log.debug(
61+
{ context: { existingApimCredentals: token.apim, timeRemaining: expiresSoonAt - nowInSeconds } },
62+
"getOrRefreshApimCredentials: APIM creds still fresh.",
63+
);
64+
apimCredentials = {
65+
accessToken: token.apim.access_token,
66+
expiresAt: token.apim.expires_at,
67+
};
68+
}
69+
}
70+
}
71+
return apimCredentials;
72+
}
73+
74+
export { _getOrRefreshApimCredentials };

0 commit comments

Comments
 (0)