Merge pull request #760 from christianlouis/copilot/debug-qr-code-rendering

fix(qr-login): render QR code server-side to eliminate CDN dependency
This commit is contained in:
Christian Krakau-Louis
2026-03-18 15:02:26 +01:00
committed by GitHub
3 changed files with 39 additions and 15 deletions
+28
View File
@@ -19,10 +19,13 @@ Security properties:
from __future__ import annotations
import base64
import io
import logging
from datetime import datetime
from typing import Annotated, Any
import segno
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel, Field
from sqlalchemy.orm import Session
@@ -72,6 +75,7 @@ class CreateChallengeResponse(BaseModel):
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_code_svg: str = Field(description="Base64-encoded SVG data URI of the QR code, ready for use in an <img> src.")
class ChallengeStatusResponse(BaseModel):
@@ -106,6 +110,29 @@ class ClaimChallengeResponse(BaseModel):
created_at: datetime
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# QR code rendering parameters
_QR_ERROR_LEVEL = "M" # Medium error correction (~15% recovery); sufficient for on-screen display
_QR_SCALE = 4 # Each QR module is rendered as 4×4 SVG pixels
def _generate_qr_svg(payload: str) -> str:
"""Generate a QR code for *payload* and return it as a base64 SVG data URI.
Using ``segno`` (pure-Python, no Pillow dependency) and SVG output so the
QR code scales crisply at any resolution without requiring a canvas or any
client-side JavaScript library.
"""
qr = segno.make(payload, error=_QR_ERROR_LEVEL)
buf = io.BytesIO()
qr.save(buf, kind="svg", scale=_QR_SCALE, xmldecl=False, svgclass=None, lineclass=None, omitsize=True)
svg_bytes = buf.getvalue()
return "data:image/svg+xml;base64," + base64.b64encode(svg_bytes).decode("ascii")
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@@ -143,6 +170,7 @@ async def create_challenge(
"expires_at": challenge.expires_at,
"ttl_seconds": ttl_seconds,
"qr_payload": qr_payload,
"qr_code_svg": _generate_qr_svg(qr_payload),
}
+10 -15
View File
@@ -32,7 +32,13 @@
id="qr-container"
aria-label="{{ _('qr_login.description') }}"
>
<canvas id="qr-canvas" width="256" height="256"></canvas>
<img
:src="qrCodeSvg"
width="256"
height="256"
alt="{{ _('qr_login.description') }}"
id="qr-image"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
{{ _("qr_login.description") }}
@@ -110,8 +116,7 @@
</section>
</div>
<!-- QR Code library (lightweight, no external deps) -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<!-- QR code is rendered server-side; no external QR library needed -->
<script>
function qrLoginPage() {
@@ -120,6 +125,7 @@ function qrLoginPage() {
challengeId: null,
challengeToken: '',
qrPayload: '',
qrCodeSvg: '',
expiresAt: null,
countdown: 0,
deviceName: '',
@@ -157,24 +163,13 @@ function qrLoginPage() {
this.challengeId = data.challenge_id;
this.challengeToken = data.challenge_token;
this.qrPayload = data.qr_payload;
this.qrCodeSvg = data.qr_code_svg;
this.expiresAt = new Date(data.expires_at);
this._ttlSeconds = data.ttl_seconds || 120;
this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
// Render QR code
this.$nextTick(() => {
const canvas = document.getElementById('qr-canvas');
if (canvas && typeof QRCode !== 'undefined') {
QRCode.toCanvas(canvas, this.qrPayload, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
}
});
// Start polling and countdown
this._startPolling();
this._startCountdown();
+1
View File
@@ -60,3 +60,4 @@ sentry-sdk[fastapi,celery,sqlalchemy]>=2.20.0,<3.0.0
strawberry-graphql[fastapi]>=0.243.0,<1.0.0
aiofiles>=24.1.0 # Asynchronous file I/O support
segno>=1.6.0 # Pure-Python QR code generator (server-side rendering, no Pillow dependency)