import AsyncStorage from '@react-native-async-storage/async-storage'; import * as Crypto from 'expo-crypto'; import * as WebBrowser from 'expo-web-browser'; import { makeRedirectUri } from 'expo-auth-session'; const ISSUER = 'https://bincio.org'; const CLIENT_ID = 'bincio-rec'; const REDIRECT_URI = makeRedirectUri({ scheme: 'bincio-rec', path: 'oauth' }); // ── PKCE helpers ────────────────────────────────────────────────────────────── async function generateVerifier(): Promise { const bytes = await Crypto.getRandomBytesAsync(32); return btoa(String.fromCharCode(...bytes)) .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } async function deriveChallenge(verifier: string): Promise { const digest = await Crypto.digestStringAsync( Crypto.CryptoDigestAlgorithm.SHA256, verifier, { encoding: Crypto.CryptoEncoding.BASE64 }, ); return digest.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); } // ── Public API ──────────────────────────────────────────────────────────────── export interface LoginResult { ok: boolean; displayName?: string; error?: string; } export async function login(): Promise { const verifier = await generateVerifier(); const challenge = await deriveChallenge(verifier); const authUrl = `${ISSUER}/oauth2/authorize?` + new URLSearchParams({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', scope: 'openid profile', code_challenge: challenge, code_challenge_method: 'S256', }); const result = await WebBrowser.openAuthSessionAsync(authUrl, REDIRECT_URI); if (result.type !== 'success') { return { ok: false, error: result.type === 'cancel' ? 'Cancelled' : 'Browser error' }; } const code = new URL(result.url).searchParams.get('code'); if (!code) return { ok: false, error: 'No code in redirect' }; const tokenResp = await fetch(`${ISSUER}/oauth2/token`, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'authorization_code', code, redirect_uri: REDIRECT_URI, client_id: CLIENT_ID, code_verifier: verifier, }).toString(), }); if (!tokenResp.ok) { const text = await tokenResp.text().catch(() => ''); return { ok: false, error: `Token exchange failed: ${text}` }; } const tokenData = await tokenResp.json(); const idToken: string = tokenData.id_token; if (!idToken) return { ok: false, error: 'No id_token in response' }; // Decode payload (no verification needed — server already validated) const payload = JSON.parse(atob(idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/'))); await Promise.all([ AsyncStorage.setItem('instanceUrl', ISSUER), AsyncStorage.setItem('handle', payload.sub ?? ''), AsyncStorage.setItem('apiToken', idToken), ]); return { ok: true, displayName: payload.name ?? payload.preferred_username }; } export async function logout(): Promise { await Promise.all([ AsyncStorage.removeItem('instanceUrl'), AsyncStorage.removeItem('handle'), AsyncStorage.removeItem('apiToken'), ]); } export interface AuthState { instanceUrl: string; handle: string; apiToken: string; } export async function loadAuthState(): Promise { const [instanceUrl, handle, apiToken] = await Promise.all([ AsyncStorage.getItem('instanceUrl'), AsyncStorage.getItem('handle'), AsyncStorage.getItem('apiToken'), ]); if (!instanceUrl || !apiToken) return null; return { instanceUrl, handle: handle ?? '', apiToken }; }