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!

Implementing OAuth with Expo and Authentik in TypeScript
Photo by Micah Williams / Unsplash

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:

  1. Create an app on your Authentik server
  2. 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);

  /* ... */
}
💡
Do not include the whole well-known part in the url, the library appends it

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;
}
☠️
Always remember that overloading globals yourself is never a good idea. If some other libs rely on the said global, you're cooked. We do this only for science and you should never do this on production. Never ever....
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