From e837e612325909fea7ea1e68b60e1cfbfb583b4c Mon Sep 17 00:00:00 2001 From: Chris Wilton-Magras Date: Fri, 3 May 2024 14:45:22 +0100 Subject: [PATCH] Hook up Azure SSO login in cognito stack --- cloud/.env.example | 5 + cloud/bin/cloud.ts | 2 - cloud/lib/auth-stack.ts | 120 +++++++++--------- cloud/lib/certificate-stack.ts | 3 - frontend/.env.example | 1 - .../AuthProviders/CognitoAuthenticatedApp.tsx | 15 +-- frontend/src/vite-env.d.ts | 1 - 7 files changed, 73 insertions(+), 74 deletions(-) diff --git a/cloud/.env.example b/cloud/.env.example index 68dfff3a..b31adf03 100644 --- a/cloud/.env.example +++ b/cloud/.env.example @@ -1,2 +1,7 @@ DOMAIN_NAME=your.domain HOSTED_ZONE_ID=YOUR_AWS_HOSTED_ZONE_ID + +# Enable Azure IdP +#IDP_NAME=azure +#AZURE_APPLICATION_ID=[your-azure-application-id] +#AZURE_TENANT_ID=[your-azure-tenant-id] diff --git a/cloud/bin/cloud.ts b/cloud/bin/cloud.ts index b2c3ea3f..56827702 100644 --- a/cloud/bin/cloud.ts +++ b/cloud/bin/cloud.ts @@ -49,8 +49,6 @@ const authStack = new AuthStack(app, generateStackName('auth'), { description: generateDescription('Auth stack'), env, tags, - authDomainName: certificateStack.authDomainName, - certificate: certificateStack.cloudFrontCert, hostedZone: hostedZoneStack.hostedZone, }); diff --git a/cloud/lib/auth-stack.ts b/cloud/lib/auth-stack.ts index 96391698..8f4b27ec 100644 --- a/cloud/lib/auth-stack.ts +++ b/cloud/lib/auth-stack.ts @@ -1,27 +1,21 @@ -import { ICertificate } from 'aws-cdk-lib/aws-certificatemanager'; import { - AllowedMethods, - CachePolicy, - Distribution, - OriginRequestPolicy, - PriceClass, - ResponseHeadersPolicy, - ViewerProtocolPolicy, -} from 'aws-cdk-lib/aws-cloudfront'; -import { HttpOrigin } from 'aws-cdk-lib/aws-cloudfront-origins'; -import { Mfa, OAuthScope, UserPool } from 'aws-cdk-lib/aws-cognito'; -import { ARecord, IHostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53'; -import { CloudFrontTarget } from 'aws-cdk-lib/aws-route53-targets'; + Mfa, + OAuthScope, + ProviderAttribute, + UserPool, + UserPoolClientIdentityProvider, + UserPoolIdentityProviderSaml, + UserPoolIdentityProviderSamlMetadata, +} from 'aws-cdk-lib/aws-cognito'; +import { IHostedZone } from 'aws-cdk-lib/aws-route53'; import { StringParameter } from 'aws-cdk-lib/aws-ssm'; import { CfnOutput, Duration, RemovalPolicy, Stack, StackProps, Tags } from 'aws-cdk-lib/core'; import { Construct } from 'constructs'; -import { resourceId, stageName } from './resourceNamingUtils'; +import { appName, resourceId, stageName } from './resourceNamingUtils'; type AuthStackProps = StackProps & { - certificate: ICertificate; hostedZone: IHostedZone; - authDomainName: string; }; export class AuthStack extends Stack { @@ -36,12 +30,14 @@ export class AuthStack extends Stack { const stage = stageName(scope); const generateResourceId = resourceId(scope); - const { certificate, env, hostedZone, authDomainName } = props; + const { env, hostedZone } = props; if (!env?.region) { throw new Error('Region not defined in stack env, cannot continue!'); } - // Cognito UserPool + /* + User Pool - including attribute claims and password policy + */ const userPool = new UserPool(this, generateResourceId('userpool'), { enableSmsRole: false, mfa: Mfa.OFF, @@ -69,12 +65,50 @@ export class AuthStack extends Stack { Tags.of(userPool).add(key, value); }); - new CfnOutput(this, 'UserPool.Id', { - value: `urn:amazon:cognito:sp:${userPool.userPoolId}`, + /* + Identity Providers - currently only Cognito itself plus Azure + */ + const supportedIdentityProviders = [UserPoolClientIdentityProvider.COGNITO]; + + if (process.env.IDP_NAME?.toUpperCase() === 'AZURE') { + const { AZURE_APPLICATION_ID, AZURE_TENANT_ID } = process.env; + if (!AZURE_APPLICATION_ID) throw new Error('Missing env var AZURE_APPLICATION_SECRET'); + if (!AZURE_TENANT_ID) throw new Error('Missing env var AZURE_TENANT_ID'); + const idp = new UserPoolIdentityProviderSaml(this, generateResourceId('azure-idp'), { + name: 'Azure', + userPool, + metadata: UserPoolIdentityProviderSamlMetadata.url( + `https://login.microsoftonline.com/${AZURE_TENANT_ID}/federationmetadata/2007-06/federationmetadata.xml?appid=${AZURE_APPLICATION_ID}` + ), + attributeMapping: { + email: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name' + ), + familyName: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname' + ), + givenName: ProviderAttribute.other( + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname' + ), + }, + }); + supportedIdentityProviders.push(UserPoolClientIdentityProvider.custom(idp.providerName)); + } + + /* + User Pool Domain - where authentication service is reachable + */ + const userPoolDomain = userPool.addDomain(generateResourceId('userpool-domain'), { + cognitoDomain: { + domainPrefix: appName.toLowerCase(), + }, }); + /* + User Pool Client - defines Auth flow and token validity + */ const logoutUrls = [`https://${hostedZone.zoneName}`]; - const callbackUrls = logoutUrls.concat(`https://api.${hostedZone.zoneName}/oauth2/idpresponse`); + const replyUrl = `${userPoolDomain.baseUrl()}/saml2/idpresponse`; const userPoolClient = userPool.addClient(generateResourceId('userpool-client'), { authFlows: { userSrp: true, @@ -84,9 +118,10 @@ export class AuthStack extends Stack { authorizationCodeGrant: true, }, scopes: [OAuthScope.OPENID, OAuthScope.EMAIL, OAuthScope.PROFILE], - callbackUrls, + callbackUrls: logoutUrls.concat(replyUrl), logoutUrls, }, + supportedIdentityProviders, accessTokenValidity: Duration.minutes(60), idTokenValidity: Duration.minutes(60), refreshTokenValidity: Duration.days(14), @@ -94,47 +129,16 @@ export class AuthStack extends Stack { preventUserExistenceErrors: true, }); - userPool.addDomain(generateResourceId('userpool-domain'), { - cognitoDomain: { - domainPrefix: generateResourceId('auth'), - }, + new CfnOutput(this, 'UserPool.Id', { + value: `urn:amazon:cognito:sp:${userPool.userPoolId}`, + }); + new CfnOutput(this, 'UserPool.ReplyURL', { + value: replyUrl, }); - new CfnOutput(this, 'UserPoolClient.Id', { value: userPoolClient.userPoolClientId, }); - /* - Use a CloudFront distribution to proxy Cognito. - - Why? Turns out a custom userpool domain won't work due to CORS failures :( - Cognito does that by creating a CloudFront distro under the hood, so we're - not adding any costs by configuring this manually. - */ - const cognitoProxyDistribution = new Distribution(this, generateResourceId('cognito-proxy'), { - certificate, - domainNames: [authDomainName], - enableLogging: true, - defaultBehavior: { - origin: new HttpOrigin(`cognito-idp.${env.region}.amazonaws.com`), - allowedMethods: AllowedMethods.ALLOW_ALL, - cachePolicy: CachePolicy.CACHING_DISABLED, - viewerProtocolPolicy: ViewerProtocolPolicy.REDIRECT_TO_HTTPS, - originRequestPolicy: OriginRequestPolicy.ALL_VIEWER_AND_CLOUDFRONT_2022, - responseHeadersPolicy: - ResponseHeadersPolicy.CORS_ALLOW_ALL_ORIGINS_WITH_PREFLIGHT_AND_SECURITY_HEADERS, - }, - priceClass: PriceClass.PRICE_CLASS_100, - }); - - new ARecord(this, generateResourceId('arecord-auth-proxy'), { - zone: hostedZone, - recordName: authDomainName, - target: RecordTarget.fromAlias(new CloudFrontTarget(cognitoProxyDistribution)), - deleteExisting: true, - comment: 'DNS A Record for Cognito auth proxy', - }); - // SSM Parameters accessed in auth edge lambda, as cannot use ENV vars. this.parameterNameUserPoolId = `/${stage}/userpool-id`; new StringParameter(this, generateResourceId('parameter-userpool-id'), { diff --git a/cloud/lib/certificate-stack.ts b/cloud/lib/certificate-stack.ts index 203faca5..7bbd1dea 100644 --- a/cloud/lib/certificate-stack.ts +++ b/cloud/lib/certificate-stack.ts @@ -16,7 +16,6 @@ type CertificateStackProps = StackProps & { export class CertificateStack extends Stack { public readonly apiDomainName: string; - public readonly authDomainName: string; public readonly cloudFrontCert: ICertificate; public readonly loadBalancerCert: ICertificate; @@ -27,13 +26,11 @@ export class CertificateStack extends Stack { const generateResourceId = resourceId(scope); const validation = CertificateValidation.fromDns(hostedZone); this.apiDomainName = `api.${hostedZone.zoneName}`; - this.authDomainName = `auth.${hostedZone.zoneName}`; // Yes this is deprecated, but CDK currently gives us no way to use // Permissions Boundaries with cross-region resources, so ... this.cloudFrontCert = new DnsValidatedCertificate(this, generateResourceId('cert-ui'), { domainName: hostedZone.zoneName, - subjectAlternativeNames: [this.authDomainName], hostedZone, validation, region: 'us-east-1', diff --git a/frontend/.env.example b/frontend/.env.example index b52fdf1d..32cc1fe0 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -7,4 +7,3 @@ VITE_BACKEND_URL=http://localhost:3000/api #VITE_COGNITO_USERPOOL_ID=YOUR_USERPOOL_ID #VITE_COGNITO_USERPOOL_CLIENT=YOUR_CLIENT_ID #VITE_COGNITO_USERPOOL_DOMAIN=YOUR_AUTH_DOMAIN -#VITE_COGNITO_USERPOOL_ENDPOINT=https://${VITE_COGNITO_USERPOOL_DOMAIN} diff --git a/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx b/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx index ff7def0d..e38f26c6 100644 --- a/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx +++ b/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx @@ -37,10 +37,10 @@ Amplify.configure({ Cognito: { userPoolId: import.meta.env.VITE_COGNITO_USERPOOL_ID, userPoolClientId: import.meta.env.VITE_COGNITO_USERPOOL_CLIENT, - userPoolEndpoint: import.meta.env.VITE_COGNITO_USERPOOL_ENDPOINT, loginWith: { oauth: { domain: import.meta.env.VITE_COGNITO_USERPOOL_DOMAIN, + providers: [{ custom: 'Azure' }], redirectSignIn: [import.meta.env.VITE_COGNITO_REDIRECT_URL], redirectSignOut: [import.meta.env.VITE_COGNITO_REDIRECT_URL], responseType: 'code', @@ -157,8 +157,9 @@ function SignInSelector() { } function BasicSignIn() { - const { authStatus, error, isPending, submitForm, toForgotPassword } = - useAuthenticator((context) => [context.submitForm]); + const { error, isPending, submitForm, toForgotPassword } = useAuthenticator( + (context) => [context.submitForm] + ); const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [usernameInvalidReason, setUsernameInvalidReason] = useState(''); @@ -167,10 +168,6 @@ function BasicSignIn() { const submitIsDisabled = !username || !password; - useEffect(() => { - console.log(`pending=${isPending} status=${authStatus} error=${error}`); - }, [error, isPending]); - useEffect(() => { if (isMounted) checkUsernameValidity(); }, [username]); @@ -252,7 +249,7 @@ function BasicSignIn() { } function SSOSignIn() { - function federatedSignIn() { + function signIn() { void signInWithRedirect({ provider: { custom: 'Azure', @@ -271,7 +268,7 @@ function SSOSignIn() { type="button" variation="primary" loadingText="Redirecting" - onClick={federatedSignIn} + onClick={signIn} > Azure logo diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index f33582bb..10f51d9c 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -6,7 +6,6 @@ interface ImportMetaEnv { readonly VITE_COGNITO_USERPOOL_ID: string; readonly VITE_COGNITO_USERPOOL_CLIENT: string; readonly VITE_COGNITO_USERPOOL_DOMAIN: string; - readonly VITE_COGNITO_USERPOOL_ENDPOINT: string; readonly VITE_COGNITO_REDIRECT_URL: string; }