← Integration guides

React Native integration

The same structurally different pattern as Flutter: no BFF — your app is a real public OAuth client (no client_secret), talking to this platform's endpoints directly, with the token held on-device.

System browser, not a WebView

react-native-webview is deliberately not used for this — an embedded webview shares your app's own process (no isolation from credential-harvesting code your app or its dependencies could run) and gives the user no real address bar/certificate proof they're actually on accounts.onehux.com. This is RFC 8252's own standard guidance for native apps doing OAuth, not a OneHux-specific rule — see the Flutter guide for the fuller reasoning. In practice: expo-auth-session (or react-native-app-auth for a bare RN project) — both use SFSafariViewController on iOS and Chrome Custom Tabs on Android, the real system browser.

1. PKCE + launch the system browser

import * as AuthSession from 'expo-auth-session';
import * as Crypto from 'expo-crypto';

const ONEHUX_BASE_URL = 'https://your-org.onehux.com'; // fixed, shared platform host — see the Integration overview
const CLIENT_ID = 'onehux_client_...';
// Registered as this Application's redirect_uri — a custom scheme (bare RN) or an Expo
// proxy/dev-client URI, whichever this app is actually built with.
const redirectUri = AuthSession.makeRedirectUri({ scheme: 'com.yourapp.app' });

async function signIn() {
	const codeVerifier = base64url(await Crypto.getRandomBytesAsync(48));
	const challengeDigest = await Crypto.digestStringAsync(
		Crypto.CryptoDigestAlgorithm.SHA256,
		codeVerifier,
		{ encoding: Crypto.CryptoEncoding.BASE64 }
	);
	const codeChallenge = challengeDigest.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
	const state = base64url(await Crypto.getRandomBytesAsync(16));

	const authUrl =
		`${ONEHUX_BASE_URL}/login?` +
		new URLSearchParams({
			client_id: CLIENT_ID,
			redirect_uri: redirectUri,
			code_challenge: codeChallenge,
			code_challenge_method: 'S256',
			scope: 'openid profile email',
			state
		}).toString();

	// Opens the real system browser and suspends until it redirects back to redirectUri —
	// see "System browser, not a WebView" above.
	const result = await AuthSession.startAsync({ authUrl });
	if (result.type !== 'success' || result.params.state !== state) {
		throw new Error('OAuth failed or state mismatch.');
	}

	return exchangeCodeForTokens(result.params.code, codeVerifier);
}

function base64url(bytes: Uint8Array): string {
	return btoa(String.fromCharCode(...bytes))
		.replace(/\+/g, '-')
		.replace(/\//g, '_')
		.replace(/=+$/, '');
}

2. Exchange the code — no client_secret, public client

import * as SecureStore from 'expo-secure-store';

async function exchangeCodeForTokens(code: string, codeVerifier: string) {
	const res = await fetch(`${ONEHUX_BASE_URL}/api/v1/oauth/token/`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			grant_type: 'authorization_code',
			code,
			redirect_uri: redirectUri,
			client_id: CLIENT_ID,
			// No client_secret — a public client authenticates via the PKCE verifier alone.
			code_verifier: codeVerifier
		})
	});
	if (!res.ok) throw new Error('Sign-in failed.');

	const tokens = await res.json(); // { access_token, id_token, refresh_token, token_type, expires_in, scope }

	// OS-level secure storage — Keychain on iOS, Keystore-backed EncryptedSharedPreferences
	// on Android — never AsyncStorage, and never a cookie (there is no BFF here; this app
	// itself holds the token).
	await SecureStore.setItemAsync('onehux_access_token', tokens.access_token);
	await SecureStore.setItemAsync('onehux_refresh_token', tokens.refresh_token);

	return tokens;
}

3. Using the token — and refreshing it

A public client's refresh token rotates on every use, same as a confidential client's, but with a shorter lifetime (7-day idle timeout, 14-day absolute cap — see Sessions) — no client_secret is sent, matching every other public-client call this app makes. A 401 from /userinfo almost always means just the 15-minute access token expired, not that the user needs to sign in again:

async function refreshTokens(): Promise<string | null> {
	const refreshToken = await SecureStore.getItemAsync('onehux_refresh_token');
	if (!refreshToken) return null;

	const res = await fetch(`${ONEHUX_BASE_URL}/api/v1/oauth/token/`, {
		method: 'POST',
		headers: { 'Content-Type': 'application/json' },
		body: JSON.stringify({
			grant_type: 'refresh_token',
			refresh_token: refreshToken,
			client_id: CLIENT_ID
			// No client_secret — same public-client rule as the code exchange above.
		})
	});
	if (!res.ok) return null;

	const tokens = await res.json();
	await SecureStore.setItemAsync('onehux_access_token', tokens.access_token);
	await SecureStore.setItemAsync('onehux_refresh_token', tokens.refresh_token);
	return tokens.access_token;
}

export async function fetchUserInfo() {
	let accessToken = await SecureStore.getItemAsync('onehux_access_token');
	if (!accessToken) return null;

	let res = await fetch(`${ONEHUX_BASE_URL}/api/v1/oauth/userinfo/`, {
		headers: { Authorization: `Bearer ${accessToken}` }
	});
	if (res.status === 401) {
		accessToken = await refreshTokens();
		if (!accessToken) {
			await SecureStore.deleteItemAsync('onehux_access_token');
			await SecureStore.deleteItemAsync('onehux_refresh_token');
			return null;
		}
		res = await fetch(`${ONEHUX_BASE_URL}/api/v1/oauth/userinfo/`, {
			headers: { Authorization: `Bearer ${accessToken}` }
		});
	}
	if (!res.ok) return null;
	return res.json(); // sub, name, email, picture, roles, permissions, ...
}