Skip to content

Commit da35fb1

Browse files
authored
Allow deploying without custom domain (#51)
*Issue #, if available:* closes #47 *Description of changes:* By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.
1 parent 48db114 commit da35fb1

21 files changed

Lines changed: 5455 additions & 330 deletions

README.md

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,8 @@ You need the following tools to deploy this sample:
5353
* [Node.js](https://nodejs.org/en/download/) (>= v20)
5454
* [Docker](https://docs.docker.com/get-docker/)
5555
* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) and a configured IAM profile
56-
* 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).)
5756

58-
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:
59-
60-
```typescript
61-
const props: EnvironmentProps = {
62-
// ...
63-
domainName: 'your-domain.example.com', // Replace with your domain name
64-
};
65-
```
57+
Before deployment, you can update the configuration variables in [`bin/cdk.ts`](cdk/bin/cdk.ts). Please read the comments in the code.
6658

6759
Then run the following commands:
6860

cdk/bin/cdk.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,25 @@ const app = new cdk.App();
88

99
interface EnvironmentProps {
1010
account: string;
11-
domainName: string;
11+
12+
/**
13+
* Custom domain name for the webapp and Cognito.
14+
* You need to have a public Route53 hosted zone for the domain name in your AWS account.
15+
*
16+
* @default No custom domain name.
17+
*/
18+
domainName?: string;
19+
20+
/**
21+
* Use a NAT instance instead of NAT Gateways.
22+
* @default true
23+
*/
1224
useNatInstance?: boolean;
1325
}
1426

1527
const props: EnvironmentProps = {
1628
account: process.env.CDK_DEFAULT_ACCOUNT!,
17-
domainName: 'FIXME.example.com',
29+
// domainName: 'FIXME.example.com',
1830
useNatInstance: true,
1931
};
2032

cdk/lib/constructs/auth.ts

Lines changed: 0 additions & 91 deletions
This file was deleted.

cdk/lib/constructs/auth/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
!prefix-generator.js

cdk/lib/constructs/auth/index.ts

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { CfnOutput, CfnResource, CustomResource, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib';
2+
import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager';
3+
import { CfnManagedLoginBranding, ManagedLoginVersion, UserPool, UserPoolClient } from 'aws-cdk-lib/aws-cognito';
4+
import { Code, Runtime, SingletonFunction } from 'aws-cdk-lib/aws-lambda';
5+
import { CnameRecord, IHostedZone } from 'aws-cdk-lib/aws-route53';
6+
import { AwsCustomResource, AwsCustomResourcePolicy, PhysicalResourceId } from 'aws-cdk-lib/custom-resources';
7+
import { Construct } from 'constructs';
8+
import { readFileSync } from 'fs';
9+
import { join } from 'path';
10+
11+
export interface AuthProps {
12+
readonly hostedZone?: IHostedZone;
13+
readonly sharedCertificate?: ICertificate;
14+
}
15+
16+
export class Auth extends Construct {
17+
readonly userPool: UserPool;
18+
readonly client: UserPoolClient;
19+
readonly domainName: string;
20+
21+
private callbackUrlCount = 0;
22+
23+
constructor(scope: Construct, id: string, props: AuthProps) {
24+
super(scope, id);
25+
const { hostedZone } = props;
26+
const subDomain = 'auth';
27+
let domainPrefix = '';
28+
if (!hostedZone) {
29+
// When we do not use a custom domain, we must make domainPrefix unique in the AWS region.
30+
// To avoid a collision, we generate a random string with CFn custom resource.
31+
const generator = new SingletonFunction(this, 'RandomStringGenerator', {
32+
runtime: Runtime.NODEJS_22_X,
33+
handler: 'index.handler',
34+
timeout: Duration.seconds(5),
35+
lambdaPurpose: 'RandomStringGenerator',
36+
uuid: '11e9c903-f11a-4989-833c-985dddef5eb2',
37+
code: Code.fromInline(readFileSync(join(__dirname, 'prefix-generator.js')).toString()),
38+
});
39+
40+
const domainPrefixResource = new CustomResource(this, 'DomainPrefix', {
41+
serviceToken: generator.functionArn,
42+
resourceType: 'Custom::RandomString',
43+
properties: { prefix: 'webapp-', length: 10 },
44+
serviceTimeout: Duration.seconds(10),
45+
});
46+
domainPrefix = domainPrefixResource.getAttString('generated');
47+
}
48+
49+
this.domainName = hostedZone
50+
? `${subDomain}.${hostedZone.zoneName}`
51+
: `${domainPrefix}.auth.${Stack.of(this).region}.amazoncognito.com`;
52+
53+
const userPool = new UserPool(this, 'UserPool', {
54+
passwordPolicy: {
55+
requireUppercase: true,
56+
requireSymbols: true,
57+
requireDigits: true,
58+
minLength: 8,
59+
},
60+
selfSignUpEnabled: true,
61+
signInAliases: {
62+
username: false,
63+
email: true,
64+
},
65+
removalPolicy: RemovalPolicy.DESTROY,
66+
});
67+
68+
const client = userPool.addClient(`Client`, {
69+
idTokenValidity: Duration.days(1),
70+
authFlows: {
71+
userPassword: true,
72+
userSrp: true,
73+
},
74+
oAuth: {
75+
flows: {
76+
authorizationCodeGrant: true,
77+
},
78+
callbackUrls: ['http://localhost/dummy'],
79+
logoutUrls: ['http://localhost/dummy'],
80+
},
81+
});
82+
83+
this.client = client;
84+
this.userPool = userPool;
85+
86+
const domain = userPool.addDomain('CognitoDomain', {
87+
...(hostedZone && props.sharedCertificate
88+
? {
89+
customDomain: {
90+
domainName: this.domainName,
91+
certificate: props.sharedCertificate,
92+
},
93+
}
94+
: {
95+
cognitoDomain: {
96+
domainPrefix,
97+
},
98+
}),
99+
managedLoginVersion: ManagedLoginVersion.NEWER_MANAGED_LOGIN,
100+
});
101+
102+
if (hostedZone) {
103+
new CnameRecord(this, 'CognitoDomainRecord', {
104+
zone: hostedZone,
105+
recordName: subDomain,
106+
domainName: domain.cloudFrontEndpoint,
107+
});
108+
}
109+
110+
new CfnManagedLoginBranding(this, 'Branding', {
111+
userPoolId: this.userPool.userPoolId,
112+
clientId: client.userPoolClientId,
113+
useCognitoProvidedValues: true,
114+
});
115+
116+
new CfnOutput(this, 'UserPoolId', { value: userPool.userPoolId });
117+
new CfnOutput(this, 'UserPoolClientId', { value: client.userPoolClientId });
118+
}
119+
120+
public addAllowedCallbackUrls(callbackUrl: string, logoutUrl: string) {
121+
const resource = this.client.node.defaultChild;
122+
if (!CfnResource.isCfnResource(resource)) {
123+
throw new Error('Expected CfnResource');
124+
}
125+
resource.addPropertyOverride(`CallbackURLs.${this.callbackUrlCount}`, callbackUrl);
126+
resource.addPropertyOverride(`LogoutURLs.${this.callbackUrlCount}`, logoutUrl);
127+
this.callbackUrlCount += 1;
128+
}
129+
130+
public updateAllowedCallbackUrls(callbackUrls: string[], logoutUrls: string[]) {
131+
// Lambda depends on userPoolClientId but userPoolClient depends on the CloudFront domain name (callback URL) which depends on Lambda (fURL).
132+
// To avoid the circular dependency, we update the callback URL after a userPoolClientId is created.
133+
// We only use this when custom domain is not used.
134+
new AwsCustomResource(this, 'UpdateCallbackUrls', {
135+
onUpdate: {
136+
service: '@aws-sdk/client-cognito-identity-provider',
137+
action: 'updateUserPoolClient',
138+
parameters: {
139+
ClientId: this.client.userPoolClientId,
140+
UserPoolId: this.userPool.userPoolId,
141+
AllowedOAuthFlows: ['code'],
142+
AllowedOAuthFlowsUserPoolClient: true,
143+
AllowedOAuthScopes: ['profile', 'phone', 'email', 'openid', 'aws.cognito.signin.user.admin'],
144+
ExplicitAuthFlows: ['ALLOW_USER_PASSWORD_AUTH', 'ALLOW_USER_SRP_AUTH', 'ALLOW_REFRESH_TOKEN_AUTH'],
145+
CallbackURLs: callbackUrls,
146+
LogoutURLs: logoutUrls,
147+
SupportedIdentityProviders: ['COGNITO'],
148+
TokenValidityUnits: {
149+
IdToken: 'minutes',
150+
},
151+
IdTokenValidity: 1440,
152+
},
153+
physicalResourceId: PhysicalResourceId.of(this.userPool.userPoolId),
154+
},
155+
policy: AwsCustomResourcePolicy.fromSdkCalls({
156+
resources: [this.userPool.userPoolArn],
157+
}),
158+
});
159+
}
160+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const response = require('cfn-response');
2+
const crypto = require('crypto');
3+
4+
exports.handler = async function (event, context) {
5+
try {
6+
console.log(event);
7+
if (event.RequestType == 'Delete') {
8+
return await response.send(event, context, response.SUCCESS);
9+
}
10+
11+
const prefix = event.ResourceProperties.prefix ?? '';
12+
const length = event.ResourceProperties.length ?? '8';
13+
const generate = () => {
14+
const random = crypto.randomBytes(parseInt(length)).toString('hex');
15+
return `${prefix}${random.slice(0, length)}`;
16+
};
17+
18+
if (event.RequestType == 'Create') {
19+
const generated = generate();
20+
return await response.send(event, context, response.SUCCESS, { generated }, generated);
21+
}
22+
if (event.RequestType == 'Update') {
23+
const current = event.PhysicalResourceId;
24+
if (current.startsWith(prefix)) {
25+
return await response.send(event, context, response.SUCCESS, { generated: current }, current);
26+
}
27+
const generated = generate();
28+
return await response.send(event, context, response.SUCCESS, { generated }, generated);
29+
}
30+
} catch (e) {
31+
console.log(e);
32+
await response.send(event, context, response.FAILED);
33+
}
34+
};

0 commit comments

Comments
 (0)