""" 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"])