docs/mobile-auth/01-research.md
Status: draft · Task: mobile-auth
Build authentication for the Onyx mobile app (Expo / React Native) in the spirit of the desktop app — email/password + Google OAuth in V1, with SAML + OIDC (and Apple Sign In) added later — validated against industry best practices and security standards.
Q1 — The desktop "pattern" is a thin WebView (cookie auth, no native code); the mobile foundation already chose native + Bearer-token + secure-store. Which path?
→ Native auth. Native login screens + Bearer token from expo-secure-store; reuse the SAME backend, adding small mobile-friendly token endpoints. OAuth/SAML/OIDC run in the system browser and return via deep link. (Explicitly NOT the webview-cookie approach.)
Q2 — Which backend(s) must V1 target? (Decides the OAuth redirect strategy.)
→ Cloud + self-hosted both first-class in V1, whatever complexity that adds. (This rules out relying on Universal/App Links alone — they only cover cloud.onyx.app — and pushes us to a host-agnostic backend SSO-bridge + deep-link return.)
Q3 — App Store Guideline 4.8 requires Sign in with Apple if Google is offered. Include SIWA in V1? → No. Google + email/password only for V1; handle Apple later / accept the App Store-rejection risk. (We still build the OAuth layer provider-generic so Apple slots in cheaply.)
backend/) — fastapi-users 12.x, cookie-transport only todaybackend/onyx/db/models.py:307-463. UserManager + FastAPIUsers setup in backend/onyx/auth/users.py.POST /auth/login (OAuth2PasswordRequestForm) → Set-Cookie fastapiusersauth (HttpOnly, samesite=lax, secure if https), 7-day expiry. Router reg backend/onyx/main.py:572-577. cookie_transport backend/onyx/auth/users.py:1326-1330.CookieTransport: redis (users.py:1549), postgres (:1553), jwt (:1557). No BearerTransport is mounted. The session strategies are transport-agnostic, though — write_token/read_token/destroy_token/refresh_token operate on raw token strings (users.py:1363/1384/1400/1405). So a Bearer transport / token-returning endpoint can be added cleanly, but it is net-new work — the earlier "bearer already supported" note was inaccurate.AUTH_BACKEND env = redis|postgres|jwt; strategy selection users.py:1539-1560. Redis/Postgres = stateful, server-side, revocable opaque tokens (secrets.token_urlsafe, 7-day TTL = SESSION_EXPIRE_TIME_SECONDS). JWT = stateless, not revocable (its own docstring concedes this, users.py:1463-1509); JWT is forbidden in multi-tenant/cloud (users.py:1540).get_refresh_router users.py:1600-1681 (POST /auth/refresh); TenantAwareRedisStrategy.refresh_token :1405-1428 extends TTL in place; returns via backend.transport.get_login_response (:1661). Web calls it every 10 min (not for SAML/OIDC).GET /auth/oauth/authorize → {authorization_url} (sets STATE+CSRF cookies); GET /auth/oauth/callback exchanges code, upserts user by email, calls backend.login() → cookie. Redirect hardcoded f'{WEB_DOMAIN}/auth/oauth/callback'. Code users.py:2183-2521; complete_login_flow users.py:2523-2607; reg main.py:601-626. Google path is non-PKCE (the client secret lives server-side — correct for a public client).GET /auth/oidc/{authorize,callback}; PKCE optional via OIDC_PKCE_ENABLED (gen users.py:2278-2295); reg main.py:636-665.backend/onyx/server/saml.py:35-298): /auth/saml/authorize → {authorization_url}, /auth/saml/callback (GET+POST), /auth/saml/logout; extracts email, upserts, backend.login() → cookie. Reg main.py:674-678. No native mobile SDK exists for SAML.{basic, google_oauth, oidc, saml, cloud} backend/onyx/configs/constants.py:314-321. Frontend discovers config via GET /auth/type (AuthTypeMetadata: auth_type, requires_verification, password_min_length, oauth_enabled, …) backend/onyx/server/manage/get_state.py:34-59./me: backend/onyx/server/manage/users.py:885-954 → UserInfo (models.py:122-202); checks oidc_expiry.CORS_ALLOWED_ORIGIN env; '*' disables credentials (shared_configs/configs.py:146-170, main.py:699-712). Irrelevant for a native client (no browser origin), but relevant if a webview were ever used.OnyxError (not HTTPException); new FastAPI endpoints are typed, no response_model; DB ops only under backend/onyx/db / backend/ee/onyx/db; alembic migrations written by hand.mobile/) — Expo SDK 56, RN 0.85, React 19.2, Expo Router, TanStack Query v5 + MMKV, Zustand, NativeWind v4, BunapiFetch mobile/src/api/client.ts:41-43 already injects Authorization: Bearer <token> from getToken() (auth=true default); base URL from EXPO_PUBLIC_API_URL (mobile/src/api/config.ts:21, build-time, "runtime URL is future work"); errors normalized to ApiError (mobile/src/api/errors.ts, isAuthError() → 401/402/403).mobile/src/api/auth/tokenStore.ts: expo-secure-store, key onyx.auth.access_token, getToken()/setToken(). Documented TODOs: clear query cache + persister on logout; identity-scope the cache; encrypt the MMKV query cache (PII).useCurrentUser mobile/src/hooks/useCurrentUser.ts → GET /api/me, query key ['me']. CurrentUser type minimal (mobile/src/api/types.ts).mobile/src/app/_layout.tsx is a bare Stack (PersistQueryClientProvider) — NO auth-gate, no (auth) group, no redirect-to-login yet. index.tsx is home.mobile/src/query/client.ts: retry skips 401/402/403. focus.ts/online.ts bridge AppState/NetInfo. MMKV instances (app + query-cache) mobile/src/state/storage.ts.app.json: scheme onyx; bundle app.onyx.mobile. Deps present: expo-secure-store ~56.0.4, expo-linking ~56.0.13, expo-constants. ABSENT: expo-auth-session, expo-web-browser, expo-crypto.@onyx-ai/shared/native: design tokens (varsLight/varsDark), types Page<T>/Result<T>.desktop/) — reference only, do NOT copyserver_url (default https://cloud.onyx.app, user-configurable via config.json, src-tauri/src/main.rs:35,285). Zero native auth code — login happens inside the webview via the web app's cookie session. Its one durable lesson for us: "configurable server URL" (cloud + self-hosted) is an established product expectation.expo-auth-session + expo-web-browser implement the compliant path. — https://docs.expo.dev/guides/authentication/Authorization header, not cookies, for native. Never put a token in a deep link — return a short-lived single-use code on the deep link and exchange it over TLS. — https://reactnative.dev/docs/securityexpo-secure-store (iOS Keychain / Android Keystore); never AsyncStorage/unencrypted MMKV (OWASP Mobile M1 Improper Credential Usage / M9 Insecure Storage). iOS Keychain persists across app uninstall and has no bulk-clear → delete explicitly on logout; use keychainAccessible *_THIS_DEVICE_ONLY. — https://mas.owasp.org/MASVS/ , https://docs.expo.dev/versions/latest/sdk/securestore/cloud.onyx.app, NOT arbitrary self-hosted. For self-hosted, a custom scheme (onyx://) + PKCE + state + single-use short-TTL code + exact redirect-URI matching is the pragmatic mitigation. Beware ASWebAuthenticationSession prompt=none silent-auth hijack — require user consent. — https://evanconnelly.github.io/post/ios-oauth/AuthSession.makeRedirectUri(); re-run prebuild after changing expo.scheme; register exact redirect URIs per build.textContentType.Reuse the existing stateful fastapi-users session token verbatim as the mobile Bearer value. Add the minimum: a POST /auth/login/mobile that returns the same session token as JSON (instead of Set-Cookie), and a one-time-code branch in complete_login_flow that, for client=mobile, mints a 60 s single-use Redis code and 302s to onyx://auth/callback?code=… (the app exchanges it at POST /auth/oauth/mobile/exchange). Refresh/logout reuse the existing /auth/refresh + /auth/logout (sliding 7-day TTL, server-revocable). On mobile: login screens, an auth-gate around the bare Stack, a deep-link handler, and the tokenStore hardening TODOs. No new token type, table, or migration.
AUTH_BACKEND=jwt self-hosted has no Redis session — must gate or store the code in Postgres); multi-tenant login must run in the tenant middleware context.Build a purpose-built mobile token subsystem: short-lived (~10 min) opaque access token + rotating refresh token bound to a per-device token family with reuse detection, exposed at /auth/mobile/{token,refresh,revoke,sessions}. New mobile_session table (one row per device → per-device revocation, a device-session list, audit fields: last_ip/UA/last_used_at) + hand-written migration; a MobileTokenService; a BearerTransport-backed authenticator composed with the existing cookie authenticator. OAuth/SAML/OIDC reuse the existing browser endpoints, swapping only the final "set cookie + 302 to WEB_DOMAIN" step for "mint one-time PKCE-bound code + 302 to the app." Mobile adds single-flight proactive refresh, biometric-gated refresh-token storage, and the auth-gate.
Introduce one thin gateway in front of the existing machinery rather than forking it: a uniform browser-SSO pair GET /auth/mobile/sso/{start,exchange} + native bearer endpoints POST /auth/mobile/{login,refresh,logout}. Two seams carry all the variation: a backend BrowserSSOProvider registry (V1 = Google wrapping the existing OAuth logic; SAML/OIDC/Apple = later registry entries) and a one-function issue_session_credential(user) token seam (V1 wraps the existing stateful token via a mounted BearerTransport; a future rotation subsystem drops in behind it with no gateway/mobile change). Mobile gets one SessionManager + a providerRegistry (each provider = a tiny descriptor) over the existing apiFetch/tokenStore. Adding a provider later = one backend provider class + one mobile registry row + a button.
AuthenticationBackend can collide with the cookie backend's route names (must namespace + not re-mount the stock auth router); same multi-tenant + custom-scheme-hijack caveats as A/B; one extra indirection layer for a 2-provider V1.Authorization: Bearer (reuse apiFetch); expo-secure-store only; OAuth in the system browser (expo-web-browser) with a host-agnostic backend one-time-code + onyx:// deep-link return (the only redirect strategy that serves cloud and self-hosted); add an auth-gate + (auth) route group; add expo-auth-session/expo-web-browser/expo-crypto; resolve the tokenStore logout/cache TODOs; needs an EAS Dev Build.issue_session_credential seam) — they differ mainly in whether that security is built now (B) or seamed-for-later (A/C), and in how provider-extensibility is structured (ad-hoc in A, registry in C, generic-bridge in B).Approach C — Mobile Auth Gateway (BFF) with a Provider Registry + Token-Issuance Seam.
Rationale: the user's two stated futures — (1) more providers (SAML/OIDC/Apple) and (2) cloud + self-hosted both first-class — are exactly the changes C makes config-shaped (provider registry rows + runtime base URL), while reusing Onyx's existing, battle-tested auth core and keeping B's security as a clean upgrade path behind issue_session_credential.
V1 credential decision: reuse the existing revocable stateful session token, presented as a Bearer (the seam keeps rotation deferrable). Rotation/reuse-detection (Approach B's subsystem) is explicitly deferred, not built — it lands later behind the issue_session_credential seam and a new /auth/mobile/refresh body, with no gateway or mobile rework.