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>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-17 12:02:33 +00:00
parent 9fe50a87d1
commit 0f6a1ee1ec
8 changed files with 346 additions and 8 deletions
+7
View File
@@ -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,
}
+7 -2
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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>
);
}
+4
View File
@@ -0,0 +1,4 @@
/**
* QR scanner route camera-based QR code scanning for mobile login.
*/
export { default } from "../../src/screens/QRScannerScreen";
+1 -4
View File
@@ -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"
+295
View File
@@ -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",
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",
},
});
+29
View File
@@ -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."""