fix(auth): Expo Go support via Linking.createURL; safe token URL construction; clean up return type annotation

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-15 17:42:36 +00:00
parent 5c373e7224
commit 14285567d5
3 changed files with 37 additions and 13 deletions
+13 -7
View File
@@ -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)
+12 -1
View File
@@ -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
+12 -5
View File
@@ -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://<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
// 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") {