From 177c02006a01f69e29f416f3f5240aa675e7bd79 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Mar 2026 14:01:31 +0100 Subject: [PATCH 1/2] feat: migrate JWT verification from HS256 shared secret to JWKS Replace symmetric HS256 JWT verification with asymmetric RS256 using JWKS. Backend now fetches and caches public keys from Supabase's JWKS endpoint instead of using a shared secret. - Add cryptography dependency for RS256 support - Use PyJWKClient to fetch/cache JWKS from {SUPABASE_URL}/.well-known/jwks.json - Remove SUPABASE_JWT_SECRET from config, docker-compose, deploy workflow, .env - Update tests to use RS256 tokens with mocked JWKS client Co-Authored-By: Claude Opus 4.6 --- ...t-verification-from-hs256-shared-secret.md | 21 +- .env.example | 4 +- .github/workflows/deploy.yml | 2 +- backend/.env.example | 3 +- backend/pyproject.toml | 1 + backend/src/app/core/auth.py | 24 +- backend/src/app/core/config.py | 1 - backend/tests/test_auth.py | 216 +++++++++--------- backend/uv.lock | 79 +++++++ docker-compose.prod.yml | 2 +- docker-compose.yml | 3 +- 11 files changed, 233 insertions(+), 123 deletions(-) diff --git a/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md b/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md index d98bae8..4f2fdee 100644 --- a/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md +++ b/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md @@ -1,11 +1,26 @@ --- # nuzlocke-tracker-t9aj title: Migrate JWT verification from HS256 shared secret to asymmetric keys (JWKS) -status: todo +status: completed type: task priority: low created_at: 2026-03-21T11:14:29Z -updated_at: 2026-03-21T11:14:29Z +updated_at: 2026-03-21T13:01:33Z --- -The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [ ] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [ ] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [ ] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [ ] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts +The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [x] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [x] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [x] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [x] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts + + +## Summary of Changes + +- Added `cryptography==45.0.3` dependency for RS256 support +- Updated `auth.py` to use `PyJWKClient` for fetching and caching JWKS public keys from `{SUPABASE_URL}/.well-known/jwks.json` +- Changed JWT verification from HS256 to RS256 +- Removed `supabase_jwt_secret` from config.py +- Updated docker-compose.yml: removed `SUPABASE_JWT_SECRET`, backend now uses JWKS from GoTrue URL +- Updated docker-compose.prod.yml: replaced `SUPABASE_JWT_SECRET` with `SUPABASE_URL` +- Updated deploy.yml: deploy workflow now writes `SUPABASE_URL` instead of `SUPABASE_JWT_SECRET` +- Updated .env.example files: removed `SUPABASE_JWT_SECRET` references +- Rewrote tests to use RS256 tokens with mocked JWKS client + +**Note:** For production, add `SUPABASE_URL` to your GitHub secrets (should point to your Supabase project URL like `https://your-project.supabase.co`). diff --git a/.env.example b/.env.example index 4692ef6..aba12cf 100644 --- a/.env.example +++ b/.env.example @@ -2,15 +2,13 @@ DEBUG=true DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke -# Supabase Auth (backend) +# Supabase Auth (backend uses JWKS from this URL for JWT verification) # For local dev with GoTrue container: SUPABASE_URL=http://localhost:9999 SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0MDQwNjEzLCJleHAiOjIwODk0MDA2MTN9.EV6tRj7gLqoiT-l2vDFw_67myqRjwpcZTuRb3Xs1nr4 -SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long # For production, replace with your Supabase cloud values: # SUPABASE_URL=https://your-project.supabase.co # SUPABASE_ANON_KEY=your-anon-key -# SUPABASE_JWT_SECRET=your-jwt-secret # Frontend settings (used by Vite) VITE_API_URL=http://localhost:8000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c46ab43..ed00105 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: # Write .env from secrets (overwrites any existing file) printf '%s\n' \ "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \ - "SUPABASE_JWT_SECRET=${{ secrets.SUPABASE_JWT_SECRET }}" \ + "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" \ | $SSH_CMD "cat > '${DEPLOY_DIR}/.env'" $SCP_CMD docker-compose.prod.yml "root@192.168.1.10:${DEPLOY_DIR}/docker-compose.yml" diff --git a/backend/.env.example b/backend/.env.example index a91efe4..9b444f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,6 @@ API_V1_PREFIX="/api/v1" # Database settings DATABASE_URL="sqlite:///./nuzlocke.db" -# Supabase Auth +# Supabase Auth (JWKS used for JWT verification) SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your-anon-key -SUPABASE_JWT_SECRET=your-jwt-secret diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c5922ea..5ef9a7b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "asyncpg==0.31.0", "alembic==1.18.4", "PyJWT==2.12.1", + "cryptography==45.0.3", ] [project.optional-dependencies] diff --git a/backend/src/app/core/auth.py b/backend/src/app/core/auth.py index 6a5b392..d7c7b7c 100644 --- a/backend/src/app/core/auth.py +++ b/backend/src/app/core/auth.py @@ -3,6 +3,7 @@ from uuid import UUID import jwt from fastapi import Depends, HTTPException, Request, status +from jwt import PyJWKClient, PyJWKClientError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -10,6 +11,8 @@ from app.core.config import settings from app.core.database import get_session from app.models.user import User +_jwks_client: PyJWKClient | None = None + @dataclass class AuthUser: @@ -20,6 +23,15 @@ class AuthUser: role: str | None = None +def _get_jwks_client() -> PyJWKClient | None: + """Get or create a cached JWKS client.""" + global _jwks_client + if _jwks_client is None and 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) + return _jwks_client + + def _extract_token(request: Request) -> str | None: """Extract Bearer token from Authorization header.""" auth_header = request.headers.get("Authorization") @@ -32,14 +44,16 @@ def _extract_token(request: Request) -> str | None: def _verify_jwt(token: str) -> dict | None: - """Verify JWT against Supabase JWT secret. Returns payload or None.""" - if not settings.supabase_jwt_secret: + """Verify JWT using JWKS public key. Returns payload or None.""" + client = _get_jwks_client() + if not client: return None try: + signing_key = client.get_signing_key_from_jwt(token) payload = jwt.decode( token, - settings.supabase_jwt_secret, - algorithms=["HS256"], + signing_key.key, + algorithms=["RS256"], audience="authenticated", ) return payload @@ -47,6 +61,8 @@ def _verify_jwt(token: str) -> dict | None: return None except jwt.InvalidTokenError: return None + except PyJWKClientError: + return None def get_current_user(request: Request) -> AuthUser | None: diff --git a/backend/src/app/core/config.py b/backend/src/app/core/config.py index 7ef08af..84541c3 100644 --- a/backend/src/app/core/config.py +++ b/backend/src/app/core/config.py @@ -20,7 +20,6 @@ class Settings(BaseSettings): # Supabase Auth supabase_url: str | None = None supabase_anon_key: str | None = None - supabase_jwt_secret: str | None = None settings = Settings() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 13c9aea..a37cb78 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,25 +1,29 @@ import time +from unittest.mock import MagicMock, patch from uuid import UUID import jwt import pytest +from cryptography.hazmat.primitives.asymmetric import rsa from httpx import ASGITransport, AsyncClient from app.core.auth import AuthUser, get_current_user, require_admin, require_auth -from app.core.config import settings from app.main import app from app.models.user import User -@pytest.fixture -def jwt_secret(): - """Provide a test JWT secret.""" - return "test-jwt-secret-for-testing-only" +@pytest.fixture(scope="module") +def rsa_key_pair(): + """Generate RSA key pair for testing.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + return private_key, public_key @pytest.fixture -def valid_token(jwt_secret): - """Generate a valid JWT token.""" +def valid_token(rsa_key_pair): + """Generate a valid RS256 JWT token.""" + private_key, _ = rsa_key_pair payload = { "sub": "user-123", "email": "test@example.com", @@ -27,12 +31,13 @@ def valid_token(jwt_secret): "aud": "authenticated", "exp": int(time.time()) + 3600, } - return jwt.encode(payload, jwt_secret, algorithm="HS256") + return jwt.encode(payload, private_key, algorithm="RS256") @pytest.fixture -def expired_token(jwt_secret): - """Generate an expired JWT token.""" +def expired_token(rsa_key_pair): + """Generate an expired RS256 JWT token.""" + private_key, _ = rsa_key_pair payload = { "sub": "user-123", "email": "test@example.com", @@ -40,12 +45,13 @@ def expired_token(jwt_secret): "aud": "authenticated", "exp": int(time.time()) - 3600, # Expired 1 hour ago } - return jwt.encode(payload, jwt_secret, algorithm="HS256") + return jwt.encode(payload, private_key, algorithm="RS256") @pytest.fixture def invalid_token(): - """Generate a token signed with wrong secret.""" + """Generate a token signed with wrong key.""" + wrong_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) payload = { "sub": "user-123", "email": "test@example.com", @@ -53,81 +59,76 @@ def invalid_token(): "aud": "authenticated", "exp": int(time.time()) + 3600, } - return jwt.encode(payload, "wrong-secret", algorithm="HS256") + return jwt.encode(payload, wrong_key, algorithm="RS256") @pytest.fixture -def auth_client(db_session, jwt_secret, valid_token, monkeypatch): - """Client with valid auth token and configured JWT secret.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) - - async def _get_client(): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {valid_token}"}, - ) as ac: - yield ac - - return _get_client +def mock_jwks_client(rsa_key_pair): + """Create a mock JWKS client that returns our test public key.""" + _, public_key = rsa_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_token(jwt_secret, valid_token, monkeypatch): +async def test_get_current_user_valid_token(valid_token, mock_jwks_client): """Test get_current_user returns user for valid token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {valid_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {valid_token}"} - user = get_current_user(MockRequest()) - assert user is not None - assert user.id == "user-123" - assert user.email == "test@example.com" - assert user.role == "authenticated" + user = get_current_user(MockRequest()) + assert user is not None + assert user.id == "user-123" + assert user.email == "test@example.com" + assert user.role == "authenticated" -async def test_get_current_user_no_token(jwt_secret, monkeypatch): +async def test_get_current_user_no_token(mock_jwks_client): """Test get_current_user returns None when no token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {} + class MockRequest: + headers = {} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch): +async def test_get_current_user_expired_token(expired_token, mock_jwks_client): """Test get_current_user returns None for expired token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {expired_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {expired_token}"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch): +async def test_get_current_user_invalid_token(invalid_token, mock_jwks_client): """Test get_current_user returns None for invalid token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {invalid_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {invalid_token}"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_malformed_header(jwt_secret, monkeypatch): +async def test_get_current_user_malformed_header(mock_jwks_client): """Test get_current_user returns None for malformed auth header.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": "NotBearer token"} + class MockRequest: + headers = {"Authorization": "NotBearer token"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None async def test_require_auth_valid_user(): @@ -158,17 +159,16 @@ async def test_protected_endpoint_without_token(db_session): async def test_protected_endpoint_with_expired_token( - db_session, jwt_secret, expired_token, monkeypatch + db_session, expired_token, mock_jwks_client ): """Test that write endpoint returns 401 with expired token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {expired_token}"}, - ) as ac: - response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {expired_token}"}, + ) as ac: + response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) assert response.status_code == 401 @@ -231,7 +231,7 @@ async def test_require_admin_user_not_in_db(db_session): async def test_admin_endpoint_returns_403_for_non_admin( - db_session, jwt_secret, monkeypatch + db_session, rsa_key_pair, mock_jwks_client ): """Test that admin endpoint returns 403 for authenticated non-admin user.""" user_id = "44444444-4444-4444-4444-444444444444" @@ -243,7 +243,7 @@ async def test_admin_endpoint_returns_403_for_non_admin( db_session.add(regular_user) await db_session.commit() - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + private_key, _ = rsa_key_pair token = jwt.encode( { "sub": user_id, @@ -252,30 +252,33 @@ async def test_admin_endpoint_returns_403_for_non_admin( "aud": "authenticated", "exp": int(time.time()) + 3600, }, - jwt_secret, - algorithm="HS256", + private_key, + algorithm="RS256", ) - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {token}"}, - ) as ac: - response = await ac.post( - "/games", - json={ - "name": "Test Game", - "slug": "test-game", - "generation": 1, - "region": "Kanto", - "category": "core", - }, - ) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + response = await ac.post( + "/games", + json={ + "name": "Test Game", + "slug": "test-game", + "generation": 1, + "region": "Kanto", + "category": "core", + }, + ) assert response.status_code == 403 assert response.json()["detail"] == "Admin access required" -async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeypatch): +async def test_admin_endpoint_succeeds_for_admin( + db_session, rsa_key_pair, mock_jwks_client +): """Test that admin endpoint succeeds for authenticated admin user.""" user_id = "55555555-5555-5555-5555-555555555555" admin_user = User( @@ -286,7 +289,7 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp db_session.add(admin_user) await db_session.commit() - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + private_key, _ = rsa_key_pair token = jwt.encode( { "sub": user_id, @@ -295,24 +298,25 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp "aud": "authenticated", "exp": int(time.time()) + 3600, }, - jwt_secret, - algorithm="HS256", + private_key, + algorithm="RS256", ) - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {token}"}, - ) as ac: - response = await ac.post( - "/games", - json={ - "name": "Test Game", - "slug": "test-game", - "generation": 1, - "region": "Kanto", - "category": "core", - }, - ) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + response = await ac.post( + "/games", + json={ + "name": "Test Game", + "slug": "test-game", + "generation": 1, + "region": "Kanto", + "category": "core", + }, + ) assert response.status_code == 201 assert response.json()["name"] == "Test Game" diff --git a/backend/uv.lock b/backend/uv.lock index 34638aa..4a806c5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -41,6 +41,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -63,6 +64,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = "==1.18.4" }, { name = "asyncpg", specifier = "==0.31.0" }, + { name = "cryptography", specifier = "==45.0.3" }, { name = "fastapi", specifier = "==0.135.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -123,6 +125,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -144,6 +179,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -315,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4c41a94..5a015af 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,7 +6,7 @@ services: environment: - DEBUG=false - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/nuzlocke - - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET} + - SUPABASE_URL=${SUPABASE_URL} depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 29343b3..dae4909 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,9 +12,8 @@ services: environment: - DEBUG=true - DATABASE_URL=postgresql://postgres:postgres@db:5432/nuzlocke - # Auth - must match GoTrue's JWT secret + # Auth - uses JWKS from GoTrue for JWT verification - SUPABASE_URL=http://gotrue:9999 - - SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long depends_on: db: condition: service_healthy -- 2.49.1 From e9eccc5b21588d2d001d2131d18b9cf6abe60534 Mon Sep 17 00:00:00 2001 From: Julian Tabel Date: Sat, 21 Mar 2026 14:01:31 +0100 Subject: [PATCH 2/2] feat: migrate JWT verification from HS256 shared secret to JWKS Replace symmetric HS256 JWT verification with asymmetric RS256 using JWKS. Backend now fetches and caches public keys from Supabase's JWKS endpoint instead of using a shared secret. - Add cryptography dependency for RS256 support - Use PyJWKClient to fetch/cache JWKS from {SUPABASE_URL}/.well-known/jwks.json - Remove SUPABASE_JWT_SECRET from config, docker-compose, deploy workflow, .env - Update tests to use RS256 tokens with mocked JWKS client Co-Authored-By: Claude Opus 4.6 --- ...t-verification-from-hs256-shared-secret.md | 31 +-- .env.example | 4 +- .github/workflows/deploy.yml | 2 +- backend/.env.example | 3 +- backend/pyproject.toml | 1 + backend/src/app/core/auth.py | 24 +- backend/src/app/core/config.py | 1 - backend/tests/test_auth.py | 216 +++++++++--------- backend/uv.lock | 79 +++++++ docker-compose.prod.yml | 2 +- docker-compose.yml | 3 +- 11 files changed, 226 insertions(+), 140 deletions(-) diff --git a/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md b/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md index 7cc74ed..4f2fdee 100644 --- a/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md +++ b/.beans/nuzlocke-tracker-t9aj--migrate-jwt-verification-from-hs256-shared-secret.md @@ -1,21 +1,11 @@ --- # nuzlocke-tracker-t9aj title: Migrate JWT verification from HS256 shared secret to asymmetric keys (JWKS) -<<<<<<< Updated upstream -status: todo -type: task -priority: low -created_at: 2026-03-21T11:14:29Z -updated_at: 2026-03-21T13:01:46Z ---- - -The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [ ] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [ ] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [ ] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [ ] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts -======= status: completed type: task priority: low created_at: 2026-03-21T11:14:29Z -updated_at: 2026-03-22T08:14:34Z +updated_at: 2026-03-21T13:01:33Z --- The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPABASE_JWT_SECRET`). Supabase recommends migrating to asymmetric keys (RS256) for better security.\n\nInstead of storing a shared secret, the backend would fetch public keys from Supabase's JWKS endpoint (`https://.supabase.co/.well-known/jwks.json`) and verify tokens against those.\n\n## Changes needed\n\n- [x] Update `backend/src/app/core/auth.py` to fetch and cache JWKS public keys\n- [x] Change `jwt.decode` from `HS256` to `RS256` with the fetched public key\n- [x] Remove `SUPABASE_JWT_SECRET` from config, docker-compose, deploy workflow, and .env files\n- [x] Update tests\n\n## References\n\n- https://supabase.com/docs/guides/auth/signing-keys\n- https://supabase.com/docs/guides/auth/jwts @@ -23,13 +13,14 @@ The backend currently verifies Supabase JWTs using an HS256 shared secret (`SUPA ## Summary of Changes -Migrated JWT verification from HS256 shared secret to RS256 asymmetric key verification using JWKS: +- Added `cryptography==45.0.3` dependency for RS256 support +- Updated `auth.py` to use `PyJWKClient` for fetching and caching JWKS public keys from `{SUPABASE_URL}/.well-known/jwks.json` +- Changed JWT verification from HS256 to RS256 +- Removed `supabase_jwt_secret` from config.py +- Updated docker-compose.yml: removed `SUPABASE_JWT_SECRET`, backend now uses JWKS from GoTrue URL +- Updated docker-compose.prod.yml: replaced `SUPABASE_JWT_SECRET` with `SUPABASE_URL` +- Updated deploy.yml: deploy workflow now writes `SUPABASE_URL` instead of `SUPABASE_JWT_SECRET` +- Updated .env.example files: removed `SUPABASE_JWT_SECRET` references +- Rewrote tests to use RS256 tokens with mocked JWKS client -- **auth.py**: Added `PyJWKClient` that fetches and caches public keys from Supabase's JWKS endpoint (`SUPABASE_URL/.well-known/jwks.json`). Keys are cached for 1 hour. -- **config.py**: Removed `supabase_jwt_secret` setting -- **pyproject.toml**: Changed `PyJWT` to `PyJWT[crypto]` for RS256 support -- **docker-compose.yml**: Configured local GoTrue for RS256 with mounted dev key -- **docker-compose.prod.yml**: Replaced `SUPABASE_JWT_SECRET` with `SUPABASE_URL` -- **deploy.yml**: Updated to pass `SUPABASE_URL` instead of `SUPABASE_JWT_SECRET` -- **tests**: Updated to use mocked JWKS client with RSA key pairs ->>>>>>> Stashed changes +**Note:** For production, add `SUPABASE_URL` to your GitHub secrets (should point to your Supabase project URL like `https://your-project.supabase.co`). diff --git a/.env.example b/.env.example index 4692ef6..aba12cf 100644 --- a/.env.example +++ b/.env.example @@ -2,15 +2,13 @@ DEBUG=true DATABASE_URL=postgresql://postgres:postgres@localhost:5432/nuzlocke -# Supabase Auth (backend) +# Supabase Auth (backend uses JWKS from this URL for JWT verification) # For local dev with GoTrue container: SUPABASE_URL=http://localhost:9999 SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzc0MDQwNjEzLCJleHAiOjIwODk0MDA2MTN9.EV6tRj7gLqoiT-l2vDFw_67myqRjwpcZTuRb3Xs1nr4 -SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long # For production, replace with your Supabase cloud values: # SUPABASE_URL=https://your-project.supabase.co # SUPABASE_ANON_KEY=your-anon-key -# SUPABASE_JWT_SECRET=your-jwt-secret # Frontend settings (used by Vite) VITE_API_URL=http://localhost:8000 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c46ab43..ed00105 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,7 +47,7 @@ jobs: # Write .env from secrets (overwrites any existing file) printf '%s\n' \ "POSTGRES_PASSWORD=${{ secrets.POSTGRES_PASSWORD }}" \ - "SUPABASE_JWT_SECRET=${{ secrets.SUPABASE_JWT_SECRET }}" \ + "SUPABASE_URL=${{ secrets.SUPABASE_URL }}" \ | $SSH_CMD "cat > '${DEPLOY_DIR}/.env'" $SCP_CMD docker-compose.prod.yml "root@192.168.1.10:${DEPLOY_DIR}/docker-compose.yml" diff --git a/backend/.env.example b/backend/.env.example index a91efe4..9b444f2 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -8,7 +8,6 @@ API_V1_PREFIX="/api/v1" # Database settings DATABASE_URL="sqlite:///./nuzlocke.db" -# Supabase Auth +# Supabase Auth (JWKS used for JWT verification) SUPABASE_URL=https://your-project.supabase.co SUPABASE_ANON_KEY=your-anon-key -SUPABASE_JWT_SECRET=your-jwt-secret diff --git a/backend/pyproject.toml b/backend/pyproject.toml index c5922ea..5ef9a7b 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,7 @@ dependencies = [ "asyncpg==0.31.0", "alembic==1.18.4", "PyJWT==2.12.1", + "cryptography==45.0.3", ] [project.optional-dependencies] diff --git a/backend/src/app/core/auth.py b/backend/src/app/core/auth.py index d2bb37a..aa6172a 100644 --- a/backend/src/app/core/auth.py +++ b/backend/src/app/core/auth.py @@ -3,6 +3,7 @@ from uuid import UUID import jwt from fastapi import Depends, HTTPException, Request, status +from jwt import PyJWKClient, PyJWKClientError from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -11,6 +12,8 @@ from app.core.database import get_session from app.models.nuzlocke_run import NuzlockeRun from app.models.user import User +_jwks_client: PyJWKClient | None = None + @dataclass class AuthUser: @@ -21,6 +24,15 @@ class AuthUser: role: str | None = None +def _get_jwks_client() -> PyJWKClient | None: + """Get or create a cached JWKS client.""" + global _jwks_client + if _jwks_client is None and 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) + return _jwks_client + + def _extract_token(request: Request) -> str | None: """Extract Bearer token from Authorization header.""" auth_header = request.headers.get("Authorization") @@ -33,14 +45,16 @@ def _extract_token(request: Request) -> str | None: def _verify_jwt(token: str) -> dict | None: - """Verify JWT against Supabase JWT secret. Returns payload or None.""" - if not settings.supabase_jwt_secret: + """Verify JWT using JWKS public key. Returns payload or None.""" + client = _get_jwks_client() + if not client: return None try: + signing_key = client.get_signing_key_from_jwt(token) payload = jwt.decode( token, - settings.supabase_jwt_secret, - algorithms=["HS256"], + signing_key.key, + algorithms=["RS256"], audience="authenticated", ) return payload @@ -48,6 +62,8 @@ def _verify_jwt(token: str) -> dict | None: return None except jwt.InvalidTokenError: return None + except PyJWKClientError: + return None def get_current_user(request: Request) -> AuthUser | None: diff --git a/backend/src/app/core/config.py b/backend/src/app/core/config.py index 7ef08af..84541c3 100644 --- a/backend/src/app/core/config.py +++ b/backend/src/app/core/config.py @@ -20,7 +20,6 @@ class Settings(BaseSettings): # Supabase Auth supabase_url: str | None = None supabase_anon_key: str | None = None - supabase_jwt_secret: str | None = None settings = Settings() diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 13c9aea..a37cb78 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,25 +1,29 @@ import time +from unittest.mock import MagicMock, patch from uuid import UUID import jwt import pytest +from cryptography.hazmat.primitives.asymmetric import rsa from httpx import ASGITransport, AsyncClient from app.core.auth import AuthUser, get_current_user, require_admin, require_auth -from app.core.config import settings from app.main import app from app.models.user import User -@pytest.fixture -def jwt_secret(): - """Provide a test JWT secret.""" - return "test-jwt-secret-for-testing-only" +@pytest.fixture(scope="module") +def rsa_key_pair(): + """Generate RSA key pair for testing.""" + private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + public_key = private_key.public_key() + return private_key, public_key @pytest.fixture -def valid_token(jwt_secret): - """Generate a valid JWT token.""" +def valid_token(rsa_key_pair): + """Generate a valid RS256 JWT token.""" + private_key, _ = rsa_key_pair payload = { "sub": "user-123", "email": "test@example.com", @@ -27,12 +31,13 @@ def valid_token(jwt_secret): "aud": "authenticated", "exp": int(time.time()) + 3600, } - return jwt.encode(payload, jwt_secret, algorithm="HS256") + return jwt.encode(payload, private_key, algorithm="RS256") @pytest.fixture -def expired_token(jwt_secret): - """Generate an expired JWT token.""" +def expired_token(rsa_key_pair): + """Generate an expired RS256 JWT token.""" + private_key, _ = rsa_key_pair payload = { "sub": "user-123", "email": "test@example.com", @@ -40,12 +45,13 @@ def expired_token(jwt_secret): "aud": "authenticated", "exp": int(time.time()) - 3600, # Expired 1 hour ago } - return jwt.encode(payload, jwt_secret, algorithm="HS256") + return jwt.encode(payload, private_key, algorithm="RS256") @pytest.fixture def invalid_token(): - """Generate a token signed with wrong secret.""" + """Generate a token signed with wrong key.""" + wrong_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) payload = { "sub": "user-123", "email": "test@example.com", @@ -53,81 +59,76 @@ def invalid_token(): "aud": "authenticated", "exp": int(time.time()) + 3600, } - return jwt.encode(payload, "wrong-secret", algorithm="HS256") + return jwt.encode(payload, wrong_key, algorithm="RS256") @pytest.fixture -def auth_client(db_session, jwt_secret, valid_token, monkeypatch): - """Client with valid auth token and configured JWT secret.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) - - async def _get_client(): - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {valid_token}"}, - ) as ac: - yield ac - - return _get_client +def mock_jwks_client(rsa_key_pair): + """Create a mock JWKS client that returns our test public key.""" + _, public_key = rsa_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_token(jwt_secret, valid_token, monkeypatch): +async def test_get_current_user_valid_token(valid_token, mock_jwks_client): """Test get_current_user returns user for valid token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {valid_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {valid_token}"} - user = get_current_user(MockRequest()) - assert user is not None - assert user.id == "user-123" - assert user.email == "test@example.com" - assert user.role == "authenticated" + user = get_current_user(MockRequest()) + assert user is not None + assert user.id == "user-123" + assert user.email == "test@example.com" + assert user.role == "authenticated" -async def test_get_current_user_no_token(jwt_secret, monkeypatch): +async def test_get_current_user_no_token(mock_jwks_client): """Test get_current_user returns None when no token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {} + class MockRequest: + headers = {} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_expired_token(jwt_secret, expired_token, monkeypatch): +async def test_get_current_user_expired_token(expired_token, mock_jwks_client): """Test get_current_user returns None for expired token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {expired_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {expired_token}"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_invalid_token(jwt_secret, invalid_token, monkeypatch): +async def test_get_current_user_invalid_token(invalid_token, mock_jwks_client): """Test get_current_user returns None for invalid token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": f"Bearer {invalid_token}"} + class MockRequest: + headers = {"Authorization": f"Bearer {invalid_token}"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None -async def test_get_current_user_malformed_header(jwt_secret, monkeypatch): +async def test_get_current_user_malformed_header(mock_jwks_client): """Test get_current_user returns None for malformed auth header.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): - class MockRequest: - headers = {"Authorization": "NotBearer token"} + class MockRequest: + headers = {"Authorization": "NotBearer token"} - user = get_current_user(MockRequest()) - assert user is None + user = get_current_user(MockRequest()) + assert user is None async def test_require_auth_valid_user(): @@ -158,17 +159,16 @@ async def test_protected_endpoint_without_token(db_session): async def test_protected_endpoint_with_expired_token( - db_session, jwt_secret, expired_token, monkeypatch + db_session, expired_token, mock_jwks_client ): """Test that write endpoint returns 401 with expired token.""" - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) - - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {expired_token}"}, - ) as ac: - response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {expired_token}"}, + ) as ac: + response = await ac.post("/runs", json={"game_id": 1, "name": "Test Run"}) assert response.status_code == 401 @@ -231,7 +231,7 @@ async def test_require_admin_user_not_in_db(db_session): async def test_admin_endpoint_returns_403_for_non_admin( - db_session, jwt_secret, monkeypatch + db_session, rsa_key_pair, mock_jwks_client ): """Test that admin endpoint returns 403 for authenticated non-admin user.""" user_id = "44444444-4444-4444-4444-444444444444" @@ -243,7 +243,7 @@ async def test_admin_endpoint_returns_403_for_non_admin( db_session.add(regular_user) await db_session.commit() - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + private_key, _ = rsa_key_pair token = jwt.encode( { "sub": user_id, @@ -252,30 +252,33 @@ async def test_admin_endpoint_returns_403_for_non_admin( "aud": "authenticated", "exp": int(time.time()) + 3600, }, - jwt_secret, - algorithm="HS256", + private_key, + algorithm="RS256", ) - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {token}"}, - ) as ac: - response = await ac.post( - "/games", - json={ - "name": "Test Game", - "slug": "test-game", - "generation": 1, - "region": "Kanto", - "category": "core", - }, - ) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + response = await ac.post( + "/games", + json={ + "name": "Test Game", + "slug": "test-game", + "generation": 1, + "region": "Kanto", + "category": "core", + }, + ) assert response.status_code == 403 assert response.json()["detail"] == "Admin access required" -async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeypatch): +async def test_admin_endpoint_succeeds_for_admin( + db_session, rsa_key_pair, mock_jwks_client +): """Test that admin endpoint succeeds for authenticated admin user.""" user_id = "55555555-5555-5555-5555-555555555555" admin_user = User( @@ -286,7 +289,7 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp db_session.add(admin_user) await db_session.commit() - monkeypatch.setattr(settings, "supabase_jwt_secret", jwt_secret) + private_key, _ = rsa_key_pair token = jwt.encode( { "sub": user_id, @@ -295,24 +298,25 @@ async def test_admin_endpoint_succeeds_for_admin(db_session, jwt_secret, monkeyp "aud": "authenticated", "exp": int(time.time()) + 3600, }, - jwt_secret, - algorithm="HS256", + private_key, + algorithm="RS256", ) - async with AsyncClient( - transport=ASGITransport(app=app), - base_url="http://test", - headers={"Authorization": f"Bearer {token}"}, - ) as ac: - response = await ac.post( - "/games", - json={ - "name": "Test Game", - "slug": "test-game", - "generation": 1, - "region": "Kanto", - "category": "core", - }, - ) + with patch("app.core.auth._get_jwks_client", return_value=mock_jwks_client): + async with AsyncClient( + transport=ASGITransport(app=app), + base_url="http://test", + headers={"Authorization": f"Bearer {token}"}, + ) as ac: + response = await ac.post( + "/games", + json={ + "name": "Test Game", + "slug": "test-game", + "generation": 1, + "region": "Kanto", + "category": "core", + }, + ) assert response.status_code == 201 assert response.json()["name"] == "Test Game" diff --git a/backend/uv.lock b/backend/uv.lock index 34638aa..4a806c5 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -41,6 +41,7 @@ source = { editable = "." } dependencies = [ { name = "alembic" }, { name = "asyncpg" }, + { name = "cryptography" }, { name = "fastapi" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -63,6 +64,7 @@ dev = [ requires-dist = [ { name = "alembic", specifier = "==1.18.4" }, { name = "asyncpg", specifier = "==0.31.0" }, + { name = "cryptography", specifier = "==45.0.3" }, { name = "fastapi", specifier = "==0.135.1" }, { name = "httpx", marker = "extra == 'dev'", specifier = "==0.28.1" }, { name = "pydantic", specifier = "==2.12.5" }, @@ -123,6 +125,39 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e6/ad/3cc14f097111b4de0040c83a525973216457bbeeb63739ef1ed275c1c021/certifi-2026.1.4-py3-none-any.whl", hash = "sha256:9943707519e4add1115f44c2bc244f782c0249876bf51b6599fee1ffbedd685c", size = 152900, upload-time = "2026-01-04T02:42:40.15Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -144,6 +179,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "45.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/13/1f/9fa001e74a1993a9cadd2333bb889e50c66327b8594ac538ab8a04f915b7/cryptography-45.0.3.tar.gz", hash = "sha256:ec21313dd335c51d7877baf2972569f40a4291b76a0ce51391523ae358d05899", size = 744738, upload-time = "2025-05-25T14:17:24.777Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/b2/2345dc595998caa6f68adf84e8f8b50d18e9fc4638d32b22ea8daedd4b7a/cryptography-45.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:7573d9eebaeceeb55285205dbbb8753ac1e962af3d9640791d12b36864065e71", size = 7056239, upload-time = "2025-05-25T14:16:12.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/3d/ac361649a0bfffc105e2298b720d8b862330a767dab27c06adc2ddbef96a/cryptography-45.0.3-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d377dde61c5d67eb4311eace661c3efda46c62113ff56bf05e2d679e02aebb5b", size = 4205541, upload-time = "2025-05-25T14:16:14.333Z" }, + { url = "https://files.pythonhosted.org/packages/70/3e/c02a043750494d5c445f769e9c9f67e550d65060e0bfce52d91c1362693d/cryptography-45.0.3-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fae1e637f527750811588e4582988932c222f8251f7b7ea93739acb624e1487f", size = 4433275, upload-time = "2025-05-25T14:16:16.421Z" }, + { url = "https://files.pythonhosted.org/packages/40/7a/9af0bfd48784e80eef3eb6fd6fde96fe706b4fc156751ce1b2b965dada70/cryptography-45.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ca932e11218bcc9ef812aa497cdf669484870ecbcf2d99b765d6c27a86000942", size = 4209173, upload-time = "2025-05-25T14:16:18.163Z" }, + { url = "https://files.pythonhosted.org/packages/31/5f/d6f8753c8708912df52e67969e80ef70b8e8897306cd9eb8b98201f8c184/cryptography-45.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:af3f92b1dc25621f5fad065288a44ac790c5798e986a34d393ab27d2b27fcff9", size = 3898150, upload-time = "2025-05-25T14:16:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/8b/50/f256ab79c671fb066e47336706dc398c3b1e125f952e07d54ce82cf4011a/cryptography-45.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2f8f8f0b73b885ddd7f3d8c2b2234a7d3ba49002b0223f58cfde1bedd9563c56", size = 4466473, upload-time = "2025-05-25T14:16:22.605Z" }, + { url = "https://files.pythonhosted.org/packages/62/e7/312428336bb2df0848d0768ab5a062e11a32d18139447a76dfc19ada8eed/cryptography-45.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9cc80ce69032ffa528b5e16d217fa4d8d4bb7d6ba8659c1b4d74a1b0f4235fca", size = 4211890, upload-time = "2025-05-25T14:16:24.738Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/8a130e22c1e432b3c14896ec5eb7ac01fb53c6737e1d705df7e0efb647c6/cryptography-45.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c824c9281cb628015bfc3c59335163d4ca0540d49de4582d6c2637312907e4b1", size = 4466300, upload-time = "2025-05-25T14:16:26.768Z" }, + { url = "https://files.pythonhosted.org/packages/ba/75/6bb6579688ef805fd16a053005fce93944cdade465fc92ef32bbc5c40681/cryptography-45.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5833bb4355cb377ebd880457663a972cd044e7f49585aee39245c0d592904578", size = 4332483, upload-time = "2025-05-25T14:16:28.316Z" }, + { url = "https://files.pythonhosted.org/packages/2f/11/2538f4e1ce05c6c4f81f43c1ef2bd6de7ae5e24ee284460ff6c77e42ca77/cryptography-45.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9bb5bf55dcb69f7067d80354d0a348368da907345a2c448b0babc4215ccd3497", size = 4573714, upload-time = "2025-05-25T14:16:30.474Z" }, + { url = "https://files.pythonhosted.org/packages/f5/bb/e86e9cf07f73a98d84a4084e8fd420b0e82330a901d9cac8149f994c3417/cryptography-45.0.3-cp311-abi3-win32.whl", hash = "sha256:3ad69eeb92a9de9421e1f6685e85a10fbcfb75c833b42cc9bc2ba9fb00da4710", size = 2934752, upload-time = "2025-05-25T14:16:32.204Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/063bc9ddc3d1c73e959054f1fc091b79572e716ef74d6caaa56e945b4af9/cryptography-45.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:97787952246a77d77934d41b62fb1b6f3581d83f71b44796a4158d93b8f5c490", size = 3412465, upload-time = "2025-05-25T14:16:33.888Z" }, + { url = "https://files.pythonhosted.org/packages/71/9b/04ead6015229a9396890d7654ee35ef630860fb42dc9ff9ec27f72157952/cryptography-45.0.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:c92519d242703b675ccefd0f0562eb45e74d438e001f8ab52d628e885751fb06", size = 7031892, upload-time = "2025-05-25T14:16:36.214Z" }, + { url = "https://files.pythonhosted.org/packages/46/c7/c7d05d0e133a09fc677b8a87953815c522697bdf025e5cac13ba419e7240/cryptography-45.0.3-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5edcb90da1843df85292ef3a313513766a78fbbb83f584a5a58fb001a5a9d57", size = 4196181, upload-time = "2025-05-25T14:16:37.934Z" }, + { url = "https://files.pythonhosted.org/packages/08/7a/6ad3aa796b18a683657cef930a986fac0045417e2dc428fd336cfc45ba52/cryptography-45.0.3-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:38deed72285c7ed699864f964a3f4cf11ab3fb38e8d39cfcd96710cd2b5bb716", size = 4423370, upload-time = "2025-05-25T14:16:39.502Z" }, + { url = "https://files.pythonhosted.org/packages/4f/58/ec1461bfcb393525f597ac6a10a63938d18775b7803324072974b41a926b/cryptography-45.0.3-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5555365a50efe1f486eed6ac7062c33b97ccef409f5970a0b6f205a7cfab59c8", size = 4197839, upload-time = "2025-05-25T14:16:41.322Z" }, + { url = "https://files.pythonhosted.org/packages/d4/3d/5185b117c32ad4f40846f579369a80e710d6146c2baa8ce09d01612750db/cryptography-45.0.3-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e4253ed8f5948a3589b3caee7ad9a5bf218ffd16869c516535325fece163dcc", size = 3886324, upload-time = "2025-05-25T14:16:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/67/85/caba91a57d291a2ad46e74016d1f83ac294f08128b26e2a81e9b4f2d2555/cryptography-45.0.3-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cfd84777b4b6684955ce86156cfb5e08d75e80dc2585e10d69e47f014f0a5342", size = 4450447, upload-time = "2025-05-25T14:16:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d1/164e3c9d559133a38279215c712b8ba38e77735d3412f37711b9f8f6f7e0/cryptography-45.0.3-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:a2b56de3417fd5f48773ad8e91abaa700b678dc7fe1e0c757e1ae340779acf7b", size = 4200576, upload-time = "2025-05-25T14:16:46.438Z" }, + { url = "https://files.pythonhosted.org/packages/71/7a/e002d5ce624ed46dfc32abe1deff32190f3ac47ede911789ee936f5a4255/cryptography-45.0.3-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:57a6500d459e8035e813bd8b51b671977fb149a8c95ed814989da682314d0782", size = 4450308, upload-time = "2025-05-25T14:16:48.228Z" }, + { url = "https://files.pythonhosted.org/packages/87/ad/3fbff9c28cf09b0a71e98af57d74f3662dea4a174b12acc493de00ea3f28/cryptography-45.0.3-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f22af3c78abfbc7cbcdf2c55d23c3e022e1a462ee2481011d518c7fb9c9f3d65", size = 4325125, upload-time = "2025-05-25T14:16:49.844Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b4/51417d0cc01802304c1984d76e9592f15e4801abd44ef7ba657060520bf0/cryptography-45.0.3-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:232954730c362638544758a8160c4ee1b832dc011d2c41a306ad8f7cccc5bb0b", size = 4560038, upload-time = "2025-05-25T14:16:51.398Z" }, + { url = "https://files.pythonhosted.org/packages/80/38/d572f6482d45789a7202fb87d052deb7a7b136bf17473ebff33536727a2c/cryptography-45.0.3-cp37-abi3-win32.whl", hash = "sha256:cb6ab89421bc90e0422aca911c69044c2912fc3debb19bb3c1bfe28ee3dff6ab", size = 2924070, upload-time = "2025-05-25T14:16:53.472Z" }, + { url = "https://files.pythonhosted.org/packages/91/5a/61f39c0ff4443651cc64e626fa97ad3099249152039952be8f344d6b0c86/cryptography-45.0.3-cp37-abi3-win_amd64.whl", hash = "sha256:d54ae41e6bd70ea23707843021c778f151ca258081586f0cfa31d936ae43d1b2", size = 3395005, upload-time = "2025-05-25T14:16:55.134Z" }, +] + [[package]] name = "fastapi" version = "0.135.1" @@ -315,6 +385,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4c41a94..5a015af 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -6,7 +6,7 @@ services: environment: - DEBUG=false - DATABASE_URL=postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/nuzlocke - - SUPABASE_JWT_SECRET=${SUPABASE_JWT_SECRET} + - SUPABASE_URL=${SUPABASE_URL} depends_on: db: condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 29343b3..dae4909 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,9 +12,8 @@ services: environment: - DEBUG=true - DATABASE_URL=postgresql://postgres:postgres@db:5432/nuzlocke - # Auth - must match GoTrue's JWT secret + # Auth - uses JWKS from GoTrue for JWT verification - SUPABASE_URL=http://gotrue:9999 - - SUPABASE_JWT_SECRET=super-secret-jwt-token-with-at-least-32-characters-long depends_on: db: condition: service_healthy -- 2.49.1