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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user