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
This commit is contained in:
+76
-29
@@ -1,42 +1,89 @@
|
||||
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';
|
||||
|
||||
interface LoginResult {
|
||||
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(
|
||||
instanceUrl: string,
|
||||
handle: string,
|
||||
password: string,
|
||||
): Promise<LoginResult> {
|
||||
const url = instanceUrl.replace(/\/$/, '') + '/api/auth/token';
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ handle, password }),
|
||||
});
|
||||
export async function login(): Promise<LoginResult> {
|
||||
const verifier = await generateVerifier();
|
||||
const challenge = await deriveChallenge(verifier);
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
return { ok: false, error: text || `HTTP ${resp.status}` };
|
||||
}
|
||||
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 data = await resp.json();
|
||||
if (!data.token) return { ok: false, error: 'No token in response' };
|
||||
|
||||
await Promise.all([
|
||||
AsyncStorage.setItem('instanceUrl', instanceUrl.trim()),
|
||||
AsyncStorage.setItem('handle', handle.trim()),
|
||||
AsyncStorage.setItem('apiToken', data.token),
|
||||
]);
|
||||
|
||||
return { ok: true, displayName: data.display_name };
|
||||
} catch (e: any) {
|
||||
return { ok: false, error: e?.message ?? 'Connection failed' };
|
||||
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> {
|
||||
|
||||
Reference in New Issue
Block a user