Files
StarPunk/tests/test_routes_authorization.py
Phil Skentelbery e5050a0a7e feat: Implement IndieAuth token and authorization endpoints (Phase 2)
Following design in /docs/design/micropub-architecture.md and
/docs/decisions/ADR-029-micropub-v1-implementation-phases.md

Token Endpoint (/auth/token):
- Exchange authorization codes for access tokens
- Form-encoded POST requests per IndieAuth spec
- PKCE support (code_verifier validation)
- OAuth 2.0 error responses
- Validates client_id, redirect_uri, me parameters
- Returns Bearer tokens with scope

Authorization Endpoint (/auth/authorization):
- GET: Display consent form (requires admin login)
- POST: Process approval/denial
- PKCE support (code_challenge storage)
- Scope validation and filtering
- Integration with session management
- Proper error handling and redirects

All 33 Phase 2 tests pass (17 token + 16 authorization)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 12:26:54 -07:00

362 lines
12 KiB
Python

"""
Tests for authorization endpoint route
Tests the /auth/authorization endpoint for IndieAuth client authorization.
"""
import pytest
from starpunk.auth import create_session
from urllib.parse import urlparse, parse_qs
def create_admin_session(client, app):
"""Helper to create an authenticated admin session"""
with app.test_request_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
session_token = create_session(admin_me)
client.set_cookie('starpunk_session', session_token)
return session_token
def test_authorization_endpoint_get_not_logged_in(client, app):
"""Test authorization endpoint redirects to login when not authenticated"""
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create'
})
# Should redirect to login
assert response.status_code == 302
assert '/auth/login' in response.location
def test_authorization_endpoint_get_logged_in(client, app):
"""Test authorization endpoint shows consent form when authenticated"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create'
})
assert response.status_code == 200
assert b'Authorization Request' in response.data
assert b'https://client.example' in response.data
assert b'create' in response.data
def test_authorization_endpoint_missing_response_type(client, app):
"""Test authorization endpoint rejects missing response_type"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123'
})
assert response.status_code == 400
assert b'Missing response_type' in response.data
def test_authorization_endpoint_invalid_response_type(client, app):
"""Test authorization endpoint rejects unsupported response_type"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'token', # Only 'code' is supported
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123'
})
assert response.status_code == 400
assert b'Unsupported response_type' in response.data
def test_authorization_endpoint_missing_client_id(client, app):
"""Test authorization endpoint rejects missing client_id"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123'
})
assert response.status_code == 400
assert b'Missing client_id' in response.data
def test_authorization_endpoint_missing_redirect_uri(client, app):
"""Test authorization endpoint rejects missing redirect_uri"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'state': 'random_state_123'
})
assert response.status_code == 400
assert b'Missing redirect_uri' in response.data
def test_authorization_endpoint_missing_state(client, app):
"""Test authorization endpoint rejects missing state"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback'
})
assert response.status_code == 400
assert b'Missing state' in response.data
def test_authorization_endpoint_empty_scope(client, app):
"""Test authorization endpoint allows empty scope"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': '' # Empty scope allowed per IndieAuth spec
})
assert response.status_code == 200
assert b'Authorization Request' in response.data
def test_authorization_endpoint_filters_unsupported_scopes(client, app):
"""Test authorization endpoint filters to supported scopes only"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create update delete' # Only 'create' is supported in V1
})
assert response.status_code == 200
# Should only show 'create' scope
assert b'create' in response.data
def test_authorization_endpoint_post_approve(client, app):
"""Test authorization approval generates code and redirects"""
create_admin_session(client, app)
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
response = client.post('/auth/authorization', data={
'approve': 'yes',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'me': admin_me,
'response_type': 'code'
})
# Should redirect to client's redirect_uri
assert response.status_code == 302
assert response.location.startswith('https://client.example/callback')
# Parse redirect URL
parsed = urlparse(response.location)
params = parse_qs(parsed.query)
# Should include code and state
assert 'code' in params
assert 'state' in params
assert params['state'][0] == 'random_state_123'
assert len(params['code'][0]) > 0
def test_authorization_endpoint_post_deny(client, app):
"""Test authorization denial redirects with error"""
create_admin_session(client, app)
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
response = client.post('/auth/authorization', data={
'approve': 'no',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'me': admin_me,
'response_type': 'code'
})
# Should redirect to client's redirect_uri with error
assert response.status_code == 302
assert response.location.startswith('https://client.example/callback')
# Parse redirect URL
parsed = urlparse(response.location)
params = parse_qs(parsed.query)
# Should include error
assert 'error' in params
assert params['error'][0] == 'access_denied'
assert 'state' in params
assert params['state'][0] == 'random_state_123'
def test_authorization_endpoint_post_not_logged_in(client, app):
"""Test authorization POST requires authentication"""
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
response = client.post('/auth/authorization', data={
'approve': 'yes',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'me': admin_me,
'response_type': 'code'
})
# Should redirect to login
assert response.status_code == 302
assert '/auth/login' in response.location
def test_authorization_endpoint_with_pkce(client, app):
"""Test authorization endpoint accepts PKCE parameters"""
create_admin_session(client, app)
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'code_challenge': 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
'code_challenge_method': 'S256'
})
assert response.status_code == 200
assert b'Authorization Request' in response.data
def test_authorization_endpoint_post_with_pkce(client, app):
"""Test authorization approval preserves PKCE parameters"""
create_admin_session(client, app)
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
response = client.post('/auth/authorization', data={
'approve': 'yes',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'me': admin_me,
'response_type': 'code',
'code_challenge': 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
'code_challenge_method': 'S256'
})
assert response.status_code == 302
assert response.location.startswith('https://client.example/callback')
# Parse redirect URL
parsed = urlparse(response.location)
params = parse_qs(parsed.query)
# Should have code and state
assert 'code' in params
assert 'state' in params
def test_authorization_endpoint_preserves_me_parameter(client, app):
"""Test authorization endpoint uses ADMIN_ME as identity"""
create_admin_session(client, app)
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
response = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create'
})
assert response.status_code == 200
# Should show admin's identity in the form
assert admin_me.encode() in response.data
def test_authorization_flow_end_to_end(client, app):
"""Test complete authorization flow from consent to token exchange"""
create_admin_session(client, app)
with app.app_context():
admin_me = app.config.get('ADMIN_ME', 'https://test.example.com')
# Step 1: Get authorization form
response1 = client.get('/auth/authorization', query_string={
'response_type': 'code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create'
})
assert response1.status_code == 200
# Step 2: Approve authorization
response2 = client.post('/auth/authorization', data={
'approve': 'yes',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'state': 'random_state_123',
'scope': 'create',
'me': admin_me,
'response_type': 'code'
})
assert response2.status_code == 302
# Extract authorization code
parsed = urlparse(response2.location)
params = parse_qs(parsed.query)
code = params['code'][0]
# Step 3: Exchange code for token
response3 = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': admin_me
}, content_type='application/x-www-form-urlencoded')
assert response3.status_code == 200
token_data = response3.get_json()
assert 'access_token' in token_data
assert token_data['token_type'] == 'Bearer'
assert token_data['scope'] == 'create'
assert token_data['me'] == admin_me