7 Commits

Author SHA1 Message Date
Renovate Bot
c7df68ac7c chore(deps): update dependency react-router-dom to v7.14.0
All checks were successful
CI / backend-tests (pull_request) Successful in 31s
CI / frontend-tests (pull_request) Successful in 28s
2026-04-02 21:03:00 +00:00
d8fec0e5d7 fix:add debugging endpoint for auth issues
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:15:25 +01:00
c9b09b8250 fix: fix supabase auth url
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 30s
2026-03-22 12:10:03 +01:00
fde1867863 fix: add logging to debug auth issues
All checks were successful
CI / backend-tests (push) Successful in 29s
CI / frontend-tests (push) Successful in 28s
2026-03-22 12:01:28 +01:00
ce9d08963f Merge pull request 'Fix intermittent 401 errors and add ES256 JWT support' (#86) from feature/fix-intermittent-401-errors into develop
All checks were successful
CI / backend-tests (push) Successful in 30s
CI / frontend-tests (push) Successful in 29s
Reviewed-on: #86
2026-03-22 11:53:48 +01:00
c5959cfd14 chore: mark ES256 JWT support bean as completed
All checks were successful
CI / backend-tests (pull_request) Successful in 33s
CI / frontend-tests (pull_request) Successful in 33s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:53:13 +01:00
e935bc4d32 fix: accept ES256 (ECC P-256) JWT keys alongside RS256 in backend auth
Supabase JWT key was switched to ECC P-256, but the JWKS verification
only accepted RS256. Add ES256 to the accepted algorithms list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 11:52:42 +01:00
10 changed files with 176 additions and 24 deletions

View File

@@ -5,7 +5,7 @@ status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-22T09:41:57Z created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:45:28Z updated_at: 2026-03-22T09:45:38Z
parent: nuzlocke-tracker-bw1m parent: nuzlocke-tracker-bw1m
blocking: blocking:
- nuzlocke-tracker-2fp1 - nuzlocke-tracker-2fp1

View File

@@ -0,0 +1,28 @@
---
# nuzlocke-tracker-95g1
title: 'Crash: Hide edit controls for non-owners in frontend'
status: completed
type: bug
priority: high
created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:59Z
parent: nuzlocke-tracker-bw1m
blocking:
- nuzlocke-tracker-i2va
---
Bean was found in 'in-progress' status on startup but no agent was running.
This likely indicates a crash or unexpected termination.
Manual review required before retrying.
Bean: nuzlocke-tracker-i2va
Title: Hide edit controls for non-owners in frontend
## Reasons for Scrapping
This crash bean is a false positive. The original task (nuzlocke-tracker-i2va) was already completed and merged to `develop` before this crash bean was created:
- Commit `3bd24fc`: fix: hide edit controls for non-owners in frontend
- Commit `118dbca`: chore: mark bean nuzlocke-tracker-i2va as completed
No additional work required.

View File

@@ -1,11 +1,11 @@
--- ---
# nuzlocke-tracker-9rm8 # nuzlocke-tracker-9rm8
title: 'Crash: Optional TOTP MFA for email/password accounts' title: 'Crash: Optional TOTP MFA for email/password accounts'
status: scrapped status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-22T09:41:57Z created_at: 2026-03-22T09:41:57Z
updated_at: 2026-03-22T09:46:14Z updated_at: 2026-03-22T09:46:30Z
parent: nuzlocke-tracker-bw1m parent: nuzlocke-tracker-bw1m
blocking: blocking:
- nuzlocke-tracker-f2hs - nuzlocke-tracker-f2hs

View File

@@ -0,0 +1,15 @@
---
# nuzlocke-tracker-snft
title: Support ES256 (ECC P-256) JWT keys in backend auth
status: completed
type: bug
priority: normal
created_at: 2026-03-22T10:51:30Z
updated_at: 2026-03-22T10:59:46Z
---
Backend JWKS verification only accepts RS256 algorithm, but Supabase JWT key was switched to ECC P-256 (ES256). This causes 401 errors on all authenticated requests. Fix: accept both RS256 and ES256 in the algorithms list, and update tests accordingly.
## Summary of Changes\n\nAdded ES256 to the accepted JWT algorithms in `_verify_jwt()` so ECC P-256 keys from Supabase are verified correctly alongside RSA keys. Added corresponding test with EC key fixtures.
Deployed to production via PR #86 merge on 2026-03-22.

View File

@@ -5,7 +5,7 @@ status: completed
type: bug type: bug
priority: high priority: high
created_at: 2026-03-21T21:50:48Z created_at: 2026-03-21T21:50:48Z
updated_at: 2026-03-22T09:01:42Z updated_at: 2026-03-22T09:44:54Z
--- ---
## Problem ## Problem

View File

@@ -1,6 +1,10 @@
from fastapi import APIRouter import urllib.request
from fastapi import APIRouter, Request
from sqlalchemy import text from sqlalchemy import text
from app.core.auth import _build_jwks_url, _extract_token, _get_jwks_client
from app.core.config import settings
from app.core.database import async_session from app.core.database import async_session
router = APIRouter(tags=["health"]) router = APIRouter(tags=["health"])
@@ -23,3 +27,45 @@ async def health_check():
async def root(): async def root():
"""Root endpoint.""" """Root endpoint."""
return {"message": "Nuzlocke Tracker API", "docs": "/docs"} return {"message": "Nuzlocke Tracker API", "docs": "/docs"}
@router.get("/auth-debug")
async def auth_debug(request: Request):
"""Temporary diagnostic endpoint for auth debugging."""
result: dict = {}
# Config
result["supabase_url"] = settings.supabase_url
result["has_jwt_secret"] = bool(settings.supabase_jwt_secret)
result["jwks_url"] = (
_build_jwks_url(settings.supabase_url) if settings.supabase_url else None
)
# JWKS fetch
jwks_url = result["jwks_url"]
if jwks_url:
try:
with urllib.request.urlopen(jwks_url, timeout=5) as resp:
result["jwks_status"] = resp.status
result["jwks_body"] = resp.read().decode()
except Exception as e:
result["jwks_fetch_error"] = str(e)
# JWKS client
client = _get_jwks_client()
result["jwks_client_exists"] = client is not None
# Token info (header only, no secrets)
token = _extract_token(request)
if token:
import jwt
try:
header = jwt.get_unverified_header(token)
result["token_header"] = header
except Exception as e:
result["token_header_error"] = str(e)
else:
result["token"] = "not provided"
return result

View File

@@ -1,3 +1,4 @@
import logging
from dataclasses import dataclass from dataclasses import dataclass
from uuid import UUID from uuid import UUID
@@ -12,6 +13,7 @@ from app.core.database import get_session
from app.models.nuzlocke_run import NuzlockeRun from app.models.nuzlocke_run import NuzlockeRun
from app.models.user import User from app.models.user import User
logger = logging.getLogger(__name__)
_jwks_client: PyJWKClient | None = None _jwks_client: PyJWKClient | None = None
@@ -24,11 +26,21 @@ class AuthUser:
role: str | None = None role: str | None = None
def _build_jwks_url(base_url: str) -> str:
"""Build the JWKS URL, adding /auth/v1 prefix for Supabase Cloud."""
base = base_url.rstrip("/")
if "/auth/v1" in base:
return f"{base}/.well-known/jwks.json"
# Supabase Cloud URLs need the /auth/v1 prefix;
# local GoTrue serves JWKS at root but uses HS256 fallback anyway.
return f"{base}/auth/v1/.well-known/jwks.json"
def _get_jwks_client() -> PyJWKClient | None: def _get_jwks_client() -> PyJWKClient | None:
"""Get or create a cached JWKS client.""" """Get or create a cached JWKS client."""
global _jwks_client global _jwks_client
if _jwks_client is None and settings.supabase_url: if _jwks_client is None and settings.supabase_url:
jwks_url = f"{settings.supabase_url.rstrip('/')}/.well-known/jwks.json" jwks_url = _build_jwks_url(settings.supabase_url)
_jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300) _jwks_client = PyJWKClient(jwks_url, cache_jwk_set=True, lifespan=300)
return _jwks_client return _jwks_client
@@ -60,7 +72,7 @@ def _verify_jwt_hs256(token: str) -> dict | None:
def _verify_jwt(token: str) -> dict | None: def _verify_jwt(token: str) -> dict | None:
"""Verify JWT using JWKS (RS256), falling back to HS256 shared secret.""" """Verify JWT using JWKS (RS256/ES256), falling back to HS256 shared secret."""
client = _get_jwks_client() client = _get_jwks_client()
if client: if client:
try: try:
@@ -68,15 +80,17 @@ def _verify_jwt(token: str) -> dict | None:
return jwt.decode( return jwt.decode(
token, token,
signing_key.key, signing_key.key,
algorithms=["RS256"], algorithms=["RS256", "ES256"],
audience="authenticated", audience="authenticated",
) )
except jwt.InvalidTokenError: except jwt.InvalidTokenError as e:
pass logger.warning("JWKS JWT validation failed: %s", e)
except PyJWKClientError: except PyJWKClientError as e:
pass logger.warning("JWKS client error: %s", e)
except PyJWKSetError: except PyJWKSetError as e:
pass logger.warning("JWKS set error: %s", e)
else:
logger.warning("No JWKS client available (SUPABASE_URL not set?)")
return _verify_jwt_hs256(token) return _verify_jwt_hs256(token)

View File

@@ -4,7 +4,7 @@ from uuid import UUID
import jwt import jwt
import pytest import pytest
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import ec, rsa
from httpx import ASGITransport, AsyncClient from httpx import ASGITransport, AsyncClient
from app.core.auth import AuthUser, get_current_user, require_admin, require_auth from app.core.auth import AuthUser, get_current_user, require_admin, require_auth
@@ -73,6 +73,55 @@ def mock_jwks_client(rsa_key_pair):
return mock_client return mock_client
@pytest.fixture(scope="module")
def ec_key_pair():
"""Generate EC P-256 key pair for testing."""
private_key = ec.generate_private_key(ec.SECP256R1())
public_key = private_key.public_key()
return private_key, public_key
@pytest.fixture
def valid_es256_token(ec_key_pair):
"""Generate a valid ES256 JWT token."""
private_key, _ = ec_key_pair
payload = {
"sub": "user-456",
"email": "ec-user@example.com",
"role": "authenticated",
"aud": "authenticated",
"exp": int(time.time()) + 3600,
}
return jwt.encode(payload, private_key, algorithm="ES256")
@pytest.fixture
def mock_jwks_client_ec(ec_key_pair):
"""Create a mock JWKS client that returns our test EC public key."""
_, public_key = ec_key_pair
mock_client = MagicMock()
mock_signing_key = MagicMock()
mock_signing_key.key = public_key
mock_client.get_signing_key_from_jwt.return_value = mock_signing_key
return mock_client
async def test_get_current_user_valid_es256_token(
valid_es256_token, mock_jwks_client_ec
):
"""Test get_current_user works with ES256 (ECC P-256) tokens."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client_ec):
class MockRequest:
headers = {"Authorization": f"Bearer {valid_es256_token}"}
user = get_current_user(MockRequest())
assert user is not None
assert user.id == "user-456"
assert user.email == "ec-user@example.com"
assert user.role == "authenticated"
async def test_get_current_user_valid_token(valid_token, mock_jwks_client): async def test_get_current_user_valid_token(valid_token, mock_jwks_client):
"""Test get_current_user returns user for valid token.""" """Test get_current_user returns user for valid token."""
with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client):

View File

@@ -17,7 +17,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "7.13.1", "react-router-dom": "7.14.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "2.0.7" "sonner": "2.0.7"
}, },
@@ -4385,9 +4385,9 @@
} }
}, },
"node_modules/react-router": { "node_modules/react-router": {
"version": "7.13.1", "version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==", "integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"cookie": "^1.0.1", "cookie": "^1.0.1",
@@ -4407,12 +4407,12 @@
} }
}, },
"node_modules/react-router-dom": { "node_modules/react-router-dom": {
"version": "7.13.1", "version": "7.14.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==", "integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"react-router": "7.13.1" "react-router": "7.14.0"
}, },
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"

View File

@@ -25,7 +25,7 @@
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
"react-router-dom": "7.13.1", "react-router-dom": "7.14.0",
"remark-gfm": "^4.0.1", "remark-gfm": "^4.0.1",
"sonner": "2.0.7" "sonner": "2.0.7"
}, },