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:
- Ein User öffnet die mobile App und löst eine Anmeldung aus, bspw. durch Tippen auf einen Button.
- 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
- 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.
- Die Login-Seite wird mit den Anmeldeinformationen an Keycloak gesendet. Keycloak authentifiziert den User.
- 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>
- 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
- 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"
...
}
- 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
- 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.
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.
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.
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.
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.
Navigation und Screens
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.
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.
Der Mapper muss im Anschluss noch angepasst werden:
- Im Feld
Client ID
sollte der Wertrn-expo-app
ausgewählt werden. Dies stellt sicher, dass nur die beiden RollenManager
oderIC
berücksichtigt werden. - Im Feld
Token Claim Name
kann der Wert aufroles
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 Claimroles
in der Response vom User-Infor-Endpunkt enthalten ist und in der App verwendet werden kann.
Zum Abschluss wird dem User John Doe
dann die Rolle IC
zugewiesen. Jane Roe
erhält die Rolle Manager
.
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.