feat: replace password login with OIDC PKCE flow
This commit is contained in:
@@ -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"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-103
@@ -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`, {
|
setConnecting(false);
|
||||||
method: 'POST',
|
if (result.ok) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
setConnectMsg({ ok: true, text: `Connected as ${result.displayName ?? ''}` });
|
||||||
body: JSON.stringify({ handle: h, password }),
|
} else if (result.error !== 'Cancelled') {
|
||||||
});
|
setConnectMsg({ ok: false, text: result.error ?? 'Sign in failed' });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function disconnect() {
|
async function handleDisconnect() {
|
||||||
await setSetting(db, 'api_token', '');
|
Alert.alert('Disconnect', 'Remove saved credentials?', [
|
||||||
setConnectMsg(null);
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Disconnect', style: 'destructive', onPress: async () => {
|
||||||
|
await logout(db);
|
||||||
|
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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<Pressable
|
||||||
<Field
|
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
||||||
label="Password"
|
onPress={connecting ? undefined : handleConnect}
|
||||||
placeholder="••••••••"
|
>
|
||||||
value={password}
|
{connecting
|
||||||
onChangeText={setPassword}
|
? <ActivityIndicator color="#fff" size="small" />
|
||||||
autoCapitalize="none"
|
: <Text style={styles.connectText}>Sign in with bincio</Text>}
|
||||||
secureTextEntry
|
</Pressable>
|
||||||
/>
|
|
||||||
<Pressable
|
|
||||||
style={[styles.connectButton, connecting && styles.buttonDisabled]}
|
|
||||||
onPress={connecting ? undefined : connect}
|
|
||||||
>
|
|
||||||
{connecting
|
|
||||||
? <ActivityIndicator color="#fff" size="small" />
|
|
||||||
: <Text style={styles.connectText}>Connect</Text>}
|
|
||||||
</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',
|
||||||
|
|||||||
Generated
+47
-4
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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', ''),
|
||||||
|
]);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user