Skip to content

Commit 420bcea

Browse files
authored
Merge pull request #869 from govuk-one-login/OLH-4194-move-required-claims-check
OLH-4194: move required claims check
2 parents e25cfa3 + 31277bf commit 420bcea

6 files changed

Lines changed: 95 additions & 58 deletions

File tree

solutions/frontend/open-api-spec.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ openapi: 3.0.3
33

44
info:
55
title: Account Management Components OAuth Provider Public API
6-
version: 0.0.14
6+
version: 0.0.15
77
description: |
88
OAuth 2.0 provider endpoints, including:
99
- GET /authorize
@@ -96,6 +96,8 @@ paths:
9696
value: https://signin.account.gov.uk/callback?error=invalid_request&error_description=E1011&state=aRandomString
9797
user_agent_mismatch:
9898
value: https://signin.account.gov.uk/callback?error=invalid_request&error_description=E1012&state=aRandomString
99+
required_claims_missing:
100+
value: https://signin.account.gov.uk/callback?error=invalid_request&error_description=E1013&state=aRandomString
99101
jwks_timeout:
100102
value: https://signin.account.gov.uk/callback?error=unauthorized_client&error_description=E2001&state=aRandomString
101103
jwks_invalid:

solutions/frontend/src/handlers/authorize/utils/verifyJwt.test.ts

Lines changed: 64 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,18 @@ vi.mock(import("../../../utils/getClaimsSchema.js"), () => ({
6464
getClaimsSchema: mockGetClaimsSchema,
6565
}));
6666

67+
const mockJourneys = vi.fn();
68+
69+
// @ts-expect-error
70+
vi.mock(import("../../../journeys/utils/config.js"), () => ({
71+
journeys: new Proxy(
72+
{},
73+
{
74+
get: () => mockJourneys,
75+
},
76+
),
77+
}));
78+
6779
let verifyJwt: typeof verifyJwtForType;
6880

6981
describe("verifyJwt", () => {
@@ -102,11 +114,16 @@ describe("verifyJwt", () => {
102114
});
103115

104116
it("verifies JWT with ES256 algorithm", async () => {
105-
const payload = { sub: "user123", aud: "test-client" };
106-
const claimsSchema = v.object({ sub: v.string(), aud: v.string() });
117+
const payload = { sub: "user123", aud: "test-client", scope: "openid" };
118+
const claimsSchema = v.object({
119+
sub: v.string(),
120+
aud: v.string(),
121+
scope: v.string(),
122+
});
107123

108124
mockJwtVerify.mockResolvedValue({ payload });
109125
mockGetClaimsSchema.mockReturnValue(claimsSchema);
126+
mockJourneys.mockResolvedValue({ requiredClaims: [] });
110127

111128
const result = await verifyJwt(
112129
reply,
@@ -441,12 +458,55 @@ describe("verifyJwt", () => {
441458
);
442459
});
443460

461+
it("returns ErrorResponse when required claims are missing", async () => {
462+
const payload = { sub: "user123", aud: "test-client", scope: "openid" };
463+
const claimsSchema = v.object({
464+
sub: v.string(),
465+
aud: v.string(),
466+
scope: v.string(),
467+
});
468+
469+
mockJwtVerify.mockResolvedValue({ payload });
470+
mockGetClaimsSchema.mockReturnValue(claimsSchema);
471+
mockJourneys.mockResolvedValue({
472+
requiredClaims: ["account_management_api_access_token"],
473+
});
474+
475+
const result = await verifyJwt(
476+
reply,
477+
signedJwt,
478+
client,
479+
redirectUri,
480+
scope,
481+
state,
482+
);
483+
484+
expect(result).toBeInstanceOf(ErrorResponse);
485+
486+
assert.ok(result instanceof ErrorResponse);
487+
488+
expect(vi.mocked(result.reply.redirect).mock.calls[0]?.[0]).toContain(
489+
"error=invalid_request",
490+
);
491+
expect(vi.mocked(result.reply.redirect).mock.calls[0]?.[0]).toContain(
492+
"error_description=E1013",
493+
);
494+
expect(addAuthorizeErrorMetric).toHaveBeenCalledWith(
495+
"RequiredClaimsMissing",
496+
);
497+
});
498+
444499
it("works without state parameter", async () => {
445-
const payload = { sub: "user123", aud: "test-client" };
446-
const claimsSchema = v.object({ sub: v.string(), aud: v.string() });
500+
const payload = { sub: "user123", aud: "test-client", scope: "openid" };
501+
const claimsSchema = v.object({
502+
sub: v.string(),
503+
aud: v.string(),
504+
scope: v.string(),
505+
});
447506

448507
mockJwtVerify.mockResolvedValue({ payload });
449508
mockGetClaimsSchema.mockReturnValue(claimsSchema);
509+
mockJourneys.mockResolvedValue({ requiredClaims: [] });
450510

451511
const result = await verifyJwt(
452512
reply,

solutions/frontend/src/handlers/authorize/utils/verifyJwt.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import assert from "node:assert";
1717
import { getAppConfig } from "../../../../../commons/utils/getAppConfig/index.js";
1818
import type { FastifyReply } from "fastify";
1919
import { jwtVerifyAlgorithms } from "../../../../../commons/utils/constants.js";
20+
import { journeys } from "../../../journeys/utils/config.js";
2021

2122
const handleJwtError = (
2223
reply: FastifyReply,
@@ -291,6 +292,29 @@ const checkClaims = async (
291292
);
292293
}
293294

295+
const journey = await journeys[claimsResult.output.scope]();
296+
297+
const missingRequiredClaims = journey.requiredClaims.filter((claim) => {
298+
return claimsResult.output[claim] === undefined;
299+
});
300+
301+
if (missingRequiredClaims.length) {
302+
logger.warn("RequiredClaimsMissing", {
303+
client_id: client.client_id,
304+
missing_required_claims: missingRequiredClaims,
305+
});
306+
addAuthorizeErrorMetric("RequiredClaimsMissing");
307+
308+
return new ErrorResponse(
309+
getRedirectToClientRedirectUriResponse(
310+
reply,
311+
redirectUri,
312+
authorizeErrors.requiredClaimsMissing,
313+
state,
314+
),
315+
);
316+
}
317+
294318
return claimsResult.output;
295319
};
296320

solutions/frontend/src/journeys/utils/onRequest.test.ts

Lines changed: 0 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,6 @@ vi.mock(import("./config.js"), () => ({
6363
stateMachine: {
6464
resolveState: vi.fn().mockReturnValue({}),
6565
},
66-
requiredClaims: [],
6766
}),
6867
},
6968
}));
@@ -147,7 +146,6 @@ describe("onRequest", () => {
147146
vi.mocked(journeys["test-scope" as Scope]).mockResolvedValue({
148147
translations: { en: { key: "value" } },
149148
stateMachine: { resolveState: vi.fn().mockReturnValue({}) },
150-
requiredClaims: [],
151149
} as any);
152150
});
153151

@@ -211,40 +209,6 @@ describe("onRequest", () => {
211209
});
212210
});
213211

214-
describe("when required claims are missing", () => {
215-
beforeEach(() => {
216-
mockSession.claims = {
217-
client_id: "test-client-id",
218-
scope: "test-scope",
219-
} as unknown as Claims;
220-
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
221-
vi.mocked(journeys["test-scope" as Scope]).mockResolvedValue({
222-
translations: { en: { key: "value" } },
223-
stateMachine: { resolveState: vi.fn().mockReturnValue({}) },
224-
requiredClaims: ["account_management_api_access_token"],
225-
} as any);
226-
});
227-
228-
it("should redirect to error page and log warning", async () => {
229-
await onRequest(mockRequest as FastifyRequest, mockReply as FastifyReply);
230-
231-
expect(mockRequest.log?.warn).toHaveBeenCalledWith(
232-
{ missingRequiredClaims: ["account_management_api_access_token"] },
233-
"RequiredClaimsMissing",
234-
);
235-
expect(metrics.addMetadata).toHaveBeenCalledWith(
236-
"error_type",
237-
"RequiredClaimsMissing",
238-
);
239-
expect(metrics.addMetric).toHaveBeenCalledWith(
240-
"JourneyRequestError",
241-
"Count",
242-
1,
243-
);
244-
expect(mockReply.redirect).toHaveBeenCalledWith("/error");
245-
});
246-
});
247-
248212
describe("successful flow", () => {
249213
beforeEach(() => {
250214
mockSession.claims = {

solutions/frontend/src/journeys/utils/onRequest.ts

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -85,23 +85,6 @@ export const onRequest = async (
8585

8686
const journey = await journeys[claims.scope]();
8787

88-
const missingRequiredClaims = journey.requiredClaims.filter((claim) => {
89-
return claims[claim] === undefined;
90-
});
91-
92-
if (missingRequiredClaims.length) {
93-
request.log.warn(
94-
{
95-
missingRequiredClaims,
96-
},
97-
"RequiredClaimsMissing",
98-
);
99-
addErrorMetric("RequiredClaimsMissing");
100-
101-
reply.redirect(paths.others.authorizeError.path);
102-
return reply;
103-
}
104-
10588
Object.entries(journey.translations).forEach(([lang, translations]) => {
10689
request.i18n.addResourceBundle(lang, "journey", translations);
10790
});

solutions/frontend/src/utils/authorizeErrors.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const authorizeErrors = {
6060
description: "E1012",
6161
type: "invalid_request",
6262
},
63+
requiredClaimsMissing: {
64+
description: "E1013",
65+
type: "invalid_request",
66+
},
6367
jwksTimeout: {
6468
description: "E2001",
6569
type: "unauthorized_client",

0 commit comments

Comments
 (0)