Compare commits
21 Commits
renovate/r
...
712badb69d
| Author | SHA1 | Date | |
|---|---|---|---|
| 712badb69d | |||
| c40dd38c99 | |||
| 98121d9954 | |||
| f340f8fd0d | |||
| d2fa9e46df | |||
| f770e4a785 | |||
| 013a45ab56 | |||
| 321b940398 | |||
| e21a8acc60 | |||
| f15e530130 | |||
| e533a3404e | |||
| a944da2204 | |||
| 012cfb96cd | |||
| e3e015852c | |||
| 59b4f7f28c | |||
| e212251da8 | |||
| f49c8cee85 | |||
| b34f1083a3 | |||
| b85668c233 | |||
| 45cbff7672 | |||
| 51b47dbfb0 |
@@ -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:38Z
|
updated_at: 2026-03-22T09:45:28Z
|
||||||
parent: nuzlocke-tracker-bw1m
|
parent: nuzlocke-tracker-bw1m
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-2fp1
|
- nuzlocke-tracker-2fp1
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
---
|
|
||||||
# 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.
|
|
||||||
@@ -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: completed
|
status: scrapped
|
||||||
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:30Z
|
updated_at: 2026-03-22T09:46:14Z
|
||||||
parent: nuzlocke-tracker-bw1m
|
parent: nuzlocke-tracker-bw1m
|
||||||
blocking:
|
blocking:
|
||||||
- nuzlocke-tracker-f2hs
|
- nuzlocke-tracker-f2hs
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
---
|
|
||||||
# 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.
|
|
||||||
@@ -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:44:54Z
|
updated_at: 2026-03-22T09:01:42Z
|
||||||
---
|
---
|
||||||
|
|
||||||
## Problem
|
## Problem
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import urllib.request
|
from fastapi import APIRouter
|
||||||
|
|
||||||
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"])
|
||||||
@@ -27,45 +23,3 @@ 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
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import logging
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from uuid import UUID
|
from uuid import UUID
|
||||||
|
|
||||||
@@ -13,7 +12,6 @@ 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
|
||||||
|
|
||||||
|
|
||||||
@@ -26,21 +24,11 @@ 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 = _build_jwks_url(settings.supabase_url)
|
jwks_url = f"{settings.supabase_url.rstrip('/')}/.well-known/jwks.json"
|
||||||
_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
|
||||||
|
|
||||||
@@ -72,7 +60,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/ES256), falling back to HS256 shared secret."""
|
"""Verify JWT using JWKS (RS256), falling back to HS256 shared secret."""
|
||||||
client = _get_jwks_client()
|
client = _get_jwks_client()
|
||||||
if client:
|
if client:
|
||||||
try:
|
try:
|
||||||
@@ -80,17 +68,15 @@ def _verify_jwt(token: str) -> dict | None:
|
|||||||
return jwt.decode(
|
return jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256", "ES256"],
|
algorithms=["RS256"],
|
||||||
audience="authenticated",
|
audience="authenticated",
|
||||||
)
|
)
|
||||||
except jwt.InvalidTokenError as e:
|
except jwt.InvalidTokenError:
|
||||||
logger.warning("JWKS JWT validation failed: %s", e)
|
pass
|
||||||
except PyJWKClientError as e:
|
except PyJWKClientError:
|
||||||
logger.warning("JWKS client error: %s", e)
|
pass
|
||||||
except PyJWKSetError as e:
|
except PyJWKSetError:
|
||||||
logger.warning("JWKS set error: %s", e)
|
pass
|
||||||
else:
|
|
||||||
logger.warning("No JWKS client available (SUPABASE_URL not set?)")
|
|
||||||
return _verify_jwt_hs256(token)
|
return _verify_jwt_hs256(token)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
import jwt
|
import jwt
|
||||||
import pytest
|
import pytest
|
||||||
from cryptography.hazmat.primitives.asymmetric import ec, rsa
|
from cryptography.hazmat.primitives.asymmetric import 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,55 +73,6 @@ 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):
|
||||||
|
|||||||
16
frontend/package-lock.json
generated
16
frontend/package-lock.json
generated
@@ -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.14.0",
|
"react-router-dom": "7.13.1",
|
||||||
"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.14.0",
|
"version": "7.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.1.tgz",
|
||||||
"integrity": "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ==",
|
"integrity": "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA==",
|
||||||
"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.14.0",
|
"version": "7.13.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.1.tgz",
|
||||||
"integrity": "sha512-2G3ajSVSZMEtmTjIklRWlNvo8wICEpLihfD/0YMDxbWK2UyP5EGfnoIn9AIQGnF3G/FX0MRbHXdFcD+rL1ZreQ==",
|
"integrity": "sha512-UJnV3Rxc5TgUPJt2KJpo1Jpy0OKQr0AjgbZzBFjaPJcFOb2Y8jA5H3LT8HUJAiRLlWrEXWHbF1Z4SCZaQjWDHw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.14.0"
|
"react-router": "7.13.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
|
|||||||
@@ -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.14.0",
|
"react-router-dom": "7.13.1",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"sonner": "2.0.7"
|
"sonner": "2.0.7"
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user