From 5c373e72243ab18a4bb0f25bb23379ef6ccfa6fc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 15 Mar 2026 17:35:49 +0000 Subject: [PATCH] fix(auth): store mobile_redirect_uri in session and redirect to deep-link after SSO; fix SafeAreaView deprecation Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/auth.py | 92 ++++++++++++++++++++++++++++ docs/MobileApp.md | 13 ++-- mobile/src/screens/WelcomeScreen.tsx | 2 +- 3 files changed, 101 insertions(+), 6 deletions(-) diff --git a/app/auth.py b/app/auth.py index 5cb08abd..92937edd 100644 --- a/app/auth.py +++ b/app/auth.py @@ -254,6 +254,14 @@ def get_gravatar_url(email): async def login(request: Request): """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. + # Only the custom ``docuelevate://`` scheme is accepted to prevent open-redirect abuse. + if request.query_params.get("mobile") == "1": + redirect_uri = request.query_params.get("redirect_uri", "") + if redirect_uri.startswith("docuelevate://"): + request.session["mobile_redirect_uri"] = redirect_uri + return templates.TemplateResponse( "login.html", { @@ -407,6 +415,12 @@ async def social_callback(request: Request, provider: str, db: Session = Depends user_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: profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() if profile and not profile.onboarding_completed: @@ -568,6 +582,14 @@ async def oauth_callback(request: Request, db: Session = Depends(get_db)): user_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: profile = db.query(_UserProfile).filter(_UserProfile.user_id == user_id).first() if profile and not profile.onboarding_completed: @@ -625,6 +647,64 @@ def _record_login_event( 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=``, + 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 # noqa: PLC0415 + from app.models import ApiToken as _ApiToken # noqa: PLC0415 + + 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 + + redirect_url = f"{mobile_redirect_uri}?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)): """Handle local username/password authentication. @@ -694,6 +774,12 @@ async def auth(request: Request, db: Session = Depends(get_db)): logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", local_user.email) _record_login_event(db, request, local_user.email, success=True) _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() if profile and not profile.onboarding_completed: post_onboarding = request.session.pop("redirect_after_login", "/upload") @@ -739,6 +825,12 @@ async def auth(request: Request, db: Session = Depends(get_db)): logger.info("[SECURITY] LOCAL_LOGIN_SUCCESS user=%s", username) _record_login_event(db, request, username, success=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") return RedirectResponse(url=redirect_url, status_code=302) else: diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 3736542b..116341af 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -64,11 +64,14 @@ See the [EAS Build documentation](https://docs.expo.dev/build/introduction/) for The mobile app uses the server's existing OAuth2/SSO setup: 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). -3. The user authenticates via SSO or local credentials. -4. The server redirects back to `docuelevate://callback`. -5. The app calls `POST /api/mobile/generate-token` to exchange the session for a **long-lived API token**. -6. The token is stored securely in the device's keychain (`expo-secure-store`). +2. The app opens `<server>/login?mobile=1&redirect_uri=docuelevate://callback` in the **system browser** (Safari / Chrome Custom Tabs). +3. The server stores `docuelevate://callback` in the browser session and presents the login page. +4. The user authenticates via SSO or local credentials. +5. After successful authentication the server mints a long-lived API token and redirects the browser to `docuelevate://callback?token=<token>`. +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 are accepted, preventing open-redirect attacks. ### Auto-generated Mobile Token diff --git a/mobile/src/screens/WelcomeScreen.tsx b/mobile/src/screens/WelcomeScreen.tsx index 5923f9a3..f779456c 100644 --- a/mobile/src/screens/WelcomeScreen.tsx +++ b/mobile/src/screens/WelcomeScreen.tsx @@ -10,12 +10,12 @@ import React from "react"; import { Image, Pressable, - SafeAreaView, ScrollView, StyleSheet, Text, View, } from "react-native"; +import { SafeAreaView } from "react-native-safe-area-context"; const FEATURES: { icon: string; title: string; description: string }[] = [ {