Files
bincio-rec/src/services/auth.ts
T
Davide Scaini 27e7f008f0 auth: replace password login with OIDC PKCE flow (Phase 5)
- Install expo-auth-session + expo-web-browser
- Add 'bincio-rec' URL scheme to app.json for deep-link redirect
- auth.ts: generate PKCE verifier/challenge, open bincio.org/oauth2/authorize
  in browser, exchange auth code for RS256 id_token, store in AsyncStorage
- SettingsScreen: remove handle/password fields, single 'Sign in with bincio'
  button that opens the browser flow
2026-06-03 16:12:15 +02:00

112 lines
3.9 KiB
TypeScript

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<string> {
const bytes = await Crypto.getRandomBytesAsync(32);
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
async function deriveChallenge(verifier: string): Promise<string> {
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<LoginResult> {
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<void> {
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<AuthState | null> {
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 };
}