Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion infrastructure/public-api.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
85 changes: 85 additions & 0 deletions infrastructure/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -1375,6 +1376,63 @@ Resources:
FunctionName: !Ref NinoCheckFunction.Alias
Principal: apigateway.amazonaws.com

IssueCredentialFunction:
Condition: IsDevLikeEnvironment
Type: AWS::Serverless::Function
Comment thread
barnabycollins marked this conversation as resolved.
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:
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions lambdas/common/src/types/access-token-index-session-item.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type AccessTokenIndexSessionItem = {
sessionId: string;
accessToken: string;
subject: string;
};
2 changes: 1 addition & 1 deletion lambdas/common/tests/config/function-config.test.ts
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
7 changes: 7 additions & 0 deletions lambdas/common/tests/mocks/mockData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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,
Expand Down
7 changes: 7 additions & 0 deletions lambdas/issue-credential/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { Config } from "jest";
import baseConfig from "../../jest.config.base";

export default {
...baseConfig,
displayName: "lambdas/issue-credential",
} satisfies Config;
37 changes: 37 additions & 0 deletions lambdas/issue-credential/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
81 changes: 81 additions & 0 deletions lambdas/issue-credential/src/handler.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResult> {
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<SessionItem>(
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);
28 changes: 28 additions & 0 deletions lambdas/issue-credential/src/helpers/retrieve-nino-user.ts
Original file line number Diff line number Diff line change
@@ -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<NinoUser> {
try {
const ninoUser = await getRecordBySessionId<NinoUser>(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");
}
}
Original file line number Diff line number Diff line change
@@ -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<string> {
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");
}
}
Loading
Loading