diff --git a/app/api/qr_auth.py b/app/api/qr_auth.py index b9dbaa5d..8f891e4e 100644 --- a/app/api/qr_auth.py +++ b/app/api/qr_auth.py @@ -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 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), } diff --git a/frontend/templates/qr_login.html b/frontend/templates/qr_login.html index 7b29f594..a2ecac26 100644 --- a/frontend/templates/qr_login.html +++ b/frontend/templates/qr_login.html @@ -32,7 +32,13 @@ id="qr-container" aria-label="{{ _('qr_login.description') }}" > - + {{ _('qr_login.description') }}

{{ _("qr_login.description") }} @@ -110,8 +116,7 @@ - - +