diff --git a/app/auth.py b/app/auth.py index 92937edd..e4332834 100644 --- a/app/auth.py +++ b/app/auth.py @@ -4,7 +4,7 @@ import logging import pathlib from datetime import datetime, timezone from functools import wraps -from urllib.parse import urlparse +from urllib.parse import urlencode, urlparse from authlib.integrations.starlette_client import OAuth from fastapi import APIRouter, Depends, Request, status @@ -256,10 +256,14 @@ 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. + # 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 redirect_uri.startswith("docuelevate://"): + if any(redirect_uri.startswith(s) for s in _MOBILE_ALLOWED_SCHEMES): request.session["mobile_redirect_uri"] = redirect_uri return templates.TemplateResponse( @@ -647,7 +651,7 @@ 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": +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 @@ -679,8 +683,8 @@ def _create_mobile_redirect(request: Request, db: Session) -> "RedirectResponse 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 + 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) @@ -700,7 +704,9 @@ def _create_mobile_redirect(request: Request, db: Session) -> "RedirectResponse logger.exception("Failed to create mobile API token for owner_id=%s", owner_id) return None - redirect_url = f"{mobile_redirect_uri}?token={plaintext}" + # 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) diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 116341af..3ee12e01 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -71,7 +71,18 @@ The mobile app uses the server's existing OAuth2/SSO setup: 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. +> **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 diff --git a/mobile/src/context/AuthContext.tsx b/mobile/src/context/AuthContext.tsx index 94deab40..4bbb66e7 100644 --- a/mobile/src/context/AuthContext.tsx +++ b/mobile/src/context/AuthContext.tsx @@ -8,6 +8,7 @@ * token via POST /api/mobile/generate-token. */ +import * as Linking from "expo-linking"; import * as SecureStore from "expo-secure-store"; import * as WebBrowser from "expo-web-browser"; import React, { @@ -104,13 +105,19 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { await api.init(cleanUrl); setBaseUrl(cleanUrl); + // Compute the correct redirect URI for the current runtime: + // • Expo Go (development) → exp://:/--/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 - // via SSO or local credentials, then the app deep-link (docuelevate://callback) - // is triggered. The WebBrowser.openAuthSessionAsync handles the redirect - // back to the app. + // via SSO or local credentials, then the app deep-link is triggered. + // The WebBrowser.openAuthSessionAsync handles the redirect back to the app. const result = await WebBrowser.openAuthSessionAsync( - `${cleanUrl}/login?mobile=1&redirect_uri=docuelevate://callback`, - "docuelevate://callback" + `${cleanUrl}/login?mobile=1&redirect_uri=${encodeURIComponent(redirectUri)}`, + redirectUri ); if (result.type !== "success") {