diff --git a/examples/aws-cognito/sst.config.ts b/examples/aws-cognito/sst.config.ts index f6bc1318ed..1ac54cc3b5 100644 --- a/examples/aws-cognito/sst.config.ts +++ b/examples/aws-cognito/sst.config.ts @@ -1,5 +1,11 @@ /// +/** + * ## AWS Cognito User Pool + * + * Create a Cognito User Pool with a hosted UI domain, client, and identity pool. + * + */ export default $config({ app(input) { return { @@ -10,6 +16,9 @@ export default $config({ }, async run() { const userPool = new sst.aws.CognitoUserPool("MyUserPool", { + domain: { + prefix: `my-app-${$app.stage}`, + }, triggers: { preSignUp: { handler: "index.handler", @@ -30,6 +39,7 @@ export default $config({ UserPool: userPool.id, Client: client.id, IdentityPool: identityPool.id, + DomainUrl: userPool.domainUrl, }; }, }); diff --git a/platform/src/components/aws/cognito-user-pool-domain.ts b/platform/src/components/aws/cognito-user-pool-domain.ts new file mode 100644 index 0000000000..513d004a1a --- /dev/null +++ b/platform/src/components/aws/cognito-user-pool-domain.ts @@ -0,0 +1,211 @@ +import { ComponentResourceOptions, Output, output } from "@pulumi/pulumi"; +import { Component, Transform, transform } from "../component"; +import { Input } from "../input"; +import { Dns } from "../dns"; +import { cognito } from "@pulumi/aws"; +import { dns as awsDns } from "./dns.js"; +import { DnsValidatedCertificate } from "./dns-validated-certificate.js"; +import { useProvider } from "./helpers/provider.js"; +import { VisibleError } from "../error"; + +export interface Args { + /** + * The Cognito user pool ID. + */ + userPool: Input; + /** + * The domain configuration - either a string for custom domain or an object. + */ + domain: Input< + | string + | { + prefix?: Input; + name?: Input; + dns?: Input; + cert?: Input; + } + >; + /** + * Transform the Cognito User Pool domain resource. + */ + transform?: Transform; +} + +/** + * The `CognitoUserPoolDomain` component is internally used by the `CognitoUserPool` + * component to add a domain to your [Amazon Cognito user pool](https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-identity-pools.html). + * + * :::note + * This component is not intended to be created directly. + * ::: + * + * Use the `domain` prop on `CognitoUserPool` instead. + * + * @todo Add `managedLoginVersion` prop when SST upgrades to Pulumi AWS v7. + * This will allow users to choose between classic hosted UI (1) and managed login (2). + * See: https://docs.aws.amazon.com/cognito/latest/developerguide/cognito-user-pools-managed-login.html + */ +export class CognitoUserPoolDomain extends Component { + private _domain: Output; + private _domainUrl: Output; + + constructor(name: string, args: Args, opts?: ComponentResourceOptions) { + super(__pulumiType, name, args, opts); + + const parent = this; + + const normalized = normalizeDomain(); + const domain = createDomainWithSsl(); + + this._domain = domain; + this._domainUrl = normalized.apply((n) => + n.prefix + ? `https://${n.prefix}.auth.${process.env.AWS_REGION || "us-east-1"}.amazoncognito.com` + : `https://${n.name}`, + ); + + function normalizeDomain() { + return output(args.domain).apply((domain) => { + const norm = typeof domain === "string" ? { name: domain } : domain; + + // Validate + if (norm.prefix && norm.name) { + throw new VisibleError( + `Cannot specify both "prefix" and "name". Use "prefix" for a Cognito-hosted domain or "name" for a custom domain.`, + ); + } + if (!norm.prefix && !norm.name) { + throw new VisibleError( + `Must specify either "prefix" or "name" in domain configuration.`, + ); + } + + // For custom domains, validate DNS/cert requirements + if (norm.name && norm.dns === false && !norm.cert) { + throw new VisibleError( + `Need to provide a validated certificate via "cert" when DNS is disabled.`, + ); + } + + return { + prefix: norm.prefix, + name: norm.name, + dns: norm.dns === false ? undefined : norm.dns ?? (norm.name ? awsDns() : undefined), + cert: norm.cert, + }; + }); + } + + function createDomainWithSsl() { + return normalized.apply((norm) => { + // For prefix domains, no SSL needed + if (norm.prefix) { + const [resourceName, domainArgs, resourceOpts] = transform( + args.transform, + `${name}Domain`, + { + userPoolId: args.userPool, + domain: norm.prefix, + }, + { parent, deleteBeforeReplace: true }, + ); + + const domain = new cognito.UserPoolDomain( + resourceName, + domainArgs, + resourceOpts, + ); + + return domain; + } + + // For custom domains, create SSL cert if needed + let certificateArn: Input; + if (norm.cert) { + certificateArn = norm.cert; + } else { + const cert = new DnsValidatedCertificate( + `${name}Ssl`, + { + domainName: norm.name!, + dns: output(norm.dns!), + }, + { parent, provider: useProvider("us-east-1") }, + ); + certificateArn = cert.arn; + } + + const [resourceName, domainArgs, resourceOpts] = transform( + args.transform, + `${name}Domain`, + { + userPoolId: args.userPool, + domain: norm.name!, + certificateArn, + }, + { parent, deleteBeforeReplace: true }, + ); + + const domain = new cognito.UserPoolDomain( + resourceName, + domainArgs, + resourceOpts, + ); + + // Create DNS records for custom domain + if (norm.dns) { + norm.dns.createAlias( + name, + { + name: norm.name!, + aliasName: domain.cloudfrontDistribution, + aliasZone: domain.cloudfrontDistributionZoneId, + }, + { parent }, + ); + } + + return domain; + }); + } + } + + /** + * The Cognito User Pool domain string. For prefix domains, this is the prefix. + * For custom domains, this is the full domain name. + */ + public get domainName() { + return this._domain.apply((d) => d.domain); + } + + /** + * The full URL of the hosted UI domain. + */ + public get domainUrl() { + return this._domainUrl; + } + + /** + * The CloudFront distribution domain name. Useful for custom domain DNS setup + * when managing DNS outside of SST. + */ + public get cloudfrontDistribution() { + return this._domain.apply((d) => d.cloudfrontDistribution); + } + + /** + * The underlying [resources](/docs/components/#nodes) this component creates. + */ + public get nodes() { + return { + /** + * The Cognito User Pool domain. + */ + domain: this._domain, + }; + } +} + +const __pulumiType = "sst:aws:CognitoUserPoolDomain"; +// @ts-expect-error +CognitoUserPoolDomain.__pulumiType = __pulumiType; diff --git a/platform/src/components/aws/cognito-user-pool.ts b/platform/src/components/aws/cognito-user-pool.ts index 5ef2be38d0..75d0ca0d57 100644 --- a/platform/src/components/aws/cognito-user-pool.ts +++ b/platform/src/components/aws/cognito-user-pool.ts @@ -2,8 +2,10 @@ import { ComponentResourceOptions, Output, all, output } from "@pulumi/pulumi"; import { Component, Prettify, Transform, transform } from "../component"; import { Input } from "../input"; import { Link } from "../link"; +import { Dns } from "../dns"; import { CognitoIdentityProvider } from "./cognito-identity-provider"; import { CognitoUserPoolClient } from "./cognito-user-pool-client"; +import { CognitoUserPoolDomain } from "./cognito-user-pool-domain"; import { Function, FunctionArgs, FunctionArn } from "./function.js"; import { VisibleError } from "../error"; import { cognito, lambda } from "@pulumi/aws"; @@ -319,6 +321,73 @@ export interface CognitoUserPoolArgs { * ``` */ triggers?: Input>; + /** + * Configure a domain for the User Pool's hosted UI. + * + * You can use either a Cognito-provided prefix domain or your own custom domain. + * + * @example + * + * Add a Cognito prefix domain. + * + * ```ts + * { + * domain: { + * prefix: "my-app-dev" + * } + * } + * ``` + * + * This creates a domain at `my-app-dev.auth.{region}.amazoncognito.com`. + * + * Add a custom domain. By default, creates an ACM certificate and configures + * DNS records using Route 53. + * + * ```ts + * { + * domain: "auth.example.com" + * } + * ``` + * + * Use a domain hosted on Cloudflare. + * + * ```ts + * { + * domain: { + * name: "auth.example.com", + * dns: sst.cloudflare.dns() + * } + * } + * ``` + */ + domain?: Input< + | string + | { + /** + * Use an Amazon Cognito prefix domain. Creates a domain at + * `{prefix}.auth.{region}.amazoncognito.com`. + * + * Cannot contain "aws", "amazon", or "cognito". + */ + prefix?: Input; + /** + * The custom domain name. Must be a subdomain (e.g., `auth.example.com`). + */ + name?: Input; + /** + * The DNS provider for automatic certificate validation and record creation. + * Set to `false` for manual DNS setup. + * + * @default `sst.aws.dns` + */ + dns?: Input; + /** + * ARN of an existing ACM certificate in `us-east-1`. By default, a certificate + * is created and validated automatically. + */ + cert?: Input; + } + >; /** * [Transform](/docs/components#transform) how this component creates its underlying * resources. @@ -328,6 +397,10 @@ export interface CognitoUserPoolArgs { * Transform the Cognito User Pool resource. */ userPool?: Transform; + /** + * Transform the Cognito User Pool domain resource. + */ + domain?: Transform; }; } @@ -439,6 +512,26 @@ interface CognitoUserPoolRef { * }); * ``` * + * #### Add a hosted UI domain + * + * Use a Cognito prefix domain for the hosted UI. + * + * ```ts title="sst.config.ts" + * new sst.aws.CognitoUserPool("MyUserPool", { + * domain: { + * prefix: "my-app-dev" + * } + * }); + * ``` + * + * Or use your own custom domain. + * + * ```ts title="sst.config.ts" + * new sst.aws.CognitoUserPool("MyUserPool", { + * domain: "auth.example.com" + * }); + * ``` + * * #### Configure triggers * * ```ts title="sst.config.ts" @@ -480,6 +573,7 @@ interface CognitoUserPoolRef { export class CognitoUserPool extends Component implements Link.Linkable { private constructorOpts: ComponentResourceOptions; private userPool: Output; + private _domainUrl?: Output; constructor( name: string, @@ -501,9 +595,11 @@ export class CognitoUserPool extends Component implements Link.Linkable { const triggers = normalizeTriggers(); const verify = normalizeVerify(); const userPool = createUserPool(); + const domain = createDomain(); this.constructorOpts = opts; this.userPool = userPool; + this._domainUrl = domain?.domainUrl; function normalizeAliasesAndUsernames() { all([args.aliases, args.usernames]).apply(([aliases, usernames]) => { @@ -690,6 +786,20 @@ export class CognitoUserPool extends Component implements Link.Linkable { ), ); } + + function createDomain() { + if (!args.domain) return; + + return new CognitoUserPoolDomain( + `${name}Domain`, + { + userPool: userPool.id, + domain: args.domain, + transform: args.transform?.domain, + }, + { parent, provider: opts.provider }, + ); + } } /** @@ -706,6 +816,13 @@ export class CognitoUserPool extends Component implements Link.Linkable { return this.userPool.arn; } + /** + * If a `domain` is configured, this is the full URL of the hosted UI. + */ + public get domainUrl() { + return this._domainUrl; + } + /** * The underlying [resources](/docs/components/#nodes) this component creates. */ diff --git a/platform/src/components/component.ts b/platform/src/components/component.ts index 24bedb419b..9cfea53c46 100644 --- a/platform/src/components/component.ts +++ b/platform/src/components/component.ts @@ -211,6 +211,11 @@ export class Component extends ComponentResource { "aws:cloudfront/keyValueStore:KeyValueStore": ["name", 64], "aws:cognito/identityPool:IdentityPool": ["identityPoolName", 128], "aws:cognito/userPool:UserPool": ["name", 128], + "aws:cognito/userPoolDomain:UserPoolDomain": [ + "domain", + 63, + { lower: true }, + ], "aws:dynamodb/table:Table": ["name", 255], "aws:ec2/keyPair:KeyPair": ["keyName", 255], "aws:ec2/eip:Eip": ["tags", 255],