feat(token): implement GET /token for token verification
Implements W3C IndieAuth Section 6.3 token verification endpoint. The token endpoint now supports both: - POST: Issue new tokens (authorization code exchange) - GET: Verify existing tokens (resource server validation) Changes: - Added GET handler to /token endpoint - Extracts Bearer token from Authorization header (RFC 6750) - Returns JSON with me, client_id, scope - Returns 401 with WWW-Authenticate for invalid tokens - 11 new tests covering all verification scenarios All 533 tests passing. Resolves critical P0 blocker for v1.0.0. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -315,3 +315,236 @@ class TestSecurityValidation:
|
||||
token_metadata = test_token_service.validate_token(data["access_token"])
|
||||
assert token_metadata is not None
|
||||
assert token_metadata["me"] == metadata["me"]
|
||||
|
||||
|
||||
class TestTokenVerification:
|
||||
"""Tests for GET /token token verification endpoint."""
|
||||
|
||||
def test_verify_valid_token_success(self, client, test_token_service):
|
||||
"""Test successful token verification with valid token."""
|
||||
# Generate a token
|
||||
token = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
# Verify the token
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["me"] == "https://user.example.com"
|
||||
assert data["client_id"] == "https://client.example.com"
|
||||
assert data["scope"] == ""
|
||||
|
||||
def test_verify_token_with_scope(self, client, test_token_service):
|
||||
"""Test token verification includes scope."""
|
||||
# Generate a token with scope
|
||||
token = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client.example.com",
|
||||
scope="create update"
|
||||
)
|
||||
|
||||
# Verify the token
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["scope"] == "create update"
|
||||
|
||||
def test_verify_invalid_token(self, client):
|
||||
"""Test verification of invalid token returns 401."""
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": "Bearer invalid_token_xyz123"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
assert response.headers["WWW-Authenticate"] == "Bearer"
|
||||
|
||||
def test_verify_missing_authorization_header(self, client):
|
||||
"""Test verification without Authorization header returns 401."""
|
||||
response = client.get("/token")
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
assert "WWW-Authenticate" in response.headers
|
||||
|
||||
def test_verify_invalid_auth_scheme(self, client):
|
||||
"""Test verification with non-Bearer auth scheme returns 401."""
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": "Basic dXNlcjpwYXNz"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
|
||||
def test_verify_empty_token(self, client):
|
||||
"""Test verification with empty token returns 401."""
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": "Bearer "}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
|
||||
def test_verify_case_insensitive_bearer(self, client, test_token_service):
|
||||
"""Test Bearer scheme is case-insensitive per RFC 6750."""
|
||||
# Generate a token
|
||||
token = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
# Test with lowercase "bearer"
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["me"] == "https://user.example.com"
|
||||
|
||||
def test_verify_expired_token(self, client, test_database):
|
||||
"""Test verification of expired token returns 401."""
|
||||
# Create token service with very short TTL
|
||||
short_ttl_service = TokenService(
|
||||
database=test_database,
|
||||
token_length=32,
|
||||
token_ttl=0 # Expires immediately
|
||||
)
|
||||
|
||||
# Generate token (will be expired)
|
||||
token = short_ttl_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
# Import app to override dependency temporarily
|
||||
from gondulf.dependencies import get_token_service
|
||||
from gondulf.main import app
|
||||
|
||||
# Override with short TTL service
|
||||
app.dependency_overrides[get_token_service] = lambda: short_ttl_service
|
||||
|
||||
try:
|
||||
# Verify the token (should be expired)
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
finally:
|
||||
# Clean up override
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
class TestTokenVerificationIntegration:
|
||||
"""Integration tests for full token lifecycle."""
|
||||
|
||||
def test_full_token_lifecycle(self, client, valid_auth_code, test_token_service):
|
||||
"""Test complete flow: exchange code, verify token."""
|
||||
code, metadata = valid_auth_code
|
||||
|
||||
# Step 1: Exchange authorization code for token
|
||||
exchange_response = client.post(
|
||||
"/token",
|
||||
data={
|
||||
"grant_type": "authorization_code",
|
||||
"code": code,
|
||||
"client_id": metadata["client_id"],
|
||||
"redirect_uri": metadata["redirect_uri"]
|
||||
}
|
||||
)
|
||||
|
||||
assert exchange_response.status_code == 200
|
||||
token_data = exchange_response.json()
|
||||
access_token = token_data["access_token"]
|
||||
|
||||
# Step 2: Verify the token
|
||||
verify_response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {access_token}"}
|
||||
)
|
||||
|
||||
assert verify_response.status_code == 200
|
||||
verify_data = verify_response.json()
|
||||
assert verify_data["me"] == metadata["me"]
|
||||
assert verify_data["client_id"] == metadata["client_id"]
|
||||
assert verify_data["scope"] == metadata["scope"]
|
||||
|
||||
def test_verify_revoked_token(self, client, test_token_service):
|
||||
"""Test verification of revoked token returns 401."""
|
||||
# Generate a token
|
||||
token = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
# Revoke the token
|
||||
revoked = test_token_service.revoke_token(token)
|
||||
assert revoked is True
|
||||
|
||||
# Try to verify revoked token
|
||||
response = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token}"}
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
data = response.json()
|
||||
assert data["detail"]["error"] == "invalid_token"
|
||||
|
||||
def test_verify_cross_client_token(self, client, test_token_service):
|
||||
"""Test token verification returns correct client_id."""
|
||||
# Generate tokens for two different clients
|
||||
token_a = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client-a.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
token_b = test_token_service.generate_token(
|
||||
me="https://user.example.com",
|
||||
client_id="https://client-b.example.com",
|
||||
scope=""
|
||||
)
|
||||
|
||||
# Verify token A returns client A
|
||||
response_a = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token_a}"}
|
||||
)
|
||||
assert response_a.status_code == 200
|
||||
assert response_a.json()["client_id"] == "https://client-a.example.com"
|
||||
|
||||
# Verify token B returns client B
|
||||
response_b = client.get(
|
||||
"/token",
|
||||
headers={"Authorization": f"Bearer {token_b}"}
|
||||
)
|
||||
assert response_b.status_code == 200
|
||||
assert response_b.json()["client_id"] == "https://client-b.example.com"
|
||||
|
||||
Reference in New Issue
Block a user