Skip to content

Commit e6e4abe

Browse files
committed
Update the code to fetch secrets at runtime
1 parent 7845ca9 commit e6e4abe

7 files changed

Lines changed: 481 additions & 34 deletions

File tree

SAMtemplates/functions/main.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -444,8 +444,8 @@ Resources:
444444
Variables:
445445
LOG_LEVEL: !Ref LogLevel
446446
TABLE_NAME: !Ref PrescriptionNotificationStatesTableName
447-
APP_NAME: !ImportValue secrets:PSUNotifyCallbackAppName
448-
API_KEY: !ImportValue secrets:PSUNotifyCallbackApiKey
447+
APP_NAME_SECRET_ARN: !ImportValue secrets:PSUNotifyCallbackAppName
448+
API_KEY_SECRET_ARN: !ImportValue secrets:PSUNotifyCallbackApiKey
449449
Metadata:
450450
BuildMethod: esbuild
451451
guard:

package-lock.json

Lines changed: 388 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
/* eslint-disable no-undef */
22
process.env.TABLE_NAME = "dummy_table";
3-
process.env.APP_NAME = "app name";
4-
process.env.API_KEY = "api key";
3+
process.env.APP_NAME_SECRET_ARN = "app name";
4+
process.env.API_KEY_SECRET_ARN = "api key";

packages/nhsNotifyUpdateCallback/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"@aws-lambda-powertools/commons": "^2.17.0",
1818
"@aws-lambda-powertools/logger": "^2.18.0",
1919
"@aws-lambda-powertools/parameters": "^2.18.0",
20+
"@aws-sdk/client-secrets-manager": "^3.812.0",
2021
"@middy/core": "^6.2.2",
2122
"@middy/input-output-logger": "^6.2.2",
2223
"@nhs/fhir-middy-error-handler": "^2.1.29",

packages/nhsNotifyUpdateCallback/src/helpers.ts

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,11 @@ import {Logger} from "@aws-lambda-powertools/logger"
44
import {DynamoDBClient} from "@aws-sdk/client-dynamodb"
55
import {DynamoDBDocumentClient, UpdateCommand, QueryCommand} from "@aws-sdk/lib-dynamodb"
66

7+
import {SecretsManagerClient, GetSecretValueCommand} from "@aws-sdk/client-secrets-manager"
78
import {createHmac, timingSafeEqual} from "crypto"
89

910
import {MessageStatusResponse} from "./types"
1011

11-
const APP_NAME = process.env.APP_NAME
12-
const API_KEY = process.env.API_KEY
13-
1412
// TTL is one week in seconds
1513
const TTL_DELTA = 60 * 60 * 24 * 7
1614

@@ -19,25 +17,66 @@ const dynamoTable = process.env.TABLE_NAME
1917
const dynamo = new DynamoDBClient({region: process.env.AWS_REGION})
2018
const docClient = DynamoDBDocumentClient.from(dynamo)
2119

20+
// Do a bit of secret caching to help reduce the number of fetches.
21+
let cachedAppName: string | undefined
22+
let cachedApiKey: string | undefined
23+
24+
const secretsClient = new SecretsManagerClient({
25+
region: process.env.AWS_REGION
26+
})
27+
2228
export function response(statusCode: number, body: unknown = {}) {
2329
return {
2430
statusCode,
2531
body: JSON.stringify(body)
2632
}
2733
}
2834

35+
async function getSecretValue(secretArn: string): Promise<string> {
36+
const cmd = new GetSecretValueCommand({SecretId: secretArn})
37+
const resp = await secretsClient.send(cmd)
38+
39+
if (resp.SecretString) {
40+
return resp.SecretString
41+
}
42+
43+
throw new Error(`Secret ${secretArn} has no usable SecretString`)
44+
}
45+
46+
/**
47+
* Loads both APP_NAME and API_KEY from Secrets Manager, if not already cached.
48+
* I'm loading these at runtime so that we can update the secret and have that change
49+
* reflected without the need for a full redeployment.
50+
*/
51+
async function loadSecrets() {
52+
if (cachedAppName && cachedApiKey) return
53+
54+
const appNameArn = process.env.APP_NAME_SECRET_ARN
55+
const apiKeyArn = process.env.API_KEY_SECRET_ARN
56+
57+
if (!appNameArn) {
58+
throw new Error("APP_NAME_SECRET_ARN environment variable is not set.")
59+
}
60+
if (!apiKeyArn) {
61+
throw new Error("API_KEY_SECRET_ARN environment variable is not set.")
62+
}
63+
64+
const [nameValue, keyValue] = await Promise.all([
65+
getSecretValue(appNameArn),
66+
getSecretValue(apiKeyArn)
67+
])
68+
69+
cachedAppName = nameValue.trim()
70+
cachedApiKey = keyValue.trim()
71+
}
72+
2973
/**
3074
* Checks the incoming NHS Notify request signature.
3175
* If it's okay, returns undefined.
3276
* If it's not okay, it returns the error response object.
3377
*/
34-
export function checkSignature(logger: Logger, event: APIGatewayProxyEvent) {
35-
if (!APP_NAME) {
36-
throw new Error("APP_NAME environment variable is not set.")
37-
}
38-
if (!API_KEY) {
39-
throw new Error("API_KEY environment variable is not set.")
40-
}
78+
export async function checkSignature(logger: Logger, event: APIGatewayProxyEvent) {
79+
await loadSecrets()
4180

4281
const signature = event.headers["x-hmac-sha256-signature"]
4382
if (!signature) {
@@ -52,10 +91,10 @@ export function checkSignature(logger: Logger, event: APIGatewayProxyEvent) {
5291
}
5392

5493
// FIXME: Delete this line before PR
55-
logger.info("Secret data", {APP_NAME, API_KEY})
94+
logger.info("Secret data", {cachedAppName, cachedApiKey})
5695

5796
// Compute the HMAC-SHA256 hash of the combination of the request body and the secret value
58-
const secretValue = `${APP_NAME}.${API_KEY}`
97+
const secretValue = `${cachedAppName!}.${cachedApiKey!}`
5998
const payload = event.body ?? ""
6099

61100
// compare hashes as Buffers, rather than hex

packages/nhsNotifyUpdateCallback/src/lambdaHandler.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const lambdaHandler = async (event: APIGatewayProxyEvent): Promise<APIGatewayPro
2626
if (!event.headers["x-request-id"]) return response(400, {message: "No x-request-id given"})
2727

2828
// Check the request signature
29-
const isErr = checkSignature(logger, event)
29+
const isErr = await checkSignature(logger, event)
3030
if (isErr) return isErr
3131
logger.info("Signature OK!")
3232

packages/nhsNotifyUpdateCallback/tests/testHelpers.test.ts

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from "@jest/globals"
99
import {createHmac} from "crypto"
1010
import {DynamoDBDocumentClient, QueryCommand, UpdateCommand} from "@aws-sdk/lib-dynamodb"
11+
import {SecretsManagerClient} from "@aws-sdk/client-secrets-manager"
1112

1213
import {response, checkSignature, updateNotificationsTable} from "../src/helpers"
1314
import {Logger} from "@aws-lambda-powertools/logger"
@@ -44,8 +45,18 @@ describe("helpers.ts", () => {
4445

4546
describe("checkSignature()", () => {
4647
let logger: Logger
47-
let validHeaders: { "x-request-id": string; "x-api-key": string; "x-hmac-sha256-signature": string }
48+
let validHeaders: Record<string, string>
49+
let smSendSpy: jest.SpiedFunction<typeof SecretsManagerClient.prototype.send>
50+
4851
beforeEach(() => {
52+
// Stub SecretsManagerClient.send so we never call AWS in tests
53+
smSendSpy = jest
54+
.spyOn(SecretsManagerClient.prototype, "send")
55+
// first call: APP_NAME
56+
.mockImplementationOnce(() => Promise.resolve({SecretString: process.env.APP_NAME_SECRET_ARN!}))
57+
// second call: API_KEY
58+
.mockImplementationOnce(() => Promise.resolve({SecretString: process.env.API_KEY_SECRET_ARN!}))
59+
4960
logger = new Logger({serviceName: "nhsNotifyUpdateCallback"})
5061
validHeaders = {
5162
"x-request-id": "requestid",
@@ -54,40 +65,48 @@ describe("helpers.ts", () => {
5465
}
5566
})
5667

57-
it("401 when missing signature header", () => {
58-
const ev = generateMockEvent("{}", {"x-api-key": "foobar", "x-request-id": "rid"})
59-
const resp = checkSignature(logger, ev)
68+
afterEach(() => {
69+
smSendSpy.mockRestore()
70+
})
71+
72+
it("401 when missing signature header", async () => {
73+
const ev = generateMockEvent("{}", {
74+
"x-api-key": "foobar",
75+
"x-request-id": "rid"
76+
})
77+
const resp = await checkSignature(logger, ev)
6078
expect(resp).toEqual({
6179
statusCode: 401,
6280
body: JSON.stringify({message: "No x-hmac-sha256-signature given"})
6381
})
6482
})
6583

66-
it("401 when missing API key header", () => {
67-
const ev = generateMockEvent("{}", {"x-hmac-sha256-signature": "foobar", "x-request-id": "rid"})
68-
const resp = checkSignature(logger, ev)
69-
84+
it("401 when missing API key header", async () => {
85+
const ev = generateMockEvent("{}", {
86+
"x-hmac-sha256-signature": "foobar",
87+
"x-request-id": "rid"
88+
})
89+
const resp = await checkSignature(logger, ev)
7090
expect(resp).toEqual({
7191
statusCode: 401,
7292
body: JSON.stringify({message: "No x-api-key header given"})
7393
})
7494
})
7595

76-
it("403 when signature hex is malformed", () => {
96+
it("403 when signature hex is malformed", async () => {
7797
const headers = {
7898
...validHeaders,
7999
"x-hmac-sha256-signature": "not a hex string!@!#zzz"
80100
}
81101
const ev = generateMockEvent(JSON.stringify({message: "blah blah blah"}), headers)
82-
const resp = checkSignature(logger, ev)
83-
102+
const resp = await checkSignature(logger, ev)
84103
expect(resp).toEqual({
85104
statusCode: 403,
86105
body: JSON.stringify({message: "Incorrect signature"})
87106
})
88107
})
89108

90-
it("403 when signature does not match HMAC", () => {
109+
it("403 when signature does not match HMAC", async () => {
91110
const payload = "payload"
92111
const wrongSig = createHmac(
93112
"sha256",
@@ -100,17 +119,16 @@ describe("helpers.ts", () => {
100119
...validHeaders,
101120
"x-hmac-sha256-signature": wrongSig
102121
})
103-
const resp = checkSignature(logger, ev)
104-
122+
const resp = await checkSignature(logger, ev)
105123
expect(resp).toEqual({
106124
statusCode: 403,
107125
body: JSON.stringify({message: "Incorrect signature"})
108126
})
109127
})
110128

111-
it("returns undefined when signature is valid", () => {
129+
it("returns undefined when signature is valid", async () => {
112130
const payload = "hi there"
113-
const secret = `${process.env.APP_NAME}.${process.env.API_KEY}`
131+
const secret = `${process.env.APP_NAME_SECRET_ARN}.${process.env.API_KEY_SECRET_ARN}`
114132
const goodSig = createHmac("sha256", secret)
115133
.update(payload, "utf8")
116134
.digest("hex")
@@ -119,13 +137,14 @@ describe("helpers.ts", () => {
119137
...validHeaders,
120138
"x-hmac-sha256-signature": goodSig
121139
})
122-
const resp = checkSignature(logger, ev)
140+
const resp = await checkSignature(logger, ev)
123141
expect(resp).toBeUndefined()
124142
})
125143
})
126144

127145
describe("updateNotificationsTable()", () => {
128146
let logger: Logger
147+
129148
beforeEach(() => {
130149
logger = new Logger({serviceName: "nhsNotifyUpdateCallback"})
131150
jest.spyOn(logger, "error")

0 commit comments

Comments
 (0)