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.
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.
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(/=+$/, '');
}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;
}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, ...
}