Skip to content

Commit 05020fd

Browse files
committed
Use JWT Auth against API Frontend
1 parent 46cfec4 commit 05020fd

6 files changed

Lines changed: 160 additions & 15 deletions

File tree

application/CohortManager/src/Web/app/account/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export const metadata: Metadata = {
99
export default async function Page() {
1010
const breadcrumbItems = [{ label: "Home", url: "/" }];
1111
const session = await auth();
12+
const isDevelopment = process.env.APP_ENV === "development";
1213

1314
return (
1415
<>
@@ -52,6 +53,33 @@ export default async function Page() {
5253
</ul>
5354
</dd>
5455
</div>
56+
{isDevelopment ? (
57+
<>
58+
<div className="nhsuk-summary-list__row">
59+
<dt className="nhsuk-summary-list__key">
60+
Development ID token
61+
</dt>
62+
<dd className="nhsuk-summary-list__value">
63+
<p>
64+
Set AUTH_CIS2_DEV_ID_TOKEN or AUTH_CIS2_DEV_JWT_TOKEN
65+
in your local env to supply a JWT when using the
66+
development credentials sign-in.
67+
</p>
68+
<pre>{session?.idToken || "No ID token available"}</pre>
69+
</dd>
70+
</div>
71+
<div className="nhsuk-summary-list__row">
72+
<dt className="nhsuk-summary-list__key">
73+
Development access token
74+
</dt>
75+
<dd className="nhsuk-summary-list__value">
76+
<pre>
77+
{session?.accessToken || "No access token available"}
78+
</pre>
79+
</dd>
80+
</div>
81+
</>
82+
) : null}
5583
</dl>
5684
</div>
5785
</div>

application/CohortManager/src/Web/app/lib/auth.ts

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import { jwtDecode } from "jwt-decode";
55
import { OAuthConfig } from "next-auth/providers";
66
import { DecodedCIS2Token } from "@/app/types/auth";
77

8+
const isDevelopment = process.env.APP_ENV === "development";
9+
10+
function getDevelopmentIdToken() {
11+
return (
12+
process.env.AUTH_CIS2_DEV_ID_TOKEN ??
13+
process.env.AUTH_CIS2_DEV_JWT_TOKEN ??
14+
process.env.AUTH_CIS2_DEV_JWT
15+
);
16+
}
17+
18+
function getDevelopmentAccessToken() {
19+
return process.env.AUTH_CIS2_DEV_ACCESS_TOKEN ?? getDevelopmentIdToken();
20+
}
21+
822
const NHS_CIS2: OAuthConfig<Profile> = {
923
id: "nhs-cis2",
1024
name: "NHS CIS2 Authentication",
@@ -31,7 +45,7 @@ const NHS_CIS2: OAuthConfig<Profile> = {
3145
export const { handlers, auth, signIn, signOut } = NextAuth({
3246
providers: [
3347
NHS_CIS2,
34-
...(process.env.APP_ENV === "development"
48+
...(isDevelopment
3549
? [
3650
Credentials({
3751
credentials: {
@@ -61,10 +75,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
6175
},
6276
async signIn({ account }) {
6377
// Handle test accounts in development
64-
if (
65-
process.env.APP_ENV === "development" &&
66-
account?.provider === "credentials"
67-
) {
78+
if (isDevelopment && account?.provider === "credentials") {
6879
return true;
6980
}
7081

@@ -88,6 +99,14 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
8899
return isValidToken;
89100
},
90101
async jwt({ account, token, profile }) {
102+
if (typeof account?.access_token === "string") {
103+
token.accessToken = account.access_token;
104+
}
105+
106+
if (typeof account?.id_token === "string") {
107+
token.idToken = account.id_token;
108+
}
109+
91110
if (account?.access_token) {
92111
try {
93112
const response = await fetch(
@@ -111,10 +130,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
111130
}
112131

113132
// Handle test accounts in development
114-
if (
115-
process.env.APP_ENV === "development" &&
116-
account?.provider === "credentials"
117-
) {
133+
if (isDevelopment && account?.provider === "credentials") {
118134
Object.assign(token, {
119135
uid: "testuid",
120136
firstName: "Test",
@@ -123,6 +139,8 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
123139
sid: "5678",
124140
workgroups: ["Test Workgroup"],
125141
workgroups_codes: ["000000000000"],
142+
accessToken: token.accessToken ?? getDevelopmentAccessToken(),
143+
idToken: token.idToken ?? getDevelopmentIdToken(),
126144
});
127145
}
128146

@@ -179,6 +197,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
179197
workgroups_codes,
180198
});
181199
}
200+
201+
session.accessToken =
202+
typeof token.accessToken === "string" ? token.accessToken : undefined;
203+
session.idToken =
204+
typeof token.idToken === "string" ? token.idToken : undefined;
205+
182206
return session;
183207
},
184208
},

application/CohortManager/src/Web/app/lib/fetchExceptions.test.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,20 @@
11
import { fetchExceptions } from "@/app/lib/fetchExceptions";
2+
import { auth } from "@/app/lib/auth";
23
import type { ExceptionsAPI } from "@/app/types/exceptionsApi";
4+
import type { Session } from "next-auth";
5+
6+
jest.mock("@/app/lib/auth", () => ({
7+
auth: jest.fn(),
8+
}));
9+
10+
const mockAuth = auth as unknown as jest.MockedFunction<
11+
() => Promise<Session | null>
12+
>;
313

414
describe("fetchExceptions", () => {
515
beforeEach(() => {
616
jest.resetModules();
17+
mockAuth.mockResolvedValue(null);
718
});
819

920
it("fetches exceptions from the API", async () => {
@@ -60,14 +71,21 @@ describe("fetchExceptions", () => {
6071
RecordUpdatedDate: "",
6172
},
6273
];
63-
global.fetch = jest.fn().mockResolvedValue({
74+
globalThis.fetch = jest.fn().mockResolvedValue({
6475
ok: true,
6576
json: jest.fn().mockResolvedValue(mockResponse),
6677
headers: { get: jest.fn().mockReturnValue(null) },
6778
});
6879

6980
const result = await fetchExceptions();
7081
expect(result.data).toEqual(mockResponse);
82+
expect(globalThis.fetch).toHaveBeenCalledWith(
83+
expect.stringContaining("/api/GetValidationExceptions?"),
84+
expect.objectContaining({
85+
cache: "no-store",
86+
headers: undefined,
87+
})
88+
);
7189
});
7290

7391
it("fetches individual exception details from the API", async () => {
@@ -93,7 +111,7 @@ describe("fetchExceptions", () => {
93111
},
94112
};
95113

96-
global.fetch = jest.fn().mockResolvedValue({
114+
globalThis.fetch = jest.fn().mockResolvedValue({
97115
ok: true,
98116
json: jest.fn().mockResolvedValue(mockResponse),
99117
headers: { get: jest.fn().mockReturnValue(null) },
@@ -104,7 +122,7 @@ describe("fetchExceptions", () => {
104122
});
105123

106124
it("throws an error if the response is not ok", async () => {
107-
global.fetch = jest.fn().mockResolvedValue({
125+
globalThis.fetch = jest.fn().mockResolvedValue({
108126
ok: false,
109127
statusText: "Not found",
110128
});
@@ -113,4 +131,35 @@ describe("fetchExceptions", () => {
113131
"Error fetching data: Not found"
114132
);
115133
});
134+
135+
it("adds the JWT token as an authorization header when available", async () => {
136+
mockAuth.mockResolvedValue({
137+
expires: new Date(Date.now() + 60_000).toISOString(),
138+
idToken: "dev-jwt-token",
139+
user: {
140+
name: "Test User",
141+
email: null,
142+
image: null,
143+
uid: "testuid",
144+
},
145+
});
146+
147+
globalThis.fetch = jest.fn().mockResolvedValue({
148+
ok: true,
149+
json: jest.fn().mockResolvedValue([]),
150+
headers: { get: jest.fn().mockReturnValue(null) },
151+
});
152+
153+
await fetchExceptions();
154+
155+
expect(globalThis.fetch).toHaveBeenCalledWith(
156+
expect.stringContaining("/api/GetValidationExceptions?"),
157+
expect.objectContaining({
158+
cache: "no-store",
159+
headers: {
160+
Authorization: "Bearer dev-jwt-token",
161+
},
162+
})
163+
);
164+
});
116165
});

application/CohortManager/src/Web/app/lib/fetchExceptions.ts

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
"use server";
22

3+
import { auth } from "@/app/lib/auth";
4+
35
type FetchExceptionsParams = {
46
exceptionId?: number;
57
page?: number;
@@ -39,10 +41,26 @@ function buildQueryString(params: FetchExceptionsParams): string {
3941
return searchParams.toString();
4042
}
4143

44+
async function getAuthHeaders() {
45+
const session = await auth();
46+
const bearerToken = session?.idToken ?? session?.accessToken;
47+
48+
if (!bearerToken) {
49+
return undefined;
50+
}
51+
52+
return {
53+
Authorization: `Bearer ${bearerToken}`,
54+
};
55+
}
56+
4257
export async function fetchExceptions(params: FetchExceptionsParams = {}) {
4358
const query = buildQueryString(params);
4459
const apiUrl = `${process.env.EXCEPTIONS_API_URL}/api/GetValidationExceptions?${query}`;
45-
const response = await fetch(apiUrl, { cache: 'no-store' });
60+
const response = await fetch(apiUrl, {
61+
cache: 'no-store',
62+
headers: await getAuthHeaders(),
63+
});
4664

4765
if (!response.ok) {
4866
throw new Error(`Error fetching data: ${response.statusText}`);
@@ -79,7 +97,9 @@ export async function fetchExceptionsByType(
7997
process.env.EXCEPTIONS_API_URL
8098
}/api/GetValidationExceptionsByType?${query.toString()}`;
8199

82-
const response = await fetch(apiUrl);
100+
const response = await fetch(apiUrl, {
101+
headers: await getAuthHeaders(),
102+
});
83103

84104
if (response.status === 204 || response.status === 400 || response.status === 404) {
85105
return {

application/CohortManager/src/Web/app/types/auth/index.d.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import type { DefaultSession } from "next-auth";
2+
13
declare module "next-auth" {
24
interface User {
35
uid: string;
@@ -10,6 +12,28 @@ declare module "next-auth" {
1012
workgroups?: string[];
1113
workgroups_codes?: string[];
1214
}
15+
16+
interface Session {
17+
user?: DefaultSession["user"] & User;
18+
accessToken?: string;
19+
idToken?: string;
20+
}
21+
}
22+
23+
declare module "next-auth/jwt" {
24+
interface JWT {
25+
uid?: string;
26+
firstName?: string;
27+
lastName?: string;
28+
sub?: string;
29+
sid?: string;
30+
odsCode?: string;
31+
orgName?: string;
32+
workgroups?: string[];
33+
workgroups_codes?: string[];
34+
accessToken?: string;
35+
idToken?: string;
36+
}
1337
}
1438

1539
export interface DecodedCIS2Token {

application/CohortManager/src/Web/tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
"moduleResolution": "node",
1717
"resolveJsonModule": true,
1818
"isolatedModules": true,
19-
"jsx": "preserve",
19+
"jsx": "react-jsx",
2020
"incremental": true,
2121
"plugins": [
2222
{

0 commit comments

Comments
 (0)