From 4caf83b87b842128fd50b679f1a3134a1c240dc5 Mon Sep 17 00:00:00 2001 From: k8ie Date: Sun, 1 Mar 2026 20:54:40 +0100 Subject: [PATCH 01/19] Rewrite for LNURL-auth --- pyproject.toml | 5 +- quorra/classes.py | 21 +---- quorra/fe/auth/auth.js | 8 +- quorra/fe/onboard/onboarding.js | 8 +- quorra/fe/style.css | 2 +- quorra/main.py | 29 +++---- quorra/routers/lnurlauth.py | 143 ++++++++++++++++++++++++++++++++ quorra/routers/login.py | 29 ++----- quorra/routers/mobile.py | 117 -------------------------- quorra/routers/oidc.py | 12 +-- quorra/routers/onboarding.py | 42 +++------- quorra/routers/tx.py | 6 -- 12 files changed, 198 insertions(+), 224 deletions(-) create mode 100644 quorra/routers/lnurlauth.py delete mode 100644 quorra/routers/mobile.py diff --git a/pyproject.toml b/pyproject.toml index 3d8899b..91b9a66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = 'quorra' dynamic = ["version"] -description = 'Quorra API server' +description = 'Quorra server' readme = 'README.md' requires-python = '>=3.8' license = {file = 'LICENSE'} @@ -24,7 +24,8 @@ dependencies = [ "pillow", "python-multipart", "deepmerge", - "python-jose" + "python-jose", + "git+https://github.com/quorra-Auth/bech32" ] [tool.setuptools] diff --git a/quorra/classes.py b/quorra/classes.py index ddabf48..15eb9f4 100644 --- a/quorra/classes.py +++ b/quorra/classes.py @@ -34,7 +34,7 @@ class QRDataResponse(BaseModel): qr_image: str class DeviceRegistrationRequest(SQLModel): - pubkey: str + pubkey: str = Field(unique=True) name: str | None = None class Device(DeviceRegistrationRequest, table=True): @@ -42,21 +42,6 @@ class Device(DeviceRegistrationRequest, table=True): user_id: str = Field(default=None, foreign_key="user.id") -class AQRMobileStateEnum(str, Enum): - accepted = "accepted" - rejected = "rejected" - -# TODO: Send a device UUID as well so that the server can get a hint -class AQRMobileIdentifyRequest(BaseModel): - signature: str - message: str - -class AQRMobileAuthenticateRequest(BaseModel): - state: AQRMobileStateEnum - signature: str - message: str - - class TokenResponse(BaseModel): access_token: str token_type: Literal["Bearer"] = "Bearer" @@ -65,7 +50,7 @@ class TokenResponse(BaseModel): class TransactionTypes(str, Enum): onboarding = "onboarding" - aqr_oidc_login = "aqr-oidc-login" + ln_oidc_login = "ln-oidc-login" class TransactionGetRequest(BaseModel): tx_type: TransactionTypes @@ -155,7 +140,7 @@ class OnboardingTransaction(Transaction): tx_type: TransactionTypes = TransactionTypes.onboarding class AqrOIDCLoginTransaction(Transaction): - tx_type: TransactionTypes = TransactionTypes.aqr_oidc_login + tx_type: TransactionTypes = TransactionTypes.ln_oidc_login class AqrOIDCLoginTransactionStates(str, Enum): created = "created" diff --git a/quorra/fe/auth/auth.js b/quorra/fe/auth/auth.js index ca10de0..5652260 100644 --- a/quorra/fe/auth/auth.js +++ b/quorra/fe/auth/auth.js @@ -12,7 +12,7 @@ async function startAqr() { if (params.nonce) { args = args + `&nonce=${params.nonce}` } - const response = await fetch(`/login/start?${args}`); + const response = await fetch(`/processes/login/start?${args}`); if (!response.ok) throw new Error("Request failed"); data = await response.json(); txId = data.tx_id; @@ -21,8 +21,8 @@ async function startAqr() { } async function showQrCode() { - const payload = { "tx_type": "aqr-oidc-login", "tx_id": txId }; - const response = await fetch("/login/qr", { + const payload = { "tx_type": "ln-oidc-login", "tx_id": txId }; + const response = await fetch("/lnurl-auth/qr", { method: "POST", headers: { "Content-Type": "application/json" @@ -36,7 +36,7 @@ async function showQrCode() { function startPolling() { const pollingUrl = `/tx/transaction`; - const payload = { "tx_id": txId, "tx_type": "aqr-oidc-login" } + const payload = { "tx_id": txId, "tx_type": "ln-oidc-login" } const encodeGetParams = p => Object.entries(p).map(kv => kv.map(encodeURIComponent).join("=")).join("&"); diff --git a/quorra/fe/onboard/onboarding.js b/quorra/fe/onboard/onboarding.js index eb0c663..03cfbb6 100644 --- a/quorra/fe/onboard/onboarding.js +++ b/quorra/fe/onboard/onboarding.js @@ -6,7 +6,7 @@ async function getLink() { } async function createOnboardingLink() { - const response = await fetch("/onboarding/create", { + const response = await fetch("/processes/onboarding/create", { method: "GET", headers: { "Content-Type": "application/json" @@ -22,7 +22,7 @@ async function createOnboardingLink() { async function startOnboardingTransaction(onboardingLink) { const payload = { "link_id": onboardingLink }; - const response = await fetch("/onboarding/init", { + const response = await fetch("/processes/onboarding/init", { method: "POST", headers: { "Content-Type": "application/json" @@ -39,7 +39,7 @@ async function startOnboardingTransaction(onboardingLink) { async function getOnboardingData() { const payload = { "tx_type": "onboarding", "tx_id": txId }; - const response = await fetch("/onboarding/qr", { + const response = await fetch("/lnurl-auth/qr", { method: "POST", headers: { "Content-Type": "application/json" @@ -69,7 +69,7 @@ function startOnboarding() { const payload = { "tx_id": txId, "data": { "username": name, "email": email }, "tx_type": "onboarding" }; try { - const response = await fetch("/onboarding/entry", { + const response = await fetch("/processes/onboarding/entry", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload), diff --git a/quorra/fe/style.css b/quorra/fe/style.css index c2ba4cd..76a1f0b 100644 --- a/quorra/fe/style.css +++ b/quorra/fe/style.css @@ -38,7 +38,7 @@ input { margin-bottom: 1rem; } #qr { - max-width: 100%; + max-width: 25%; height: auto; margin: 1rem 0; border-radius: 8px; diff --git a/quorra/main.py b/quorra/main.py index a542f7f..c58fe5b 100644 --- a/quorra/main.py +++ b/quorra/main.py @@ -9,15 +9,12 @@ from . import __version__ -from .routers import onboarding -from .routers import mobile -from .routers import login -from .routers import oidc -from .routers import tx +from .routers import ( + onboarding, login, + lnurlauth, oidc, tx +) -from .database import engine -from .database import SessionDep -from .database import vk +from .database import engine, SessionDep, vk from valkey.exceptions import ResponseError from valkey.commands.search.field import TagField @@ -35,15 +32,15 @@ async def prep_valkey(): await create_oidc_at_index() async def create_drt_index(): - idx = vk.ft("idx:device_registration_token") - schema = (TagField("$.data.device_registration.token", as_name="device_registration_token")) + idx = vk.ft("idx:ln_k1") + schema = (TagField("$.data.ln.k1", as_name="ln_k1")) try: idx.info() except ResponseError: idx.create_index( schema, definition=IndexDefinition( - prefix=["onboarding:"], + prefix=["ln-oidc-login:", "onboarding:"], index_type=IndexType.JSON ) ) @@ -59,7 +56,7 @@ async def create_oidc_code_index(): idx.create_index( schema, definition=IndexDefinition( - prefix=["aqr-oidc-login:"], + prefix=["ln-oidc-login:"], index_type=IndexType.JSON ) ) @@ -75,7 +72,7 @@ async def create_oidc_at_index(): idx.create_index( schema, definition=IndexDefinition( - prefix=["aqr-oidc-login:"], + prefix=["ln-oidc-login:"], index_type=IndexType.JSON ) ) @@ -103,9 +100,9 @@ async def healthcheck(session: SessionDep): return {"health": "ok"} -app.include_router(onboarding.router, prefix="/onboarding", tags=["New user onboarding"]) -app.include_router(login.router, prefix="/login", tags=["Login session management"]) -app.include_router(mobile.router, prefix="/mobile", tags=["Mobile endpoints"]) +app.include_router(onboarding.router, prefix="/processes/onboarding", tags=["Onboarding process endpoints"]) +app.include_router(login.router, prefix="/processes/login", tags=["Login process endpoints"]) +app.include_router(lnurlauth.router, prefix="/lnurl-auth", tags=["Lightning login endpoints"]) app.include_router(oidc.router, prefix="/oidc", tags=["OIDC"]) app.include_router(tx.router, prefix="/tx", tags=["Transaction management"]) diff --git a/quorra/routers/lnurlauth.py b/quorra/routers/lnurlauth.py new file mode 100644 index 0000000..b38377a --- /dev/null +++ b/quorra/routers/lnurlauth.py @@ -0,0 +1,143 @@ +from typing import Annotated + +from fastapi import APIRouter, Header +from sqlmodel import select + +from fastapi import HTTPException + +from uuid import uuid4 +import json +import base64 +import bech32 +import random + +from sqlalchemy.exc import IntegrityError + +from ..classes import ( + User, Device, + Transaction, TransactionTypes, + ErrorResponse, QRDataResponse +) + +from cryptography.hazmat.primitives.asymmetric.ec import ( + ECDSA, + SECP256K1, + EllipticCurvePublicKey, +) +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric.utils import decode_dss_signature, Prehashed +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat +from cryptography.exceptions import InvalidSignature + +from valkey.commands.search.query import Query + +from .oidc import store_oidc_code + +from ..database import SessionDep +from ..database import vk + +from ..utils import generate_qr +from ..utils import escape_valkey_tag +from ..utils import generate_qr +from ..config import server_url + +router = APIRouter() + +async def verify_signature(k1: str, sig: str, key: str) -> bool: + pubkey_bytes = bytes.fromhex(key) + pubkey = EllipticCurvePublicKey.from_encoded_point(SECP256K1(), pubkey_bytes) + k1_bytes = bytes.fromhex(k1) + signature_bytes = bytes.fromhex(sig) + try: + pubkey.verify(signature_bytes, k1_bytes, ECDSA(Prehashed(hashes.SHA256()))) + except InvalidSignature: + return False + return True + + +@router.get("/register", response_model=None, responses={403: {"model": ErrorResponse}}) +async def ln_register(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None): + """Finishes device registration.""" + if not await verify_signature(k1, sig, key): + raise HTTPException(status_code=403, detail="Invalid signature") + safe_k1 = escape_valkey_tag(k1) + q = Query(f"@ln_k1:{{{safe_k1}}}") + res = vk.ft("idx:ln_k1").search(q) + if res.total == 1: + tx_id = res.docs[0]["id"].split(":")[-1] + tx = Transaction.load(TransactionTypes.onboarding.value, tx_id) + # OPTION 1 - new user registration - transaction data contains the entry object + if "entry" in tx._private_data: + user_details = tx._private_data["entry"] + u: User = User(id=str(uuid4()), username=user_details["username"], email=user_details["email"]) + session.add(u) + session.commit() + session.refresh(u) + uid: int = u.id + # TODO: OPTION 2 - user exists, only register device - the transaction should contain an existing user ID + else: + raise HTTPException(status_code=403, detail="Token invalid") + d: Device = Device(pubkey=key, user_id=uid, id=str(uuid4())) + session.add(d) + try: + session.commit() + except IntegrityError: + # TODO: Proper status code + raise HTTPException(status_code=403, detail="This public key already exists in a different device") + session.refresh(d) + # TODO: Pass the error back into the transaction if anything fails + tx.set_state("finished") + return None + + +# TODO: Implement for LN +# @router.post("/aqr/identify", response_model=None, responses={403: {"model": ErrorResponse}}) +# async def aqr_identify(db_session: SessionDep): +# return None + + +@router.get("/authenticate", response_model=None, responses={404: {"model": ErrorResponse}}) +async def ln_authenticate(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None): + if not await verify_signature(k1, sig, key): + raise HTTPException(status_code=403, detail="Invalid signature") + safe_k1 = escape_valkey_tag(k1) + q = Query(f"@ln_k1:{{{safe_k1}}}") + res = vk.ft("idx:ln_k1").search(q) + if res.total == 1: + tx_id = res.docs[0]["id"].split(":")[-1] + tx = Transaction.load(TransactionTypes.ln_oidc_login.value, tx_id) + # TODO: Exception handling here + # could be that a valid signature is presented but the device doesn't exist + device = session.exec(select(Device).where(Device.pubkey == key)).one() + user = session.exec(select(User).where(User.id == device.user_id)).one() + tx.add_private_data(".user", {"uid": user.id, "device-id": device.id}) + await store_oidc_code(tx) + tx.set_state("confirmed") + return None + + +@router.post("/qr", responses={404: {"model": ErrorResponse}}) +def qr_gen(rq: Transaction) -> QRDataResponse: + """Generates a QR code for the frontend""" + tx = Transaction.load(rq.tx_type.value, rq.tx_id) + # TODO: Fill in the appropriate action based on the transaction type + # TODO: Select the right endpoint based on the TX type + if tx.tx_type is TransactionTypes.ln_oidc_login: + endpoint = "authenticate" + action = "login" + elif tx.tx_type is TransactionTypes.onboarding: + endpoint = "register" + action = "register" + else: + # TODO: Proper status code + raise HTTPException(status_code=404, detail="Invalid transaction type") + if tx is None: + raise HTTPException(status_code=404, detail="Transaction not found") + elif "ln" not in tx.data or "k1" not in tx.data["ln"]: + # TODO: Choose a more suitable status code + raise HTTPException(status_code=404, detail="k1 not present in transaction") + link = "{}/lnurl-auth/{}?k1={}&tag={}&action={}".format(server_url, endpoint, tx.data["ln"]["k1"], "login", action) + qr_content = "lightning:{}".format(bech32.encode_bytes("lnurl", link.encode())) + qr_image = generate_qr(qr_content) + return QRDataResponse(link=qr_content, qr_image=qr_image) diff --git a/quorra/routers/login.py b/quorra/routers/login.py index e1a53af..c3d705b 100644 --- a/quorra/routers/login.py +++ b/quorra/routers/login.py @@ -6,16 +6,17 @@ from fastapi import HTTPException import json +import random -from ..classes import Device -from ..classes import ErrorResponse -from ..classes import QRDataResponse, Transaction, AqrOIDCLoginTransaction -from ..classes import TransactionTypes +from ..classes import ( + Device, + Transaction, TransactionTypes, + ErrorResponse, QRDataResponse +) from ..database import SessionDep from ..database import vk -from ..utils import generate_qr from ..config import server_url @@ -25,25 +26,13 @@ @router.get("/start", status_code=201) async def login_start(client_id: str, scope: str, nonce: str | None = None) -> Transaction: """Starts a new login session.""" - tx = Transaction.new("aqr-oidc-login") + tx = Transaction.new("ln-oidc-login") tx_id = tx.tx_id - qr_content = "quorra+{}/mobile/login?s={}".format(server_url, tx_id) - qr_image = generate_qr(qr_content) + k1 = random.randbytes(32).hex() + tx.add_data(".ln", {"k1": k1}) oidc_context = {"client-id": client_id, "scope": scope} if nonce is not None: oidc_context["nonce"] = nonce tx.add_data(".oidc_data", oidc_context) return tx -# TODO: Rotating login codes -@router.post("/qr", responses={404: {"model": ErrorResponse}}) -def qr_gen(rq: Transaction) -> QRDataResponse: - """Generates an AQR for the frontend""" - tx = Transaction.load(TransactionTypes.aqr_oidc_login.value, rq.tx_id) - if tx is None: - raise HTTPException(status_code=404, detail="Transaction not found") - if "oidc_data" not in tx.data: - raise HTTPException(status_code=404, detail="OIDC data is missing in transaction data") - link = "quorra+{}/mobile/login?s={}".format(server_url, tx.tx_id) - qr_image = generate_qr(link) - return QRDataResponse(link=link, qr_image=qr_image) diff --git a/quorra/routers/mobile.py b/quorra/routers/mobile.py deleted file mode 100644 index 6b5f3b4..0000000 --- a/quorra/routers/mobile.py +++ /dev/null @@ -1,117 +0,0 @@ -from typing import Annotated - -from fastapi import APIRouter, Header -from sqlmodel import select - -from fastapi import HTTPException - -from uuid import uuid4 -import json -import base64 - -from ..classes import DeviceRegistrationRequest, User, Device, Transaction -from ..classes import AQRMobileIdentifyRequest, AQRMobileAuthenticateRequest -from ..classes import ErrorResponse, AqrOIDCLoginTransactionStates - -from cryptography.hazmat.primitives.asymmetric import ed25519 -from cryptography.hazmat.primitives import serialization -from cryptography.exceptions import InvalidSignature - -from valkey.commands.search.query import Query - -from .oidc import store_oidc_code - -from ..database import SessionDep -from ..database import vk - -from ..utils import generate_qr -from ..utils import escape_valkey_tag -from ..config import server_url - -router = APIRouter() - -# TODO: Return the device ID for future use as a hint for the server -@router.post("/register", status_code=201, response_model=None, responses={403: {"model": ErrorResponse}}) -async def register_device(rq: DeviceRegistrationRequest, session: SessionDep, x_registration_token: Annotated[str, Header()]): - """Finishes device registration. - - Can either use a user or a device registration token. - - If a device registration token is used, a user ID is also required. - The device will be added to the matched user. - - If a user registration token is used, a new user is created. - """ - # OPTION 1 - new user registration - safe_token = escape_valkey_tag(x_registration_token) - q = Query(f"@device_registration_token:{{{safe_token}}}") - res = vk.ft("idx:device_registration_token").search(q) - if res.total == 1: - # Finds the matching transaction - tx_id = res.docs[0]["id"].split(":")[-1] - tx = Transaction.load("onboarding", tx_id) - user_details = tx._private_data["entry"] - u: User = User(id=str(uuid4()), username=user_details["username"], email=user_details["email"]) - session.add(u) - session.commit() - session.refresh(u) - tx.set_state("finished") - uid: int = u.id - # TODO: Adding a new device to an existing user - # TODO: OPTION 2 - user exists, only register device - # TODO: Fill with logic to find the user ID - else: - raise HTTPException(status_code=403, detail="Token invalid") - d: Device = Device(**rq.dict(), user_id=uid, id=str(uuid4())) - session.add(d) - session.commit() - session.refresh(d) - return None - - -# TODO: Use UUID hints from the device -@router.post("/aqr/identify", response_model=None, responses={403: {"model": ErrorResponse}}) -async def aqr_identify(rq: AQRMobileIdentifyRequest, db_session: SessionDep, session: str): - tx = Transaction.load("aqr-oidc-login", session) - if tx.state != AqrOIDCLoginTransactionStates.created.value: - raise HTTPException(status_code=403, detail="Session already identified") - devices = db_session.exec(select(Device)).all() - device_found = False - for device in devices: - key = ed25519.Ed25519PublicKey.from_public_bytes(base64.b64decode(device.pubkey)) - try: - key.verify(base64.b64decode(rq.signature), rq.message.encode('utf-8')) - except InvalidSignature: - pass - else: - device_found = True - matched_device = device - break - if device_found: - tx.add_private_data(".user", {"device-id": matched_device.id}) - tx.set_state("identified") - else: - raise HTTPException(status_code=403, detail="No matching device") - return None - - -@router.post("/aqr/authenticate", response_model=None, responses={404: {"model": ErrorResponse}}) -async def aqr_authenticate(rq: AQRMobileAuthenticateRequest, db_session: SessionDep, session: str): - tx = Transaction.load("aqr-oidc-login", session) - device_id = tx._private_data["user"]["device-id"] - device = db_session.exec(select(Device).where(Device.id == device_id)).one() - key = ed25519.Ed25519PublicKey.from_public_bytes(base64.b64decode(device.pubkey)) - try: - key.verify(base64.b64decode(rq.signature), rq.message.encode('utf-8')) - except InvalidSignature: - raise HTTPException(status_code=403, detail="Signature invalid") - else: - if rq.state == "accepted": - user = db_session.exec(select(User).where(User.id == device.user_id)).one() - tx.add_private_data(".user", {"uid": user.id}) - await store_oidc_code(tx) - tx.set_state("confirmed") - elif rq.state == "rejected": - pass - # TODO: Figure out what to do here - return None diff --git a/quorra/routers/oidc.py b/quorra/routers/oidc.py index 318cd88..051fe34 100644 --- a/quorra/routers/oidc.py +++ b/quorra/routers/oidc.py @@ -10,9 +10,10 @@ from ..config import server_url, oidc_clients -from ..classes import ErrorResponse -from ..classes import TokenResponse -from ..classes import User, Transaction +from ..classes import ( + User, Transaction, + TokenResponse, ErrorResponse +) from valkey.commands.search.query import Query @@ -96,9 +97,10 @@ async def token(db_session: SessionDep, request: Request, grant_type: str = Form safe_code = escape_valkey_tag(code) q = Query(f"@oidc_code:{{{safe_code}}}") res = vk.ft("idx:oidc_code").search(q) + print(res.total) if res.total == 1: tx_id = res.docs[0]["id"].split(":")[-1] - tx = Transaction.load("aqr-oidc-login", tx_id) + tx = Transaction.load("ln-oidc-login", tx_id) else: raise HTTPException(status_code=400, detail="invalid_grant") # Final checks before issuing the ID token @@ -137,7 +139,7 @@ def userinfo(authorization: Annotated[str | None, Header(alias="Authorization")] res = vk.ft("idx:oidc_at").search(q) if res.total == 1: tx_id = res.docs[0]["id"].split(":")[-1] - tx = Transaction.load("aqr-oidc-login", tx_id) + tx = Transaction.load("ln-oidc-login", tx_id) else: raise HTTPException(status_code=401, detail="unauthorized") user = tx._private_data["user"]["uid"] diff --git a/quorra/routers/onboarding.py b/quorra/routers/onboarding.py index ff355af..36332d9 100644 --- a/quorra/routers/onboarding.py +++ b/quorra/routers/onboarding.py @@ -7,18 +7,18 @@ from uuid import uuid4 import json +import random -from ..classes import OnboardingLink, User -from ..classes import OnboardingTransaction, OnboardingTransactionStates -from ..classes import RegistrationRequest, QRDataResponse -from ..classes import TransactionTypes, Transaction, TransactionUpdateRequest -from ..classes import ErrorResponse +from ..classes import ( + OnboardingLink, User, + OnboardingTransaction, OnboardingTransactionStates, + RegistrationRequest, + TransactionTypes, Transaction, TransactionUpdateRequest, + ErrorResponse +) -from ..database import SessionDep -from ..database import vk +from ..database import SessionDep, vk -from ..utils import generate_qr -from ..config import server_url from ..config import config router = APIRouter() @@ -60,7 +60,7 @@ async def init(req: RegistrationRequest, session: SessionDep) -> OnboardingTrans @router.post("/entry", responses={404: {"model": ErrorResponse}}) def entry(rq: TransactionUpdateRequest) -> Transaction: """Adds the user context to the onboarding transaction""" - token: str = str(uuid4()) + k1: str = str(random.randbytes(32).hex()) tx = Transaction.load(TransactionTypes.onboarding.value, rq.tx_id) if tx is None: raise HTTPException(status_code=404, detail="Transaction not found") @@ -68,28 +68,8 @@ def entry(rq: TransactionUpdateRequest) -> Transaction: raise HTTPException(status_code=403, detail="Transaction has already been filled") if "username" in rq.data and "email" in rq.data: entry_data = {"username": rq.data["username"], "email": rq.data["email"]} - tx.add_data(".device_registration", {"token": token}) + tx.add_data(".ln", {"k1": k1}) tx.add_private_data(".entry", entry_data) tx.set_state(OnboardingTransactionStates.filled.value) return tx -# TODO: Rotating device registration tokens -@router.post("/qr", responses={404: {"model": ErrorResponse}}) -def qr_gen(rq: Transaction) -> QRDataResponse: - """Generates an onboarding QR code for the frontend""" - tx = Transaction.load(TransactionTypes.onboarding.value, rq.tx_id) - if tx is None: - raise HTTPException(status_code=404, detail="Transaction not found") - if "device_registration" not in tx.data or "token" not in tx.data["device_registration"]: - raise HTTPException(status_code=404, detail="Mobile token is missing in transaction data") - token = tx.data["device_registration"]["token"] - link = "quorra+{}/mobile/register?t={}".format(server_url, token) - qr_image = generate_qr(link) - return QRDataResponse(link=link, qr_image=qr_image) - -@router.post("/finish", responses={404: {"model": ErrorResponse}}) -def finish(rq: TransactionUpdateRequest) -> Transaction: - """Debug only - finish a transaction""" - tx = Transaction.load(TransactionTypes.onboarding.value, rq.tx_id) - tx.set_state(OnboardingTransactionStates.finished.value) - return tx diff --git a/quorra/routers/tx.py b/quorra/routers/tx.py index 331caf9..7d4ad6c 100644 --- a/quorra/routers/tx.py +++ b/quorra/routers/tx.py @@ -37,9 +37,3 @@ async def get_transaction(rq: TransactionGetRequest) -> Transaction: if tx.state != "finished": tx.prolong() return tx - -@router.post("/create_transaction", status_code=201, response_model=Transaction, responses={401: {"model": ErrorResponse}, 403: {"model": ErrorResponse}, 404: {"model": ErrorResponse}}) -async def create_transaction(rq: TransactionCreateRequest, session: SessionDep): - """Start a new transaction.""" - tx = Transaction.new(rq.tx_type.value) - return tx From 424695a8a69da50d0bbd62c8fcbbada7a17f819e Mon Sep 17 00:00:00 2001 From: k8ie Date: Sun, 1 Mar 2026 20:56:50 +0100 Subject: [PATCH 02/19] Remove todo --- quorra/routers/lnurlauth.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/quorra/routers/lnurlauth.py b/quorra/routers/lnurlauth.py index b38377a..eb3e7ee 100644 --- a/quorra/routers/lnurlauth.py +++ b/quorra/routers/lnurlauth.py @@ -121,8 +121,6 @@ async def ln_authenticate(session: SessionDep, k1: str, tag: str, sig: str, key: def qr_gen(rq: Transaction) -> QRDataResponse: """Generates a QR code for the frontend""" tx = Transaction.load(rq.tx_type.value, rq.tx_id) - # TODO: Fill in the appropriate action based on the transaction type - # TODO: Select the right endpoint based on the TX type if tx.tx_type is TransactionTypes.ln_oidc_login: endpoint = "authenticate" action = "login" From 4ed71704e6dd96786f864d6265d9ed5d263b741d Mon Sep 17 00:00:00 2001 From: k8ie Date: Sun, 1 Mar 2026 21:01:33 +0100 Subject: [PATCH 03/19] Fix PEP 508 --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 91b9a66..bc4911c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "python-multipart", "deepmerge", "python-jose", - "git+https://github.com/quorra-Auth/bech32" + "bech32 @ git+https://github.com/Quorra-Auth/bech32.git" ] [tool.setuptools] From c06b0801c0cdf3de2ec0c43b9a61fea026ec8834 Mon Sep 17 00:00:00 2001 From: k8ie Date: Sun, 1 Mar 2026 21:31:07 +0100 Subject: [PATCH 04/19] Better LN status handling --- quorra/classes.py | 9 +++++++++ quorra/routers/lnurlauth.py | 12 +++++++----- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/quorra/classes.py b/quorra/classes.py index 15eb9f4..4dde046 100644 --- a/quorra/classes.py +++ b/quorra/classes.py @@ -148,3 +148,12 @@ class AqrOIDCLoginTransactionStates(str, Enum): confirmed = "confirmed" rejected = "rejected" token_issued = "token-issued" + + +class LNStatusEnum(str, Enum): + ok = "OK" + error = "error" + +class LNStatusResponse(BaseModel): + status: LNStatusEnum + reason: str | None = None diff --git a/quorra/routers/lnurlauth.py b/quorra/routers/lnurlauth.py index eb3e7ee..04d6f97 100644 --- a/quorra/routers/lnurlauth.py +++ b/quorra/routers/lnurlauth.py @@ -16,7 +16,7 @@ from ..classes import ( User, Device, Transaction, TransactionTypes, - ErrorResponse, QRDataResponse + ErrorResponse, QRDataResponse, LNStatusResponse, LNStatusEnum ) from cryptography.hazmat.primitives.asymmetric.ec import ( @@ -56,8 +56,9 @@ async def verify_signature(k1: str, sig: str, key: str) -> bool: return True +# TODO: Correct error response @router.get("/register", response_model=None, responses={403: {"model": ErrorResponse}}) -async def ln_register(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None): +async def ln_register(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None) -> LNStatusResponse: """Finishes device registration.""" if not await verify_signature(k1, sig, key): raise HTTPException(status_code=403, detail="Invalid signature") @@ -88,7 +89,7 @@ async def ln_register(session: SessionDep, k1: str, tag: str, sig: str, key: str session.refresh(d) # TODO: Pass the error back into the transaction if anything fails tx.set_state("finished") - return None + return LNStatusResponse(status=LNStatusEnum.ok) # TODO: Implement for LN @@ -97,8 +98,9 @@ async def ln_register(session: SessionDep, k1: str, tag: str, sig: str, key: str # return None +# TODO: Correct error response @router.get("/authenticate", response_model=None, responses={404: {"model": ErrorResponse}}) -async def ln_authenticate(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None): +async def ln_authenticate(session: SessionDep, k1: str, tag: str, sig: str, key: str, action: str | None = None) -> LNStatusResponse: if not await verify_signature(k1, sig, key): raise HTTPException(status_code=403, detail="Invalid signature") safe_k1 = escape_valkey_tag(k1) @@ -114,7 +116,7 @@ async def ln_authenticate(session: SessionDep, k1: str, tag: str, sig: str, key: tx.add_private_data(".user", {"uid": user.id, "device-id": device.id}) await store_oidc_code(tx) tx.set_state("confirmed") - return None + return LNStatusResponse(status=LNStatusEnum.ok) @router.post("/qr", responses={404: {"model": ErrorResponse}}) From 2d6aa424ae5ea2108b31f60deb995916d646e614 Mon Sep 17 00:00:00 2001 From: k8ie Date: Sun, 1 Mar 2026 21:56:42 +0100 Subject: [PATCH 05/19] Stop the leaks lol --- quorra/routers/oidc.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/quorra/routers/oidc.py b/quorra/routers/oidc.py index 051fe34..c32959c 100644 --- a/quorra/routers/oidc.py +++ b/quorra/routers/oidc.py @@ -97,7 +97,6 @@ async def token(db_session: SessionDep, request: Request, grant_type: str = Form safe_code = escape_valkey_tag(code) q = Query(f"@oidc_code:{{{safe_code}}}") res = vk.ft("idx:oidc_code").search(q) - print(res.total) if res.total == 1: tx_id = res.docs[0]["id"].split(":")[-1] tx = Transaction.load("ln-oidc-login", tx_id) @@ -130,7 +129,6 @@ async def token(db_session: SessionDep, request: Request, grant_type: str = Form # TODO: Implement checking scopes @router.get("/userinfo", responses={401: {"model": ErrorResponse}}) def userinfo(authorization: Annotated[str | None, Header(alias="Authorization")] = None): - print(authorization) if not authorization.startswith("Bearer "): raise HTTPException(status_code=401, detail="unauthorized") access_token = authorization.removeprefix("Bearer ") From 245eb78b77d7a9658831d58f20135b8644cad06b Mon Sep 17 00:00:00 2001 From: k8ie Date: Wed, 4 Mar 2026 15:44:26 +0100 Subject: [PATCH 06/19] Try adding a custom parameter --- quorra/routers/lnurlauth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/quorra/routers/lnurlauth.py b/quorra/routers/lnurlauth.py index 04d6f97..0acd5be 100644 --- a/quorra/routers/lnurlauth.py +++ b/quorra/routers/lnurlauth.py @@ -137,7 +137,7 @@ def qr_gen(rq: Transaction) -> QRDataResponse: elif "ln" not in tx.data or "k1" not in tx.data["ln"]: # TODO: Choose a more suitable status code raise HTTPException(status_code=404, detail="k1 not present in transaction") - link = "{}/lnurl-auth/{}?k1={}&tag={}&action={}".format(server_url, endpoint, tx.data["ln"]["k1"], "login", action) + link = "{}/lnurl-auth/{}?k1={}&tag={}&action={}&server=quorra".format(server_url, endpoint, tx.data["ln"]["k1"], "login", action) qr_content = "lightning:{}".format(bech32.encode_bytes("lnurl", link.encode())) qr_image = generate_qr(qr_content) return QRDataResponse(link=qr_content, qr_image=qr_image) From 0a38201e558cab2446bc69dca573b59a515c4185 Mon Sep 17 00:00:00 2001 From: k8ie Date: Wed, 4 Mar 2026 19:34:03 +0100 Subject: [PATCH 07/19] Give the onboarding FE a nice overhaul --- quorra/fe/onboard/index.html | 42 ++++++++------ quorra/fe/style.css | 109 +++++++++++++++++++++++++++++------ 2 files changed, 116 insertions(+), 35 deletions(-) diff --git a/quorra/fe/onboard/index.html b/quorra/fe/onboard/index.html index e423d11..2dabda3 100644 --- a/quorra/fe/onboard/index.html +++ b/quorra/fe/onboard/index.html @@ -3,24 +3,30 @@ - + Quorra Onboarding -

Welcome to Quorra!

-
-
-

Quorra uses a mobile token to sign you in

-

Download one of our apps to get started

+
+

Welcome to Quorra!

+

Quorra allows you to sign in using QR codes

+

Download one of our apps to get started:

Voucher for Linux

Flare for Android

- +
OR
+

+ You can use your Lightning wallet,
+ Quorra is powered by Lightning ⚡ +

+
-