GraphQL in NextJS mit Apollo, AppSync, Cognito und Azure AD als IdP
March 4, 2022Mit AWS AppSync lässt sich leicht ein GraphQL Server mit Anbindung an verschiedene AWS Services implementieren. Zur Authentifizierung von Nutzer*innen gibt es verschiedene Möglichkeiten: IAM credentials, API Keys, Lambda authorizer, AWS Cognito und OIDC.
Ein Kunde hatte die Anforderung, eine AppSync API mit dem bestehendem Azure AD zu verbinden. Dies ließe sich am einfachsten mit der OIDC Authentifizierung umsetzen, leider hat AppSync momentan jedoch einen Bug, wodurch es nicht direkt mit Azure AD nutzbar ist. Als alternativen kann entweder ein Lambda Authorizer genutzt werden oder mit Amazon Cognito eine Ebene vor Azure AD gelegt werden, welche die Authentifizierung übernimmt und direkt in AppSync integriert ist. Wir haben uns für die zweite Möglichkeit entschieden, da wir nicht selber die Logik zur Validierung der JWT Tokens schreiben wollten, frei nach dem “No Code” Prinzip.
Das gesamte Setup ist mit AWS CDK in TypeScript geschrieben.
Cognito Setup
Der Cognito CDK Stack nimmt folgende Konfiguration:
export interface CognitoStackProps extends cdk.StackProps {
domain: string;
hostedZoneId: string;
}
Unter domain
soll die Webseite erreichbar sein, weiterhin wird unter einer auth
subdomain das Cognito Hosted UI aufgesetzt.
Die domain
muss über die hostedZoneId
konfigurierbar sein.
Nun zu den Bestandteilen des Stacks:
export class CognitoStack extends cdk.Stack {
userPool: cognito.UserPool;
idp: cognito.CfnUserPoolIdentityProvider;
cognitoDomain: cognito.UserPoolDomain;
appClient: cognito.UserPoolClient;
constructor(scope: cdk.Construct, id: string, props: CognitoStackProps) {
super(scope, id, props);
this.userPool = new cognito.UserPool(this, 'Pool', {
selfSignUpEnabled: false,
});
...
Der UserPool erlaubt kein Self-Signup, da Azure AD als einzige Quelle für Nutzer*innen dienen soll.
Interessanter ist der OIDC Issuer, also die Resource, die die Verbindung zu Azure AD herstellt. Leider gibt es hierzu derzeit nur ein L1 construct von CDK, dieses lässt sich aber gut nutzen:
const azureClientId = cdk.SecretValue.secretsManager("/cognito", {
jsonField: "azure_client_id",
});
const azureClientSecret = cdk.SecretValue.secretsManager("/cognito", {
jsonField: "azure_client_secret",
});
const azureTenant = cdk.SecretValue.secretsManager("/cognito", {
jsonField: "azure_tenant",
});
const azureOidcIssuer = cdk.Fn.sub(
"https://login.microsoftonline.com/${tenant}/v2.0",
{ tenant: azureTenant.toString() }
);
this.idp = new cognito.CfnUserPoolIdentityProvider(this, "CfnUserIDP", {
providerName: "azuread",
providerType: "OIDC",
userPoolId: this.userPool.userPoolId,
attributeMapping: {
email: "upn",
family_name: "family_name",
given_name: "given_name",
},
providerDetails: {
client_id: azureClientId,
client_secret: azureClientSecret,
attributes_request_method: "GET",
oidc_issuer: azureOidcIssuer,
authorize_scopes: "offline_access openid profile User.Read email",
},
});
Die Secrets kommen aus einer App Registration in Azure AD, hier wird gezeigt, wie man diese anlegt.
Die Secrets müssen entsprechen im AWS SecretsManager hinterlegt sein.
Unter attributeMapping
kann man Attribute aus Azure AD auf Attribute im Cognito UserPool mappen.
Nun brauchen wir noch einen Cognito App Client, welcher von unserer Website genutzt wird, um die Authentifizierung zu starten:
this.appClient = this.userPool.addClient("AppClient", {
authFlows: {
custom: true,
userSrp: true,
},
oAuth: {
flows: {
authorizationCodeGrant: true,
},
scopes: [
cognito.OAuthScope.OPENID,
cognito.OAuthScope.EMAIL,
cognito.OAuthScope.PROFILE,
cognito.OAuthScope.COGNITO_ADMIN,
],
callbackUrls: ["http://localhost:3000", `https://${props.domain}`],
},
supportedIdentityProviders: [
cognito.UserPoolClientIdentityProvider.custom(this.idp.providerName),
],
});
this.appClient.node.addDependency(this.idp);
Die callbackUrls
enthalten localhost
, um lokales Testen zu ermöglichen und die Adresse der Webseite.
Da supportedIdentityProviders
nur den vorher aufgesetzten idp
enthält, ist es nur über diesen möglich, den AppClient zu nutzen.
Weiterhin muss manuell eine dependency
gesetzt werden, um sicherzustellen, dass der IdP zuerst provisioniert wird.
Nun muss noch das Hosted UI aufgesetzt werden:
const hostedZone = route53.HostedZone.fromHostedZoneAttributes(
this,
"HostedZone",
{
hostedZoneId: props.hostedZoneId,
zoneName: props.domain,
}
);
const certificate = new certificatemanager.DnsValidatedCertificate(
this,
"Certificate",
{
domainName: `auth.${props.domain}`,
region: "us-east-1",
hostedZone,
}
);
this.cognitoDomain = this.userPool.addDomain("CognitoDomain", {
customDomain: {
domainName: `auth.${props.domain}`,
certificate,
},
});
new route53.CnameRecord(this, "CognitoRecord", {
zone: hostedZone,
recordName: "auth",
domainName: this.cognitoDomain.cloudFrontDomainName,
});
Wichtig ist, dass bereits ein A record für props.domain
gesetzt ist, ansonsten scheitert das Anlegen der Custom Domain des Hosted UIs.
Hier funktioniert z.B. ein Platzhalter auf 127.0.0.1 wenn gerade noch kein richtiges Ziel vorhanden ist.
Dank des L3 constructs DnsValidatedCertificate
wird ein gültiges Zertifikat für die Domain automatisch erstellt.
Nun muss noch unsere AppSync API mit Cognito als authorizer verbunden werden:
new appsync.GraphqlApi(this, "GraphqlApi", {
schema: appsync.Schema.fromAsset(
path.join(__dirname, "..", "schema.graphql")
),
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.USER_POOL,
userPoolConfig: {
userPool: this.userPool,
appIdClientRegex: this.appClient,
defaultAction: appsync.UserPoolDefaultAction.DENY,
},
},
},
});
Client Setup
Unsere Website ist mit NextJS gebaut, als GraphQL Client wird der Apollo Client genutzt. Wenn ein*e Nutzer*in die Seite öffnet, soll er/sie sich zuerst einloggen müssen, falls dies noch nicht geschehen ist.
Zur Authentifizierung nutzen wir die Pakete aws-amplify
und @aws-amplify/auth
. Die Amplify Konfiguration in config/auth.ts
sieht wie folgt aus:
import { AuthOptions } from "@aws-amplify/auth/lib/types";
export const provider = "azuread";
export const options: AuthOptions = {
region: "eu-central-1",
mandatorySignIn: true,
userPoolId: process.env.NEXT_PUBLIC_COGNITO_USER_POOL_ID,
userPoolWebClientId: process.env.NEXT_PUBLIC_COGNITO_WEB_CLIENT_ID,
oauth: {
domain: process.env.NEXT_PUBLIC_COGNITO_AUTH_DOMAIN,
scope: ["email", "openid", "aws.cognito.signin.user.admin", "profile"],
redirectSignIn:
process.env.NEXT_PUBLIC_COGNITO_REDIRECT_URL || "http://localhost:3000",
redirectSignOut:
process.env.NEXT_PUBLIC_COGNITO_REDIRECT_URL || "http://localhost:3000",
responseType: "code",
},
};
Zur Authentifizierung und zum Verwalten der eingeloggten Nutzer*innen gibt es einen React Hook, der die Amplify Auth
Klasse kapselt und konfiguriert:
import { useState, useEffect, useMemo } from "react";
import { Auth, CognitoUser } from "@aws-amplify/auth";
import { provider, options } from "config/auth";
export const useAuth = () => {
const [user, setUser] = useState<null | CognitoUser>(null);
const auth = useMemo(() => {
Auth.configure(options);
return Auth;
}, []);
useEffect(() => {
auth
.currentAuthenticatedUser()
.then((user: CognitoUser) => setUser(user))
.catch((err) => {
console.log(err);
});
}, [auth]);
const signIn = () => auth.federatedSignIn({ customProvider: provider });
const signOut = () => auth.signOut();
return {
user,
auth,
signIn,
signOut,
};
};
Mit diesem Hook kann nun Apollo konfiguriert werden.
Hierzu gibt es eine providers/Apollo.tsx
Komponente, welche den Provider von Apollo mit einem jwt
Token konfiguriert:
import React from "react";
import {
ApolloClient,
InMemoryCache,
ApolloProvider as OriginalProvider,
createHttpLink,
} from "@apollo/client";
import { setContext } from "@apollo/client/link/context";
import { useAuth } from "hooks/useAuth";
export const ApolloProvider: React.FunctionComponent = ({ children }) => {
const { auth } = useAuth();
const withToken = setContext(async (_, { headers }) => {
const session = await auth.currentSession();
return {
headers: {
...headers,
Authorization: session.idToken.jwtToken,
},
};
});
const httpLink = createHttpLink({
uri: process.env.NEXT_PUBLIC_APPSYNC_URL,
});
const client = new ApolloClient({
link: withToken.concat(httpLink),
connectToDevTools: true,
cache: new InMemoryCache(),
});
return <OriginalProvider client={client}>{children}</OriginalProvider>;
};
Der useAuth
Hook zusammen mit dem Provider werden zentral in pages/_app.tsx
eingebunden, um sicherzustellen, dass immer ein User vorhanden ist und Apollo einfach genutzt werden kann:
import type { AppProps } from "next/app";
import React from "react";
import { ApolloProvider } from "providers/Apollo";
import { useAuth } from "hooks/useAuth";
function MyApp({ Component, pageProps }: AppProps) {
const { signIn, user } = useAuth();
return (
{user ? (
<ApolloProvider>
<Component {...pageProps} />
</ApolloProvider>
) : (
<button onClick={() => signIn()}>Login</button>
)}
);
}
export default MyApp;
Mit diesem Setup kann nun in allen Seiten und Komponenten einfach Apollo genutzt werden.