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