diff --git a/.gitignore b/.gitignore index 640f6cf..249982b 100644 --- a/.gitignore +++ b/.gitignore @@ -420,6 +420,9 @@ FodyWeavers.xsd .idea /site +# Local scripts (not for source control) +scripts/ + # macOS .DS_Store .DS_Store? diff --git a/Directory.Build.props b/Directory.Build.props index c68bd1a..3b22822 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,6 +1,6 @@ - 2.1.5 + 2.2.0 MIT diff --git a/Directory.Packages.props b/Directory.Packages.props index b5f2384..0f19abc 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,18 +3,18 @@ true - - + + - + - + diff --git a/LayeredCraft.Cdk.Constructs.slnx b/LayeredCraft.Cdk.Constructs.slnx index 06187cc..10b3839 100644 --- a/LayeredCraft.Cdk.Constructs.slnx +++ b/LayeredCraft.Cdk.Constructs.slnx @@ -11,6 +11,7 @@ + diff --git a/README.md b/README.md index 3b9bf7a..cf6eca9 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ A comprehensive library of reusable AWS CDK constructs for .NET projects, design - **๐Ÿš€ Lambda Functions**: Comprehensive Lambda construct with configurable OpenTelemetry support, IAM management, and environment configuration - **๐ŸŒ Static Sites**: Complete static website hosting with S3, CloudFront, SSL certificates, and Route53 DNS management - **๐Ÿ“Š DynamoDB Tables**: Full-featured DynamoDB construct with streams, TTL, and global secondary indexes +- **๐Ÿ” Cognito User Pools**: Complete Cognito user pool with custom domains, resource servers, OAuth clients, and Managed Login branding - **๐Ÿงช Testing Helpers**: Extensive testing utilities with fluent assertions and builders - **๐Ÿ“ Type Safety**: Full intellisense and compile-time validation - **โšก Performance**: Optimized for cold starts with AWS Lambda SnapStart support @@ -93,6 +94,7 @@ var table = new DynamoDbTableConstruct(this, "UserTable", new DynamoDbTableConst - **[Lambda Function Construct](https://layeredcraft.github.io/cdk-constructs/constructs/lambda-function)** - Full-featured Lambda functions with OpenTelemetry, IAM, and more - **[Static Site Construct](https://layeredcraft.github.io/cdk-constructs/constructs/static-site)** - Complete static website hosting with CloudFront and SSL - **[DynamoDB Table Construct](https://layeredcraft.github.io/cdk-constructs/constructs/dynamodb-table)** - Production-ready DynamoDB tables with streams and indexes +- **[Cognito User Pool Construct](https://layeredcraft.github.io/cdk-constructs/constructs/cognito-user-pool)** - Full-featured Cognito user pool with custom domains, OAuth clients, and branding - **[Testing Guide](https://layeredcraft.github.io/cdk-constructs/testing)** - Comprehensive testing utilities and patterns - **[Examples](https://layeredcraft.github.io/cdk-constructs/examples)** - Real-world usage examples and patterns diff --git a/docs/constructs/cognito-user-pool.md b/docs/constructs/cognito-user-pool.md new file mode 100644 index 0000000..c8f800c --- /dev/null +++ b/docs/constructs/cognito-user-pool.md @@ -0,0 +1,332 @@ +# Cognito User Pool Construct + +The `CognitoUserPoolConstruct` provides a production-ready Amazon Cognito User Pool with support for custom domains, resource servers, OAuth app clients, user groups, and Managed Login branding (v2). + +## Features + +- **:busts_in_silhouette: User Pool**: Email-based sign-in with auto-verification, configurable self sign-up, and password policy +- **:globe_with_meridians: Domain Modes**: Cognito-hosted domain prefix or fully custom domain with ACM certificate and Route53 record +- **:shield: Resource Servers**: Define API scopes for machine-to-machine or user-delegated authorization +- **:iphone: App Clients**: Multiple OAuth 2.0 app clients with configurable flows, scopes, and identity providers +- **:busts_in_silhouette: User Groups**: Named groups with optional precedence and IAM role assignment +- **:art: Managed Login Branding**: Full Cognito Managed Login v2 branding via settings JSON and optional image assets +- **:outbox_tray: CloudFormation Outputs**: Automatic exports for user pool ID, ARN, and each app client ID + +## Basic Usage + +```csharp +using Amazon.CDK; +using Amazon.CDK.AWS.Cognito; +using LayeredCraft.Cdk.Constructs; +using LayeredCraft.Cdk.Constructs.Models; + +public class MyStack : Stack +{ + public MyStack(Construct scope, string id, IStackProps props = null) : base(scope, id, props) + { + var pool = new CognitoUserPoolConstruct(this, "my-user-pool", new CognitoUserPoolConstructProps + { + UserPoolName = "my-app-users", + SelfSignUpEnabled = true, + Domain = new CognitoUserPoolDomainProps + { + CognitoDomainPrefix = "my-app-auth", + }, + AppClients = + [ + new CognitoUserPoolAppClientProps + { + Name = "my-web-app", + CallbackUrls = ["https://example.com/authentication/login-callback"], + LogoutUrls = ["https://example.com"], + AllowedOAuthScopes = [OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE], + }, + ], + }); + } +} +``` + +## Configuration Properties + +### Root Properties (`CognitoUserPoolConstructProps`) + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `UserPoolName` | `string` | **required** | Name of the Cognito user pool | +| `SelfSignUpEnabled` | `bool` | `true` | Whether users can self-register | +| `RemovalPolicy` | `RemovalPolicy` | `DESTROY` | Behavior when the stack is deleted | +| `Mfa` | `Mfa` | `OFF` | MFA requirement (`OFF`, `OPTIONAL`, `REQUIRED`) | +| `PasswordMinLength` | `int` | `12` | Minimum password length | +| `Domain` | `ICognitoUserPoolDomainProps?` | `null` | Domain configuration (Cognito prefix or custom) | +| `ResourceServers` | `IReadOnlyList` | `[]` | OAuth resource server definitions | +| `AppClients` | `IReadOnlyList` | `[]` | OAuth app client definitions | +| `Groups` | `IReadOnlyCollection?` | `[]` | User group definitions | + +### Domain Properties (`CognitoUserPoolDomainProps`) + +Exactly one of `CognitoDomainPrefix` or (`DomainName` + `AuthSubDomain`) must be set. + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `CognitoDomainPrefix` | `string?` | `null` | Cognito-hosted domain prefix (`{prefix}.auth.us-east-1.amazoncognito.com`) | +| `DomainName` | `string?` | `null` | Root domain for custom domain (e.g., `example.com`) | +| `AuthSubDomain` | `string?` | `null` | Subdomain for custom domain (e.g., `auth` โ†’ `auth.example.com`) | +| `ManagedLoginVersion` | `CognitoManagedLoginVersion` | `ManagedLogin` | `ClassicHostedUi` or `ManagedLogin` (v2) | +| `CreateRoute53Record` | `bool` | `true` | Whether to create an A-alias record in the hosted zone (custom domain only) | + +### App Client Properties (`CognitoUserPoolAppClientProps`) + +| Property | Type | Default | Description | +|----------|------|---------|-------------| +| `Name` | `string` | **required** | App client name | +| `GenerateSecret` | `bool` | `false` | Whether to generate a client secret | +| `CallbackUrls` | `IReadOnlyList` | `[]` | Allowed redirect URIs after login | +| `LogoutUrls` | `IReadOnlyList` | `[]` | Allowed redirect URIs after logout | +| `AuthorizationCodeGrant` | `bool` | `true` | Enable Authorization Code grant | +| `ImplicitCodeGrant` | `bool` | `false` | Enable Implicit grant | +| `ClientCredentials` | `bool` | `false` | Enable Client Credentials grant | +| `AllowedOAuthScopes` | `IReadOnlyList` | `[OPENID, EMAIL]` | Permitted OAuth scopes | +| `SupportedIdentityProviders` | `IReadOnlyList` | `[COGNITO]` | Identity providers | +| `ManagedLoginBranding` | `ICognitoManagedLoginBrandingProps?` | `null` | Optional Managed Login branding | + +### Resource Server Properties (`CognitoResourceServerProps`) + +| Property | Type | Description | +|----------|------|-------------| +| `Name` | `string` | Display name for the resource server | +| `Identifier` | `string` | Unique URI identifier (e.g., `my-api`) | +| `Scopes` | `IReadOnlyList` | Scope definitions | + +Each scope (`CognitoResourceServerScopeProps`) has: +- `Name` โ€” scope name (e.g., `read`) +- `Description` โ€” human-readable description + +Use the scope as `OAuthScope.Custom("my-api/read")` in app client `AllowedOAuthScopes`. + +### User Group Properties (`CognitoUserPoolGroupProps`) + +| Property | Type | Description | +|----------|------|-------------| +| `Name` | `string` | Group name | +| `Description` | `string?` | Optional group description | +| `Precedence` | `int?` | Precedence for group priority (lower = higher priority) | +| `RoleArn` | `string?` | Optional IAM role ARN to associate with the group | + +### Managed Login Branding Properties (`CognitoManagedLoginBrandingProps`) + +| Property | Type | Description | +|----------|------|-------------| +| `SettingsJson` | `string` | Full Managed Login settings JSON (see Cognito docs for schema) | +| `Assets` | `IReadOnlyList?` | Optional image assets (logo, favicon, etc.) | + +## Advanced Examples + +### Custom Domain with Route53 + +```csharp +var pool = new CognitoUserPoolConstruct(this, "auth-pool", new CognitoUserPoolConstructProps +{ + UserPoolName = "my-app-users", + Domain = new CognitoUserPoolDomainProps + { + DomainName = "example.com", // Hosted zone must exist in this account + AuthSubDomain = "auth", // โ†’ auth.example.com + ManagedLoginVersion = CognitoManagedLoginVersion.ManagedLogin, + CreateRoute53Record = true, + }, + AppClients = + [ + new CognitoUserPoolAppClientProps + { + Name = "web-app", + CallbackUrls = ["https://example.com/authentication/login-callback"], + LogoutUrls = ["https://example.com"], + AllowedOAuthScopes = [OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE], + }, + ], +}); +``` + +> The hosted zone for `DomainName` must already exist in the same account. The construct performs a `HostedZone.FromLookup` and creates an ACM certificate with DNS validation automatically. + +### Resource Servers and API Scopes + +```csharp +var pool = new CognitoUserPoolConstruct(this, "auth-pool", new CognitoUserPoolConstructProps +{ + UserPoolName = "my-api-users", + Domain = new CognitoUserPoolDomainProps { CognitoDomainPrefix = "my-api-auth" }, + ResourceServers = + [ + new CognitoResourceServerProps + { + Name = "My API", + Identifier = "my-api", + Scopes = + [ + new CognitoResourceServerScopeProps { Name = "read", Description = "Read access" }, + new CognitoResourceServerScopeProps { Name = "write", Description = "Write access" }, + ], + }, + ], + AppClients = + [ + new CognitoUserPoolAppClientProps + { + Name = "my-web-app", + CallbackUrls = ["https://example.com/callback"], + LogoutUrls = ["https://example.com"], + AllowedOAuthScopes = + [ + OAuthScope.OPENID, + OAuthScope.EMAIL, + OAuthScope.Custom("my-api/read"), + ], + }, + ], +}); +``` + +### Machine-to-Machine (Client Credentials) App Client + +```csharp +new CognitoUserPoolAppClientProps +{ + Name = "backend-service", + GenerateSecret = true, + CallbackUrls = [], + LogoutUrls = [], + AuthorizationCodeGrant = false, + ImplicitCodeGrant = false, + ClientCredentials = true, + AllowedOAuthScopes = [OAuthScope.Custom("my-api/read")], + SupportedIdentityProviders = [], +}, +``` + +### User Groups + +```csharp +var pool = new CognitoUserPoolConstruct(this, "auth-pool", new CognitoUserPoolConstructProps +{ + UserPoolName = "my-app-users", + Groups = + [ + new CognitoUserPoolGroupProps(Name: "admin", Description: "Administrators", Precedence: 1), + new CognitoUserPoolGroupProps(Name: "player", Description: "Regular players", Precedence: 2), + ], + // ... domain and app clients +}); +``` + +### Managed Login Branding + +```csharp +new CognitoUserPoolAppClientProps +{ + Name = "my-web-app", + CallbackUrls = ["https://example.com/callback"], + LogoutUrls = ["https://example.com"], + AllowedOAuthScopes = [OAuthScope.OPENID, OAuthScope.EMAIL], + ManagedLoginBranding = new CognitoManagedLoginBrandingProps( + SettingsJson: MyBrandingConstants.SettingsJson), +}, +``` + +The `SettingsJson` must be a valid Cognito Managed Login settings JSON document. The construct deserializes it and converts it to a JSII-compatible CLR object graph before passing it to CloudFormation. + +## CloudFormation Outputs + +The construct automatically creates CloudFormation outputs for cross-stack sharing. Export names follow the pattern `{stack-name}-{construct-id}-{qualifier}` (all lowercase): + +| Qualifier | Value | +|-----------|-------| +| `user-pool-id` | `UserPool.UserPoolId` | +| `user-pool-arn` | `UserPool.UserPoolArn` | +| `client-{clientName}-id` | `UserPoolClientId` for each app client | + +> Spaces in client names are replaced with hyphens and the name is lowercased. For example, a client named `My Web App` produces the qualifier `client-my-web-app-id`. + +### Importing in Another Stack + +```csharp +// In the consuming stack: +var userPoolId = Fn.ImportValue("my-infra-stack-prod-auth-pool-user-pool-id"); +var clientId = Fn.ImportValue("my-infra-stack-prod-auth-pool-client-my-web-app-id"); +``` + +## Testing + +### Props Builder + +```csharp +var props = new CognitoUserPoolConstructPropsBuilder() + .WithUserPoolName("my-pool") + .WithSelfSignUpEnabled(true) + .WithCognitoDomain("my-pool-auth") + .AddResourceServer("My API", "my-api", + scopes: [new CognitoResourceServerScopeProps { Name = "read", Description = "Read" }]) + .AddWebAppClient( + name: "web-app", + callbackUrls: ["https://example.com/callback"], + logoutUrls: ["https://example.com"]) + .AddGroup("admin", description: "Admins", precedence: 1) + .Build(); +``` + +#### Convenience: `ForWebApplication` + +```csharp +var props = new CognitoUserPoolConstructPropsBuilder() + .ForWebApplication(userPoolName: "my-pool", cognitoDomainPrefix: "my-pool-auth") + .Build(); +``` + +This configures a pool with a Cognito-hosted domain and a single `web-client` app client in one call. + +### Assertion Methods + +```csharp +// Pool and domain +template.ShouldHaveUserPool("my-app-users"); +template.ShouldHaveCognitoUserPoolDomain("my-pool-auth"); + +// App clients and resource servers +template.ShouldHaveUserPoolClient("web-app"); +template.ShouldHaveResourceServer("my-api"); + +// Groups +template.ShouldHaveUserPoolGroup("admin"); + +// Managed Login branding +template.ShouldHaveManagedLoginBranding(); +template.ShouldNotHaveManagedLoginBranding(); + +// CloudFormation exports +template.ShouldExportUserPoolId("test-stack", "auth-pool"); +template.ShouldExportUserPoolArn("test-stack", "auth-pool"); +template.ShouldExportAppClientId("test-stack", "auth-pool", "web-app"); +``` + +### AutoFixture Integration + +```csharp +[Theory] +[CognitoUserPoolConstructAutoData] +public void Should_Create_User_Pool(CognitoUserPoolConstructProps props) +{ + // props generated with sensible defaults (no branding) +} + +[Theory] +[CognitoUserPoolConstructAutoData(includeBranding: true)] +public void Should_Create_User_Pool_With_Branding(CognitoUserPoolConstructProps props) +{ + // props generated with Managed Login branding included +} +``` + +## Examples + +For more real-world examples, see the [Examples](../examples/index.md) section. diff --git a/docs/index.md b/docs/index.md index c89699d..93a4fc6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -90,6 +90,16 @@ Production-ready DynamoDB tables with: - TTL configuration - Lambda stream integration +### [Cognito User Pool Construct](constructs/cognito-user-pool.md) + +Full-featured Cognito User Pool with: + +- Custom and Cognito-hosted domains +- Resource servers and OAuth scopes +- App clients with configurable OAuth flows +- User groups +- Managed Login v2 branding + ## Documentation - **[Testing Guide](testing/index.md)** - Comprehensive testing utilities and patterns diff --git a/docs/testing/index.md b/docs/testing/index.md index f2663fe..f7887d4 100644 --- a/docs/testing/index.md +++ b/docs/testing/index.md @@ -228,6 +228,54 @@ template.ShouldHaveTimeToLiveAttribute("expiresAt"); template.ShouldHaveTableOutputs("test-stack", "test-construct"); ``` +## Cognito User Pool Testing + +### Props Builder + +```csharp +var props = new CognitoUserPoolConstructPropsBuilder() + .WithUserPoolName("my-pool") + .WithSelfSignUpEnabled(true) + .WithCognitoDomain("my-pool-auth") + .AddResourceServer("My API", "my-api", + scopes: [new CognitoResourceServerScopeProps { Name = "read", Description = "Read access" }]) + .AddWebAppClient( + name: "web-app", + callbackUrls: ["https://example.com/callback"], + logoutUrls: ["https://example.com"]) + .AddGroup("admin", description: "Administrators", precedence: 1) + .Build(); + +// Convenience preset for a standard web application setup +var webAppProps = new CognitoUserPoolConstructPropsBuilder() + .ForWebApplication(userPoolName: "my-pool", cognitoDomainPrefix: "my-pool-auth") + .Build(); +``` + +### Assertion Methods + +```csharp +// Pool and domain +template.ShouldHaveUserPool("my-app-users"); +template.ShouldHaveCognitoUserPoolDomain("my-pool-auth"); + +// App clients and resource servers +template.ShouldHaveUserPoolClient("web-app"); +template.ShouldHaveResourceServer("my-api"); + +// Groups +template.ShouldHaveUserPoolGroup("admin"); + +// Managed Login branding +template.ShouldHaveManagedLoginBranding(); +template.ShouldNotHaveManagedLoginBranding(); + +// CloudFormation exports +template.ShouldExportUserPoolId("test-stack", "auth-pool"); +template.ShouldExportUserPoolArn("test-stack", "auth-pool"); +template.ShouldExportAppClientId("test-stack", "auth-pool", "web-app"); +``` + ## AutoFixture Integration ### Custom Attributes @@ -253,6 +301,7 @@ public void Should_Create_Lambda_With_Custom_Settings(LambdaFunctionConstructPro - `LambdaFunctionConstructAutoDataAttribute`: Generates Lambda props - `StaticSiteConstructAutoDataAttribute`: Generates static site props - `DynamoDbTableConstructAutoDataAttribute`: Generates DynamoDB props +- `CognitoUserPoolConstructAutoDataAttribute`: Generates Cognito user pool props (pass `includeBranding: true` to include Managed Login branding) ## Testing Patterns @@ -371,3 +420,4 @@ For complete testing examples, see the test files in the repository: - `LambdaFunctionConstructTests.cs` - `StaticSiteConstructTests.cs` - `DynamoDbTableConstructTests.cs` +- `CognitoUserPoolConstructTests.cs` diff --git a/mkdocs.yml b/mkdocs.yml index 937f4ad..9420536 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - Lambda Function: constructs/lambda-function.md - Static Site: constructs/static-site.md - DynamoDB Table: constructs/dynamodb-table.md + - Cognito User Pool: constructs/cognito-user-pool.md - Testing: testing/index.md - Examples: examples/index.md diff --git a/scripts/pack-local.ps1 b/scripts/pack-local.ps1 new file mode 100644 index 0000000..9f779cb --- /dev/null +++ b/scripts/pack-local.ps1 @@ -0,0 +1,44 @@ +$ErrorActionPreference = 'Stop' + +$ScriptDir = $PSScriptRoot +$RepoRoot = Split-Path $ScriptDir -Parent +$CounterFile = Join-Path $ScriptDir '.counter' +$OutputDir = '/usr/local/share/nuget/local' + +# Read VersionPrefix from Directory.Build.props +$BuildProps = Join-Path $RepoRoot 'Directory.Build.props' +$xml = [xml](Get-Content $BuildProps) +$VersionPrefix = $xml.Project.PropertyGroup.VersionPrefix.Trim() + +if (-not $VersionPrefix) { + Write-Error "Error: Could not read VersionPrefix from Directory.Build.props" + exit 1 +} + +# Read or initialize the counter +if (Test-Path $CounterFile) { + $Counter = [int](Get-Content $CounterFile) +} else { + $Counter = 1 +} + +$Version = "$VersionPrefix-local.$Counter" + +Write-Host "Packing version: $Version" +Write-Host "Output directory: $OutputDir" + +New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null + +dotnet pack "$RepoRoot/LayeredCraft.Cdk.Constructs.slnx" ` + /p:Version="$Version" ` + --configuration Release ` + --output "$OutputDir" ` + --no-restore + +Write-Host "" +Write-Host "Packed successfully: $Version" +Write-Host "Packages written to: $OutputDir" + +# Increment and persist the counter +$Counter++ +Set-Content -Path $CounterFile -Value $Counter diff --git a/scripts/pack-local.sh b/scripts/pack-local.sh new file mode 100755 index 0000000..17a6cc2 --- /dev/null +++ b/scripts/pack-local.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +COUNTER_FILE="$SCRIPT_DIR/.counter" +OUTPUT_DIR="/usr/local/share/nuget/local" + +# Read VersionPrefix from Directory.Build.props +VERSION_PREFIX=$(sed -n 's/.*\(.*\)<\/VersionPrefix>.*/\1/p' "$REPO_ROOT/Directory.Build.props" | tr -d '[:space:]') + +if [[ -z "$VERSION_PREFIX" ]]; then + echo "Error: Could not read VersionPrefix from Directory.Build.props" >&2 + exit 1 +fi + +# Read or initialize the counter +if [[ -f "$COUNTER_FILE" ]]; then + COUNTER=$(cat "$COUNTER_FILE") +else + COUNTER=1 +fi + +VERSION="${VERSION_PREFIX}-local.${COUNTER}" + +echo "Packing version: $VERSION" +echo "Output directory: $OUTPUT_DIR" + +mkdir -p "$OUTPUT_DIR" + +dotnet pack "$REPO_ROOT/LayeredCraft.Cdk.Constructs.slnx" \ + /p:Version="$VERSION" \ + --configuration Release \ + --output "$OUTPUT_DIR" \ + --no-restore + +echo "" +echo "Packed successfully: $VERSION" +echo "Packages written to: $OUTPUT_DIR" + +# Increment and persist the counter +COUNTER=$((COUNTER + 1)) +echo "$COUNTER" > "$COUNTER_FILE" \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs b/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs new file mode 100644 index 0000000..1ca1836 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/CognitoUserPoolConstruct.cs @@ -0,0 +1,409 @@ +using System.Text.Json; +using Amazon.CDK; +using Amazon.CDK.AWS.CertificateManager; +using Amazon.CDK.AWS.Cognito; +using Amazon.CDK.AWS.Route53; +using Amazon.CDK.AWS.Route53.Targets; +using Constructs; +using LayeredCraft.Cdk.Constructs.Extensions; +using LayeredCraft.Cdk.Constructs.Models; + +namespace LayeredCraft.Cdk.Constructs; + +public sealed class CognitoUserPoolConstruct : Construct +{ + public CognitoUserPoolConstruct( + Construct scope, + string id, + ICognitoUserPoolConstructProps props) + : base(scope, id) + { + ArgumentNullException.ThrowIfNull(scope); + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentNullException.ThrowIfNull(props); + ArgumentException.ThrowIfNullOrWhiteSpace(props.UserPoolName); + + UserPool = new UserPool(this, id, new UserPoolProps + { + UserPoolName = props.UserPoolName, + SelfSignUpEnabled = props.SelfSignUpEnabled, + SignInAliases = new SignInAliases + { + Email = true, + }, + SignInCaseSensitive = false, + AutoVerify = new AutoVerifiedAttrs + { + Email = true, + }, + StandardAttributes = new StandardAttributes + { + Email = new StandardAttribute + { + Required = true, + Mutable = true, + }, + Fullname = new StandardAttribute + { + Required = true, + Mutable = true + } + }, + PasswordPolicy = new PasswordPolicy + { + MinLength = props.PasswordMinLength, + RequireLowercase = false, + RequireUppercase = false, + RequireDigits = false, + RequireSymbols = false, + }, + AccountRecovery = AccountRecovery.EMAIL_ONLY, + Mfa = props.Mfa, + RemovalPolicy = props.RemovalPolicy, + }); + + var resourceServers = CreateResourceServers(props); + + ResourceServers = resourceServers; + + if (props.Domain is not null) + { + ConfigureUserPoolDomain(id, props); + } + + var appClients = CreateAppClients(props, resourceServers); + + AppClients = appClients; + + CreateGroups(UserPool, props.Groups); + + CreateOutputs(id, appClients); + } + + public UserPool UserPool { get; } + + public UserPoolDomain? Domain { get; set; } + + public ICertificate? Certificate { get; set; } + + public IReadOnlyDictionary ResourceServers { get; } + + public IReadOnlyDictionary AppClients { get; } + + private Dictionary CreateResourceServers(ICognitoUserPoolConstructProps props) + { + var resourceServers = new Dictionary(StringComparer.Ordinal); + + foreach (var resourceServer in props.ResourceServers) + { + ArgumentNullException.ThrowIfNull(resourceServer); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceServer.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(resourceServer.Identifier); + ArgumentNullException.ThrowIfNull(resourceServer.Scopes); + + var scopes = resourceServer.Scopes + .Select(scopeProps => + { + ArgumentNullException.ThrowIfNull(scopeProps); + ArgumentException.ThrowIfNullOrWhiteSpace(scopeProps.Name); + ArgumentException.ThrowIfNullOrWhiteSpace(scopeProps.Description); + + return new ResourceServerScope(new ResourceServerScopeProps + { + ScopeName = scopeProps.Name, + ScopeDescription = scopeProps.Description, + }); + }) + .ToArray(); + + var createdResourceServer = UserPool.AddResourceServer( + $"{resourceServer.Name}-resource-server", + new UserPoolResourceServerOptions + { + Identifier = resourceServer.Identifier, + UserPoolResourceServerName = resourceServer.Name, + Scopes = scopes, + }); + + resourceServers.Add(resourceServer.Identifier, createdResourceServer); + } + + return resourceServers; + } + + private Dictionary CreateAppClients(ICognitoUserPoolConstructProps props, + Dictionary resourceServers) + { + var appClients = new Dictionary(StringComparer.Ordinal); + + foreach (var appClient in props.AppClients) + { + ArgumentNullException.ThrowIfNull(appClient); + ArgumentException.ThrowIfNullOrWhiteSpace(appClient.Name); + ArgumentNullException.ThrowIfNull(appClient.CallbackUrls); + ArgumentNullException.ThrowIfNull(appClient.LogoutUrls); + ArgumentNullException.ThrowIfNull(appClient.AllowedOAuthScopes); + ArgumentNullException.ThrowIfNull(appClient.SupportedIdentityProviders); + + var createdAppClient = UserPool.AddClient( + $"{appClient.Name}-app-client", + new UserPoolClientOptions + { + UserPoolClientName = appClient.Name, + GenerateSecret = appClient.GenerateSecret, + SupportedIdentityProviders = appClient.SupportedIdentityProviders.ToArray(), + OAuth = new OAuthSettings + { + CallbackUrls = appClient.CallbackUrls.ToArray(), + LogoutUrls = appClient.LogoutUrls.ToArray(), + Flows = new OAuthFlows + { + AuthorizationCodeGrant = appClient.AuthorizationCodeGrant, + ImplicitCodeGrant = appClient.ImplicitCodeGrant, + ClientCredentials = appClient.ClientCredentials, + }, + Scopes = appClient.AllowedOAuthScopes.ToArray(), + }, + }); + foreach (var resourceServer in resourceServers) + { + createdAppClient.Node.AddDependency(resourceServer.Value); + } + + CreateManagedLoginBranding(UserPool, createdAppClient, appClient.ManagedLoginBranding, appClient.Name); + + appClients.Add(appClient.Name, createdAppClient); + } + + return appClients; + } + + /// + /// Creates CloudFormation outputs for the user pool ID, ARN, and each app client ID. + /// Export names follow the pattern {stack-name}-{construct-id}-{qualifier} produced by + /// . + /// + private void CreateOutputs(string id, Dictionary appClients) + { + var stack = Stack.Of(this); + + _ = new CfnOutput(this, $"{id}-user-pool-id-output", new CfnOutputProps + { + Value = UserPool.UserPoolId, + ExportName = stack.CreateExportName(id, "user-pool-id"), + }); + + _ = new CfnOutput(this, $"{id}-user-pool-arn-output", new CfnOutputProps + { + Value = UserPool.UserPoolArn, + ExportName = stack.CreateExportName(id, "user-pool-arn"), + }); + + foreach (var (clientName, client) in appClients) + { + var sanitizedName = clientName.ToLowerInvariant().Replace(' ', '-'); + _ = new CfnOutput(this, $"{id}-client-{sanitizedName}-id-output", new CfnOutputProps + { + Value = client.UserPoolClientId, + ExportName = stack.CreateExportName(id, $"client-{sanitizedName}-id"), + }); + } + } + + /// + /// Creates a AWS::Cognito::ManagedLoginBranding resource for the given app client. + /// No-ops when is . + /// When a has been configured the branding resource is given an explicit + /// CloudFormation dependency on the domain so it is not created before the domain exists. + /// + private void CreateManagedLoginBranding( + IUserPool userPool, + UserPoolClient userPoolClient, + ICognitoManagedLoginBrandingProps? branding, + string appClientName) + { + if (branding is null) + { + return; + } + + ArgumentException.ThrowIfNullOrWhiteSpace(branding.SettingsJson); + + var parsed = JsonSerializer.Deserialize(branding.SettingsJson); + var settings = ToJsiiCompatible(parsed); + + List? assets = null; + + if (branding.Assets is { Count: > 0 }) + { + assets = []; + + foreach (var asset in branding.Assets) + { + ArgumentException.ThrowIfNullOrWhiteSpace(asset.Category); + ArgumentException.ThrowIfNullOrWhiteSpace(asset.ColorMode); + ArgumentException.ThrowIfNullOrWhiteSpace(asset.Extension); + ArgumentException.ThrowIfNullOrWhiteSpace(asset.FilePath); + + var bytes = Convert.ToBase64String(File.ReadAllBytes(asset.FilePath)); + + assets.Add(new CfnManagedLoginBranding.AssetTypeProperty + { + Category = asset.Category, + ColorMode = asset.ColorMode, + Extension = asset.Extension, + Bytes = bytes, + }); + } + } + + var cfnBranding = new CfnManagedLoginBranding(this, $"managed-login-branding-{appClientName}", new CfnManagedLoginBrandingProps + { + UserPoolId = userPool.UserPoolId, + ClientId = userPoolClient.UserPoolClientId, + Settings = settings, + Assets = assets?.ToArray(), + }); + + if (Domain is not null) + { + cfnBranding.Node.AddDependency(Domain); + } + } + + /// + /// Recursively converts a into a plain CLR object graph + /// (, , or a primitive) that the + /// JSII runtime can serialize when passing the branding settings to CloudFormation. + /// itself is not a JSII-compatible type. + /// + private static object ToJsiiCompatible(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.Object => element.EnumerateObject() + .ToDictionary(p => p.Name, p => ToJsiiCompatible(p.Value)), + JsonValueKind.Array => element.EnumerateArray() + .Select(ToJsiiCompatible) + .ToArray(), + JsonValueKind.String => (object)element.GetString()!, + JsonValueKind.Number => element.TryGetInt64(out var l) + ? (object)l + : element.GetDouble(), + JsonValueKind.True => (object)true, + JsonValueKind.False => (object)false, + _ => (object)null!, + }; + } + + private void ConfigureUserPoolDomain(string id, ICognitoUserPoolConstructProps props) + { + var hasPrefixDomain = !string.IsNullOrWhiteSpace(props.Domain!.CognitoDomainPrefix); + var hasCustomDomain = + !string.IsNullOrWhiteSpace(props.Domain.DomainName) && + !string.IsNullOrWhiteSpace(props.Domain.AuthSubDomain); + + if (hasPrefixDomain == hasCustomDomain) + { + throw new ArgumentException( + "Exactly one domain mode must be configured. Specify either CognitoDomainPrefix or DomainName + AuthSubDomain.", + nameof(props)); + } + + if (hasPrefixDomain) + { + Domain = new UserPoolDomain(this, $"{id}-domain", new UserPoolDomainProps + { + UserPool = UserPool, + CognitoDomain = new CognitoDomainOptions + { + DomainPrefix = props.Domain.CognitoDomainPrefix!, + }, + ManagedLoginVersion = props.Domain.ManagedLoginVersion switch + { + CognitoManagedLoginVersion.ClassicHostedUi => ManagedLoginVersion.CLASSIC_HOSTED_UI, + _ => ManagedLoginVersion.NEWER_MANAGED_LOGIN, + }, + }); + } + else + { + var zone = HostedZone.FromLookup(this, $"{id}-hosted-zone", new HostedZoneProviderProps + { + DomainName = props.Domain.DomainName!, + }); + + var authDomain = $"{props.Domain.AuthSubDomain}.{props.Domain.DomainName}"; + + // Use a caller-supplied certificate (required when the stack is not in us-east-1, + // since Cognito custom domains require an ACM certificate in us-east-1). + // When none is provided, create one in the stack's region (valid for us-east-1 stacks). + if (props.Domain.Certificate is null) + { + var stackRegion = Stack.Of(this).Region; + if (!Token.IsUnresolved(stackRegion) && stackRegion != "us-east-1") + { + throw new ArgumentException( + $"Cognito custom domains require an ACM certificate in us-east-1, but this stack is in '{stackRegion}'. " + + "Create the certificate in a us-east-1 stack and supply it via props.Domain.Certificate.", + nameof(props)); + } + } + + Certificate = props.Domain.Certificate ?? new Certificate(this, $"{id}-certificate", new CertificateProps + { + DomainName = authDomain, + Validation = CertificateValidation.FromDns(zone), + }); + + Domain = new UserPoolDomain(this, $"{id}-domain", new UserPoolDomainProps + { + UserPool = UserPool, + CustomDomain = new CustomDomainOptions + { + DomainName = authDomain, + Certificate = Certificate, + }, + ManagedLoginVersion = props.Domain.ManagedLoginVersion switch + { + CognitoManagedLoginVersion.ClassicHostedUi => ManagedLoginVersion.CLASSIC_HOSTED_UI, + _ => ManagedLoginVersion.NEWER_MANAGED_LOGIN, + }, + }); + + if (props.Domain.CreateRoute53Record) + { + _ = new ARecord(this, $"{id}-alias-record", new ARecordProps + { + Zone = zone, + RecordName = authDomain, + Target = RecordTarget.FromAlias(new UserPoolDomainTarget(Domain)), + }); + } + } + } + + private void CreateGroups( + IUserPool userPool, + IReadOnlyCollection? groups) + { + if (groups is null || groups.Count == 0) + { + return; + } + + foreach (var group in groups) + { + ArgumentException.ThrowIfNullOrWhiteSpace(group.Name); + + _ = new CfnUserPoolGroup(this, $"group-{group.Name.ToLowerInvariant()}", new CfnUserPoolGroupProps + { + UserPoolId = userPool.UserPoolId, + GroupName = group.Name, + Description = group.Description, + Precedence = group.Precedence, + RoleArn = group.RoleArn + }); + } + } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoManagedLoginBrandingProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoManagedLoginBrandingProps.cs new file mode 100644 index 0000000..662215d --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoManagedLoginBrandingProps.cs @@ -0,0 +1,65 @@ +namespace LayeredCraft.Cdk.Constructs.Models; + +/// +/// Configuration properties for a single branding asset uploaded to Cognito Managed Login. +/// Asset bytes are read from at synth time and base64-encoded before +/// being passed to CloudFormation. +/// +public interface ICognitoManagedLoginBrandingAssetProps +{ + /// + /// The asset category as defined by Cognito (e.g. FORM_LOGO, BROWSER_FAVICON). + /// + string Category { get; } + + /// + /// The color mode the asset applies to. Accepted values are LIGHT and DARK. + /// + string ColorMode { get; } + + /// + /// The file extension of the asset (e.g. png, svg, ico). + /// + string Extension { get; } + + /// + /// The absolute or relative path to the asset file on the machine running cdk synth / cdk deploy. + /// The file is read and base64-encoded at synth time. + /// + string FilePath { get; } +} + +/// +/// Default implementation of . +/// +public sealed record CognitoManagedLoginBrandingAssetProps( + string Category, + string ColorMode, + string Extension, + string FilePath) : ICognitoManagedLoginBrandingAssetProps; + +/// +/// Configuration properties for Cognito Managed Login branding associated with an app client. +/// +public interface ICognitoManagedLoginBrandingProps +{ + /// + /// A JSON string matching the Cognito Managed Login branding settings schema. + /// The document is deserialized at synth time and passed directly to the + /// AWS::Cognito::ManagedLoginBranding CloudFormation resource. + /// + string SettingsJson { get; } + + /// + /// Optional collection of branding assets (logos, favicons) to upload alongside the settings. + /// When or empty no assets are included. + /// + IReadOnlyCollection? Assets { get; } +} + +/// +/// Default implementation of . +/// +public sealed record CognitoManagedLoginBrandingProps( + string SettingsJson, + IReadOnlyCollection? Assets = null) : ICognitoManagedLoginBrandingProps; diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerProps.cs new file mode 100644 index 0000000..64c4026 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerProps.cs @@ -0,0 +1,19 @@ +namespace LayeredCraft.Cdk.Constructs.Models; + +public interface ICognitoResourceServerProps +{ + string Name { get; } + + string Identifier { get; } + + IReadOnlyList Scopes { get; } + +} +public sealed record CognitoResourceServerProps : ICognitoResourceServerProps +{ + public required string Name { get; init; } + + public required string Identifier { get; init; } + + public required IReadOnlyList Scopes { get; init; } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerScopeProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerScopeProps.cs new file mode 100644 index 0000000..8b5973b --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoResourceServerScopeProps.cs @@ -0,0 +1,15 @@ +namespace LayeredCraft.Cdk.Constructs.Models; + +public interface ICognitoResourceServerScopeProps +{ + string Name { get; } + + string Description { get; } +} + +public sealed record CognitoResourceServerScopeProps : ICognitoResourceServerScopeProps +{ + public required string Name { get; init; } + + public required string Description { get; init; } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolAppClientProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolAppClientProps.cs new file mode 100644 index 0000000..c62f7d1 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolAppClientProps.cs @@ -0,0 +1,57 @@ +using Amazon.CDK.AWS.Cognito; + +namespace LayeredCraft.Cdk.Constructs.Models; + +public interface ICognitoUserPoolAppClientProps +{ + string Name { get; } + + bool GenerateSecret { get; } + + IReadOnlyList CallbackUrls { get; } + + IReadOnlyList LogoutUrls { get; } + + bool AuthorizationCodeGrant { get; } + + bool ImplicitCodeGrant { get; } + + bool ClientCredentials { get; } + + IReadOnlyList AllowedOAuthScopes { get; } + + IReadOnlyList SupportedIdentityProviders { get; } + + /// + /// Optional Managed Login branding configuration to associate with this app client. + /// When , no AWS::Cognito::ManagedLoginBranding resource is created + /// for the client and Cognito's default styling is used. + /// + ICognitoManagedLoginBrandingProps? ManagedLoginBranding { get; } +} + +public sealed record CognitoUserPoolAppClientProps : ICognitoUserPoolAppClientProps +{ + public required string Name { get; init; } + + public bool GenerateSecret { get; init; } = false; + + public IReadOnlyList CallbackUrls { get; init; } = Array.Empty(); + + public IReadOnlyList LogoutUrls { get; init; } = Array.Empty(); + + public bool AuthorizationCodeGrant { get; init; } = true; + + public bool ImplicitCodeGrant { get; init; } = false; + + public bool ClientCredentials { get; init; } = false; + + public IReadOnlyList AllowedOAuthScopes { get; init; } = + [OAuthScope.OPENID, OAuthScope.EMAIL]; + + public IReadOnlyList SupportedIdentityProviders { get; init; } = + [UserPoolClientIdentityProvider.COGNITO]; + + /// + public ICognitoManagedLoginBrandingProps? ManagedLoginBranding { get; init; } +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs new file mode 100644 index 0000000..30ade03 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolConstructProps.cs @@ -0,0 +1,39 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.Cognito; + +namespace LayeredCraft.Cdk.Constructs.Models; + +public interface ICognitoUserPoolConstructProps +{ + string UserPoolName { get; } + + bool SelfSignUpEnabled { get; } + + RemovalPolicy RemovalPolicy { get; } + + Mfa Mfa { get; } + + int PasswordMinLength { get; } + + IReadOnlyList ResourceServers { get; } + IReadOnlyList AppClients { get; } + ICognitoUserPoolDomainProps? Domain { get; } + IReadOnlyCollection? Groups { get; } +} + +public sealed record CognitoUserPoolConstructProps : ICognitoUserPoolConstructProps +{ + public required string UserPoolName { get; init; } + + public bool SelfSignUpEnabled { get; init; } = true; + + public RemovalPolicy RemovalPolicy { get; init; } = RemovalPolicy.DESTROY; + + public Mfa Mfa { get; init; } = Mfa.OFF; + + public int PasswordMinLength { get; init; } = 12; + public IReadOnlyList ResourceServers { get; init; } = []; + public IReadOnlyList AppClients { get; init; } = []; + public ICognitoUserPoolDomainProps? Domain { get; init; } + public IReadOnlyCollection? Groups { get; init; } = []; +} diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolDomainProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolDomainProps.cs new file mode 100644 index 0000000..afad518 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolDomainProps.cs @@ -0,0 +1,49 @@ +using Amazon.CDK.AWS.CertificateManager; + +namespace LayeredCraft.Cdk.Constructs.Models; + + +public interface ICognitoUserPoolDomainProps +{ + string? CognitoDomainPrefix { get; } + + string? DomainName { get; } + + string? AuthSubDomain { get; } + + CognitoManagedLoginVersion ManagedLoginVersion { get; } + + bool CreateRoute53Record { get; } + + /// + /// An existing ACM certificate to use for the custom domain. + /// When provided, the construct skips certificate creation and uses this certificate directly. + /// Cognito custom domains require the certificate to be in us-east-1. + /// If and a custom domain is configured, a new certificate is created + /// in the stack's region (valid when the stack is already deployed to us-east-1). + /// + ICertificate? Certificate { get; } +} + +public sealed record CognitoUserPoolDomainProps : ICognitoUserPoolDomainProps +{ + public string? CognitoDomainPrefix { get; init; } + + public string? DomainName { get; init; } + + public string? AuthSubDomain { get; init; } + + public CognitoManagedLoginVersion ManagedLoginVersion { get; init; } = + CognitoManagedLoginVersion.ManagedLogin; + + public bool CreateRoute53Record { get; init; } = true; + + /// + public ICertificate? Certificate { get; init; } +} + +public enum CognitoManagedLoginVersion +{ + ClassicHostedUi, + ManagedLogin, +} \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolGroupProps.cs b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolGroupProps.cs new file mode 100644 index 0000000..8cc2800 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Models/CognitoUserPoolGroupProps.cs @@ -0,0 +1,15 @@ +namespace LayeredCraft.Cdk.Constructs.Models; + +public interface ICognitoUserPoolGroupProps +{ + string Name { get; } + string? Description { get; } + int? Precedence { get; } + string? RoleArn { get; } +} + +public sealed record CognitoUserPoolGroupProps( + string Name, + string? Description = null, + int? Precedence = null, + string? RoleArn = null) : ICognitoUserPoolGroupProps; \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs b/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs index 78aac48..337e612 100644 --- a/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs +++ b/src/LayeredCraft.Cdk.Constructs/Testing/CdkTestHelper.cs @@ -227,4 +227,19 @@ public static DynamoDbTableConstructPropsBuilder CreateDynamoDbTablePropsBuilder return new DynamoDbTableConstructPropsBuilder() .WithPartitionKey("pk", Amazon.CDK.AWS.DynamoDB.AttributeType.STRING); } + + /// + /// Creates a with sensible test defaults. + /// The builder defaults to a Cognito-hosted domain prefix to avoid requiring AWS environment + /// context (account/region) for Route 53 and certificate lookups. + /// + /// Optional user pool name override. Defaults to "test-user-pool". + /// A configured builder for creating Cognito user pool test props + public static CognitoUserPoolConstructPropsBuilder CreateCognitoUserPoolPropsBuilder( + string userPoolName = "test-user-pool") + { + return new CognitoUserPoolConstructPropsBuilder() + .WithUserPoolName(userPoolName) + .WithCognitoDomain(userPoolName); + } } \ No newline at end of file diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructAssertions.cs b/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructAssertions.cs new file mode 100644 index 0000000..634584b --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructAssertions.cs @@ -0,0 +1,151 @@ +using Amazon.CDK.Assertions; + +namespace LayeredCraft.Cdk.Constructs.Testing; + +/// +/// Extension methods for asserting Cognito resources in CDK templates. +/// These helpers simplify common testing scenarios for consumers of the library. +/// +public static class CognitoUserPoolConstructAssertions +{ + /// + /// Asserts that the template contains a Cognito user pool with the specified name. + /// + public static void ShouldHaveUserPool(this Template template, string userPoolName) + { + template.HasResourceProperties("AWS::Cognito::UserPool", Match.ObjectLike(new Dictionary + { + { "UserPoolName", userPoolName }, + })); + } + + /// + /// Asserts that the template contains a Cognito-hosted user pool domain with the specified prefix. + /// + public static void ShouldHaveCognitoUserPoolDomain(this Template template, string domainPrefix) + { + template.HasResourceProperties("AWS::Cognito::UserPoolDomain", Match.ObjectLike(new Dictionary + { + { "Domain", domainPrefix }, + })); + } + + /// + /// Asserts that the template contains a user pool app client with the specified name. + /// + public static void ShouldHaveUserPoolClient(this Template template, string clientName) + { + template.HasResourceProperties("AWS::Cognito::UserPoolClient", Match.ObjectLike(new Dictionary + { + { "ClientName", clientName }, + })); + } + + /// + /// Asserts that the template contains a user pool resource server with the specified identifier. + /// + public static void ShouldHaveResourceServer(this Template template, string identifier) + { + template.HasResourceProperties("AWS::Cognito::UserPoolResourceServer", Match.ObjectLike(new Dictionary + { + { "Identifier", identifier }, + })); + } + + /// + /// Asserts that the template contains a user pool group with the specified name. + /// + public static void ShouldHaveUserPoolGroup(this Template template, string groupName) + { + template.HasResourceProperties("AWS::Cognito::UserPoolGroup", Match.ObjectLike(new Dictionary + { + { "GroupName", groupName }, + })); + } + + /// + /// Asserts that the template contains exactly one AWS::Cognito::ManagedLoginBranding resource + /// and that it carries a ClientId property linking it to an app client. + /// + public static void ShouldHaveManagedLoginBranding(this Template template) + { + template.ResourceCountIs("AWS::Cognito::ManagedLoginBranding", 1); + + var brandings = template.FindResources("AWS::Cognito::ManagedLoginBranding"); + var props = (IDictionary)brandings.Values.First()["Properties"]; + + if (!props.ContainsKey("ClientId")) + { + throw new InvalidOperationException( + "Expected AWS::Cognito::ManagedLoginBranding to have a ClientId property linking it to an app client."); + } + + if (!props.ContainsKey("Settings")) + { + throw new InvalidOperationException( + "Expected AWS::Cognito::ManagedLoginBranding to have a Settings property."); + } + } + + /// + /// Asserts that the template contains no AWS::Cognito::ManagedLoginBranding resources. + /// + public static void ShouldNotHaveManagedLoginBranding(this Template template) + { + template.ResourceCountIs("AWS::Cognito::ManagedLoginBranding", 0); + } + + /// + /// Asserts that the template exports the user pool ID with the expected export name + /// ({stackName}-{constructId}-user-pool-id). + /// + public static void ShouldExportUserPoolId(this Template template, string stackName, string constructId) + { + var exportName = $"{stackName.ToLowerInvariant()}-{constructId.ToLowerInvariant()}-user-pool-id"; + AssertExportExists(template, exportName); + } + + /// + /// Asserts that the template exports the user pool ARN with the expected export name + /// ({stackName}-{constructId}-user-pool-arn). + /// + public static void ShouldExportUserPoolArn(this Template template, string stackName, string constructId) + { + var exportName = $"{stackName.ToLowerInvariant()}-{constructId.ToLowerInvariant()}-user-pool-arn"; + AssertExportExists(template, exportName); + } + + /// + /// Asserts that the template exports the app client ID for the given client name with the expected export name + /// ({stackName}-{constructId}-client-{clientName}-id). + /// + public static void ShouldExportAppClientId(this Template template, string stackName, string constructId, string clientName) + { + var sanitized = clientName.ToLowerInvariant().Replace(' ', '-'); + var exportName = $"{stackName.ToLowerInvariant()}-{constructId.ToLowerInvariant()}-client-{sanitized}-id"; + AssertExportExists(template, exportName); + } + + private static void AssertExportExists(Template template, string exportName) + { + var json = template.ToJSON(); + if (!json.TryGetValue("Outputs", out var outputsObj)) + { + throw new InvalidOperationException($"Template has no Outputs section. Expected export '{exportName}'."); + } + + var outputs = (IDictionary)outputsObj; + foreach (var output in outputs.Values) + { + var outputDict = (IDictionary)output; + if (outputDict.TryGetValue("Export", out var exportObj)) + { + var export = (IDictionary)exportObj; + if (export.TryGetValue("Name", out var name) && name?.ToString() == exportName) + return; + } + } + + throw new InvalidOperationException($"Expected CloudFormation export '{exportName}' was not found in the template."); + } +} diff --git a/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructPropsBuilder.cs b/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructPropsBuilder.cs new file mode 100644 index 0000000..857e192 --- /dev/null +++ b/src/LayeredCraft.Cdk.Constructs/Testing/CognitoUserPoolConstructPropsBuilder.cs @@ -0,0 +1,184 @@ +using Amazon.CDK; +using Amazon.CDK.AWS.Cognito; +using LayeredCraft.Cdk.Constructs.Models; + +namespace LayeredCraft.Cdk.Constructs.Testing; + +/// +/// Fluent builder for creating instances in tests. +/// Provides sensible defaults and allows targeted customization of specific properties. +/// +public class CognitoUserPoolConstructPropsBuilder +{ + private string _userPoolName = "test-user-pool"; + private bool _selfSignUpEnabled = true; + private RemovalPolicy _removalPolicy = RemovalPolicy.DESTROY; + private Mfa _mfa = Mfa.OFF; + private int _passwordMinLength = 12; + private ICognitoUserPoolDomainProps? _domain = new CognitoUserPoolDomainProps + { + CognitoDomainPrefix = "test-user-pool", + ManagedLoginVersion = CognitoManagedLoginVersion.ManagedLogin, + CreateRoute53Record = false, + }; + private readonly List _resourceServers = []; + private readonly List _appClients = []; + private readonly List _groups = []; + + /// Sets the Cognito user pool name. + public CognitoUserPoolConstructPropsBuilder WithUserPoolName(string userPoolName) + { + _userPoolName = userPoolName; + return this; + } + + /// Enables or disables self sign-up. Defaults to . + public CognitoUserPoolConstructPropsBuilder WithSelfSignUpEnabled(bool enabled) + { + _selfSignUpEnabled = enabled; + return this; + } + + /// Sets the removal policy. Defaults to . + public CognitoUserPoolConstructPropsBuilder WithRemovalPolicy(RemovalPolicy removalPolicy) + { + _removalPolicy = removalPolicy; + return this; + } + + /// Sets the MFA requirement. Defaults to . + public CognitoUserPoolConstructPropsBuilder WithMfa(Mfa mfa) + { + _mfa = mfa; + return this; + } + + /// Sets the minimum password length. Defaults to 12. + public CognitoUserPoolConstructPropsBuilder WithPasswordMinLength(int minLength) + { + _passwordMinLength = minLength; + return this; + } + + /// + /// Configures a Cognito-hosted domain with the given prefix and Managed Login v2. + /// Use this in tests to avoid requiring AWS environment context (account/region) for custom domains. + /// + public CognitoUserPoolConstructPropsBuilder WithCognitoDomain( + string domainPrefix, + CognitoManagedLoginVersion version = CognitoManagedLoginVersion.ManagedLogin) + { + _domain = new CognitoUserPoolDomainProps + { + CognitoDomainPrefix = domainPrefix, + ManagedLoginVersion = version, + CreateRoute53Record = false, + }; + return this; + } + + /// Sets a fully custom domain props object. + public CognitoUserPoolConstructPropsBuilder WithDomain(ICognitoUserPoolDomainProps domain) + { + _domain = domain; + return this; + } + + /// Removes the domain configuration so no AWS::Cognito::UserPoolDomain is created. + public CognitoUserPoolConstructPropsBuilder WithoutDomain() + { + _domain = null; + return this; + } + + /// Adds a resource server with the given scopes. + public CognitoUserPoolConstructPropsBuilder AddResourceServer( + string name, + string identifier, + IReadOnlyList? scopes = null) + { + _resourceServers.Add(new CognitoResourceServerProps + { + Name = name, + Identifier = identifier, + Scopes = scopes ?? [], + }); + return this; + } + + /// Adds an app client using a pre-built props object. + public CognitoUserPoolConstructPropsBuilder AddAppClient(ICognitoUserPoolAppClientProps client) + { + _appClients.Add(client); + return this; + } + + /// + /// Adds a standard web app client with Authorization Code Grant and optional Managed Login branding. + /// + public CognitoUserPoolConstructPropsBuilder AddWebAppClient( + string name, + IReadOnlyList callbackUrls, + IReadOnlyList logoutUrls, + ICognitoManagedLoginBrandingProps? branding = null) + { + _appClients.Add(new CognitoUserPoolAppClientProps + { + Name = name, + GenerateSecret = false, + CallbackUrls = callbackUrls, + LogoutUrls = logoutUrls, + AuthorizationCodeGrant = true, + ImplicitCodeGrant = false, + ClientCredentials = false, + AllowedOAuthScopes = [OAuthScope.OPENID, OAuthScope.EMAIL], + SupportedIdentityProviders = [UserPoolClientIdentityProvider.COGNITO], + ManagedLoginBranding = branding, + }); + return this; + } + + /// Adds a user group. + public CognitoUserPoolConstructPropsBuilder AddGroup( + string name, + string? description = null, + int? precedence = null) + { + _groups.Add(new CognitoUserPoolGroupProps(name, description, precedence)); + return this; + } + + /// + /// Configures a complete web application setup: named user pool, Cognito-hosted domain, + /// a single web client, and a default resource server. + /// Provides a convenient starting point that can be further customised with additional calls. + /// + public CognitoUserPoolConstructPropsBuilder ForWebApplication( + string userPoolName = "test-user-pool", + string cognitoDomainPrefix = "test-user-pool") + { + return WithUserPoolName(userPoolName) + .WithCognitoDomain(cognitoDomainPrefix) + .AddWebAppClient( + name: "web-client", + callbackUrls: ["https://example.com/callback"], + logoutUrls: ["https://example.com"]); + } + + /// Builds the instance. + public CognitoUserPoolConstructProps Build() + { + return new CognitoUserPoolConstructProps + { + UserPoolName = _userPoolName, + SelfSignUpEnabled = _selfSignUpEnabled, + RemovalPolicy = _removalPolicy, + Mfa = _mfa, + PasswordMinLength = _passwordMinLength, + Domain = _domain, + ResourceServers = [.. _resourceServers], + AppClients = [.. _appClients], + Groups = [.. _groups], + }; + } +} diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/CognitoUserPoolConstructTests.cs b/test/LayeredCraft.Cdk.Constructs.Tests/CognitoUserPoolConstructTests.cs new file mode 100644 index 0000000..d5936e9 --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/CognitoUserPoolConstructTests.cs @@ -0,0 +1,273 @@ +using Amazon.CDK; +using Amazon.CDK.Assertions; +using AwesomeAssertions; +using LayeredCraft.Cdk.Constructs; +using LayeredCraft.Cdk.Constructs.Models; +using LayeredCraft.Cdk.Constructs.Testing; +using LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes; + +namespace LayeredCraft.Cdk.Constructs.Tests; + +[Collection("CDK Tests")] +public class CognitoUserPoolConstructTests +{ + // ------------------------------------------------------------------------- + // User pool + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldCreateUserPool(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveUserPool(props.UserPoolName); + } + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExposeUserPoolProperty(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + var construct = new CognitoUserPoolConstruct(stack, "test-pool", props); + + construct.UserPool.Should().NotBeNull(); + } + + // ------------------------------------------------------------------------- + // Domain + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldCreateUserPoolDomain(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveCognitoUserPoolDomain(props.Domain!.CognitoDomainPrefix!); + } + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExposeDomainProperty(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + var construct = new CognitoUserPoolConstruct(stack, "test-pool", props); + + construct.Domain.Should().NotBeNull(); + } + + [Fact] + public void Construct_ShouldNotCreateDomain_WhenDomainIsNull() + { + var stack = CreateStack(); + var props = CdkTestHelper.CreateCognitoUserPoolPropsBuilder() + .AddWebAppClient("client", ["https://example.com/callback"], ["https://example.com"]) + .WithoutDomain() + .Build(); + + var construct = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + construct.Domain.Should().BeNull(); + template.ResourceCountIs("AWS::Cognito::UserPoolDomain", 0); + } + + // ------------------------------------------------------------------------- + // App client + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldCreateAppClient(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveUserPoolClient(props.AppClients[0].Name); + } + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExposeAppClientsProperty(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + var construct = new CognitoUserPoolConstruct(stack, "test-pool", props); + + construct.AppClients.Should().ContainKey(props.AppClients[0].Name); + } + + // ------------------------------------------------------------------------- + // Resource server + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldCreateResourceServer(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveResourceServer(props.ResourceServers[0].Identifier); + } + + // ------------------------------------------------------------------------- + // Groups + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldCreateUserGroup(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveUserPoolGroup(props.Groups!.First().Name); + } + + // ------------------------------------------------------------------------- + // Managed login branding โ€” absent + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData(includeBranding: false)] + public void Construct_ShouldNotCreateManagedLoginBranding_WhenBrandingIsNull(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldNotHaveManagedLoginBranding(); + } + + // ------------------------------------------------------------------------- + // Managed login branding โ€” present + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData(includeBranding: true)] + public void Construct_ShouldCreateManagedLoginBranding_WhenBrandingIsConfigured(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldHaveManagedLoginBranding(); + } + + [Theory] + [CognitoUserPoolConstructAutoData(includeBranding: true)] + public void Construct_ShouldNotCreateBranding_WhenNoBrandingOnClient(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + var noBrandingProps = props with + { + AppClients = + [ + new CognitoUserPoolAppClientProps + { + Name = props.AppClients[0].Name, + CallbackUrls = props.AppClients[0].CallbackUrls, + LogoutUrls = props.AppClients[0].LogoutUrls, + AllowedOAuthScopes = props.AppClients[0].AllowedOAuthScopes, + SupportedIdentityProviders = props.AppClients[0].SupportedIdentityProviders, + ManagedLoginBranding = null, + }, + ], + }; + + _ = new CognitoUserPoolConstruct(stack, "test-pool", noBrandingProps); + var template = Template.FromStack(stack); + + template.ShouldNotHaveManagedLoginBranding(); + } + + [Theory] + [CognitoUserPoolConstructAutoData(includeBranding: true)] + public void Construct_ShouldThrow_WhenBrandingSettingsJsonIsEmpty(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + var badProps = props with + { + AppClients = + [ + new CognitoUserPoolAppClientProps + { + Name = props.AppClients[0].Name, + CallbackUrls = props.AppClients[0].CallbackUrls, + LogoutUrls = props.AppClients[0].LogoutUrls, + AllowedOAuthScopes = props.AppClients[0].AllowedOAuthScopes, + SupportedIdentityProviders = props.AppClients[0].SupportedIdentityProviders, + ManagedLoginBranding = new CognitoManagedLoginBrandingProps(SettingsJson: ""), + }, + ], + }; + + var act = () => new CognitoUserPoolConstruct(stack, "test-pool", badProps); + + act.Should().Throw(); + } + + // ------------------------------------------------------------------------- + // CloudFormation outputs + // ------------------------------------------------------------------------- + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExportUserPoolId(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldExportUserPoolId("test-stack", "test-pool"); + } + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExportUserPoolArn(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldExportUserPoolArn("test-stack", "test-pool"); + } + + [Theory] + [CognitoUserPoolConstructAutoData] + public void Construct_ShouldExportAppClientId(CognitoUserPoolConstructProps props) + { + var stack = CreateStack(); + + _ = new CognitoUserPoolConstruct(stack, "test-pool", props); + var template = Template.FromStack(stack); + + template.ShouldExportAppClientId("test-stack", "test-pool", props.AppClients[0].Name); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static Stack CreateStack() => new(new App(), "test-stack"); +} diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/CognitoUserPoolConstructAutoDataAttribute.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/CognitoUserPoolConstructAutoDataAttribute.cs new file mode 100644 index 0000000..168c8fe --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Attributes/CognitoUserPoolConstructAutoDataAttribute.cs @@ -0,0 +1,16 @@ +using AutoFixture.Xunit3; +using LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations; + +namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Attributes; + +public class CognitoUserPoolConstructAutoDataAttribute(bool includeBranding = false) + : AutoDataAttribute(() => CreateFixture(includeBranding)) +{ + private static IFixture CreateFixture(bool includeBranding) + { + return BaseFixtureFactory.CreateFixture(fixture => + { + fixture.Customize(new CognitoUserPoolConstructCustomization(includeBranding)); + }); + } +} diff --git a/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/CognitoUserPoolConstructCustomization.cs b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/CognitoUserPoolConstructCustomization.cs new file mode 100644 index 0000000..36b926b --- /dev/null +++ b/test/LayeredCraft.Cdk.Constructs.Tests/TestKit/Customizations/CognitoUserPoolConstructCustomization.cs @@ -0,0 +1,45 @@ +using LayeredCraft.Cdk.Constructs.Models; +using LayeredCraft.Cdk.Constructs.Testing; + +namespace LayeredCraft.Cdk.Constructs.Tests.TestKit.Customizations; + +public class CognitoUserPoolConstructCustomization(bool includeBranding = false) : ICustomization +{ + private const string TestSettingsJson = """ + { + "components": { + "pageBackground": { + "darkMode": { "color": "031425ff" }, + "lightMode": { "color": "ffffffff" } + }, + "primaryButton": { + "darkMode": { "defaults": { "backgroundColor": "e9c400ff", "textColor": "231c00ff" } } + } + } + } + """; + + public void Customize(IFixture fixture) + { + fixture.Customize(transform => transform + .FromFactory(() => BuildProps()) + .OmitAutoProperties()); + } + + private CognitoUserPoolConstructProps BuildProps() => + CdkTestHelper.CreateCognitoUserPoolPropsBuilder() + .AddResourceServer( + name: "Test API", + identifier: "test-api", + scopes: + [ + new CognitoResourceServerScopeProps { Name = "read", Description = "Read access" }, + ]) + .AddWebAppClient( + name: "test-web-client", + callbackUrls: ["https://example.com/callback"], + logoutUrls: ["https://example.com"], + branding: includeBranding ? new CognitoManagedLoginBrandingProps(TestSettingsJson) : null) + .AddGroup(name: "test-group", description: "Test group", precedence: 1) + .Build(); +}