feat: replace password login with OIDC PKCE flow

This commit is contained in:
Davide Scaini
2026-06-03 16:21:28 +02:00
parent 78af14a78d
commit 6e69358e1e
5 changed files with 179 additions and 110 deletions
+9 -3
View File
@@ -7,7 +7,10 @@
"scheme": "bincio", "scheme": "bincio",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"newArchEnabled": true, "newArchEnabled": true,
"platforms": ["ios", "android"], "platforms": [
"ios",
"android"
],
"icon": "./assets/icon.png", "icon": "./assets/icon.png",
"splash": { "splash": {
"image": "./assets/splash-icon.png", "image": "./assets/splash-icon.png",
@@ -39,11 +42,14 @@
"expo-sqlite", "expo-sqlite",
[ [
"expo-document-picker", "expo-document-picker",
{ "iCloudContainerEnvironment": "Production" } {
"iCloudContainerEnvironment": "Production"
}
], ],
"expo-background-fetch", "expo-background-fetch",
"expo-task-manager", "expo-task-manager",
"@maplibre/maplibre-react-native" "@maplibre/maplibre-react-native",
"expo-web-browser"
] ]
} }
} }
+21 -95
View File
@@ -1,17 +1,17 @@
import { useSQLiteContext } from 'expo-sqlite'; import { useSQLiteContext } from 'expo-sqlite';
import { useState } from 'react'; import { useState } from 'react';
import { import {
ActivityIndicator, Platform, Pressable, ScrollView, StyleSheet, ActivityIndicator, Alert, Platform, Pressable, ScrollView, StyleSheet,
Text, TextInput, View, Text, TextInput, View,
} from 'react-native'; } from 'react-native';
import { deleteRemoteActivities, getSetting, setSetting, useSetting } from '@/db/queries'; import { deleteRemoteActivities, setSetting, useSetting } from '@/db/queries';
import { PALETTES, type PaletteKey } from '@/theme'; import { PALETTES, type PaletteKey } from '@/theme';
import { useTheme, usePaletteControl } from '@/ThemeContext'; import { useTheme, usePaletteControl } from '@/ThemeContext';
import { login, logout } from '@/services/auth';
export default function SettingsScreen() { export default function SettingsScreen() {
const db = useSQLiteContext(); const db = useSQLiteContext();
const storedUrl = useSetting('instance_url') ?? '';
const storedHandle = useSetting('handle') ?? ''; const storedHandle = useSetting('handle') ?? '';
const storedPath = useSetting('auto_import_path') ?? ''; const storedPath = useSetting('auto_import_path') ?? '';
const storedToken = useSetting('api_token'); const storedToken = useSetting('api_token');
@@ -19,66 +19,39 @@ export default function SettingsScreen() {
const storedSyncUpload = useSetting('sync_upload') === 'true'; const storedSyncUpload = useSetting('sync_upload') === 'true';
const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas'; const storedUploadFormat = (useSetting('upload_format') ?? 'raw') as 'raw' | 'bas';
const [instanceUrl, setInstanceUrl] = useState(storedUrl);
const [handle, setHandle] = useState(storedHandle);
const [autoPath, setAutoPath] = useState(storedPath); const [autoPath, setAutoPath] = useState(storedPath);
const [syncMode, setSyncMode] = useState(storedSyncMode); const [syncMode, setSyncMode] = useState(storedSyncMode);
const [syncUpload, setSyncUpload] = useState(storedSyncUpload); const [syncUpload, setSyncUpload] = useState(storedSyncUpload);
const [uploadFormat, setUploadFormat] = useState(storedUploadFormat); const [uploadFormat, setUploadFormat] = useState(storedUploadFormat);
const [saved, setSaved] = useState(false);
const theme = useTheme(); const theme = useTheme();
const { paletteKey: palette, setPaletteOverride } = usePaletteControl(); const { paletteKey: palette, setPaletteOverride } = usePaletteControl();
const [password, setPassword] = useState('');
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null); const [connectMsg, setConnectMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [resetArmed, setResetArmed] = useState(false); const [resetArmed, setResetArmed] = useState(false);
const [resetMsg, setResetMsg] = useState<string | null>(null); const [resetMsg, setResetMsg] = useState<string | null>(null);
async function save() { async function handleConnect() {
await setSetting(db, 'instance_url', instanceUrl.trim());
await setSetting(db, 'handle', handle.trim());
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
async function connect() {
const url = instanceUrl.trim().replace(/\/$/, '');
const h = handle.trim();
if (!url || !h || !password) {
setConnectMsg({ ok: false, text: 'Fill in URL, handle, and password first.' });
return;
}
setConnecting(true); setConnecting(true);
setConnectMsg(null); setConnectMsg(null);
try { const result = await login(db);
const resp = await fetch(`${url}/api/auth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ handle: h, password }),
});
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
setConnectMsg({ ok: false, text: err.detail ?? `Error ${resp.status}` });
return;
}
const data = await resp.json();
await setSetting(db, 'instance_url', url);
await setSetting(db, 'handle', h);
await setSetting(db, 'api_token', data.token);
setPassword('');
setConnectMsg({ ok: true, text: `Connected as ${data.display_name || h}` });
} catch {
setConnectMsg({ ok: false, text: 'Could not reach instance — check the URL.' });
} finally {
setConnecting(false); setConnecting(false);
if (result.ok) {
setConnectMsg({ ok: true, text: `Connected as ${result.displayName ?? ''}` });
} else if (result.error !== 'Cancelled') {
setConnectMsg({ ok: false, text: result.error ?? 'Sign in failed' });
} }
} }
async function disconnect() { async function handleDisconnect() {
await setSetting(db, 'api_token', ''); Alert.alert('Disconnect', 'Remove saved credentials?', [
{ text: 'Cancel', style: 'cancel' },
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
await logout(db);
setConnectMsg(null); setConnectMsg(null);
}},
]);
} }
async function resetSyncedData() { async function resetSyncedData() {
@@ -98,78 +71,36 @@ export default function SettingsScreen() {
<ScrollView style={styles.container} contentContainerStyle={styles.content}> <ScrollView style={styles.container} contentContainerStyle={styles.content}>
<Text style={styles.header}>Settings</Text> <Text style={styles.header}>Settings</Text>
<Section title="Instance">
<Field
label="Instance URL"
placeholder="https://bincio.org"
value={instanceUrl}
onChangeText={setInstanceUrl}
autoCapitalize="none"
keyboardType="url"
/>
<Field
label="Handle"
placeholder="yourhandle"
value={handle}
onChangeText={setHandle}
autoCapitalize="none"
/>
<Text style={styles.hint}>
Connect to a Bincio instance to sync your activities. Leave blank to use
the app offline only.
</Text>
</Section>
<Pressable style={styles.saveButton} onPress={save}>
<Text style={styles.saveButtonText}>
{saved ? '✓ Saved' : 'Save'}
</Text>
</Pressable>
<Section title="Connection"> <Section title="Connection">
{isConnected ? ( {isConnected ? (
<> <>
<Row label="Status" value={`Connected as ${storedHandle || '—'}`} /> <Row label="Status" value={`Connected as ${storedHandle || '—'}`} />
<Pressable style={styles.disconnectButton} onPress={disconnect}> <Pressable style={styles.disconnectButton} onPress={handleDisconnect}>
<Text style={styles.disconnectText}>Disconnect</Text> <Text style={styles.disconnectText}>Disconnect</Text>
</Pressable> </Pressable>
</> </>
) : ( ) : (
<>
<Field
label="Password"
placeholder="••••••••"
value={password}
onChangeText={setPassword}
autoCapitalize="none"
secureTextEntry
/>
<Pressable <Pressable
style={[styles.connectButton, connecting && styles.buttonDisabled]} style={[styles.connectButton, connecting && styles.buttonDisabled]}
onPress={connecting ? undefined : connect} onPress={connecting ? undefined : handleConnect}
> >
{connecting {connecting
? <ActivityIndicator color="#fff" size="small" /> ? <ActivityIndicator color="#fff" size="small" />
: <Text style={styles.connectText}>Connect</Text>} : <Text style={styles.connectText}>Sign in with bincio</Text>}
</Pressable> </Pressable>
</>
)} )}
{connectMsg && ( {connectMsg && (
<Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}> <Text style={connectMsg.ok ? styles.msgOk : styles.msgErr}>
{connectMsg.text} {connectMsg.text}
</Text> </Text>
)} )}
<Text style={styles.hint}>
Your password is used once to obtain a session token, then forgotten.
The token is stored locally and sent with each sync request.
</Text>
</Section> </Section>
{Platform.OS === 'android' && ( {Platform.OS === 'android' && (
<Section title="Auto-import (Android)"> <Section title="Auto-import (Android)">
{!storedUrl ? ( {!isConnected ? (
<Text style={[styles.hint, styles.hintWarn]}> <Text style={[styles.hint, styles.hintWarn]}>
Configure and save a Bincio instance URL above first it's needed to download the extraction engine. Sign in to a Bincio instance above first it's needed to download the extraction engine.
</Text> </Text>
) : ( ) : (
<> <>
@@ -356,11 +287,6 @@ const styles = StyleSheet.create({
}, },
rowLabel: { color: '#a1a1aa', fontSize: 14 }, rowLabel: { color: '#a1a1aa', fontSize: 14 },
rowValue: { color: '#71717a', fontSize: 14 }, rowValue: { color: '#71717a', fontSize: 14 },
saveButton: {
backgroundColor: '#2563eb', borderRadius: 10,
paddingVertical: 14, alignItems: 'center', marginBottom: 28,
},
saveButtonText: { color: '#fff', fontWeight: '600', fontSize: 16 },
connectButton: { connectButton: {
backgroundColor: '#059669', borderRadius: 8, margin: 12, backgroundColor: '#059669', borderRadius: 8, margin: 12,
paddingVertical: 12, alignItems: 'center', paddingVertical: 12, alignItems: 'center',
+47 -4
View File
@@ -10,8 +10,10 @@
"dependencies": { "dependencies": {
"@maplibre/maplibre-react-native": "~11.0.0", "@maplibre/maplibre-react-native": "~11.0.0",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-auth-session": "~7.0.11",
"expo-background-fetch": "~14.0.9", "expo-background-fetch": "~14.0.9",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8", "expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
@@ -22,6 +24,7 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9", "expo-task-manager": "~14.0.9",
"expo-web-browser": "~15.0.11",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
@@ -4561,6 +4564,24 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-auth-session": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-7.0.11.tgz",
"integrity": "sha512-AhWtt/m9rb1Po77X/VBFbeE6UTgbm2vXP2iCblUSRsHCw2qD6lO0ulKUB8Xyxy9FtoI9yrNQ1iwCNgIIgo8VYQ==",
"license": "MIT",
"dependencies": {
"expo-application": "~7.0.8",
"expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-linking": "~8.0.12",
"expo-web-browser": "~15.0.11",
"invariant": "^2.2.4"
},
"peerDependencies": {
"react": "*",
"react-native": "*"
}
},
"node_modules/expo-background-fetch": { "node_modules/expo-background-fetch": {
"version": "14.0.9", "version": "14.0.9",
"resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-14.0.9.tgz", "resolved": "https://registry.npmjs.org/expo-background-fetch/-/expo-background-fetch-14.0.9.tgz",
@@ -4587,6 +4608,18 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-crypto": {
"version": "15.0.9",
"resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-15.0.9.tgz",
"integrity": "sha512-SNWKa2fXx7v9gkp1h/7nqXY5XN7qgNDn3yRc2aO0gWGbeMbvob/haMxxsPFe9f51aqH5NjNCqHf2kvLhvAd8KQ==",
"license": "MIT",
"dependencies": {
"base64-js": "^1.3.0"
},
"peerDependencies": {
"expo": "*"
}
},
"node_modules/expo-document-picker": { "node_modules/expo-document-picker": {
"version": "14.0.8", "version": "14.0.8",
"resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-document-picker/-/expo-document-picker-14.0.8.tgz",
@@ -4631,12 +4664,12 @@
} }
}, },
"node_modules/expo-linking": { "node_modules/expo-linking": {
"version": "8.0.11", "version": "8.0.12",
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.12.tgz",
"integrity": "sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA==", "integrity": "sha512-FpXeIpFgZuxihwT9lBo86YD3y6LphBuAhN680MMxm/Y7fmsc57vimn2d3vFu68VI0+Z9w457t494mu2wvlgWTQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"expo-constants": "~18.0.12", "expo-constants": "~18.0.13",
"invariant": "^2.2.4" "invariant": "^2.2.4"
}, },
"peerDependencies": { "peerDependencies": {
@@ -5027,6 +5060,16 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo-web-browser": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.11.tgz",
"integrity": "sha512-r2LS4Ro6DgUPZkcaEfgt8mp9eJuoA93x11Jh7S6utFe0FEzvUNn2yFhxg8XVwESaaHGt2k5V8LuK36rsp0BeIw==",
"license": "MIT",
"peerDependencies": {
"expo": "*",
"react-native": "*"
}
},
"node_modules/expo/node_modules/@expo/cli": { "node_modules/expo/node_modules/@expo/cli": {
"version": "54.0.23", "version": "54.0.23",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-54.0.23.tgz",
+3
View File
@@ -12,8 +12,10 @@
"dependencies": { "dependencies": {
"@maplibre/maplibre-react-native": "~11.0.0", "@maplibre/maplibre-react-native": "~11.0.0",
"expo": "~54.0.33", "expo": "~54.0.33",
"expo-auth-session": "~7.0.11",
"expo-background-fetch": "~14.0.9", "expo-background-fetch": "~14.0.9",
"expo-constants": "~18.0.13", "expo-constants": "~18.0.13",
"expo-crypto": "~15.0.9",
"expo-document-picker": "~14.0.8", "expo-document-picker": "~14.0.8",
"expo-file-system": "~19.0.21", "expo-file-system": "~19.0.21",
"expo-linking": "~8.0.11", "expo-linking": "~8.0.11",
@@ -24,6 +26,7 @@
"expo-status-bar": "~3.0.9", "expo-status-bar": "~3.0.9",
"expo-system-ui": "~6.0.9", "expo-system-ui": "~6.0.9",
"expo-task-manager": "~14.0.9", "expo-task-manager": "~14.0.9",
"expo-web-browser": "~15.0.11",
"react": "19.1.0", "react": "19.1.0",
"react-dom": "19.1.0", "react-dom": "19.1.0",
"react-native": "0.81.5", "react-native": "0.81.5",
+91
View File
@@ -0,0 +1,91 @@
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', ''),
]);
}