209 lines
7.0 KiB
Python
209 lines
7.0 KiB
Python
"""
|
|
Test JWT token structure and scope support.
|
|
|
|
This test obtains a JWT token via OAuth and examines its structure.
|
|
"""
|
|
|
|
import base64
|
|
import json
|
|
|
|
import pytest
|
|
|
|
|
|
def decode_jwt_without_verification(token: str) -> dict:
|
|
"""
|
|
Decode JWT token without signature verification (for inspection only).
|
|
|
|
Returns:
|
|
Dict with header and payload
|
|
"""
|
|
parts = token.split(".")
|
|
if len(parts) != 3:
|
|
raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}")
|
|
|
|
# Decode header
|
|
header = json.loads(
|
|
base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4))
|
|
)
|
|
|
|
# Decode payload
|
|
payload = json.loads(
|
|
base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4))
|
|
)
|
|
|
|
return {
|
|
"header": header,
|
|
"payload": payload,
|
|
}
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_jwt_token_structure_with_custom_client():
|
|
"""
|
|
Test that we can create a JWT-enabled OAuth client and examine the token structure.
|
|
|
|
This test manually configures a JWT client and obtains a token.
|
|
"""
|
|
import os
|
|
|
|
import httpx
|
|
|
|
# This test requires manual setup of a JWT client
|
|
# Skip if not configured
|
|
client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID")
|
|
if not client_id:
|
|
pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test")
|
|
|
|
_client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET")
|
|
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
|
|
|
|
# Fetch discovery
|
|
async with httpx.AsyncClient() as client:
|
|
discovery_response = await client.get(
|
|
f"{nextcloud_host}/.well-known/openid-configuration"
|
|
)
|
|
discovery_response.raise_for_status()
|
|
discovery = discovery_response.json()
|
|
|
|
_token_endpoint = discovery["token_endpoint"]
|
|
|
|
# For this test, we'll use client credentials grant if supported
|
|
# Otherwise, skip this test
|
|
pytest.skip(
|
|
"JWT token test requires OAuth flow - use manual testing script instead"
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_opaque_token_vs_jwt_comparison():
|
|
"""
|
|
Compare opaque tokens vs JWT tokens to understand the differences.
|
|
|
|
This is a documentation test that explains the findings.
|
|
"""
|
|
# This test documents our findings about JWT vs opaque tokens
|
|
# Based on manual testing with the test script
|
|
|
|
findings = {
|
|
"oidc_app_capabilities": {
|
|
"supports_jwt_tokens": True,
|
|
"supports_opaque_tokens": True,
|
|
"configuration_method": "per-client via token_type field",
|
|
"jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)",
|
|
},
|
|
"dynamic_registration": {
|
|
"sets_allowed_scopes": False,
|
|
"note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request",
|
|
"workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API",
|
|
},
|
|
"jwt_token_structure": {
|
|
"header": {
|
|
"typ": "at+JWT", # RFC 9068 access token type
|
|
"alg": "RS256", # Signature algorithm
|
|
},
|
|
"payload_claims": {
|
|
"iss": "issuer URL",
|
|
"sub": "user ID",
|
|
"aud": "client ID",
|
|
"exp": "expiration timestamp",
|
|
"iat": "issued at timestamp",
|
|
"scope": "space-separated scope string (THIS IS THE KEY!)",
|
|
"client_id": "client identifier",
|
|
"jti": "JWT ID",
|
|
# Optional based on scopes:
|
|
"roles": "if roles scope present",
|
|
"groups": "if groups scope present",
|
|
"email": "if email scope present",
|
|
"name": "if profile scope present",
|
|
},
|
|
"scope_claim": {
|
|
"format": "space-separated string",
|
|
"example": "openid profile email nc:read nc:write",
|
|
"extraction": "payload['scope'].split()",
|
|
},
|
|
},
|
|
"scope_validation": {
|
|
"oidc_app": {
|
|
"validates": True,
|
|
"method": "Intersects requested scopes with allowed_scopes per client",
|
|
"location": "LoginRedirectorController.php:251-267",
|
|
},
|
|
"user_oidc_app": {
|
|
"validates_scopes": False,
|
|
"validates": ["token expiration", "issuer", "audience (optional)"],
|
|
"limitation": "Does NOT extract or validate scopes from JWT",
|
|
},
|
|
},
|
|
"token_size": {
|
|
"opaque": "72 characters",
|
|
"jwt": "~800-1200 characters (depends on claims)",
|
|
"overhead": "JWT is 10-15x larger than opaque tokens",
|
|
},
|
|
"recommendation": {
|
|
"for_mcp_server": "Use JWT tokens with self-validation",
|
|
"reasoning": [
|
|
"Can extract scopes directly from token payload",
|
|
"No additional API call needed",
|
|
"Standard approach (RFC 9068)",
|
|
"Works with existing oidc app",
|
|
],
|
|
"alternative": "Implement introspection endpoint in oidc app (future work)",
|
|
},
|
|
}
|
|
|
|
# Print findings for documentation
|
|
print("\n" + "=" * 80)
|
|
print("JWT Token vs Opaque Token Findings")
|
|
print("=" * 80)
|
|
print(json.dumps(findings, indent=2))
|
|
print("=" * 80 + "\n")
|
|
|
|
# This test always passes - it's for documentation
|
|
assert True, "Findings documented"
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_scope_presence_in_jwt():
|
|
"""
|
|
Verify that custom scopes (nc:read, nc:write) are present in JWT tokens.
|
|
|
|
NOTE: This test documents the expected behavior based on manual testing.
|
|
Actual implementation will be tested in integration tests after JWT validation is implemented.
|
|
"""
|
|
expected_behavior = {
|
|
"client_configuration": {
|
|
"allowed_scopes": "openid profile email nc:read nc:write",
|
|
"token_type": "jwt",
|
|
},
|
|
"authorization_request": {
|
|
"scope": "openid profile email nc:read nc:write",
|
|
},
|
|
"token_response": {
|
|
"access_token": "JWT with scope claim",
|
|
},
|
|
"jwt_payload": {
|
|
"scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes
|
|
},
|
|
"scope_filtering": {
|
|
"description": "oidc app filters requested scopes against allowed_scopes",
|
|
"example": {
|
|
"requested": "openid profile nc:read nc:write nc:admin",
|
|
"allowed": "openid profile email nc:read nc:write",
|
|
"granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested
|
|
},
|
|
},
|
|
}
|
|
|
|
print("\n" + "=" * 80)
|
|
print("Expected JWT Scope Behavior")
|
|
print("=" * 80)
|
|
print(json.dumps(expected_behavior, indent=2))
|
|
print("=" * 80 + "\n")
|
|
|
|
assert True, "Expected behavior documented"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
# Run with: uv run pytest tests/server/test_jwt_tokens.py -v
|
|
pytest.main([__file__, "-v", "-s"])
|