Merge pull request #756 from christianlouis/copilot/fix-qr-code-login-timeout
fix(qr-auth): fix QR code countdown clock skew and add mobile QR scanner
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
+17
-2
@@ -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=<challenge_token>&server=<server_url>`.
|
||||
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 – <device name>"`) 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
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
@@ -12,6 +12,7 @@ export default function AuthLayout() {
|
||||
<Stack screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="index" />
|
||||
<Stack.Screen name="login" />
|
||||
<Stack.Screen name="qr-scanner" />
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* QR scanner route – camera-based QR code scanning for mobile login.
|
||||
*/
|
||||
export { default } from "../../src/screens/QRScannerScreen";
|
||||
@@ -144,10 +144,7 @@ export default function LoginScreen() {
|
||||
<Pressable
|
||||
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
|
||||
onPress={() => {
|
||||
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"
|
||||
|
||||
@@ -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 (
|
||||
<View style={styles.centered}>
|
||||
<ActivityIndicator size="large" color="#1e40af" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// Permission denied
|
||||
if (!permission.granted) {
|
||||
return (
|
||||
<View style={styles.centered}>
|
||||
<Text style={styles.permissionText}>
|
||||
Camera access is required to scan QR codes.
|
||||
</Text>
|
||||
<Pressable
|
||||
style={styles.permissionButton}
|
||||
onPress={requestPermission}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Grant camera access"
|
||||
>
|
||||
<Text style={styles.permissionButtonText}>Grant Camera Access</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={styles.backLink}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Go back"
|
||||
>
|
||||
<Text style={styles.backLinkText}>← Back</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<CameraView
|
||||
style={styles.camera}
|
||||
facing="back"
|
||||
barcodeScannerSettings={{
|
||||
barcodeTypes: ["qr"],
|
||||
}}
|
||||
onBarcodeScanned={scanned ? undefined : handleBarCodeScanned}
|
||||
/>
|
||||
|
||||
{/* Overlay with scan area indicator */}
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.overlayTop} />
|
||||
<View style={styles.overlayMiddle}>
|
||||
<View style={styles.overlaySide} />
|
||||
<View style={styles.scanArea}>
|
||||
<View style={[styles.corner, styles.cornerTopLeft]} />
|
||||
<View style={[styles.corner, styles.cornerTopRight]} />
|
||||
<View style={[styles.corner, styles.cornerBottomLeft]} />
|
||||
<View style={[styles.corner, styles.cornerBottomRight]} />
|
||||
</View>
|
||||
<View style={styles.overlaySide} />
|
||||
</View>
|
||||
<View style={styles.overlayBottom}>
|
||||
{processing ? (
|
||||
<View style={styles.statusContainer}>
|
||||
<ActivityIndicator color="#fff" />
|
||||
<Text style={styles.statusText}>Signing in…</Text>
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.instructionText}>
|
||||
Point your camera at the QR code{"\n"}shown on the DocuElevate web app
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Pressable
|
||||
onPress={() => router.back()}
|
||||
style={styles.cancelButton}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="Cancel QR scan"
|
||||
>
|
||||
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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",
|
||||
},
|
||||
});
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user