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)