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>
This commit is contained in:
2025-11-24 12:26:54 -07:00
parent 2eaf67279d
commit e5050a0a7e
4 changed files with 1103 additions and 1 deletions

View File

@@ -2,16 +2,18 @@
Authentication routes for StarPunk
Handles IndieLogin authentication flow including login form, OAuth callback,
and logout functionality.
logout functionality, and IndieAuth endpoints for Micropub clients.
"""
from flask import (
Blueprint,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
@@ -26,6 +28,14 @@ from starpunk.auth import (
verify_session,
)
from starpunk.tokens import (
create_access_token,
create_authorization_code,
exchange_authorization_code,
InvalidAuthorizationCodeError,
validate_scope,
)
# Create blueprint
bp = Blueprint("auth", __name__, url_prefix="/auth")
@@ -182,3 +192,259 @@ def logout():
flash("Logged out successfully", "success")
return response
@bp.route("/token", methods=["POST"])
def token_endpoint():
"""
IndieAuth token endpoint for exchanging authorization codes for access tokens
Implements the IndieAuth token endpoint as specified in:
https://www.w3.org/TR/indieauth/#token-endpoint
Form parameters (application/x-www-form-urlencoded):
grant_type: Must be "authorization_code"
code: The authorization code received from authorization endpoint
client_id: Client application URL (must match authorization request)
redirect_uri: Redirect URI (must match authorization request)
me: User's profile URL (must match authorization request)
code_verifier: PKCE verifier (optional, required if PKCE was used)
Returns:
200 OK with JSON response on success:
{
"access_token": "xxx",
"token_type": "Bearer",
"scope": "create",
"me": "https://user.example"
}
400 Bad Request with JSON error response on failure:
{
"error": "invalid_grant|invalid_request|invalid_client",
"error_description": "Human-readable error description"
}
"""
# Only accept form-encoded POST requests
if request.content_type and 'application/x-www-form-urlencoded' not in request.content_type:
return jsonify({
"error": "invalid_request",
"error_description": "Content-Type must be application/x-www-form-urlencoded"
}), 400
# Extract parameters from form data
grant_type = request.form.get('grant_type')
code = request.form.get('code')
client_id = request.form.get('client_id')
redirect_uri = request.form.get('redirect_uri')
me = request.form.get('me')
code_verifier = request.form.get('code_verifier')
# Validate required parameters
if not grant_type:
return jsonify({
"error": "invalid_request",
"error_description": "Missing grant_type parameter"
}), 400
if grant_type != 'authorization_code':
return jsonify({
"error": "unsupported_grant_type",
"error_description": f"Unsupported grant_type: {grant_type}"
}), 400
if not code:
return jsonify({
"error": "invalid_request",
"error_description": "Missing code parameter"
}), 400
if not client_id:
return jsonify({
"error": "invalid_request",
"error_description": "Missing client_id parameter"
}), 400
if not redirect_uri:
return jsonify({
"error": "invalid_request",
"error_description": "Missing redirect_uri parameter"
}), 400
if not me:
return jsonify({
"error": "invalid_request",
"error_description": "Missing me parameter"
}), 400
# Exchange authorization code for token
try:
auth_info = exchange_authorization_code(
code=code,
client_id=client_id,
redirect_uri=redirect_uri,
me=me,
code_verifier=code_verifier
)
# IndieAuth spec: MUST NOT issue token if no scope
if not auth_info['scope']:
return jsonify({
"error": "invalid_scope",
"error_description": "Authorization code was issued without scope"
}), 400
# Create access token
access_token = create_access_token(
me=auth_info['me'],
client_id=auth_info['client_id'],
scope=auth_info['scope']
)
# Return token response
return jsonify({
"access_token": access_token,
"token_type": "Bearer",
"scope": auth_info['scope'],
"me": auth_info['me']
}), 200
except InvalidAuthorizationCodeError as e:
current_app.logger.warning(f"Invalid authorization code: {e}")
return jsonify({
"error": "invalid_grant",
"error_description": str(e)
}), 400
except Exception as e:
current_app.logger.error(f"Token endpoint error: {e}")
return jsonify({
"error": "server_error",
"error_description": "An unexpected error occurred"
}), 500
@bp.route("/authorization", methods=["GET", "POST"])
def authorization_endpoint():
"""
IndieAuth authorization endpoint for Micropub client authorization
Implements the IndieAuth authorization endpoint as specified in:
https://www.w3.org/TR/indieauth/#authorization-endpoint
GET: Display authorization consent form
Query parameters:
response_type: Must be "code"
client_id: Client application URL
redirect_uri: Client's callback URL
state: Client's CSRF state token
scope: Space-separated list of requested scopes (optional)
me: User's profile URL (optional)
code_challenge: PKCE challenge (optional)
code_challenge_method: PKCE method, typically "S256" (optional)
POST: Process authorization approval/denial
Form parameters:
approve: "yes" if user approved, anything else is denial
(other parameters inherited from GET via hidden form fields)
Returns:
GET: HTML authorization consent form
POST: Redirect to client's redirect_uri with code and state parameters
"""
if request.method == "GET":
# Extract IndieAuth parameters
response_type = request.args.get('response_type')
client_id = request.args.get('client_id')
redirect_uri = request.args.get('redirect_uri')
state = request.args.get('state')
scope = request.args.get('scope', '')
me_param = request.args.get('me')
code_challenge = request.args.get('code_challenge')
code_challenge_method = request.args.get('code_challenge_method')
# Validate required parameters
if not response_type:
return "Missing response_type parameter", 400
if response_type != 'code':
return f"Unsupported response_type: {response_type}", 400
if not client_id:
return "Missing client_id parameter", 400
if not redirect_uri:
return "Missing redirect_uri parameter", 400
if not state:
return "Missing state parameter", 400
# Validate and filter scope to supported scopes
validated_scope = validate_scope(scope)
# Check if user is logged in as admin
session_token = request.cookies.get("starpunk_session")
if not session_token or not verify_session(session_token):
# Store authorization request in session
session['pending_auth_url'] = request.url
flash("Please log in to authorize this application", "info")
return redirect(url_for('auth.login_form'))
# User is logged in, show authorization consent form
# Use ADMIN_ME as the user's identity
me = current_app.config.get('ADMIN_ME')
return render_template(
'auth/authorize.html',
client_id=client_id,
redirect_uri=redirect_uri,
state=state,
scope=validated_scope,
me=me,
response_type=response_type,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method
)
else: # POST
# User submitted authorization form
approve = request.form.get('approve')
client_id = request.form.get('client_id')
redirect_uri = request.form.get('redirect_uri')
state = request.form.get('state')
scope = request.form.get('scope', '')
me = request.form.get('me')
code_challenge = request.form.get('code_challenge')
code_challenge_method = request.form.get('code_challenge_method')
# Check if user is still logged in
session_token = request.cookies.get("starpunk_session")
if not session_token or not verify_session(session_token):
flash("Session expired, please log in again", "error")
return redirect(url_for('auth.login_form'))
# If user denied, redirect with error
if approve != 'yes':
error_redirect = f"{redirect_uri}?error=access_denied&error_description=User+denied+authorization&state={state}"
return redirect(error_redirect)
# User approved, generate authorization code
try:
auth_code = create_authorization_code(
me=me,
client_id=client_id,
redirect_uri=redirect_uri,
scope=scope,
state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method
)
# Redirect back to client with authorization code
callback_url = f"{redirect_uri}?code={auth_code}&state={state}"
return redirect(callback_url)
except Exception as e:
current_app.logger.error(f"Authorization endpoint error: {e}")
error_redirect = f"{redirect_uri}?error=server_error&error_description=Failed+to+generate+authorization+code&state={state}"
return redirect(error_redirect)

View File

@@ -0,0 +1,81 @@
{% extends "base.html" %}
{% block title %}Authorize Application - StarPunk{% endblock %}
{% block content %}
<div class="authorization-container">
<h2>Authorization Request</h2>
<div class="authorization-info">
<p class="auth-intro">
An application is requesting access to your StarPunk site.
</p>
<div class="client-info">
<h3>Application Details</h3>
<dl>
<dt>Client:</dt>
<dd><code>{{ client_id }}</code></dd>
<dt>Your Identity:</dt>
<dd><code>{{ me }}</code></dd>
{% if scope %}
<dt>Requested Permissions:</dt>
<dd>
<ul class="scope-list">
{% for s in scope.split() %}
<li><strong>{{ s }}</strong> - {% if s == 'create' %}Create new posts{% endif %}</li>
{% endfor %}
</ul>
</dd>
{% else %}
<dt>Requested Permissions:</dt>
<dd><em>No permissions requested (read-only access)</em></dd>
{% endif %}
</dl>
</div>
<div class="authorization-warning">
<p><strong>Warning:</strong> Only authorize applications you trust.</p>
<p>This application will be able to perform the above actions on your behalf.</p>
</div>
</div>
<form action="{{ url_for('auth.authorization_endpoint') }}" method="POST" class="authorization-form">
<!-- Pass through all parameters as hidden fields -->
<input type="hidden" name="client_id" value="{{ client_id }}">
<input type="hidden" name="redirect_uri" value="{{ redirect_uri }}">
<input type="hidden" name="state" value="{{ state }}">
<input type="hidden" name="scope" value="{{ scope }}">
<input type="hidden" name="me" value="{{ me }}">
<input type="hidden" name="response_type" value="{{ response_type }}">
{% if code_challenge %}
<input type="hidden" name="code_challenge" value="{{ code_challenge }}">
<input type="hidden" name="code_challenge_method" value="{{ code_challenge_method }}">
{% endif %}
<div class="authorization-actions">
<button type="submit" name="approve" value="yes" class="button button-primary">
Authorize
</button>
<button type="submit" name="approve" value="no" class="button button-secondary">
Deny
</button>
</div>
</form>
<div class="authorization-help">
<h3>What does this mean?</h3>
<p>
By clicking "Authorize", you allow this application to access your StarPunk site
with the permissions listed above. You can revoke access at any time from your
admin dashboard.
</p>
<p>
If you don't recognize this application or didn't intend to authorize it,
click "Deny" to reject the request.
</p>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,361 @@
"""
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

394
tests/test_routes_token.py Normal file
View File

@@ -0,0 +1,394 @@
"""
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']