diff --git a/infrastructure/public-api.yaml b/infrastructure/public-api.yaml index 207bf0f7e..4e5e89591 100644 --- a/infrastructure/public-api.yaml +++ b/infrastructure/public-api.yaml @@ -75,7 +75,7 @@ paths: x-amazon-apigateway-integration: httpMethod: "GET" credentials: - Fn::GetAtt: [ "JWKSBucketRole", "Arn" ] + Fn::GetAtt: ["JWKSBucketRole", "Arn"] uri: Fn::Sub: - "arn:aws:apigateway:${AWS::Region}:s3:path/govuk-one-login-hmrc-check-published-keys-${env}/jwks.json" diff --git a/infrastructure/template.yaml b/infrastructure/template.yaml index 3d2dc8580..9407b178b 100644 --- a/infrastructure/template.yaml +++ b/infrastructure/template.yaml @@ -67,6 +67,7 @@ Conditions: UseCanaryDeploymentAlarms: !Or - !Not [!Equals [!Ref StepFunctionsDeploymentPreference, ALL_AT_ONCE]] - !Not [!Equals [!Ref LambdaDeploymentPreference, AllAtOnce]] + CanaryAlarmsAndDevLikeEnvironment: !And [!Condition IsDevLikeEnvironment, !Condition UseCanaryDeploymentAlarms] DeployAlarms: !Or - !Condition IsNotDevLikeEnvironment - !Equals [!Ref DeployAlarmsInDevEnvironment, "true"] @@ -1375,6 +1376,63 @@ Resources: FunctionName: !Ref NinoCheckFunction.Alias Principal: apigateway.amazonaws.com + IssueCredentialFunction: + Condition: IsDevLikeEnvironment + Type: AWS::Serverless::Function + Metadata: + BuildMethod: esbuild + BuildProperties: + Sourcemap: true + Properties: + DeploymentPreference: + Type: !Ref LambdaDeploymentPreference + Alarms: !If + - CanaryAlarmsAndDevLikeEnvironment + - [!Ref IssueCredentialFunctionCanaryErrors] + - [!Ref AWS::NoValue] + Role: !GetAtt CodeDeployServiceRole.Arn + Handler: lambdas/issue-credential/src/handler.handler + LoggingConfig: + LogGroup: !Sub /aws/lambda/${AWS::StackName}/IssueCredentialFunction + CodeSigningConfigArn: !If [EnforceCodeSigning, !Ref CodeSigningConfigArn, !Ref AWS::NoValue] + Policies: + - DynamoDBReadPolicy: + TableName: !Sub "{{resolve:ssm:/${CommonStackName}/SessionTableName}}" + - DynamoDBReadPolicy: + TableName: !Sub "{{resolve:ssm:/${CommonStackName}/PersonIdentityTableName}}" + - DynamoDBReadPolicy: + TableName: !Ref UserAttemptsTable + - DynamoDBReadPolicy: + TableName: !Ref NinoUsersTable + - EventBridgePutEventsPolicy: + EventBusName: !Ref CheckHmrcEventBus + Environment: + Variables: + POWERTOOLS_SERVICE_NAME: !Sub "${CriIdentifier}-IssueCredentialFunction" + SESSION_TABLE: !Sub "{{resolve:ssm:/${CommonStackName}/SessionTableName}}" + PERSON_IDENTITY_TABLE: !Sub "{{resolve:ssm:/${CommonStackName}/PersonIdentityTableName}}" + ATTEMPT_TABLE: !Ref UserAttemptsTable + NINO_USER_TABLE: !Ref NinoUsersTable + AUDIT_EVENT_BUS: !Ref CheckHmrcEventBus + AUDIT_SOURCE: !FindInMap [EnvironmentConfiguration, !Ref Environment, DOMAINNAME] + AUDIT_ISSUER: !Sub "{{resolve:ssm:/${CommonStackName}/verifiable-credential/issuer}}" + LOG_FULL_ERRORS: !If [IsIntOrProdEnvironment, "false", "true"] + + IssueCredentialFunctionLogGroup: + Condition: IsDevLikeEnvironment + Type: AWS::Logs::LogGroup + Properties: + LogGroupName: !Sub /aws/lambda/${AWS::StackName}/IssueCredentialFunction + RetentionInDays: 30 + + IssueCredentialFunctionPermission: + Condition: IsDevLikeEnvironment + Type: AWS::Lambda::Permission + Properties: + Action: lambda:InvokeFunction + FunctionName: !Ref IssueCredentialFunction.Alias + Principal: apigateway.amazonaws.com + NinoIssueCredentialStateMachine: Type: AWS::Serverless::StateMachine Properties: @@ -2217,6 +2275,33 @@ Resources: ComparisonOperator: GreaterThanOrEqualToThreshold TreatMissingData: notBreaching + IssueCredentialFunctionCanaryErrors: + Type: AWS::CloudWatch::Alarm + Condition: CanaryAlarmsAndDevLikeEnvironment + Properties: + ActionsEnabled: true + AlarmActions: + - !ImportValue platform-alarm-warning-alert-topic + OKActions: + - !ImportValue platform-alarm-warning-alert-topic + AlarmDescription: !Sub "Errors returned from the IssueCredentialFunction Lambda." + MetricName: Errors + Dimensions: + - Name: Resource + Value: !Sub "${AWS::StackName}-IssueCredentialFunction:live" + - Name: FunctionName + Value: !Ref NinoCheckFunction + - Name: ExecutedVersion + Value: !GetAtt IssueCredentialFunction.Version.Version + Namespace: AWS/Lambda + Statistic: Sum + Unit: Count + Period: 60 + EvaluationPeriods: 1 + Threshold: 1 + ComparisonOperator: GreaterThanOrEqualToThreshold + TreatMissingData: notBreaching + ############################################################################### AuditEventStateMachine: diff --git a/lambdas/common/src/types/access-token-index-session-item.ts b/lambdas/common/src/types/access-token-index-session-item.ts new file mode 100644 index 000000000..26fd9f9c4 --- /dev/null +++ b/lambdas/common/src/types/access-token-index-session-item.ts @@ -0,0 +1,5 @@ +export type AccessTokenIndexSessionItem = { + sessionId: string; + accessToken: string; + subject: string; +}; diff --git a/lambdas/common/tests/config/function-config.test.ts b/lambdas/common/tests/config/function-config.test.ts index fe2f96fe8..1e3176759 100644 --- a/lambdas/common/tests/config/function-config.test.ts +++ b/lambdas/common/tests/config/function-config.test.ts @@ -1,6 +1,6 @@ import { BaseFunctionConfig } from "../../src/config/base-function-config"; -const originalProcessEnv = JSON.parse(JSON.stringify(process.env)); +const originalProcessEnv = { ...process.env }; const validEnvVars = { SESSION_TABLE: "session-table", diff --git a/lambdas/common/tests/mocks/mockData.ts b/lambdas/common/tests/mocks/mockData.ts index 211a6f805..b4da5ee91 100644 --- a/lambdas/common/tests/mocks/mockData.ts +++ b/lambdas/common/tests/mocks/mockData.ts @@ -3,6 +3,7 @@ import { ISO8601DateString, UnixSecondsTimestamp } from "../../../common/src/typ import { AttemptItem } from "../../../common/src/types/attempt"; import { NinoSessionItem } from "../../../common/src/types/nino-session-item"; import { NinoUser } from "../../../common/src/types/nino-user"; +import { AccessTokenIndexSessionItem } from "../../src/types/access-token-index-session-item"; export const mockTxn = "very good"; @@ -26,6 +27,12 @@ export const mockSession: NinoSessionItem = { txn: "narp", }; +export const mockSessionFromIndex: AccessTokenIndexSessionItem = { + sessionId: mockSessionId, + accessToken: mockAccessToken, + subject: "yarp", +}; + export const mockAttempt: AttemptItem = { sessionId: mockSessionId, timestamp: new Date().toISOString() as ISO8601DateString, diff --git a/lambdas/issue-credential/jest.config.ts b/lambdas/issue-credential/jest.config.ts new file mode 100644 index 000000000..d5cd20620 --- /dev/null +++ b/lambdas/issue-credential/jest.config.ts @@ -0,0 +1,7 @@ +import type { Config } from "jest"; +import baseConfig from "../../jest.config.base"; + +export default { + ...baseConfig, + displayName: "lambdas/issue-credential", +} satisfies Config; diff --git a/lambdas/issue-credential/package.json b/lambdas/issue-credential/package.json new file mode 100644 index 000000000..66c4288f7 --- /dev/null +++ b/lambdas/issue-credential/package.json @@ -0,0 +1,37 @@ +{ + "name": "issue-credential-handler", + "description": "", + "scripts": { + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "unit": "jest --silent", + "test": "npm run unit --", + "test:coverage": "npm run unit -- --coverage", + "deploy": "../../deploy.sh", + "compile": "tsc" + }, + "dependencies": { + "@aws-lambda-powertools/commons": "1.14.2", + "@aws-lambda-powertools/logger": "2.3.0", + "@aws-lambda-powertools/parameters": "2.21.0", + "@aws-sdk/client-dynamodb": "3.828.0", + "@aws-sdk/client-eventbridge": "3.828.0", + "@aws-sdk/util-dynamodb": "3.828.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.150", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "aws-sdk-client-mock": "4.1.0", + "aws-sdk-client-mock-jest": "4.1.0", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" + } +} diff --git a/lambdas/issue-credential/src/handler.ts b/lambdas/issue-credential/src/handler.ts new file mode 100644 index 000000000..e90efc929 --- /dev/null +++ b/lambdas/issue-credential/src/handler.ts @@ -0,0 +1,81 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult, Context } from "aws-lambda"; +import { initOpenTelemetry } from "../../open-telemetry/src/otel-setup"; +import { BaseFunctionConfig } from "../../common/src/config/base-function-config"; +import { CriError } from "../../common/src/errors/cri-error"; +import { handleErrorResponse } from "../../common/src/errors/cri-error-response"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { logger } from "../../common/src/util/logger"; +import { retrieveSessionIdByAccessToken } from "./helpers/retrieve-session-by-access-token"; +import { metrics } from "../../common/src/util/metrics"; +import { countAttempts } from "../../common/src/database/count-attempts"; +import { retrieveNinoUser } from "./helpers/retrieve-nino-user"; +import { LambdaInterface } from "@aws-lambda-powertools/commons"; +import { getRecordBySessionId } from "../../common/src/database/get-record-by-session-id"; +import { SessionItem } from "../../common/src/database/types/session-item"; + +initOpenTelemetry(); + +const dynamoClient = new DynamoDBClient(); + +const functionConfig = new BaseFunctionConfig(); + +class IssueCredentialHandler implements LambdaInterface { + @logger.injectLambdaContext({ resetKeys: true }) + @metrics.logMetrics({ throwOnEmptyMetrics: false, captureColdStartMetric: true }) + public async handler({ headers }: APIGatewayProxyEvent, context: Context): Promise { + try { + logger.info(`${context.functionName} invoked.`); + + const accessToken = (headers["Authorization"]?.match(/^Bearer [a-zA-Z0-9_-]+$/) ?? [])[0]; + + if (!accessToken) throw new CriError(400, "You must provide a valid access token"); + + const sessionId = await retrieveSessionIdByAccessToken( + functionConfig.tableNames.sessionTable, + dynamoClient, + accessToken + ); + + const session = await getRecordBySessionId( + dynamoClient, + functionConfig.tableNames.sessionTable, + sessionId, + "expiryDate" + ); + + logger.appendKeys({ + govuk_signin_journey_id: session.clientSessionId, + }); + logger.info(`Identified government journey id: ${session.clientSessionId}`); + + const failedAttemptCount = await countAttempts( + functionConfig.tableNames.attemptTable, + dynamoClient, + session.sessionId, + "FAIL" + ); + logger.info(`Identified ${failedAttemptCount} failed attempts.`); + + const personIdentity = await getRecordBySessionId( + dynamoClient, + functionConfig.tableNames.personIdentityTable, + session.sessionId, + "expiryDate" + ); + logger.info(`Retrieved person identity.`); + + const ninoUser = await retrieveNinoUser(functionConfig.tableNames.ninoUserTable, dynamoClient, session.sessionId); + logger.info(`Retrieved NINo-user entry.`); + + return { + statusCode: 200, + body: JSON.stringify({ failedAttemptCount, personIdentity, ninoUser }), + }; + } catch (error) { + return handleErrorResponse(error, logger); + } + } +} + +const handlerClass = new IssueCredentialHandler(); +export const handler = handlerClass.handler.bind(handlerClass); diff --git a/lambdas/issue-credential/src/helpers/retrieve-nino-user.ts b/lambdas/issue-credential/src/helpers/retrieve-nino-user.ts new file mode 100644 index 000000000..694fb631d --- /dev/null +++ b/lambdas/issue-credential/src/helpers/retrieve-nino-user.ts @@ -0,0 +1,28 @@ +import { getRecordBySessionId } from "../../../common/src/database/get-record-by-session-id"; +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { logger } from "../../../common/src/util/logger"; +import { RecordNotFoundError } from "../../../common/src/database/exceptions/errors"; +import { CriError } from "../../../common/src/errors/cri-error"; +import { safeStringifyError } from "../../../common/src/util/stringify-error"; +import { NinoUser } from "../../../common/src/types/nino-user"; + +export async function retrieveNinoUser( + ninoUserTableName: string, + dynamoClient: DynamoDBClient, + sessionId: string +): Promise { + try { + const ninoUser = await getRecordBySessionId(dynamoClient, ninoUserTableName, sessionId, "ttl"); + + return ninoUser; + } catch (error) { + if (error instanceof RecordNotFoundError) { + logger.info(`No valid NINo user record found.`); + throw new CriError(500, `No NINo user entry found for the given session ID.`); + } + + logger.error(`Caught unexpected NINo user retrieval error: ${safeStringifyError(error)}`); + + throw new CriError(500, "Unexpected error getting NINo user"); + } +} diff --git a/lambdas/issue-credential/src/helpers/retrieve-session-by-access-token.ts b/lambdas/issue-credential/src/helpers/retrieve-session-by-access-token.ts new file mode 100644 index 000000000..12f820c2b --- /dev/null +++ b/lambdas/issue-credential/src/helpers/retrieve-session-by-access-token.ts @@ -0,0 +1,53 @@ +import { DynamoDBClient, QueryCommand } from "@aws-sdk/client-dynamodb"; +import { AccessTokenIndexSessionItem } from "../../../common/src/types/access-token-index-session-item"; +import { logger } from "../../../common/src/util/logger"; +import { CriError } from "../../../common/src/errors/cri-error"; +import { safeStringifyError } from "../../../common/src/util/stringify-error"; +import { unmarshall } from "@aws-sdk/util-dynamodb"; +import { withRetry } from "../../../common/src/util/retry"; + +export async function retrieveSessionIdByAccessToken( + sessionTableName: string, + dynamoClient: DynamoDBClient, + accessToken: string +): Promise { + try { + async function sendQueryCommand() { + const command = new QueryCommand({ + TableName: sessionTableName, + IndexName: "access-token-index", + KeyConditionExpression: "accessToken = :value", + ExpressionAttributeValues: { + ":value": { + S: accessToken, + }, + }, + }); + + const result = await dynamoClient.send(command); + + if (result.Count === 0 || !result.Items) { + throw new CriError(400, `No session entry found for the given access token`); + } + + const retrievedRecords = result.Items.map((v) => unmarshall(v)) as AccessTokenIndexSessionItem[]; + + if (retrievedRecords.length > 1) { + throw new CriError(500, `Found ${retrievedRecords.length} session records but was only expecting 1.`); + } + + return retrievedRecords[0].sessionId; + } + + return await withRetry(sendQueryCommand, logger, { + maxRetries: 3, + baseDelay: 300, + }); + } catch (error) { + if (error instanceof CriError) throw error; + + logger.error(`Caught unexpected session retrieval error: ${safeStringifyError(error)}`); + + throw new CriError(500, "Unexpected error getting session information"); + } +} diff --git a/lambdas/issue-credential/tests/handler.test.ts b/lambdas/issue-credential/tests/handler.test.ts new file mode 100644 index 000000000..f1626cb45 --- /dev/null +++ b/lambdas/issue-credential/tests/handler.test.ts @@ -0,0 +1,142 @@ +jest.mock("../../common/src/util/logger", () => ({ + logger: mockLogger, +})); +jest.mock("../../common/src/config/base-function-config"); +jest.mock("../../common/src/database/count-attempts"); +jest.mock("../../common/src/database/get-record-by-session-id"); +jest.mock("../src/helpers/retrieve-session-by-access-token"); +jest.mock("../src/helpers/retrieve-nino-user"); +jest.mock("../../common/src/util/metrics"); + +import { mockFunctionConfig } from "../../common/tests/mocks/mockConfig"; +import { BaseFunctionConfig } from "../../common/src/config/base-function-config"; +(BaseFunctionConfig as unknown as jest.Mock).mockReturnValue(mockFunctionConfig); + +import { mockDynamoClient } from "../../common/tests/mocks/mockDynamoClient"; +import { + mockAccessToken, + mockNinoUser, + mockPersonIdentity, + mockSession, + mockSessionId, +} from "../../common/tests/mocks/mockData"; +import { APIGatewayProxyEvent, Context } from "aws-lambda"; +import { mockLogger } from "../../common/tests/logger"; +import { handler } from "../src/handler"; +import { retrieveSessionIdByAccessToken } from "../src/helpers/retrieve-session-by-access-token"; +import { countAttempts } from "../../common/src/database/count-attempts"; +import { retrieveNinoUser } from "../src/helpers/retrieve-nino-user"; +import { getRecordBySessionId } from "../../common/src/database/get-record-by-session-id"; + +const mockContext: Context = { + awsRequestId: "", + callbackWaitsForEmptyEventLoop: false, + functionName: "", + functionVersion: "", + invokedFunctionArn: "", + logGroupName: "", + logStreamName: "", + memoryLimitInMB: "", + done(): void {}, + fail(): void {}, + getRemainingTimeInMillis(): number { + return 0; + }, + succeed(): void {}, +}; + +const internalServerError = { + statusCode: 500, + body: JSON.stringify({ message: "Internal server error" }), +}; + +const badRequest = { + statusCode: 400, + body: expect.any(String), +}; + +const handlerInput: Parameters = [ + { + headers: { + Authorization: `Bearer ${mockAccessToken}`, + }, + } as unknown as APIGatewayProxyEvent, + mockContext, +]; + +(retrieveSessionIdByAccessToken as unknown as jest.Mock).mockResolvedValue(mockSessionId); +(countAttempts as unknown as jest.Mock).mockResolvedValue(0); +(getRecordBySessionId as unknown as jest.Mock).mockResolvedValueOnce(mockSession); +(getRecordBySessionId as unknown as jest.Mock).mockResolvedValueOnce(mockPersonIdentity); +(retrieveNinoUser as unknown as jest.Mock).mockResolvedValue(mockNinoUser); + +describe("issue-credential handler", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("executes successfully with a valid input", async () => { + const response = await handler(...handlerInput); + + expect(response).toStrictEqual({ + statusCode: 200, + body: JSON.stringify({ failedAttemptCount: 0, personIdentity: mockPersonIdentity, ninoUser: mockNinoUser }), + }); + + expect(mockLogger.appendKeys).toHaveBeenCalledWith({ govuk_signin_journey_id: mockSession.clientSessionId }); + expect(countAttempts).toHaveBeenCalledWith( + mockFunctionConfig.tableNames.attemptTable, + mockDynamoClient, + mockSession.sessionId, + "FAIL" + ); + }); + + it("handles application errors correctly", async () => { + (retrieveSessionIdByAccessToken as unknown as jest.Mock).mockImplementationOnce(() => { + throw new Error("nooooooo!!!"); + }); + + const response = await handler(...handlerInput); + + expect(response).toStrictEqual(internalServerError); + + expect(retrieveSessionIdByAccessToken).toHaveBeenCalledWith( + mockFunctionConfig.tableNames.sessionTable, + mockDynamoClient, + `Bearer ${mockAccessToken}` + ); + expect(countAttempts).not.toHaveBeenCalled(); + }); + + it("handles a missing or malformed access token", async () => { + const response1 = await handler({ headers: {} } as unknown as APIGatewayProxyEvent, mockContext); + expect(response1).toStrictEqual(badRequest); + + const response2 = await handler( + { headers: { Authorization: "Bearer" } } as unknown as APIGatewayProxyEvent, + mockContext + ); + expect(response2).toStrictEqual(badRequest); + + const response3 = await handler( + { headers: { Authorization: "gimme the access" } } as unknown as APIGatewayProxyEvent, + mockContext + ); + expect(response3).toStrictEqual(badRequest); + + const response4 = await handler( + { headers: { Authorization: "Bearer billybob " } } as unknown as APIGatewayProxyEvent, + mockContext + ); + expect(response4).toStrictEqual(badRequest); + + const response5 = await handler( + { headers: { Authorization: "Bearer billybob" } } as unknown as APIGatewayProxyEvent, + mockContext + ); + expect(response5).toStrictEqual(badRequest); + + expect(retrieveSessionIdByAccessToken).not.toHaveBeenCalled(); + }); +}); diff --git a/lambdas/issue-credential/tests/helpers/retrieve-nino-user.test.ts b/lambdas/issue-credential/tests/helpers/retrieve-nino-user.test.ts new file mode 100644 index 000000000..076ea87ef --- /dev/null +++ b/lambdas/issue-credential/tests/helpers/retrieve-nino-user.test.ts @@ -0,0 +1,60 @@ +import { RecordNotFoundError } from "../../../common/src/database/exceptions/errors"; +import * as getRecordModule from "../../../common/src/database/get-record-by-session-id"; +import { logger } from "../../../common/src/util/logger"; +import { mockDynamoClient } from "../../../common/tests/mocks/mockDynamoClient"; +import { mockNinoUser, mockSessionId } from "../../../common/tests/mocks/mockData"; +import { retrieveNinoUser } from "../../src/helpers/retrieve-nino-user"; +jest.mock("../../../common/src/util/logger"); +jest.mock("../../../common/src/util/metrics"); + +const getRecordBySessionId = jest.spyOn(getRecordModule, "getRecordBySessionId"); +getRecordBySessionId.mockResolvedValue(mockNinoUser); + +const ninoUserTableName = "nino-user-squad"; + +describe("retrieveNinoUser()", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns as expected for some valid input", async () => { + const result = await retrieveNinoUser(ninoUserTableName, mockDynamoClient, mockSessionId); + + expect(result).toEqual(mockNinoUser); + }); + + it("handles a missing record correctly", async () => { + getRecordBySessionId.mockImplementationOnce(() => { + throw new RecordNotFoundError(ninoUserTableName, mockSessionId); + }); + + let thrown = false; + + try { + await retrieveNinoUser(ninoUserTableName, mockDynamoClient, mockSessionId); + } catch (error) { + thrown = true; + expect(error).toEqual(expect.objectContaining({ name: "CriError", status: 500 })); + } + + expect(thrown).toEqual(true); + }); + + it("handles an unrecognised error correctly", async () => { + getRecordBySessionId.mockImplementationOnce(() => { + throw new Error("illegal"); + }); + + let thrown = false; + + try { + await retrieveNinoUser(ninoUserTableName, mockDynamoClient, mockSessionId); + } catch (error) { + thrown = true; + expect(error).toEqual(expect.objectContaining({ name: "CriError", status: 500 })); + } + + expect(thrown).toEqual(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); +}); diff --git a/lambdas/issue-credential/tests/helpers/retrieve-session-by-access-token.test.ts b/lambdas/issue-credential/tests/helpers/retrieve-session-by-access-token.test.ts new file mode 100644 index 000000000..fde0609a5 --- /dev/null +++ b/lambdas/issue-credential/tests/helpers/retrieve-session-by-access-token.test.ts @@ -0,0 +1,122 @@ +import { logger } from "../../../common/src/util/logger"; +import { retrieveSessionIdByAccessToken } from "../../src/helpers/retrieve-session-by-access-token"; +import { mockDynamoClient } from "../../../common/tests/mocks/mockDynamoClient"; +import { mockAccessToken, mockSessionFromIndex } from "../../../common/tests/mocks/mockData"; +import { marshall } from "@aws-sdk/util-dynamodb"; +import { QueryCommand } from "@aws-sdk/client-dynamodb"; +jest.mock("../../../common/src/util/logger"); +jest.mock("../../../common/src/util/metrics"); + +jest.mock("@aws-sdk/client-dynamodb", () => ({ + QueryCommand: jest.fn().mockImplementation((input) => ({ + type: "QueryCommandInstance", + input, + })), + DynamoDBClient: jest.fn().mockImplementation(() => mockDynamoClient), +})); + +const mockSendFunction = mockDynamoClient.send as jest.Mock; + +const sessionTableName = "my-session-zone"; + +describe("retrieveSessionByAccessToken()", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns as expected for some valid input", async () => { + mockSendFunction.mockResolvedValueOnce({ + Items: [marshall(mockSessionFromIndex)], + Count: 1, + }); + + const result = await retrieveSessionIdByAccessToken(sessionTableName, mockDynamoClient, mockAccessToken); + + expect(mockDynamoClient.send).toHaveBeenCalledWith( + new QueryCommand({ + TableName: sessionTableName, + IndexName: "access-token-index", + KeyConditionExpression: "accessToken = :value", + ExpressionAttributeValues: { + ":value": { + S: mockAccessToken, + }, + }, + }) + ); + + expect(result).toEqual(mockSessionFromIndex.sessionId); + }); + + it("handles a missing record correctly", async () => { + mockSendFunction.mockResolvedValue({ + Items: [], + Count: 0, + }); + + let thrown = false; + + try { + await retrieveSessionIdByAccessToken(sessionTableName, mockDynamoClient, mockAccessToken); + } catch (error) { + thrown = true; + expect(error).toEqual(expect.objectContaining({ name: "CriError", status: 400 })); + } + + expect(thrown).toEqual(true); + }); + + it("handles too many records correctly", async () => { + mockSendFunction.mockResolvedValue({ + Items: [marshall(mockSessionFromIndex), marshall(mockSessionFromIndex)], + Count: 2, + }); + + let thrown = false; + + try { + await retrieveSessionIdByAccessToken(sessionTableName, mockDynamoClient, mockAccessToken); + } catch (error) { + thrown = true; + expect(error).toEqual( + expect.objectContaining({ name: "CriError", status: 500, message: expect.stringContaining("2") }) + ); + } + + expect(thrown).toEqual(true); + }); + + it("handles an unrecognised error correctly", async () => { + mockSendFunction.mockImplementation(() => { + throw new Error("illegal"); + }); + + let thrown = false; + + try { + await retrieveSessionIdByAccessToken(sessionTableName, mockDynamoClient, mockAccessToken); + } catch (error) { + thrown = true; + expect(error).toEqual(expect.objectContaining({ name: "CriError", status: 500 })); + } + + expect(thrown).toEqual(true); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Error")); + }); + + it("should retry if a query fails", async () => { + mockSendFunction.mockResolvedValueOnce({ + Items: [], + Count: 0, + }); + mockSendFunction.mockResolvedValueOnce({ + Items: [marshall(mockSessionFromIndex)], + Count: 1, + }); + + const result = await retrieveSessionIdByAccessToken(sessionTableName, mockDynamoClient, mockAccessToken); + + expect(result).toEqual(mockSessionFromIndex.sessionId); + expect(mockSendFunction).toHaveBeenCalledTimes(2); + }); +}); diff --git a/lambdas/issue-credential/tsconfig.json b/lambdas/issue-credential/tsconfig.json new file mode 100644 index 000000000..98f8c0f6b --- /dev/null +++ b/lambdas/issue-credential/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "target": "es2022", + "strict": true, + "preserveConstEnums": true, + "sourceMap": true, + "module": "es2022", + "moduleResolution": "node", + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "types": ["jest", "node", "aws-lambda"], + "outDir": "./build/", + "noImplicitAny": true + }, + "exclude": ["node_modules", "build", "coverage"], + "include": ["src", "tests"] +} diff --git a/package-lock.json b/package-lock.json index 2f7169f40..10838c62b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,79 @@ } }, "lambdas/credential-subject": {}, + "lambdas/issue-credential": { + "dependencies": { + "@aws-lambda-powertools/commons": "1.14.2", + "@aws-lambda-powertools/logger": "2.3.0", + "@aws-lambda-powertools/parameters": "2.21.0", + "@aws-sdk/client-dynamodb": "3.828.0", + "@aws-sdk/client-eventbridge": "3.828.0", + "@aws-sdk/util-dynamodb": "3.828.0" + }, + "devDependencies": { + "@types/aws-lambda": "^8.10.150", + "@types/jest": "^29.5.5", + "@typescript-eslint/eslint-plugin": "^8.13.0", + "@typescript-eslint/parser": "^8.13.0", + "aws-sdk-client-mock": "4.1.0", + "aws-sdk-client-mock-jest": "4.1.0", + "eslint": "^8.53.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-prettier": "^5.2.1", + "jest": "^29.7.0", + "prettier": "^3.1.0", + "ts-jest": "^29.1.1", + "ts-node": "^10.9.2", + "typescript": "^5.3.2" + } + }, + "lambdas/issue-credential/node_modules/@aws-lambda-powertools/parameters": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/parameters/-/parameters-2.21.0.tgz", + "integrity": "sha512-49Gg2tQSqtcM4CnSR4AzzVntluxivc0JQmtLde/hsVn5eh22U2fmCUeK3FgVhrwoknaiOyN4pR4iOeY25pVR/g==", + "dependencies": { + "@aws-lambda-powertools/commons": "^2.21.0" + }, + "peerDependencies": { + "@aws-sdk/client-appconfigdata": ">=3.x", + "@aws-sdk/client-dynamodb": ">=3.x", + "@aws-sdk/client-secrets-manager": ">=3.x", + "@aws-sdk/client-ssm": ">=3.x", + "@aws-sdk/util-dynamodb": ">=3.x", + "@middy/core": "4.x || 5.x || 6.x" + }, + "peerDependenciesMeta": { + "@aws-sdk/client-appconfigdata": { + "optional": true + }, + "@aws-sdk/client-dynamodb": { + "optional": true + }, + "@aws-sdk/client-secrets-manager": { + "optional": true + }, + "@aws-sdk/client-ssm": { + "optional": true + }, + "@aws-sdk/util-dynamodb": { + "optional": true + }, + "@middy/core": { + "optional": true + } + } + }, + "lambdas/issue-credential/node_modules/@aws-lambda-powertools/parameters/node_modules/@aws-lambda-powertools/commons": { + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/@aws-lambda-powertools/commons/-/commons-2.23.0.tgz", + "integrity": "sha512-uZ4Ym5fu2OV7vQkdUskL1ZjyuMH+blN1KqQmmFjsPCPJcr0jqXxku9X+N7qbfWTwglNR9FCpnFPcxzeIPJ8ZFg==" + }, + "lambdas/issue-credential/node_modules/@types/aws-lambda": { + "version": "8.10.150", + "resolved": "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.150.tgz", + "integrity": "sha512-AX+AbjH/rH5ezX1fbK8onC/a+HyQHo7QGmvoxAE42n22OsciAxvZoZNEr22tbXs8WfP1nIsBjKDpgPm3HjOZbA==", + "dev": true + }, "lambdas/jwt-signer": { "dependencies": { "@aws-sdk/client-kms": "3.828.0", @@ -9787,6 +9860,10 @@ "resolved": "lambdas/time-handler", "link": true }, + "node_modules/issue-credential-handler": { + "resolved": "lambdas/issue-credential", + "link": true + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "dev": true,