92 lines
3.0 KiB
TypeScript
92 lines
3.0 KiB
TypeScript
import * as Crypto from 'expo-crypto';
|
|
import * as WebBrowser from 'expo-web-browser';
|
|
import { makeRedirectUri } from 'expo-auth-session';
|
|
import { setSetting } from '@/db/queries';
|
|
import { type SQLiteDatabase } from 'expo-sqlite';
|
|
|
|
const ISSUER = 'https://bincio.org';
|
|
const CLIENT_ID = 'bincio-autarchive';
|
|
const REDIRECT_URI = makeRedirectUri({ scheme: 'bincio', path: 'oauth' });
|
|
|
|
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, '');
|
|
}
|
|
|
|
export interface LoginResult {
|
|
ok: boolean;
|
|
displayName?: string;
|
|
error?: string;
|
|
}
|
|
|
|
export async function login(db: SQLiteDatabase): 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' };
|
|
|
|
const payload = JSON.parse(atob(idToken.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
|
|
await Promise.all([
|
|
setSetting(db, 'instance_url', ISSUER),
|
|
setSetting(db, 'handle', payload.sub ?? ''),
|
|
setSetting(db, 'api_token', idToken),
|
|
]);
|
|
|
|
return { ok: true, displayName: payload.name ?? payload.preferred_username };
|
|
}
|
|
|
|
export async function logout(db: SQLiteDatabase): Promise<void> {
|
|
await Promise.all([
|
|
setSetting(db, 'instance_url', ''),
|
|
setSetting(db, 'handle', ''),
|
|
setSetting(db, 'api_token', ''),
|
|
]);
|
|
}
|