diff --git a/package-lock.json b/package-lock.json index 81e02be57d..493cecf428 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2203,9 +2203,9 @@ "license": "MIT" }, "node_modules/@aws-sdk/client-secrets-manager": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.980.0.tgz", - "integrity": "sha512-TeDBmkR8x3toPnvkFMBG73QqxsWjksFUMJyR0C4tZjVXjFq9igGwq8nHYDrQA0Hony6tGvH0SyNsjsL5w5qTww==", + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.981.0.tgz", + "integrity": "sha512-NVSbeeU/IjVobvFrwR4vLaEn3L83SfqRZXjIyBlHtU6agtHVCOJCdhrkK0z7uFahxD9FqqiQTYMYhzIfgL7VjA==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", @@ -2218,7 +2218,7 @@ "@aws-sdk/middleware-user-agent": "^3.972.5", "@aws-sdk/region-config-resolver": "^3.972.3", "@aws-sdk/types": "^3.973.1", - "@aws-sdk/util-endpoints": "3.980.0", + "@aws-sdk/util-endpoints": "3.981.0", "@aws-sdk/util-user-agent-browser": "^3.972.3", "@aws-sdk/util-user-agent-node": "^3.972.3", "@smithy/config-resolver": "^4.4.6", @@ -2266,9 +2266,9 @@ } }, "node_modules/@aws-sdk/client-secrets-manager/node_modules/@aws-sdk/util-endpoints": { - "version": "3.980.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.980.0.tgz", - "integrity": "sha512-AjKBNEc+rjOZQE1HwcD9aCELqg1GmUj1rtICKuY8cgwB73xJ4U/kNyqKKpN2k9emGqlfDY2D8itIp/vDc6OKpw==", + "version": "3.981.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.981.0.tgz", + "integrity": "sha512-a8nXh/H3/4j+sxhZk+N3acSDlgwTVSZbX9i55dx41gI1H+geuonuRG+Shv3GZsCb46vzc08RK2qC78ypO8uRlg==", "license": "Apache-2.0", "dependencies": { "@aws-sdk/types": "^3.973.1", @@ -17656,6 +17656,7 @@ "integrity": "sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -20070,6 +20071,7 @@ "@aws-lambda-powertools/logger": "^2.30.1", "@aws-lambda-powertools/parameters": "^2.30.1", "@aws-sdk/client-dynamodb": "^3.980.0", + "@aws-sdk/client-secrets-manager": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.980.0", "@cpt-ui-common/authFunctions": "^1.0.0", "@cpt-ui-common/dynamoFunctions": "^1.0.0", @@ -20095,6 +20097,7 @@ "dependencies": { "@aws-lambda-powertools/logger": "^2.30.1", "@aws-lambda-powertools/parameters": "^2.30.1", + "@aws-sdk/client-secrets-manager": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.980.0", "@cpt-ui-common/dynamoFunctions": "^1.0.0", "@middy/core": "^7.0.2", @@ -20120,6 +20123,7 @@ "license": "MIT", "dependencies": { "@aws-lambda-powertools/logger": "^2.30.1", + "@aws-sdk/client-secrets-manager": "^3.981.0", "axios": "^1.13.2", "axios-retry": "^4.5.0" }, diff --git a/packages/cdk/nagSuppressions.ts b/packages/cdk/nagSuppressions.ts index 26604aab66..1b0aed2b62 100644 --- a/packages/cdk/nagSuppressions.ts +++ b/packages/cdk/nagSuppressions.ts @@ -258,6 +258,37 @@ export const nagSuppressions = (stack: Stack) => { ] ) + safeAddNagSuppression( + stack, + "/StatelessStack/SharedSecrets/ApigeeApiKey/Resource", + [ + { + id: "AwsSolutions-SMG4", + reason: "Suppress error for not rotating secret. This is by design." + } + ] + ) + safeAddNagSuppression( + stack, + "/StatelessStack/SharedSecrets/ApigeeApiSecret/Resource", + [ + { + id: "AwsSolutions-SMG4", + reason: "Suppress error for not rotating secret. This is by design." + } + ] + ) + safeAddNagSuppression( + stack, + "/StatelessStack/SharedSecrets/ApigeeDoHSApiKey/Resource", + [ + { + id: "AwsSolutions-SMG4", + reason: "Suppress error for not rotating secret. This is by design." + } + ] + ) + } } diff --git a/packages/cdk/resources/SharedSecrets.ts b/packages/cdk/resources/SharedSecrets.ts index e006235d4a..36a3d7494e 100644 --- a/packages/cdk/resources/SharedSecrets.ts +++ b/packages/cdk/resources/SharedSecrets.ts @@ -16,6 +16,9 @@ export interface SharedSecretsProps { readonly stackName: string readonly deploymentRole: IRole readonly useMockOidc?: boolean + readonly apigeeApiKey: string + readonly apigeeApiSecret: string + readonly apigeeDoHSApiKey: string } // Construct for managing shared secrets and associated resources @@ -26,6 +29,11 @@ export class SharedSecrets extends Construct { public readonly useJwtKmsKeyPolicy: ManagedPolicy public readonly getPrimaryJwtPrivateKeyPolicy: ManagedPolicy public readonly getMockJwtPrivateKeyPolicy: ManagedPolicy + public readonly apigeeSecretsKmsKey: IKey + public readonly apigeeApiKey: Secret + public readonly apigeeApiSecret: Secret + public readonly apigeeDoHSApiKey: Secret + public readonly getApigeeSecretsPolicy: ManagedPolicy constructor(scope: Construct, id: string, props: SharedSecretsProps) { super(scope, id) @@ -57,6 +65,65 @@ export class SharedSecrets extends Construct { }) }) + this.apigeeSecretsKmsKey = new Key(this, "ApigeeSecretsKmsKey", { + description: `${props.stackName}-apigeeSecretsKmsKey`, + enableKeyRotation: true, + removalPolicy: RemovalPolicy.DESTROY, + pendingWindow: Duration.days(7), + policy: new PolicyDocument({ + statements: [ + // Allow full IAM permissions for account root + new PolicyStatement({ + sid: "EnableIAMUserPermissions", + effect: Effect.ALLOW, + actions: ["kms:*"], + principals: [new AccountRootPrincipal()], + resources: ["*"] + }), + // Allow the deployment role to encrypt and generate data keys + new PolicyStatement({ + effect: Effect.ALLOW, + principals: [props.deploymentRole], + actions: ["kms:Encrypt", "kms:GenerateDataKey*"], + resources: ["*"] + }) + ] + }) + }) + this.apigeeApiKey = new Secret(this, "ApigeeApiKey", { + secretName: `${props.stackName}-apigeeApiKey`, + secretStringValue: SecretValue.unsafePlainText(props.apigeeApiKey), + encryptionKey: this.apigeeSecretsKmsKey + }) + this.apigeeApiSecret = new Secret(this, "ApigeeApiSecret", { + secretName: `${props.stackName}-apigeeApiSecret`, + secretStringValue: SecretValue.unsafePlainText(props.apigeeApiSecret), + encryptionKey: this.apigeeSecretsKmsKey + }) + this.apigeeDoHSApiKey = new Secret(this, "ApigeeDoHSApiKey", { + secretName: `${props.stackName}-apigeeDoHSApiKey`, + secretStringValue: SecretValue.unsafePlainText(props.apigeeDoHSApiKey), + encryptionKey: this.apigeeSecretsKmsKey + }) + + // Create a managed policy to allow getting the primary JWT private key secret + this.getApigeeSecretsPolicy = new ManagedPolicy(this, "GetApigeeSecretsPolicy", { + statements: [ + new PolicyStatement({ + actions: ["secretsmanager:GetSecretValue"], + resources: [ + this.apigeeApiKey.secretArn, + this.apigeeApiSecret.secretArn, + this.apigeeDoHSApiKey.secretArn] + }), + new PolicyStatement({ + actions: ["kms:DescribeKey", "kms:Decrypt"], + effect: Effect.ALLOW, + resources: [this.apigeeSecretsKmsKey.keyArn] + }) + ] + }) + // Create a managed policy to allow using the KMS key for decryption this.useJwtKmsKeyPolicy = new ManagedPolicy(this, "UseJwtKmsKeyPolicy", { description: "Policy to allow using the JWT KMS key", diff --git a/packages/cdk/resources/api/apiFunctions.ts b/packages/cdk/resources/api/apiFunctions.ts index 9ab9edd71a..02d17caf9f 100644 --- a/packages/cdk/resources/api/apiFunctions.ts +++ b/packages/cdk/resources/api/apiFunctions.ts @@ -4,8 +4,7 @@ import {SharedSecrets} from "../SharedSecrets" import {ITableV2} from "aws-cdk-lib/aws-dynamodb" import {IManagedPolicy} from "aws-cdk-lib/aws-iam" import {NodejsFunction} from "aws-cdk-lib/aws-lambda-nodejs" -import {Secret} from "aws-cdk-lib/aws-secretsmanager" -import {NagSuppressions} from "cdk-nag" +import {ISecret, Secret} from "aws-cdk-lib/aws-secretsmanager" // Interface for properties needed to create API functions export interface ApiFunctionsProps { @@ -39,9 +38,9 @@ export interface ApiFunctionsProps { readonly apigeeDoHSEndpoint: string readonly apigeePrescriptionsEndpoint: string readonly apigeePersonalDemographicsEndpoint: string - readonly apigeeApiKey: string - readonly apigeeApiSecret: string - readonly apigeeDoHSApiKey: string + readonly apigeeApiKey: ISecret + readonly apigeeApiSecret: ISecret + readonly apigeeDoHSApiKey: ISecret readonly jwtKid: string readonly logLevel: string readonly roleId: string @@ -78,7 +77,8 @@ export class ApiFunctions extends Construct { props.sessionManagementTableReadPolicy, props.useSessionManagementKmsKeyPolicy, props.sharedSecrets.useJwtKmsKeyPolicy, - props.sharedSecrets.getPrimaryJwtPrivateKeyPolicy + props.sharedSecrets.getPrimaryJwtPrivateKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ] if (props.useMockOidc && props.sharedSecrets.getMockJwtPrivateKeyPolicy) { @@ -101,10 +101,9 @@ export class ApiFunctions extends Construct { // Indicate if mock mode is available MOCK_MODE_ENABLED: props.useMockOidc ? "true" : "false", - APIGEE_API_SECRET: props.apigeeApiSecret, - APIGEE_API_KEY: props.apigeeApiKey, + APIGEE_API_SECRET_ARN: props.apigeeApiSecret.secretArn, + APIGEE_API_KEY_ARN: props.apigeeApiKey.secretArn, FULL_CLOUDFRONT_DOMAIN: props.fullCloudfrontDomain - } // If mock OIDC is enabled, add mock environment variables @@ -240,14 +239,6 @@ export class ApiFunctions extends Construct { // Add the policy to apiFunctionsPolicies apiFunctionsPolicies.push(patientSearchLambda.executeLambdaManagedPolicy) - // Suppress the AwsSolutions-L1 rule for the prescription list Lambda function - NagSuppressions.addResourceSuppressions(prescriptionListLambda.lambda, [ - { - id: "AwsSolutions-L1", - reason: "The Lambda function uses the latest runtime version supported at the time of implementation." - } - ]) - // Prescription Details Lambda Function const prescriptionDetailsLambda = new LambdaFunction(this, "PrescriptionDetails", { serviceName: props.serviceName, @@ -266,10 +257,9 @@ export class ApiFunctions extends Construct { apigeePrescriptionsEndpoint: props.apigeePrescriptionsEndpoint, apigeeDoHSEndpoint: props.apigeeDoHSEndpoint, apigeePersonalDemographicsEndpoint: props.apigeePersonalDemographicsEndpoint, - apigeeApiKey: props.apigeeApiKey, jwtKid: props.jwtKid, roleId: props.roleId, - APIGEE_DOHS_API_KEY: props.apigeeDoHSApiKey + APIGEE_DOHS_API_KEY_ARN: props.apigeeDoHSApiKey.secretArn } }) diff --git a/packages/cdk/resources/api/oauth2Functions.ts b/packages/cdk/resources/api/oauth2Functions.ts index 89f30614c9..6de78586ac 100644 --- a/packages/cdk/resources/api/oauth2Functions.ts +++ b/packages/cdk/resources/api/oauth2Functions.ts @@ -2,7 +2,7 @@ import {Construct} from "constructs" import {LambdaFunction} from "../LambdaFunction" import {ITableV2} from "aws-cdk-lib/aws-dynamodb" import {IManagedPolicy} from "aws-cdk-lib/aws-iam" -import {Secret} from "aws-cdk-lib/aws-secretsmanager" +import {ISecret, Secret} from "aws-cdk-lib/aws-secretsmanager" import {NodejsFunction} from "aws-cdk-lib/aws-lambda-nodejs" import {SharedSecrets} from "../SharedSecrets" @@ -57,8 +57,8 @@ export interface OAuth2FunctionsProps { readonly logRetentionInDays: number readonly logLevel: string readonly jwtKid: string - readonly apigeeApiKey: string - readonly apigeeApiSecret: string + readonly apigeeApiKey: ISecret + readonly apigeeApiSecret: ISecret } /** @@ -103,7 +103,8 @@ export class OAuth2Functions extends Construct { props.sharedSecrets.getPrimaryJwtPrivateKeyPolicy, props.sessionManagementTableWritePolicy, props.sessionManagementTableReadPolicy, - props.useSessionManagementKmsKeyPolicy + props.useSessionManagementKmsKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, @@ -131,7 +132,8 @@ export class OAuth2Functions extends Construct { additionalPolicies: [ props.stateMappingTableWritePolicy, props.stateMappingTableReadPolicy, - props.useStateMappingKmsKeyPolicy + props.useStateMappingKmsKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, @@ -154,7 +156,8 @@ export class OAuth2Functions extends Construct { additionalPolicies: [ props.stateMappingTableWritePolicy, props.stateMappingTableReadPolicy, - props.useStateMappingKmsKeyPolicy + props.useStateMappingKmsKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, @@ -199,7 +202,8 @@ export class OAuth2Functions extends Construct { props.useStateMappingKmsKeyPolicy, props.sessionStateMappingTableWritePolicy, props.sessionStateMappingTableReadPolicy, - props.useSessionStateMappingKmsKeyPolicy + props.useSessionStateMappingKmsKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, @@ -212,7 +216,7 @@ export class OAuth2Functions extends Construct { FULL_CLOUDFRONT_DOMAIN: props.fullCloudfrontDomain, StateMappingTableName: props.stateMappingTable.tableName, SessionStateMappingTableName: props.sessionStateMappingTable.tableName, - APIGEE_API_KEY: props.apigeeApiKey + APIGEE_API_KEY_ARN: props.apigeeApiKey.secretArn } }) @@ -238,7 +242,8 @@ export class OAuth2Functions extends Construct { props.sessionStateMappingTableWritePolicy, props.useSessionStateMappingKmsKeyPolicy, props.sharedSecrets.useJwtKmsKeyPolicy, - props.sharedSecrets.getMockJwtPrivateKeyPolicy + props.sharedSecrets.getMockJwtPrivateKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, @@ -259,8 +264,8 @@ export class OAuth2Functions extends Construct { MOCK_OIDC_ISSUER: props.mockOidcIssuer, FULL_CLOUDFRONT_DOMAIN: props.fullCloudfrontDomain, jwtKid: props.jwtKid, - APIGEE_API_KEY: props.apigeeApiKey, - APIGEE_API_SECRET: props.apigeeApiSecret + APIGEE_API_KEY_ARN: props.apigeeApiKey.secretArn, + APIGEE_API_SECRET_ARN: props.apigeeApiSecret.secretArn } }) @@ -278,7 +283,8 @@ export class OAuth2Functions extends Construct { props.useStateMappingKmsKeyPolicy, props.sessionStateMappingTableReadPolicy, props.sessionStateMappingTableWritePolicy, - props.useSessionStateMappingKmsKeyPolicy + props.useSessionStateMappingKmsKeyPolicy, + props.sharedSecrets.getApigeeSecretsPolicy ], logRetentionInDays: props.logRetentionInDays, logLevel: props.logLevel, diff --git a/packages/cdk/stacks/StatelessResourcesStack.ts b/packages/cdk/stacks/StatelessResourcesStack.ts index a9dbae4c7a..ebe71a7147 100644 --- a/packages/cdk/stacks/StatelessResourcesStack.ts +++ b/packages/cdk/stacks/StatelessResourcesStack.ts @@ -81,9 +81,9 @@ export class StatelessResourcesStack extends Stack { const mockOidcjwksEndpoint = this.node.tryGetContext("mockOidcjwksEndpoint") const useMockOidc: boolean = this.node.tryGetContext("useMockOidc") - const apigeeApiKey = this.node.tryGetContext("apigeeApiKey") - const apigeeApiSecret = this.node.tryGetContext("apigeeApiSecret") - const apigeeDoHSApiKey = this.node.tryGetContext("apigeeDoHSApiKey") + const apigeeApiKey: string = this.node.tryGetContext("apigeeApiKey") + const apigeeApiSecret: string = this.node.tryGetContext("apigeeApiSecret") + const apigeeDoHSApiKey: string = this.node.tryGetContext("apigeeDoHSApiKey") const apigeeCIS2TokenEndpoint = this.node.tryGetContext("apigeeCIS2TokenEndpoint") const apigeeMockTokenEndpoint = this.node.tryGetContext("apigeeMockTokenEndpoint") const apigeePrescriptionsEndpoint = this.node.tryGetContext("apigeePrescriptionsEndpoint") @@ -211,7 +211,10 @@ export class StatelessResourcesStack extends Stack { const sharedSecrets = new SharedSecrets(this, "SharedSecrets", { stackName: props.stackName, deploymentRole: deploymentRole, - useMockOidc: useMockOidc + useMockOidc: useMockOidc, + apigeeApiKey: apigeeApiKey, + apigeeDoHSApiKey: apigeeDoHSApiKey, + apigeeApiSecret: apigeeApiSecret }) // Functions for the login OAuth2 proxy lambdas @@ -266,8 +269,8 @@ export class StatelessResourcesStack extends Stack { logRetentionInDays, logLevel, jwtKid, - apigeeApiKey, - apigeeApiSecret + apigeeApiKey: sharedSecrets.apigeeApiKey, + apigeeApiSecret: sharedSecrets.apigeeApiSecret }) // -- functions for API @@ -302,9 +305,9 @@ export class StatelessResourcesStack extends Stack { apigeeMockTokenEndpoint: apigeeMockTokenEndpoint, apigeePrescriptionsEndpoint: apigeePrescriptionsEndpoint, apigeeDoHSEndpoint: apigeeDoHSEndpoint, - apigeeApiKey: apigeeApiKey, - apigeeDoHSApiKey: apigeeDoHSApiKey, - apigeeApiSecret, + apigeeApiKey: sharedSecrets.apigeeApiKey, + apigeeDoHSApiKey: sharedSecrets.apigeeDoHSApiKey, + apigeeApiSecret: sharedSecrets.apigeeApiSecret, jwtKid: jwtKid, roleId: roleId, apigeePersonalDemographicsEndpoint: apigeePersonalDemographicsEndpoint, diff --git a/packages/cognito/package.json b/packages/cognito/package.json index 7db6ddc0f5..e40e922080 100644 --- a/packages/cognito/package.json +++ b/packages/cognito/package.json @@ -16,6 +16,7 @@ "@aws-lambda-powertools/logger": "^2.30.1", "@aws-lambda-powertools/parameters": "^2.30.1", "@aws-sdk/client-dynamodb": "^3.980.0", + "@aws-sdk/client-secrets-manager": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.980.0", "@cpt-ui-common/authFunctions": "^1.0.0", "@cpt-ui-common/dynamoFunctions": "^1.0.0", diff --git a/packages/cognito/src/authorizeMock.ts b/packages/cognito/src/authorizeMock.ts index 0cf377ae15..4ea4a0fb0e 100644 --- a/packages/cognito/src/authorizeMock.ts +++ b/packages/cognito/src/authorizeMock.ts @@ -1,6 +1,7 @@ import {Logger} from "@aws-lambda-powertools/logger" import {APIGatewayProxyEvent, APIGatewayProxyResult} from "aws-lambda" import {injectLambdaContext} from "@aws-lambda-powertools/logger/middleware" +import {getSecret} from "@aws-lambda-powertools/parameters/secrets" import {MiddyErrorHandler} from "@cpt-ui-common/middyErrorHandler" @@ -23,7 +24,7 @@ const authorizeEndpoint = process.env["IDP_AUTHORIZE_PATH"] as string const cis2ClientId = process.env["OIDC_CLIENT_ID"] as string const userPoolClientId = process.env["COGNITO_CLIENT_ID"] as string const cloudfrontDomain = process.env["FULL_CLOUDFRONT_DOMAIN"] as string -const apigeeApiKey = process.env["APIGEE_API_KEY"] as string +const apigeeApiKeyArn = process.env["APIGEE_API_KEY_ARN"] as string const logger = new Logger({serviceName: "authorize"}) const errorResponseBody = {message: "A system error has occurred"} @@ -32,6 +33,7 @@ const middyErrorHandler = new MiddyErrorHandler(errorResponseBody) const lambdaHandler = async ( event: APIGatewayProxyEvent ): Promise => { + const apigeeApiKey = await getSecret(apigeeApiKeyArn) logger.appendKeys({"apigw-request-id": event.requestContext?.requestId}) // we need to use the base domain for the environment so that pull requests go to that callback uri // as we can only have one callback uri per apigee application @@ -43,7 +45,7 @@ const lambdaHandler = async ( cis2ClientId, userPoolClientId, cloudfrontDomain, - apigeeApiKey + apigeeApiKeyArn }}) // Validate required environment variables @@ -82,7 +84,7 @@ const lambdaHandler = async ( // Build the redirect parameters for CIS2 const responseParameters = { redirect_uri: callbackUri, - client_id: apigeeApiKey, + client_id: apigeeApiKey.toString(), // Ensure client_id is a string response_type: "code", state: newState } diff --git a/packages/cognito/src/tokenMock.ts b/packages/cognito/src/tokenMock.ts index adc2201686..ab1284b1b5 100644 --- a/packages/cognito/src/tokenMock.ts +++ b/packages/cognito/src/tokenMock.ts @@ -50,8 +50,8 @@ const cloudfrontDomain= process.env["FULL_CLOUDFRONT_DOMAIN"] as string const jwtPrivateKeyArn= process.env["jwtPrivateKeyArn"] as string const jwtKid= process.env["jwtKid"] as string const idpTokenPath= process.env["MOCK_IDP_TOKEN_PATH"] as string -const apigeeApiKey = process.env["APIGEE_API_KEY"] as string -const apigeeApiSecret = process.env["APIGEE_API_SECRET"] as string +const apigeeApiKeyArn = process.env["APIGEE_API_KEY_ARN"] as string +const apigeeApiSecretArn = process.env["APIGEE_API_SECRET_ARN"] as string const apigeeMockTokenEndpoint = process.env["MOCK_OIDC_TOKEN_ENDPOINT"] as string const dynamoClient = new DynamoDBClient() @@ -73,6 +73,8 @@ async function createSignedJwt(claims: Record) { } const lambdaHandler = async (event: APIGatewayProxyEvent): Promise => { + const apigeeApiKey = await getSecret(apigeeApiKeyArn) + const apigeeApiSecret = await getSecret(apigeeApiSecretArn) logger.appendKeys({"apigw-request-id": event.requestContext?.requestId}) // we need to use the base domain for the environment so that pull requests go to that callback uri @@ -93,8 +95,8 @@ const lambdaHandler = async (event: APIGatewayProxyEvent): Promise { + return { + mockGetSecret: vi.fn() + } +}) + +vi.mock("@aws-lambda-powertools/parameters/secrets", () => { + const getSecret = mockGetSecret.mockImplementation(async () => { + return "apigee_api_key" + }) + + return { + getSecret + } +}) describe("authorize mock handler", () => { beforeEach(() => { diff --git a/packages/common/authFunctions/jest.config.ts b/packages/common/authFunctions/jest.config.ts deleted file mode 100644 index e3d83833cd..0000000000 --- a/packages/common/authFunctions/jest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import defaultConfig from "../../../jest.default.config.ts" -import type {JestConfigWithTsJest} from "ts-jest" - -const jestConfig: JestConfigWithTsJest = { - ...defaultConfig, - "rootDir": ".", - setupFiles: ["/.jest/setEnvVars.js"], - moduleNameMapper: {"@/(.*)$": ["/src/$1"]} -} - -export default jestConfig diff --git a/packages/common/authFunctions/jest.debug.config.ts b/packages/common/authFunctions/jest.debug.config.ts deleted file mode 100644 index a306273831..0000000000 --- a/packages/common/authFunctions/jest.debug.config.ts +++ /dev/null @@ -1,9 +0,0 @@ -import config from "./jest.config" -import type {JestConfigWithTsJest} from "ts-jest" - -const debugConfig: JestConfigWithTsJest = { - ...config, - "preset": "ts-jest" -} - -export default debugConfig diff --git a/packages/common/authFunctions/package.json b/packages/common/authFunctions/package.json index 1b6694ee57..df9ddc64fe 100644 --- a/packages/common/authFunctions/package.json +++ b/packages/common/authFunctions/package.json @@ -20,6 +20,7 @@ "dependencies": { "@aws-lambda-powertools/logger": "^2.30.1", "@aws-lambda-powertools/parameters": "^2.30.1", + "@aws-sdk/client-secrets-manager": "^3.981.0", "@aws-sdk/lib-dynamodb": "^3.980.0", "@cpt-ui-common/dynamoFunctions": "^1.0.0", "@middy/core": "^7.0.2", diff --git a/packages/common/authFunctions/src/authenticateRequest.ts b/packages/common/authFunctions/src/authenticateRequest.ts index 695e97efed..08637733d3 100644 --- a/packages/common/authFunctions/src/authenticateRequest.ts +++ b/packages/common/authFunctions/src/authenticateRequest.ts @@ -37,8 +37,8 @@ export interface AuthenticateRequestOptions { tokenMappingTableName: string sessionManagementTableName: string jwtPrivateKeyArn: string - apigeeApiKey: string - apigeeApiSecret: string + apigeeApiKeyArn: string + apigeeApiSecretArn: string jwtKid: string apigeeCis2TokenEndpoint: string apigeeMockTokenEndpoint: string @@ -57,8 +57,8 @@ export const authParametersFromEnv = (): AuthenticateRequestOptions => { tokenMappingTableName: process.env["TokenMappingTableName"] as string, sessionManagementTableName: process.env["SessionManagementTableName"] as string, jwtPrivateKeyArn: process.env["jwtPrivateKeyArn"] as string, - apigeeApiKey: process.env["APIGEE_API_KEY"] as string, - apigeeApiSecret: process.env["APIGEE_API_SECRET"] as string, + apigeeApiKeyArn: process.env["APIGEE_API_KEY_ARN"] as string, + apigeeApiSecretArn: process.env["APIGEE_API_SECRET_ARN"] as string, jwtKid: process.env["jwtKid"] as string, apigeeMockTokenEndpoint: process.env["apigeeMockTokenEndpoint"] as string, apigeeCis2TokenEndpoint: process.env["apigeeCIS2TokenEndpoint"] as string, @@ -82,12 +82,17 @@ const refreshTokenFlow = async ( if (existingToken.refreshToken === undefined) { throw new Error("Missing refresh token") } + const apigeeApiKey = await getSecret(authOptions.apigeeApiKeyArn) + const apigeeApiSecret = await getSecret(authOptions.apigeeApiSecretArn) + if (!apigeeApiKey || !apigeeApiSecret) { + throw new Error("Missing Apigee API credentials") + } const refreshResult = await refreshApigeeAccessToken( axiosInstance, apigeeTokenEndpoint, existingToken.refreshToken, - authOptions.apigeeApiKey, - authOptions.apigeeApiSecret, + apigeeApiKey.toString(), + apigeeApiSecret.toString(), logger ) @@ -134,13 +139,18 @@ export async function authenticateRequest( ): Promise { const { jwtPrivateKeyArn, - apigeeApiKey, - apigeeApiSecret, + apigeeApiKeyArn, + apigeeApiSecretArn, jwtKid, apigeeMockTokenEndpoint, apigeeCis2TokenEndpoint, cloudfrontDomain } = authOptions + const apigeeApiKey = await getSecret(apigeeApiKeyArn) + const apigeeApiSecret = await getSecret(apigeeApiSecretArn) + if (!apigeeApiKey || !apigeeApiSecret) { + throw new Error("Missing Apigee API credentials") + } logger.info("Starting authentication flow") // Extract username and determine if this is a mock request @@ -238,8 +248,8 @@ export async function authenticateRequest( const callbackUri = `https://${baseEnvironmentDomain}/oauth2/mock-callback` const tokenExchangeBody = { grant_type: "authorization_code", - client_id: apigeeApiKey, - client_secret: apigeeApiSecret, + client_id: apigeeApiKey.toString(), + client_secret: apigeeApiSecret.toString(), redirect_uri: callbackUri, code: userRecord.apigeeCode } @@ -268,7 +278,7 @@ export async function authenticateRequest( logger, apigeeCis2TokenEndpoint, jwtPrivateKey, - apigeeApiKey, + apigeeApiKey.toString(), jwtKid, userRecord.cis2IdToken ) diff --git a/packages/common/authFunctions/tests/test_authenticateRequest.test.ts b/packages/common/authFunctions/tests/test_authenticateRequest.test.ts index a3012fc99a..4a9cb1d3ec 100644 --- a/packages/common/authFunctions/tests/test_authenticateRequest.test.ts +++ b/packages/common/authFunctions/tests/test_authenticateRequest.test.ts @@ -99,13 +99,15 @@ describe("authenticateRequest", () => { error: vi.fn() } as unknown as Logger + const mockApigeeApiKey = "dummy_apigee_api_key" + const mockApigeeApiSecret = "dummy_apigee_api_secret" const mockOptions = { tokenMappingTableName: "test-table", sessionManagementTableName: "test-session-table", jwtPrivateKeyArn: "test-key-arn", - apigeeApiKey: "test-api-key", + apigeeApiKeyArn: "dummy_apigee_api_key_arn", jwtKid: "test-kid", - apigeeApiSecret: "test-api-secret", + apigeeApiSecretArn: "dummy_apigee_api_secret_arn", apigeeMockTokenEndpoint: "mock-token-endpoint", apigeeCis2TokenEndpoint: "cis2-token-endpoint", cloudfrontDomain: "test-cloudfront-domain" @@ -127,9 +129,6 @@ describe("authenticateRequest", () => { // Default mock for constructSignedJWTBody mockConstructSignedJWTBody.mockReturnValue({param: "value"}) - - // Ensure process.env is populated - process.env.APIGEE_API_SECRET = "test-api-secret" }) it("should use existing valid token when available", async () => { @@ -232,6 +231,11 @@ describe("authenticateRequest", () => { refreshToken: "refreshed-refresh-token", expiresIn: 3600 }) + mockGetSecret + .mockReturnValueOnce("test-private-key") + .mockReturnValueOnce("test-private-key") + .mockReturnValueOnce("dummy_apigee_api_key") + .mockReturnValueOnce("dummy_apigee_api_secret") const result = await authenticateRequest( "test-user", @@ -253,8 +257,8 @@ describe("authenticateRequest", () => { axiosInstance, mockOptions.apigeeCis2TokenEndpoint, // Use the one from options "expiring-refresh-token", - mockOptions.apigeeApiKey, - "test-api-secret", // API secret from env var + mockApigeeApiKey, + mockApigeeApiSecret, // API secret from env var mockLogger ) @@ -298,6 +302,11 @@ describe("authenticateRequest", () => { refreshToken: "refreshed-refresh-token", expiresIn: 3600 }) + mockGetSecret + .mockReturnValueOnce("test-private-key") + .mockReturnValueOnce("test-private-key") + .mockReturnValueOnce("dummy_apigee_api_key") + .mockReturnValueOnce("dummy_apigee_api_secret") const result = await authenticateRequest( "test-user", @@ -319,8 +328,8 @@ describe("authenticateRequest", () => { axiosInstance, mockOptions.apigeeCis2TokenEndpoint, // Use the one from options "expiring-refresh-token", - mockOptions.apigeeApiKey, - "test-api-secret", // API secret from env var + mockApigeeApiKey, + mockApigeeApiSecret, // API secret from env var mockLogger ) @@ -427,7 +436,6 @@ describe("authenticateRequest", () => { expect(mockConstructSignedJWTBody).not.toHaveBeenCalled() expect(mockExchangeTokenForApigeeAccessToken).toHaveBeenCalled() expect(mockUpdateTokenMapping).toHaveBeenCalled() - expect(mockGetSecret).not.toHaveBeenCalled() }) it("should acquire new token when no token exists for mocked user", async () => { @@ -470,7 +478,6 @@ describe("authenticateRequest", () => { expect(mockConstructSignedJWTBody).not.toHaveBeenCalled() expect(mockExchangeTokenForApigeeAccessToken).toHaveBeenCalled() expect(mockUpdateTokenMapping).toHaveBeenCalled() - expect(mockGetSecret).not.toHaveBeenCalled() }) it("should handle token refresh failure gracefully", async () => { diff --git a/packages/common/authFunctions/vitest.config.ts b/packages/common/authFunctions/vitest.config.ts index 2d790760a4..b07fa8e1be 100644 --- a/packages/common/authFunctions/vitest.config.ts +++ b/packages/common/authFunctions/vitest.config.ts @@ -35,7 +35,9 @@ const viteConfig = defineConfig({ MOCK_USER_INFO_ENDPOINT: `${MOCK_OIDC_HOST}/userinfo`, MOCK_USER_POOL_IDP: "MockDummyPoolIdentityProvider", MOCK_IDP_TOKEN_PATH: `${MOCK_OIDC_HOST}/token`, - FULL_CLOUDFRONT_DOMAIN: "cpt-ui-pr-854.dev.eps.national.nhs.uk" + FULL_CLOUDFRONT_DOMAIN: "cpt-ui-pr-854.dev.eps.national.nhs.uk", + APIGEE_API_KEY_ARN: "dummy_apigee_api_key_arn", + APIGEE_API_SECRET_ARN: "dummy_apigee_api_secret_arn" } } }) diff --git a/packages/common/doHSClient/.vscode/launch.json b/packages/common/doHSClient/.vscode/launch.json index ba322cd1dc..c16b361af4 100644 --- a/packages/common/doHSClient/.vscode/launch.json +++ b/packages/common/doHSClient/.vscode/launch.json @@ -19,7 +19,7 @@ "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "disableOptimisticBPs": true, - "program": "${workspaceFolder}/../../,,/node_modules/.bin/jest", + "program": "${workspaceFolder}/../../../node_modules/.bin/jest", "windows": { "program": "${workspaceFolder}/node_modules/jest/bin/jest" }, diff --git a/packages/common/doHSClient/jest.debug.config.ts b/packages/common/doHSClient/jest.debug.config.ts index a306273831..fa3a3ff872 100644 --- a/packages/common/doHSClient/jest.debug.config.ts +++ b/packages/common/doHSClient/jest.debug.config.ts @@ -1,4 +1,4 @@ -import config from "./jest.config" +import config from "./jest.config.ts" import type {JestConfigWithTsJest} from "ts-jest" const debugConfig: JestConfigWithTsJest = { diff --git a/packages/common/doHSClient/package.json b/packages/common/doHSClient/package.json index c55b5df4e7..a6bfeb4945 100644 --- a/packages/common/doHSClient/package.json +++ b/packages/common/doHSClient/package.json @@ -14,6 +14,7 @@ }, "dependencies": { "@aws-lambda-powertools/logger": "^2.30.1", + "@aws-sdk/client-secrets-manager": "^3.981.0", "axios": "^1.13.2", "axios-retry": "^4.5.0" }, diff --git a/packages/common/doHSClient/src/doHSClient.ts b/packages/common/doHSClient/src/doHSClient.ts index 50e218a811..a14616af6c 100644 --- a/packages/common/doHSClient/src/doHSClient.ts +++ b/packages/common/doHSClient/src/doHSClient.ts @@ -1,9 +1,10 @@ import {Logger} from "@aws-lambda-powertools/logger" import axios, {AxiosRequestConfig} from "axios" +import {getSecret} from "@aws-lambda-powertools/parameters/secrets" // Read the DoHS API Key from environment variables const apigeeDoHSEndpoint = process.env["apigeeDoHSEndpoint"] as string -const apigeeDoHSApiKey = process.env["APIGEE_DOHS_API_KEY"] as string +const apigeeDoHSApiKeyArn = process.env["APIGEE_DOHS_API_KEY_ARN"] as string interface DoHSContact { ContactType: string @@ -22,6 +23,7 @@ export interface DoHSOrg { } export const doHSClient = async (odsCodes: Array, logger: Logger): Promise> => { + const apigeeDoHSApiKey = await getSecret(apigeeDoHSApiKeyArn) logger.info("Fetching DoHS API data for ODS codes", {odsCodes}) if (odsCodes.length === 0) { diff --git a/packages/common/doHSClient/tests/test_doHSClient.test.ts b/packages/common/doHSClient/tests/test_doHSClient.test.ts index 350a85bb24..169689f203 100644 --- a/packages/common/doHSClient/tests/test_doHSClient.test.ts +++ b/packages/common/doHSClient/tests/test_doHSClient.test.ts @@ -17,6 +17,17 @@ const validEndpoint = "https://api.example.com/dohs" process.env.apigeeApiKey = validApiKey process.env.apigeeDoHSEndpoint = validEndpoint +const mockGetSecret = jest.fn() +jest.unstable_mockModule("@aws-lambda-powertools/parameters/secrets", () => { + const getSecret = mockGetSecret.mockImplementation(async () => { + return validApiKey + }) + + return { + getSecret + } +}) + // Now we can safely import the module const {doHSClient} = await import("../src/doHSClient") @@ -40,8 +51,9 @@ describe("doHSClient", () => { it("throws an error if apigeeApiKey is not set", async () => { // Temporarily unset the API key - const originalApiKey = process.env.APIGEE_DOHS_API_KEY - delete process.env.APIGEE_DOHS_API_KEY + mockGetSecret.mockImplementationOnce(async () => { + return null + }) // Re-import the module to pick up the changed environment jest.resetModules() @@ -51,8 +63,6 @@ describe("doHSClient", () => { "Apigee API Key environment variable is not set" ) - // Restore the API key - process.env.APIGEE_DOHS_API_KEY = originalApiKey }) it("throws an error if apigeeDoHSEndpoint is not set", async () => {