Files
StarPunk/tests/test_routes_token.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

395 lines
15 KiB
Python

"""
Tests for token endpoint route
Tests the /auth/token endpoint for IndieAuth token exchange.
"""
import pytest
from starpunk.tokens import create_authorization_code
import hashlib
def test_token_endpoint_success(client, app):
"""Test successful token exchange"""
with app.app_context():
# Create authorization code
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create"
)
# Exchange for token
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 200
data = response.get_json()
assert 'access_token' in data
assert data['token_type'] == 'Bearer'
assert data['scope'] == 'create'
assert data['me'] == 'https://user.example'
def test_token_endpoint_with_pkce(client, app):
"""Test token exchange with PKCE"""
with app.app_context():
# Generate PKCE verifier and challenge
code_verifier = "test_verifier_with_sufficient_entropy_12345"
code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest()
# Create authorization code with PKCE
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create",
code_challenge=code_challenge,
code_challenge_method="S256"
)
# Exchange with correct verifier
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example',
'code_verifier': code_verifier
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 200
data = response.get_json()
assert 'access_token' in data
def test_token_endpoint_missing_grant_type(client, app):
"""Test token endpoint rejects missing grant_type"""
with app.app_context():
response = client.post('/auth/token', data={
'code': 'some_code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'grant_type' in data['error_description']
def test_token_endpoint_invalid_grant_type(client, app):
"""Test token endpoint rejects invalid grant_type"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'password',
'code': 'some_code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'unsupported_grant_type'
def test_token_endpoint_missing_code(client, app):
"""Test token endpoint rejects missing code"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'code' in data['error_description']
def test_token_endpoint_missing_client_id(client, app):
"""Test token endpoint rejects missing client_id"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': 'some_code',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'client_id' in data['error_description']
def test_token_endpoint_missing_redirect_uri(client, app):
"""Test token endpoint rejects missing redirect_uri"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': 'some_code',
'client_id': 'https://client.example',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'redirect_uri' in data['error_description']
def test_token_endpoint_missing_me(client, app):
"""Test token endpoint rejects missing me parameter"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': 'some_code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'me' in data['error_description']
def test_token_endpoint_invalid_code(client, app):
"""Test token endpoint rejects invalid authorization code"""
with app.app_context():
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': 'invalid_code_12345',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
def test_token_endpoint_code_replay(client, app):
"""Test token endpoint prevents code replay attacks"""
with app.app_context():
# Create authorization code
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create"
)
# First exchange succeeds
response1 = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response1.status_code == 200
# Second exchange fails (replay attack)
response2 = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response2.status_code == 400
data = response2.get_json()
assert data['error'] == 'invalid_grant'
assert 'already been used' in data['error_description']
def test_token_endpoint_client_id_mismatch(client, app):
"""Test token endpoint rejects mismatched client_id"""
with app.app_context():
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create"
)
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://different-client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
assert 'client_id' in data['error_description']
def test_token_endpoint_redirect_uri_mismatch(client, app):
"""Test token endpoint rejects mismatched redirect_uri"""
with app.app_context():
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create"
)
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/different-callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
assert 'redirect_uri' in data['error_description']
def test_token_endpoint_me_mismatch(client, app):
"""Test token endpoint rejects mismatched me parameter"""
with app.app_context():
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create"
)
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://different-user.example'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
assert 'me parameter' in data['error_description']
def test_token_endpoint_empty_scope(client, app):
"""Test token endpoint rejects authorization code with empty scope"""
with app.app_context():
# Create authorization code with empty scope
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="" # Empty scope
)
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
}, content_type='application/x-www-form-urlencoded')
# IndieAuth spec: MUST NOT issue token if no scope
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_scope'
def test_token_endpoint_wrong_content_type(client, app):
"""Test token endpoint rejects non-form-encoded requests"""
with app.app_context():
response = client.post('/auth/token',
json={
'grant_type': 'authorization_code',
'code': 'some_code',
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
})
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_request'
assert 'Content-Type' in data['error_description']
def test_token_endpoint_pkce_missing_verifier(client, app):
"""Test token endpoint rejects PKCE exchange without verifier"""
with app.app_context():
# Create authorization code with PKCE
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create",
code_challenge="some_challenge",
code_challenge_method="S256"
)
# Exchange without verifier
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example'
# Missing code_verifier
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
assert 'code_verifier' in data['error_description']
def test_token_endpoint_pkce_wrong_verifier(client, app):
"""Test token endpoint rejects PKCE exchange with wrong verifier"""
with app.app_context():
code_verifier = "correct_verifier"
code_challenge = hashlib.sha256(code_verifier.encode()).hexdigest()
# Create authorization code with PKCE
code = create_authorization_code(
me="https://user.example",
client_id="https://client.example",
redirect_uri="https://client.example/callback",
scope="create",
code_challenge=code_challenge,
code_challenge_method="S256"
)
# Exchange with wrong verifier
response = client.post('/auth/token', data={
'grant_type': 'authorization_code',
'code': code,
'client_id': 'https://client.example',
'redirect_uri': 'https://client.example/callback',
'me': 'https://user.example',
'code_verifier': 'wrong_verifier'
}, content_type='application/x-www-form-urlencoded')
assert response.status_code == 400
data = response.get_json()
assert data['error'] == 'invalid_grant'
assert 'code_verifier' in data['error_description']