diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py index b7e1d439..b9dbaa5d 100644 --- a/app/api/qr_auth.py +++ b/app/api/qr_auth.py @@ -70,6 +70,7 @@ class CreateChallengeResponse(BaseModel): challenge_id: int challenge_token: str expires_at: datetime + ttl_seconds: int = Field(description="Seconds until the challenge expires (use for client-side countdown).") qr_payload: str = Field(description="The string to encode in the QR code.") @@ -131,10 +132,16 @@ async def create_challenge( base_url = str(request.base_url).rstrip("/") qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}" + # Compute the TTL in seconds so the client can run a countdown timer + # without comparing absolute timestamps (which breaks when client and + # server clocks are out of sync). + ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) + return { "challenge_id": challenge.id, "challenge_token": challenge.challenge_token, "expires_at": challenge.expires_at, + "ttl_seconds": ttl_seconds, "qr_payload": qr_payload, } diff --git a/docs/AuthenticationSetup.md b/docs/AuthenticationSetup.md index 2c9e15a9..c77f48b3 100644 --- a/docs/AuthenticationSetup.md +++ b/docs/AuthenticationSetup.md @@ -190,20 +190,23 @@ QR code login allows users to authenticate a mobile device by scanning a QR code ### How It Works 1. The authenticated web user opens the **QR Login** page and a challenge QR code is displayed. -2. The mobile app scans the QR code and calls the claim endpoint. -3. An API token is issued for the mobile device and the web UI is notified via polling. +2. The user opens the DocuElevate mobile app and taps **Scan QR Code to Login**, which opens the device camera. +3. The mobile app scans the QR code. The QR code contains both the challenge token and the server URL (`docuelevate://qr-login?token=...&server=...`), so there is no need to enter the server URL manually. +4. An API token is issued for the mobile device and the web UI is notified via polling. + +> **Note:** The countdown timer on the web page uses server-relative time (TTL in seconds) rather than absolute timestamps, so it works correctly even when the client's clock is not in sync with the server. ### Configuration | Variable | Description | Default | |----------|-------------|---------| -| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid | `120` | +| `QR_LOGIN_CHALLENGE_TTL_SECONDS` | How long a QR challenge is valid (seconds) | `120` | ### API Endpoints | Method | Path | Description | |--------|------|-------------| -| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge | +| `POST` | `/api/qr-auth/challenge` | Create a new QR login challenge (returns `ttl_seconds` for client countdown) | | `GET` | `/api/qr-auth/challenge/{id}/status` | Poll the status of a challenge | | `POST` | `/api/qr-auth/claim` | Claim a challenge from a mobile device | diff --git a/docs/MobileApp.md b/docs/MobileApp.md index 77df917f..8b8e6f79 100644 --- a/docs/MobileApp.md +++ b/docs/MobileApp.md @@ -8,6 +8,7 @@ DocuElevate includes a native mobile application for iOS and Android built with |---------|-----|---------| | SSO login (OAuth2) | ✅ | ✅ | | Local / basic auth login | ✅ | ✅ | +| QR code login (scan from web) | ✅ | ✅ | | Auto-generated API token | ✅ | ✅ | | Camera capture → upload | ✅ | ✅ | | File picker upload | ✅ | ✅ | @@ -112,6 +113,18 @@ When developing with **Expo Go** the app does not have the `docuelevate://` cust No extra configuration is needed — just run `npx expo start` and scan the QR code with the **Expo Go** app. +### QR Code Login Flow + +As an alternative to SSO, users can log in by scanning a QR code displayed in the web UI: + +1. The authenticated web user navigates to **Profile → Security & Sessions → Log in on mobile via QR code**. +2. A QR code is displayed containing a deep link: `docuelevate://qr-login?token=&server=`. +3. In the mobile app, the user taps **Scan QR Code to Login**, which opens the device camera. +4. The app scans the QR code, extracts both the server URL and the challenge token, and calls `POST /api/qr-auth/claim`. +5. An API token is issued and stored securely — no need to enter the server URL manually. + +> **Note:** The QR code already contains the server URL, so users do not need to type it in when using QR login. + ### Auto-generated Mobile Token When the mobile app completes login it automatically creates a named API token (`"Mobile App – "`) via `POST /api/mobile/generate-token`. This token: @@ -286,7 +299,8 @@ mobile/ │ ├── (auth)/ # Unauthenticated route group │ │ ├── _layout.tsx # Stack navigator (headerless) │ │ ├── index.tsx # Welcome screen -│ │ └── login.tsx # Login screen +│ │ ├── login.tsx # Login screen +│ │ └── qr-scanner.tsx # QR code scanner screen │ └── (tabs)/ # Authenticated route group │ ├── _layout.tsx # Tab navigator │ ├── index.tsx # Upload screen (default tab) @@ -303,7 +317,8 @@ mobile/ ├── hooks/ │ └── usePushNotifications.ts # Push token registration ├── screens/ - │ ├── LoginScreen.tsx # Server URL + SSO button + │ ├── LoginScreen.tsx # Server URL + SSO button + QR code scanner + │ ├── QRScannerScreen.tsx # Camera-based QR code scanner for login │ ├── UploadScreen.tsx # Camera capture + photo library + file picker │ ├── FilesScreen.tsx # Processed document list │ └── ProfileScreen.tsx # User profile + sign out diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html index 517dd3ca..7b29f594 100644 --- a/frontend/templates/qr_login.html +++ b/frontend/templates/qr_login.html @@ -126,6 +126,8 @@ function qrLoginPage() { errorMsg: '', _pollTimer: null, _countdownTimer: null, + _ttlSeconds: 0, + _receivedAt: null, _csrfToken() { return document.cookie @@ -156,6 +158,8 @@ function qrLoginPage() { this.challengeToken = data.challenge_token; this.qrPayload = data.qr_payload; this.expiresAt = new Date(data.expires_at); + this._ttlSeconds = data.ttl_seconds || 120; + this._receivedAt = Date.now(); this.status = 'pending'; this.deviceName = ''; @@ -214,8 +218,9 @@ function qrLoginPage() { }, _updateCountdown() { - if (!this.expiresAt) { this.countdown = 0; return; } - const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000)); + if (!this._receivedAt) { this.countdown = 0; return; } + const elapsed = (Date.now() - this._receivedAt) / 1000; + const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed)); this.countdown = remaining; }, diff --git a/mobile/app.json b/mobile/app.json index 1aa4aef0..26cc937b 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -19,7 +19,7 @@ "bundleIdentifier": "org.docuelevate.mobile", "appleTeamId": "975U2ZESBM", "infoPlist": { - "NSCameraUsageDescription": "DocuElevate uses the camera to capture documents for upload.", + "NSCameraUsageDescription": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload.", "NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.", "NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.", "UIBackgroundModes": ["fetch", "remote-notification"], @@ -101,7 +101,7 @@ [ "expo-camera", { - "cameraPermission": "DocuElevate uses the camera to capture documents for upload." + "cameraPermission": "DocuElevate uses the camera to scan QR codes for login and to capture documents for upload." } ], "expo-document-picker", diff --git a/mobile/app/(auth)/_layout.tsx b/mobile/app/(auth)/_layout.tsx index 7040faec..e7288998 100644 --- a/mobile/app/(auth)/_layout.tsx +++ b/mobile/app/(auth)/_layout.tsx @@ -12,6 +12,7 @@ export default function AuthLayout() { + ); } diff --git a/mobile/app/(auth)/qr-scanner.tsx b/mobile/app/(auth)/qr-scanner.tsx new file mode 100644 index 00000000..b6c018fb --- /dev/null +++ b/mobile/app/(auth)/qr-scanner.tsx @@ -0,0 +1,4 @@ +/** + * QR scanner route – camera-based QR code scanning for mobile login. + */ +export { default } from "../../src/screens/QRScannerScreen"; diff --git a/mobile/src/screens/LoginScreen.tsx b/mobile/src/screens/LoginScreen.tsx index cc2b3133..8a0ace90 100644 --- a/mobile/src/screens/LoginScreen.tsx +++ b/mobile/src/screens/LoginScreen.tsx @@ -144,10 +144,7 @@ export default function LoginScreen() { { - Alert.alert( - "Scan QR Code", - "Open the DocuElevate web app on your computer, go to Profile → Security & Sessions → \"Log in on mobile via QR code\", and scan the QR code shown there.\n\nThe app will automatically detect the QR code when scanned with your device camera." - ); + router.push("/(auth)/qr-scanner"); }} disabled={loading || qrLoading} accessibilityRole="button" diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx new file mode 100644 index 00000000..696c8e2b --- /dev/null +++ b/mobile/src/screens/QRScannerScreen.tsx @@ -0,0 +1,295 @@ +/** + * QRScannerScreen – camera-based QR code scanner for mobile login. + * + * Opens the device camera and scans for QR codes containing a + * `docuelevate://qr-login?token=...&server=...` payload. On successful + * scan the token is claimed via the API and the user is signed in. + */ + +import { CameraView, useCameraPermissions } from "expo-camera"; +import { useRouter } from "expo-router"; +import React, { useCallback, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Pressable, + StyleSheet, + Text, + View, +} from "react-native"; +import { useAuth } from "../context/AuthContext"; + +export default function QRScannerScreen() { + const { signInWithQR } = useAuth(); + const router = useRouter(); + const [permission, requestPermission] = useCameraPermissions(); + const [scanned, setScanned] = useState(false); + const [processing, setProcessing] = useState(false); + const processingRef = useRef(false); + + const handleBarCodeScanned = useCallback( + async (result: { data: string }) => { + // Prevent duplicate scans while processing + if (processingRef.current) return; + + const { data } = result; + + // Only accept docuelevate:// QR codes + if (!data.startsWith("docuelevate://qr-login")) return; + + processingRef.current = true; + setScanned(true); + setProcessing(true); + + try { + const url = new URL(data); + const token = url.searchParams.get("token"); + const server = url.searchParams.get("server"); + + if (!token || !server) { + Alert.alert("Invalid QR Code", "This QR code does not contain valid login information."); + setScanned(false); + processingRef.current = false; + setProcessing(false); + return; + } + + await signInWithQR(server, token); + // signInWithQR updates AuthContext → AuthGuard redirects to main app + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "QR login failed"; + Alert.alert("QR Login Failed", message); + setScanned(false); + processingRef.current = false; + setProcessing(false); + } + }, + [signInWithQR] + ); + + // Permissions not yet determined + if (!permission) { + return ( + + + + ); + } + + // Permission denied + if (!permission.granted) { + return ( + + + Camera access is required to scan QR codes. + + + Grant Camera Access + + router.back()} + style={styles.backLink} + accessibilityRole="button" + accessibilityLabel="Go back" + > + ← Back + + + ); + } + + return ( + + + + {/* Overlay with scan area indicator */} + + + + + + + + + + + + + + {processing ? ( + + + Signing in… + + ) : ( + + Point your camera at the QR code{"\n"}shown on the DocuElevate web app + + )} + + router.back()} + style={styles.cancelButton} + accessibilityRole="button" + accessibilityLabel="Cancel QR scan" + > + Cancel + + + + + ); +} + +const SCAN_AREA_SIZE = 250; +const CORNER_SIZE = 24; +const CORNER_WIDTH = 3; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: "#000", + }, + camera: { + flex: 1, + }, + centered: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "#f3f4f6", + padding: 24, + }, + permissionText: { + fontSize: 16, + color: "#374151", + textAlign: "center", + marginBottom: 20, + }, + permissionButton: { + backgroundColor: "#1e40af", + borderRadius: 8, + paddingVertical: 14, + paddingHorizontal: 24, + minHeight: 48, + alignItems: "center", + justifyContent: "center", + }, + permissionButtonText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + backLink: { + marginTop: 20, + minHeight: 44, + alignItems: "center", + justifyContent: "center", + }, + backLinkText: { + fontSize: 14, + color: "#6b7280", + }, + overlay: { + ...StyleSheet.absoluteFillObject, + }, + overlayTop: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + }, + overlayMiddle: { + flexDirection: "row", + height: SCAN_AREA_SIZE, + }, + overlaySide: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + }, + scanArea: { + width: SCAN_AREA_SIZE, + height: SCAN_AREA_SIZE, + }, + corner: { + position: "absolute", + width: CORNER_SIZE, + height: CORNER_SIZE, + }, + cornerTopLeft: { + top: 0, + left: 0, + borderTopWidth: CORNER_WIDTH, + borderLeftWidth: CORNER_WIDTH, + borderColor: "#fff", + }, + cornerTopRight: { + top: 0, + right: 0, + borderTopWidth: CORNER_WIDTH, + borderRightWidth: CORNER_WIDTH, + borderColor: "#fff", + }, + cornerBottomLeft: { + bottom: 0, + left: 0, + borderBottomWidth: CORNER_WIDTH, + borderLeftWidth: CORNER_WIDTH, + borderColor: "#fff", + }, + cornerBottomRight: { + bottom: 0, + right: 0, + borderBottomWidth: CORNER_WIDTH, + borderRightWidth: CORNER_WIDTH, + borderColor: "#fff", + }, + overlayBottom: { + flex: 1, + backgroundColor: "rgba(0,0,0,0.5)", + alignItems: "center", + paddingTop: 32, + }, + statusContainer: { + flexDirection: "row", + alignItems: "center", + }, + statusText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + marginLeft: 10, + }, + instructionText: { + color: "#fff", + fontSize: 15, + textAlign: "center", + lineHeight: 22, + }, + cancelButton: { + marginTop: 24, + paddingVertical: 12, + paddingHorizontal: 32, + borderRadius: 8, + borderWidth: 1, + borderColor: "rgba(255,255,255,0.5)", + minHeight: 44, + alignItems: "center", + justifyContent: "center", + }, + cancelButtonText: { + color: "#fff", + fontSize: 15, + fontWeight: "500", + }, +}); diff --git a/tests/test_session_management.py b/tests/test_session_management.py index 039aca46..e11c93ca 100644 --- a/tests/test_session_management.py +++ b/tests/test_session_management.py @@ -401,6 +401,35 @@ class TestQRLogin: expires = expires.replace(tzinfo=timezone.utc) assert expires > datetime.now(timezone.utc) + @patch("app.utils.session_manager.settings") + def test_create_qr_challenge_ttl_seconds(self, mock_settings, db_session: Session, sample_user_id: str): + """Test that ttl_seconds can be derived from created_at and expires_at. + + The API endpoint computes ttl_seconds = (expires_at - created_at) to + allow the client to run a countdown timer without comparing absolute + timestamps (avoiding clock-skew issues). + """ + from app.utils.session_manager import create_qr_challenge + + mock_settings.qr_login_challenge_ttl_seconds = 120 + + challenge = create_qr_challenge(db_session, sample_user_id) + + ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) + assert ttl_seconds == 120 + + @patch("app.utils.session_manager.settings") + def test_create_qr_challenge_custom_ttl(self, mock_settings, db_session: Session, sample_user_id: str): + """Test that a custom TTL is correctly reflected in the challenge timestamps.""" + from app.utils.session_manager import create_qr_challenge + + mock_settings.qr_login_challenge_ttl_seconds = 300 + + challenge = create_qr_challenge(db_session, sample_user_id) + + ttl_seconds = max(0, int((challenge.expires_at - challenge.created_at).total_seconds())) + assert ttl_seconds == 300 + @patch("app.utils.session_manager.settings") def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str): """Test validating a valid QR challenge."""