From fee833b908364b31742670d9d2581b34315b63f4 Mon Sep 17 00:00:00 2001 From: Leo Vasanko Date: Mon, 16 Feb 2026 23:55:42 +0000 Subject: [PATCH] Made PKCE and nonce optional, only when client wants them. --- paskia/authcode.py | 6 +++--- paskia/fastapi/oid.py | 35 ++++++++++++++++++----------------- paskia/fastapi/ws.py | 32 +++++++++++++------------------- 3 files changed, 34 insertions(+), 39 deletions(-) diff --git a/paskia/authcode.py b/paskia/authcode.py index a239aae..2031a4e 100644 --- a/paskia/authcode.py +++ b/paskia/authcode.py @@ -23,15 +23,15 @@ AUTH_CODE_LIFETIME = timedelta(seconds=60) class OIDCCode(msgspec.Struct): """An OIDC authorization code pending token exchange. - PKCE uses S256 only (verified at auth time). + PKCE uses S256 only when provided (verified at token exchange). """ session_key: str created: datetime redirect_uri: str scope: str - nonce: str - code_challenge: str + nonce: str | None = None + code_challenge: str | None = None class CookieCode(msgspec.Struct): diff --git a/paskia/fastapi/oid.py b/paskia/fastapi/oid.py index 5336e22..f4b4754 100644 --- a/paskia/fastapi/oid.py +++ b/paskia/fastapi/oid.py @@ -196,23 +196,24 @@ async def _handle_authorization_code( status_code=400, ) - # Verify PKCE (S256 only, enforced at auth time) - if not code_verifier: - return JSONResponse( - { - "error": "invalid_grant", - "error_description": "Missing code_verifier", - }, - status_code=400, - ) - if not _verify_pkce(code_verifier, oidc_code.code_challenge): - return JSONResponse( - { - "error": "invalid_grant", - "error_description": "Invalid code_verifier", - }, - status_code=400, - ) + # Verify PKCE if code_challenge was provided at authorization time + if oidc_code.code_challenge: + if not code_verifier: + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Missing code_verifier", + }, + status_code=400, + ) + if not _verify_pkce(code_verifier, oidc_code.code_challenge): + return JSONResponse( + { + "error": "invalid_grant", + "error_description": "Invalid code_verifier", + }, + status_code=400, + ) # Get user from session user = db.data().users.get(session.user_uuid) diff --git a/paskia/fastapi/ws.py b/paskia/fastapi/ws.py index 87d82e3..ebe0c94 100644 --- a/paskia/fastapi/ws.py +++ b/paskia/fastapi/ws.py @@ -158,25 +158,19 @@ async def websocket_authenticate( await ws.send_json({"status": 400, "detail": "Scope must include openid"}) return - # PKCE is required with S256 - if not code_challenge: - await ws.send_json( - {"status": 400, "detail": "PKCE code_challenge is required"} - ) - return - if code_challenge_method and code_challenge_method != "S256": - await ws.send_json( - { - "status": 400, - "detail": "Only S256 code_challenge_method is supported", - } - ) - return - - # Nonce is required for OIDC - if not nonce: - await ws.send_json({"status": 400, "detail": "nonce is required for OIDC"}) - return + # PKCE: when code_challenge is present, only S256 is supported + # If method is omitted, default to S256 per best practice (not "plain" per RFC 7636) + # When code_challenge is absent, ignore code_challenge_method entirely + if code_challenge: + if code_challenge_method and code_challenge_method != "S256": + await ws.send_json( + { + "status": 400, + "detail": "Only S256 code_challenge_method is supported", + } + ) + return + # Default to S256 when method not specified (implicit) # Validate state parameter if provided (defensive against injection) if state: