Skip to content

Commit 38f5ff5

Browse files
authored
Chore: [AEA-5803] - cli to debug Notify token exchange (#2441)
## Summary - 🤖 Operational or Infrastructure Change ### Details Refactor token exchange to permit a local cli to debug config issues.
1 parent 94e6295 commit 38f5ff5

File tree

15 files changed

+547
-89
lines changed

15 files changed

+547
-89
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
**/node_modules/
88
.#*
99
__pycache__/
10+
.env
1011
.envrc
1112
.idea
1213
.vscode/settings.json
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# NHS Notify Token Exchange CLI
2+
3+
CLI tool for testing the NHS Notify OAuth2 token exchange flow.
4+
5+
## Usage
6+
7+
```bash
8+
cd packages/nhsNotifyLambda
9+
npm run cli:test-token
10+
```
11+
12+
## Required Environment Variables
13+
14+
Set these before running the CLI:
15+
16+
```bash
17+
export NHS_NOTIFY_HOST="https://int.api.service.nhs.uk"
18+
export NHS_NOTIFY_API_KEY="your-api-key-here"
19+
export NHS_NOTIFY_PRIVATE_KEY="-----BEGIN ...
20+
... key contents ...
21+
... -----"
22+
export NHS_NOTIFY_KID="your-key-id-here"
23+
```
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env node
2+
3+
/**
4+
* CLI tool to test NHS Notify token exchange
5+
*
6+
* Usage:
7+
* npm run cli:test-token
8+
*
9+
* Required environment variables:
10+
* NOTIFY_API_BASE_URL - e.g., https://int.api.service.nhs.uk
11+
* NOTIFY_API_KEY - API key for NHS Notify
12+
* NOTIFY_PRIVATE_KEY - RSA private key (PEM format)
13+
* NOTIFY_KID - Key ID
14+
*/
15+
16+
import {Logger} from "@aws-lambda-powertools/logger"
17+
import axios from "axios"
18+
import axiosRetry from "axios-retry"
19+
import {tokenExchange} from "../src/utils/auth.js"
20+
import {NotifySecrets} from "../src/utils/secrets.js"
21+
22+
const logger = initLogger()
23+
const {host} = loadNotifyConfig(logger)
24+
const notifySecrets = loadNotifySecrets(logger)
25+
const axiosInstance = initAxiosInst(host)
26+
27+
logger.info("Testing token exchange", {
28+
host,
29+
apiKeyPrefix: notifySecrets.apiKey.substring(0, 10) + "...",
30+
kid: notifySecrets.kid
31+
})
32+
33+
try {
34+
// Perform token exchange
35+
const accessToken = await tokenExchange(
36+
logger,
37+
axiosInstance,
38+
host,
39+
notifySecrets
40+
)
41+
42+
logger.info("Token exchange successful!", {
43+
tokenPrefix: accessToken.substring(0, 20) + "...",
44+
tokenLength: accessToken.length
45+
})
46+
console.log("\n✅ SUCCESS!")
47+
48+
process.exit(0)
49+
50+
} catch (error) {
51+
logger.error("Token exchange failed", {error})
52+
console.error("\n❌ FAILED!")
53+
console.error(`\nError: ${error instanceof Error ? error.message : String(error)}\n`)
54+
process.exit(1)
55+
}
56+
57+
function initAxiosInst(host: string) {
58+
const axiosInstance = axios.create({
59+
baseURL: host,
60+
timeout: 30000
61+
})
62+
63+
// Add retry logic
64+
axiosRetry(axiosInstance, {
65+
retries: 2,
66+
retryDelay: axiosRetry.exponentialDelay,
67+
retryCondition: (error) => {
68+
return axiosRetry.isNetworkOrIdempotentRequestError(error)
69+
|| error.response?.status === 429
70+
|| error.response?.status === 500
71+
}
72+
})
73+
return axiosInstance
74+
}
75+
76+
function initLogger() {
77+
return new Logger({
78+
serviceName: "token-exchange-cli",
79+
logLevel: "INFO"
80+
})
81+
}
82+
83+
function loadNotifyConfig(logger: Logger): {host: string} {
84+
const host = process.env.NOTIFY_API_BASE_URL
85+
if (!host) {
86+
logger.error("Missing required environment variable: NOTIFY_API_BASE_URL")
87+
process.exit(1)
88+
}
89+
return {host}
90+
}
91+
92+
function loadNotifySecrets(logger: Logger): NotifySecrets {
93+
const apiKey = process.env.NOTIFY_API_KEY
94+
const privateKey = process.env.NOTIFY_PRIVATE_KEY
95+
const kid = process.env.NOTIFY_KID
96+
97+
if (!apiKey) {
98+
logger.error("Missing required environment variable: NOTIFY_API_KEY")
99+
process.exit(1)
100+
}
101+
102+
if (!privateKey) {
103+
logger.error("Missing required environment variable: NOTIFY_PRIVATE_KEY")
104+
process.exit(1)
105+
}
106+
107+
if (!kid) {
108+
logger.error("Missing required environment variable: NOTIFY_KID")
109+
process.exit(1)
110+
}
111+
return {apiKey, privateKey, kid}
112+
}

packages/nhsNotifyLambda/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"lint": "eslint --max-warnings 0 --fix --config ../../eslint.config.mjs .",
1212
"compile": "tsc",
1313
"test": "npm run compile && npm run unit",
14-
"check-licenses": "license-checker --failOn GPL --failOn LGPL --start ../.."
14+
"check-licenses": "license-checker --failOn GPL --failOn LGPL --start ../..",
15+
"cli:test-token": "npm run compile && node lib/cli/test-token-exchange.js"
1516
},
1617
"dependencies": {
1718
"@aws-lambda-powertools/commons": "^2.28.1",

packages/nhsNotifyLambda/src/utils/auth.ts

Lines changed: 9 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,50 +1,37 @@
11
import {Logger} from "@aws-lambda-powertools/logger"
2-
import {getSecret} from "@aws-lambda-powertools/parameters/secrets"
32
import {AxiosInstance} from "axios"
4-
53
import {SignJWT, importPKCS8} from "jose"
64

5+
import {NotifySecrets} from "./secrets.js"
6+
77
/**
88
* Exchange API key + JWT for a bearer token from NHS Notify.
99
*/
1010
export async function tokenExchange(
1111
logger: Logger,
1212
axiosInstance: AxiosInstance,
13-
host: string
13+
host: string,
14+
notifySecrets: NotifySecrets
1415
): Promise<string> {
15-
const [apiKeyRaw, privateKeyRaw, kidRaw] = await Promise.all([
16-
getSecret(process.env.API_KEY_SECRET!),
17-
getSecret(process.env.PRIVATE_KEY_SECRET!),
18-
getSecret(process.env.KID_SECRET!)
19-
])
20-
21-
const API_KEY = apiKeyRaw?.toString().trim()
22-
const PRIVATE_KEY = privateKeyRaw?.toString().trim()
23-
const KID = kidRaw?.toString().trim()
24-
25-
if (!API_KEY || !PRIVATE_KEY || !KID) {
26-
throw new Error("Missing one of API_KEY, PRIVATE_KEY or KID from Secrets Manager")
27-
}
28-
2916
// create and sign the JWT
3017
const alg = "RS512"
3118
const now = Math.floor(Date.now() / 1000)
3219
const jti = crypto.randomUUID()
3320

34-
const key = await importPKCS8(PRIVATE_KEY, alg)
21+
const key = await importPKCS8(notifySecrets.privateKey, alg)
3522

3623
const jwt = await new SignJWT({
37-
sub: API_KEY,
38-
iss: API_KEY,
24+
sub: notifySecrets.apiKey,
25+
iss: notifySecrets.apiKey,
3926
jti,
4027
aud: `${host}/oauth2/token`
4128
})
42-
.setProtectedHeader({alg, kid: KID, typ: "JWT"})
29+
.setProtectedHeader({alg, kid: notifySecrets.kid, typ: "JWT"})
4330
.setIssuedAt(now)
4431
.setExpirationTime(now + 60) // 1 minute
4532
.sign(key)
4633

47-
logger.info("Exchanging JWT for access token", {host, jti})
34+
logger.info("Exchanging JWT for access token", {jwt, host, jti})
4835

4936
// Request the token
5037
const params = new URLSearchParams({

packages/nhsNotifyLambda/src/utils/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {NotifyDataItemMessage} from "./types"
22
import {checkCooldownForUpdate, addPrescriptionMessagesToNotificationStateStore} from "./dynamo"
33
import {removeSQSMessages, drainQueue, reportQueueStatus} from "./sqs"
44
import {handleNotifyRequests, makeRealNotifyRequest} from "./notify"
5+
import {tokenExchange} from "./auth"
56

67
export {
78
NotifyDataItemMessage,
@@ -11,5 +12,6 @@ export {
1112
drainQueue,
1213
reportQueueStatus,
1314
handleNotifyRequests,
14-
makeRealNotifyRequest
15+
makeRealNotifyRequest,
16+
tokenExchange
1517
}

packages/nhsNotifyLambda/src/utils/notify.ts

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,16 @@ import {
1111
MessageBatchItem
1212
} from "./types"
1313
import {loadConfig} from "./ssm"
14+
import {loadSecrets, NotifySecrets} from "./secrets"
1415
import {tokenExchange} from "./auth"
1516
import {NOTIFY_REQUEST_MAX_BYTES, NOTIFY_REQUEST_MAX_ITEMS, DUMMY_NOTIFY_DELAY_MS} from "./constants"
1617

18+
export interface NotifyConfig {
19+
routingPlanId: string
20+
notifyApiBaseUrl: string
21+
notifySecrets: NotifySecrets
22+
}
23+
1724
/**
1825
* Returns the original array, chunked in batches of up to <size>
1926
*
@@ -52,8 +59,6 @@ export async function handleNotifyRequests(
5259
return []
5360
}
5461

55-
const configPromise = loadConfig()
56-
5762
// Map the NotifyDataItems into the structure needed for notify
5863
const messages: Array<MessageBatchItem> = data.flatMap(item => {
5964
// Ignore messages with missing deduplication IDs (the field is possibly undefined)
@@ -70,15 +75,20 @@ export async function handleNotifyRequests(
7075
}]
7176
})
7277

73-
// Check if we should make real requests
74-
const {makeRealNotifyRequestsFlag, notifyApiBaseUrlRaw} = await configPromise
75-
if (!makeRealNotifyRequestsFlag || !notifyApiBaseUrlRaw) return await makeFakeNotifyRequest(logger, data, messages)
76-
77-
if (!notifyApiBaseUrlRaw) throw new Error("NOTIFY_API_BASE_URL is not defined in the environment variables!")
78-
// Just to be safe, trim any whitespace. Also, secrets may be bytes, so make sure it's a string
79-
const notifyBaseUrl = notifyApiBaseUrlRaw.trim()
80-
81-
return await makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, messages)
78+
const {makeRealNotifyRequestsFlag, notifyApiBaseUrl} = await loadConfig()
79+
if (!makeRealNotifyRequestsFlag) {
80+
return await makeFakeNotifyRequest(logger, data, messages)
81+
} else if (!notifyApiBaseUrl) {
82+
throw new Error("NOTIFY_API_BASE_URL is not defined in the environment variables!")
83+
} else {
84+
const notifySecrets = await loadSecrets()
85+
const config: NotifyConfig = {
86+
routingPlanId,
87+
notifyApiBaseUrl,
88+
notifySecrets
89+
}
90+
return await makeRealNotifyRequest(logger, config, data, messages)
91+
}
8292
}
8393

8494
/**
@@ -90,7 +100,6 @@ async function makeFakeNotifyRequest(
90100
data: Array<NotifyDataItemMessage>,
91101
messages: Array<MessageBatchItem>
92102
): Promise<Array<NotifyDataItemMessage>> {
93-
94103
logger.info("Not doing real Notify requests. Simply waiting for some time and returning success on all messages")
95104
await new Promise(f => setTimeout(f, DUMMY_NOTIFY_DELAY_MS))
96105

@@ -146,13 +155,15 @@ function logNotificationRequest(logger: Logger,
146155
* Handles splitting large batches into smaller ones as needed.
147156
*
148157
* @param logger - AWS logging object
149-
* @param routingPlanId - The Notify routing plan ID with which to process the data
150-
* @param data - The details for the notification
158+
* @param config - configuration for talking to NHS Notify
159+
* @param data - PSU SQS messages to process
160+
* @param messages - The data being sent to NHS Notify
161+
* @param bearerToken - lazy initialised Bearer token to communicate with Notify
162+
* @param axiosInstance - lazy initialised HTTP client
151163
*/
152164
export async function makeRealNotifyRequest(
153165
logger: Logger,
154-
routingPlanId: string,
155-
notifyBaseUrl: string,
166+
config: NotifyConfig,
156167
data: Array<NotifyDataItemMessage>,
157168
messages: Array<MessageBatchItem>,
158169
bearerToken?: string,
@@ -166,16 +177,16 @@ export async function makeRealNotifyRequest(
166177
data: {
167178
type: "MessageBatch" as const,
168179
attributes: {
169-
routingPlanId,
180+
routingPlanId: config.routingPlanId,
170181
messageBatchReference,
171182
messages
172183
}
173184
}
174185
}
175186

176187
// Lazily get the bearer token and axios instance, so we only do it once even if we recurse
177-
axiosInstance ??= setupAxios(logger, notifyBaseUrl)
178-
bearerToken ??= await tokenExchange(logger, axiosInstance, notifyBaseUrl)
188+
axiosInstance ??= setupAxios(logger, config.notifyApiBaseUrl)
189+
bearerToken ??= await tokenExchange(logger, axiosInstance, config.notifyApiBaseUrl, config.notifySecrets)
179190

180191
// Recursive split if too large
181192
if (messages.length >= NOTIFY_REQUEST_MAX_ITEMS || estimateSize(body) > NOTIFY_REQUEST_MAX_BYTES) {
@@ -188,13 +199,17 @@ export async function makeRealNotifyRequest(
188199

189200
// send both halves in parallel
190201
const [res1, res2] = await Promise.all([
191-
makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, firstHalf, bearerToken, axiosInstance),
192-
makeRealNotifyRequest(logger, routingPlanId, notifyBaseUrl, data, secondHalf, bearerToken, axiosInstance)
202+
makeRealNotifyRequest(
203+
logger, config, data, firstHalf, bearerToken, axiosInstance
204+
),
205+
makeRealNotifyRequest(
206+
logger, config, data, secondHalf, bearerToken, axiosInstance
207+
)
193208
])
194209
return [...res1, ...res2]
195210
}
196211

197-
logger.info("Making a request for notifications to NHS notify", {count: messages.length, routingPlanId})
212+
logger.info("Request notifications of NHS notify", {count: messages.length, routingPlanId: config.routingPlanId})
198213

199214
try {
200215
const headers = {
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {getSecret} from "@aws-lambda-powertools/parameters/secrets"
2+
3+
export interface NotifySecrets {
4+
apiKey: string, privateKey: string, kid: string
5+
}
6+
7+
export async function loadSecrets(): Promise<NotifySecrets> {
8+
const [apiKeyRaw, privateKeyRaw, kidRaw] = await Promise.all([
9+
getSecret(process.env.API_KEY_SECRET!),
10+
getSecret(process.env.PRIVATE_KEY_SECRET!),
11+
getSecret(process.env.KID_SECRET!)
12+
])
13+
14+
if (!apiKeyRaw || !privateKeyRaw || !kidRaw) {
15+
throw new Error("Missing one of API_KEY, PRIVATE_KEY or KID from Secrets Manager")
16+
}
17+
return {
18+
apiKey: apiKeyRaw.toString().trim(),
19+
privateKey: privateKeyRaw.toString().trim(),
20+
kid: kidRaw.toString().trim()
21+
}
22+
}

0 commit comments

Comments
 (0)