diff --git a/cloud/.env.example b/cloud/.env.example index 68dfff3a6..b31adf032 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 b2c3ea3f0..56827702a 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 963916986..8f4b27ec6 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 203faca55..7bbd1deae 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 b52fdf1d5..32cc1fe05 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/assets/icons/Azure.svg b/frontend/src/assets/icons/Azure.svg new file mode 100644 index 000000000..7151406b9 --- /dev/null +++ b/frontend/src/assets/icons/Azure.svg @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx b/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx index cd2f78026..e38f26c68 100644 --- a/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx +++ b/frontend/src/components/AuthProviders/CognitoAuthenticatedApp.tsx @@ -1,11 +1,20 @@ -import { fetchAuthSession } from '@aws-amplify/auth'; +import { fetchAuthSession, signInWithRedirect } from '@aws-amplify/auth'; import { + Alert, + Button, + Fieldset, + Flex, + PasswordField, + Tabs, + TextField, + useAuthenticator, withAuthenticator, WithAuthenticatorProps, } from '@aws-amplify/ui-react'; import { Amplify } from 'aws-amplify'; -import { PropsWithChildren } from 'react'; +import { FormEvent, useEffect, useState } from 'react'; +import AzureLogo from '@src/assets/icons/Azure.svg'; import BotAvatar from '@src/assets/images/BotAvatarDefault.svg'; import App from '@src/components/App'; import { interceptRequest } from '@src/service/backendService'; @@ -28,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', @@ -70,6 +79,7 @@ const CognitoAuthenticatedApp = withAuthenticator( components: { SignIn: { Header: WelcomeHeader, + Footer: () => null, }, ForgotPassword: { Header: ResetPasswordHeader, @@ -94,28 +104,179 @@ const CognitoAuthenticatedApp = withAuthenticator( function WelcomeHeader() { return ( - Welcome to SpyLogic + <> + + + ); } function ResetPasswordHeader() { - return Reset password; + return ; } function ChangePasswordHeader() { - return Change password; + return ; } function CustomHeader({ - classes, - children, -}: { classes: string } & PropsWithChildren) { + className, + heading, +}: { + className: string; + heading: string; +}) { return ( -
-

{children}

+
+

{heading}

SpyLogic logo
); } +function SignInSelector() { + return ( + , + }, + { + label: 'Single Sign On (SSO)', + value: 'sso', + content: , + }, + ]} + /> + ); +} + +function BasicSignIn() { + const { error, isPending, submitForm, toForgotPassword } = useAuthenticator( + (context) => [context.submitForm] + ); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [usernameInvalidReason, setUsernameInvalidReason] = useState(''); + const [passwordInvalidReason, setPasswordInvalidReason] = useState(''); + const [isMounted, setMounted] = useState(false); + + const submitIsDisabled = !username || !password; + + useEffect(() => { + if (isMounted) checkUsernameValidity(); + }, [username]); + + useEffect(() => { + if (isMounted) checkPasswordValidity(); + }, [password]); + + useEffect(() => { + setMounted(true); + }, []); + + function checkUsernameValidity() { + setUsernameInvalidReason(username ? '' : 'Username cannot be empty'); + } + + function checkPasswordValidity() { + setPasswordInvalidReason(password ? '' : 'Password cannot be empty'); + } + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (submitIsDisabled) { + checkUsernameValidity(); + checkPasswordValidity(); + } else { + submitForm({ username, password }); + } + } + + return ( + <> +
+
+ { + setUsername(value); + }} + /> + { + setPassword(value); + }} + /> +
+ {error && ( + + {error} + + )} + + +
+ + ); +} + +function SSOSignIn() { + function signIn() { + void signInWithRedirect({ + provider: { + custom: 'Azure', + }, + }); + // TODO Catch login errors, e.g. someone without SL SSO access tries their luck + } + + return ( +
+

If you have a Scott Logic email address, sign in via SSO:

+ +
+ ); +} + export default CognitoAuthenticatedApp; diff --git a/frontend/src/components/AuthProviders/CognitoAuthenticator.css b/frontend/src/components/AuthProviders/CognitoAuthenticator.css index c64d4647e..870ca8206 100644 --- a/frontend/src/components/AuthProviders/CognitoAuthenticator.css +++ b/frontend/src/components/AuthProviders/CognitoAuthenticator.css @@ -71,12 +71,102 @@ align-items: stretch; } +[data-amplify-authenticator] .amplify-flex.flex-column { + flex-direction: column; +} + [data-amplify-authenticator] [data-amplify-router-content], [data-amplify-authenticator] [data-amplify-form] { overflow-y: auto; height: 100%; } +/* Hide original login form, we are replacing with Basic & SSO login tabs */ +[data-amplify-authenticator] + [data-amplify-form][data-amplify-authenticator-signin] { + display: none; +} + +/* Tabs for Basic & SSO login forms */ +[data-amplify-authenticator] .amplify-tabs { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0; + padding-bottom: 0; +} + +[data-amplify-authenticator] .amplify-tabs .amplify-tabs__list { + display: flex; + gap: 0.5rem; +} + +[data-amplify-authenticator] + .amplify-tabs + .amplify-tabs__list + .amplify-tabs__item { + flex: 1 1 0; + padding: 0.5rem; + border: 0.125rem solid var(--chat-button-border-colour); + border-top-left-radius: 0.375rem; + border-top-right-radius: 0.375rem; + background-color: transparent; + color: var(--main-input-inactive-text-colour); + font-weight: bold; + font-size: inherit; + cursor: pointer; + transition: none; +} + +[data-amplify-authenticator] + .amplify-tabs + .amplify-tabs__list + .amplify-tabs__item:focus-visible { + border-color: transparent; +} + +[data-amplify-authenticator] + .amplify-tabs + .amplify-tabs__list + .amplify-tabs__item--active { + border-color: var(--chat-user-border-colour); + background-color: var(--chat-bot-background-colour); + color: var(--chat-alerted-text-colour); + cursor: default; +} + +[data-amplify-authenticator] + .amplify-tabs + .amplify-tabs__list + .amplify-tabs__item:not(.amplify-tabs__item--active):hover { + border-color: var(--main-border-colour); + color: var(--main-text-colour-inverted); +} + +[data-amplify-authenticator] .amplify-tabs .amplify-tabs__panel { + display: none; +} + +[data-amplify-authenticator] .amplify-tabs .amplify-tabs__panel--active { + display: block; +} + +[data-amplify-authenticator] [data-amplify-form].basic-login-form { + padding-bottom: 0; +} + +[data-amplify-authenticator] [data-amplify-form].sso-login-form p { + margin-top: 0.5rem; +} + +[data-amplify-authenticator] [data-amplify-form].sso-login-form button { + gap: 1rem; +} + +[data-amplify-authenticator] [data-amplify-form].sso-login-form .azure-logo { + height: 1.375rem; +} + [data-amplify-authenticator] [data-amplify-form] fieldset { position: relative; margin: 0; @@ -166,6 +256,13 @@ padding: 0.75rem 1rem; } +[data-amplify-authenticator] + [data-amplify-form] + .amplify-field-group + .amplify-input[aria-invalid='true'] { + border-color: var(--error-colour); +} + [data-amplify-authenticator] [data-amplify-form] .amplify-field-group @@ -180,6 +277,14 @@ background-color: var(--chat-button-background-colour-hover); } +[data-amplify-authenticator] + [data-amplify-form] + .amplify-field + .amplify-field__error-message { + margin: 0; + color: var(--error-colour); +} + [data-amplify-authenticator] [data-amplify-form] .amplify-button--primary { padding: 0.75rem 1rem; border: 0.125rem solid var(--action-button-background-colour); @@ -273,6 +378,7 @@ } /* Apply common spacing (see also media queries) */ +[data-amplify-authenticator] .amplify-tabs, [data-amplify-authenticator] [data-amplify-form], [data-amplify-authenticator] .form-header, [data-amplify-authenticator] .welcome-header, @@ -285,7 +391,9 @@ padding-top: 0; } -[data-amplify-authenticator] .form-header { +[data-amplify-authenticator] .form-header, +[data-amplify-authenticator] [data-amplify-form].basic-login-form, +[data-amplify-authenticator] [data-amplify-form].sso-login-form { margin: 0 -2rem; } @@ -294,6 +402,7 @@ margin: 0.25rem 0; } + [data-amplify-authenticator] .amplify-tabs, [data-amplify-authenticator] [data-amplify-form], [data-amplify-authenticator] .form-header, [data-amplify-authenticator] .welcome-header, @@ -306,7 +415,9 @@ padding-top: 0; } - [data-amplify-authenticator] .form-header { + [data-amplify-authenticator] .form-header, + [data-amplify-authenticator] [data-amplify-form].basic-login-form, + [data-amplify-authenticator] [data-amplify-form].sso-login-form { margin: 0 -1.5rem; } } @@ -328,6 +439,10 @@ padding: 0.25rem 1rem; } + [data-amplify-authenticator] .amplify-tabs { + padding: 0.5rem 1rem; + } + [data-amplify-authenticator] [data-amplify-form], [data-amplify-authenticator] [data-amplify-footer] { padding-bottom: 0.5rem; @@ -338,7 +453,9 @@ padding-top: 0; } - [data-amplify-authenticator] .form-header { + [data-amplify-authenticator] .form-header, + [data-amplify-authenticator] [data-amplify-form].basic-login-form, + [data-amplify-authenticator] [data-amplify-form].sso-login-form { margin: 0 -1rem; } } diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index f33582bb7..10f51d9c1 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; } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index e1666bcfd..9b901fcea 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -11,6 +11,17 @@ export default defineConfig({ '@src': path.resolve(__dirname, './src'), }, }, + build: { + rollupOptions: { + output: { + manualChunks: (id) => { + if (id.includes('@aws-amplify')) { + return 'amplify'; + } + }, + }, + }, + }, test: { environment: 'happy-dom', testTimeout: 10000,