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 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
@@ -256,10 +256,14 @@ 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 # Persist the mobile deep-link redirect URI in the session so it survives
# the OAuth provider round-trip and is available when auth completes. # 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": if request.query_params.get("mobile") == "1":
redirect_uri = request.query_params.get("redirect_uri", "") 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 request.session["mobile_redirect_uri"] = redirect_uri
return templates.TemplateResponse( 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) 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. """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 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 return None
# Lazy imports to avoid circular dependency via app.api.__init__ # 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.api.api_tokens import generate_api_token, hash_token
from app.models import ApiToken as _ApiToken # noqa: PLC0415 from app.models import ApiToken as _ApiToken
plaintext = generate_api_token() plaintext = generate_api_token()
token_hash_value = hash_token(plaintext) 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) logger.exception("Failed to create mobile API token for owner_id=%s", owner_id)
return None 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) 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) 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. 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`). 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 ### 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") {