From 9fe50a87d1bcaceea01238f3766887d40e5226ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 11:53:39 +0000 Subject: [PATCH 1/4] Initial plan From 0f6a1ee1ec8186d70afc17abe50258c968058c92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:02:33 +0000 Subject: [PATCH 2/4] fix(api): add ttl_seconds to QR challenge response and fix client-side countdown The QR login page countdown timer compared the server's UTC expiration timestamp against the client's local clock, causing the QR code to appear immediately expired when the client clock was ahead of the server. Changes: - Add ttl_seconds field to CreateChallengeResponse (seconds until expiry) - Frontend countdown now uses relative elapsed time since response was received, eliminating clock-skew issues - Mobile app: replace alert-only QR button with actual camera-based QR code scanner using expo-camera - Add QRScannerScreen with barcode scanning, permission handling, and scan area overlay - Update camera permission description to mention QR code scanning - Add tests for ttl_seconds computation Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- app/api/qr_auth.py | 7 + frontend/templates/qr_login.html | 9 +- mobile/app.json | 4 +- mobile/app/(auth)/_layout.tsx | 1 + mobile/app/(auth)/qr-scanner.tsx | 4 + mobile/src/screens/LoginScreen.tsx | 5 +- mobile/src/screens/QRScannerScreen.tsx | 295 +++++++++++++++++++++++++ tests/test_session_management.py | 29 +++ 8 files changed, 346 insertions(+), 8 deletions(-) create mode 100644 mobile/app/(auth)/qr-scanner.tsx create mode 100644 mobile/src/screens/QRScannerScreen.tsx 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/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..9831112a --- /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", + gap: 10, + }, + statusText: { + color: "#fff", + fontSize: 16, + fontWeight: "600", + }, + 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.""" From 723b14e660737887c454b8e8300ac38bb390841f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:04:53 +0000 Subject: [PATCH 3/4] docs: update QR code login documentation with scanner and TTL details Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- docs/AuthenticationSetup.md | 11 +++++++---- docs/MobileApp.md | 19 +++++++++++++++++-- 2 files changed, 24 insertions(+), 6 deletions(-) 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 From 70b193e07d2f85e353b6b12c56bf5bcbf828ee24 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:05:39 +0000 Subject: [PATCH 4/4] fix(mobile): replace gap with marginLeft for React Native compatibility Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> --- mobile/src/screens/QRScannerScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mobile/src/screens/QRScannerScreen.tsx b/mobile/src/screens/QRScannerScreen.tsx index 9831112a..696c8e2b 100644 --- a/mobile/src/screens/QRScannerScreen.tsx +++ b/mobile/src/screens/QRScannerScreen.tsx @@ -263,12 +263,12 @@ const styles = StyleSheet.create({ statusContainer: { flexDirection: "row", alignItems: "center", - gap: 10, }, statusText: { color: "#fff", fontSize: 16, fontWeight: "600", + marginLeft: 10, }, instructionText: { color: "#fff",