From c245172134ddb3bf498e988b7860faea3664490e Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Tue, 13 Feb 2024 16:35:20 +0000 Subject: [PATCH] 807: CDK auth stack (AWS Cognito) (#818) --- cloud/.gitignore | 1 + cloud/README.md | 33 +++++++++++ cloud/bin/cloud.ts | 11 ++++ cloud/cdk.json | 2 +- cloud/lib/api-stack.ts | 40 +++++++++++--- cloud/lib/auth-stack.ts | 119 ++++++++++++++++++++++++++++++++++++++++ cloud/lib/index.ts | 1 + cloud/package-lock.json | 12 ++++ cloud/package.json | 1 + 9 files changed, 212 insertions(+), 8 deletions(-) create mode 100644 cloud/lib/auth-stack.ts diff --git a/cloud/.gitignore b/cloud/.gitignore index a63355605..5d6598a37 100644 --- a/cloud/.gitignore +++ b/cloud/.gitignore @@ -1,6 +1,7 @@ *.js *.d.ts node_modules +.env # CDK asset staging directory .cdk.staging diff --git a/cloud/README.md b/cloud/README.md index 3f29e84ee..768394343 100644 --- a/cloud/README.md +++ b/cloud/README.md @@ -15,3 +15,36 @@ The `cdk.json` file tells the CDK Toolkit how to execute your app. _Caution! This will overwrite existing resources, so ensure the team are notified before running this manually._ Note that once the CDK Pipeline is in place, DEV deployment will happen automatically on merging into dev branch. + +--- + +## First-time admin tasks + +If you are setting up the CDK project for the first time, there are a few setup tasks you must complete. + +### Bootstrapping the CDK using a Developer Policy + +In order to deploy AWS resources to a remote environment using CDK, you must first +[bootstrap the CDK](https://docs.aws.amazon.com/cdk/v2/guide/bootstrapping.html). For this project, as per +[CDK guidelines](https://aws.amazon.com/blogs/devops/secure-cdk-deployments-with-iam-permission-boundaries/), we use a +lightweight permissions boundary to restrict permissions, to prevent creation of new users or roles with elevated +permissions. See `cdk-developer-policy.yaml` for details. + +Create the permissions boundary CloudFormation stack: + +``` +aws cloudformation create-stack \ + --stack-name CDKDeveloperPolicy \ + --template-body file://cdk-developer-policy.yaml \ + --capabilities CAPABILITY_NAMED_IAM +``` + +Then bootstrap the CDK: + +``` +# install dependencies if not already done +npm install + +# run the bootstrap command +npx cdk bootstrap --custom-permissions-boundary cdk-developer-policy +``` diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index e81eb0063..68acd0a62 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -22,7 +22,18 @@ const tags = { const generateStackName = stackName(app); const generateDescription = resourceDescription(app); +// Don't need this stack, yet... Or ever? Will ask Pete C. +/* +const authStack = new AuthStack(app, generateStackName('auth'), { + tags, + description: generateDescription('Auth stack'), +}); +*/ + new ApiStack(app, generateStackName('api'), { tags, description: generateDescription('API stack'), + // userPool: authStack.userPool, + // userPoolClient: authStack.userPoolClient, + // userPoolDomain: authStack.userPoolDomain, }); diff --git a/cloud/cdk.json b/cloud/cdk.json index e9955d143..d24901d95 100644 --- a/cloud/cdk.json +++ b/cloud/cdk.json @@ -1,5 +1,5 @@ { - "app": "npx ts-node --prefer-ts-exts bin/cloud.ts", + "app": "npx ts-node --prefer-ts-exts -r dotenv/config bin/cloud.ts", "watch": { "include": ["**"], "exclude": [ diff --git a/cloud/lib/api-stack.ts b/cloud/lib/api-stack.ts index 65c163ce7..f3ea5e3c8 100644 --- a/cloud/lib/api-stack.ts +++ b/cloud/lib/api-stack.ts @@ -1,4 +1,4 @@ -import { Stack, StackProps } from 'aws-cdk-lib/core'; +//import { UserPool, UserPoolClient, UserPoolDomain } from 'aws-cdk-lib/aws-cognito'; import { Vpc } from 'aws-cdk-lib/aws-ec2'; import { DockerImageAsset } from 'aws-cdk-lib/aws-ecr-assets'; import { @@ -7,18 +7,26 @@ import { Secret as EnvSecret, } from 'aws-cdk-lib/aws-ecs'; import { ApplicationLoadBalancedFargateService } from 'aws-cdk-lib/aws-ecs-patterns'; +//import { ListenerAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2'; +//import { AuthenticateCognitoAction } from 'aws-cdk-lib/aws-elasticloadbalancingv2-actions'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; +import { Stack, StackProps } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; import { join } from 'node:path'; -import { resourceName, stageName } from './resourceNamingUtils'; +import { resourceName } from './resourceNamingUtils'; -export class ApiStack extends Stack { - stage: string; +type ApiStackProps = StackProps & { + // userPool: UserPool; + // userPoolClient: UserPoolClient; + // userPoolDomain: UserPoolDomain; +}; - constructor(scope: Construct, id: string, props: StackProps) { +export class ApiStack extends Stack { + constructor(scope: Construct, id: string, props: ApiStackProps) { super(scope, id, props); - this.stage = stageName(scope); + // TODO Enable cognito auth + //const { userPool, userPoolClient, userPoolDomain } = props; const generateResourceName = resourceName(scope); @@ -45,6 +53,7 @@ export class ApiStack extends Stack { // Create a load-balanced Fargate service and make it public const containerPort = 3001; const serviceName = generateResourceName('fargate'); + const loadBalancerName = generateResourceName('alb'); const fargateService = new ApplicationLoadBalancedFargateService( this, serviceName, @@ -72,10 +81,27 @@ export class ApiStack extends Stack { }, }, memoryLimitMiB: 512, // Default is 512 - loadBalancerName: generateResourceName('elb'), + loadBalancerName, publicLoadBalancer: true, // Default is true } ); + + // Hook up Cognito to load balancer + // https://stackoverflow.com/q/71124324 + // TODO This needs HTTPS and a Route53 domain, so in meantime try VPCLink: + // https://repost.aws/knowledge-center/api-gateway-alb-integration + /* + const authActionName = generateResourceName('alb-auth'); + fargateService.listener.addAction(authActionName, { + action: new AuthenticateCognitoAction({ + userPool, + userPoolClient, + userPoolDomain, + next: ListenerAction.forward([fargateService.targetGroup]), + }), + }); + */ + fargateService.targetGroup.configureHealthCheck({ path: '/health' }); } } diff --git a/cloud/lib/auth-stack.ts b/cloud/lib/auth-stack.ts new file mode 100644 index 000000000..be965edfd --- /dev/null +++ b/cloud/lib/auth-stack.ts @@ -0,0 +1,119 @@ +import { + Mfa, + OAuthScope, + ProviderAttribute, + UserPool, + UserPoolClient, + UserPoolClientIdentityProvider, + UserPoolDomain, + UserPoolIdentityProviderSaml, + UserPoolIdentityProviderSamlMetadata, +} from 'aws-cdk-lib/aws-cognito'; +import { CfnOutput, Duration, Stack, StackProps, Tags } from 'aws-cdk-lib/core'; +import { Construct } from 'constructs'; + +import { resourceName } from './resourceNamingUtils'; + +export class AuthStack extends Stack { + userPool: UserPool; + userPoolClient: UserPoolClient; + userPoolDomain: UserPoolDomain; + + constructor(scope: Construct, id: string, props: StackProps) { + super(scope, id, props); + + const azureTenantId = process.env.AZURE_TENANT_ID; + const azureApplicationId = process.env.AZURE_APPLICATION_ID; + if (!azureTenantId || !azureApplicationId) { + throw new Error( + 'Need AZURE_TENANT_ID and AZURE_APPLICATION_ID environment vars!' + ); + } + + const generateResourceName = resourceName(scope); + + // Cognito UserPool + const userPoolName = generateResourceName('userpool'); + this.userPool = new UserPool(this, userPoolName, { + userPoolName, + enableSmsRole: false, + mfa: Mfa.OFF, + //email // not configured, we're not going to send email from here + signInCaseSensitive: false, + autoVerify: { email: false }, // will be sending email invite anyway + selfSignUpEnabled: false, // only users we explicity allow + standardAttributes: { + givenName: { required: true }, + familyName: { required: true }, + email: { required: true }, + }, + signInAliases: { email: true }, + deletionProtection: false, + }); + // Tags not correctly assigned from parent stack: https://github.com/aws/aws-cdk/issues/14127 + Object.entries(props.tags ?? {}).forEach(([key, value]) => { + Tags.of(this.userPool).add(key, value); + }); + + new CfnOutput(this, 'UserPool.Identifier', { + value: `urn:amazon:cognito:sp:${this.userPool.userPoolId}`, + }); + new CfnOutput(this, 'UserPool.ReplyUrl', { + value: `https://${userPoolName}.auth.${this.region}.amazoncognito.com/saml2/idpresponse`, + }); + + const idpName = generateResourceName('userpool-idp'); + const identityProvider = new UserPoolIdentityProviderSaml(this, idpName, { + name: idpName, + idpSignout: true, + metadata: UserPoolIdentityProviderSamlMetadata.url( + `https://login.microsoftonline.com/${azureTenantId}/federationmetadata/2007-06/federationmetadata.xml?appid=${azureApplicationId}` + ), + userPool: this.userPool, + attributeMapping: { + email: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ), + familyName: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname' + ), + givenName: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname' + ), + }, + }); + + const userPoolClientName = generateResourceName('userpool-client'); + this.userPoolClient = this.userPool.addClient(userPoolClientName, { + userPoolClientName, + authFlows: { + userSrp: true, + }, + supportedIdentityProviders: [ + UserPoolClientIdentityProvider.custom(identityProvider.providerName), + ], + generateSecret: true, + oAuth: { + flows: { + authorizationCodeGrant: true, + }, + scopes: [OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE], + //callbackUrls, + //logoutUrls: callbackUrls, + }, + accessTokenValidity: Duration.minutes(60), + idTokenValidity: Duration.minutes(60), + refreshTokenValidity: Duration.days(30), + authSessionValidity: Duration.minutes(3), + enableTokenRevocation: true, + preventUserExistenceErrors: true, + }); + + const userPoolDomainName = generateResourceName('userpool-domain'); + this.userPoolDomain = this.userPool.addDomain(userPoolDomainName, { + cognitoDomain: { + domainPrefix: userPoolName, + }, + }); + } +} diff --git a/cloud/lib/index.ts b/cloud/lib/index.ts index a9f81ae16..7e4b6f799 100644 --- a/cloud/lib/index.ts +++ b/cloud/lib/index.ts @@ -1,2 +1,3 @@ export * from './resourceNamingUtils'; export { ApiStack } from './api-stack'; +export { AuthStack } from './auth-stack'; diff --git a/cloud/package-lock.json b/cloud/package-lock.json index efe4deb76..fe3850df4 100644 --- a/cloud/package-lock.json +++ b/cloud/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "aws-cdk-lib": "^2.104.0", "constructs": "^10.3.0", + "dotenv": "^16.3.1", "source-map-support": "^0.5.21" }, "bin": { @@ -1158,6 +1159,17 @@ "node": ">=6.0.0" } }, + "node_modules/dotenv": { + "version": "16.3.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.3.1.tgz", + "integrity": "sha512-IPzF4w4/Rd94bA9imS68tZBaYyBWSCE47V1RGuMrB94iyTOIEwRmVL2x/4An+6mETpLrKJ5hQkB8W4kFAadeIQ==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/motdotla/dotenv?sponsor=1" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/cloud/package.json b/cloud/package.json index a632ecc0e..871a4dd4e 100644 --- a/cloud/package.json +++ b/cloud/package.json @@ -29,6 +29,7 @@ "dependencies": { "aws-cdk-lib": "^2.104.0", "constructs": "^10.3.0", + "dotenv": "^16.3.1", "source-map-support": "^0.5.21" } }