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
10 changes: 1 addition & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,8 @@ You need the following tools to deploy this sample:
* [Node.js](https://nodejs.org/en/download/) (>= v20)
* [Docker](https://docs.docker.com/get-docker/)
* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and a configured IAM profile
* A public domain name configured as a Hosted Zone in Amazon Route53 (If you do not like this requirement, please [upvote the issue #47](https://github.com/aws-samples/serverless-full-stack-webapp-starter-kit/issues/47).)

Before deployment, you need to update the domain name in [`bin/cdk.ts`](cdk/bin/cdk.ts) to use your own domain that is configured as a Hosted Zone in Route53:

```typescript
const props: EnvironmentProps = {
// ...
domainName: 'your-domain.example.com', // Replace with your domain name
};
```
Before deployment, you can update the configuration variables in [`bin/cdk.ts`](cdk/bin/cdk.ts). Please read the comments in the code.

Then run the following commands:

Expand Down
16 changes: 14 additions & 2 deletions cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,25 @@ const app = new cdk.App();

interface EnvironmentProps {
account: string;
domainName: string;

/**
* Custom domain name for the webapp and Cognito.
* You need to have a public Route53 hosted zone for the domain name in your AWS account.
*
* @default No custom domain name.
*/
domainName?: string;

/**
* Use a NAT instance instead of NAT Gateways.
* @default true
*/
useNatInstance?: boolean;
}

const props: EnvironmentProps = {
account: process.env.CDK_DEFAULT_ACCOUNT!,
domainName: 'FIXME.example.com',
// domainName: 'FIXME.example.com',
useNatInstance: true,
};

Expand Down
91 changes: 0 additions & 91 deletions cdk/lib/constructs/auth.ts

This file was deleted.

1 change: 1 addition & 0 deletions cdk/lib/constructs/auth/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
!prefix-generator.js
160 changes: 160 additions & 0 deletions cdk/lib/constructs/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { CfnOutput, CfnResource, CustomResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
import { CfnManagedLoginBranding, ManagedLoginVersion, UserPool, UserPoolClient } from 'aws-cdk-lib/aws-cognito';
import { Code, Runtime, SingletonFunction } from 'aws-cdk-lib/aws-lambda';
import { CnameRecord, IHostedZone } from 'aws-cdk-lib/aws-route53';
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
import { Construct } from 'constructs';
import { readFileSync } from 'fs';
import { join } from 'path';

export interface AuthProps {
readonly hostedZone?: IHostedZone;
readonly sharedCertificate?: ICertificate;
}

export class Auth extends Construct {
readonly userPool: UserPool;
readonly client: UserPoolClient;
readonly domainName: string;

private callbackUrlCount = 0;

constructor(scope: Construct, id: string, props: AuthProps) {
super(scope, id);
const { hostedZone } = props;
const subDomain = 'auth';
let domainPrefix = '';
if (!hostedZone) {
// When we do not use a custom domain, we must make domainPrefix unique in the AWS region.
// To avoid a collision, we generate a random string with CFn custom resource.
const generator = new SingletonFunction(this, 'RandomStringGenerator', {
runtime: Runtime.NODEJS_22_X,
handler: 'index.handler',
timeout: Duration.seconds(5),
lambdaPurpose: 'RandomStringGenerator',
uuid: '11e9c903-f11a-4989-833c-985dddef5eb2',
code: Code.fromInline(readFileSync(join(__dirname, 'prefix-generator.js')).toString()),
});

const domainPrefixResource = new CustomResource(this, 'DomainPrefix', {
serviceToken: generator.functionArn,
resourceType: 'Custom::RandomString',
properties: { prefix: 'webapp-', length: 10 },
serviceTimeout: Duration.seconds(10),
});
domainPrefix = domainPrefixResource.getAttString('generated');
}

this.domainName = hostedZone
? `${subDomain}.${hostedZone.zoneName}`
: `${domainPrefix}.auth.${Stack.of(this).region}.amazoncognito.com`;

const userPool = new UserPool(this, 'UserPool', {
passwordPolicy: {
requireUppercase: true,
requireSymbols: true,
requireDigits: true,
minLength: 8,
},
selfSignUpEnabled: true,
signInAliases: {
username: false,
email: true,
},
removalPolicy: RemovalPolicy.DESTROY,
});

const client = userPool.addClient(`Client`, {
idTokenValidity: Duration.days(1),
authFlows: {
userPassword: true,
userSrp: true,
},
oAuth: {
flows: {
authorizationCodeGrant: true,
},
callbackUrls: ['http://localhost/dummy'],
logoutUrls: ['http://localhost/dummy'],
},
});

this.client = client;
this.userPool = userPool;

const domain = userPool.addDomain('CognitoDomain', {
...(hostedZone && props.sharedCertificate
? {
customDomain: {
domainName: this.domainName,
certificate: props.sharedCertificate,
},
}
: {
cognitoDomain: {
domainPrefix,
},
}),
managedLoginVersion: ManagedLoginVersion.NEWER_MANAGED_LOGIN,
});

if (hostedZone) {
new CnameRecord(this, 'CognitoDomainRecord', {
zone: hostedZone,
recordName: subDomain,
domainName: domain.cloudFrontEndpoint,
});
}

new CfnManagedLoginBranding(this, 'Branding', {
userPoolId: this.userPool.userPoolId,
clientId: client.userPoolClientId,
useCognitoProvidedValues: true,
});

new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
new CfnOutput(this, 'UserPoolClientId', { value: client.userPoolClientId });
}

public addAllowedCallbackUrls(callbackUrl: string, logoutUrl: string) {
const resource = this.client.node.defaultChild;
if (!CfnResource.isCfnResource(resource)) {
throw new Error('Expected CfnResource');
}
resource.addPropertyOverride(`CallbackURLs.${this.callbackUrlCount}`, callbackUrl);
resource.addPropertyOverride(`LogoutURLs.${this.callbackUrlCount}`, logoutUrl);
this.callbackUrlCount += 1;
}

public updateAllowedCallbackUrls(callbackUrls: string[], logoutUrls: string[]) {
// Lambda depends on userPoolClientId but userPoolClient depends on the CloudFront domain name (callback URL) which depends on Lambda (fURL).
// To avoid the circular dependency, we update the callback URL after a userPoolClientId is created.
// We only use this when custom domain is not used.
new AwsCustomResource(this, 'UpdateCallbackUrls', {
onUpdate: {
service: '@aws-sdk/client-cognito-identity-provider',
action: 'updateUserPoolClient',
parameters: {
ClientId: this.client.userPoolClientId,
UserPoolId: this.userPool.userPoolId,
AllowedOAuthFlows: ['code'],
AllowedOAuthFlowsUserPoolClient: true,
AllowedOAuthScopes: ['profile', 'phone', 'email', 'openid', 'aws.cognito.signin.user.admin'],
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
CallbackURLs: callbackUrls,
LogoutURLs: logoutUrls,
SupportedIdentityProviders: ['COGNITO'],
TokenValidityUnits: {
IdToken: 'minutes',
},
IdTokenValidity: 1440,
},
physicalResourceId: PhysicalResourceId.of(this.userPool.userPoolId),
},
policy: AwsCustomResourcePolicy.fromSdkCalls({
resources: [this.userPool.userPoolArn],
}),
});
}
}
34 changes: 34 additions & 0 deletions cdk/lib/constructs/auth/prefix-generator.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const response = require('cfn-response');
const crypto = require('crypto');

exports.handler = async function (event, context) {
try {
console.log(event);
if (event.RequestType == 'Delete') {
return await response.send(event, context, response.SUCCESS);
}

const prefix = event.ResourceProperties.prefix ?? '';
const length = event.ResourceProperties.length ?? '8';
const generate = () => {
const random = crypto.randomBytes(parseInt(length)).toString('hex');
return `${prefix}${random.slice(0, length)}`;
};

if (event.RequestType == 'Create') {
const generated = generate();
return await response.send(event, context, response.SUCCESS, { generated }, generated);
}
if (event.RequestType == 'Update') {
const current = event.PhysicalResourceId;
if (current.startsWith(prefix)) {
return await response.send(event, context, response.SUCCESS, { generated: current }, current);
}
const generated = generate();
return await response.send(event, context, response.SUCCESS, { generated }, generated);
}
} catch (e) {
console.log(e);
await response.send(event, context, response.FAILED);
}
};
Loading