Skip to content

Commit

Permalink
Hook up Azure SSO login in cognito stack
Browse files Browse the repository at this point in the history
  • Loading branch information
chriswilty committed May 3, 2024
1 parent 0df6169 commit e837e61
Show file tree
Hide file tree
Showing 7 changed files with 73 additions and 74 deletions.
5 changes: 5 additions & 0 deletions cloud/.env.example
Original file line number Diff line number Diff line change
@@ -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]
2 changes: 0 additions & 2 deletions cloud/bin/cloud.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Expand Down
120 changes: 62 additions & 58 deletions cloud/lib/auth-stack.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -84,57 +118,27 @@ 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),
enableTokenRevocation: true,
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'), {
Expand Down
3 changes: 0 additions & 3 deletions cloud/lib/certificate-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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',
Expand Down
1 change: 0 additions & 1 deletion frontend/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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('');
Expand All @@ -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]);
Expand Down Expand Up @@ -252,7 +249,7 @@ function BasicSignIn() {
}

function SSOSignIn() {
function federatedSignIn() {
function signIn() {
void signInWithRedirect({
provider: {
custom: 'Azure',
Expand All @@ -271,7 +268,7 @@ function SSOSignIn() {
type="button"
variation="primary"
loadingText="Redirecting"
onClick={federatedSignIn}
onClick={signIn}
>
<Flex justifyContent="center">
<img className="azure-logo" alt="Azure logo" src={AzureLogo} />
Expand Down
1 change: 0 additions & 1 deletion frontend/src/vite-env.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down

0 comments on commit e837e61

Please sign in to comment.