diff --git a/backend-api/docs/sessionStateMachine.md b/backend-api/docs/sessionStateMachine.md new file mode 100644 index 00000000..9b2e1aa0 --- /dev/null +++ b/backend-api/docs/sessionStateMachine.md @@ -0,0 +1,8 @@ +# Session State Machine + +The auth session for the async backend follows a forward-only state machine, as illustrated below. + +```mermaid +flowchart LR + ASYNC_AUTH_SESSION_CREATED == start biometric session ==> ASYNC_BIOMETRIC_TOKEN_ISSUED +``` \ No newline at end of file diff --git a/backend-api/src/functions/adapters/dynamoDbAdapter.ts b/backend-api/src/functions/adapters/dynamoDbAdapter.ts index 208d27dd..2f646b21 100644 --- a/backend-api/src/functions/adapters/dynamoDbAdapter.ts +++ b/backend-api/src/functions/adapters/dynamoDbAdapter.ts @@ -6,6 +6,7 @@ import { QueryCommand, QueryCommandInput, QueryCommandOutput, + UpdateItemCommand, } from "@aws-sdk/client-dynamodb"; import { CreateSessionAttributes } from "../services/session/sessionService"; import { NodeHttpHandler } from "@smithy/node-http-handler"; @@ -14,14 +15,19 @@ import { NativeAttributeValue, unmarshall, } from "@aws-sdk/util-dynamodb"; - -const sessionStates = { - ASYNC_AUTH_SESSION_CREATED: "ASYNC_AUTH_SESSION_CREATED", -}; +import { UpdateSessionOperation } from "../common/session/updateOperations/UpdateSessionOperation"; +import { emptySuccess, errorResult, Result } from "../utils/result"; +import { + SessionRegistry, + UpdateSessionError, +} from "../common/session/SessionRegistry"; +import { logger } from "../common/logging/logger"; +import { LogMessage } from "../common/logging/LogMessage"; +import { SessionState } from "../common/session/session"; export type DatabaseRecord = Record; -export class DynamoDbAdapter { +export class DynamoDbAdapter implements SessionRegistry { private readonly tableName: string; private readonly dynamoDbClient = new DynamoDBClient({ region: process.env.REGION, @@ -48,7 +54,7 @@ export class DynamoDbAdapter { FilterExpression: "sessionState = :sessionState", ExpressionAttributeValues: { ":subjectIdentifier": marshall(subjectIdentifier), - ":sessionState": marshall(sessionStates.ASYNC_AUTH_SESSION_CREATED), + ":sessionState": marshall(SessionState.AUTH_SESSION_CREATED), ":currentTimeInSeconds": marshall(this.getTimeNowInSeconds()), }, ProjectionExpression: this.formatAsProjectionExpression(attributesToGet), @@ -94,7 +100,7 @@ export class DynamoDbAdapter { createdAt: Date.now(), issuer: issuer, sessionId: sessionId, - sessionState: sessionStates.ASYNC_AUTH_SESSION_CREATED, + sessionState: SessionState.AUTH_SESSION_CREATED, clientState: state, subjectIdentifier: sub, timeToLive: timeToLive, @@ -114,6 +120,50 @@ export class DynamoDbAdapter { } } + async updateSession( + sessionId: string, + updateOperation: UpdateSessionOperation, + ): Promise> { + const updateExpressionDataToLog = { + updateExpression: updateOperation.getDynamoDbUpdateExpression(), + conditionExpression: updateOperation.getDynamoDbConditionExpression(), + }; + + try { + logger.debug(LogMessage.UPDATE_SESSION_ATTEMPT, { + data: updateExpressionDataToLog, + }); + await this.dynamoDbClient.send( + new UpdateItemCommand({ + TableName: this.tableName, + Key: { + sessionId: { S: sessionId }, + }, + UpdateExpression: updateOperation.getDynamoDbUpdateExpression(), + ConditionExpression: updateOperation.getDynamoDbConditionExpression(), + ExpressionAttributeValues: + updateOperation.getDynamoDbExpressionAttributeValues(), + }), + ); + } catch (error) { + if (error instanceof ConditionalCheckFailedException) { + logger.error(LogMessage.UPDATE_SESSION_CONDITIONAL_CHECK_FAILURE, { + error: error.message, + data: updateExpressionDataToLog, + }); + return errorResult(UpdateSessionError.CONDITIONAL_CHECK_FAILURE); + } else { + logger.error(LogMessage.UPDATE_SESSION_UNEXPECTED_FAILURE, { + error: error, + data: updateExpressionDataToLog, + }); + return errorResult(UpdateSessionError.INTERNAL_SERVER_ERROR); + } + } + logger.debug(LogMessage.UPDATE_SESSION_SUCCESS); + return emptySuccess(); + } + private getTimeNowInSeconds() { return Math.floor(Date.now() / 1000); } diff --git a/backend-api/src/functions/adapters/tests/dynamoDbAdapter.test.ts b/backend-api/src/functions/adapters/tests/dynamoDbAdapter.test.ts new file mode 100644 index 00000000..fd2e53e0 --- /dev/null +++ b/backend-api/src/functions/adapters/tests/dynamoDbAdapter.test.ts @@ -0,0 +1,150 @@ +import { expect } from "@jest/globals"; +import "dotenv/config"; +import "../../testUtils/matchers"; +import { DynamoDbAdapter } from "../dynamoDbAdapter"; +import { BiometricTokenIssued } from "../../common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued"; +import { + SessionRegistry, + UpdateSessionError, +} from "../../common/session/SessionRegistry"; +import { + ConditionalCheckFailedException, + DynamoDBClient, + UpdateItemCommand, +} from "@aws-sdk/client-dynamodb"; +import { mockClient } from "aws-sdk-client-mock"; +import { emptySuccess, errorResult, Result } from "../../utils/result"; +import { UpdateSessionOperation } from "../../common/session/updateOperations/UpdateSessionOperation"; + +const mockDynamoDbClient = mockClient(DynamoDBClient); + +let sessionRegistry: SessionRegistry; +let consoleErrorSpy: jest.SpyInstance; +let consoleDebugSpy: jest.SpyInstance; + +describe("DynamoDbAdapter", () => { + beforeEach(() => { + sessionRegistry = new DynamoDbAdapter("mock_table_name"); + consoleErrorSpy = jest.spyOn(console, "error"); + consoleDebugSpy = jest.spyOn(console, "debug"); + }); + describe("updateSession", () => { + let result: Result; + + const updateOperation: UpdateSessionOperation = new BiometricTokenIssued( + "NFC_PASSPORT", + "mock_opaque_id", + ); + + describe("On every attempt", () => { + beforeEach(async () => { + await sessionRegistry.updateSession("mock_session_id", updateOperation); + }); + + it("Logs the attempt", () => { + expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({ + messageCode: "MOBILE_ASYNC_UPDATE_SESSION_ATTEMPT", + data: { + updateExpression: updateOperation.getDynamoDbUpdateExpression(), + conditionExpression: + updateOperation.getDynamoDbConditionExpression(), + }, + }); + }); + }); + + describe("When a conditional check fails", () => { + beforeEach(async () => { + mockDynamoDbClient.on(UpdateItemCommand).rejects( + new ConditionalCheckFailedException({ + $metadata: {}, + message: "Conditional check failed", + }), + ); + result = await sessionRegistry.updateSession( + "mock_session_id", + updateOperation, + ); + }); + + it("Logs the failure", () => { + expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({ + messageCode: "MOBILE_ASYNC_UPDATE_SESSION_CONDITIONAL_CHECK_FAILURE", + error: "Conditional check failed", + data: { + updateExpression: updateOperation.getDynamoDbUpdateExpression(), + conditionExpression: + updateOperation.getDynamoDbConditionExpression(), + }, + }); + }); + + it("Returns failure with conditional check failure error", () => { + expect(result).toEqual( + errorResult(UpdateSessionError.CONDITIONAL_CHECK_FAILURE), + ); + }); + }); + + describe("When there is an unexpected error updating the session", () => { + beforeEach(async () => { + mockDynamoDbClient.on(UpdateItemCommand).rejects("mock_error"); + result = await sessionRegistry.updateSession( + "mock_session_id", + updateOperation, + ); + }); + + it("Logs the failure", () => { + expect(consoleErrorSpy).toHaveBeenCalledWithLogFields({ + messageCode: "MOBILE_ASYNC_UPDATE_SESSION_UNEXPECTED_FAILURE", + data: { + updateExpression: updateOperation.getDynamoDbUpdateExpression(), + conditionExpression: + updateOperation.getDynamoDbConditionExpression(), + }, + }); + }); + + it("Returns failure with server error", () => { + expect(result).toEqual( + errorResult(UpdateSessionError.INTERNAL_SERVER_ERROR), + ); + }); + }); + + describe("Given the session is successfully updated", () => { + beforeEach(async () => { + const expectedUpdateItemCommandInput = { + TableName: "mock_table_name", + Key: { + sessionId: { S: "mock_session_id" }, + }, + UpdateExpression: updateOperation.getDynamoDbUpdateExpression(), + ConditionExpression: updateOperation.getDynamoDbConditionExpression(), + ExpressionAttributeValues: + updateOperation.getDynamoDbExpressionAttributeValues(), + }; + mockDynamoDbClient + .onAnyCommand() // default + .rejects("Did not receive expected input") + .on(UpdateItemCommand, expectedUpdateItemCommandInput, true) // match to expected input + .resolves({}); + result = await sessionRegistry.updateSession( + "mock_session_id", + updateOperation, + ); + }); + + it("Logs the success", () => { + expect(consoleDebugSpy).toHaveBeenCalledWithLogFields({ + messageCode: "MOBILE_ASYNC_UPDATE_SESSION_SUCCESS", + }); + }); + + it("Returns an empty success", () => { + expect(result).toEqual(emptySuccess()); + }); + }); + }); +}); diff --git a/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.test.ts b/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.test.ts index 3d4beef8..94ee37c5 100644 --- a/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.test.ts +++ b/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.test.ts @@ -9,9 +9,22 @@ import { IAsyncBiometricTokenDependencies } from "./handlerDependencies"; import { expectedSecurityHeaders, mockSessionId, + mockInertSessionRegistry, } from "../testUtils/unitTestData"; import { logger } from "../common/logging/logger"; -import { emptyFailure, successResult } from "../utils/result"; +import { + emptyFailure, + emptySuccess, + errorResult, + successResult, +} from "../utils/result"; +import { UpdateSessionError } from "../common/session/SessionRegistry"; +import { BiometricTokenIssued } from "../common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued"; + +jest.mock("crypto", () => ({ + ...jest.requireActual("crypto"), + randomUUID: () => "mock_opaque_id", +})); describe("Async Biometric Token", () => { let dependencies: IAsyncBiometricTokenDependencies; @@ -39,6 +52,11 @@ describe("Async Biometric Token", () => { .fn() .mockResolvedValue(successResult("mockBiometricToken")); + const mockSuccessfulSessionRegistry = { + ...mockInertSessionRegistry, + updateSession: jest.fn().mockResolvedValue(emptySuccess()), + }; + beforeEach(() => { dependencies = { env: { @@ -48,9 +66,11 @@ describe("Async Biometric Token", () => { BIOMETRIC_SUBMITTER_KEY_SECRET_PATH_DL: "mock_secret_path_dl", BIOMETRIC_SUBMITTER_KEY_SECRET_CACHE_DURATION_IN_SECONDS: "900", READID_BASE_URL: "mockReadIdBaseUrl", + SESSION_TABLE_NAME: "mockTableName", }, getSecrets: mockGetSecretsSuccess, getBiometricToken: mockGetBiometricTokenSuccess, + getSessionRegistry: () => mockSuccessfulSessionRegistry, }; context = buildLambdaContext(); consoleInfoSpy = jest.spyOn(console, "info"); @@ -91,6 +111,7 @@ describe("Async Biometric Token", () => { ["BIOMETRIC_SUBMITTER_KEY_SECRET_PATH_DL"], ["BIOMETRIC_SUBMITTER_KEY_SECRET_CACHE_DURATION_IN_SECONDS"], ["READID_BASE_URL"], + ["SESSION_TABLE_NAME"], ])("Given %s environment variable is missing", (envVar: string) => { beforeEach(async () => { delete dependencies.env[envVar]; @@ -201,6 +222,67 @@ describe("Async Biometric Token", () => { }); }); + describe("When session update fails", () => { + describe("When failure is due to client error", () => { + beforeEach(async () => { + dependencies.getSessionRegistry = () => ({ + ...mockInertSessionRegistry, + updateSession: jest + .fn() + .mockResolvedValue( + errorResult(UpdateSessionError.CONDITIONAL_CHECK_FAILURE), + ), + }); + result = await lambdaHandlerConstructor( + dependencies, + validRequest, + context, + ); + }); + + it("Returns 401 Unauthorized", () => { + expect(result).toStrictEqual({ + statusCode: 401, + body: JSON.stringify({ + error: "invalid_session", + error_description: + "User session is not in a valid state for this operation.", + }), + headers: expectedSecurityHeaders, + }); + }); + }); + + describe("When failure is due to server error", () => { + beforeEach(async () => { + dependencies.getSessionRegistry = () => ({ + ...mockInertSessionRegistry, + updateSession: jest + .fn() + .mockResolvedValue( + errorResult(UpdateSessionError.INTERNAL_SERVER_ERROR), + ), + }); + result = await lambdaHandlerConstructor( + dependencies, + validRequest, + context, + ); + }); + + it("Returns 500 Internal Server Error", () => { + expect(result).toStrictEqual({ + statusCode: 500, + body: JSON.stringify({ + error: "server_error", + error_description: "Internal Server Error", + }), + headers: expectedSecurityHeaders, + }); + }); + }); + }); + describe("Given a valid request is made", () => { beforeEach(async () => { result = await lambdaHandlerConstructor( @@ -228,6 +310,13 @@ describe("Async Biometric Token", () => { ); }); + it("Passes correct arguments to update session", () => { + expect(mockSuccessfulSessionRegistry.updateSession).toHaveBeenCalledWith( + mockSessionId, + new BiometricTokenIssued("NFC_PASSPORT", "mock_opaque_id"), + ); + }); + it("Logs COMPLETED", async () => { expect(consoleInfoSpy).toHaveBeenCalledWithLogFields({ messageCode: "MOBILE_ASYNC_BIOMETRIC_TOKEN_COMPLETED", diff --git a/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.ts b/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.ts index 306a0a99..445d1420 100644 --- a/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.ts +++ b/backend-api/src/functions/asyncBiometricToken/asyncBiometricTokenHandler.ts @@ -11,6 +11,7 @@ import { badRequestResponse, notImplementedResponse, serverErrorResponse, + unauthorizedResponse, } from "../common/lambdaResponses"; import { validateRequestBody } from "./validateRequestBody/validateRequestBody"; import { logger } from "../common/logging/logger"; @@ -20,8 +21,16 @@ import { getBiometricTokenConfig, } from "./biometricTokenConfig"; import { GetSecrets } from "../common/config/secrets"; -import { emptyFailure, Result, successResult } from "../utils/result"; +import { + emptyFailure, + FailureWithValue, + Result, + successResult, +} from "../utils/result"; import { DocumentType } from "../types/document"; +import { BiometricTokenIssued } from "../common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued"; +import { UpdateSessionError } from "../common/session/SessionRegistry"; +import { randomUUID } from "crypto"; export async function lambdaHandlerConstructor( dependencies: IAsyncBiometricTokenDependencies, @@ -46,7 +55,7 @@ export async function lambdaHandlerConstructor( }); return badRequestResponse("invalid_request", errorMessage); } - const documentType = validateRequestBodyResult.value.documentType; + const { documentType, sessionId } = validateRequestBodyResult.value; const submitterKeyResult = await getSubmitterKeyForDocumentType( documentType, @@ -66,6 +75,19 @@ export async function lambdaHandlerConstructor( return serverErrorResponse; } + const opaqueId = generateOpaqueId(); + + const sessionRegistry = dependencies.getSessionRegistry( + config.SESSION_TABLE_NAME, + ); + const updateSessionResult = await sessionRegistry.updateSession( + sessionId, + new BiometricTokenIssued(documentType, opaqueId), + ); + if (updateSessionResult.isError) { + return handleUpdateSessionFailure(updateSessionResult); + } + logger.info(LogMessage.BIOMETRIC_TOKEN_COMPLETED); return notImplementedResponse; } @@ -115,3 +137,21 @@ async function getSubmitterKeyForDocumentType( ); } } + +function handleUpdateSessionFailure( + failure: FailureWithValue, +): APIGatewayProxyResult { + switch (failure.value) { + case UpdateSessionError.CONDITIONAL_CHECK_FAILURE: + return unauthorizedResponse( + "invalid_session", + "User session is not in a valid state for this operation.", + ); + case UpdateSessionError.INTERNAL_SERVER_ERROR: + return serverErrorResponse; + } +} + +function generateOpaqueId(): string { + return randomUUID(); +} diff --git a/backend-api/src/functions/asyncBiometricToken/biometricTokenConfig.ts b/backend-api/src/functions/asyncBiometricToken/biometricTokenConfig.ts index 555309b0..8a59cbc0 100644 --- a/backend-api/src/functions/asyncBiometricToken/biometricTokenConfig.ts +++ b/backend-api/src/functions/asyncBiometricToken/biometricTokenConfig.ts @@ -13,6 +13,7 @@ const REQUIRED_ENVIRONMENT_VARIABLES = [ "BIOMETRIC_SUBMITTER_KEY_SECRET_PATH_DL", "BIOMETRIC_SUBMITTER_KEY_SECRET_CACHE_DURATION_IN_SECONDS", "READID_BASE_URL", + "SESSION_TABLE_NAME", ] as const; export type BiometricTokenConfig = Config< diff --git a/backend-api/src/functions/asyncBiometricToken/handlerDependencies.ts b/backend-api/src/functions/asyncBiometricToken/handlerDependencies.ts index 744e3c3e..778ff4ef 100644 --- a/backend-api/src/functions/asyncBiometricToken/handlerDependencies.ts +++ b/backend-api/src/functions/asyncBiometricToken/handlerDependencies.ts @@ -4,15 +4,19 @@ import { getBiometricToken, GetBiometricToken, } from "./getBiometricToken/getBiometricToken"; +import { SessionRegistry } from "../common/session/SessionRegistry"; +import { DynamoDbAdapter } from "../adapters/dynamoDbAdapter"; export type IAsyncBiometricTokenDependencies = { env: NodeJS.ProcessEnv; getSecrets: GetSecrets; getBiometricToken: GetBiometricToken; + getSessionRegistry: (tableName: string) => SessionRegistry; }; export const runtimeDependencies: IAsyncBiometricTokenDependencies = { env: process.env, getSecrets: getSecretsFromParameterStore, getBiometricToken, + getSessionRegistry: (tableName: string) => new DynamoDbAdapter(tableName), }; diff --git a/backend-api/src/functions/common/lambdaResponses.ts b/backend-api/src/functions/common/lambdaResponses.ts index 465f3100..2c78698f 100644 --- a/backend-api/src/functions/common/lambdaResponses.ts +++ b/backend-api/src/functions/common/lambdaResponses.ts @@ -22,6 +22,20 @@ export const badRequestResponse = ( }; }; +export const unauthorizedResponse = ( + error: string, + errorDescription: string, +): APIGatewayProxyResult => { + return { + headers: securityHeaders, + statusCode: 401, + body: JSON.stringify({ + error, + error_description: errorDescription, + }), + }; +}; + export const notImplementedResponse: APIGatewayProxyResult = { headers: securityHeaders, statusCode: 501, diff --git a/backend-api/src/functions/common/logging/LogMessage.ts b/backend-api/src/functions/common/logging/LogMessage.ts index 3bfcd1e1..8e9bf43e 100644 --- a/backend-api/src/functions/common/logging/LogMessage.ts +++ b/backend-api/src/functions/common/logging/LogMessage.ts @@ -17,6 +17,26 @@ export class LogMessage implements LogAttributes { "Failed to retrieve one or more secrets from SSM Parameter Store.", ); + static readonly UPDATE_SESSION_ATTEMPT = new LogMessage( + "MOBILE_ASYNC_UPDATE_SESSION_ATTEMPT", + "Attempting to update user session in DynamoDB.", + ); + + static readonly UPDATE_SESSION_SUCCESS = new LogMessage( + "MOBILE_ASYNC_UPDATE_SESSION_SUCCESS", + "Successfully updated user session in DynamoDB.", + ); + + static readonly UPDATE_SESSION_UNEXPECTED_FAILURE = new LogMessage( + "MOBILE_ASYNC_UPDATE_SESSION_UNEXPECTED_FAILURE", + "An unexpected failure occurred while trying to update the user session in DynamoDB.", + ); + + static readonly UPDATE_SESSION_CONDITIONAL_CHECK_FAILURE = new LogMessage( + "MOBILE_ASYNC_UPDATE_SESSION_CONDITIONAL_CHECK_FAILURE", + "One or more required conditions were not met when trying to update the user session in DynamoDB.", + ); + // Biometric Token static readonly BIOMETRIC_TOKEN_STARTED = new LogMessage( "MOBILE_ASYNC_BIOMETRIC_TOKEN_STARTED", diff --git a/backend-api/src/functions/common/session/SessionRegistry.ts b/backend-api/src/functions/common/session/SessionRegistry.ts new file mode 100644 index 00000000..d53431d0 --- /dev/null +++ b/backend-api/src/functions/common/session/SessionRegistry.ts @@ -0,0 +1,14 @@ +import { Result } from "../../utils/result"; +import { UpdateSessionOperation } from "./updateOperations/UpdateSessionOperation"; + +export interface SessionRegistry { + updateSession( + sessionId: string, + updateOperation: UpdateSessionOperation, + ): Promise>; +} + +export enum UpdateSessionError { + INTERNAL_SERVER_ERROR = "INTERNAL_SERVER_ERROR", + CONDITIONAL_CHECK_FAILURE = "CONDITIONAL_CHECK_FAILURE", +} diff --git a/backend-api/src/functions/common/session/session.ts b/backend-api/src/functions/common/session/session.ts new file mode 100644 index 00000000..06b85210 --- /dev/null +++ b/backend-api/src/functions/common/session/session.ts @@ -0,0 +1,4 @@ +export enum SessionState { + AUTH_SESSION_CREATED = "ASYNC_AUTH_SESSION_CREATED", + BIOMETRIC_TOKEN_ISSUED = "ASYNC_BIOMETRIC_TOKEN_ISSUED", +} diff --git a/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.test.ts b/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.test.ts new file mode 100644 index 00000000..42ad5f9c --- /dev/null +++ b/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.test.ts @@ -0,0 +1,44 @@ +import { BiometricTokenIssued } from "./BiometricTokenIssued"; +import { SessionState } from "../../session"; + +describe("BiometricTokenIssued", () => { + let biometricTokenIssued: BiometricTokenIssued; + + beforeEach(() => { + biometricTokenIssued = new BiometricTokenIssued( + "NFC_PASSPORT", + "mockOpaqueId", + ); + }); + + describe("When I request the DynamoDB UpdateExpression", () => { + it("Returns the appropriate UpdateExpression string", () => { + const result = biometricTokenIssued.getDynamoDbUpdateExpression(); + expect(result).toEqual( + "set documentType = :documentType, opaqueId = :opaqueId, sessionState = :biometricTokenIssued", + ); + }); + }); + + describe("When I request the DynamoDB ConditionExpression", () => { + it("Returns the appropriate ConditionExpression string", () => { + const result = biometricTokenIssued.getDynamoDbConditionExpression(); + expect(result).toEqual( + "attribute_exists(sessionId) AND sessionState in (:authSessionCreated)", + ); + }); + }); + + describe("When I request the ExpressionAttributeValues", () => { + it("Returns the ExpressionAttributeValues with the correct session state", () => { + const result = + biometricTokenIssued.getDynamoDbExpressionAttributeValues(); + expect(result).toEqual({ + ":documentType": { S: "NFC_PASSPORT" }, + ":opaqueId": { S: "mockOpaqueId" }, + ":biometricTokenIssued": { S: SessionState.BIOMETRIC_TOKEN_ISSUED }, + ":authSessionCreated": { S: SessionState.AUTH_SESSION_CREATED }, + }); + }); + }); +}); diff --git a/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.ts b/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.ts new file mode 100644 index 00000000..a1d80235 --- /dev/null +++ b/backend-api/src/functions/common/session/updateOperations/BiometricTokenIssued/BiometricTokenIssued.ts @@ -0,0 +1,27 @@ +import { UpdateSessionOperation } from "../UpdateSessionOperation"; +import { DocumentType } from "../../../../types/document"; +import { SessionState } from "../../session"; + +export class BiometricTokenIssued implements UpdateSessionOperation { + constructor( + private readonly documentType: DocumentType, + private readonly opaqueId: string, + ) {} + + getDynamoDbUpdateExpression() { + return "set documentType = :documentType, opaqueId = :opaqueId, sessionState = :biometricTokenIssued"; + } + + getDynamoDbConditionExpression(): string { + return `attribute_exists(sessionId) AND sessionState in (:authSessionCreated)`; + } + + getDynamoDbExpressionAttributeValues() { + return { + ":documentType": { S: this.documentType }, + ":opaqueId": { S: this.opaqueId }, + ":biometricTokenIssued": { S: SessionState.BIOMETRIC_TOKEN_ISSUED }, + ":authSessionCreated": { S: SessionState.AUTH_SESSION_CREATED }, + }; + } +} diff --git a/backend-api/src/functions/common/session/updateOperations/UpdateSessionOperation.ts b/backend-api/src/functions/common/session/updateOperations/UpdateSessionOperation.ts new file mode 100644 index 00000000..d3746b05 --- /dev/null +++ b/backend-api/src/functions/common/session/updateOperations/UpdateSessionOperation.ts @@ -0,0 +1,7 @@ +import { AttributeValue } from "@aws-sdk/client-dynamodb"; + +export interface UpdateSessionOperation { + getDynamoDbUpdateExpression(): string; + getDynamoDbConditionExpression(): string | undefined; + getDynamoDbExpressionAttributeValues(): Record; +} diff --git a/backend-api/src/functions/testUtils/unitTestData.ts b/backend-api/src/functions/testUtils/unitTestData.ts index 3efafa3a..3544f8d6 100644 --- a/backend-api/src/functions/testUtils/unitTestData.ts +++ b/backend-api/src/functions/testUtils/unitTestData.ts @@ -1,3 +1,5 @@ +import { SessionRegistry } from "../common/session/SessionRegistry"; + export const mockSessionId = "58f4281d-d988-49ce-9586-6ef70a2be0b4"; export const expectedSecurityHeaders = { @@ -9,3 +11,9 @@ export const expectedSecurityHeaders = { }; export const NOW_IN_MILLISECONDS: number = 1704110400000; // 2024-01-01 12:00:00.000 + +export const mockInertSessionRegistry: SessionRegistry = { + updateSession: jest.fn(() => { + throw new Error("Not implemented"); + }), +}; diff --git a/backend-api/template.yaml b/backend-api/template.yaml index 2d588fcc..e24943fe 100644 --- a/backend-api/template.yaml +++ b/backend-api/template.yaml @@ -1160,7 +1160,13 @@ Resources: - !Sub - arn:aws:ssm:${AWS::Region}:${AWS::AccountId}:parameter${submitterKeyPathDl} - submitterKeyPathDl: !FindInMap [ EnvironmentVariables, !Ref Environment, BiometricSubmitterKeySecretPathDl ] - + - PolicyName: AsyncBiometricTokenFunctionDynamodbPolicy + PolicyDocument: + Statement: + - Effect: Allow + Action: + - "dynamodb:UpdateItem" + Resource: !GetAtt SessionsTable.Arn PermissionsBoundary: !If - UsePermissionsBoundary - !Ref PermissionsBoundary diff --git a/backend-api/tests/api-tests/activeSession.test.ts b/backend-api/tests/api-tests/activeSession.test.ts index 999928f6..8368298e 100644 --- a/backend-api/tests/api-tests/activeSession.test.ts +++ b/backend-api/tests/api-tests/activeSession.test.ts @@ -1,10 +1,6 @@ import { randomUUID } from "crypto"; -import { - PROXY_API_INSTANCE, - SESSIONS_API_INSTANCE, - STS_MOCK_API_INSTANCE, -} from "./utils/apiInstance"; -import { getFirstRegisteredClient } from "./utils/getRegisteredClient"; +import { SESSIONS_API_INSTANCE } from "./utils/apiInstance"; +import { createSessionForSub, getAccessToken } from "./utils/apiTestHelpers"; jest.setTimeout(4 * 5000); @@ -142,49 +138,3 @@ describe("GET /async/activeSession", () => { }); }); }); - -async function getAccessToken(sub?: string, scope?: string) { - const requestBody = new URLSearchParams({ - subject_token: sub ?? randomUUID(), - scope: scope ?? "idCheck.activeSession.read", - grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", - subject_token_type: "urn:ietf:params:oauth:token-type:access_token", - }); - const stsMockResponse = await STS_MOCK_API_INSTANCE.post( - "/token", - requestBody, - ); - return stsMockResponse.data.access_token; -} - -async function createSessionForSub(sub: string) { - const clientDetails = await getFirstRegisteredClient(); - const clientIdAndSecret = `${clientDetails.client_id}:${clientDetails.client_secret}`; - const clientIdAndSecretB64 = - Buffer.from(clientIdAndSecret).toString("base64"); - const asyncTokenResponse = await PROXY_API_INSTANCE.post( - "/async/token", - "grant_type=client_credentials", - { - headers: { - "x-custom-auth": `Basic ${clientIdAndSecretB64}`, - }, - }, - ); - const asyncCredentialResponse = await PROXY_API_INSTANCE.post( - "/async/credential", - { - sub: sub ?? randomUUID(), - govuk_signin_journey_id: "44444444-4444-4444-4444-444444444444", - client_id: clientDetails.client_id, - state: "testState", - redirect_uri: clientDetails.redirect_uri, - }, - { - headers: { - "x-custom-auth": `Bearer ${asyncTokenResponse.data.access_token}`, - }, - }, - ); - return asyncCredentialResponse.data; -} diff --git a/backend-api/tests/api-tests/biometricToken.test.ts b/backend-api/tests/api-tests/biometricToken.test.ts index 6f358255..79bcbe30 100644 --- a/backend-api/tests/api-tests/biometricToken.test.ts +++ b/backend-api/tests/api-tests/biometricToken.test.ts @@ -1,5 +1,6 @@ import { SESSIONS_API_INSTANCE } from "./utils/apiInstance"; import { expectedSecurityHeaders, mockSessionId } from "./utils/apiTestData"; +import { getValidSessionId } from "./utils/apiTestHelpers"; describe("POST /async/biometricToken", () => { describe("Given request body is invalid", () => { @@ -28,8 +29,14 @@ describe("POST /async/biometricToken", () => { describe("Given there is a valid request", () => { it("Returns an error and 501 status code", async () => { + const sessionId = await getValidSessionId(); + if (!sessionId) + throw new Error( + "Failed to get valid session ID to call biometricToken endpoint", + ); + const requestBody = { - sessionId: mockSessionId, + sessionId, documentType: "NFC_PASSPORT", }; @@ -43,6 +50,6 @@ describe("POST /async/biometricToken", () => { expect(response.headers).toEqual( expect.objectContaining(expectedSecurityHeaders), ); - }); + }, 15000); }); }); diff --git a/backend-api/tests/api-tests/clientCredentialsGrantFlow.test.ts b/backend-api/tests/api-tests/clientCredentialsGrantFlow.test.ts index 208b1f09..837a60e1 100644 --- a/backend-api/tests/api-tests/clientCredentialsGrantFlow.test.ts +++ b/backend-api/tests/api-tests/clientCredentialsGrantFlow.test.ts @@ -5,7 +5,7 @@ import { PRIVATE_API_INSTANCE, PROXY_API_INSTANCE } from "./utils/apiInstance"; import { ClientDetails, getFirstRegisteredClient, -} from "./utils/getRegisteredClient"; +} from "./utils/apiTestHelpers"; const getApisToTest = (): { apiName: string; diff --git a/backend-api/tests/api-tests/utils/apiTestHelpers.ts b/backend-api/tests/api-tests/utils/apiTestHelpers.ts new file mode 100644 index 00000000..fe303606 --- /dev/null +++ b/backend-api/tests/api-tests/utils/apiTestHelpers.ts @@ -0,0 +1,92 @@ +import { + GetSecretValueCommand, + SecretsManagerClient, +} from "@aws-sdk/client-secrets-manager"; +import { + PROXY_API_INSTANCE, + SESSIONS_API_INSTANCE, + STS_MOCK_API_INSTANCE, +} from "./apiInstance"; +import { randomUUID } from "crypto"; + +export interface ClientDetails { + client_id: string; + client_secret: string; + redirect_uri: string; +} + +async function getRegisteredClients(): Promise { + const secretsManagerClient = new SecretsManagerClient({ + region: "eu-west-2", + }); + const secretName = `${process.env.TEST_ENVIRONMENT}/clientRegistryApiTest`; + const command = new GetSecretValueCommand({ + SecretId: secretName, + }); + const response = await secretsManagerClient.send(command); + return JSON.parse(response.SecretString!); +} + +export async function getFirstRegisteredClient(): Promise { + const clientsDetails = await getRegisteredClients(); + return clientsDetails[0]; +} + +export async function getAccessToken(sub?: string, scope?: string) { + const requestBody = new URLSearchParams({ + subject_token: sub ?? randomUUID(), + scope: scope ?? "idCheck.activeSession.read", + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token_type: "urn:ietf:params:oauth:token-type:access_token", + }); + const stsMockResponse = await STS_MOCK_API_INSTANCE.post( + "/token", + requestBody, + ); + return stsMockResponse.data.access_token; +} + +export async function createSessionForSub(sub: string) { + const clientDetails = await getFirstRegisteredClient(); + const clientIdAndSecret = `${clientDetails.client_id}:${clientDetails.client_secret}`; + const clientIdAndSecretB64 = + Buffer.from(clientIdAndSecret).toString("base64"); + const asyncTokenResponse = await PROXY_API_INSTANCE.post( + "/async/token", + "grant_type=client_credentials", + { + headers: { + "x-custom-auth": `Basic ${clientIdAndSecretB64}`, + }, + }, + ); + const asyncCredentialResponse = await PROXY_API_INSTANCE.post( + "/async/credential", + { + sub: sub ?? randomUUID(), + govuk_signin_journey_id: "44444444-4444-4444-4444-444444444444", + client_id: clientDetails.client_id, + state: "testState", + redirect_uri: clientDetails.redirect_uri, + }, + { + headers: { + "x-custom-auth": `Bearer ${asyncTokenResponse.data.access_token}`, + }, + }, + ); + return asyncCredentialResponse.data; +} + +export async function getValidSessionId(): Promise { + const sub = randomUUID(); + await createSessionForSub(sub); + const serviceToken = await getAccessToken(sub); + const activeSessionResponse = await SESSIONS_API_INSTANCE.get( + "/async/activeSession", + { + headers: { Authorization: `Bearer ${serviceToken}` }, + }, + ); + return activeSessionResponse.data["sessionId"] ?? null; +} diff --git a/backend-api/tests/api-tests/utils/getRegisteredClient.ts b/backend-api/tests/api-tests/utils/getRegisteredClient.ts deleted file mode 100644 index 5280d452..00000000 --- a/backend-api/tests/api-tests/utils/getRegisteredClient.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { - GetSecretValueCommand, - SecretsManagerClient, -} from "@aws-sdk/client-secrets-manager"; - -export interface ClientDetails { - client_id: string; - client_secret: string; - redirect_uri: string; -} - -async function getRegisteredClients(): Promise { - const secretsManagerClient = new SecretsManagerClient({ - region: "eu-west-2", - }); - const secretName = `${process.env.TEST_ENVIRONMENT}/clientRegistryApiTest`; - const command = new GetSecretValueCommand({ - SecretId: secretName, - }); - const response = await secretsManagerClient.send(command); - return JSON.parse(response.SecretString!); -} - -export async function getFirstRegisteredClient(): Promise { - const clientsDetails = await getRegisteredClients(); - return clientsDetails[0]; -}