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_id: int
|
||||||
challenge_token: str
|
challenge_token: str
|
||||||
expires_at: datetime
|
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.")
|
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("/")
|
base_url = str(request.base_url).rstrip("/")
|
||||||
qr_payload = f"docuelevate://qr-login?token={challenge.challenge_token}&server={base_url}"
|
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 {
|
return {
|
||||||
"challenge_id": challenge.id,
|
"challenge_id": challenge.id,
|
||||||
"challenge_token": challenge.challenge_token,
|
"challenge_token": challenge.challenge_token,
|
||||||
"expires_at": challenge.expires_at,
|
"expires_at": challenge.expires_at,
|
||||||
|
"ttl_seconds": ttl_seconds,
|
||||||
"qr_payload": qr_payload,
|
"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
|
### How It Works
|
||||||
|
|
||||||
1. The authenticated web user opens the **QR Login** page and a challenge QR code is displayed.
|
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.
|
2. The user opens the DocuElevate mobile app and taps **Scan QR Code to Login**, which opens the device camera.
|
||||||
3. An API token is issued for the mobile device and the web UI is notified via polling.
|
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
|
### Configuration
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| 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
|
### API Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| 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 |
|
| `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 |
|
| `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) | ✅ | ✅ |
|
| SSO login (OAuth2) | ✅ | ✅ |
|
||||||
| Local / basic auth login | ✅ | ✅ |
|
| Local / basic auth login | ✅ | ✅ |
|
||||||
|
| QR code login (scan from web) | ✅ | ✅ |
|
||||||
| Auto-generated API token | ✅ | ✅ |
|
| Auto-generated API token | ✅ | ✅ |
|
||||||
| Camera capture → upload | ✅ | ✅ |
|
| Camera capture → upload | ✅ | ✅ |
|
||||||
| File picker 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.
|
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
|
### 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:
|
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
|
│ ├── (auth)/ # Unauthenticated route group
|
||||||
│ │ ├── _layout.tsx # Stack navigator (headerless)
|
│ │ ├── _layout.tsx # Stack navigator (headerless)
|
||||||
│ │ ├── index.tsx # Welcome screen
|
│ │ ├── index.tsx # Welcome screen
|
||||||
│ │ └── login.tsx # Login screen
|
│ │ ├── login.tsx # Login screen
|
||||||
|
│ │ └── qr-scanner.tsx # QR code scanner screen
|
||||||
│ └── (tabs)/ # Authenticated route group
|
│ └── (tabs)/ # Authenticated route group
|
||||||
│ ├── _layout.tsx # Tab navigator
|
│ ├── _layout.tsx # Tab navigator
|
||||||
│ ├── index.tsx # Upload screen (default tab)
|
│ ├── index.tsx # Upload screen (default tab)
|
||||||
@@ -303,7 +317,8 @@ mobile/
|
|||||||
├── hooks/
|
├── hooks/
|
||||||
│ └── usePushNotifications.ts # Push token registration
|
│ └── usePushNotifications.ts # Push token registration
|
||||||
├── screens/
|
├── 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
|
│ ├── UploadScreen.tsx # Camera capture + photo library + file picker
|
||||||
│ ├── FilesScreen.tsx # Processed document list
|
│ ├── FilesScreen.tsx # Processed document list
|
||||||
│ └── ProfileScreen.tsx # User profile + sign out
|
│ └── ProfileScreen.tsx # User profile + sign out
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ function qrLoginPage() {
|
|||||||
errorMsg: '',
|
errorMsg: '',
|
||||||
_pollTimer: null,
|
_pollTimer: null,
|
||||||
_countdownTimer: null,
|
_countdownTimer: null,
|
||||||
|
_ttlSeconds: 0,
|
||||||
|
_receivedAt: null,
|
||||||
|
|
||||||
_csrfToken() {
|
_csrfToken() {
|
||||||
return document.cookie
|
return document.cookie
|
||||||
@@ -156,6 +158,8 @@ function qrLoginPage() {
|
|||||||
this.challengeToken = data.challenge_token;
|
this.challengeToken = data.challenge_token;
|
||||||
this.qrPayload = data.qr_payload;
|
this.qrPayload = data.qr_payload;
|
||||||
this.expiresAt = new Date(data.expires_at);
|
this.expiresAt = new Date(data.expires_at);
|
||||||
|
this._ttlSeconds = data.ttl_seconds || 120;
|
||||||
|
this._receivedAt = Date.now();
|
||||||
this.status = 'pending';
|
this.status = 'pending';
|
||||||
this.deviceName = '';
|
this.deviceName = '';
|
||||||
|
|
||||||
@@ -214,8 +218,9 @@ function qrLoginPage() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
_updateCountdown() {
|
_updateCountdown() {
|
||||||
if (!this.expiresAt) { this.countdown = 0; return; }
|
if (!this._receivedAt) { this.countdown = 0; return; }
|
||||||
const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000));
|
const elapsed = (Date.now() - this._receivedAt) / 1000;
|
||||||
|
const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
|
||||||
this.countdown = remaining;
|
this.countdown = remaining;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@
|
|||||||
"bundleIdentifier": "org.docuelevate.mobile",
|
"bundleIdentifier": "org.docuelevate.mobile",
|
||||||
"appleTeamId": "975U2ZESBM",
|
"appleTeamId": "975U2ZESBM",
|
||||||
"infoPlist": {
|
"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.",
|
"NSPhotoLibraryUsageDescription": "DocuElevate accesses your photo library to select documents for upload.",
|
||||||
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
|
"NSPhotoLibraryAddUsageDescription": "DocuElevate saves scanned documents to your photo library.",
|
||||||
"UIBackgroundModes": ["fetch", "remote-notification"],
|
"UIBackgroundModes": ["fetch", "remote-notification"],
|
||||||
@@ -101,7 +101,7 @@
|
|||||||
[
|
[
|
||||||
"expo-camera",
|
"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",
|
"expo-document-picker",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export default function AuthLayout() {
|
|||||||
<Stack screenOptions={{ headerShown: false }}>
|
<Stack screenOptions={{ headerShown: false }}>
|
||||||
<Stack.Screen name="index" />
|
<Stack.Screen name="index" />
|
||||||
<Stack.Screen name="login" />
|
<Stack.Screen name="login" />
|
||||||
|
<Stack.Screen name="qr-scanner" />
|
||||||
</Stack>
|
</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
|
<Pressable
|
||||||
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
|
style={[styles.qrButton, qrLoading && styles.buttonDisabled]}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
Alert.alert(
|
router.push("/(auth)/qr-scanner");
|
||||||
"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."
|
|
||||||
);
|
|
||||||
}}
|
}}
|
||||||
disabled={loading || qrLoading}
|
disabled={loading || qrLoading}
|
||||||
accessibilityRole="button"
|
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)
|
expires = expires.replace(tzinfo=timezone.utc)
|
||||||
assert expires > datetime.now(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")
|
@patch("app.utils.session_manager.settings")
|
||||||
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
|
def test_validate_qr_challenge_valid(self, mock_settings, db_session: Session, sample_user_id: str):
|
||||||
"""Test validating a valid QR challenge."""
|
"""Test validating a valid QR challenge."""
|
||||||
|
|||||||
Reference in New Issue
Block a user