GraphQL in NextJS mit Apollo, AppSync, Cognito und Azure AD als IdP

Mit 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.

Alexander ist Cloud Consultant bei superluminar, AWS Certified DevOps Engineer – Professional, AWS Certified Solutions Architect – Associate und AWS Certified Security – Specialty, und weiterhin Google Cloud Certified Professional Cloud Architect. Hier schreibt er über AWS spezifische Themen, auf seinem privaten Blog unsubstantiated.blog über GCP, Kubernetes, Terraform und andere Technologien. Auf Twitter ist er unter @m1raD zu finden.