From ddef1e023538d35ecf637a30e654f10ebb06d8d2 Mon Sep 17 00:00:00 2001 From: Thijs Daniels Date: Thu, 29 Aug 2024 17:03:31 +0200 Subject: [PATCH] feat: next api auth --- .changeset/breezy-days-peel.md | 5 + .changeset/thick-lamps-hope.md | 5 + package-lock.json | 9 +- .../src/constructs/DockerCluster.ts | 2 + .../cdk-next-app/src/constructs/NextApp.ts | 10 + packages/cdk-site-distribution/package.json | 3 +- .../src/constructs/SiteDistribution.ts | 234 ++++++++++-------- 7 files changed, 156 insertions(+), 112 deletions(-) create mode 100644 .changeset/breezy-days-peel.md create mode 100644 .changeset/thick-lamps-hope.md diff --git a/.changeset/breezy-days-peel.md b/.changeset/breezy-days-peel.md new file mode 100644 index 00000000..946a33f1 --- /dev/null +++ b/.changeset/breezy-days-peel.md @@ -0,0 +1,5 @@ +--- +"@codedazur/cdk-site-distribution": minor +--- + +Additional behaviors are now supported. diff --git a/.changeset/thick-lamps-hope.md b/.changeset/thick-lamps-hope.md new file mode 100644 index 00000000..cee25127 --- /dev/null +++ b/.changeset/thick-lamps-hope.md @@ -0,0 +1,5 @@ +--- +"@codedazur/cdk-next-app": minor +--- + +Authentication is now disabled by default for the API route, even if authentication is prvided for the default behavior. diff --git a/package-lock.json b/package-lock.json index d85aac44..cf687478 100644 --- a/package-lock.json +++ b/package-lock.json @@ -77,7 +77,7 @@ "@codedazur/react-media": "^2.0.0", "@codedazur/react-notifications": "^0.1.5", "@codedazur/react-pagination": "^1.1.1", - "@codedazur/react-parallax": "^0.1.1", + "@codedazur/react-parallax": "^0.1.2", "@codedazur/react-preferences": "^1.0.1", "@codedazur/react-select": "^0.0.4", "@faker-js/faker": "^8.4.1", @@ -188,7 +188,7 @@ "@codedazur/react-media": "^2.0.0", "@codedazur/react-notifications": "^0.1.5", "@codedazur/react-pagination": "^1.1.1", - "@codedazur/react-parallax": "^0.1.1", + "@codedazur/react-parallax": "^0.1.2", "next": "^14.2.5", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -31518,7 +31518,8 @@ "version": "0.4.0", "license": "MIT", "dependencies": { - "@codedazur/cdk-cache-invalidator": "^1.2.1" + "@codedazur/cdk-cache-invalidator": "^1.2.1", + "@codedazur/essentials": "^1.10.1" }, "devDependencies": { "@types/node": "^20.14.10", @@ -32534,7 +32535,7 @@ }, "packages/react-parallax": { "name": "@codedazur/react-parallax", - "version": "0.1.1", + "version": "0.1.2", "license": "MIT", "dependencies": { "@codedazur/essentials": "^1.9.1", diff --git a/packages/cdk-docker-cluster/src/constructs/DockerCluster.ts b/packages/cdk-docker-cluster/src/constructs/DockerCluster.ts index dd4e793e..1a5bcfaa 100644 --- a/packages/cdk-docker-cluster/src/constructs/DockerCluster.ts +++ b/packages/cdk-docker-cluster/src/constructs/DockerCluster.ts @@ -52,6 +52,8 @@ interface SourceProps { /** * An Docker cluster on a load balanced Fargate service on EC2, using an image * built from a Dockerfile in a directory and pushed to ECR. + * @todo Make the distribution and laod balancer optional, to reduce cost and + * increase deployment speed for development environments. */ export class DockerCluster extends Construct { public readonly domain?: string; diff --git a/packages/cdk-next-app/src/constructs/NextApp.ts b/packages/cdk-next-app/src/constructs/NextApp.ts index b788dc23..b5ffd5ed 100644 --- a/packages/cdk-next-app/src/constructs/NextApp.ts +++ b/packages/cdk-next-app/src/constructs/NextApp.ts @@ -34,6 +34,16 @@ export class NextApp extends DockerCluster { port, ...service, }, + distribution: { + ...props.distribution, + behaviors: { + ...props.distribution?.behaviors, + "/api/*": { + authentication: false, + ...props.distribution?.behaviors?.["/api/*"], + }, + }, + }, ...props, }); } diff --git a/packages/cdk-site-distribution/package.json b/packages/cdk-site-distribution/package.json index 7e711e9c..d1642562 100644 --- a/packages/cdk-site-distribution/package.json +++ b/packages/cdk-site-distribution/package.json @@ -27,7 +27,8 @@ "constructs": ">=10" }, "dependencies": { - "@codedazur/cdk-cache-invalidator": "^1.2.1" + "@codedazur/cdk-cache-invalidator": "^1.2.1", + "@codedazur/essentials": "^1.10.1" }, "devDependencies": { "@types/node": "^20.14.10", diff --git a/packages/cdk-site-distribution/src/constructs/SiteDistribution.ts b/packages/cdk-site-distribution/src/constructs/SiteDistribution.ts index ce9f5c7a..d444a102 100644 --- a/packages/cdk-site-distribution/src/constructs/SiteDistribution.ts +++ b/packages/cdk-site-distribution/src/constructs/SiteDistribution.ts @@ -1,4 +1,5 @@ import { CacheInvalidator } from "@codedazur/cdk-cache-invalidator"; +import { revalueObject } from "@codedazur/essentials"; import { CfnOutput } from "aws-cdk-lib"; import { Certificate, @@ -7,6 +8,7 @@ import { } from "aws-cdk-lib/aws-certificatemanager"; import { AllowedMethods, + BehaviorOptions, Function as CloudFrontFunction, Distribution, FunctionCode, @@ -27,26 +29,32 @@ import { CloudFrontTarget } from "aws-cdk-lib/aws-route53-targets"; import { Secret } from "aws-cdk-lib/aws-secretsmanager"; import { Construct } from "constructs"; -export interface SiteDistributionProps { - origin: IOrigin; +export interface SiteDistributionProps extends BehaviorProps { priceClass?: PriceClass; - functions?: { - viewerRequest?: FunctionCode[]; - viewerResponse?: FunctionCode[]; - }; - authentication?: { - username: string; - password?: string | Secret; - }; domain?: { name: string; subdomain?: string; zone?: IHostedZone; }; + behaviors?: Record>; + invalidateCache?: boolean | string[]; +} + +export interface BehaviorProps { + origin: IOrigin; + authentication?: + | { + username: string; + password: string; + } + | false; + functions?: { + viewerRequest?: FunctionCode[]; + viewerResponse?: FunctionCode[]; + }; allowedMethods?: AllowedMethods; cachePolicy?: ICachePolicy; originRequestPolicy?: IOriginRequestPolicy; - invalidateCache?: boolean | string[]; } /** @@ -57,11 +65,6 @@ export class SiteDistribution extends Construct { public readonly domain?: string; public readonly zone?: IHostedZone; public readonly certificate?: ICertificate; - public readonly passwordSecret?: Secret; - public readonly functions: { - viewerRequest?: CloudFrontFunction; - viewerResponse?: CloudFrontFunction; - }; public readonly distribution: Distribution; public readonly alias?: ARecord; public readonly cacheInvalidator?: CacheInvalidator; @@ -76,12 +79,6 @@ export class SiteDistribution extends Construct { this.domain = this.determineDomain(); this.zone = this.findHostedZone(); this.certificate = this.createCertificate(); - - if (this.props.authentication && !this.props.authentication.password) { - this.passwordSecret = this.createPasswordSecret(); - } - - this.functions = this.createFunctions(); this.distribution = this.createDistribution(); this.alias = this.createAlias(); @@ -137,9 +134,85 @@ export class SiteDistribution extends Construct { }); } - protected createFunctions() { - const viewerRequest = this.createViewerRequestFunction(); - const viewerResponse = this.createViewerResponseFunction(); + protected createDistribution() { + const distribution = new Distribution(this, "Distribution", { + priceClass: this.props.priceClass, + certificate: this.certificate, + domainNames: this.domain ? [this.domain] : undefined, + defaultBehavior: this.behavior(), + additionalBehaviors: this.props.behaviors + ? revalueObject(this.props.behaviors, ([, props]) => + this.behavior(props), + ) + : undefined, + }); + + new CfnOutput(this, "DistributionId", { + value: distribution.distributionId, + }); + + new CfnOutput(this, "DistributionDomainName", { + value: distribution.distributionDomainName, + }); + + return distribution; + } + + protected behavior({ + pattern = "/*", + authentication, + functions: functionsCode, + ...props + }: Partial & { + pattern?: string; + } = {}): BehaviorOptions { + const functions = this.createFunctions({ + pattern, + authentication: authentication ?? this.props.authentication, + functions: functionsCode ?? this.props.functions, + }); + + return { + origin: props.origin ?? this.props.origin, + allowedMethods: props.allowedMethods ?? this.props.allowedMethods, + originRequestPolicy: + props.originRequestPolicy ?? this.props.originRequestPolicy, + viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + cachePolicy: props.cachePolicy ?? this.props.cachePolicy, + functionAssociations: [ + functions.viewerRequest + ? { + function: functions.viewerRequest, + eventType: FunctionEventType.VIEWER_REQUEST, + } + : null, + functions.viewerResponse + ? { + function: functions.viewerResponse, + eventType: FunctionEventType.VIEWER_RESPONSE, + } + : null, + ].filter((association) => !!association), + }; + } + + protected createFunctions({ + pattern, + authentication, + functions, + }: { + pattern: string; + } & Partial>) { + const viewerRequest = this.createViewerRequestFunction({ + id: `ViewerRequestFunction-${pattern}`, + authentication, + code: functions?.viewerRequest, + }); + + const viewerResponse = this.createViewerResponseFunction({ + id: `ViewerResponseFunction-${pattern}`, + code: functions?.viewerResponse, + }); /** * Although the response function doesn't actually depend on the request @@ -156,32 +229,44 @@ export class SiteDistribution extends Construct { }; } - protected createViewerRequestFunction() { - const handlers = [ - this.getAuthenticateCode(), - ...(this.props.functions?.viewerRequest ?? []), - ].filter((handler): handler is FunctionCode => !!handler); + protected createViewerRequestFunction({ + id, + authentication, + code = [], + }: { + id: string; + authentication?: BehaviorProps["authentication"]; + code?: FunctionCode[]; + }) { + const handlers = [this.getAuthenticateCode(authentication), ...code].filter( + (handler) => !!handler, + ); if (handlers.length === 0) { return undefined; } - return new CloudFrontFunction(this, "ViewerRequestFunction", { + return new CloudFrontFunction(this, id, { code: this.getHandlerChainCode(handlers, "request"), }); } - protected createViewerResponseFunction() { - const handlers = [ - this.getSecurityHeadersCode(), - ...(this.props.functions?.viewerResponse ?? []), - ].filter((handler): handler is FunctionCode => !!handler); + protected createViewerResponseFunction({ + id, + code = [], + }: { + id: string; + code?: FunctionCode[]; + }) { + const handlers = [this.getSecurityHeadersCode(), ...code].filter( + (handler) => !!handler, + ); if (handlers.length === 0) { return undefined; } - return new CloudFrontFunction(this, "ViewerResponseFunction", { + return new CloudFrontFunction(this, id, { code: this.getHandlerChainCode(handlers, "response"), }); } @@ -208,12 +293,14 @@ export class SiteDistribution extends Construct { `); } - protected getAuthenticateCode() { - if (!this.props.authentication) { + protected getAuthenticateCode(props: BehaviorProps["authentication"]) { + if (!props) { return; } - const token = this.getAuthenticationToken(); + const { username, password } = props; + + const token = Buffer.from(`${username}:${password}`).toString("base64"); return FunctionCode.fromInline(/* js */ ` function authenticate(event, next) { @@ -237,30 +324,6 @@ export class SiteDistribution extends Construct { `); } - protected getAuthenticationToken() { - const password = this.getPassword(); - - if (!password) { - return undefined; - } - - return Buffer.from( - [this.props.authentication?.username, password].join(":"), - ).toString("base64"); - } - - protected getPassword() { - if (!this.props.authentication?.password) { - return this.passwordSecret?.secretValue.toString(); - } - - if (this.props.authentication.password instanceof Secret) { - return this.props.authentication.password.secretValue.toString(); - } - - return this.props.authentication.password; - } - /** * @todo Make these headers configurable. * @todo Research CSP and define a good default. @@ -291,49 +354,6 @@ export class SiteDistribution extends Construct { `); } - protected createDistribution() { - const distribution = new Distribution(this, "Distribution", { - priceClass: this.props.priceClass, - certificate: this.certificate, - domainNames: this.domain ? [this.domain] : undefined, - defaultBehavior: { - origin: this.props.origin, - allowedMethods: this.props.allowedMethods, - originRequestPolicy: this.props.originRequestPolicy, - viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - functionAssociations: [ - ...(this.functions.viewerRequest - ? [ - { - function: this.functions.viewerRequest, - eventType: FunctionEventType.VIEWER_REQUEST, - }, - ] - : []), - ...(this.functions.viewerResponse - ? [ - { - function: this.functions.viewerResponse, - eventType: FunctionEventType.VIEWER_RESPONSE, - }, - ] - : []), - ], - cachePolicy: this.props.cachePolicy, - }, - }); - - new CfnOutput(this, "DistributionId", { - value: distribution.distributionId, - }); - - new CfnOutput(this, "DistributionDomainName", { - value: distribution.distributionDomainName, - }); - - return distribution; - } - protected createAlias() { return this.domain && this.zone ? new ARecord(this, "DomainAlias", {