Skip to content
Open
Changes from 1 commit
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
728533c
test: add failing tests for python sdk error hierarchy and base model
ejirocodes Mar 24, 2026
a8c18f3
feat: add python sdk package skeleton with error hierarchy
ejirocodes Mar 24, 2026
fbe1c5b
test: add failing tests for sync and async HTTP client
ejirocodes Mar 24, 2026
6b4cd35
test: add failing tests for jwt verification and jwks fetching
ejirocodes Mar 24, 2026
488e72a
feat: add user, team, session, contact channel, and permission pydant…
ejirocodes Mar 24, 2026
7b75ba8
feat: add jwt verification with async jwks fetcher and ttl cache
ejirocodes Mar 24, 2026
28171d4
feat: add sync and async HTTP client with retry logic
ejirocodes Mar 24, 2026
aecd4aa
feat: add project, api key, oauth, payment, and notification models
ejirocodes Mar 24, 2026
07c32d4
feat: add pagination support with cursor-based PaginatedResult
ejirocodes Mar 24, 2026
ea427ed
test: add failing tests for auth module
ejirocodes Mar 24, 2026
ad63cf0
test: add failing tests for token store subsystem
ejirocodes Mar 24, 2026
3ca5501
feat: add authenticate_request with jwt verification and partial decode
ejirocodes Mar 24, 2026
8060bc6
feat: export auth types and functions from stack_auth package
ejirocodes Mar 24, 2026
6b11389
feat: add token store with CAS-based refresh and dual locks
ejirocodes Mar 24, 2026
13f8875
test: add failing tests for StackServerApp and AsyncStackServerApp
ejirocodes Mar 25, 2026
fd3a816
feat: add StackServerApp and AsyncStackServerApp with user CRUD
ejirocodes Mar 25, 2026
7eb0c25
feat: export StackServerApp and AsyncStackServerApp from package root
ejirocodes Mar 25, 2026
e1ccb71
test: add failing tests for session management methods
ejirocodes Mar 25, 2026
7f4e3eb
feat: add session management methods to StackServerApp and AsyncStack…
ejirocodes Mar 25, 2026
3094338
test: add failing tests for team CRUD methods
ejirocodes Mar 25, 2026
3bf752f
feat: add team CRUD and API key lookup to StackServerApp
ejirocodes Mar 25, 2026
c48b9ca
test: add failing tests for team membership, invitations, and profiles
ejirocodes Mar 25, 2026
cd64ecb
feat: add team membership, invitations, and member profiles
ejirocodes Mar 25, 2026
6b012c0
test: add failing tests for permission management methods
ejirocodes Mar 25, 2026
e26f3a2
feat: add permission management methods to StackServerApp
ejirocodes Mar 25, 2026
c2f3a3a
test: add failing tests for contact channel verification methods
ejirocodes Mar 25, 2026
adeb156
feat: add contact channel verification methods to StackServerApp
ejirocodes Mar 25, 2026
7e41fc8
feat: add api key management methods to both facades
ejirocodes Mar 25, 2026
eae4842
feat: add oauth provider methods and connected accounts to both facades
ejirocodes Mar 25, 2026
e8ce41b
fix: update team member profile test to include required user_id field
ejirocodes Mar 25, 2026
0e6afdc
feat: add ServerItem, AsyncServerItem, and EmailDeliveryInfo models
ejirocodes Mar 25, 2026
c5ab839
feat: add payment methods and email sending to StackServerApp
ejirocodes Mar 25, 2026
299718c
feat: add data vault store with key-value operations
ejirocodes Mar 25, 2026
23c9f85
feat: add get_data_vault_store to facade classes and exports
ejirocodes Mar 25, 2026
f3780f2
docs: add comprehensive Google-style docstrings to all public SDK met…
ejirocodes Mar 25, 2026
c4ee7da
docs: add module docstring with quick-start example and complete PyPI…
ejirocodes Mar 25, 2026
1343d3a
test: add integration tests for payment, email, and data vault methods
ejirocodes Mar 25, 2026
0f39d10
fix: make metadata fields nullable on user and team models
ejirocodes Mar 25, 2026
c6da1ff
Merge branch 'stack-auth:dev' into feat/python-sdk
ejirocodes Mar 25, 2026
287183b
Merge branch 'feat/python-sdk' of https://github.com/ejirocodes/stack…
ejirocodes Mar 25, 2026
6138ff0
test: add failing tests for publishable_client_key and token store de…
ejirocodes Mar 25, 2026
0959548
fix: add publishable_client_key parameter and fix token store defaults
ejirocodes Mar 25, 2026
80f2c32
test: add failing tests for get_partial_user method
ejirocodes Mar 25, 2026
6c0c58d
feat: add get_partial_user method to StackServerApp and AsyncStackSer…
ejirocodes Mar 25, 2026
bfff27d
fix: make RequestLike runtime_checkable and remove type: ignore from …
ejirocodes Mar 25, 2026
7ec2e27
fix: add debug logging to authenticate_request exception handlers
ejirocodes Mar 25, 2026
b7e21a0
fix: replace broad exception catches with specific types and add debu…
ejirocodes Mar 25, 2026
625e3c0
test: add end-to-end integration test suite for live Stack Auth valid…
ejirocodes Mar 25, 2026
9ddc8aa
docs: add comprehensive README for python sdk
ejirocodes Mar 25, 2026
5c09ea7
Merge branch 'stack-auth:dev' into feat/python-sdk
ejirocodes Mar 26, 2026
d055007
fix: address PR review findings from bot analysis
ejirocodes Mar 26, 2026
5cbad57
fix: catch NotFoundError in data vault delete to match spec contract
ejirocodes Mar 26, 2026
37118e0
fix: skip aud/iss verification when not provided and narrow data vaul…
ejirocodes Mar 26, 2026
cac86ef
fix: add None guard before model_validate in get_item methods
ejirocodes Mar 26, 2026
131bb89
docs: clarify why 429 retries apply to all HTTP methods
ejirocodes Mar 26, 2026
e9105b3
fix: harden response parsing and add missing None guards
ejirocodes Mar 26, 2026
f7786c3
docs: add missing docstrings to reach 80% coverage threshold
ejirocodes Mar 26, 2026
b44fabc
fix: validate mutually exclusive product_id/product in grant_product
ejirocodes Mar 26, 2026
6aaa4b3
fix: guard against non-dict JSON in token refresh response
ejirocodes Mar 26, 2026
9aeed6b
fix: map TEAM_MEMBERSHIP_NOT_FOUND to NotFoundError
ejirocodes Mar 26, 2026
a6ac188
refactor: use modern Python 3.10+ imports in token store module
ejirocodes Mar 26, 2026
94293c1
fix: raise RateLimitError when 429 retries are exhausted
ejirocodes Mar 26, 2026
573fe23
fix: treat missing access_token in refresh response as failure
ejirocodes Mar 26, 2026
a26a682
refactor: extract _get_actual_status helper to deduplicate header par…
ejirocodes Mar 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
test: add failing tests for jwt verification and jwks fetching
- AsyncJWKSFetcher and SyncJWKSFetcher construction and key retrieval
- TTL cache behavior with 5-minute expiry
- Force-refresh on unknown kid
- verify_token for valid, expired, invalid signature, missing kid tokens
- CVE-2022-29217 protection: HS256 tokens rejected
  • Loading branch information
ejirocodes committed Mar 24, 2026
commit 6b4cd35875412b7fd0118df479f70367cc6ec603
322 changes: 322 additions & 0 deletions sdks/implementations/python/tests/test_jwt.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
"""Tests for JWT verification and JWKS fetching."""

from __future__ import annotations

import time
from typing import Any
from unittest.mock import patch

import httpx
import jwt as pyjwt
import pytest
import respx
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.serialization import (
Encoding,
NoEncryption,
PrivateFormat,
)
from jwt.algorithms import RSAAlgorithm

from stack_auth._jwt import (
ALLOWED_ALGORITHMS,
JWKS_CACHE_TTL,
AsyncJWKSFetcher,
SyncJWKSFetcher,
async_verify_token,
sync_verify_token,
)

# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

JWKS_URL = "https://api.stack-auth.com/api/v1/projects/test-project/.well-known/jwks.json"
KID = "test-key-1"


@pytest.fixture()
def rsa_keypair() -> tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]:
"""Generate an RSA keypair for testing."""
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
return private_key, private_key.public_key()


@pytest.fixture()
def jwks_response(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> dict[str, Any]:
"""Build a JWKS JSON response from the test keypair."""
_, public_key = rsa_keypair
jwk_dict = RSAAlgorithm.to_jwk(public_key, as_dict=True)
jwk_dict["kid"] = KID
jwk_dict["use"] = "sig"
jwk_dict["alg"] = "RS256"
return {"keys": [jwk_dict]}


@pytest.fixture()
def private_key_pem(rsa_keypair: tuple[rsa.RSAPrivateKey, rsa.RSAPublicKey]) -> bytes:
"""PEM-encoded private key for signing test tokens."""
private_key, _ = rsa_keypair
return private_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())


def _make_token(
private_key_pem: bytes,
kid: str = KID,
exp: int | None = None,
algorithm: str = "RS256",
include_kid: bool = True,
) -> str:
"""Create a signed JWT for testing."""
payload: dict[str, Any] = {"sub": "user-123", "iss": "stack-auth"}
if exp is not None:
payload["exp"] = exp
else:
payload["exp"] = int(time.time()) + 3600 # 1 hour from now

headers: dict[str, Any] = {}
if include_kid:
headers["kid"] = kid

return pyjwt.encode(payload, private_key_pem, algorithm=algorithm, headers=headers)


# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------


def test_allowed_algorithms_is_rs256_only() -> None:
assert ALLOWED_ALGORITHMS == ["RS256"]


def test_cache_ttl_is_300_seconds() -> None:
assert JWKS_CACHE_TTL == 300.0


# ---------------------------------------------------------------------------
# AsyncJWKSFetcher
# ---------------------------------------------------------------------------


class TestAsyncJWKSFetcher:
"""Tests for the async JWKS fetcher."""

async def test_construction(self) -> None:
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
assert fetcher is not None

@respx.mock
async def test_get_signing_key_fetches_and_returns_rsa_key(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
key = await fetcher.get_signing_key(KID)
assert key is not None

@respx.mock
async def test_get_signing_key_caches_within_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
await fetcher.get_signing_key(KID)
await fetcher.get_signing_key(KID)
assert route.call_count == 1

@respx.mock
async def test_get_signing_key_refetches_after_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)

with patch("stack_auth._jwt.time.monotonic", return_value=1000.0):
await fetcher.get_signing_key(KID)

# Advance 301 seconds past TTL
with patch("stack_auth._jwt.time.monotonic", return_value=1301.0):
await fetcher.get_signing_key(KID)

assert route.call_count == 2

@respx.mock
async def test_unknown_kid_force_refreshes_then_raises(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
# Pre-populate cache
await fetcher.get_signing_key(KID)
assert route.call_count == 1

with pytest.raises(ValueError, match="not found in JWKS"):
await fetcher.get_signing_key("unknown-kid")

# Should have force-refreshed once before raising
assert route.call_count == 2


# ---------------------------------------------------------------------------
# SyncJWKSFetcher
# ---------------------------------------------------------------------------


class TestSyncJWKSFetcher:
"""Tests for the sync JWKS fetcher."""

def test_construction(self) -> None:
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
assert fetcher is not None

@respx.mock
def test_get_signing_key_fetches_and_returns_rsa_key(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
key = fetcher.get_signing_key(KID)
assert key is not None

@respx.mock
def test_get_signing_key_caches_within_ttl(
self,
jwks_response: dict[str, Any],
) -> None:
route = respx.get(JWKS_URL).respond(json=jwks_response)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
fetcher.get_signing_key(KID)
fetcher.get_signing_key(KID)
assert route.call_count == 1


# ---------------------------------------------------------------------------
# verify_token (async)
# ---------------------------------------------------------------------------


class TestAsyncVerifyToken:
"""Tests for async_verify_token."""

@respx.mock
async def test_valid_token_returns_claims(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
claims = await async_verify_token(token, fetcher)
assert claims["sub"] == "user-123"
assert claims["iss"] == "stack-auth"

@respx.mock
async def test_expired_token_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, exp=1) # expired in 1970
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.ExpiredSignatureError):
await async_verify_token(token, fetcher)

@respx.mock
async def test_invalid_signature_raises(
self,
jwks_response: dict[str, Any],
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
# Sign with a DIFFERENT key
other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
other_pem = other_key.private_bytes(Encoding.PEM, PrivateFormat.PKCS8, NoEncryption())
token = _make_token(other_pem)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.InvalidSignatureError):
await async_verify_token(token, fetcher)

@respx.mock
async def test_missing_kid_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, include_kid=False)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(ValueError, match="missing 'kid'"):
await async_verify_token(token, fetcher)

@respx.mock
async def test_hs256_token_rejected(
self,
jwks_response: dict[str, Any],
) -> None:
"""HS256-signed tokens must be rejected even if alg header says HS256 (CVE-2022-29217)."""
respx.get(JWKS_URL).respond(json=jwks_response)
# HS256 token signed with a symmetric key
token = pyjwt.encode(
{"sub": "user-123", "exp": int(time.time()) + 3600},
"secret",
algorithm="HS256",
headers={"kid": KID},
)
async with httpx.AsyncClient() as client:
fetcher = AsyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.InvalidAlgorithmError):
await async_verify_token(token, fetcher)


# ---------------------------------------------------------------------------
# verify_token (sync)
# ---------------------------------------------------------------------------


class TestSyncVerifyToken:
"""Tests for sync_verify_token."""

@respx.mock
def test_valid_token_returns_claims(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
claims = sync_verify_token(token, fetcher)
assert claims["sub"] == "user-123"

@respx.mock
def test_expired_token_raises(
self,
jwks_response: dict[str, Any],
private_key_pem: bytes,
) -> None:
respx.get(JWKS_URL).respond(json=jwks_response)
token = _make_token(private_key_pem, exp=1)
with httpx.Client() as client:
fetcher = SyncJWKSFetcher(JWKS_URL, client)
with pytest.raises(pyjwt.ExpiredSignatureError):
sync_verify_token(token, fetcher)