Merge pull request #685 from christianlouis/copilot/fix-sso-browser-widget-issue

fix(mobile): SSO callback closes browser and delivers token to app; Expo Go support; SafeAreaView deprecation
This commit is contained in:
Christian Krakau-Louis
2026-03-15 18:46:01 +01:00
committed by GitHub
4 changed files with 131 additions and 12 deletions
+99 -1
View File
@@ -4,7 +4,7 @@ import logging
import pathlib import pathlib
from datetime import datetime, timezone from datetime import datetime, timezone
from functools import wraps from functools import wraps
from urllib.parse import urlparse from urllib.parse import urlencode, urlparse
from authlib.integrations.starlette_client import OAuth from authlib.integrations.starlette_client import OAuth
from fastapi import APIRouter, Depends, Request, status from fastapi import APIRouter, Depends, Request, status
@@ -254,6 +254,18 @@ def get_gravatar_url(email):
async def login(request: Request): async def login(request: Request):
"""Show login page with appropriate authentication options.""" """Show login page with appropriate authentication options."""
# Persist the mobile deep-link redirect URI in the session so it survives
# the OAuth provider round-trip and is available when auth completes.
# Accepted schemes:
# • "docuelevate://" — production / EAS builds (custom app scheme)
# • "exp://" — Expo Go development client
# Only custom (non-HTTP) schemes are accepted to prevent open-redirect abuse.
_MOBILE_ALLOWED_SCHEMES = ("docuelevate://", "exp://")
if request.query_params.get("mobile") == "1":
redirect_uri = request.query_params.get("redirect_uri", "")
if any(redirect_uri.startswith(s) for s in _MOBILE_ALLOWED_SCHEMES):
request.session["mobile_redirect_uri"] = redirect_uri
return templates.TemplateResponse( return templates.TemplateResponse(
"login.html", "login.html",
{ {
@@ -407,6 +419,12 @@ async def social_callback(request: Request, provider: str, db: Session = Depends
user_id = ( user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id") user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
) )
# Mobile app flow: issue an inline API token and redirect back to the app.
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
return mobile_resp
if user_id: if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
@@ -568,6 +586,14 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)):
user_id = ( user_id = (
user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id") user_data.get("sub") or user_data.get("preferred_username") or user_data.get("email") or user_data.get("id")
) )
# Mobile app flow: issue an inline API token and redirect back to the app.
# This check runs before onboarding so native-app users are never sent
# to the web-based onboarding wizard.
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
return mobile_resp
if user_id: if user_id:
profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
@@ -625,6 +651,66 @@ def _record_login_event(
logger.debug("Failed to write login audit event for user=%s", username, exc_info=True) logger.debug("Failed to write login audit event for user=%s", username, exc_info=True)
def _create_mobile_redirect(request: Request, db: Session) -> RedirectResponse | None:
"""Generate a mobile API token and return a redirect to the mobile app.
If ``mobile_redirect_uri`` is stored in the session (set when the login
page was opened with ``?mobile=1&redirect_uri=docuelevate://...``), this
function creates a long-lived API token, appends it as a ``?token=``
query parameter to the redirect URI, and returns the redirect so that
``WebBrowser.openAuthSessionAsync`` in the Expo app intercepts the
deep link and stores the token.
Returns ``None`` when the request is not part of a mobile SSO flow.
Args:
request: The current FastAPI request. The ``user`` dict must already
be stored in ``request.session`` before calling this function.
db: Active database session used to persist the new API token.
Returns:
A ``RedirectResponse`` to the deep-link URI with ``?token=<plaintext>``,
or ``None`` if no mobile redirect URI is pending.
"""
mobile_redirect_uri = request.session.pop("mobile_redirect_uri", None)
if not mobile_redirect_uri:
return None
user = request.session.get("user") or {}
owner_id = user.get("sub") or user.get("preferred_username") or user.get("email") or user.get("id")
if not owner_id:
logger.warning("Mobile SSO redirect requested but no owner_id could be resolved from session")
return None
# Lazy imports to avoid circular dependency via app.api.__init__
from app.api.api_tokens import generate_api_token, hash_token
from app.models import ApiToken as _ApiToken
plaintext = generate_api_token()
token_hash_value = hash_token(plaintext)
prefix = plaintext[:12]
db_token = _ApiToken(
owner_id=owner_id,
name="Mobile App",
token_hash=token_hash_value,
token_prefix=prefix,
)
try:
db.add(db_token)
db.commit()
except Exception:
db.rollback()
logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
return None
# Safely append the token as a query parameter, preserving any existing params.
separator = "&" if "?" in mobile_redirect_uri else "?"
redirect_url = f"{mobile_redirect_uri}{separator}{urlencode({'token': plaintext})}"
logger.info("[SECURITY] MOBILE_SSO_TOKEN_ISSUED owner=%s token_id=%s", owner_id, db_token.id)
return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND)
async def auth(request: Request, db: Session = Depends(get_db)): async def auth(request: Request, db: Session = Depends(get_db)):
"""Handle local username/password authentication. """Handle local username/password authentication.
@@ -694,6 +780,12 @@ async def auth(request: Request, db: Session = Depends(get_db)):
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email) logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email)
_record_login_event(db, request, local_user.email, success=True) _record_login_event(db, request, local_user.email, success=True)
_ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin)) _ensure_user_profile(db, user_data, is_admin=bool(local_user.is_admin))
# Mobile app flow: issue an inline API token and redirect back to the app.
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
return mobile_resp
profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first() profile = db.query(_UserProfile).filter(_UserProfile.user_id == local_user.email).first()
if profile and not profile.onboarding_completed: if profile and not profile.onboarding_completed:
post_onboarding = request.session.pop("redirect_after_login", "/upload") post_onboarding = request.session.pop("redirect_after_login", "/upload")
@@ -739,6 +831,12 @@ async def auth(request: Request, db: Session = Depends(get_db)):
logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username) logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username)
_record_login_event(db, request, username, success=True) _record_login_event(db, request, username, success=True)
_ensure_user_profile(db, admin_user_data, is_admin=True) _ensure_user_profile(db, admin_user_data, is_admin=True)
# Mobile app flow: issue an inline API token and redirect back to the app.
mobile_resp = _create_mobile_redirect(request, db)
if mobile_resp:
return mobile_resp
redirect_url = request.session.pop("redirect_after_login", "/upload") redirect_url = request.session.pop("redirect_after_login", "/upload")
return RedirectResponse(url=redirect_url, status_code=302) return RedirectResponse(url=redirect_url, status_code=302)
else: else:
+19 -5
View File
@@ -64,11 +64,25 @@ See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for
The mobile app uses the server's existing OAuth2/SSO setup: The mobile app uses the server's existing OAuth2/SSO setup:
1. User enters the DocuElevate server URL on the login screen. 1. User enters the DocuElevate server URL on the login screen.
2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome). 2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome Custom Tabs).
3. The user authenticates via SSO or local credentials. 3. The server stores `docuelevate://callback` in the browser session and presents the login page.
4. The server redirects back to `docuelevate://callback`. 4. The user authenticates via SSO or local credentials.
5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**. 5. After successful authentication the server mints a long-lived API token and redirects the browser to `docuelevate://callback?token=<token>`.
6. The token is stored securely in the device's keychain (`expo-secure-store`). 6. `WebBrowser.openAuthSessionAsync` intercepts the `docuelevate://` deep link and returns the URL to the app.
7. The app extracts the token from the URL and stores it securely in the device's keychain (`expo-secure-store`).
> **Security note:** The `redirect_uri` is validated server-side; only URIs with the `docuelevate://` custom scheme (production) or the `exp://` scheme (Expo Go development) are accepted, preventing open-redirect attacks.
### Testing in Expo Go
When developing with **Expo Go** the app does not have the `docuelevate://` custom URL scheme registered. The auth flow adapts automatically:
1. `Linking.createURL('callback')` returns an `exp://` URI pointing at the local dev server (e.g. `exp://192.168.1.5:8081/--/callback`).
2. This URI is sent to the server as `redirect_uri`; the server accepts it alongside the production `docuelevate://` scheme.
3. After successful authentication the server redirects back to the `exp://` URI.
4. `WebBrowser.openAuthSessionAsync` intercepts the deep link and the Expo Go app receives the token.
No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app.
### Auto-generated Mobile Token ### Auto-generated Mobile Token
+12 -5
View File
@@ -8,6 +8,7 @@
* token via POST /api/mobile/generate-token. * token via POST /api/mobile/generate-token.
*/ */
import * as Linking from "expo-linking";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser"; import * as WebBrowser from "expo-web-browser";
import React, { import React, {
@@ -104,13 +105,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
await api.init(cleanUrl); await api.init(cleanUrl);
setBaseUrl(cleanUrl); setBaseUrl(cleanUrl);
// Compute the correct redirect URI for the current runtime:
// • Expo Go (development) → exp://<host>:<port>/--/callback
// • Standalone / EAS build → docuelevate://callback
// Both schemes are accepted by the server; using Linking.createURL
// ensures the browser deep-link resolves correctly in every environment.
const redirectUri = Linking.createURL("callback");
// Open the web login page in the system browser. The user authenticates // Open the web login page in the system browser. The user authenticates
// via SSO or local credentials, then the app deep-link (docuelevate://callback) // via SSO or local credentials, then the app deep-link is triggered.
// is triggered. The WebBrowser.openAuthSessionAsync handles the redirect // The WebBrowser.openAuthSessionAsync handles the redirect back to the app.
// back to the app.
const result = await WebBrowser.openAuthSessionAsync( const result = await WebBrowser.openAuthSessionAsync(
`${cleanUrl}/login?mobile=1&redirect_uri=docuelevate://callback`, `${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`,
"docuelevate://callback" redirectUri
); );
if (result.type !== "success") { if (result.type !== "success") {
+1 -1
View File
@@ -10,12 +10,12 @@ import React from "react";
import { import {
Image, Image,
Pressable, Pressable,
SafeAreaView,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Text, Text,
View, View,
} from "react-native"; } from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
const FEATURES: { icon: string; title: string; description: string }[] = [ const FEATURES: { icon: string; title: string; description: string }[] = [
{ {