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:
+99
-1
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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 }[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user