diff --git a/admin/index.html b/admin/index.html index b86dfbf..dd1921f 100644 --- a/admin/index.html +++ b/admin/index.html @@ -231,7 +231,7 @@ // ── tiny utils ────────────────────────────────────────────────────────── const $ = (sel) => document.querySelector(sel); const fmtDate = (d) => d ? new Date(d).toLocaleString() : '—'; -const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>'); +const esc = (s) => String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); const api = { async request(method, path, body) { @@ -240,13 +240,24 @@ opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } - const r = await fetch(path, opts); - if (!r.ok) { - const txt = await r.text().catch(() => ''); - throw new Error(`${method} ${path} → ${r.status}: ${txt}`); + try { + const r = await fetch(path, opts); + if (r.status === 401) { + window.location.href = '/'; + return; + } + if (!r.ok) { + const txt = await r.text().catch(() => ''); + throw new Error(`${method} ${path} → ${r.status}: ${txt}`); + } + if (r.status === 204) return null; + return r.json(); + } catch (err) { + if (err.name === 'TypeError') { + throw new Error('Network error — check your connection or server availability'); + } + throw err; } - if (r.status === 204) return null; - return r.json(); }, get(path) { return this.request('GET', path); }, patch(path, body) { return this.request('PATCH', path, body); }, @@ -588,7 +599,7 @@ function renderProfiles(profiles) { if (!profiles.length) return '
No profiles.
'; let html = ''; - ['ID','Name','Description','Admin Only','Actions'].forEach(h => html += ``); + ['ID','Name','Description','Admin Only'].forEach(h => html += ``); html += ''; for (const p of profiles) { html += ` @@ -601,7 +612,6 @@ - `; } html += '
${h}${h}
${p.is_admin_only ? 'Yes' : 'No'}
'; diff --git a/docs/android-client.md b/docs/android-client.md index b5f8d66..d939f29 100644 --- a/docs/android-client.md +++ b/docs/android-client.md @@ -74,6 +74,57 @@ Use this for any future UI differences between the web client and the Android app (hiding elements, adjusting layout, etc.). +## OAuth / login flow + +Android WebView **does not share cookies** with the system browser. When the user clicks **Login**, the app opens gnexus-auth in the user's default browser (Chrome, DuckDuckGo, etc.) so SSO works. After successful authentication, the backend redirects the browser to `/auth/mobile-done?sid=` instead of setting a cookie. + +`/auth/mobile-done` is a bridge page styled with the gnexus UI kit. It: +1. Immediately attempts `window.location.href = "intent://auth/callback?sid=xxx#Intent;scheme=navi;package=com.navi.client;end"` — Chrome understands this and opens the app automatically. +2. If the browser blocks the redirect (DuckDuckGo, Firefox), a styled fallback button **"Open Navi App"** appears after 1.5s. Tapping it triggers the same Intent URL with a user gesture, which the browser cannot block. + +### Deep-link intent filter + +`AndroidManifest.xml` declares an intent filter for `navi://auth/callback`: + +```xml + + + + + + +``` + +The activity uses `launchMode="singleTask"` so `onNewIntent` is called even when the app is already running. + +### Cookie injection + +`MainActivity` handles the incoming intent in both `onCreate` (cold start) and `onNewIntent` (app already running): + +```kotlin +override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + intent.data?.let { uri -> + if (uri.scheme == "navi" && uri.host == "auth" && uri.path == "/callback") { + val sid = uri.getQueryParameter("sid") + val prefs = getSharedPreferences("navi", Context.MODE_PRIVATE) + val serverUrl = prefs.getString("server_url", null) + if (sid != null && serverUrl != null) { + CookieManager.getInstance().setCookie( + serverUrl, + "navi_auth_session=$sid; Path=/" + ) + webView.loadUrl(serverUrl) + } + } + } +} +``` + +After the cookie is set, the WebView reloads the server URL and the user is authenticated. + +--- + ## Back navigation Hardware back button navigates WebView history if available (`webView.canGoBack()`), otherwise falls through to the default Android behaviour (closes the activity). diff --git a/docs/api.md b/docs/api.md index 69e3d72..fb64dc1 100644 --- a/docs/api.md +++ b/docs/api.md @@ -33,19 +33,32 @@ #### `GET /auth/callback` -OAuth callback. Validates state, exchanges code for tokens, creates DB session, sets cookie. +OAuth callback. Validates state, exchanges code for tokens, creates DB session. **Query params** - `code` — authorization code from gnexus-auth - `state` — state parameter -**Response `302`** → Location: `/` +**Response `302`** +- **Browser** → Location: `/` (with `Set-Cookie`) +- **Android** → Location: `/auth/mobile-done?sid=` **Errors** - `400` — invalid state, PKCE failure, or token exchange failed --- +#### `GET /auth/mobile-done` + +Bridge page for Android OAuth. Renders HTML that attempts an automatic deep-link back into the native app via Chrome Intent URL (`intent://...`), and falls back to a manual button for browsers that block automatic navigation to custom schemes (e.g. DuckDuckGo). Styled with the gnexus UI kit design system. + +**Query params** +- `sid` — session id that will become the `navi_auth_session` cookie + +**Response `200`** — HTML page + +--- + #### `POST /auth/logout` Logout current user. Deletes DB session and clears cookie. diff --git a/docs/auth.md b/docs/auth.md index bf5e37a..69d9dfc 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -101,7 +101,7 @@ ## OAuth login flow -### 1. Initiate login +### Browser flow (default) ``` GET /auth/login @@ -112,6 +112,44 @@ 2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`) 3. Returns `302` redirect to gnexus-auth `/oauth/authorize` +After the user logs in at gnexus-auth, they are redirected back to `/auth/callback`, where the backend exchanges the code, creates a DB session, sets an `httpOnly` cookie, and redirects to `/`. + +### Android flow (WebView bridge) + +Android WebView **cannot share cookies** with the system browser (Chrome, DuckDuckGo, etc.). Therefore the Android app uses a **bridge-page flow** that avoids cookies entirely: + +``` +GET /auth/login ← Backend detects Android via User-Agent (NaviAndroid/1.0) + ↓ 302 +gnexus-auth /oauth/authorize ← Opens in external browser (SSO works) + ↓ +GET /auth/callback?code=...&state=... + ↓ 302 +/auth/mobile-done?sid= + ↓ JS redirect +intent://auth/callback?sid=...#Intent;scheme=navi;package=com.navi.client;end + ↓ +Android intercepts intent → CookieManager.setCookie() → reload WebView +``` + +Key points: +- Backend detects Android from the HTTP `User-Agent` header (`NaviAndroid/1.0`) rather than a query parameter. +- For Android, `/auth/callback` **does not set a cookie** — instead it redirects to `/auth/mobile-done`. +- `/auth/mobile-done` renders a bridge page styled with the gnexus UI kit (dark theme, success accent border). It immediately attempts a Chrome Intent URL auto-redirect; if the browser blocks it, a fallback button appears after 1.5s. +- The `intent://` URL is intercepted by Android's `intent-filter` for `navi://auth/callback`, which delivers the `sid` to `MainActivity.onNewIntent`. The app then sets the `navi_auth_session` cookie via `CookieManager` and reloads the WebView. + +### 1. Initiate login + +``` +GET /auth/login +``` + +Navi backend: +1. Calls `client.build_authorization_request(return_to="/", scopes=["openid", "email", "profile", "roles", "permissions"])` +2. Stores PKCE verifier and state in `InMemoryPkceStore` / `InMemoryStateStore` (from `gnexus-auth-client-py`) +3. **For Android**: stores `platform=android` alongside the state metadata so callback knows to skip the cookie +4. Returns `302` redirect to gnexus-auth `/oauth/authorize` + ### 2. User logs in at gnexus-auth User authenticates with gnexus-auth, approves scopes, and is redirected back to: @@ -129,8 +167,8 @@ 4. Determines `role` from `role_ids` and collects `permissions` from `permission_ids` 5. **Upserts** `navi_users` row (id, email, display_name, role, permissions) 6. **Creates** `user_auth_sessions` row with encrypted tokens -7. Sets `navi_auth_session` cookie -8. Redirects to `/` +7. **Browser**: sets `navi_auth_session` cookie and redirects to `/` +8. **Android**: redirects to `/auth/mobile-done?sid=` ### 4. Subsequent requests diff --git a/webclient/src/api/index.js b/webclient/src/api/index.js index 36a49da..904c607 100644 --- a/webclient/src/api/index.js +++ b/webclient/src/api/index.js @@ -105,14 +105,3 @@ return res.json() } -// ─── Admin ───────────────────────────────────────────────────────────────── -export function getAdminUsers() { return request('GET', '/admin/users') } -export function getAdminSessions() { return request('GET', '/admin/sessions') } -export function getAdminMemory() { return request('GET', '/admin/memory') } -export function getAdminProfiles() { return request('GET', '/admin/profiles') } -export function getAdminSession(id) { return request('GET', `/admin/sessions/${id}`) } -export function deleteAdminSession(id) { return request('DELETE', `/admin/sessions/${id}`) } -export function getAdminUser(id) { return request('GET', `/admin/users/${id}`) } -export function getAdminUserSessions(id) { return request('GET', `/admin/users/${id}/sessions`) } -export function updateUserRole(id, role) { return request('PATCH', `/admin/users/${id}/role`, { role }) } -export function updateProfileAvailability(id, isAdminOnly) { return request('PATCH', `/admin/profiles/${id}/availability`, { is_admin_only: isAdminOnly }) }