Implementing OAuth with Expo and Authentik in TypeScript
Learn how to set up OAuth authentication in your mobile app using Expo, Authentik, and TypeScript. From project initialization to token revocation, this guide covers everything with a touch of French flair!
Bonjour and welcome to the world of app security! Are you ready to secure your app as if it were the Louvre museum? Today, we're diving into setting up OAuth authentication using Expo, Authentik, and a sprinkle of TypeScript, all served on a silver platter of modern development techniques. Let’s get started, and as the French say, "Allons-y!"
Project Initialization with TypeScript Template and PNPM
First things first, let's set up our project. We'll use PNPM for its efficiency in handling node modules like a Parisian handling a croissant—carefully and effectively.
Create a new Expo project with the TypeScript template:
pnpx create-expo-app -t expo-template-blank-typescript
Install PNPM if you haven't:
npm install -g pnpm
Voilà! You have laid the foundation of your project.
Installing Dependencies
To handle authentication, we'll need a few packages. Let’s install them like we’re stocking up before a French strike—better safe than sorry!
pnpm expo install expo-auth-session expo-secure-store
These will help us manage authentication sessions and securely store our tokens.
AutoDiscovery with Authentik
Authentik supports OpenID Connect (OIDC) auto-discovery, making it easier than a Monday morning in Parisian metro to configure. Here’s how to set it up:
- Create an app on your Authentik server
- Ensure that your Authentik server URL is correct and the app slug is correct.
In your project, create a configuration for Authentik:
// useAuth.ts
import { useAutoDiscovery } from 'expo-auth-session';
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function useAuth() {
const discovery = useAutoDiscovery(AuthentikAppUrl);
/* ... */
}
Making the Request
When everything is set up, making the request is as simple as ordering a café au lait:
// App.tsx
import { makeRedirectUri, useAutoDiscovery, useAuthRequest } from 'expo-auth-session';
const redirectUri = makeRedirectUri();
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function App() {
const discovery = useAutoDiscovery(AuthentikAppUrl);
// To retrieve the redirect URL, add this to the callback URL list
// of your Authentik application.
// console.log(`Redirect URL: ${redirectUri}`);
const [request, result, promptAsync] = useAuthRequest(
{
redirectUri,
clientId: AuthentikClientId,
// id_token will return a JWT token
responseType: "id_token",
// retrieve the user's profile
scopes: ["openid", "profile"],
usePKCE: true,
extraParams: {
// ideally, this will be a random value
nonce: "nonce",
},
},
discovery
);
/* ... */
}
Saving the Token
Once you receive the token, store it securely like a secret recipe for the perfect baguette:
// token.ts
import * as SecureStore from 'expo-secure-store';
import { TokenResponse } from "expo-auth-session";
const AUTH_TOKEN_KEY = 'AUTH_TOKEN_KEY';
export async function saveToken(token: TokenResponse) {
await SecureStore.setItemAsync(AUTH_TOKEN_KEY,JSON.stringify(token));
}
// useAuth.ts
import { useAutoDiscovery, makeRedirectUri, useAuthRequest } from "expo-auth-session";
import { Alert } from "react-native";
import { useCallback, useEffect } from "react";
import { saveToken } from "./token";
const redirectUri = makeRedirectUri();
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function useAuth() {
const discovery = useAutoDiscovery(AuthentikAppUrl);
// To retrieve the redirect URL, add this to the callback URL list
// of your Authentik application.
// console.log(`Redirect URL: ${redirectUri}`);
const [request, result, promptAsync] = useAuthRequest(
{
redirectUri,
clientId: AuthentikClientId,
// id_token will return a JWT token
responseType: "id_token",
// retrieve the user's profile
scopes: ["openid", "profile"],
usePKCE: true,
extraParams: {
// ideally, this will be a random value
nonce: "nonce",
},
},
discovery
);
const handleResult = useCallback(() => {
if(!result) return;
if (result.type === 'error') {
Alert.alert(
"Authentication error",
result.params.error_description || "something went wrong"
);
return;
}
if (result.type === "success") {
if(!result.authentication || !result.authentication) return;
saveToken(result.authentication);
}
}, [result]);
useEffect(()=> {
handleResult();
}, [handleResult]);
/* ... */
}
Decoding the Token to Get Claims
Decoding the token to retrieve claims is like decoding the French menu in a fancy restaurant—necessary to understand what you’re getting:
// token.ts
import * as SecureStore from 'expo-secure-store';
import { jwtDecode, type JwtPayload } from 'jwt-decode';
const AUTH_TOKEN_KEY = 'AUTH_TOKEN_KEY';
export async function saveToken(token: string) {
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
}
export interface Claims extends JwtPayload {
name: string;
preferred_username: string;
email: string;
nickname: string;
groups: string[];
given_name: string;
}
export const decodeToken = (token: string) => {
const decoded = jwtDecode<Claims>(token);
return decoded;
};
But wait jwt-decode relies on `atob` but react-native doesn't have it so lets fix this. Let's install base-64
and polyfill ourselves.
pnpm add base-64 && pnpm add --save-dev @types/base-64
Let's update our files
// token.ts
import { decode } from "base-64";
import * as SecureStore from 'expo-secure-store';
import { jwtDecode, type JwtPayload } from 'jwt-decode';
const AUTH_TOKEN_KEY = 'AUTH_TOKEN_KEY';
export async function saveToken(token: string) {
await SecureStore.setItemAsync(AUTH_TOKEN_KEY, token);
}
export interface Claims extends JwtPayload {
name: string;
preferred_username: string;
email: string;
nickname: string;
groups: string[];
given_name: string;
}
export function decodeToken(token: string) {
const originalAtoB = global.atob;
global.atob = decode; // Dangerous overload
const decoded = jwtDecode<Claims>(token);
global.atob = originalAtoB;
return decoded;
}
You should maybe call your api and handle the token decode server side
// useAuth.ts
import { useAutoDiscovery, makeRedirectUri, useAuthRequest } from "expo-auth-session";
import { Alert } from "react-native";
import { useCallback, useEffect, useState } from "react";
import { decodeToken, getToken, type Claims, saveToken } from "./token";
const redirectUri = makeRedirectUri();
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function useAuth() {
/* ... */
const handleResult = useCallback(() => {
if(!result) return;
if (result.type === 'error') {
Alert.alert(
"Authentication error",
result.params.error_description || "something went wrong"
);
return;
}
if (result.type === "success") {
if(!result.authentication || !result.authentication.idToken) return;
// Retrieve the JWT token and decode it
const jwtToken = result.authentication.idToken;
const decoded = decodeToken(jwtToken);
setClaims(decoded);
saveToken(result.authentication);
}
}, [result]);
useEffect(()=> {
handleResult();
}, [handleResult]);
/* ... */
}
Retrieving and Checking the Token
Fetching the token and checking its validity is like checking if the cheese has matured to perfection:
// token.ts
/* ... */
export async function getToken(): Promise<TokenResponse | null> {
const result = await SecureStore.getItemAsync(AUTH_TOKEN_KEY);
if (!result) return null;
return new TokenResponse(JSON.parse(result));
}
export interface Claims extends JwtPayload {
name: string;
preferred_username: string;
email: string;
nickname: string;
groups: string[];
given_name: string;
}
export function decodeToken(token: string) {
const originalAtoB = global.atob;
global.atob = decode; // Dangerous overload
const decoded = jwtDecode<Claims>(token);
global.atob = originalAtoB;
return decoded;
}
// useAuth.ts
import { useAutoDiscovery, makeRedirectUri, useAuthRequest } from "expo-auth-session";
import { Alert } from "react-native";
import { useCallback, useEffect, useState } from "react";
import { decodeToken, getToken, type Claims, saveToken } from "./token";
const redirectUri = makeRedirectUri();
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function useAuth() {
/* .... */
const retrieveTokenOnMount = useCallback(async () => {
const token = await getToken();
if (!token || !token.idToken) return;
if(token.shouldRefresh()){
// refresh if required
await token.refreshAsync({
...token.getRequestConfig(),
clientId: AuthentikClientId,
},
{
tokenEndpoint: discovery?.tokenEndpoint
}
);
}
const decoded = decodeToken(token.idToken);
setClaims(decoded);
}, [discovery]);
useEffect(() => {
retrieveTokenOnMount();
}, [retrieveTokenOnMount]);
/* .... */
}
Logging Out and Revoking the Token
Finally, logging out and revoking the token should be as dramatic as a French exit—silent but effective:
// useAuth.ts
import { useAutoDiscovery, makeRedirectUri, useAuthRequest, revokeAsync } from "expo-auth-session";
import { Alert } from "react-native";
import { useCallback, useEffect, useState } from "react";
import { decodeToken, getToken, type Claims, saveToken, removeToken } from "./token";
import { openBrowserAsync } from "expo-web-browser";
const redirectUri = makeRedirectUri();
const AuthentikClientId = 'your-client-id-here';
const AuthentikAppUrl = 'https:/[Your authentik domain]/application/o/[Your authentik app slug]';
export default function useAuth() {
/* ... */
const revoke = async () => {
const token = await getToken();
if(!token) return;
await revokeAsync(
{
...token.getRequestConfig(),
token: token.accessToken,
clientId: AuthentikClientId,
},
{
revocationEndpoint: discovery?.revocationEndpoint,
}
);
setClaims(null);
await removeToken();
if(discovery?.endSessionEndpoint) {
await openBrowserAsync(
discovery?.endSessionEndpoint
);
}
};
}
Wrap it up
Now write a little app file with ui
// App.tsx
import { Button, StyleSheet, Text, View } from "react-native";
import useAuth from "./useAuth";
import { Claims } from "./token";
export default function App() {
const { claims,request, promptAsync,revoke } = useAuth();
return (
<View style={styles.container}>
{claims ? (
<>
<Text style={styles.title}>You are logged in!</Text>
<Text style={styles.title}>JWT claims:</Text>
{Object.keys(claims).map((key) => (
<Text key={key}>
{key}: {claims[key as keyof Claims]}
</Text>
))}
<Button title="Log out" onPress={revoke} />
</>
) : (
<Button
disabled={!request}
title="Log in"
onPress={() => promptAsync()}
/>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
alignItems: "center",
justifyContent: "center",
},
title: {
fontSize: 20,
textAlign: "center",
marginTop: 40,
},
});
Conclusion
And there you have it! You've just secured your app with OAuth using Expo, Authentik, and TypeScript, all while maintaining that effortless French chic. Now your app's security is as robust as the French spirit—unyielding and sophisticated. Enjoy coding, and remember, as they say in France, "Plus ça change, plus c'est la même chose" — the more things change, the more they stay the same, especially in coding standards!
The whole source code is available on Gitlab