From d5de21bb380489d741c0aafa615a0030759b91eb Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 5 Feb 2026 15:20:02 +0100 Subject: [PATCH 01/39] dev(db): Adds a dev user to the seeding script for API --- deployments/api/src/stitch/api/db/init_job.py | 23 +++++++++++++++++++ deployments/api/src/stitch/api/deps.py | 2 +- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index 72b9d93..c36bcd1 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -27,6 +27,7 @@ User as UserEntity, WMData, ) +from stitch.api.deps import get_current_user """ DB init/seed job. @@ -263,6 +264,17 @@ def create_seed_user() -> UserModel: email="seed@example.com", ) +def create_dev_user() -> UserModel: + dev_user = get_current_user() + print("[db-init] getting info for Dev User...", flush=True) + print(f"[db-init] User: '{dev_user}'...", flush=True) + return UserModel( + id=dev_user.id, + first_name=dev_user.name, + last_name="Deverson", + email=dev_user.email, + ) + def create_seed_sources(): gem_sources = [ @@ -356,16 +368,27 @@ def seed_dev(engine) -> None: session.add(user_model) session.flush() + dev_model = create_dev_user() + session.add(dev_model) + session.flush() + user_entity = UserEntity( id=user_model.id, email=user_model.email, name=f"{user_model.first_name} {user_model.last_name}", ) + dev_entity = UserEntity( + id=dev_model.id, + email=dev_model.email, + name=f"{dev_model.first_name} {dev_model.last_name}", + ) + gem_sources, wm_sources, rmi_sources, cc_sources = create_seed_sources() session.add_all(gem_sources + wm_sources + rmi_sources + cc_sources) resources = create_seed_resources(user_entity) + resources = create_seed_resources(dev_entity) session.add_all(resources) memberships = create_seed_memberships( diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/deps.py index 8074f3f..49932e4 100644 --- a/deployments/api/src/stitch/api/deps.py +++ b/deployments/api/src/stitch/api/deps.py @@ -7,7 +7,7 @@ def get_current_user() -> User: """Placeholder user dependency. Replace with real auth in production.""" - return User(id=111, role="admin", email="admin@stitch.com", name="Stitch Admin") + return User(id=2, role="admin", email="dev@example.com", name="Dev") CurrentUser = Annotated[User, Depends(get_current_user)] From 0f9d57ad7bda30f66783b53005005225c37fe1ca Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Thu, 5 Feb 2026 15:32:06 +0100 Subject: [PATCH 02/39] style: ruff --- deployments/api/src/stitch/api/db/init_job.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index c36bcd1..d188ec5 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -264,6 +264,7 @@ def create_seed_user() -> UserModel: email="seed@example.com", ) + def create_dev_user() -> UserModel: dev_user = get_current_user() print("[db-init] getting info for Dev User...", flush=True) From 3aa0d05a8ca7f11c705d172b5b024e1cc5f29f5e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:07:09 -0700 Subject: [PATCH 03/39] feat(auth): add stitch-auth package for OIDC JWT validation --- packages/stitch-auth/.python-version | 1 + packages/stitch-auth/pyproject.toml | 31 +++ .../stitch-auth/src/stitch/auth/__init__.py | 14 ++ .../stitch-auth/src/stitch/auth/claims.py | 8 + .../stitch-auth/src/stitch/auth/errors.py | 11 ++ packages/stitch-auth/src/stitch/auth/py.typed | 0 .../stitch-auth/src/stitch/auth/settings.py | 31 +++ .../stitch-auth/src/stitch/auth/validator.py | 54 ++++++ packages/stitch-auth/tests/conftest.py | 115 ++++++++++++ .../stitch-auth/tests/test_claims_unit.py | 57 ++++++ packages/stitch-auth/tests/test_settings.py | 107 +++++++++++ .../stitch-auth/tests/test_validator_unit.py | 177 ++++++++++++++++++ 12 files changed, 606 insertions(+) create mode 100644 packages/stitch-auth/.python-version create mode 100644 packages/stitch-auth/pyproject.toml create mode 100644 packages/stitch-auth/src/stitch/auth/__init__.py create mode 100644 packages/stitch-auth/src/stitch/auth/claims.py create mode 100644 packages/stitch-auth/src/stitch/auth/errors.py create mode 100644 packages/stitch-auth/src/stitch/auth/py.typed create mode 100644 packages/stitch-auth/src/stitch/auth/settings.py create mode 100644 packages/stitch-auth/src/stitch/auth/validator.py create mode 100644 packages/stitch-auth/tests/conftest.py create mode 100644 packages/stitch-auth/tests/test_claims_unit.py create mode 100644 packages/stitch-auth/tests/test_settings.py create mode 100644 packages/stitch-auth/tests/test_validator_unit.py diff --git a/packages/stitch-auth/.python-version b/packages/stitch-auth/.python-version new file mode 100644 index 0000000..763b626 --- /dev/null +++ b/packages/stitch-auth/.python-version @@ -0,0 +1 @@ +3.12.12 diff --git a/packages/stitch-auth/pyproject.toml b/packages/stitch-auth/pyproject.toml new file mode 100644 index 0000000..b8db2ae --- /dev/null +++ b/packages/stitch-auth/pyproject.toml @@ -0,0 +1,31 @@ +[project] +name = "stitch-auth" +version = "0.1.0" +description = "Provider-agnostic OIDC JWT validation for Stitch" +authors = [{ name = "Michael Barlow", email = "mbarlow@rmi.org" }] +requires-python = ">=3.12.12" +dependencies = [ + "pyjwt[crypto]>=2.9.0", + "pydantic>=2.0", + "pydantic-settings>=2.11.0", +] + +[build-system] +requires = ["uv_build>=0.9.5,<0.10.0"] +build-backend = "uv_build" + +[tool.uv.build-backend] +module-name = "stitch.auth" + +[dependency-groups] +dev = [ + "pytest>=8.0", + "cryptography>=44.0.0", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = ["test_*.py"] +python_classes = ["Test*"] +python_functions = ["test_*"] +addopts = ["-v", "--strict-markers", "--tb=short"] diff --git a/packages/stitch-auth/src/stitch/auth/__init__.py b/packages/stitch-auth/src/stitch/auth/__init__.py new file mode 100644 index 0000000..47c0459 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/__init__.py @@ -0,0 +1,14 @@ +from .claims import TokenClaims +from .errors import AuthError, JWKSFetchError, TokenExpiredError, TokenValidationError +from .settings import OIDCSettings +from .validator import JWTValidator + +__all__ = [ + "AuthError", + "JWKSFetchError", + "JWTValidator", + "OIDCSettings", + "TokenClaims", + "TokenExpiredError", + "TokenValidationError", +] diff --git a/packages/stitch-auth/src/stitch/auth/claims.py b/packages/stitch-auth/src/stitch/auth/claims.py new file mode 100644 index 0000000..632d056 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/claims.py @@ -0,0 +1,8 @@ +from pydantic import BaseModel, Field + + +class TokenClaims(BaseModel): + sub: str + email: str | None = None + name: str | None = None + raw: dict = Field(default_factory=dict) diff --git a/packages/stitch-auth/src/stitch/auth/errors.py b/packages/stitch-auth/src/stitch/auth/errors.py new file mode 100644 index 0000000..4892306 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/errors.py @@ -0,0 +1,11 @@ +class AuthError(Exception): + """Base for all auth errors. Consumers can catch broadly or narrowly.""" + + +class TokenExpiredError(AuthError): ... + + +class TokenValidationError(AuthError): ... + + +class JWKSFetchError(AuthError): ... diff --git a/packages/stitch-auth/src/stitch/auth/py.typed b/packages/stitch-auth/src/stitch/auth/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/stitch-auth/src/stitch/auth/settings.py b/packages/stitch-auth/src/stitch/auth/settings.py new file mode 100644 index 0000000..e364ccd --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/settings.py @@ -0,0 +1,31 @@ +from typing import Self + +from pydantic import model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class OIDCSettings(BaseSettings): + issuer: str = "" + audience: str = "" + jwks_uri: str = "" + algorithms: tuple[str, ...] = ("RS256",) + jwks_cache_ttl: int = 600 + clock_skew_seconds: int = 30 + disabled: bool = False + + model_config = SettingsConfigDict( + env_prefix="AUTH_", + env_file=".env", + env_file_encoding="utf-8", + extra="ignore", + ) + + @model_validator(mode="after") + def _require_fields_when_enabled(self) -> Self: + if not self.disabled: + missing = [ + f for f in ("issuer", "audience", "jwks_uri") if not getattr(self, f) + ] + if missing: + raise ValueError(f"Required when AUTH_DISABLED is not true: {missing}") + return self diff --git a/packages/stitch-auth/src/stitch/auth/validator.py b/packages/stitch-auth/src/stitch/auth/validator.py new file mode 100644 index 0000000..947b900 --- /dev/null +++ b/packages/stitch-auth/src/stitch/auth/validator.py @@ -0,0 +1,54 @@ +from datetime import timedelta + +import jwt +from jwt import PyJWKClient + +from .claims import TokenClaims +from .errors import JWKSFetchError, TokenExpiredError, TokenValidationError +from .settings import OIDCSettings + + +class JWTValidator: + def __init__(self, settings: OIDCSettings) -> None: + self._settings = settings + self._jwks_client = PyJWKClient( + uri=settings.jwks_uri, + cache_jwk_set=True, + lifespan=settings.jwks_cache_ttl, + ) + + def validate(self, token: str) -> TokenClaims: + try: + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + except (jwt.PyJWKClientError, jwt.PyJWKClientConnectionError) as e: + raise JWKSFetchError(str(e)) from e + + try: + payload = jwt.decode( + token, + signing_key.key, + algorithms=list(self._settings.algorithms), + audience=self._settings.audience, + issuer=self._settings.issuer, + leeway=timedelta(seconds=self._settings.clock_skew_seconds), + options={ + "require": ["exp", "iss", "aud", "sub", "nbf"], + "verify_exp": True, + "verify_iss": True, + "verify_aud": True, + }, + ) + except jwt.ExpiredSignatureError as e: + raise TokenExpiredError(str(e)) from e + except jwt.InvalidTokenError as e: + raise TokenValidationError(str(e)) from e + + email = payload.get("email") or payload.get("preferred_username") + name = payload.get("name") + + return TokenClaims( + sub=payload["sub"], + email=email, + name=name, + raw=payload, + ) diff --git a/packages/stitch-auth/tests/conftest.py b/packages/stitch-auth/tests/conftest.py new file mode 100644 index 0000000..c8b2c15 --- /dev/null +++ b/packages/stitch-auth/tests/conftest.py @@ -0,0 +1,115 @@ +"""Pytest fixtures for stitch-auth tests. + +Provides an RSA keypair, JWKS endpoint mock, and a token factory +for testing JWTValidator without hitting real OIDC providers. +""" + +import time +from typing import Any +from unittest.mock import MagicMock, patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa +from cryptography.hazmat.primitives import serialization +from jwt import PyJWK +from jwt.algorithms import RSAAlgorithm + +from stitch.auth.settings import OIDCSettings + + +@pytest.fixture +def rsa_private_key() -> rsa.RSAPrivateKey: + """Generate a fresh RSA private key for signing test tokens.""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture +def rsa_private_key_pem(rsa_private_key: rsa.RSAPrivateKey) -> bytes: + """PEM-encoded private key for PyJWT signing.""" + return rsa_private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + +@pytest.fixture +def rsa_public_jwk(rsa_private_key: rsa.RSAPrivateKey) -> dict[str, Any]: + """JWK dict for the public key (as returned by a JWKS endpoint).""" + public_key = rsa_private_key.public_key() + jwk_dict = RSAAlgorithm.to_jwk(public_key, as_dict=True) + jwk_dict["kid"] = "test-key-1" + jwk_dict["use"] = "sig" + jwk_dict["alg"] = "RS256" + return jwk_dict + + +@pytest.fixture +def oidc_settings() -> OIDCSettings: + """OIDC settings for tests — auth enabled with test values.""" + return OIDCSettings( + issuer="https://test.auth0.com/", + audience="https://api.test.example.com", + jwks_uri="https://test.auth0.com/.well-known/jwks.json", + algorithms=("RS256",), + clock_skew_seconds=30, + disabled=False, + ) + + +@pytest.fixture +def token_factory(rsa_private_key_pem: bytes): + """Factory that creates signed JWTs with configurable claims.""" + + def _make_token( + sub: str = "auth0|user123", + email: str | None = "user@example.com", + name: str | None = "Test User", + iss: str = "https://test.auth0.com/", + aud: str = "https://api.test.example.com", + exp: int | None = None, + nbf: int | None = None, + iat: int | None = None, + kid: str = "test-key-1", + extra_claims: dict[str, Any] | None = None, + ) -> str: + now = int(time.time()) + payload: dict[str, Any] = { + "sub": sub, + "iss": iss, + "aud": aud, + "exp": exp if exp is not None else now + 3600, + "nbf": nbf if nbf is not None else now - 10, + "iat": iat if iat is not None else now, + } + if email is not None: + payload["email"] = email + if name is not None: + payload["name"] = name + if extra_claims: + payload.update(extra_claims) + + return jwt.encode( + payload, + rsa_private_key_pem, + algorithm="RS256", + headers={"kid": kid}, + ) + + return _make_token + + +@pytest.fixture +def mock_jwks_client(rsa_public_jwk: dict[str, Any]): + """Patches PyJWKClient.get_signing_key_from_jwt to return our test key.""" + signing_key = PyJWK.from_dict(rsa_public_jwk) + + mock_client = MagicMock() + mock_client.get_signing_key_from_jwt.return_value = signing_key + + with patch( + "stitch.auth.validator.PyJWKClient", return_value=mock_client + ) as mock_cls: + mock_cls._instance = mock_client + yield mock_client diff --git a/packages/stitch-auth/tests/test_claims_unit.py b/packages/stitch-auth/tests/test_claims_unit.py new file mode 100644 index 0000000..5b7a5db --- /dev/null +++ b/packages/stitch-auth/tests/test_claims_unit.py @@ -0,0 +1,57 @@ +"""Tests for TokenClaims construction edge cases.""" + +import pytest + +from pydantic import ValidationError +from stitch.auth.claims import TokenClaims + + +class TestTokenClaimsConstruction: + """TokenClaims model validation.""" + + def test_minimal_claims(self): + """Only sub is required.""" + claims = TokenClaims(sub="auth0|abc123") + + assert claims.sub == "auth0|abc123" + assert claims.email is None + assert claims.name is None + assert claims.raw == {} + + def test_full_claims(self): + """All fields populated.""" + claims = TokenClaims( + sub="auth0|abc123", + email="user@example.com", + name="Jane Doe", + raw={"custom": "value"}, + ) + + assert claims.sub == "auth0|abc123" + assert claims.email == "user@example.com" + assert claims.name == "Jane Doe" + assert claims.raw["custom"] == "value" + + def test_sub_is_required(self): + """Missing sub raises ValidationError.""" + with pytest.raises(ValidationError): + TokenClaims() # pyright: ignore[reportCallIssue] + + def test_raw_defaults_to_empty_dict(self): + """raw field defaults to empty dict, not shared reference.""" + claims1 = TokenClaims(sub="user1") + claims2 = TokenClaims(sub="user2") + + claims1.raw["key"] = "value" + + assert "key" not in claims2.raw + + def test_uuid_sub(self): + """Entra ID-style UUID sub.""" + claims = TokenClaims(sub="550e8400-e29b-41d4-a716-446655440000") + assert claims.sub == "550e8400-e29b-41d4-a716-446655440000" + + def test_pipe_sub(self): + """Auth0-style sub with pipe separator.""" + claims = TokenClaims(sub="auth0|abc123") + assert claims.sub == "auth0|abc123" diff --git a/packages/stitch-auth/tests/test_settings.py b/packages/stitch-auth/tests/test_settings.py new file mode 100644 index 0000000..e8f0a90 --- /dev/null +++ b/packages/stitch-auth/tests/test_settings.py @@ -0,0 +1,107 @@ +"""Tests for OIDCSettings parsing and validation.""" + +import pytest +from pydantic import ValidationError + +from stitch.auth.settings import OIDCSettings + + +class TestOIDCSettingsValidation: + """Validation rules for OIDCSettings.""" + + def test_disabled_requires_no_other_fields(self): + """AUTH_DISABLED=true should work without issuer/audience/jwks_uri.""" + settings = OIDCSettings(disabled=True) + + assert settings.disabled is True + assert settings.issuer == "" + assert settings.audience == "" + assert settings.jwks_uri == "" + + def test_enabled_requires_issuer_audience_jwks_uri(self): + """Missing required fields when auth is enabled raises ValidationError.""" + with pytest.raises( + ValidationError, match="Required when AUTH_DISABLED is not true" + ): + OIDCSettings(disabled=False) + + def test_enabled_partial_fields_raises(self): + """Providing only some required fields still raises.""" + with pytest.raises( + ValidationError, match="Required when AUTH_DISABLED is not true" + ): + OIDCSettings( + issuer="https://test.auth0.com/", + audience="", + jwks_uri="", + ) + + def test_enabled_all_fields_succeeds(self): + """All required fields provided when enabled.""" + settings = OIDCSettings( + issuer="https://test.auth0.com/", + audience="https://api.example.com", + jwks_uri="https://test.auth0.com/.well-known/jwks.json", + ) + + assert settings.issuer == "https://test.auth0.com/" + assert settings.audience == "https://api.example.com" + assert settings.disabled is False + + +class TestOIDCSettingsDefaults: + """Default values for OIDCSettings.""" + + def test_default_algorithms(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.algorithms == ("RS256",) + + def test_default_cache_ttl(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.jwks_cache_ttl == 600 + + def test_default_clock_skew(self): + settings = OIDCSettings( + issuer="https://x.com/", + audience="aud", + jwks_uri="https://x.com/jwks", + ) + assert settings.clock_skew_seconds == 30 + + def test_default_disabled_is_false(self): + """disabled defaults to False (auth is on by default).""" + with pytest.raises(ValidationError): + OIDCSettings() + + +class TestOIDCSettingsFromEnv: + """Settings can be loaded from environment variables.""" + + def test_from_env_vars(self, monkeypatch): + monkeypatch.setenv("AUTH_ISSUER", "https://env.auth0.com/") + monkeypatch.setenv("AUTH_AUDIENCE", "https://api.env.example.com") + monkeypatch.setenv( + "AUTH_JWKS_URI", "https://env.auth0.com/.well-known/jwks.json" + ) + monkeypatch.setenv("AUTH_CLOCK_SKEW_SECONDS", "60") + + settings = OIDCSettings() + + assert settings.issuer == "https://env.auth0.com/" + assert settings.audience == "https://api.env.example.com" + assert settings.clock_skew_seconds == 60 + + def test_disabled_from_env(self, monkeypatch): + monkeypatch.setenv("AUTH_DISABLED", "true") + + settings = OIDCSettings() + + assert settings.disabled is True diff --git a/packages/stitch-auth/tests/test_validator_unit.py b/packages/stitch-auth/tests/test_validator_unit.py new file mode 100644 index 0000000..f4eae71 --- /dev/null +++ b/packages/stitch-auth/tests/test_validator_unit.py @@ -0,0 +1,177 @@ +"""Unit tests for JWTValidator with mocked JWKS.""" + +import time + +import jwt as pyjwt +import pytest + +from stitch.auth.errors import JWKSFetchError, TokenExpiredError, TokenValidationError +from stitch.auth.validator import JWTValidator + + +class TestJWTValidatorHappyPath: + """Successful token validation scenarios.""" + + def test_validates_token_returns_claims( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Valid token produces TokenClaims with correct fields.""" + token = token_factory( + sub="auth0|abc123", + email="user@example.com", + name="Jane Doe", + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.sub == "auth0|abc123" + assert claims.email == "user@example.com" + assert claims.name == "Jane Doe" + assert claims.raw["sub"] == "auth0|abc123" + + def test_email_fallback_to_preferred_username( + self, oidc_settings, mock_jwks_client, token_factory + ): + """When email claim is absent, falls back to preferred_username.""" + token = token_factory( + email=None, + extra_claims={"preferred_username": "user@tenant.onmicrosoft.com"}, + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.email == "user@tenant.onmicrosoft.com" + + def test_optional_claims_can_be_absent( + self, oidc_settings, mock_jwks_client, token_factory + ): + """email and name are optional.""" + token = token_factory(email=None, name=None) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.email is None + assert claims.name is None + assert claims.sub == "auth0|user123" + + def test_raw_contains_full_payload( + self, oidc_settings, mock_jwks_client, token_factory + ): + """raw dict contains the complete JWT payload.""" + token = token_factory( + extra_claims={"given_name": "Jane", "family_name": "Doe"}, + ) + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.raw["given_name"] == "Jane" + assert claims.raw["family_name"] == "Doe" + + def test_uuid_sub_format(self, oidc_settings, mock_jwks_client, token_factory): + """Entra ID-style UUID sub is treated as opaque string.""" + token = token_factory(sub="550e8400-e29b-41d4-a716-446655440000") + validator = JWTValidator(oidc_settings) + + claims = validator.validate(token) + + assert claims.sub == "550e8400-e29b-41d4-a716-446655440000" + + +class TestJWTValidatorErrors: + """Error handling and exception mapping.""" + + def test_expired_token_raises_token_expired_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Expired token raises TokenExpiredError.""" + token = token_factory(exp=int(time.time()) - 3600) + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenExpiredError): + validator.validate(token) + + def test_wrong_audience_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Mismatched audience raises TokenValidationError.""" + token = token_factory(aud="https://wrong-audience.example.com") + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_wrong_issuer_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Mismatched issuer raises TokenValidationError.""" + token = token_factory(iss="https://wrong-issuer.example.com/") + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_jwks_fetch_error(self, oidc_settings, mock_jwks_client): + """JWKS client error raises JWKSFetchError.""" + mock_jwks_client.get_signing_key_from_jwt.side_effect = pyjwt.PyJWKClientError( + "Connection refused" + ) + validator = JWTValidator(oidc_settings) + + with pytest.raises(JWKSFetchError, match="Connection refused"): + validator.validate("some.invalid.token") + + def test_jwks_connection_error(self, oidc_settings, mock_jwks_client): + """JWKS connection error raises JWKSFetchError.""" + mock_jwks_client.get_signing_key_from_jwt.side_effect = ( + pyjwt.PyJWKClientConnectionError("Timeout") + ) + validator = JWTValidator(oidc_settings) + + with pytest.raises(JWKSFetchError, match="Timeout"): + validator.validate("some.invalid.token") + + def test_nbf_in_future_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory + ): + """Token not yet valid (nbf in future) raises TokenValidationError.""" + token = token_factory(nbf=int(time.time()) + 3600) + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) + + def test_tampered_token_raises_token_validation_error( + self, oidc_settings, mock_jwks_client, token_factory, rsa_private_key_pem + ): + """Token signed with wrong key raises TokenValidationError.""" + from cryptography.hazmat.primitives.asymmetric import rsa as rsa_mod + from cryptography.hazmat.primitives import serialization + + other_key = rsa_mod.generate_private_key(public_exponent=65537, key_size=2048) + other_pem = other_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + + token = pyjwt.encode( + { + "sub": "auth0|user123", + "iss": "https://test.auth0.com/", + "aud": "https://api.test.example.com", + "exp": int(time.time()) + 3600, + "nbf": int(time.time()) - 10, + }, + other_pem, + algorithm="RS256", + headers={"kid": "test-key-1"}, + ) + + validator = JWTValidator(oidc_settings) + + with pytest.raises(TokenValidationError): + validator.validate(token) From a62e20d0010c999c8c25164892d40e0a6f149ea4 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:47:34 -0700 Subject: [PATCH 04/39] build: add stitch-auth to workspace members --- pyproject.toml | 3 +- uv.lock | 165 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a4d8390..87d0322 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,10 +7,11 @@ requires-python = ">=3.12" dependencies = ["stitch-core"] [tool.uv.workspace] -members = ["deployments/api", "packages/stitch-core"] +members = ["deployments/api", "packages/stitch-core", "packages/stitch-auth"] [tool.uv.sources] stitch-core = { workspace = true } +stitch-auth = { workspace = true } [dependency-groups] dev = ["pytest>=8.4.2", "ruff>=0.14.2"] diff --git a/uv.lock b/uv.lock index 9c97c2b..ed186f8 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,7 @@ requires-python = ">=3.12.12" members = [ "stitch", "stitch-api", + "stitch-auth", "stitch-core", ] @@ -58,6 +59,63 @@ 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/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { 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" @@ -79,6 +137,59 @@ 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 = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, +] + [[package]] name = "dnspython" version = "2.8.0" @@ -530,6 +641,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/f7/212343c1c9cfac35fd943c527af85e9091d633176e2a407a0797856ff7b9/psycopg_binary-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:04bb2de4ba69d6f8395b446ede795e8884c040ec71d01dd07ac2b2d18d4153d1", size = 3642122, upload-time = "2025-12-06T17:34:52.506Z" }, ] +[[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" @@ -657,6 +777,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.0.2" @@ -973,6 +1107,7 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, + { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -990,6 +1125,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, + { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] @@ -1001,6 +1137,35 @@ dev = [ { name = "pytest-anyio", specifier = ">=0.0.0" }, ] +[[package]] +name = "stitch-auth" +version = "0.1.0" +source = { editable = "packages/stitch-auth" } +dependencies = [ + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, +] + +[package.dev-dependencies] +dev = [ + { name = "cryptography" }, + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "pydantic", specifier = ">=2.0" }, + { name = "pydantic-settings", specifier = ">=2.11.0" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "cryptography", specifier = ">=44.0.0" }, + { name = "pytest", specifier = ">=8.0" }, +] + [[package]] name = "stitch-core" version = "0.1.0" From 4301aba03c180459c687db878fd6ac0fb706f03b Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:52:20 -0700 Subject: [PATCH 05/39] fix(lock): regenerate uv.lock without API stitch-auth dep --- uv.lock | 2 -- 1 file changed, 2 deletions(-) diff --git a/uv.lock b/uv.lock index ed186f8..a806a75 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,7 +1107,6 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, - { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -1125,7 +1124,6 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, - { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] From 967d86257ded77795743a2ac5cedd3aaefb3dae5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:47:58 -0700 Subject: [PATCH 06/39] build(api): add stitch-auth dependency --- deployments/api/pyproject.toml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/deployments/api/pyproject.toml b/deployments/api/pyproject.toml index bbf2243..4eff8e4 100644 --- a/deployments/api/pyproject.toml +++ b/deployments/api/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "greenlet>=3.3.0", "pydantic-settings>=2.12.0", "sqlalchemy>=2.0.44", + "stitch-auth", "stitch-core", ] @@ -39,4 +40,5 @@ python_functions = ["test_*"] addopts = ["-v", "--strict-markers", "--tb=short"] [tool.uv.sources] +stitch-auth = { workspace = true } stitch-core = { workspace = true } From 296271e55a5a61dcc1f55cc355403177ff357908 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:07:26 -0700 Subject: [PATCH 07/39] feat(api): add sub column to User, consolidate first_name/last_name to name --- deployments/api/src/stitch/api/db/init_job.py | 22 +++++++++---------- .../api/src/stitch/api/db/model/user.py | 7 ++++-- deployments/api/src/stitch/api/entities.py | 1 + 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/deployments/api/src/stitch/api/db/init_job.py b/deployments/api/src/stitch/api/db/init_job.py index d188ec5..5a256c2 100644 --- a/deployments/api/src/stitch/api/db/init_job.py +++ b/deployments/api/src/stitch/api/db/init_job.py @@ -27,7 +27,6 @@ User as UserEntity, WMData, ) -from stitch.api.deps import get_current_user """ DB init/seed job. @@ -259,21 +258,18 @@ def fail_partial(existing_tables: set[str], expected: set[str]) -> None: def create_seed_user() -> UserModel: return UserModel( id=1, - first_name="Seed", - last_name="User", + sub="seed|system", + name="Seed User", email="seed@example.com", ) def create_dev_user() -> UserModel: - dev_user = get_current_user() - print("[db-init] getting info for Dev User...", flush=True) - print(f"[db-init] User: '{dev_user}'...", flush=True) return UserModel( - id=dev_user.id, - first_name=dev_user.name, - last_name="Deverson", - email=dev_user.email, + id=2, + sub="dev|local-placeholder", + name="Dev Deverson", + email="dev@example.com", ) @@ -375,14 +371,16 @@ def seed_dev(engine) -> None: user_entity = UserEntity( id=user_model.id, + sub=user_model.sub, email=user_model.email, - name=f"{user_model.first_name} {user_model.last_name}", + name=user_model.name, ) dev_entity = UserEntity( id=dev_model.id, + sub=dev_model.sub, email=dev_model.email, - name=f"{dev_model.first_name} {dev_model.last_name}", + name=dev_model.name, ) gem_sources, wm_sources, rmi_sources, cc_sources = create_seed_sources() diff --git a/deployments/api/src/stitch/api/db/model/user.py b/deployments/api/src/stitch/api/db/model/user.py index 9f8bbb7..9c2047e 100644 --- a/deployments/api/src/stitch/api/db/model/user.py +++ b/deployments/api/src/stitch/api/db/model/user.py @@ -1,10 +1,13 @@ +from sqlalchemy import String, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from .common import Base class User(Base): __tablename__ = "users" + __table_args__ = (UniqueConstraint("sub", name="uq_users_sub"),) + id: Mapped[int] = mapped_column(primary_key=True) - first_name: Mapped[str] - last_name: Mapped[str] + sub: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] email: Mapped[str] diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py index 4401800..337a5ee 100644 --- a/deployments/api/src/stitch/api/entities.py +++ b/deployments/api/src/stitch/api/entities.py @@ -164,6 +164,7 @@ class CreateResource(ResourceBase): class User(BaseModel): id: int = Field(...) + sub: str = Field(...) role: str | None = None email: EmailStr name: str From bbebbb8d3862862e9952b8e09d326ba4d235150a Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:08:12 -0700 Subject: [PATCH 08/39] feat(api): integrate JWT auth with JIT user provisioning --- deployments/api/src/stitch/api/deps.py | 133 +++++++++++++++++- deployments/api/src/stitch/api/main.py | 2 + .../api/src/stitch/api/routers/resources.py | 6 +- deployments/api/tests/conftest.py | 14 +- 4 files changed, 145 insertions(+), 10 deletions(-) diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/deps.py index 49932e4..11282a2 100644 --- a/deployments/api/src/stitch/api/deps.py +++ b/deployments/api/src/stitch/api/deps.py @@ -1,13 +1,138 @@ +import asyncio +import logging +from functools import lru_cache from typing import Annotated -from fastapi import Depends +from fastapi import Depends, HTTPException, Request +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from starlette.status import HTTP_401_UNAUTHORIZED +from stitch.auth import JWTValidator, OIDCSettings, TokenClaims +from stitch.auth.errors import AuthError, JWKSFetchError + +from stitch.api.db.config import UnitOfWorkDep +from stitch.api.db.model.user import User as UserModel from stitch.api.entities import User +from stitch.api.settings import Environment, get_settings + +logger = logging.getLogger(__name__) + + +@lru_cache +def get_oidc_settings() -> OIDCSettings: + return OIDCSettings() + + +@lru_cache +def get_jwt_validator() -> JWTValidator: + return JWTValidator(get_oidc_settings()) + + +_DEV_CLAIMS = TokenClaims( + sub="dev|local-placeholder", + email="dev@example.com", + name="Dev User", + raw={}, +) + + +def validate_auth_config_at_startup() -> None: + """Called from FastAPI lifespan. Fail fast if misconfigured.""" + settings = get_oidc_settings() + if settings.disabled and get_settings().environment == Environment.PROD: + raise RuntimeError( + "AUTH_DISABLED=true is forbidden when ENVIRONMENT=prod. " + "Remove AUTH_DISABLED or set it to false." + ) + + +async def get_token_claims(request: Request) -> TokenClaims: + """Extract and validate JWT from Authorization header.""" + settings = get_oidc_settings() + + if settings.disabled: + return _DEV_CLAIMS + + auth_header = request.headers.get("Authorization") + if not auth_header: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Missing Authorization header", + headers={"WWW-Authenticate": "Bearer"}, + ) + + scheme, _, token = auth_header.partition(" ") + if scheme.lower() != "bearer" or not token: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid Authorization header format", + headers={"WWW-Authenticate": "Bearer"}, + ) + + validator = get_jwt_validator() + try: + return await asyncio.to_thread(validator.validate, token) + except JWKSFetchError: + logger.error( + "JWKS endpoint unreachable or returned invalid data", exc_info=True + ) + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + except AuthError: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + + +Claims = Annotated[TokenClaims, Depends(get_token_claims)] + + +async def get_current_user(claims: Claims, uow: UnitOfWorkDep) -> User: + """Resolve TokenClaims to a User entity. JIT provision on first login. + + Race-safe: uses a savepoint so concurrent first-login requests + don't corrupt the outer transaction on IntegrityError. + """ + session = uow.session + + user_model = ( + await session.execute(select(UserModel).where(UserModel.sub == claims.sub)) + ).scalar_one_or_none() + + if user_model is not None: + user_model.name = claims.name or user_model.name + user_model.email = claims.email or user_model.email + return _to_entity(user_model) + + try: + async with session.begin_nested(): + user_model = UserModel( + sub=claims.sub, + name=claims.name or "", + email=claims.email or "", + ) + session.add(user_model) + except IntegrityError: + user_model = ( + await session.execute(select(UserModel).where(UserModel.sub == claims.sub)) + ).scalar_one() + + return _to_entity(user_model) -def get_current_user() -> User: - """Placeholder user dependency. Replace with real auth in production.""" - return User(id=2, role="admin", email="dev@example.com", name="Dev") +def _to_entity(model: UserModel) -> User: + return User( + id=model.id, + sub=model.sub, + email=model.email, + name=model.name, + ) CurrentUser = Annotated[User, Depends(get_current_user)] diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index b8cd779..9d192f6 100644 --- a/deployments/api/src/stitch/api/main.py +++ b/deployments/api/src/stitch/api/main.py @@ -5,6 +5,7 @@ from starlette.status import HTTP_503_SERVICE_UNAVAILABLE from .middleware import register_middlewares from .db.config import dispose_engine +from .deps import validate_auth_config_at_startup from .settings import get_settings from .routers.resources import router as resource_router @@ -17,6 +18,7 @@ @asynccontextmanager async def lifespan(app: FastAPI): + validate_auth_config_at_startup() yield await dispose_engine() diff --git a/deployments/api/src/stitch/api/routers/resources.py b/deployments/api/src/stitch/api/routers/resources.py index 8f769cd..6458952 100644 --- a/deployments/api/src/stitch/api/routers/resources.py +++ b/deployments/api/src/stitch/api/routers/resources.py @@ -15,12 +15,14 @@ @router.get("/") -async def get_all_resources(*, uow: UnitOfWorkDep) -> Sequence[Resource]: +async def get_all_resources( + *, uow: UnitOfWorkDep, user: CurrentUser +) -> Sequence[Resource]: return await resource_actions.get_all(session=uow.session) @router.get("/{id}", response_model=Resource) -async def get_resource(*, uow: UnitOfWorkDep, id: int) -> Resource: +async def get_resource(*, uow: UnitOfWorkDep, user: CurrentUser, id: int) -> Resource: return await resource_actions.get(session=uow.session, id=id) diff --git a/deployments/api/tests/conftest.py b/deployments/api/tests/conftest.py index 2551a91..c85cf60 100644 --- a/deployments/api/tests/conftest.py +++ b/deployments/api/tests/conftest.py @@ -37,7 +37,9 @@ def anyio_backend() -> str: @pytest.fixture def test_user() -> User: """Test user entity for dependency injection.""" - return User(id=1, email="test@test.com", name="Test User", role="admin") + return User( + id=1, sub="test|user-1", email="test@test.com", name="Test User", role="admin" + ) @pytest.fixture @@ -45,8 +47,8 @@ def test_user_model() -> UserModel: """Test user ORM model for database seeding.""" return UserModel( id=1, - first_name="Test", - last_name="User", + sub="test|user-1", + name="Test User", email="test@test.com", ) @@ -84,9 +86,13 @@ def mock_uow(mock_session: MagicMock) -> MagicMock: @pytest.fixture(autouse=True) def reset_dependency_overrides(): - """Reset FastAPI dependency overrides after each test.""" + """Reset FastAPI dependency overrides and auth caches after each test.""" yield app.dependency_overrides = {} + from stitch.api.deps import get_oidc_settings, get_jwt_validator + + get_oidc_settings.cache_clear() + get_jwt_validator.cache_clear() @pytest.fixture From 387ccd4fde7241743532cc27d730eccc8a7067f0 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:58:46 -0700 Subject: [PATCH 09/39] infra: add stitch-auth to Docker build, AUTH_DISABLED for local dev --- env.example | 3 +++ 1 file changed, 3 insertions(+) diff --git a/env.example b/env.example index b222c8f..a51f749 100644 --- a/env.example +++ b/env.example @@ -12,3 +12,6 @@ STITCH_DB_SEED_MODE="if-needed" STITCH_DB_SEED_PROFILE="dev" FRONTEND_ORIGIN_URL=http://localhost:3000 + +# Auth (AUTH_DISABLED=true bypasses JWT validation for local dev) +AUTH_DISABLED=true From cc17a255b8626e59d267af0132314587599bd392 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:24:12 -0700 Subject: [PATCH 10/39] fix(stitch.api.db): improve type hints & LBYL in uow --- deployments/api/src/stitch/api/db/config.py | 5 +++-- deployments/api/src/stitch/api/db/model/sources.py | 3 ++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/deployments/api/src/stitch/api/db/config.py b/deployments/api/src/stitch/api/db/config.py index 8394abd..be7c8f0 100644 --- a/deployments/api/src/stitch/api/db/config.py +++ b/deployments/api/src/stitch/api/db/config.py @@ -33,8 +33,9 @@ async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: await self.rollback() else: await self.commit() - await self._session.close() - self._session = None + if self._session is not None: + await self._session.close() + self._session = None async def commit(self) -> None: await self.session.commit() diff --git a/deployments/api/src/stitch/api/db/model/sources.py b/deployments/api/src/stitch/api/db/model/sources.py index f05fc2d..a315b16 100644 --- a/deployments/api/src/stitch/api/db/model/sources.py +++ b/deployments/api/src/stitch/api/db/model/sources.py @@ -1,4 +1,5 @@ # pyright: reportAssignmentType=false +from typing_extensions import Self from collections.abc import Mapping, MutableMapping from typing import Final, Generic, TypeVar, TypedDict, get_args, get_origin @@ -67,7 +68,7 @@ def as_entity(self): return self.__entity_class_out__.model_validate(self) @classmethod - def from_entity(cls, entity: TModelIn) -> "SourceBase": + def from_entity(cls, entity: TModelIn) -> Self: mapper = inspect(cls) column_keys = {col.key for col in mapper.columns} filtered = {k: v for k, v in entity.model_dump().items() if k in column_keys} From 3eeaa1ec54063a641d16b33c93815b8ef6a222a5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:43:42 -0700 Subject: [PATCH 11/39] refactor(api): move auth code from deps.py to stitch.api.auth module --- deployments/api/src/stitch/api/{deps.py => auth.py} | 0 deployments/api/src/stitch/api/db/resource_actions.py | 2 +- deployments/api/src/stitch/api/main.py | 2 +- deployments/api/src/stitch/api/routers/resources.py | 2 +- deployments/api/tests/conftest.py | 4 ++-- 5 files changed, 5 insertions(+), 5 deletions(-) rename deployments/api/src/stitch/api/{deps.py => auth.py} (100%) diff --git a/deployments/api/src/stitch/api/deps.py b/deployments/api/src/stitch/api/auth.py similarity index 100% rename from deployments/api/src/stitch/api/deps.py rename to deployments/api/src/stitch/api/auth.py diff --git a/deployments/api/src/stitch/api/db/resource_actions.py b/deployments/api/src/stitch/api/db/resource_actions.py index 4e407ce..303da4d 100644 --- a/deployments/api/src/stitch/api/db/resource_actions.py +++ b/deployments/api/src/stitch/api/db/resource_actions.py @@ -9,7 +9,7 @@ from starlette.status import HTTP_404_NOT_FOUND from stitch.api.db.model.sources import SOURCE_TABLES, SourceModel -from stitch.api.deps import CurrentUser +from stitch.api.auth import CurrentUser from stitch.api.entities import ( CreateResource, CreateResourceSourceData, diff --git a/deployments/api/src/stitch/api/main.py b/deployments/api/src/stitch/api/main.py index 9d192f6..0cbf561 100644 --- a/deployments/api/src/stitch/api/main.py +++ b/deployments/api/src/stitch/api/main.py @@ -5,7 +5,7 @@ from starlette.status import HTTP_503_SERVICE_UNAVAILABLE from .middleware import register_middlewares from .db.config import dispose_engine -from .deps import validate_auth_config_at_startup +from .auth import validate_auth_config_at_startup from .settings import get_settings from .routers.resources import router as resource_router diff --git a/deployments/api/src/stitch/api/routers/resources.py b/deployments/api/src/stitch/api/routers/resources.py index 6458952..8d63c15 100644 --- a/deployments/api/src/stitch/api/routers/resources.py +++ b/deployments/api/src/stitch/api/routers/resources.py @@ -4,7 +4,7 @@ from stitch.api.db import resource_actions from stitch.api.db.config import UnitOfWorkDep -from stitch.api.deps import CurrentUser +from stitch.api.auth import CurrentUser from stitch.api.entities import CreateResource, Resource diff --git a/deployments/api/tests/conftest.py b/deployments/api/tests/conftest.py index c85cf60..54edf0c 100644 --- a/deployments/api/tests/conftest.py +++ b/deployments/api/tests/conftest.py @@ -16,7 +16,7 @@ UserModel, WMSourceModel, ) -from stitch.api.deps import get_current_user +from stitch.api.auth import get_current_user from stitch.api.entities import User from stitch.api.main import app @@ -89,7 +89,7 @@ def reset_dependency_overrides(): """Reset FastAPI dependency overrides and auth caches after each test.""" yield app.dependency_overrides = {} - from stitch.api.deps import get_oidc_settings, get_jwt_validator + from stitch.api.auth import get_oidc_settings, get_jwt_validator get_oidc_settings.cache_clear() get_jwt_validator.cache_clear() From 7c39caa069f54799af28a5cb4c68c4519385492e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:54:02 -0700 Subject: [PATCH 12/39] build(lock): update uv.lock for stitch-auth API dependency --- uv.lock | 2 ++ 1 file changed, 2 insertions(+) diff --git a/uv.lock b/uv.lock index a806a75..ed186f8 100644 --- a/uv.lock +++ b/uv.lock @@ -1107,6 +1107,7 @@ dependencies = [ { name = "greenlet" }, { name = "pydantic-settings" }, { name = "sqlalchemy" }, + { name = "stitch-auth" }, { name = "stitch-core" }, ] @@ -1124,6 +1125,7 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.3.0" }, { name = "pydantic-settings", specifier = ">=2.12.0" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, + { name = "stitch-auth", editable = "packages/stitch-auth" }, { name = "stitch-core", editable = "packages/stitch-core" }, ] From 5bd9155b6930f403b972d64a04a52c3cff25641f Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 12:58:31 -0700 Subject: [PATCH 13/39] ci: trigger workflow run after base branch change From 5ed8066c09195e1f565052a0247f042b0ee047b5 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:43:18 -0700 Subject: [PATCH 14/39] feat(auth): add local auth testing with localauth0 mock OIDC server --- README.md | 39 +++++++ deployments/api/src/stitch/api/auth.py | 18 ++- dev/localauth0.toml | 18 +++ docker-compose.yml | 8 ++ docs/auth-testing.md | 145 +++++++++++++++++++++++++ env.example | 14 ++- 6 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 dev/localauth0.toml create mode 100644 docs/auth-testing.md diff --git a/README.md b/README.md index 8d9c356..6201b28 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,45 @@ Note: The `db-init` service runs automatically (via `depends_on`) to apply schem - `STITCH_DB_SEED_MODE` - `STITCH_DB_SEED_PROFILE` +### Auth Testing (optional) + +By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardcoded dev user with no token required. To test real JWT auth flows locally with a mock OIDC server: + +1. Update `.env`: + ``` + AUTH_DISABLED=false + AUTH_ISSUER=http://localauth0:3000/ + AUTH_AUDIENCE=stitch-api-local + AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json + ``` + +2. Start with the `auth-test` profile: + ```bash + docker compose --profile auth-test up --build + ``` + +3. Get a token and make requests: + ```bash + # Health check (always open) + curl localhost:8000/api/v1/health + + # No token → 401 + curl localhost:8000/api/v1/resources/ + + # Get a valid token + TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + + # Authenticated request → 200 + curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ + ``` + +Swagger UI (`/docs`) also supports the "Authorize" button for token entry. + +See [docs/auth-testing.md](docs/auth-testing.md) for the full scenario guide. + ## Reset (wipe DB volumes safely) Stop containers and delete the Postgres volume (this removes all local DB data): diff --git a/deployments/api/src/stitch/api/auth.py b/deployments/api/src/stitch/api/auth.py index 11282a2..80d05b2 100644 --- a/deployments/api/src/stitch/api/auth.py +++ b/deployments/api/src/stitch/api/auth.py @@ -4,6 +4,7 @@ from typing import Annotated from fastapi import Depends, HTTPException, Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import select from sqlalchemy.exc import IntegrityError from starlette.status import HTTP_401_UNAUTHORIZED @@ -36,6 +37,10 @@ def get_jwt_validator() -> JWTValidator: raw={}, ) +# auto_error=False so that when AUTH_DISABLED=true the missing header +# doesn't trigger a 403 before our custom handler runs. +_bearer_scheme = HTTPBearer(auto_error=False) + def validate_auth_config_at_startup() -> None: """Called from FastAPI lifespan. Fail fast if misconfigured.""" @@ -47,8 +52,17 @@ def validate_auth_config_at_startup() -> None: ) -async def get_token_claims(request: Request) -> TokenClaims: - """Extract and validate JWT from Authorization header.""" +async def get_token_claims( + request: Request, + _credential: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme), +) -> TokenClaims: + """Extract and validate JWT from Authorization header. + + The ``_credential`` parameter exists solely so FastAPI registers the + HTTPBearer security scheme in the OpenAPI spec (Swagger "Authorize" + button). Actual token parsing still uses the raw header so we can + return precise 401 messages for missing/malformed values. + """ settings = get_oidc_settings() if settings.disabled: diff --git a/dev/localauth0.toml b/dev/localauth0.toml new file mode 100644 index 0000000..84777c8 --- /dev/null +++ b/dev/localauth0.toml @@ -0,0 +1,18 @@ +issuer = "http://localauth0:3000/" + +[user_info] +subject = "mock|dev-user-1" +name = "Dev User" +email = "dev@example.com" +email_verified = true + +[[audience]] +name = "stitch-api-local" +permissions = [] + +[[audience]] +name = "wrong-audience" +permissions = [] + +[http] +port = 3000 diff --git a/docker-compose.yml b/docker-compose.yml index 99622a0..a4900a3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,5 +81,13 @@ services: ports: - "3000:80" + localauth0: + profiles: [auth-test] + image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + ports: + - "3100:3000" + volumes: + - ./dev/localauth0.toml:/localauth0.toml:ro + volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md new file mode 100644 index 0000000..0efc83a --- /dev/null +++ b/docs/auth-testing.md @@ -0,0 +1,145 @@ +# Local Auth Testing Guide + +This guide covers how to test JWT authentication locally using [localauth0](https://github.com/primait/localauth0), a lightweight mock OIDC server. + +## Default Mode (auth disabled) + +By default, `AUTH_DISABLED=true` in `.env`. All API requests are accepted without tokens, and a hardcoded dev user (`sub="dev|local-placeholder"`) is injected. This is the normal local development experience. + +## Enabling Auth Testing + +### 1. Configure environment + +Update `.env` with the auth-test settings: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=http://localauth0:3000/ +AUTH_AUDIENCE=stitch-api-local +AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +``` + +### 2. Start the stack + +```bash +docker compose --profile auth-test up --build +``` + +This starts the normal stack (db, api, frontend) plus `localauth0` on port 3100 (host) / 3000 (Docker network). + +### 3. Verify localauth0 is running + +```bash +curl -s localhost:3100/.well-known/openid-configuration | jq . +``` + +## Getting Tokens + +### Valid token (correct audience) + +```bash +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') + +echo $TOKEN +``` + +### Token with wrong audience + +```bash +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +``` + +## Test Scenarios + +| # | Scenario | Command | Expected | +|---|----------|---------|----------| +| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | +| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | +| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | +| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | +| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | +| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | +| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | + +### Running the scenarios + +```bash +# 1. No token +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/resources/ +# → 401 + +# 2. Malformed header +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/ +# → 401 + +# 3. Garbage token +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/ +# → 401 + +# 4. Wrong audience +WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/ +# → 401 + +# 5. Valid token (first request — JIT user creation) +TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ + -H "Content-Type: application/json" \ + -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token') +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 6. Same token again (user already exists) +curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ +# → 200 + +# 7. Health endpoint (always open) +curl -s -o /dev/null -w "%{http_code}" localhost:8000/api/v1/health +# → 200 +``` + +## Verifying JIT User Provisioning + +After a successful authenticated request, verify the user was created in the database via Adminer: + +1. Open http://localhost:8081 +2. Connect to `stitch` database (user: `postgres`, password: `postgres`) +3. Browse the `users` table +4. You should see a row with `sub = "mock|dev-user-1"` + +## Using Swagger UI + +1. Open http://localhost:8000/docs +2. Click the "Authorize" button (lock icon) +3. Enter a Bearer token obtained from localauth0 +4. Click "Authorize" +5. All subsequent "Try it out" requests will include the token + +## localauth0 Configuration + +The mock server is configured via `dev/localauth0.toml`: + +- **Issuer**: `http://localauth0:3000/` (matches `AUTH_ISSUER`) +- **User**: `sub=mock|dev-user-1`, name "Dev User", email `dev@example.com` +- **Audiences**: `stitch-api-local` (valid) and `wrong-audience` (for testing rejection) +- **Port**: 3000 inside Docker, mapped to 3100 on the host + +## Configuring Real Auth0 + +For staging or production, replace the environment variables with your Auth0 tenant values: + +``` +AUTH_DISABLED=false +AUTH_ISSUER=https://your-tenant.auth0.com/ +AUTH_AUDIENCE=your-api-audience +AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json +``` diff --git a/env.example b/env.example index a51f749..c9f8389 100644 --- a/env.example +++ b/env.example @@ -13,5 +13,17 @@ STITCH_DB_SEED_PROFILE="dev" FRONTEND_ORIGIN_URL=http://localhost:3000 -# Auth (AUTH_DISABLED=true bypasses JWT validation for local dev) +# --- Auth --- +# AUTH_DISABLED=true bypasses JWT validation for local dev (default). AUTH_DISABLED=true + +# For local auth testing with mock OIDC (docker compose --profile auth-test): +# AUTH_DISABLED=false +# AUTH_ISSUER=http://localauth0:3000/ +# AUTH_AUDIENCE=stitch-api-local +# AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +# +# For real Auth0: +# AUTH_ISSUER=https://your-tenant.auth0.com/ +# AUTH_AUDIENCE=your-api-audience +# AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json From dd2099c0ec123634d8de611bbe74908f0bd451f0 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 13:54:34 -0700 Subject: [PATCH 15/39] md,yml formatting and localauth0 config --- docker-compose.yml | 6 +++++- docs/auth-testing.md | 18 +++++++++--------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index a4900a3..1112a33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -84,10 +84,14 @@ services: localauth0: profiles: [auth-test] image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + healthcheck: + test: ["CMD", "/localauth0", "healthcheck"] + environment: + LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml ports: - "3100:3000" volumes: - - ./dev/localauth0.toml:/localauth0.toml:ro + - ./dev/localauth0.toml:/etc/localauth0.toml:ro volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md index 0efc83a..c92c67c 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -57,15 +57,15 @@ WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ ## Test Scenarios -| # | Scenario | Command | Expected | -|---|----------|---------|----------| -| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | -| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | -| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | -| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | -| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | -| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | -| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | +| # | Scenario | Command | Expected | +| --- | --------------------------------- | --------------------------------------------------------------------------------- | ---------------------- | +| 1 | No Authorization header | `curl localhost:8000/api/v1/resources/` | 401 | +| 2 | Malformed header | `curl -H "Authorization: Basic xyz" localhost:8000/api/v1/resources/` | 401 | +| 3 | Garbage token (wrong signing key) | `curl -H "Authorization: Bearer not.a.real.jwt" localhost:8000/api/v1/resources/` | 401 | +| 4 | Wrong audience | `curl -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/` | 401 | +| 5 | Valid token, first request | `curl -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/` | 200, user JIT-created | +| 6 | Valid token, repeat request | Same as #5 | 200, user info updated | +| 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | ### Running the scenarios From 267bc09b838d1d42f78dfcb875505d3497363867 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:26:52 -0700 Subject: [PATCH 16/39] fix(auth): add access_token custom claims, auth-demo script, and docs improvements --- README.md | 4 +- dev/auth-demo.sh | 207 +++++++++++++++++++++++++++++++++++++++ dev/localauth0.toml | 9 ++ docker-compose.local.yml | 11 +++ docker-compose.yml | 12 --- docs/auth-testing.md | 49 +++++++-- 6 files changed, 272 insertions(+), 20 deletions(-) create mode 100755 dev/auth-demo.sh diff --git a/README.md b/README.md index 6201b28..1b04572 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardc 2. Start with the `auth-test` profile: ```bash - docker compose --profile auth-test up --build + docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build ``` 3. Get a token and make requests: @@ -82,7 +82,7 @@ By default, auth is disabled (`AUTH_DISABLED=true`) — all requests get a hardc # Get a valid token TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') # Authenticated request → 200 diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh new file mode 100755 index 0000000..04fc069 --- /dev/null +++ b/dev/auth-demo.sh @@ -0,0 +1,207 @@ +#!/usr/bin/env bash +# +# Interactive auth testing demo. +# Walks through each auth scenario against the local API + localauth0. +# +# Prerequisites: +# 1. .env configured with AUTH_DISABLED=false and localauth0 settings +# 2. Stack running: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up +# +# Usage: +# bash dev/auth-demo.sh + +set -euo pipefail + +API=localhost:8000/api/v1 +OIDC=localhost:3100 + +BOLD='\033[1m' +DIM='\033[2m' +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[0;33m' +CYAN='\033[0;36m' +RESET='\033[0m' + +# ── helpers ────────────────────────────────────────────────────────── + +step_number=0 + +wait_for_enter() { + echo "" + read -rp "$(echo -e "${DIM}Press Enter to run...${RESET}")" +} + +show_step() { + step_number=$((step_number + 1)) + local title=$1 + local description=$2 + local expect=$3 + + echo "" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e "${BOLD} Scenario ${step_number}: ${title}${RESET}" + echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" + echo -e " ${description}" + echo -e " ${CYAN}Expected: ${expect}${RESET}" +} + +show_cmd() { + echo -e "\n ${DIM}\$${RESET} $1" +} + +run_curl() { + local label=$1 + shift + echo "" + # Run curl, capture status code on last line + local response + response=$(curl -s -w "\n%{http_code}" "$@") + local http_code + http_code=$(echo "$response" | tail -1) + local body + body=$(echo "$response" | sed '$d') + + if [ -n "$body" ]; then + echo -e " ${DIM}Body:${RESET}" + # Pretty-print JSON if jq is available, otherwise raw + if command -v jq &>/dev/null; then + echo "$body" | jq . 2>/dev/null | sed 's/^/ /' || echo " $body" + else + echo " $body" + fi + fi + echo -e " ${BOLD}HTTP ${http_code}${RESET}" +} + +# ── preflight checks ──────────────────────────────────────────────── + +echo -e "${BOLD}Auth Testing Demo${RESET}" +echo -e "${DIM}Testing API at ${API}, OIDC at ${OIDC}${RESET}" +echo "" + +echo -n "Checking API... " +if curl -sf -o /dev/null "${API}/health" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " API is not reachable at ${API}. Is the stack running?" + exit 1 +fi + +echo -n "Checking localauth0... " +if curl -sf -o /dev/null "${OIDC}/.well-known/openid-configuration" 2>/dev/null; then + echo -e "${GREEN}OK${RESET}" +else + echo -e "${RED}FAILED${RESET}" + echo " localauth0 is not reachable at ${OIDC}." + echo " Start with: docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up" + exit 1 +fi + +# ── scenario 1: health endpoint (no auth required) ────────────────── + +show_step \ + "Health endpoint (no auth)" \ + "The /health endpoint is always open — no token required." \ + "200" +show_cmd "curl ${API}/health" +wait_for_enter +run_curl "health" "${API}/health" + +# ── scenario 2: no authorization header ───────────────────────────── + +show_step \ + "No Authorization header" \ + "A request with no token at all. The API checks for the Authorization\n header and rejects the request before any JWT parsing happens." \ + "401" +show_cmd "curl ${API}/resources/" +wait_for_enter +run_curl "no-auth" "${API}/resources/" + +# ── scenario 3: malformed header (wrong scheme) ───────────────────── + +show_step \ + "Malformed Authorization header" \ + "Using 'Basic' instead of 'Bearer'. The API parses the scheme and\n rejects anything that isn't 'Bearer '." \ + "401" +show_cmd "curl -H 'Authorization: Basic xyz' ${API}/resources/" +wait_for_enter +run_curl "basic-auth" "${API}/resources/" -H "Authorization: Basic xyz" + +# ── scenario 4: garbage token (wrong signing key) ─────────────────── + +show_step \ + "Garbage token (invalid JWT)" \ + "A string that isn't a valid JWT. The JWKS client can't find a matching\n key ID, so signature verification fails." \ + "401" +show_cmd "curl -H 'Authorization: Bearer not.a.real.jwt' ${API}/resources/" +wait_for_enter +run_curl "garbage" "${API}/resources/" -H "Authorization: Bearer not.a.real.jwt" + +# ── scenario 5: wrong audience ────────────────────────────────────── + +show_step \ + "Valid JWT, wrong audience" \ + "A properly signed token from localauth0, but issued for 'wrong-audience'\n instead of 'stitch-api-local'. The API validates the 'aud' claim and rejects it." \ + "401" + +echo -e "\n ${DIM}Fetching token with audience='wrong-audience'...${RESET}" +WRONG_TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$WRONG_TOKEN" ] || [ "$WRONG_TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#WRONG_TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$WRONG_TOKEN' ${API}/resources/" +wait_for_enter +run_curl "wrong-aud" "${API}/resources/" -H "Authorization: Bearer ${WRONG_TOKEN}" + +# ── scenario 6: valid token, first request (JIT provisioning) ─────── + +show_step \ + "Valid token — first request (JIT user creation)" \ + "A properly signed token with the correct audience. On the first\n authenticated request, the API creates a new user row in the database\n from the token's sub/name/email claims." \ + "200 + user JIT-created in DB" + +echo -e "\n ${DIM}Fetching token with audience='stitch-api-local'...${RESET}" +TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ + -H "Content-Type: application/json" \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + | jq -r '.access_token' 2>/dev/null) || true + +if [ -z "$TOKEN" ] || [ "$TOKEN" = "null" ]; then + echo -e " ${RED}Failed to get token from localauth0${RESET}" + exit 1 +fi +echo -e " ${GREEN}Got token${RESET} ${DIM}(${#TOKEN} chars)${RESET}" + +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-first" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── scenario 7: valid token, repeat request ───────────────────────── + +show_step \ + "Valid token — repeat request (user already exists)" \ + "Same token again. The API finds the existing user by 'sub' and updates\n name/email from the token claims. No new row is created." \ + "200 + user info updated" +show_cmd "curl -H 'Authorization: Bearer \$TOKEN' ${API}/resources/" +wait_for_enter +run_curl "valid-repeat" "${API}/resources/" -H "Authorization: Bearer ${TOKEN}" + +# ── done ───────────────────────────────────────────────────────────── + +echo "" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo -e "${GREEN}${BOLD} All scenarios complete.${RESET}" +echo -e "${BOLD}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${RESET}" +echo "" +echo -e " Verify JIT user in Adminer: ${CYAN}http://localhost:8081${RESET}" +echo -e " Try Swagger UI: ${CYAN}http://localhost:8000/docs${RESET}" +echo "" diff --git a/dev/localauth0.toml b/dev/localauth0.toml index 84777c8..c98906a 100644 --- a/dev/localauth0.toml +++ b/dev/localauth0.toml @@ -14,5 +14,14 @@ permissions = [] name = "wrong-audience" permissions = [] +# Inject user-profile claims into the access token. +# This mirrors a real Auth0 "Login / Post Login" Action that copies +# email and name into the access_token for API consumption. +[access_token] +custom_claims = [ + { name = "email", value = { String = "dev@example.com" } }, + { name = "name", value = { String = "Dev User" } }, +] + [http] port = 3000 diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 1808182..eca7ee2 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -19,3 +19,14 @@ services: - /app/packages - --reload-exclude - "*/tests/*" + localauth0: + profiles: [auth-test] + image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 + healthcheck: + test: ["CMD", "/localauth0", "healthcheck"] + environment: + LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml + ports: + - "3100:3000" + volumes: + - ./dev/localauth0.toml:/etc/localauth0.toml:ro diff --git a/docker-compose.yml b/docker-compose.yml index 1112a33..99622a0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -81,17 +81,5 @@ services: ports: - "3000:80" - localauth0: - profiles: [auth-test] - image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 - healthcheck: - test: ["CMD", "/localauth0", "healthcheck"] - environment: - LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml - ports: - - "3100:3000" - volumes: - - ./dev/localauth0.toml:/etc/localauth0.toml:ro - volumes: db_data: diff --git a/docs/auth-testing.md b/docs/auth-testing.md index c92c67c..e926381 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -2,6 +2,27 @@ This guide covers how to test JWT authentication locally using [localauth0](https://github.com/primait/localauth0), a lightweight mock OIDC server. +## How Auth Works in Production + +When `AUTH_DISABLED=false`, every request (except `/health`) goes through a JWT validation pipeline that mirrors a real Auth0 deployment: + +1. **Header parsing** — extract the `Bearer ` from the `Authorization` header +2. **JWKS fetch** — retrieve the signing key from the OIDC provider's `/.well-known/jwks.json` endpoint (cached for 600s) +3. **Signature verification** — verify the token was signed with the provider's private key (RS256) +4. **Claims validation** — the following claims are required and checked: + | Claim | Check | + |-------|-------| + | `exp` | Token has not expired (with 30s clock skew tolerance) | + | `nbf` | Token is not used before its "not before" time | + | `iss` | Issuer matches `AUTH_ISSUER` | + | `aud` | Audience matches `AUTH_AUDIENCE` | + | `sub` | Subject is present (unique user identifier) | +5. **User provisioning** — the `sub` claim is looked up in the `users` table. On first login, a user row is JIT-created; on subsequent logins, `name` and `email` are updated from the token claims. + +Any failure at steps 1-4 returns a **401** with `WWW-Authenticate: Bearer`. + +**Production guardrail:** `AUTH_DISABLED=true` is blocked at startup when `ENVIRONMENT=prod`. + ## Default Mode (auth disabled) By default, `AUTH_DISABLED=true` in `.env`. All API requests are accepted without tokens, and a hardcoded dev user (`sub="dev|local-placeholder"`) is injected. This is the normal local development experience. @@ -22,7 +43,7 @@ AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json ### 2. Start the stack ```bash -docker compose --profile auth-test up --build +docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build ``` This starts the normal stack (db, api, frontend) plus `localauth0` on port 3100 (host) / 3000 (Docker network). @@ -35,12 +56,14 @@ curl -s localhost:3100/.well-known/openid-configuration | jq . ## Getting Tokens +Tokens from localauth0 are valid for **24 hours** (`expires_in: 86400`). Expired-token validation is covered by unit tests in `packages/stitch-auth/tests/test_validator_unit.py`. + ### Valid token (correct audience) ```bash TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') echo $TOKEN @@ -51,7 +74,7 @@ echo $TOKEN ```bash WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ | jq -r '.access_token') ``` @@ -67,7 +90,17 @@ WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ | 6 | Valid token, repeat request | Same as #5 | 200, user info updated | | 7 | Health endpoint (no auth) | `curl localhost:8000/api/v1/health` | 200 always | -### Running the scenarios +**Not testable with localauth0:** wrong-issuer rejection (localauth0's issuer is fixed). This is validated in production and covered by unit tests (`test_validator_unit.py::test_wrong_issuer_raises`). + +### Interactive demo script + +Run the scenarios interactively with step-by-step confirmation: + +```bash +bash dev/auth-demo.sh +``` + +### Running the scenarios manually ```bash # 1. No token @@ -85,7 +118,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer not.a.real.jwt" # 4. Wrong audience WRONG_TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"wrong-audience","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ | jq -r '.access_token') curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" localhost:8000/api/v1/resources/ # → 401 @@ -93,7 +126,7 @@ curl -s -o /dev/null -w "%{http_code}" -H "Authorization: Bearer $WRONG_TOKEN" l # 5. Valid token (first request — JIT user creation) TOKEN=$(curl -s -X POST localhost:3100/oauth/token \ -H "Content-Type: application/json" \ - -d '{"client_id":"test","audience":"stitch-api-local","grant_type":"client_credentials"}' \ + -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ | jq -r '.access_token') curl -s -w "\n%{http_code}" -H "Authorization: Bearer $TOKEN" localhost:8000/api/v1/resources/ # → 200 @@ -124,6 +157,10 @@ After a successful authenticated request, verify the user was created in the dat 4. Click "Authorize" 5. All subsequent "Try it out" requests will include the token +## CORS and Browser Requests + +The API's CORS middleware explicitly allows the `Authorization` header from the configured `FRONTEND_ORIGIN_URL`. Browser-based requests from the frontend will include the JWT in the `Authorization` header and pass CORS preflight checks. To test this flow, use the frontend at http://localhost:3000 after authenticating via Swagger or configure the frontend to send tokens. + ## localauth0 Configuration The mock server is configured via `dev/localauth0.toml`: From 407bba4591cdc684ed74626a43cb4612e39a4d6c Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:42:49 -0700 Subject: [PATCH 17/39] fix(auth): handle garbage tokens in validator, add demo script confirmations --- dev/auth-demo.sh | 10 ++++++++-- packages/stitch-auth/src/stitch/auth/validator.py | 2 ++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/dev/auth-demo.sh b/dev/auth-demo.sh index 04fc069..fdd0e5e 100755 --- a/dev/auth-demo.sh +++ b/dev/auth-demo.sh @@ -146,7 +146,10 @@ show_step \ "A properly signed token from localauth0, but issued for 'wrong-audience'\n instead of 'stitch-api-local'. The API validates the 'aud' claim and rejects it." \ "401" -echo -e "\n ${DIM}Fetching token with audience='wrong-audience'...${RESET}" +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"wrong-audience\", ...}'${RESET}" +wait_for_enter + WRONG_TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ -H "Content-Type: application/json" \ -d '{"client_id":"client_id","client_secret":"client_secret","audience":"wrong-audience","grant_type":"client_credentials"}' \ @@ -169,7 +172,10 @@ show_step \ "A properly signed token with the correct audience. On the first\n authenticated request, the API creates a new user row in the database\n from the token's sub/name/email claims." \ "200 + user JIT-created in DB" -echo -e "\n ${DIM}Fetching token with audience='stitch-api-local'...${RESET}" +show_cmd "curl -s -X POST ${OIDC}/oauth/token -H 'Content-Type: application/json' \\" +echo -e " ${DIM}-d '{\"audience\":\"stitch-api-local\", ...}'${RESET}" +wait_for_enter + TOKEN=$(curl -s -X POST "${OIDC}/oauth/token" \ -H "Content-Type: application/json" \ -d '{"client_id":"client_id","client_secret":"client_secret","audience":"stitch-api-local","grant_type":"client_credentials"}' \ diff --git a/packages/stitch-auth/src/stitch/auth/validator.py b/packages/stitch-auth/src/stitch/auth/validator.py index 947b900..0ac2532 100644 --- a/packages/stitch-auth/src/stitch/auth/validator.py +++ b/packages/stitch-auth/src/stitch/auth/validator.py @@ -22,6 +22,8 @@ def validate(self, token: str) -> TokenClaims: signing_key = self._jwks_client.get_signing_key_from_jwt(token) except (jwt.PyJWKClientError, jwt.PyJWKClientConnectionError) as e: raise JWKSFetchError(str(e)) from e + except jwt.InvalidTokenError as e: + raise TokenValidationError(str(e)) from e try: payload = jwt.decode( From 5a2dd606e04e4e6bf7c32b2da82f527bd7814d67 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Tue, 10 Feb 2026 19:55:10 -0700 Subject: [PATCH 18/39] ci: trigger workflow run after base branch change From 0d3d731d946912e396bebbca9cfd3abc601b1439 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Wed, 18 Feb 2026 16:12:48 -0700 Subject: [PATCH 19/39] feat: add auth0 npm package --- deployments/stitch-frontend/package-lock.json | 98 +++++++++++++++++++ deployments/stitch-frontend/package.json | 1 + 2 files changed, 99 insertions(+) diff --git a/deployments/stitch-frontend/package-lock.json b/deployments/stitch-frontend/package-lock.json index 1a23ae5..1fdce74 100644 --- a/deployments/stitch-frontend/package-lock.json +++ b/deployments/stitch-frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "stitch-frontend", "version": "0.0.0", "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", @@ -113,6 +114,41 @@ "dev": true, "license": "MIT" }, + "node_modules/@auth0/auth0-auth-js": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-auth-js/-/auth0-auth-js-1.4.0.tgz", + "integrity": "sha512-ShA7KT4KvcBEtxsXZTcrmoNxai5q1JXhB2aEBFnZD1L6LNLzzmiUWiFTtGMsaaITCylr8TJ/onEQk6XZmUHXbg==", + "license": "MIT", + "dependencies": { + "jose": "^6.0.8", + "openid-client": "^6.8.0" + } + }, + "node_modules/@auth0/auth0-react": { + "version": "2.15.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-react/-/auth0-react-2.15.0.tgz", + "integrity": "sha512-LbRU87U54/YW/N3UHtNVoj3mCBBz+iYAdAByQjbXOkpI6IYnjMBwIwDusW3N23XNXq9WnihD57Dyi2R3/Q9btw==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-spa-js": "^2.16.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1", + "react-dom": "^16.11.0 || ^17 || ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1" + } + }, + "node_modules/@auth0/auth0-spa-js": { + "version": "2.16.0", + "resolved": "https://registry.npmjs.org/@auth0/auth0-spa-js/-/auth0-spa-js-2.16.0.tgz", + "integrity": "sha512-UTP45NqjC2jVc/WaWh+iYOZt6FajpTJc+3WzljbXBiv2f76wDw4Mt9hW/aShBovsRmvKEIHaCifD3c/Gxmo2ZQ==", + "license": "MIT", + "dependencies": { + "@auth0/auth0-auth-js": "^1.4.0", + "browser-tabs-lock": "^1.2.15", + "dpop": "^2.1.1", + "es-cookie": "~1.3.2" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2388,6 +2424,16 @@ "concat-map": "0.0.1" } }, + "node_modules/browser-tabs-lock": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/browser-tabs-lock/-/browser-tabs-lock-1.3.0.tgz", + "integrity": "sha512-g6nHaobTiT0eMZ7jh16YpD2kcjAp+PInbiVq3M1x6KKaEIVhT4v9oURNIpZLOZ3LQbQ3XYfNhMAb/9hzNLIWrw==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "lodash": ">=4.17.21" + } + }, "node_modules/browserslist": { "version": "4.28.1", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", @@ -2682,6 +2728,15 @@ "license": "MIT", "peer": true }, + "node_modules/dpop": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/dpop/-/dpop-2.1.1.tgz", + "integrity": "sha512-J0Of2JTiM4h5si0tlbPQ/lkqfZ5wAEVkKYBhkwyyANnPJfWH4VsR5uIkZ+T+OSPIwDYUg1fbd5Mmodd25HjY1w==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2729,6 +2784,12 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-cookie": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", + "integrity": "sha512-UTlYYhXGLOy05P/vKVT2Ui7WtC7NiRzGtJyAKKn32g5Gvcjn7KAClLPWlipCtxIus934dFg9o9jXiBL0nP+t9Q==", + "license": "MIT" + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -3481,6 +3542,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz", + "integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -3877,6 +3947,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -4051,6 +4127,28 @@ "dev": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz", + "integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/openid-client": { + "version": "6.8.2", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz", + "integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==", + "license": "MIT", + "dependencies": { + "jose": "^6.1.3", + "oauth4webapi": "^3.8.4" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", diff --git a/deployments/stitch-frontend/package.json b/deployments/stitch-frontend/package.json index 22d99f5..01b22a4 100644 --- a/deployments/stitch-frontend/package.json +++ b/deployments/stitch-frontend/package.json @@ -16,6 +16,7 @@ "test:coverage": "vitest --coverage" }, "dependencies": { + "@auth0/auth0-react": "^2.15.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.16", "react": "^19.2.0", From 4626d39f8119a1f095def559abe967e32840b7d9 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:35:46 -0700 Subject: [PATCH 20/39] chore(frontend): add .env.example and .env to .gitignore --- deployments/stitch-frontend/.env.example | 4 ++++ deployments/stitch-frontend/.gitignore | 1 + 2 files changed, 5 insertions(+) create mode 100644 deployments/stitch-frontend/.env.example diff --git a/deployments/stitch-frontend/.env.example b/deployments/stitch-frontend/.env.example new file mode 100644 index 0000000..db76088 --- /dev/null +++ b/deployments/stitch-frontend/.env.example @@ -0,0 +1,4 @@ +VITE_AUTH0_DOMAIN= +VITE_AUTH0_CLIENT_ID= +VITE_AUTH0_AUDIENCE= +VITE_API_URL= diff --git a/deployments/stitch-frontend/.gitignore b/deployments/stitch-frontend/.gitignore index de9ee66..62be7e5 100644 --- a/deployments/stitch-frontend/.gitignore +++ b/deployments/stitch-frontend/.gitignore @@ -7,6 +7,7 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* +.env node_modules dist dist-ssr From 498db093bf9b80552c62ea9b736890e020dd6191 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:36:07 -0700 Subject: [PATCH 21/39] test(frontend): add Auth0 env vars to vitest config --- deployments/stitch-frontend/vitest.config.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/deployments/stitch-frontend/vitest.config.js b/deployments/stitch-frontend/vitest.config.js index 908aa37..606ff9b 100644 --- a/deployments/stitch-frontend/vitest.config.js +++ b/deployments/stitch-frontend/vitest.config.js @@ -8,5 +8,11 @@ export default defineConfig({ environment: "jsdom", setupFiles: "./src/test/setup.js", css: true, + env: { + VITE_AUTH0_DOMAIN: "test.auth0.com", + VITE_AUTH0_CLIENT_ID: "test-client-id", + VITE_AUTH0_AUDIENCE: "https://test-api", + VITE_API_URL: "http://localhost:8000/api/v1", + }, }, }); From 973c86e0eceb8317a751b62a8afd26be66471333 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:36:25 -0700 Subject: [PATCH 22/39] test(frontend): add global Auth0 mock to test setup --- deployments/stitch-frontend/src/test/setup.js | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/deployments/stitch-frontend/src/test/setup.js b/deployments/stitch-frontend/src/test/setup.js index 971941d..9478604 100644 --- a/deployments/stitch-frontend/src/test/setup.js +++ b/deployments/stitch-frontend/src/test/setup.js @@ -1,4 +1,4 @@ -import { expect, afterEach } from "vitest"; +import { expect, afterEach, vi } from "vitest"; import { cleanup } from "@testing-library/react"; import * as matchers from "@testing-library/jest-dom/matchers"; @@ -7,3 +7,16 @@ expect.extend(matchers); afterEach(() => { cleanup(); }); + +vi.mock("@auth0/auth0-react", () => ({ + useAuth0: vi.fn().mockReturnValue({ + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), + }), + Auth0Provider: ({ children }) => children, +})); From 7ed9191f1a4ee6791e2fdbdb6be1994e3bb7bd49 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:37:44 -0700 Subject: [PATCH 23/39] feat(frontend): implement authenticated fetcher with tests --- deployments/stitch-frontend/src/auth/api.js | 12 ++++ .../stitch-frontend/src/auth/api.test.js | 68 +++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 deployments/stitch-frontend/src/auth/api.js create mode 100644 deployments/stitch-frontend/src/auth/api.test.js diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js new file mode 100644 index 0000000..2c03d06 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.js @@ -0,0 +1,12 @@ +import config from "../config/env"; + +export function createAuthenticatedFetcher(getAccessTokenSilently) { + return async (url, options = {}) => { + const token = await getAccessTokenSilently({ + authorizationParams: { audience: config.auth0.audience }, + }); + const headers = new Headers(options.headers); + headers.set("Authorization", `Bearer ${token}`); + return fetch(url, { ...options, headers }); + }; +} diff --git a/deployments/stitch-frontend/src/auth/api.test.js b/deployments/stitch-frontend/src/auth/api.test.js new file mode 100644 index 0000000..3cbf848 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/api.test.js @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { createAuthenticatedFetcher } from "./api"; + +describe("createAuthenticatedFetcher", () => { + let getAccessTokenSilently; + let fetcher; + + beforeEach(() => { + getAccessTokenSilently = vi.fn().mockResolvedValue("test-token"); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(JSON.stringify({ ok: true }))), + ); + }); + + it("attaches Bearer token to request", async () => { + await fetcher("http://api.test/data"); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("passes audience to getAccessTokenSilently", async () => { + await fetcher("http://api.test/data"); + + expect(getAccessTokenSilently).toHaveBeenCalledWith({ + authorizationParams: { audience: "https://test-api" }, + }); + }); + + it("preserves caller headers", async () => { + await fetcher("http://api.test/data", { + headers: { "Content-Type": "application/json" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Content-Type")).toBe("application/json"); + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("overrides caller Authorization with token", async () => { + await fetcher("http://api.test/data", { + headers: { Authorization: "Bearer old-token" }, + }); + + const [, options] = fetch.mock.calls[0]; + expect(options.headers.get("Authorization")).toBe("Bearer test-token"); + }); + + it("propagates token acquisition errors", async () => { + getAccessTokenSilently.mockRejectedValue(new Error("token error")); + fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "token error", + ); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("propagates fetch errors", async () => { + fetch.mockRejectedValue(new Error("network error")); + + await expect(fetcher("http://api.test/data")).rejects.toThrow( + "network error", + ); + }); +}); From cae5cd3c893f818a519f0dcc96a49124050e955d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:38:31 -0700 Subject: [PATCH 24/39] feat(frontend): update API functions to accept fetcher parameter --- .../stitch-frontend/src/queries/api.js | 15 +++---- .../stitch-frontend/src/queries/api.test.js | 44 +++++++++++-------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index d585eb5..91f54d9 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -1,9 +1,8 @@ -const API_BASE_URL = - import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1"; +import config from "../config/env"; -export async function getResources() { - const url = `${API_BASE_URL}/resources/`; - const response = await fetch(url); +export async function getResources(fetcher) { + const url = `${config.apiBaseUrl}/resources/`; + const response = await fetcher(url); if (!response.ok) { throw new Error(`HTTP error! status: ${response.status}`); } @@ -11,9 +10,9 @@ export async function getResources() { return data; } -export async function getResource(id) { - const url = `${API_BASE_URL}/resources/${id}`; - const response = await fetch(url); +export async function getResource(id, fetcher) { + const url = `${config.apiBaseUrl}/resources/${id}`; + const response = await fetcher(url); if (!response.ok) { const error = new Error(`HTTP error! status: ${response.status}`); error.status = response.status; diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index 54425a3..3645043 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -2,8 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { getResources, getResource } from "./api"; describe("API Functions", () => { + let mockFetcher; + beforeEach(() => { - global.fetch = vi.fn(); + mockFetcher = vi.fn(); }); describe("getResources", () => { @@ -13,33 +15,35 @@ describe("API Functions", () => { { id: 2, name: "Resource 2", type: "test" }, ]; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResources, }); - const result = await getResources(); + const result = await getResources(mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/", ); expect(result).toEqual(mockResources); }); it("throws error when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResources()).rejects.toThrow("HTTP error! status: 500"); + await expect(getResources(mockFetcher)).rejects.toThrow( + "HTTP error! status: 500", + ); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Network error")); + mockFetcher.mockRejectedValueOnce(new Error("Network error")); - await expect(getResources()).rejects.toThrow("Network error"); + await expect(getResources(mockFetcher)).rejects.toThrow("Network error"); }); }); @@ -47,28 +51,28 @@ describe("API Functions", () => { it("fetches and returns a single resource successfully", async () => { const mockResource = { id: 42, name: "Test Resource", type: "example" }; - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: true, status: 200, json: async () => mockResource, }); - const result = await getResource(42); + const result = await getResource(42, mockFetcher); - expect(global.fetch).toHaveBeenCalledWith( + expect(mockFetcher).toHaveBeenCalledWith( "http://localhost:8000/api/v1/resources/42", ); expect(result).toEqual(mockResource); }); it("throws error with status when response is not ok", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); try { - await getResource(999); + await getResource(999, mockFetcher); expect.fail("Should have thrown an error"); } catch (error) { expect(error.message).toBe("HTTP error! status: 404"); @@ -77,33 +81,35 @@ describe("API Functions", () => { }); it("includes status code in error object for 404", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 404, }); - await expect(getResource(123)).rejects.toMatchObject({ + await expect(getResource(123, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 404", status: 404, }); }); it("includes status code in error object for 500", async () => { - global.fetch.mockResolvedValueOnce({ + mockFetcher.mockResolvedValueOnce({ ok: false, status: 500, }); - await expect(getResource(1)).rejects.toMatchObject({ + await expect(getResource(1, mockFetcher)).rejects.toMatchObject({ message: "HTTP error! status: 500", status: 500, }); }); it("throws error on network failure", async () => { - global.fetch.mockRejectedValueOnce(new Error("Failed to fetch")); + mockFetcher.mockRejectedValueOnce(new Error("Failed to fetch")); - await expect(getResource(1)).rejects.toThrow("Failed to fetch"); + await expect(getResource(1, mockFetcher)).rejects.toThrow( + "Failed to fetch", + ); }); }); }); From 50748b4766f25c5a2ecd4bb9c3991bcb31aeb174 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:38:59 -0700 Subject: [PATCH 25/39] feat(frontend): add useAuthenticatedQuery hook and wire up auth to queries --- .../stitch-frontend/src/hooks/useAuthenticatedQuery.js | 10 ++++++++++ deployments/stitch-frontend/src/hooks/useResources.js | 6 +++--- deployments/stitch-frontend/src/queries/resources.js | 4 ++-- 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js new file mode 100644 index 0000000..01d7415 --- /dev/null +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -0,0 +1,10 @@ +import { useQuery } from "@tanstack/react-query"; +import { useAuth0 } from "@auth0/auth0-react"; +import { createAuthenticatedFetcher } from "../auth/api"; + +export function useAuthenticatedQuery(queryOptions) { + const { getAccessTokenSilently } = useAuth0(); + const fetcher = createAuthenticatedFetcher(getAccessTokenSilently); + const { queryFn, ...rest } = queryOptions; + return useQuery({ ...rest, queryFn: () => queryFn(fetcher) }); +} diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index 5957e20..dad38b3 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -1,10 +1,10 @@ -import { useQuery } from "@tanstack/react-query"; +import { useAuthenticatedQuery } from "./useAuthenticatedQuery"; import { resourceQueries } from "../queries/resources"; export function useResources() { - return useQuery(resourceQueries.list()); + return useAuthenticatedQuery(resourceQueries.list()); } export function useResource(id) { - return useQuery(resourceQueries.detail(id)); + return useAuthenticatedQuery(resourceQueries.detail(id)); } diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index a2b497d..3ff7413 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -13,13 +13,13 @@ export const resourceKeys = { export const resourceQueries = { list: () => ({ queryKey: resourceKeys.lists(), - queryFn: getResources, + queryFn: (fetcher) => getResources(fetcher), enabled: false, }), detail: (id) => ({ queryKey: resourceKeys.detail(id), - queryFn: () => getResource(id), + queryFn: (fetcher) => getResource(id, fetcher), enabled: false, }), }; From 0d74999a38eab793a969c91e2066b8238a6e8962 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:39:33 -0700 Subject: [PATCH 26/39] feat(frontend): implement AuthGate component with tests --- .../stitch-frontend/src/auth/AuthGate.jsx | 36 ++++++++ .../src/auth/AuthGate.test.jsx | 91 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 deployments/stitch-frontend/src/auth/AuthGate.jsx create mode 100644 deployments/stitch-frontend/src/auth/AuthGate.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx new file mode 100644 index 0000000..9026d6b --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -0,0 +1,36 @@ +import { useEffect } from "react"; +import { useAuth0 } from "@auth0/auth0-react"; + +export default function AuthGate({ children }) { + const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0(); + + useEffect(() => { + if (!isLoading && !isAuthenticated && !error) { + loginWithRedirect(); + } + }, [isLoading, isAuthenticated, error, loginWithRedirect]); + + if (isLoading) { + return ( +
+

Loading...

+
+ ); + } + + if (error) { + return ( +
+

+ Authentication error: {error.message} +

+
+ ); + } + + if (!isAuthenticated) { + return null; + } + + return children; +} diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx new file mode 100644 index 0000000..a2140c7 --- /dev/null +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -0,0 +1,91 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { useAuth0 } from "@auth0/auth0-react"; +import AuthGate from "./AuthGate"; + +describe("AuthGate", () => { + it("shows loading indicator while auth is loading", () => { + vi.mocked(useAuth0).mockReturnValue({ + isLoading: true, + isAuthenticated: false, + error: null, + loginWithRedirect: vi.fn(), + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("Loading...")).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(vi.mocked(useAuth0)().loginWithRedirect).not.toHaveBeenCalled(); + }); + + it("shows error message when auth fails", () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: false, + error: new Error("Something went wrong"), + loginWithRedirect, + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect( + screen.getByText("Authentication error: Something went wrong"), + ).toBeInTheDocument(); + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(loginWithRedirect).not.toHaveBeenCalled(); + }); + + it("calls loginWithRedirect when unauthenticated", () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: false, + error: null, + loginWithRedirect, + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.queryByText("App Content")).not.toBeInTheDocument(); + expect(loginWithRedirect).toHaveBeenCalled(); + }); + + it("renders children when authenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + isLoading: false, + isAuthenticated: true, + error: null, + loginWithRedirect: vi.fn(), + getAccessTokenSilently: vi.fn(), + logout: vi.fn(), + }); + + render( + +
App Content
+
, + ); + + expect(screen.getByText("App Content")).toBeInTheDocument(); + }); +}); From ab622c51bf1bd2b67ed2ea6583c6c4dd298e6c0d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:40:17 -0700 Subject: [PATCH 27/39] feat(frontend): configure Auth0Provider, add AuthGate and LogoutButton to app --- deployments/stitch-frontend/src/App.jsx | 4 ++++ .../src/components/ResourceView.jsx | 3 ++- deployments/stitch-frontend/src/main.jsx | 21 ++++++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/deployments/stitch-frontend/src/App.jsx b/deployments/stitch-frontend/src/App.jsx index 7c25b62..0568759 100644 --- a/deployments/stitch-frontend/src/App.jsx +++ b/deployments/stitch-frontend/src/App.jsx @@ -1,9 +1,13 @@ import ResourcesView from "./components/ResourcesView"; import ResourceView from "./components/ResourceView"; +import { LogoutButton } from "./components/LogoutButton"; function App() { return (
+
+ +
diff --git a/deployments/stitch-frontend/src/components/ResourceView.jsx b/deployments/stitch-frontend/src/components/ResourceView.jsx index 65ba525..b393a79 100644 --- a/deployments/stitch-frontend/src/components/ResourceView.jsx +++ b/deployments/stitch-frontend/src/components/ResourceView.jsx @@ -6,6 +6,7 @@ import ClearCacheButton from "./ClearCacheButton"; import JsonView from "./JsonView"; import Input from "./Input"; import { resourceKeys } from "../queries/resources"; +import config from "../config/env"; export default function ResourceView({ className, endpoint }) { const queryClient = useQueryClient(); @@ -29,7 +30,7 @@ export default function ResourceView({ className, endpoint }) {
- {import.meta.env.VITE_API_URL} + {config.apiBaseUrl} {endpoint}
diff --git a/deployments/stitch-frontend/src/main.jsx b/deployments/stitch-frontend/src/main.jsx index d489c37..e4221ad 100644 --- a/deployments/stitch-frontend/src/main.jsx +++ b/deployments/stitch-frontend/src/main.jsx @@ -1,8 +1,11 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Auth0Provider } from "@auth0/auth0-react"; import "./index.css"; import App from "./App.jsx"; +import AuthGate from "./auth/AuthGate"; +import config from "./config/env"; // Set global defaults for QueryClient const queryClient = new QueryClient({ @@ -16,8 +19,20 @@ const queryClient = new QueryClient({ createRoot(document.getElementById("root")).render( - - - + + + + + + + , ); From 77d4a973b8cc0ed2a365273bc852d9f5dc26508b Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 15:40:58 -0700 Subject: [PATCH 28/39] test(frontend): add env config tests --- .../stitch-frontend/src/config/env.test.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 deployments/stitch-frontend/src/config/env.test.js diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js new file mode 100644 index 0000000..1be3216 --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("config/env", () => { + beforeEach(() => { + vi.resetModules(); + }); + + it("returns correct config when all vars are present", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", "http://localhost:9000/api/v1"); + + const { default: config } = await import("./env.js"); + + expect(config.auth0.domain).toBe("my.auth0.com"); + expect(config.auth0.clientId).toBe("my-client-id"); + expect(config.auth0.audience).toBe("https://my-api"); + expect(config.apiBaseUrl).toBe("http://localhost:9000/api/v1"); + }); + + it("throws when required vars are missing", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", ""); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", ""); + vi.stubEnv("VITE_AUTH0_AUDIENCE", ""); + + await expect(() => import("./env.js")).rejects.toThrow( + "VITE_AUTH0_DOMAIN", + ); + }); + + it("uses default API URL when VITE_API_URL is unset", async () => { + vi.stubEnv("VITE_AUTH0_DOMAIN", "my.auth0.com"); + vi.stubEnv("VITE_AUTH0_CLIENT_ID", "my-client-id"); + vi.stubEnv("VITE_AUTH0_AUDIENCE", "https://my-api"); + vi.stubEnv("VITE_API_URL", ""); + + const { default: config } = await import("./env.js"); + + expect(config.apiBaseUrl).toBe("http://localhost:8000/api/v1"); + }); +}); From 6e8975fa4c56a8d4997bd0e6ffdf353b579c0b35 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 16:19:32 -0700 Subject: [PATCH 29/39] feat(frontend): add LogoutButton component and env config module --- .../src/components/LogoutButton.jsx | 11 +++++++ deployments/stitch-frontend/src/config/env.js | 29 +++++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 deployments/stitch-frontend/src/components/LogoutButton.jsx create mode 100644 deployments/stitch-frontend/src/config/env.js diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx new file mode 100644 index 0000000..4fea9a6 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -0,0 +1,11 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export const LogoutButton = () => { + const { isAuthenticated, logout: authLogout } = useAuth0(); + + const logout = () => + authLogout({ logoutParams: { returnTo: window.location.origin } }); + + return isAuthenticated && ; +}; diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js new file mode 100644 index 0000000..3af620c --- /dev/null +++ b/deployments/stitch-frontend/src/config/env.js @@ -0,0 +1,29 @@ +const REQUIRED_KEYS = [ + "VITE_AUTH0_DOMAIN", + "VITE_AUTH0_CLIENT_ID", + "VITE_AUTH0_AUDIENCE", +]; + +function loadConfig() { + const missing = REQUIRED_KEYS.filter((key) => !import.meta.env[key]); + + if (missing.length > 0) { + throw new Error( + `Missing required environment variables: ${missing.join(", ")}. ` + + `Check your .env file or deployment config.`, + ); + } + + return Object.freeze({ + auth0: Object.freeze({ + domain: import.meta.env.VITE_AUTH0_DOMAIN, + clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, + audience: import.meta.env.VITE_AUTH0_AUDIENCE, + }), + apiBaseUrl: import.meta.env.VITE_API_URL || "http://localhost:8000/api/v1", + }); +} + +const config = loadConfig(); + +export default config; From e70cc875ede474fd6113163cda4b122b13f5b59f Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 16:22:04 -0700 Subject: [PATCH 30/39] chore: move .env.example to repo root, remove redundant frontend .env ignore --- deployments/stitch-frontend/.env.example => .env.example | 0 deployments/stitch-frontend/.gitignore | 1 - 2 files changed, 1 deletion(-) rename deployments/stitch-frontend/.env.example => .env.example (100%) diff --git a/deployments/stitch-frontend/.env.example b/.env.example similarity index 100% rename from deployments/stitch-frontend/.env.example rename to .env.example diff --git a/deployments/stitch-frontend/.gitignore b/deployments/stitch-frontend/.gitignore index 62be7e5..de9ee66 100644 --- a/deployments/stitch-frontend/.gitignore +++ b/deployments/stitch-frontend/.gitignore @@ -7,7 +7,6 @@ yarn-error.log* pnpm-debug.log* lerna-debug.log* -.env node_modules dist dist-ssr From a5ed2023a621e1e534306550fdf4fbfe9e505f1d Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 20:10:33 -0700 Subject: [PATCH 31/39] test(frontend): add LogoutButton tests, extract shared auth0 mock defaults, add JSDoc --- .../src/auth/AuthGate.test.jsx | 34 ++--------------- deployments/stitch-frontend/src/auth/api.js | 9 +++++ .../src/components/LogoutButton.test.jsx | 38 +++++++++++++++++++ deployments/stitch-frontend/src/config/env.js | 3 +- .../src/hooks/useAuthenticatedQuery.js | 11 ++++++ .../stitch-frontend/src/test/utils.jsx | 17 +++++++++ 6 files changed, 81 insertions(+), 31 deletions(-) create mode 100644 deployments/stitch-frontend/src/components/LogoutButton.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx index a2140c7..5d3cde6 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -1,17 +1,15 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; import AuthGate from "./AuthGate"; describe("AuthGate", () => { it("shows loading indicator while auth is loading", () => { vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, isLoading: true, isAuthenticated: false, - error: null, - loginWithRedirect: vi.fn(), - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -28,12 +26,10 @@ describe("AuthGate", () => { it("shows error message when auth fails", () => { const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, + ...auth0TestDefaults, isAuthenticated: false, error: new Error("Something went wrong"), loginWithRedirect, - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -52,12 +48,9 @@ describe("AuthGate", () => { it("calls loginWithRedirect when unauthenticated", () => { const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, + ...auth0TestDefaults, isAuthenticated: false, - error: null, loginWithRedirect, - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), }); render( @@ -69,23 +62,4 @@ describe("AuthGate", () => { expect(screen.queryByText("App Content")).not.toBeInTheDocument(); expect(loginWithRedirect).toHaveBeenCalled(); }); - - it("renders children when authenticated", () => { - vi.mocked(useAuth0).mockReturnValue({ - isLoading: false, - isAuthenticated: true, - error: null, - loginWithRedirect: vi.fn(), - getAccessTokenSilently: vi.fn(), - logout: vi.fn(), - }); - - render( - -
App Content
-
, - ); - - expect(screen.getByText("App Content")).toBeInTheDocument(); - }); }); diff --git a/deployments/stitch-frontend/src/auth/api.js b/deployments/stitch-frontend/src/auth/api.js index 2c03d06..80ba100 100644 --- a/deployments/stitch-frontend/src/auth/api.js +++ b/deployments/stitch-frontend/src/auth/api.js @@ -1,5 +1,14 @@ import config from "../config/env"; +/** + * Returns a `fetch`-compatible function that automatically attaches a Bearer + * token to every request. The token is acquired on each call via Auth0's + * `getAccessTokenSilently`, so callers never handle tokens directly. + * + * @param {Function} getAccessTokenSilently - Auth0 SDK method for obtaining + * an access token without user interaction. + * @returns {Function} An async `(url, options?) => Response` fetcher. + */ export function createAuthenticatedFetcher(getAccessTokenSilently) { return async (url, options = {}) => { const token = await getAccessTokenSilently({ diff --git a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx new file mode 100644 index 0000000..32215f6 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import { LogoutButton } from "./LogoutButton"; + +describe("LogoutButton", () => { + it("renders when authenticated", () => { + render(); + expect( + screen.getByRole("button", { name: /log out/i }), + ).toBeInTheDocument(); + }); + + it("does not render when not authenticated", () => { + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + isAuthenticated: false, + }); + + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("calls logout with returnTo on click", async () => { + const logout = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, logout }); + + const user = userEvent.setup(); + render(); + await user.click(screen.getByRole("button", { name: /log out/i })); + + expect(logout).toHaveBeenCalledWith({ + logoutParams: { returnTo: window.location.origin }, + }); + }); +}); diff --git a/deployments/stitch-frontend/src/config/env.js b/deployments/stitch-frontend/src/config/env.js index 3af620c..6adef6c 100644 --- a/deployments/stitch-frontend/src/config/env.js +++ b/deployments/stitch-frontend/src/config/env.js @@ -24,6 +24,7 @@ function loadConfig() { }); } -const config = loadConfig(); +// Optional named export for `import { config } from "./config/env.js"` +export const config = loadConfig(); export default config; diff --git a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js index 01d7415..32d0398 100644 --- a/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js +++ b/deployments/stitch-frontend/src/hooks/useAuthenticatedQuery.js @@ -2,6 +2,17 @@ import { useQuery } from "@tanstack/react-query"; import { useAuth0 } from "@auth0/auth0-react"; import { createAuthenticatedFetcher } from "../auth/api"; +/** + * Wraps `useQuery` so that every request carries a valid Auth0 bearer token. + * + * Callers provide a `queryFn(fetcher)` instead of a plain `queryFn()`. The + * hook builds an authenticated fetcher (via `createAuthenticatedFetcher`) and + * passes it into `queryFn`, keeping token acquisition out of individual query + * functions. + * + * @param {object} queryOptions - Standard TanStack Query options, except + * `queryFn` receives an authenticated `fetcher` as its first argument. + */ export function useAuthenticatedQuery(queryOptions) { const { getAccessTokenSilently } = useAuth0(); const fetcher = createAuthenticatedFetcher(getAccessTokenSilently); diff --git a/deployments/stitch-frontend/src/test/utils.jsx b/deployments/stitch-frontend/src/test/utils.jsx index cc13656..cc69045 100644 --- a/deployments/stitch-frontend/src/test/utils.jsx +++ b/deployments/stitch-frontend/src/test/utils.jsx @@ -1,6 +1,23 @@ +import { vi } from "vitest"; import { render } from "@testing-library/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +/** + * Default return value for the mocked `useAuth0` hook (mirrors setup.js). + * Spread and override in tests that need a different auth state: + * + * vi.mocked(useAuth0).mockReturnValue({ ...auth0Defaults, isAuthenticated: false }); + */ +export const auth0TestDefaults = { + isAuthenticated: true, + isLoading: false, + error: null, + user: { sub: "test-user-id", email: "test@example.com" }, + getAccessTokenSilently: vi.fn().mockResolvedValue("test-access-token"), + loginWithRedirect: vi.fn(), + logout: vi.fn(), +}; + export function renderWithQueryClient(ui, options = {}) { const queryClient = new QueryClient({ defaultOptions: { From 150103f0c28375969be47e2b7427a0c2d88ba5fd Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 20:11:57 -0700 Subject: [PATCH 32/39] chore: prettier formatting --- deployments/stitch-frontend/src/config/env.test.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/deployments/stitch-frontend/src/config/env.test.js b/deployments/stitch-frontend/src/config/env.test.js index 1be3216..3e45544 100644 --- a/deployments/stitch-frontend/src/config/env.test.js +++ b/deployments/stitch-frontend/src/config/env.test.js @@ -24,9 +24,7 @@ describe("config/env", () => { vi.stubEnv("VITE_AUTH0_CLIENT_ID", ""); vi.stubEnv("VITE_AUTH0_AUDIENCE", ""); - await expect(() => import("./env.js")).rejects.toThrow( - "VITE_AUTH0_DOMAIN", - ); + await expect(() => import("./env.js")).rejects.toThrow("VITE_AUTH0_DOMAIN"); }); it("uses default API URL when VITE_API_URL is unset", async () => { From d2771e34d6edeb496d16bf7b65ceeabbdfc1233e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Thu, 19 Feb 2026 20:38:41 -0700 Subject: [PATCH 33/39] chore: rename env.example and add comments --- .env.example | 48 ++++++++++++++++++++++++++++++++++++++++++++---- env.example | 29 ----------------------------- 2 files changed, 44 insertions(+), 33 deletions(-) delete mode 100644 env.example diff --git a/.env.example b/.env.example index db76088..0eeb6d0 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,44 @@ -VITE_AUTH0_DOMAIN= -VITE_AUTH0_CLIENT_ID= -VITE_AUTH0_AUDIENCE= -VITE_API_URL= +LOG_LEVEL=info + +POSTGRES_DB=stitch +POSTGRES_HOST=db +POSTGRES_PORT=5432 + +STITCH_MIGRATOR_PASSWORD=CHANGE_ME_migrator123! +STITCH_APP_PASSWORD=CHANGE_ME_app123! + +STITCH_DB_SCHEMA_MODE="if-empty" +STITCH_DB_SEED_MODE="if-needed" +STITCH_DB_SEED_PROFILE="dev" + +FRONTEND_ORIGIN_URL=http://localhost:3000 + +# --- Auth (API) --- +# AUTH_DISABLED=true bypasses JWT validation for local dev (default). +# When false, the API validates Bearer tokens using the issuer/audience/JWKS below. +AUTH_DISABLED=true + +# For local auth testing with mock OIDC (docker compose --profile auth-test): +# AUTH_DISABLED=false +# AUTH_ISSUER=http://localauth0:3000/ +# AUTH_AUDIENCE=stitch-api-local +# AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json +# +# For real Auth0: +# AUTH_ISSUER=https://.auth0.com/ +# AUTH_AUDIENCE= +# AUTH_JWKS_URI=https://.auth0.com/.well-known/jwks.json + +# --- Auth (Frontend) --- +# +# For local auth testing (localauth0 on host port 3100): +# VITE_AUTH0_DOMAIN=localhost:3100 +# VITE_AUTH0_CLIENT_ID=local-test-client +# VITE_AUTH0_AUDIENCE=stitch-api-local +# VITE_API_URL=http://localhost:8000/api/v1 +# +# For real Auth0: +# VITE_AUTH0_DOMAIN=.auth0.com +# VITE_AUTH0_CLIENT_ID= +# VITE_AUTH0_AUDIENCE= +# VITE_API_URL=http://localhost:8000/api/v1 diff --git a/env.example b/env.example deleted file mode 100644 index c9f8389..0000000 --- a/env.example +++ /dev/null @@ -1,29 +0,0 @@ -LOG_LEVEL=info - -POSTGRES_DB=stitch -POSTGRES_HOST=db -POSTGRES_PORT=5432 - -STITCH_MIGRATOR_PASSWORD=CHANGE_ME_migrator123! -STITCH_APP_PASSWORD=CHANGE_ME_app123! - -STITCH_DB_SCHEMA_MODE="if-empty" -STITCH_DB_SEED_MODE="if-needed" -STITCH_DB_SEED_PROFILE="dev" - -FRONTEND_ORIGIN_URL=http://localhost:3000 - -# --- Auth --- -# AUTH_DISABLED=true bypasses JWT validation for local dev (default). -AUTH_DISABLED=true - -# For local auth testing with mock OIDC (docker compose --profile auth-test): -# AUTH_DISABLED=false -# AUTH_ISSUER=http://localauth0:3000/ -# AUTH_AUDIENCE=stitch-api-local -# AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json -# -# For real Auth0: -# AUTH_ISSUER=https://your-tenant.auth0.com/ -# AUTH_AUDIENCE=your-api-audience -# AUTH_JWKS_URI=https://your-tenant.auth0.com/.well-known/jwks.json From 056b4b582447a330d414c76f3ee35fa80b7d54a1 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 10:59:45 -0700 Subject: [PATCH 34/39] feat(frontend): add LoginPage component, replace auto-redirect in AuthGate --- .../stitch-frontend/src/auth/AuthGate.jsx | 12 ++----- .../src/auth/AuthGate.test.jsx | 13 +++----- .../src/components/LoginPage.jsx | 22 +++++++++++++ .../src/components/LoginPage.test.jsx | 33 +++++++++++++++++++ 4 files changed, 63 insertions(+), 17 deletions(-) create mode 100644 deployments/stitch-frontend/src/components/LoginPage.jsx create mode 100644 deployments/stitch-frontend/src/components/LoginPage.test.jsx diff --git a/deployments/stitch-frontend/src/auth/AuthGate.jsx b/deployments/stitch-frontend/src/auth/AuthGate.jsx index 9026d6b..19cefd0 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.jsx @@ -1,14 +1,8 @@ -import { useEffect } from "react"; import { useAuth0 } from "@auth0/auth0-react"; +import LoginPage from "../components/LoginPage"; export default function AuthGate({ children }) { - const { isLoading, isAuthenticated, error, loginWithRedirect } = useAuth0(); - - useEffect(() => { - if (!isLoading && !isAuthenticated && !error) { - loginWithRedirect(); - } - }, [isLoading, isAuthenticated, error, loginWithRedirect]); + const { isLoading, isAuthenticated, error } = useAuth0(); if (isLoading) { return ( @@ -29,7 +23,7 @@ export default function AuthGate({ children }) { } if (!isAuthenticated) { - return null; + return ; } return children; diff --git a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx index 5d3cde6..7b39d3f 100644 --- a/deployments/stitch-frontend/src/auth/AuthGate.test.jsx +++ b/deployments/stitch-frontend/src/auth/AuthGate.test.jsx @@ -20,16 +20,13 @@ describe("AuthGate", () => { expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(vi.mocked(useAuth0)().loginWithRedirect).not.toHaveBeenCalled(); }); it("shows error message when auth fails", () => { - const loginWithRedirect = vi.fn(); vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, isAuthenticated: false, error: new Error("Something went wrong"), - loginWithRedirect, }); render( @@ -42,15 +39,12 @@ describe("AuthGate", () => { screen.getByText("Authentication error: Something went wrong"), ).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(loginWithRedirect).not.toHaveBeenCalled(); }); - it("calls loginWithRedirect when unauthenticated", () => { - const loginWithRedirect = vi.fn(); + it("renders LoginPage when unauthenticated", () => { vi.mocked(useAuth0).mockReturnValue({ ...auth0TestDefaults, isAuthenticated: false, - loginWithRedirect, }); render( @@ -59,7 +53,10 @@ describe("AuthGate", () => { , ); + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByRole("button", { name: /log in to continue/i }), + ).toBeInTheDocument(); expect(screen.queryByText("App Content")).not.toBeInTheDocument(); - expect(loginWithRedirect).toHaveBeenCalled(); }); }); diff --git a/deployments/stitch-frontend/src/components/LoginPage.jsx b/deployments/stitch-frontend/src/components/LoginPage.jsx new file mode 100644 index 0000000..74bfa82 --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.jsx @@ -0,0 +1,22 @@ +import { useAuth0 } from "@auth0/auth0-react"; +import Button from "./Button"; + +export default function LoginPage() { + const { loginWithRedirect } = useAuth0(); + + return ( +
+
+

Stitch

+

+ Oil & Gas Asset Data Platform +

+

+ Integrate diverse datasets, apply AI-driven enrichment with human + review, and deliver curated, trustworthy data. +

+ +
+
+ ); +} diff --git a/deployments/stitch-frontend/src/components/LoginPage.test.jsx b/deployments/stitch-frontend/src/components/LoginPage.test.jsx new file mode 100644 index 0000000..931e44a --- /dev/null +++ b/deployments/stitch-frontend/src/components/LoginPage.test.jsx @@ -0,0 +1,33 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useAuth0 } from "@auth0/auth0-react"; +import { auth0TestDefaults } from "../test/utils"; +import LoginPage from "./LoginPage"; + +describe("LoginPage", () => { + it("renders app name and description", () => { + render(); + + expect(screen.getByText("Stitch")).toBeInTheDocument(); + expect( + screen.getByText("Oil & Gas Asset Data Platform"), + ).toBeInTheDocument(); + }); + + it("calls loginWithRedirect on button click", async () => { + const loginWithRedirect = vi.fn(); + vi.mocked(useAuth0).mockReturnValue({ + ...auth0TestDefaults, + loginWithRedirect, + }); + + const user = userEvent.setup(); + render(); + await user.click( + screen.getByRole("button", { name: /log in to continue/i }), + ); + + expect(loginWithRedirect).toHaveBeenCalled(); + }); +}); From 06716573eb3a3f644bc4076a83846f81887f2bca Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 10:59:53 -0700 Subject: [PATCH 35/39] infra: add CORS proxy for localauth0, pass build args to frontend Dockerfile --- .env.example | 6 +++--- deployments/stitch-frontend/Dockerfile | 4 ++++ dev/localauth0-proxy.conf | 20 ++++++++++++++++++++ dev/localauth0.toml | 2 +- docker-compose.local.yml | 12 ++++++++++-- docker-compose.yml | 5 +++++ 6 files changed, 43 insertions(+), 6 deletions(-) create mode 100644 dev/localauth0-proxy.conf diff --git a/.env.example b/.env.example index 0eeb6d0..b6c4eeb 100644 --- a/.env.example +++ b/.env.example @@ -20,7 +20,7 @@ AUTH_DISABLED=true # For local auth testing with mock OIDC (docker compose --profile auth-test): # AUTH_DISABLED=false -# AUTH_ISSUER=http://localauth0:3000/ +# AUTH_ISSUER=http://localhost:3100/ # AUTH_AUDIENCE=stitch-api-local # AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json # @@ -31,8 +31,8 @@ AUTH_DISABLED=true # --- Auth (Frontend) --- # -# For local auth testing (localauth0 on host port 3100): -# VITE_AUTH0_DOMAIN=localhost:3100 +# For local auth testing (localauth0 via CORS proxy on port 3100): +# VITE_AUTH0_DOMAIN=http://localhost:3100 # VITE_AUTH0_CLIENT_ID=local-test-client # VITE_AUTH0_AUDIENCE=stitch-api-local # VITE_API_URL=http://localhost:8000/api/v1 diff --git a/deployments/stitch-frontend/Dockerfile b/deployments/stitch-frontend/Dockerfile index d96dbe1..5948734 100644 --- a/deployments/stitch-frontend/Dockerfile +++ b/deployments/stitch-frontend/Dockerfile @@ -10,6 +10,10 @@ COPY index.html vite.config.js ./ COPY public/ ./public/ COPY src/ ./src/ +ARG VITE_AUTH0_DOMAIN +ARG VITE_AUTH0_CLIENT_ID +ARG VITE_AUTH0_AUDIENCE +ARG VITE_API_URL RUN npm run build ######################## diff --git a/dev/localauth0-proxy.conf b/dev/localauth0-proxy.conf new file mode 100644 index 0000000..a17c8ff --- /dev/null +++ b/dev/localauth0-proxy.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + + location / { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client'; + add_header 'Access-Control-Max-Age' 86400; + return 204; + } + + proxy_pass http://localauth0:3000; + + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client' always; + } +} diff --git a/dev/localauth0.toml b/dev/localauth0.toml index c98906a..454feaf 100644 --- a/dev/localauth0.toml +++ b/dev/localauth0.toml @@ -1,4 +1,4 @@ -issuer = "http://localauth0:3000/" +issuer = "http://localhost:3100/" [user_info] subject = "mock|dev-user-1" diff --git a/docker-compose.local.yml b/docker-compose.local.yml index eca7ee2..a0458ae 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -26,7 +26,15 @@ services: test: ["CMD", "/localauth0", "healthcheck"] environment: LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml - ports: - - "3100:3000" volumes: - ./dev/localauth0.toml:/etc/localauth0.toml:ro + auth0-proxy: + profiles: [auth-test] + image: nginx:1.27-alpine + ports: + - "3100:80" + volumes: + - ./dev/localauth0-proxy.conf:/etc/nginx/conf.d/default.conf:ro + depends_on: + localauth0: + condition: service_healthy diff --git a/docker-compose.yml b/docker-compose.yml index 0175c1f..f076d72 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,11 @@ services: build: context: deployments/stitch-frontend dockerfile: Dockerfile + args: + VITE_AUTH0_DOMAIN: ${VITE_AUTH0_DOMAIN:-} + VITE_AUTH0_CLIENT_ID: ${VITE_AUTH0_CLIENT_ID:-} + VITE_AUTH0_AUDIENCE: ${VITE_AUTH0_AUDIENCE:-} + VITE_API_URL: ${VITE_API_URL:-http://localhost:8000/api/v1} ports: - "3000:80" From 90d3e6ba9b188730e717f8dae322f271ef074e42 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 12:01:29 -0700 Subject: [PATCH 36/39] fix(frontend): use openUrl for logout redirect, bypass Auth0 /v2/logout --- deployments/stitch-frontend/src/components/LogoutButton.jsx | 4 +++- .../stitch-frontend/src/components/LogoutButton.test.jsx | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/deployments/stitch-frontend/src/components/LogoutButton.jsx b/deployments/stitch-frontend/src/components/LogoutButton.jsx index 4fea9a6..4942842 100644 --- a/deployments/stitch-frontend/src/components/LogoutButton.jsx +++ b/deployments/stitch-frontend/src/components/LogoutButton.jsx @@ -5,7 +5,9 @@ export const LogoutButton = () => { const { isAuthenticated, logout: authLogout } = useAuth0(); const logout = () => - authLogout({ logoutParams: { returnTo: window.location.origin } }); + authLogout({ + openUrl: () => window.location.assign(window.location.origin), + }); return isAuthenticated && ; }; diff --git a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx index 32215f6..197ce5a 100644 --- a/deployments/stitch-frontend/src/components/LogoutButton.test.jsx +++ b/deployments/stitch-frontend/src/components/LogoutButton.test.jsx @@ -32,7 +32,7 @@ describe("LogoutButton", () => { await user.click(screen.getByRole("button", { name: /log out/i })); expect(logout).toHaveBeenCalledWith({ - logoutParams: { returnTo: window.location.origin }, + openUrl: expect.any(Function), }); }); }); From 249fba942f73ad24b99bf574af2685826f47678e Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 12:01:38 -0700 Subject: [PATCH 37/39] fix(docker): OpenResty nonce proxy, fix client_id, faster healthcheck --- .env.example | 3 ++- dev/localauth0-proxy.conf | 51 +++++++++++++++++++++++++++++++++++++++ docker-compose.local.yml | 5 +++- 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index b6c4eeb..cf1668c 100644 --- a/.env.example +++ b/.env.example @@ -33,7 +33,8 @@ AUTH_DISABLED=true # # For local auth testing (localauth0 via CORS proxy on port 3100): # VITE_AUTH0_DOMAIN=http://localhost:3100 -# VITE_AUTH0_CLIENT_ID=local-test-client +# localauth0 hardcodes expected client_id as the literal string "client_id" +# VITE_AUTH0_CLIENT_ID=client_id # VITE_AUTH0_AUDIENCE=stitch-api-local # VITE_API_URL=http://localhost:8000/api/v1 # diff --git a/dev/localauth0-proxy.conf b/dev/localauth0-proxy.conf index a17c8ff..f604415 100644 --- a/dev/localauth0-proxy.conf +++ b/dev/localauth0-proxy.conf @@ -1,7 +1,58 @@ +# CORS + nonce-forwarding proxy for localauth0 (requires OpenResty). +# +# localauth0 doesn't propagate the OIDC nonce from /authorize into the +# ID token. It *will* include one if the /oauth/token request body has a +# "nonce" field. The Auth0 SPA SDK only sends nonce on the authorize +# redirect (per spec), so we capture it there and inject it into the +# token exchange. + +lua_shared_dict nonces 1m; + server { listen 80; server_name _; + # --- /authorize: capture nonce (browser navigation, no CORS needed) --- + location = /authorize { + access_by_lua_block { + local raw = ngx.var.arg_nonce + if raw then + ngx.shared.nonces:set("latest", ngx.unescape_uri(raw), 300) + end + } + + proxy_pass http://localauth0:3000; + } + + # --- /oauth/token: inject stored nonce into request body --------------- + location = /oauth/token { + if ($request_method = 'OPTIONS') { + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000'; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client'; + add_header 'Access-Control-Max-Age' 86400; + return 204; + } + + access_by_lua_block { + ngx.req.read_body() + local body = ngx.req.get_body_data() + local nonce = ngx.shared.nonces:get("latest") + if nonce and body then + body = body .. "&nonce=" .. ngx.escape_uri(nonce) + ngx.req.set_body_data(body) + ngx.req.set_header("Content-Length", tostring(#body)) + end + } + + proxy_pass http://localauth0:3000; + + add_header 'Access-Control-Allow-Origin' 'http://localhost:3000' always; + add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; + add_header 'Access-Control-Allow-Headers' 'Content-Type, Authorization, auth0-client' always; + } + + # --- everything else: plain proxy + CORS ------------------------------- location / { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' 'http://localhost:3000'; diff --git a/docker-compose.local.yml b/docker-compose.local.yml index a0458ae..241564a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -24,13 +24,16 @@ services: image: public.ecr.aws/primaassicurazioni/localauth0:0.8.3 healthcheck: test: ["CMD", "/localauth0", "healthcheck"] + start_period: 10s + start_interval: 1s + interval: 30s environment: LOCALAUTH0_CONFIG_PATH: /etc/localauth0.toml volumes: - ./dev/localauth0.toml:/etc/localauth0.toml:ro auth0-proxy: profiles: [auth-test] - image: nginx:1.27-alpine + image: openresty/openresty:alpine ports: - "3100:80" volumes: From 878581de69befa67c098702a7c36d1f3c3cd1b78 Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 12:53:24 -0700 Subject: [PATCH 38/39] docs: add local auth proxy guide, update auth-testing references --- docs/auth-testing.md | 21 +++++++--- docs/local-auth-proxy.md | 86 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 docs/local-auth-proxy.md diff --git a/docs/auth-testing.md b/docs/auth-testing.md index e926381..21986df 100644 --- a/docs/auth-testing.md +++ b/docs/auth-testing.md @@ -35,7 +35,7 @@ Update `.env` with the auth-test settings: ``` AUTH_DISABLED=false -AUTH_ISSUER=http://localauth0:3000/ +AUTH_ISSUER=http://localhost:3100/ AUTH_AUDIENCE=stitch-api-local AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json ``` @@ -46,7 +46,7 @@ AUTH_JWKS_URI=http://localauth0:3000/.well-known/jwks.json docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build ``` -This starts the normal stack (db, api, frontend) plus `localauth0` on port 3100 (host) / 3000 (Docker network). +This starts the normal stack (db, api, frontend) plus `localauth0` (Docker-internal on port 3000) and `auth0-proxy` (OpenResty on host port 3100). See [Local Auth Proxy](local-auth-proxy.md) for details on the proxy architecture. ### 3. Verify localauth0 is running @@ -157,18 +157,27 @@ After a successful authenticated request, verify the user was created in the dat 4. Click "Authorize" 5. All subsequent "Try it out" requests will include the token -## CORS and Browser Requests +## Frontend Auth Flow -The API's CORS middleware explicitly allows the `Authorization` header from the configured `FRONTEND_ORIGIN_URL`. Browser-based requests from the frontend will include the JWT in the `Authorization` header and pass CORS preflight checks. To test this flow, use the frontend at http://localhost:3000 after authenticating via Swagger or configure the frontend to send tokens. +The frontend at http://localhost:3000 uses the Auth0 SPA SDK to authenticate via the proxy at `localhost:3100`. After login, it attaches Bearer tokens to all API requests. The API's CORS middleware allows the `Authorization` header from `FRONTEND_ORIGIN_URL`. + +To test the full browser flow: + +1. Open http://localhost:3000 — you'll see the login page +2. Click "Log in to continue" — redirects to localauth0 via the proxy +3. Click "Login" on localauth0's page — redirects back to the app +4. The app fetches resources with the token attached ## localauth0 Configuration The mock server is configured via `dev/localauth0.toml`: -- **Issuer**: `http://localauth0:3000/` (matches `AUTH_ISSUER`) +- **Issuer**: `http://localhost:3100/` (matches `AUTH_ISSUER`; the proxy's address, not localauth0's internal port) - **User**: `sub=mock|dev-user-1`, name "Dev User", email `dev@example.com` - **Audiences**: `stitch-api-local` (valid) and `wrong-audience` (for testing rejection) -- **Port**: 3000 inside Docker, mapped to 3100 on the host +- **Port**: 3000 inside Docker, exposed via `auth0-proxy` on host port 3100 + +For proxy-specific details and troubleshooting, see [Local Auth Proxy](local-auth-proxy.md). ## Configuring Real Auth0 diff --git a/docs/local-auth-proxy.md b/docs/local-auth-proxy.md new file mode 100644 index 0000000..07951f4 --- /dev/null +++ b/docs/local-auth-proxy.md @@ -0,0 +1,86 @@ +# Local Auth Proxy (OpenResty) + +The auth-test Docker profile includes an [OpenResty](https://openresty.org/) reverse proxy (`auth0-proxy`) that sits between the browser and [localauth0](https://github.com/primait/localauth0). This doc explains why it exists and how to troubleshoot it. + +## Why a proxy? + +The Auth0 SPA SDK makes cross-origin XHR requests (token exchange, userinfo) from the frontend origin (`http://localhost:3000`) to the OIDC provider. localauth0 doesn't set CORS headers, so the browser blocks these requests. + +In production, Auth0 lives on a separate origin from the frontend and handles CORS itself. The proxy mirrors this architecture locally: the frontend talks to `localhost:3100` (the proxy), which forwards to `localauth0:3000` inside the Docker network. + +``` +Browser + ├─ localhost:3000 ──► Frontend (nginx, serves SPA) + ├─ localhost:3100 ──► Auth0 Proxy (OpenResty) ──► localauth0:3000 (Docker internal) + └─ localhost:8000 ──► API (FastAPI) + └─ JWKS validation via http://localauth0:3000/.well-known/jwks.json +``` + +## Why OpenResty instead of plain nginx? + +Plain nginx handles CORS fine, but localauth0 has a second limitation: it doesn't propagate the OIDC **nonce** from `/authorize` into the ID token. + +The OIDC spec says the nonce is sent in the `/authorize` redirect and returned in the ID token. localauth0 ignores it there — but *will* include a nonce if the `/oauth/token` request body contains a `nonce` field. The Auth0 SPA SDK (correctly) only sends nonce on `/authorize`, not in the token request. + +The proxy uses Lua (`access_by_lua_block`) to bridge this gap: + +1. On `GET /authorize?nonce=xxx` — captures the nonce into a shared memory dict +2. On `POST /oauth/token` — appends `&nonce=xxx` to the request body before forwarding + +This requires OpenResty (nginx + LuaJIT). See `dev/localauth0-proxy.conf` for the full config. + +## localauth0 limitations & workarounds + +| Limitation | Workaround | Details | +|---|---|---| +| No CORS headers | Proxy adds `Access-Control-Allow-*` | Standard nginx `add_header` directives | +| No nonce propagation | Lua captures from `/authorize`, injects into `/oauth/token` | `lua_shared_dict` with 5-min TTL | +| Hardcoded `client_id` | Use literal string `"client_id"` | localauth0 [hardcodes](https://github.com/primait/localauth0/blob/master/src/lib.rs) `CLIENT_ID_VALUE = "client_id"` — not configurable via TOML | +| No `/v2/logout` endpoint | `LogoutButton` uses SDK's `openUrl` option | Navigates directly to `window.location.origin` after clearing local session | +| ServiceWorker on `:3100` | Clear manually if it causes issues | localauth0 registers a SW that can cache responses on the proxy's origin | + +## Starting the stack + +```bash +docker compose -f docker-compose.yml -f docker-compose.local.yml --profile auth-test up --build +``` + +The `auth0-proxy` service waits for localauth0's healthcheck before starting. The healthcheck is configured with `start_period: 10s` and `start_interval: 1s`, so the proxy should be ready within ~15 seconds of localauth0 starting. + +## Troubleshooting + +### CORS errors on `/oauth/token` + +The browser blocks the token exchange response. Check: + +1. Is `auth0-proxy` running? `curl -s http://localhost:3100/.well-known/openid-configuration | head -1` +2. Open browser DevTools → Network tab → look at the failed request's response headers. You should see `Access-Control-Allow-Origin: http://localhost:3000`. +3. If headers are missing, the request may not be hitting the proxy (check the URL is `localhost:3100`, not `localhost:3000`). + +### "Nonce (nonce) claim must be a string present in the ID token" + +The proxy didn't inject the nonce into the token request. Check: + +1. Was `/authorize` routed through the proxy (`localhost:3100`)? The nonce is captured from this request. +2. Check proxy logs: `docker compose logs auth0-proxy`. Look for the `GET /authorize` and `POST /oauth/token` entries. +3. If the proxy was restarted between `/authorize` and `/oauth/token`, the nonce is lost (stored in memory). Retry the login. + +### 401 on token exchange (`"access_denied"`) + +localauth0 rejected the token request. The most common cause: + +- **Wrong `client_id`**: localauth0 expects the literal string `"client_id"`. Check `VITE_AUTH0_CLIENT_ID=client_id` in `.env`. Any other value (e.g., `local-test-client`) will fail. + +### Logout stuck at `localhost:3100/v2/logout` + +localauth0 doesn't implement Auth0's `/v2/logout` endpoint. The `LogoutButton` uses the SDK's `openUrl` option to bypass this, but if you see this URL: + +1. localauth0's **ServiceWorker** may be intercepting the navigation. Open DevTools → Application → Service Workers → Unregister any SW on `localhost:3100`. +2. Hard-refresh (`Ctrl+Shift+R`) or clear site data for `localhost:3100`. + +### Slow proxy startup + +The proxy depends on localauth0 being healthy. If localauth0 takes a long time: + +1. Check localauth0 logs: `docker compose logs localauth0` +2. The healthcheck runs `/localauth0 healthcheck` every 1s during the start period (10s). After that, it falls back to 30s intervals. From fd59a59685717d55cb38ac53a6f0eb3ef77e7e6f Mon Sep 17 00:00:00 2001 From: Michael Barlow Date: Sat, 21 Feb 2026 14:22:45 -0700 Subject: [PATCH 39/39] fix(ci): use correct .env.example filename in Docker workflows --- .github/workflows/docker-compose-build.yml | 2 +- .github/workflows/docker-compose-config.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/docker-compose-build.yml b/.github/workflows/docker-compose-build.yml index 8074b3d..c140095 100644 --- a/.github/workflows/docker-compose-build.yml +++ b/.github/workflows/docker-compose-build.yml @@ -10,6 +10,6 @@ jobs: uses: actions/checkout@v5 - name: Set up example Env file - run: cp env.example .env + run: cp .env.example .env - run: docker compose build diff --git a/.github/workflows/docker-compose-config.yml b/.github/workflows/docker-compose-config.yml index 77d7f1b..f633036 100644 --- a/.github/workflows/docker-compose-config.yml +++ b/.github/workflows/docker-compose-config.yml @@ -10,6 +10,6 @@ jobs: uses: actions/checkout@v5 - name: Set up example Env file - run: cp env.example .env + run: cp .env.example .env - run: docker compose config