Skip to content

Commit 00ea227

Browse files
committed
Add certificate expiry check
1 parent ead65de commit 00ea227

File tree

8 files changed

+1057
-193
lines changed

8 files changed

+1057
-193
lines changed

infrastructure/terraform/components/api/module_authorizer_lambda.tf

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,24 @@ module "authorizer_lambda" {
3030
send_to_firehose = true
3131
log_destination_arn = local.destination_arn
3232
log_subscription_role_arn = local.acct.log_subscription_role_arn
33+
34+
lambda_env_vars = {
35+
CLOUDWATCH_NAMESPACE = "/aws/api-gateway/supplier/alarms"
36+
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS = 14
37+
}
38+
}
39+
40+
data "aws_iam_policy_document" "authorizer_lambda" {
41+
statement {
42+
sid = "AllowPutMetricData"
43+
effect = "Allow"
44+
45+
actions = [
46+
"cloudwatch:PutMetricData"
47+
]
48+
49+
resources = [
50+
"*"
51+
]
52+
}
3353
}

lambdas/authorizer/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
{
22
"dependencies": {
3-
"esbuild": "^0.25.11"
3+
"@aws-sdk/client-cloudwatch": "^3.922.0",
4+
"esbuild": "^0.25.11",
5+
"pino": "^10.1.0",
6+
"zod": "^4.1.12"
47
},
58
"devDependencies": {
69
"@tsconfig/node22": "^22.0.2",

lambdas/authorizer/src/__tests__/index.test.ts

Lines changed: 104 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,20 @@
1-
import { APIGatewayRequestAuthorizerEvent, Callback, Context } from 'aws-lambda';
2-
import { handler } from '../index';
1+
import { APIGatewayEventClientCertificate, APIGatewayRequestAuthorizerEvent, Callback, Context } from 'aws-lambda';
2+
import { Deps } from '../deps';
3+
import pino from 'pino';
4+
import { EnvVars } from '../env';
5+
import { createAuthorizerHandler } from '../authorizer';
6+
7+
const mockedDeps: jest.Mocked<Deps> = {
8+
logger: { info: jest.fn(), error: jest.fn() } as unknown as pino.Logger,
9+
env: {
10+
CLOUDWATCH_NAMESPACE: 'cloudwatch-namespace',
11+
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS: 14
12+
} as unknown as EnvVars,
13+
cloudWatchClient: {
14+
send: jest.fn().mockResolvedValue({}),
15+
} as any,
16+
} as Deps;
17+
318

419
describe('Authorizer Lambda Function', () => {
520
let mockEvent: APIGatewayRequestAuthorizerEvent;
@@ -11,17 +26,20 @@ describe('Authorizer Lambda Function', () => {
1126
type: 'REQUEST',
1227
methodArn: 'arn:aws:execute-api:region:account-id:api-id/stage/GET/resource',
1328
headers: {},
14-
pathParameters: {}
29+
pathParameters: {},
30+
requestContext: {identity: {clientCert: null}},
1531
} as APIGatewayRequestAuthorizerEvent;
1632

1733
mockContext = {} as Context;
1834
mockCallback = jest.fn();
1935
});
2036

21-
it('Should allow access when headers match', () => {
37+
it('Should allow access when headers match', async() => {
2238
mockEvent.headers = { headerauth1: 'headervalue1' };
2339

40+
const handler = createAuthorizerHandler(mockedDeps);
2441
handler(mockEvent, mockContext, mockCallback);
42+
await new Promise(process.nextTick);
2543

2644
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
2745
policyDocument: expect.objectContaining({
@@ -34,10 +52,12 @@ describe('Authorizer Lambda Function', () => {
3452
}));
3553
});
3654

37-
it('Should deny access when headers do not match', () => {
55+
it('Should deny access when headers do not match', async() => {
3856
mockEvent.headers = { headerauth1: 'wrongValue' };
3957

58+
const handler = createAuthorizerHandler(mockedDeps);
4059
handler(mockEvent, mockContext, mockCallback);
60+
await new Promise(process.nextTick);
4161

4262
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
4363
policyDocument: expect.objectContaining({
@@ -50,10 +70,12 @@ describe('Authorizer Lambda Function', () => {
5070
}));
5171
});
5272

53-
it('Should handle null headers gracefully', () => {
73+
it('Should handle null headers gracefully', async() => {
5474
mockEvent.headers = null;
5575

76+
const handler = createAuthorizerHandler(mockedDeps);
5677
handler(mockEvent, mockContext, mockCallback);
78+
await new Promise(process.nextTick);
5779

5880
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
5981
policyDocument: expect.objectContaining({
@@ -66,10 +88,12 @@ describe('Authorizer Lambda Function', () => {
6688
}));
6789
});
6890

69-
it('Should handle defined headers correctly', () => {
91+
it('Should handle defined headers correctly', async() => {
7092
mockEvent.headers = { headerauth1: 'headervalue1' };
7193

94+
const handler = createAuthorizerHandler(mockedDeps);
7295
handler(mockEvent, mockContext, mockCallback);
96+
await new Promise(process.nextTick);
7397

7498
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
7599
policyDocument: expect.objectContaining({
@@ -82,14 +106,16 @@ describe('Authorizer Lambda Function', () => {
82106
}));
83107
});
84108

85-
it('Should handle additional headers correctly', () => {
109+
it('Should handle additional headers correctly', async() => {
86110
mockEvent.headers = {
87111
headerauth1: 'headervalue1' ,
88112
otherheader1: 'headervalue2',
89113
otherheader2: 'headervalue3'
90114
};
91115

116+
const handler = createAuthorizerHandler(mockedDeps);
92117
handler(mockEvent, mockContext, mockCallback);
118+
await new Promise(process.nextTick);
93119

94120
expect(mockCallback).toHaveBeenCalledWith(null, expect.objectContaining({
95121
policyDocument: expect.objectContaining({
@@ -101,4 +127,74 @@ describe('Authorizer Lambda Function', () => {
101127
}),
102128
}));
103129
});
130+
131+
describe('Certificate expiry check', () => {
132+
133+
beforeEach(() => {
134+
jest.useFakeTimers({ doNotFake: ['nextTick'] })
135+
.setSystemTime(new Date('2025-11-03T14:19:00Z'));
136+
});
137+
138+
afterEach(() => {
139+
jest.useRealTimers();
140+
})
141+
142+
it('Should not send CloudWatch metric when certificate is null', async () => {
143+
mockEvent.headers = { headerauth1: 'headervalue1' };
144+
mockEvent.requestContext.identity.clientCert = null;
145+
146+
const handler = createAuthorizerHandler(mockedDeps);
147+
handler(mockEvent, mockContext, mockCallback);
148+
await new Promise(process.nextTick);
149+
150+
expect(mockedDeps.cloudWatchClient.send).not.toHaveBeenCalled();
151+
});
152+
153+
it('Should send CloudWatch metric when the certificate expiry threshold is reached', async () => {
154+
mockEvent.headers = { headerauth1: 'headervalue1' };
155+
mockEvent.requestContext.identity.clientCert = buildCertWithExpiry('2025-11-17T14:19:00Z');
156+
157+
const handler = createAuthorizerHandler(mockedDeps);
158+
handler(mockEvent, mockContext, mockCallback);
159+
await new Promise(process.nextTick);
160+
161+
expect(mockedDeps.cloudWatchClient.send).toHaveBeenCalledWith(
162+
expect.objectContaining({
163+
input: {
164+
Namespace: 'cloudwatch-namespace',
165+
MetricData: [
166+
{
167+
MetricName: 'apim-client-certificate-near-expiry',
168+
Dimensions: [
169+
{ Name: 'SUBJECT_DN', Value: 'CN=test-subject' },
170+
{ Name: 'NOT_AFTER', Value: '2025-11-17T14:19:00Z' },
171+
],
172+
},
173+
],
174+
},
175+
})
176+
);
177+
});
178+
179+
it('Should not send CloudWatch metric when the certificate expiry threshold is not yet reached', async () => {
180+
mockEvent.headers = { headerauth1: 'headervalue1' };
181+
mockEvent.requestContext.identity.clientCert = buildCertWithExpiry('2025-11-18T14:19:00Z');
182+
183+
const handler = createAuthorizerHandler(mockedDeps);
184+
handler(mockEvent, mockContext, mockCallback);
185+
await new Promise(process.nextTick);
186+
187+
expect(mockedDeps.cloudWatchClient.send).not.toHaveBeenCalled();
188+
});
189+
});
190+
191+
function buildCertWithExpiry(expiry: string): APIGatewayEventClientCertificate {
192+
193+
return {
194+
subjectDN: 'CN=test-subject',
195+
validity: {
196+
notAfter: expiry,
197+
} as APIGatewayEventClientCertificate['validity'],
198+
} as APIGatewayEventClientCertificate;
199+
}
104200
});
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// A simple request-based authorizer example to demonstrate how to use request
2+
// parameters to allow or deny a request. In this example, a request is
3+
// authorized if the client-supplied HeaderAuth1 header and stage variable of StageVar1
4+
// both match specified values of 'headerValue1' and 'stageValue1', respectively.
5+
//
6+
// Example curl request (replace <api-url> and <stage> as appropriate):
7+
//
8+
// curl -H "HeaderAuth1: headerValue1" \
9+
// "<api-url>/<stage>/your-resource"
10+
//
11+
12+
// See https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-use-lambda-authorizer.html for the original JS documentation
13+
14+
import {APIGatewayAuthorizerResult, APIGatewayEventClientCertificate, APIGatewayRequestAuthorizerEvent, APIGatewayRequestAuthorizerHandler,
15+
Callback, Context } from 'aws-lambda';
16+
import { Deps } from './deps';
17+
import { PutMetricDataCommand } from '@aws-sdk/client-cloudwatch';
18+
19+
export function createAuthorizerHandler(deps: Deps): APIGatewayRequestAuthorizerHandler {
20+
21+
return (
22+
event: APIGatewayRequestAuthorizerEvent,
23+
context: Context,
24+
callback: Callback<APIGatewayAuthorizerResult>
25+
): void => {
26+
deps.logger.info(event, 'Received event');
27+
28+
const headers = event.headers || {};
29+
30+
checkCertificateExpiry(event.requestContext.identity.clientCert, deps).then(() => {
31+
// Perform authorization to return the Allow policy for correct parameters and
32+
// the 'Unauthorized' error, otherwise.
33+
if (
34+
headers['headerauth1'] === 'headervalue1'
35+
) {
36+
deps.logger.info('Allow event');
37+
callback(null, generateAllow('me', event.methodArn));
38+
} else {
39+
deps.logger.info('Deny event');
40+
callback(null, generateDeny('me', event.methodArn));
41+
}
42+
});
43+
};
44+
}
45+
46+
47+
// Helper function to generate an IAM policy
48+
function generatePolicy(
49+
principalId: string,
50+
effect: 'Allow' | 'Deny',
51+
resource: string
52+
): APIGatewayAuthorizerResult {
53+
// Required output:
54+
const authResponse: APIGatewayAuthorizerResult = {
55+
principalId,
56+
policyDocument: {
57+
Version: '2012-10-17',
58+
Statement: [
59+
{
60+
Action: 'execute-api:Invoke',
61+
Effect: effect,
62+
Resource: resource,
63+
},
64+
],
65+
},
66+
context: {
67+
stringKey: 'stringval',
68+
numberKey: 123,
69+
booleanKey: true,
70+
},
71+
};
72+
return authResponse;
73+
}
74+
75+
function generateAllow(principalId: string, resource: string): APIGatewayAuthorizerResult {
76+
return generatePolicy(principalId, 'Allow', resource);
77+
}
78+
79+
function generateDeny(principalId: string, resource: string): APIGatewayAuthorizerResult {
80+
return generatePolicy(principalId, 'Deny', resource);
81+
}
82+
83+
function getCertificateExpiryInDays(certificate: APIGatewayEventClientCertificate): number {
84+
const now = new Date().getTime();
85+
const expiry = new Date(certificate.validity.notAfter).getTime();
86+
return (expiry - now) / (1000 * 60 * 60 * 24);
87+
}
88+
89+
async function checkCertificateExpiry(certificate: APIGatewayEventClientCertificate | null, deps: Deps): Promise<void> {
90+
deps.logger.info({
91+
description: 'Client certificate details',
92+
issuerDN: certificate?.issuerDN,
93+
subjectDN: certificate?.subjectDN,
94+
validity: certificate?.validity,
95+
});
96+
97+
if (!certificate) {
98+
// In a real production environment, we won't have got this far if there wasn't a cert
99+
return;
100+
}
101+
102+
const expiry = getCertificateExpiryInDays(certificate);
103+
104+
if (expiry <= deps.env.CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS) {
105+
const { subjectDN, validity } = certificate;
106+
107+
deps.logger.info({
108+
description: 'Client certificate near expiry',
109+
certificateExpiry: validity.notAfter,
110+
subjectDN,
111+
});
112+
await deps.cloudWatchClient.send(buildCloudWatchCommand(deps.env.CLOUDWATCH_NAMESPACE, certificate));
113+
}
114+
115+
function buildCloudWatchCommand(namespace: string, certificate: APIGatewayEventClientCertificate): PutMetricDataCommand {
116+
return new PutMetricDataCommand({
117+
MetricData: [{
118+
MetricName: 'apim-client-certificate-near-expiry',
119+
Dimensions: [
120+
{Name: 'SUBJECT_DN', Value: certificate.subjectDN},
121+
{Name: 'NOT_AFTER', Value: certificate.validity.notAfter}
122+
]
123+
}],
124+
Namespace: namespace
125+
});
126+
}
127+
};

lambdas/authorizer/src/deps.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { CloudWatchClient } from "@aws-sdk/client-cloudwatch";
2+
import pino from 'pino';
3+
import { envVars, EnvVars } from "./env";
4+
5+
export type Deps = {
6+
cloudWatchClient: CloudWatchClient;
7+
logger: pino.Logger;
8+
env: EnvVars;
9+
};
10+
11+
export function createDependenciesContainer(): Deps {
12+
const log = pino();
13+
14+
return {
15+
cloudWatchClient: new CloudWatchClient({}),
16+
logger: log,
17+
env: envVars
18+
};
19+
}

lambdas/authorizer/src/env.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import {z} from 'zod';
2+
3+
const EnvVarsSchema = z.object({
4+
CLOUDWATCH_NAMESPACE: z.string(),
5+
CLIENT_CERTIFICATE_EXPIRATION_ALERT_DAYS: z.coerce.number().int()
6+
});
7+
8+
export type EnvVars = z.infer<typeof EnvVarsSchema>;
9+
10+
export const envVars = EnvVarsSchema.parse(process.env);

0 commit comments

Comments
 (0)