React Native und Expo mit Keycloak und dem Authorization Code Flow (PKCE) von OAuth 2.0

René Wilby | 26.03.2024 Min. Lesezeit

Authentifizierung in mobilen Apps

Die meisten Apps, die wir tagtäglich nutzen, erfordern einen User-Account. Die Gründe dafür sind vielfältig. Als App-Entwickler:in steht man vor der Wahl, wie man für die eigene App einen solchen Account-Mechanismus und ein zugehöriges Authentifizierungsverfahren implementiert. In meiner Wahrnehmung setzen viele App-Entwickler:innen dabei auf Verfahren, die es Anwender:innen ermöglichen, sich mit einem bestehenden Account in einer neuen App anzumelden. Diese Verfahren (“Sign in with GitHub” oder “Sign in with Google”) basieren auf OAuth 2.0 und dem Authorization Code Flow.

Eine mobile App ist nicht vertrauenswürdig

Für Authentifizierungsverfahren und andere sicherheitsrelevante Aspekte gilt, dass eine mobile App nicht als vertrauenswürdig eingestuft werden kann. Eine App kann bspw. dekompiliert und modifiziert werden. Daher sollten keine Secrets und Passwörter im Quellcode einer App enthalten sein und alle Daten, die von einer App an ein Backend gesendet werden, müssen im Backend sorgfältig validiert werden. Für Authentifizierungsverfahren stellt es eine gewisse Herausforderung dar, dass in einer mobilen App keine Passwörter und Secrets gespeichert werden sollten.

Auch der bei App-Entwickler:innen beliebte Authorization Code Flow von OAuth 2.0 benötigt in seiner ursprünglichen Form ein Client-Secret, um an einem gewissen Punkt im Flow einen Access-Token erhalten zu können. Dieses Client-Secret sollte aus den zuvor beschriebenen Gründen nicht in der App enthalten sein. App-Entwickler:innen haben daher die Wahl: Sie können das Client-Secret in ein vertrauenswürdiges Backend auslagern und den Authorization Code Flow entsprechend anpassen bzw. umleiten, oder sie verzichten gänzlich auf das Client-Secret. Mit der Erweiterung Proof Key for Code Exchange (PKCE) bietet der Authorization Code Flow die Option auf ein Client-Secret zu verzichten.

Authorization Code Flow (with Proof Key for Code Exchange)

Der Authorization Code Flow mit PKCE kann auf ein Client-Secret verzichten, weil zwischen Client und Authorization Server ein einmaliger Code ausgehandelt wird. Anhand dieses Codes, der für jede Ausstellung eines Access-Tokens erneuert erstellt wird, kann der Authorization Server erkennen, dass der Client, der einen Access-Token erhalten möchte, der gleiche Client ist, für den sich zuvor ein:e Anwender:in über den Authorization Code Flow authentifiziert hat. Als Authorization Server kommt in diesem Artikel Keycloak zum Einsatz. Das nachfolgende Sequenzdiagramm zeigt den Ablauf:

UUsse(er1r)Hit"Signin"buttoAnAppp(p2)((R36e)()q4uR)ReeesdAqtiuurteAehsucetttnht(A(oti5c8roc)c)iae(zLtAs7R(aoeus)e9tgt-q)iihTAuonooceUnrkcss-ieeteCznsroasU-dt(-sIeiwTenoiorf(ntk-ow-heIiCnntoCfhdooedCeodVeerCihfailelre)nKgKeeey)yccllooaakk
  1. Ein User öffnet die mobile App und löst eine Anmeldung aus, bspw. durch Tippen auf einen Button.
  2. Die App generiert einen Code-Verifier und eine Code-Challenge. Zusammen mit der Code-Challenge sendet die App einen Request an Keycloak, um einen Authorization-Code zu erhalten. Keyloak läuft in diesem Beispiel unter http://localhost:11111.
http://localhost:11111/realms/test/protocol/openid-connect/auth?code_challenge=<CODE_CHALLENGE>&code_challenge_method=S256&redirect_uri=exp://192.168.178.95:8081&client_id=rn-expo-app&response_type=code&state=<STATE>&scope=openid+profile
  1. Keycloak merkt sich die Code-Challenge und leitet den Request auf eine eigene Login-Seite um. Die Login-Seite wird in der App angezeigt. Hier erfolgt die Anmeldung durch den User.

Keycloak Login

  1. Die Login-Seite wird mit den Anmeldeinformationen an Keycloak gesendet. Keycloak authentifiziert den User.
  2. Keycloak sendet einen Authorization-Code an die App.
GET exp://192.168.178.95:8081?state=<STATE>&session_state=<SESSION_STATE>&iss=http://localhost:11111/realms/test&code=<AUTHORIZATION_CODE>
  1. Die App fordert für diesen Authorization-Code einen Access-Token an. In dem Request ist auch der zuvor erzeugte Code-Verifier enthalten.
POST http://localhost:11111/realms/test/protocol/openid-connect/token
  grant_type=authorization_code
  client_id=rn-expo-app
  code=<AUTHORIZATION_CODE>
  code_verifier=<CODE_VERIFIER>
  redirect_uri=exp://192.168.178.95:8081
  1. Keycloak vergleicht den erhaltenen Code-Verifier mit der gespeicherten Code-Challenge (Details). Bei Übereinstimmung sendet Keycloak den angeforderten Access-Token in Form eines JSON Web Tokens (JWT) an die App. Der Payload des JWT enthält u.a. folgende Daten:
{
  ...
  "exp": 1705659894,
  "iat": 1705659594,
  "auth_time": 1705659567,
  "iss": "http://localhost:11111/realms/test",
  "aud": "account",
  "sub": "cd026591-5cb2-4089-970b-f317153ae6c8",
  "typ": "Bearer",
  "azp": "rn-expo-app",
  "scope": "openid profile email"
  ...
}
  1. Die App ruft den User-Info-Endpunkt von Keycloak auf. Dabei wird der erhaltende Access-Token im HTTP-Header Authorization: Bearer <ACCESS_TOKEN> mitgesendet.
GET http://localhost:11111/realms/test/protocol/openid-connect/userinfo
  1. Keycloak sendet die Informationen zum User an die App zurück. Diese User-Informationen können bspw. folgendermaßen aussehen:
{
  "sub":"cd026591-5cb2-4089-970b-f317153ae6c8",
  "email_verified": true,
  "name": "John Doe",
  "preferred_username": "jd",
  "given_name": "John",
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

Im weiteren Verlauf des Artikels wird nun ein Beispiel erstellt, welches den skizzierten Authorization Code Flow mit PKCE praktisch umsetzt. Für die Erstellung der mobilen App kommt React Native in Verbindung mit Expo zum Einsatz. Als Authorization Server wird Keycloak verwendet.

Keycloak einrichten

Als Container starten

Für das vorliegende Beispiel wird Keycloak lokal in einem Container ausgeführt. Dafür ist eine entsprechende Laufzeit-Umgebung für Container erforderlich. Hier bieten sich bspw. Docker Desktop oder Podman Desktop an.

Nachdem die Laufzeitumgebung installiert und gestartet wurde, kann ein neuer Container mit einer laufenden Keycloak-Instanz wie folgt gestartet werden:

podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin quay.io/keycloak/keycloak:latest start-dev

Benutzername und -passwort werden als Umgebungsvariablen übergeben. Die Keycloak-Instanz ist anschließend über http://localhost:11111/admin erreichbar.

Zum Stoppen und Löschen des Containers können bei Bedarf folgende Befehle verwendet werden:

podman stop keycloak && podman rm keycloak

Realm erstellen

Die erforderlichen Konfigurationen, die im weiteren Verlauf in Keycloak vorgenommen werden, erfolgen in einem Realm. Es bietet sich an einen neuen Realm mit der Bezeichnung test zu erstellen. Dazu muss man sich als User admin am Web-Interface von Keycloak unter http://localhost:11111/admin anmelden.

Der genaue Ablauf zur Erstellung eines Realms ist in der Keycloak-Dokumentation beschrieben. Nachdem der Realm erstellt wurde, wechselt Keycloak automatisch in den neuen Realm test.

Client erstellen

Anschließend muss ein neuer Client im Realm test erstellt werden. Als Client-ID sollte dabei die Bezeichnung rn-expo-app verwendet werden. Als Authentication flow muss der Standard flow ausgewählt werden, da dies dem oben gezeigten Authorization Code Flow entspricht.

Keycloak - Client erstellen - Authentication

Zu guter Letzt muss für Valid redirect URI der Wert exp://192.168.178.95:8081 eingetragen werden. Das verwendete Protokoll exp:// stammt von Expo. Die Zusammenhänge werden im weiteren Verlauf noch beschrieben.

Keycloak - Client erstellen - Redirect

Für weitere Details zur Erstellung und Konfiguration von Clients sei auch an dieser Stelle auf die offizielle Keycloak-Dokumentation verwiesen.

User erstellen

In der Praxis ist Keycloak in der Regel mit einem Identity Provider verbunden. Um die Komplexität des Beispiels nicht unnötig zu erhöhen, wird hier auf eben diesen verzichtet. Stattdessen wird ein User direkt in Keycloak im Realm test angelegt.

Keycloak - User erstellen

Nachdem der User erstellt wurde, werden für den User Credentials vergeben. Als Passwort kommt in diesem Beispiel der Wert password zum Einsatz. Um weitere manuelle Schritte zu sparen, wird das Passwort als nicht-temporär markiert.

Keycloak - User erstellen - Passwort

Weitere Informationen zur Erstellung und Verwaltung von Usern finden sich in der Keycloak-Dokumentation.

Realm exportieren

Der erstellte Realm und der darin enthaltene Client und User lassen sich bei Bedarf in eine JSON-Datei exportieren, so dass sie zu einem späteren Zeitpunkt wiederverwendet werden können. Dazu ist folgender Befehl im Terminal auszuführen:

podman exec -it keycloak /opt/keycloak/bin/kc.sh export --file /tmp/realm-export.json --realm test --users realm_file
podman cp keycloak:/tmp/realm-export.json /path/to/folder/with/realm-export

Die JSON-Datei realm-export.json befindet sich anschließend im Verzeichnis /path/to/folder/with/realm-export. Wenn man den Keycloak-Container zu einem späteren Zeitpunkt erneut startet, kann man die Datei über ein Volume einbinden und beim Start von Keycloak importieren:

podman run --name keycloak --detach --publish 11111:8080 --env KEYCLOAK_ADMIN=admin --env KEYCLOAK_ADMIN_PASSWORD=admin --volume /path/to/folder/with/realm-export:/opt/keycloak/data/import quay.io/keycloak/keycloak:latest start-dev --import-realm

Expo-Projekt rn-expo-oauth-authorization-code-flow-pkce-keycloak

Da nun die Keycloak-Instanz mit der erforderlichen Konfiguration zur Verfügung steht, wird als Nächstes die React Native App erstellt. Dazu wird ein neues Expo-Projekt über die Expo-CLI erzeugt:

npx create-expo-app rn-expo-oauth-authorization-code-flow-pkce-keycloak

Das erstellte Projekt wird im weiteren Verlauf Schritt für Schritt um die erforderlichen Funktionen erweitert.

Die Beispiel-App besteht aus einer einfachen Navigation und einer Handvoll Screens. Die App hat folgende Screen-Struktur:

Screens
└── SignIn
└── Home
    └── Profile
    └── Manager
    └── IC

SignInScreen und HomeScreen stehen auf gleicher Ebene, sind aber nicht zur selben Zeit sichtbar. Die Authentifizierung erfolgt im SignInScreen. Authentifizierte Anwender:innen gelangen zum HomeScreen und haben Zugriff auf die untergeordneten Screens, wie bspw. ProfileScreen.

Die jeweiligen Screens sind einfache React-Komponenten. Der HomeScreen ist bspw. wie folgt implementiert:

import React from 'react'
import { Button, StyleSheet, Text, View } from 'react-native'

const HomeScreen = ({ navigation }) => {
  return (
    <View style={styles.container}>
      <Text>Home-Screen</Text>
      <Button onPress={() => navigation.navigate('Profile')} title={'Profile'} />
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
})

export { HomeScreen }

Die technische Grundlage für die Navigation bildet React Navigation. Die erforderlichen Dependencies werden wie folgt im Projekt hinzugefügt:

npx expo install @react-navigation/native react-native-screens react-native-safe-area-context @react-navigation/native-stack

Die erwähnten Screens werden in einem einfachen Native-Stack-Navigator organisiert. Der Native-Stack-Navigator wird in einem NavigationContainer in der App.js eingebunden:

import React from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { HomeScreen } from './screens/HomeScreen'
import { ProfileScreen } from './screens/ProfileScreen'
import { SignInScreen } from './screens/SignInScreen'

const NativeStack = createNativeStackNavigator()

export default function App() {
  return (
    <NavigationContainer>
      <NativeStack.Navigator>
        <NativeStack.Screen name={'Home'} component={HomeScreen} />
        <NativeStack.Screen name={'Profile'} component={ProfileScreen} />
        <NativeStack.Screen name={'SignIn'} component={SignInScreen} />
      </NativeStack.Navigator>
    </NavigationContainer>
  )
}

React Navigation beschreibt in der eigenen Dokumentation einen einfachen Ansatz, um verschiedene Screens innerhalb eines Stacks abhängig von der Authentifizierung zu rendern. Dieses sog. Protected-Routes-Pattern kommt auch in der Beispiel-App zum Einsatz. Der Authentifizierungszustand wird dabei zunächst noch über einen lokalen Zustand abgebildet. Die angepasste App.js sieht nun folgendermaßen aus:

import React, { useState } from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { HomeScreen } from './screens/HomeScreen'
import { ProfileScreen } from './screens/ProfileScreen'
import { SignInScreen } from './screens/SignInScreen'

const NativeStack = createNativeStackNavigator()

export default function App() {
  const [isSignedIn, setIsSignedIn] = useState(false)

  return (
    <NavigationContainer>
      <NativeStack.Navigator>
        {isSignedIn ? (
          <>
            <NativeStack.Screen name={'Home'} component={HomeScreen} />
            <NativeStack.Screen name={'Profile'} component={ProfileScreen} />
          </>
        ) : (
          <NativeStack.Screen name={'SignIn'} component={SignInScreen} />
        )}
      </NativeStack.Navigator>
    </NavigationContainer>
  )
}

Je nach Ausprägung von isSignedIn sind nur HomeScreen und ProfileScreen oder SignInScreen im Native-Stack verfügbar.

AuthContext

Für die Erweiterung des Beispiels ist eine Komponenten-übergreifende Verwaltung eines zentralen Zustands erforderlich. Dafür wird eine Kombination aus React Context und React Reducer verwendet, so wie sie hier beschrieben ist. Context und Reducer werden in der Datei context/AuthContext.js implementiert.

import React, { createContext, useMemo, useReducer } from 'react'

const initialState = {
  isSignedIn: false,
  accessToken: null,
  idToken: null,
  userInfo: null,
}

const AuthContext = createContext({
  state: initialState,
  signIn: () => {},
  signOut: () => {},
})

const AuthProvider = ({ children }) => {
  const [authState, dispatch] = useReducer((previousState, action) => {
    switch (action.type) {
      case 'SIGN_IN':
        return {
          ...previousState,
          isSignedIn: true,
          accessToken: 'TODO',
          idToken: 'TODO',
        }
      case 'USER_INFO':
        return {
          ...previousState,
          userInfo: { /* TODO */ },
        }
      case 'SIGN_OUT':
        return {
          initialState,
        }
    }
  }, initialState)

  const authContext = useMemo(
    () => ({
      state: authState,
      signIn: () => { /* TODO */ },
      signOut: () => { /* TODO */ },
    }),
    [authState]
  )

  return (
    <AuthContext.Provider value={authContext}>{children}</AuthContext.Provider>
  )
}

export { AuthContext, AuthProvider }

Der Context besteht aus einem Zustand, der alle relevanten Informationen für die Authentifizierung enthält, und den Funktionen signIn und signOut. Der Reducer verarbeitet drei Action-Typs:

  • SIGN_IN: Die Anmeldung ist erfolgt und ein Access-Token liegt vor.
  • SIGN_OUT: Die Abmeldung ist erfolgt und die Beispiel-App befindet sich wieder im initialen Zustand.
  • USER_INFO: Der Abruf der Informationen des angemeldeten Users ist erfolgt.

Der Context-Provider AuthProvider wird in der App.js eingebunden:

import React from 'react'
import { Main } from './components/Main'
import { AuthProvider } from './context/AuthContext'

export default function App() {
  return (
    <AuthProvider>
      <Main />
    </AuthProvider>
  )
}

Der bisherige Code aus der App.js wird in eine eigene Komponente Main in die Datei components/Main.js überführt:

import React, { useContext } from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { AuthContext } from '../context/AuthContext'
import { HomeScreen } from './screens/HomeScreen'
import { AboutScreen } from './screens/AboutScreen'
import { SignInScreen } from './screens/SignInScreen'

const NativeStack = createNativeStackNavigator()

const Main = () => {
  const { state } = useContext(AuthContext)
  
  return (
    <NavigationContainer>
      <NativeStack.Navigator>
        {state.isSignedIn ? (
          <>
            <NativeStack.Screen name={'Home'} component={HomeScreen} />
            <NativeStack.Screen name={'About'} component={AboutScreen} />
          </>
        ) : (
          <NativeStack.Screen
            name={'SignIn'}
            component={SignInScreen}
            options={{ animationTypeForReplace: 'pop' }}
          />
        )}
      </NativeStack.Navigator>
    </NavigationContainer>
  )
}

export { Main }

Auf den zuvor erstellten Context kann in der Komponente mit Hilfe des useContext-Hooks zugegriffen werden. Der im Context enthaltene Zustand state kann nun anstelle des zuvor verwendeten lokalen Zustands genutzt werden, um zu entscheiden, welche Screens innerhalb des Native-Stack gerendert werden.

Die Funktionen signIn und signOut können im SignInScreen bzw. HomeScreen eingebunden werden. Auch dies geschieht über den bereitgestellten Context:

import React, { useContext } from 'react'
import { Button, StyleSheet, Text, View } from 'react-native'
import { AuthContext } from '../context/AuthContext'

const SignInScreen = () => {
  const { signIn } = useContext(AuthContext)

  return (
    <View style={styles.container}>
      <Text>SignIn-Screen</Text>
      <Button onPress={signIn} title={'Sign in'} />
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: '#fff',
    alignItems: 'center',
    justifyContent: 'center',
  },
})

export { SignInScreen }

Expo AuthSession

Die Umsetzung des Authorization Code Flow mit PKCE in Verbindung mit Keycloak kann in einer React Native App teilweise mit Hilfe des Moduls Expo AuthSession realisiert werden. Das Modul unterstützt PKCE, sofern der verwendete Authorization Server dies auch unterstützt. Bei Keycloak ist dies der Fall, bei GitHub bspw. nicht. Zunächst werden die erforderlichen Dependencies für das Modul im Projekt hinzugefügt:

npx expo install expo-auth-session expo-crypto

Das Modul stellt die erforderlichen Hilfsfunktionen bereit, um einen Authorization-Code von Keycloak zu erhalten. Bezogen auf das oben gezeigte Sequenz-Diagramm deckt es die Schritte 2 bis 5 ab. Hierzu wird context/AuthContext.js entsprechend erweitert.

...
import { makeRedirectUri, useAuthRequest, useAutoDiscovery } from 'expo-auth-session'
...
const AuthProvider = ({ children }) => {
  const discovery = useAutoDiscovery(process.env.EXPO_PUBLIC_KEYCLOAK_URL)
  const redirectUri = makeRedirectUri()
  const [request, response, promptAsync] = useAuthRequest(
    {
      clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID,
      redirectUri: redirectUri,
      scopes: ['openid', 'profile'],
    },
    discovery
  )
  ...
  const authContext = useMemo(
    () => ({
      state: authState,
      signIn: () => {
        promptAsync()
      },
      signOut: () => { /* TODO */ },
    }),
    [authState]
  )
}
...

Die verwendeten Umgebungsvariablen EXPO_PUBLIC_KEYCLOAK_URL und EXPO_PUBLIC_KEYCLOAK_CLIENT_ID können bspw. in der Datei .env abgelegt werden:

EXPO_PUBLIC_KEYCLOAK_URL=http://localhost:11111/realms/test
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=rn-expo-app

Die Hilfsfunktion useAutoDiscovery ermittelt die erforderlichen URLs vom Authorization Server, um den Authorization Code Flow einzuleiten. Die Funktion makeRedirectUri erzeugt eine Redirect-URI, die eine Umleitung von Keycloak zurück in die native App ermöglicht. Bei der Ausführung der App über Expo Go entsteht hier bspw. eine URI wie exp://192.168.178.95:8081. Das Scheme exp:// gehört zu Expo Go. Die IP-Adresse entspricht der Adresse des Devices auf dem Expo Go ausgeführt wird. Bei der Ausführung über einen Development Build wird ein Scheme erzeugt, wie es in der app.json in expo.scheme festgelegt wird. Die Funktion promptAsync leitet den eigentlichen Authentifizierungsprozess ein, so wie oben im Ablauf in Schritt 3 beschrieben.

Im Anschluss an eine erfolgreiche Authentifizierung stellt das Modul einen Authorization-Code zur Verfügung, mit dem ein Access-Token von Keycloak abgerufen werden kann. Dies erfolgt in einem useEffect-Hook ebenfalls in context/AuthContext.js:

...
  useEffect(() => {
    const getToken = async ({ code, codeVerifier, redirectUri }) => {
      try {
        const formData = {
          grant_type: 'authorization_code',
          client_id: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID,
          code: code,
          code_verifier: codeVerifier,
          redirect_uri: redirectUri,
        }
        const formBody = []
        for (const property in formData) {
          var encodedKey = encodeURIComponent(property)
          var encodedValue = encodeURIComponent(formData[property])
          formBody.push(encodedKey + '=' + encodedValue)
        }

        const response = await fetch(
          `${process.env.EXPO_PUBLIC_KEYCLOAK_URL}/protocol/openid-connect/token`,
          {
            method: 'POST',
            headers: {
              Accept: 'application/json',
              'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: formBody.join('&'),
          }
        )
        if (response.ok) {
          const payload = await response.json()
          dispatch({ type: 'SIGN_IN', payload })
        }
      } catch (e) {
        console.warn(e)
      }
    }
    if (response?.type === 'success') {
      const { code } = response.params
      getToken({
        code,
        codeVerifier: request?.codeVerifier,
        redirectUri,
      })
    } else if (response?.type === 'error') {
      console.warn('Authentication error: ', response.error)
    }
  }, [dispatch, redirectUri, request?.codeVerifier, response])
...

Hat das Modul einen Authorization-Code erhalten, steht dieser in response.params.code zur Verfügung. Zusammen mit den weiteren erforderlichen Parameter grant_type, client_id, code_verifier und redirect_uri erfolgt der Request an Keycloak. Der dabei verwendete Parameter code_verifier wurde zuvor vom Modul expo-auth-session erzeugt und ist Teil des beschriebenen PKCE. Die Response von Keycloak enthält den Access-Token. Dieser wird in einem Action-Objekt dispatched und vom passenden Reducer verarbeitet:

...
      case 'SIGN_IN':
        return {
          ...previousState,
          isSignedIn: true,
          accessToken: action.payload.access_token,
          idToken: action.payload.id_token,
        }
...

Mit Hilfe des Access-Tokens können nun die Informationen zum authentifizierten User vom User-Info-Endpunkt von Keycloak abgerufen werden. Der Access-Token wird im Request an Keycloak im HTTP-Header Authorization als Bearer-Token angegeben.

...
  useEffect(() => {
    const getUserInfo = async () => {
      try {
        const accessToken = authState.accessToken
        const response = await fetch(
          `${process.env.EXPO_PUBLIC_KEYCLOAK_URL}/protocol/openid-connect/userinfo`,
          {
            method: 'GET',
            headers: {
              Authorization: 'Bearer ' + accessToken,
              Accept: 'application/json',
            },
          }
        )
        if (response.ok) {
          const payload = await response.json()
          dispatch({ type: 'USER_INFO', payload })
        }
      } catch (e) {
        console.warn(e)
      }
    }
    if (authState.isSignedIn) {
      getUserInfo()
    }
  }, [authState.accessToken, authState.isSignedIn, dispatch])
...

Die Response von Keycloak enthält die User-Informationen. Diese werden in einem Action-Objekt dispatched und vom passenden Reducer verarbeitet:

...
      case 'USER_INFO':
        return {
          ...previousState,
          userInfo: {
            username: action.payload.preferred_username,
            givenName: action.payload.given_name,
            familyName: action.payload.family_name,
            email: action.payload.email,
          },
        }
...

Abschließend wird noch die Funktion signOut implementiert. Hierzu wird ein ID-Token verwendet, der von Keycloak zusammen mit dem Access-Token ausgestellt wurde. Dieser Token ist im Request enthalten, der die aktive Session des authentifizierten Users in Keycloak beendet. Im Anschluss wird die Action SIGN_OUT dispatched, um die App wieder in den initialen Zustand zu versetzen.

...
  const authContext = useMemo(
    () => ({
      state: authState,
      signIn: () => {
        promptAsync()
      },
      signOut: async () => {
        try {
          const idToken = authState.idToken
          await fetch(
            `${process.env.EXPO_PUBLIC_KEYCLOAK_URL}/protocol/openid-connect/logout?id_token_hint=${idToken}`
          )
          dispatch({ type: 'SIGN_OUT' })
        } catch (e) {
          console.warn(e)
        }
      },
    }),
    [authState]
  )
...

Die Beispiel-App enthält nun alle erforderlichen Funktionen, um den oben im Sequenz-Diagramm gezeigten Ablauf von Schritt 1 bis 9 vollständig durchlaufen zu können.

Rollen-basierte Autorisierung

Die im Beispiel gezeigte Authentifizierung kann flexibel erweitert werden. In Keycloak können Rollen definiert werden, die dann ebenfalls in den Informationen zum angemeldeten User zur Verfügung gestellt werden können. Diese Rolleninformationen können dann in der App berücksichtigt werden, um bspw. bestimmte Screens nur für bestimmte Rollen zur Verfügung zustellen.

Erweiterung Keycloak

In Keycloak wird dafür zunächst ein weiterer User (Jane Roe) mit einem Passwort erstellt.

Anschließend wird die Konfiguration des bestehenden Clients rn-expo-app erweitert. Im Tab Roles werden die beiden Rollen Manager und IC erstellt.

Keycloak - Client erweitern - Rollen

Im Tab Client scopes muss der Client Scope mit der Bezeichnung rn-expo-app-dedicated bearbeitet werden. Im Tab Mappers wird der Mapper mit der Bezeichnung client roles und dem Typ User Client Role hinzugefügt.

Keycloak - Client erweitern - Mapper

Der Mapper muss im Anschluss noch angepasst werden:

  • Im Feld Client ID sollte der Wert rn-expo-app ausgewählt werden. Dies stellt sicher, dass nur die beiden Rollen Manager oder IC berücksichtigt werden.
  • Im Feld Token Claim Name kann der Wert auf roles verkürzt werden. Dies erleichtert später die Verarbeitung in der App.
  • Die Option Add to userinfo muss aktiviert werden. Dies führt dazu, dass der Claim roles in der Response vom User-Infor-Endpunkt enthalten ist und in der App verwendet werden kann.

Keycloak - User Client Role

Zum Abschluss wird dem User John Doe dann die Rolle IC zugewiesen. Jane Roe erhält die Rolle Manager.

Keycloak - User erweitern - Rollen

Diese Konfigurationsanpassung führt dazu, dass im Access-Token und im User-Info-Endpunkt die zugewiesene Rolle im Claim roles enthalten ist:

{
  ...
  "roles": [
    "IC"
  ],
  "name": "John Doe",
  "preferred_username": "jd",
  "given_name": "John",
  "family_name": "Doe",
  "email": "john.doe@example.com"
}

Erweiterung App

Die Beispiel-App erhält zwei zusätzliche Screens ICScreen und ManagerScreen, die nur für authentifizierte Anwender:innen mit der entsprechenden Rolle sichtbar sein sollen. Der AuthContext wird so erweitert, dass beim Abruf des User-Info-Endpunktes auch die zugewiesenen Rollen mit im lokalen Zustand gespeichert werden:

...
      case 'USER_INFO':
        return {
          ...previousState,
          userInfo: {
            username: action.payload.preferred_username,
            givenName: action.payload.given_name,
            familyName: action.payload.family_name,
            email: action.payload.email,
            roles: action.payload.roles,
          },
        }
...

Darüber hinaus erhält der AuthContext die Funktion hasRole:

...
const AuthContext = createContext({
  state: initialState,
  signIn: () => {},
  signOut: () => {},
  hasRole: (role) => false,
})
...

Die Funktion prüft, ob eine übergebene Rolle Teil der Rollen ist, die dem authentifizierten User in Keycloak zugewiesen wurden:

...
  const authContext = useMemo(
    () => ({
      state: authState,
      signIn: () => {
        promptAsync()
      },
      signOut: async () => {
        try {
          const idToken = authState.idToken
          await fetch(
            `${process.env.EXPO_PUBLIC_KEYCLOAK_URL}/protocol/openid-connect/logout?id_token_hint=${idToken}`
          )
          dispatch({ type: 'SIGN_OUT' })
        } catch (e) {
          console.warn(e)
        }
      },
      hasRole: (role) => authState.userInfo?.roles.indexOf(role) != -1,
    }),
    [authState, promptAsync]
  )
...

Die neuen Screens und die neue Hilfsfunktion können anschließend in der Komponente Main eingebunden werden:

import React, { useContext } from 'react'
import { NavigationContainer } from '@react-navigation/native'
import { createNativeStackNavigator } from '@react-navigation/native-stack'
import { AuthContext } from '../context/AuthContext'
import { HomeScreen } from '../screens/HomeScreen'
import { ProfileScreen } from '../screens/ProfileScreen'
import { SignInScreen } from '../screens/SignInScreen'
import { ManagerScreen } from '../screens/ManagerScreen'
import { ICScreen } from '../screens/ICScreen'

const NativeStack = createNativeStackNavigator()

const Main = () => {
  const { hasRole, state } = useContext(AuthContext)

  return (
    <NavigationContainer>
      <NativeStack.Navigator>
        {state.isSignedIn ? (
          <>
            <NativeStack.Screen name={'Home'} component={HomeScreen} />
            {hasRole('Manager') && (
              <NativeStack.Screen name={'Manager'} component={ManagerScreen} />
            )}
            {hasRole('IC') && (
              <NativeStack.Screen name={'IC'} component={ICScreen} />
            )}
            <NativeStack.Screen name={'Profile'} component={ProfileScreen} />
          </>
        ) : (
          <NativeStack.Screen
            name={'SignIn'}
            component={SignInScreen}
            options={{ animationTypeForReplace: 'pop' }}
          />
        )}
      </NativeStack.Navigator>
    </NavigationContainer>
  )
}

export { Main }

Wird die Beispiel-App nun über npx expo start gestartet, kann die implementierte Authentifizierung und Rollen-basierte Autorisierung getestet werden.

Fazit

Eine sichere Authentifizierung ohne Passwörter oder Secrets innerhalb des Source-Codes einer App kann mit Hilfe des Authorization Code Flow mit Proof Key for Code Exchange (PKCE) erreicht werden. Ein Authorization Server wie Keycloak unterstützt PKCE und kann schnell eingerichtet werden. Wird die App auf Basis von React Native und Expo erstellt, kann einem das Modul expo-auth-session viel Arbeit abnehmen. Die verbleibenden Funktionen sind dank der Konzepte von React schnell implementiert und dank React Native auch Plattform-übergreifend einsetzbar. Auch die Erweiterung hin zu einer Rollen-basierten Autorisierung kann sowohl in Keycloak als auch in der App schnell erreicht werden.

Der Quellcode für das Beispiel ist bei GitHub verfügbar.