feat: Complete IndieAuth server removal (Phases 2-4)

Completed all remaining phases of ADR-030 IndieAuth provider removal.
StarPunk no longer acts as an authorization server - all IndieAuth
operations delegated to external providers.

Phase 2 - Remove Token Issuance:
- Deleted /auth/token endpoint
- Removed token_endpoint() function from routes/auth.py
- Deleted tests/test_routes_token.py

Phase 3 - Remove Token Storage:
- Deleted starpunk/tokens.py module entirely
- Created migration 004 to drop tokens and authorization_codes tables
- Deleted tests/test_tokens.py
- Removed all internal token CRUD operations

Phase 4 - External Token Verification:
- Created starpunk/auth_external.py module
- Implemented verify_external_token() for external IndieAuth providers
- Updated Micropub endpoint to use external verification
- Added TOKEN_ENDPOINT configuration
- Updated all Micropub tests to mock external verification
- HTTP timeout protection (5s) for external requests

Additional Changes:
- Created migration 003 to remove code_verifier from auth_state
- Fixed 5 migration tests that referenced obsolete code_verifier column
- Updated 11 Micropub tests for external verification
- Fixed test fixture and app context issues
- All 501 tests passing

Breaking Changes:
- Micropub clients must use external IndieAuth providers
- TOKEN_ENDPOINT configuration now required
- Existing internal tokens invalid (tables dropped)

Migration Impact:
- Simpler codebase: -500 lines of code
- Fewer database tables: -2 tables (tokens, authorization_codes)
- More secure: External providers handle token security
- More maintainable: Less authentication code to maintain

Standards Compliance:
- W3C IndieAuth specification
- OAuth 2.0 Bearer token authentication
- IndieWeb principle: delegate to external services

Related:
- ADR-030: IndieAuth Provider Removal Strategy
- ADR-050: Remove Custom IndieAuth Server
- Migration 003: Remove code_verifier from auth_state
- Migration 004: Drop tokens and authorization_codes tables

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-11-24 17:23:46 -07:00
parent 869402ab0d
commit a3bac86647
36 changed files with 5597 additions and 2670 deletions

View File

@@ -28,14 +28,6 @@ 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")
@@ -194,257 +186,3 @@ def logout():
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)