diff --git a/app.json b/app.json index 5299a5b..553430b 100644 --- a/app.json +++ b/app.json @@ -2,6 +2,7 @@ "expo": { "name": "bincio-rec", "slug": "bincio-rec", + "scheme": "bincio-rec", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", @@ -12,7 +13,9 @@ "infoPlist": { "NSLocationWhenInUseUsageDescription": "bincio-rec uses your location to record your activity track.", "NSLocationAlwaysAndWhenInUseUsageDescription": "bincio-rec uses your location in the background to record your activity track.", - "UIBackgroundModes": ["location"] + "UIBackgroundModes": [ + "location" + ] } }, "android": { @@ -39,7 +42,9 @@ "web": { "favicon": "./assets/favicon.png" }, - "ignorePaths": ["CLAUDE.md"], + "ignorePaths": [ + "CLAUDE.md" + ], "plugins": [ "expo-sqlite", [ @@ -56,7 +61,8 @@ "icon": "./assets/icon.png", "color": "#3b82f6" } - ] + ], + "expo-web-browser" ] } } diff --git a/package-lock.json b/package-lock.json index c6d08c1..6a889e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,9 +14,10 @@ "@react-navigation/native": "^7.2.5", "@react-navigation/native-stack": "^7.16.0", "expo": "~56.0.8", - "expo-av": "^16.0.8", + "expo-auth-session": "~56.0.13", "expo-crypto": "^56.0.4", "expo-file-system": "~56.0.7", + "expo-intent-launcher": "~56.0.4", "expo-keep-awake": "~56.0.3", "expo-location": "~56.0.15", "expo-notifications": "~56.0.15", @@ -24,12 +25,14 @@ "expo-sqlite": "~56.0.4", "expo-status-bar": "~56.0.4", "expo-task-manager": "~56.0.16", + "expo-web-browser": "~56.0.5", "react": "19.2.3", "react-native": "0.85.3", "react-native-ble-plx": "^3.5.1", "react-native-gesture-handler": "~2.31.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", "zustand": "^5.0.14" }, "devDependencies": { @@ -2441,6 +2444,12 @@ "node": ">=0.6" } }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "license": "ISC" + }, "node_modules/bplist-creator": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/bplist-creator/-/bplist-creator-0.1.0.tgz", @@ -2842,6 +2851,56 @@ "node": ">= 8" } }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-tree": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-1.1.3.tgz", + "integrity": "sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.0.14", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/css-tree/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -2929,6 +2988,61 @@ "integrity": "sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==", "license": "MIT" }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2956,6 +3070,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-stack-parser": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", @@ -3090,21 +3216,22 @@ "expo": "*" } }, - "node_modules/expo-av": { - "version": "16.0.8", - "resolved": "https://registry.npmjs.org/expo-av/-/expo-av-16.0.8.tgz", - "integrity": "sha512-cmVPftGR/ca7XBgs7R6ky36lF3OC0/MM/lpgX/yXqfv0jASTsh7AYX9JxHCwFmF+Z6JEB1vne9FDx4GiLcGreQ==", + "node_modules/expo-auth-session": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-auth-session/-/expo-auth-session-56.0.13.tgz", + "integrity": "sha512-LR8Suq8BHKRFBUcAKTMmZufCcDcr0sQa8rIYit1r7kshrqAy9glIUU4aqHt8tflW/ISN0x1vU+HU8AQaackM0A==", "license": "MIT", - "peerDependencies": { - "expo": "*", - "react": "*", - "react-native": "*", - "react-native-web": "*" + "dependencies": { + "expo-application": "~56.0.3", + "expo-constants": "~56.0.16", + "expo-crypto": "~56.0.4", + "expo-linking": "~56.0.13", + "expo-web-browser": "~56.0.5", + "invariant": "^2.2.4" }, - "peerDependenciesMeta": { - "react-native-web": { - "optional": true - } + "peerDependencies": { + "react": "*", + "react-native": "*" } }, "node_modules/expo-constants": { @@ -3139,6 +3266,15 @@ "react-native": "*" } }, + "node_modules/expo-intent-launcher": { + "version": "56.0.4", + "resolved": "https://registry.npmjs.org/expo-intent-launcher/-/expo-intent-launcher-56.0.4.tgz", + "integrity": "sha512-ZqRMuPunNSucK9kRbtX+I8bD/OhY0moGNnHKHCWx//1BZ+HyKBCVw6OX12gWHjowz1AhcSVeAasYt48Y+tXPwQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-keep-awake": { "version": "56.0.3", "resolved": "https://registry.npmjs.org/expo-keep-awake/-/expo-keep-awake-56.0.3.tgz", @@ -3149,6 +3285,20 @@ "react": "*" } }, + "node_modules/expo-linking": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-56.0.13.tgz", + "integrity": "sha512-38YrpTh6xdiDxmYSDIUffDqev1hIcEggw2fZ3IZhNp2DVLF1xvqsbO6hJD1fuBKN8P34B3Ggc9Yy26fkqdfCOA==", + "license": "MIT", + "dependencies": { + "expo-constants": "~56.0.16", + "invariant": "^2.2.4" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/expo-location": { "version": "56.0.15", "resolved": "https://registry.npmjs.org/expo-location/-/expo-location-56.0.15.tgz", @@ -3287,6 +3437,16 @@ "react-native": "*" } }, + "node_modules/expo-web-browser": { + "version": "56.0.5", + "resolved": "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-56.0.5.tgz", + "integrity": "sha512-kaN+wcR5lHwPCH1IgrU1XyPUQvBRzdF1TMp65uAF9iUCyipqYnmrvV87eqAmrdkFFopWVgU7FcxPu1UZw+gvUQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo/node_modules/@expo/cli": { "version": "56.1.13", "resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.13.tgz", @@ -4681,6 +4841,12 @@ "integrity": "sha512-ocnPZQLNpvbedwTy9kNrQEsknEfgvcLMvOtz3sFeWApDq1MXH1TqkCIx58xlpESsfwQOnuBO9beyQuNGzVvuhQ==", "license": "Apache-2.0" }, + "node_modules/mdn-data": { + "version": "2.0.14", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.14.tgz", + "integrity": "sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==", + "license": "CC0-1.0" + }, "node_modules/memoize-one": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz", @@ -5189,6 +5355,18 @@ "node": "^16.14.0 || >=18.0.0" } }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, "node_modules/nullthrows": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", @@ -5761,6 +5939,21 @@ "react-native": ">=0.82.0" } }, + "node_modules/react-native-svg": { + "version": "15.15.4", + "resolved": "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.15.4.tgz", + "integrity": "sha512-boT/vIRgj6zZKBpfTPJJiYWMbZE9duBMOwPK6kCSTgxsS947IFMOq9OgIFkpWZTB7t229H24pDRkh3W9ZK/J1A==", + "license": "MIT", + "dependencies": { + "css-select": "^5.1.0", + "css-tree": "^1.1.3", + "warn-once": "0.1.1" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + } + }, "node_modules/react-native/node_modules/commander": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", diff --git a/package.json b/package.json index 774fedb..0fd25b0 100644 --- a/package.json +++ b/package.json @@ -9,9 +9,10 @@ "@react-navigation/native": "^7.2.5", "@react-navigation/native-stack": "^7.16.0", "expo": "~56.0.8", - "expo-av": "^16.0.8", + "expo-auth-session": "~56.0.13", "expo-crypto": "^56.0.4", "expo-file-system": "~56.0.7", + "expo-intent-launcher": "~56.0.4", "expo-keep-awake": "~56.0.3", "expo-location": "~56.0.15", "expo-notifications": "~56.0.15", @@ -19,12 +20,14 @@ "expo-sqlite": "~56.0.4", "expo-status-bar": "~56.0.4", "expo-task-manager": "~56.0.16", + "expo-web-browser": "~56.0.5", "react": "19.2.3", "react-native": "0.85.3", "react-native-ble-plx": "^3.5.1", "react-native-gesture-handler": "~2.31.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", + "react-native-svg": "15.15.4", "zustand": "^5.0.14" }, "devDependencies": { diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index e18734f..6b62f12 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { - View, Text, TextInput, StyleSheet, Switch, + View, Text, StyleSheet, Switch, TouchableOpacity, Alert, ScrollView, ActivityIndicator, } from 'react-native'; import AsyncStorage from '@react-native-async-storage/async-storage'; @@ -131,34 +131,28 @@ function AppTab() { function SyncTab() { const { accent } = useTheme(); - const [instanceUrl, setInstanceUrl] = useState(''); - const [handle, setHandle] = useState(''); - const [password, setPassword] = useState(''); const [connectedAs, setConnectedAs] = useState(null); const [connecting, setConnecting] = useState(false); useEffect(() => { loadAuthState().then((auth) => { - if (auth) { setInstanceUrl(auth.instanceUrl); setHandle(auth.handle); setConnectedAs(auth.handle); } + if (auth) setConnectedAs(auth.handle); }); }, []); async function handleConnect() { - if (!instanceUrl.trim()) { Alert.alert('Required', 'Enter the instance URL.'); return; } - if (!handle.trim()) { Alert.alert('Required', 'Enter your handle.'); return; } - if (!password) { Alert.alert('Required', 'Enter your password.'); return; } setConnecting(true); - const result = await login(instanceUrl.trim(), handle.trim(), password); + const result = await login(); setConnecting(false); - if (result.ok) { setConnectedAs(result.displayName || handle.trim()); setPassword(''); } - else Alert.alert('Login failed', result.error ?? 'Unknown error'); + if (result.ok) setConnectedAs(result.displayName ?? ''); + else if (result.error !== 'Cancelled') Alert.alert('Sign in failed', result.error ?? 'Unknown error'); } async function handleDisconnect() { Alert.alert('Disconnect', 'Remove saved credentials?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Disconnect', style: 'destructive', onPress: async () => { - await logout(); setConnectedAs(null); setHandle(''); setPassword(''); + await logout(); setConnectedAs(null); }}, ]); } @@ -167,18 +161,6 @@ function SyncTab() { bincio instance - Instance URL - - {connectedAs ? ( @@ -191,16 +173,9 @@ function SyncTab() { ) : ( <> - Handle - - - Password - - - Your password is used once to obtain a session token, then forgotten. - + Sign in with your bincio account to sync recordings. - {connecting ? : Connect} + {connecting ? : Sign in with bincio} )} diff --git a/src/services/auth.ts b/src/services/auth.ts index e955c7b..a7aa255 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -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 { + const bytes = await Crypto.getRandomBytesAsync(32); + return btoa(String.fromCharCode(...bytes)) + .replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); +} + +async function deriveChallenge(verifier: string): Promise { + 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 { - 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 { + 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 {