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:
@@ -153,5 +153,5 @@ def create_app(config=None):
|
||||
|
||||
# Package version (Semantic Versioning 2.0.0)
|
||||
# See docs/standards/versioning-strategy.md for details
|
||||
__version__ = "1.0.0-rc.3"
|
||||
__version_info__ = (1, 0, 0, "rc", 3)
|
||||
__version__ = "1.0.0-rc.4"
|
||||
__version_info__ = (1, 0, 0, "rc", 4)
|
||||
|
||||
@@ -27,7 +27,6 @@ Exceptions:
|
||||
IndieLoginError: External service error
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
@@ -68,42 +67,6 @@ class IndieLoginError(AuthError):
|
||||
pass
|
||||
|
||||
|
||||
# PKCE helper functions
|
||||
def _generate_pkce_verifier() -> str:
|
||||
"""
|
||||
Generate PKCE code_verifier.
|
||||
|
||||
Creates a cryptographically random 43-character URL-safe string
|
||||
as required by PKCE specification (RFC 7636).
|
||||
|
||||
Returns:
|
||||
URL-safe base64-encoded random string (43 characters)
|
||||
"""
|
||||
# Generate 32 random bytes = 43 chars when base64-url encoded
|
||||
verifier = secrets.token_urlsafe(32)
|
||||
return verifier
|
||||
|
||||
|
||||
def _generate_pkce_challenge(verifier: str) -> str:
|
||||
"""
|
||||
Generate PKCE code_challenge from code_verifier.
|
||||
|
||||
Creates SHA256 hash of verifier and encodes as base64-url string
|
||||
per RFC 7636 S256 method.
|
||||
|
||||
Args:
|
||||
verifier: The code_verifier string from _generate_pkce_verifier()
|
||||
|
||||
Returns:
|
||||
Base64-URL encoded SHA256 hash (43 characters)
|
||||
"""
|
||||
# SHA256 hash the verifier
|
||||
digest = hashlib.sha256(verifier.encode('utf-8')).digest()
|
||||
# Base64-URL encode (no padding)
|
||||
challenge = base64.urlsafe_b64encode(digest).decode('utf-8').rstrip('=')
|
||||
return challenge
|
||||
|
||||
|
||||
# Logging helper functions
|
||||
def _redact_token(value: str, show_chars: int = 6) -> str:
|
||||
"""
|
||||
@@ -230,37 +193,35 @@ def _generate_state_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def _verify_state_token(state: str) -> Optional[str]:
|
||||
def _verify_state_token(state: str) -> bool:
|
||||
"""
|
||||
Verify and consume CSRF state token, returning code_verifier.
|
||||
Verify and consume CSRF state token.
|
||||
|
||||
Args:
|
||||
state: State token to verify
|
||||
|
||||
Returns:
|
||||
code_verifier string if valid, None if invalid or expired
|
||||
True if valid, False if invalid or expired
|
||||
"""
|
||||
db = get_db(current_app)
|
||||
|
||||
# Check if state exists and not expired, retrieve code_verifier
|
||||
# Check if state exists and not expired
|
||||
result = db.execute(
|
||||
"""
|
||||
SELECT code_verifier FROM auth_state
|
||||
SELECT 1 FROM auth_state
|
||||
WHERE state = ? AND expires_at > datetime('now')
|
||||
""",
|
||||
(state,),
|
||||
).fetchone()
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
code_verifier = result['code_verifier']
|
||||
return False
|
||||
|
||||
# Delete state (single-use)
|
||||
db.execute("DELETE FROM auth_state WHERE state = ?", (state,))
|
||||
db.commit()
|
||||
|
||||
return code_verifier
|
||||
return True
|
||||
|
||||
|
||||
def _cleanup_expired_sessions() -> None:
|
||||
@@ -289,7 +250,7 @@ def _cleanup_expired_sessions() -> None:
|
||||
# Core authentication functions
|
||||
def initiate_login(me_url: str) -> str:
|
||||
"""
|
||||
Initiate IndieLogin authentication flow with PKCE.
|
||||
Initiate IndieLogin authentication flow.
|
||||
|
||||
Args:
|
||||
me_url: User's IndieWeb identity URL
|
||||
@@ -310,37 +271,27 @@ def initiate_login(me_url: str) -> str:
|
||||
state = _generate_state_token()
|
||||
current_app.logger.debug(f"Auth: Generated state token: {_redact_token(state, 8)}")
|
||||
|
||||
# Generate PKCE verifier and challenge
|
||||
code_verifier = _generate_pkce_verifier()
|
||||
code_challenge = _generate_pkce_challenge(code_verifier)
|
||||
current_app.logger.debug(
|
||||
f"Auth: Generated PKCE pair:\n"
|
||||
f" verifier: {_redact_token(code_verifier)}\n"
|
||||
f" challenge: {_redact_token(code_challenge)}"
|
||||
)
|
||||
|
||||
# Store state and verifier in database (5-minute expiry)
|
||||
# Store state in database (5-minute expiry)
|
||||
db = get_db(current_app)
|
||||
expires_at = datetime.utcnow() + timedelta(minutes=5)
|
||||
redirect_uri = f"{current_app.config['SITE_URL']}auth/callback"
|
||||
|
||||
db.execute(
|
||||
"""
|
||||
INSERT INTO auth_state (state, code_verifier, expires_at, redirect_uri)
|
||||
VALUES (?, ?, ?, ?)
|
||||
INSERT INTO auth_state (state, expires_at, redirect_uri)
|
||||
VALUES (?, ?, ?)
|
||||
""",
|
||||
(state, code_verifier, expires_at, redirect_uri),
|
||||
(state, expires_at, redirect_uri),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
# Build IndieLogin authorization URL with PKCE
|
||||
# Build IndieLogin authorization URL
|
||||
params = {
|
||||
"me": me_url,
|
||||
"client_id": current_app.config["SITE_URL"],
|
||||
"redirect_uri": redirect_uri,
|
||||
"state": state,
|
||||
"code_challenge": code_challenge,
|
||||
"code_challenge_method": "S256",
|
||||
"response_type": "code",
|
||||
}
|
||||
|
||||
current_app.logger.debug(
|
||||
@@ -349,8 +300,7 @@ def initiate_login(me_url: str) -> str:
|
||||
f" client_id: {current_app.config['SITE_URL']}\n"
|
||||
f" redirect_uri: {redirect_uri}\n"
|
||||
f" state: {_redact_token(state, 8)}\n"
|
||||
f" code_challenge: {_redact_token(code_challenge)}\n"
|
||||
f" code_challenge_method: S256"
|
||||
f" response_type: code"
|
||||
)
|
||||
|
||||
# CORRECT ENDPOINT: /authorize (not /auth)
|
||||
@@ -370,7 +320,7 @@ def initiate_login(me_url: str) -> str:
|
||||
|
||||
def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optional[str]:
|
||||
"""
|
||||
Handle IndieLogin callback with PKCE verification.
|
||||
Handle IndieLogin callback.
|
||||
|
||||
Args:
|
||||
code: Authorization code from IndieLogin
|
||||
@@ -387,15 +337,14 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
"""
|
||||
current_app.logger.debug(f"Auth: Verifying state token: {_redact_token(state, 8)}")
|
||||
|
||||
# Verify state token and retrieve code_verifier (CSRF protection)
|
||||
code_verifier = _verify_state_token(state)
|
||||
if not code_verifier:
|
||||
# Verify state token (CSRF protection)
|
||||
if not _verify_state_token(state):
|
||||
current_app.logger.warning(
|
||||
"Auth: Invalid state token received (possible CSRF or expired token)"
|
||||
)
|
||||
raise InvalidStateError("Invalid or expired state token")
|
||||
|
||||
current_app.logger.debug("Auth: State token valid, code_verifier retrieved")
|
||||
current_app.logger.debug("Auth: State token valid")
|
||||
|
||||
# Verify issuer (security check)
|
||||
expected_iss = f"{current_app.config['INDIELOGIN_URL']}/"
|
||||
@@ -407,7 +356,7 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
|
||||
current_app.logger.debug(f"Auth: Issuer verified: {iss}")
|
||||
|
||||
# Prepare code verification request with PKCE verifier
|
||||
# Prepare code verification request
|
||||
# Note: For authentication-only flows (identity verification), we use the
|
||||
# authorization endpoint, not the token endpoint. grant_type is not needed.
|
||||
# See IndieAuth spec: authorization endpoint for authentication,
|
||||
@@ -416,13 +365,12 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
"code": code,
|
||||
"client_id": current_app.config["SITE_URL"],
|
||||
"redirect_uri": f"{current_app.config['SITE_URL']}auth/callback",
|
||||
"code_verifier": code_verifier, # PKCE verification
|
||||
}
|
||||
|
||||
# Use authorization endpoint for authentication-only flow (identity verification)
|
||||
token_url = f"{current_app.config['INDIELOGIN_URL']}/authorize"
|
||||
|
||||
# Log the request (code_verifier will be redacted)
|
||||
# Log the request
|
||||
_log_http_request(
|
||||
method="POST",
|
||||
url=token_url,
|
||||
@@ -434,12 +382,11 @@ def handle_callback(code: str, state: str, iss: Optional[str] = None) -> Optiona
|
||||
"Auth: Sending code verification request to authorization endpoint:\n"
|
||||
" Method: POST\n"
|
||||
" URL: %s\n"
|
||||
" Data: code=%s, client_id=%s, redirect_uri=%s, code_verifier=%s",
|
||||
" Data: code=%s, client_id=%s, redirect_uri=%s",
|
||||
token_url,
|
||||
_redact_token(code),
|
||||
token_exchange_data["client_id"],
|
||||
token_exchange_data["redirect_uri"],
|
||||
_redact_token(code_verifier),
|
||||
)
|
||||
|
||||
# Exchange code for identity at authorization endpoint (authentication-only flow)
|
||||
|
||||
153
starpunk/auth_external.py
Normal file
153
starpunk/auth_external.py
Normal file
@@ -0,0 +1,153 @@
|
||||
"""
|
||||
External IndieAuth Token Verification for StarPunk
|
||||
|
||||
This module handles verification of bearer tokens issued by external
|
||||
IndieAuth providers. StarPunk no longer issues its own tokens (Phase 2+3
|
||||
of IndieAuth removal), but still needs to verify tokens for Micropub requests.
|
||||
|
||||
Functions:
|
||||
verify_external_token: Verify token with external IndieAuth provider
|
||||
check_scope: Verify token has required scope
|
||||
|
||||
Configuration (via Flask app.config):
|
||||
TOKEN_ENDPOINT: External token endpoint URL for verification
|
||||
ADMIN_ME: Expected 'me' value in token (site owner identity)
|
||||
|
||||
ADR: ADR-030 IndieAuth Provider Removal Strategy
|
||||
Date: 2025-11-24
|
||||
"""
|
||||
|
||||
import httpx
|
||||
from typing import Optional, Dict, Any
|
||||
from flask import current_app
|
||||
|
||||
|
||||
class TokenVerificationError(Exception):
|
||||
"""Token verification failed"""
|
||||
pass
|
||||
|
||||
|
||||
def verify_external_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify bearer token with external IndieAuth provider
|
||||
|
||||
Makes a GET request to the token endpoint with Authorization header.
|
||||
The external provider returns token info if valid, or error if invalid.
|
||||
|
||||
Args:
|
||||
token: Bearer token to verify
|
||||
|
||||
Returns:
|
||||
Dict with token info (me, client_id, scope) if valid
|
||||
None if token is invalid or verification fails
|
||||
|
||||
Token info dict contains:
|
||||
me: User's profile URL
|
||||
client_id: Client application URL
|
||||
scope: Space-separated list of scopes
|
||||
"""
|
||||
token_endpoint = current_app.config.get("TOKEN_ENDPOINT")
|
||||
admin_me = current_app.config.get("ADMIN_ME")
|
||||
|
||||
if not token_endpoint:
|
||||
current_app.logger.error(
|
||||
"TOKEN_ENDPOINT not configured. Cannot verify external tokens."
|
||||
)
|
||||
return None
|
||||
|
||||
if not admin_me:
|
||||
current_app.logger.error(
|
||||
"ADMIN_ME not configured. Cannot verify token ownership."
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
# Verify token with external provider
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json",
|
||||
}
|
||||
|
||||
current_app.logger.debug(
|
||||
f"Verifying token with external provider: {token_endpoint}"
|
||||
)
|
||||
|
||||
response = httpx.get(
|
||||
token_endpoint,
|
||||
headers=headers,
|
||||
timeout=5.0,
|
||||
follow_redirects=True,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
current_app.logger.warning(
|
||||
f"Token verification failed: HTTP {response.status_code}"
|
||||
)
|
||||
return None
|
||||
|
||||
token_info = response.json()
|
||||
|
||||
# Validate required fields
|
||||
if "me" not in token_info:
|
||||
current_app.logger.warning("Token response missing 'me' field")
|
||||
return None
|
||||
|
||||
# Verify token belongs to site owner
|
||||
token_me = token_info["me"].rstrip("/")
|
||||
expected_me = admin_me.rstrip("/")
|
||||
|
||||
if token_me != expected_me:
|
||||
current_app.logger.warning(
|
||||
f"Token 'me' mismatch: {token_me} != {expected_me}"
|
||||
)
|
||||
return None
|
||||
|
||||
current_app.logger.debug(f"Token verified successfully for {token_me}")
|
||||
return token_info
|
||||
|
||||
except httpx.TimeoutException:
|
||||
current_app.logger.error(
|
||||
f"Token verification timeout for {token_endpoint}"
|
||||
)
|
||||
return None
|
||||
|
||||
except httpx.RequestError as e:
|
||||
current_app.logger.error(
|
||||
f"Token verification request failed: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(
|
||||
f"Unexpected error during token verification: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def check_scope(required_scope: str, token_scope: str) -> bool:
|
||||
"""
|
||||
Check if token has required scope
|
||||
|
||||
Scopes are space-separated in token_scope string.
|
||||
Any scope in the list satisfies the requirement.
|
||||
|
||||
Args:
|
||||
required_scope: Scope needed (e.g., "create")
|
||||
token_scope: Space-separated scope string from token
|
||||
|
||||
Returns:
|
||||
True if token has required scope, False otherwise
|
||||
|
||||
Examples:
|
||||
>>> check_scope("create", "create update")
|
||||
True
|
||||
>>> check_scope("create", "read")
|
||||
False
|
||||
>>> check_scope("create", "")
|
||||
False
|
||||
"""
|
||||
if not token_scope:
|
||||
return False
|
||||
|
||||
scopes = token_scope.split()
|
||||
return required_scope in scopes
|
||||
@@ -36,6 +36,9 @@ def load_config(app, config_override=None):
|
||||
app.config["SESSION_LIFETIME"] = int(os.getenv("SESSION_LIFETIME", "30"))
|
||||
app.config["INDIELOGIN_URL"] = os.getenv("INDIELOGIN_URL", "https://indielogin.com")
|
||||
|
||||
# External IndieAuth token verification (Phase 4: ADR-030)
|
||||
app.config["TOKEN_ENDPOINT"] = os.getenv("TOKEN_ENDPOINT", "")
|
||||
|
||||
# Validate required configuration
|
||||
if not app.config["SESSION_SECRET"]:
|
||||
raise ValueError(
|
||||
|
||||
@@ -74,10 +74,9 @@ CREATE TABLE IF NOT EXISTS authorization_codes (
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_hash ON authorization_codes(code_hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_auth_codes_expires ON authorization_codes(expires_at);
|
||||
|
||||
-- CSRF state tokens (for IndieAuth flow)
|
||||
-- CSRF state tokens (for admin login flow)
|
||||
CREATE TABLE IF NOT EXISTS auth_state (
|
||||
state TEXT PRIMARY KEY,
|
||||
code_verifier TEXT NOT NULL DEFAULT '',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP NOT NULL,
|
||||
redirect_uri TEXT
|
||||
|
||||
@@ -29,7 +29,7 @@ from typing import Optional
|
||||
from flask import Request, current_app, jsonify
|
||||
|
||||
from starpunk.notes import create_note, get_note, InvalidNoteDataError, NoteNotFoundError
|
||||
from starpunk.tokens import check_scope
|
||||
from starpunk.auth_external import check_scope
|
||||
|
||||
|
||||
# Custom Exceptions
|
||||
|
||||
@@ -53,7 +53,7 @@ def is_schema_current(conn):
|
||||
|
||||
Uses heuristic: Check for presence of latest schema features
|
||||
Checks for:
|
||||
- code_verifier column in auth_state (migration 001 or SCHEMA_SQL >= v0.8.0)
|
||||
- code_verifier column NOT in auth_state (removed in migration 003)
|
||||
- authorization_codes table (migration 002 or SCHEMA_SQL >= v1.0.0-rc.1)
|
||||
- token_hash column in tokens table (migration 002)
|
||||
- Token indexes (migration 002 only, removed from SCHEMA_SQL in v1.0.0-rc.2)
|
||||
@@ -66,9 +66,9 @@ def is_schema_current(conn):
|
||||
False if any piece is missing (legacy database needing migrations)
|
||||
"""
|
||||
try:
|
||||
# Check for code_verifier column in auth_state (migration 001)
|
||||
# This is also in SCHEMA_SQL, so we can't use it alone
|
||||
if not column_exists(conn, 'auth_state', 'code_verifier'):
|
||||
# Check for code_verifier column NOT in auth_state (removed in migration 003)
|
||||
# If it still exists, schema is outdated
|
||||
if column_exists(conn, 'auth_state', 'code_verifier'):
|
||||
return False
|
||||
|
||||
# Check for authorization_codes table (added in migration 002)
|
||||
@@ -210,6 +210,11 @@ def is_migration_needed(conn, migration_name):
|
||||
# All features exist - migration not needed
|
||||
return False
|
||||
|
||||
# Migration 003: Removes code_verifier column from auth_state
|
||||
if migration_name == "003_remove_code_verifier_from_auth_state.sql":
|
||||
# Check if column still exists (should be removed)
|
||||
return column_exists(conn, 'auth_state', 'code_verifier')
|
||||
|
||||
# Unknown migration - assume it's needed
|
||||
return True
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -28,7 +28,7 @@ from starpunk.micropub import (
|
||||
handle_create,
|
||||
handle_query,
|
||||
)
|
||||
from starpunk.tokens import verify_token
|
||||
from starpunk.auth_external import verify_external_token
|
||||
|
||||
# Create blueprint
|
||||
bp = Blueprint("micropub", __name__)
|
||||
@@ -71,7 +71,7 @@ def micropub_endpoint():
|
||||
if not token:
|
||||
return error_response("unauthorized", "No access token provided", 401)
|
||||
|
||||
token_info = verify_token(token)
|
||||
token_info = verify_external_token(token)
|
||||
if not token_info:
|
||||
return error_response("unauthorized", "Invalid or expired access token", 401)
|
||||
|
||||
|
||||
@@ -1,412 +0,0 @@
|
||||
"""
|
||||
Token management for Micropub IndieAuth integration
|
||||
|
||||
Handles:
|
||||
- Access token generation and verification
|
||||
- Authorization code generation and exchange
|
||||
- Token hashing for secure storage (SHA256)
|
||||
- Scope validation
|
||||
- Token expiry management
|
||||
|
||||
Security:
|
||||
- Tokens stored as SHA256 hashes (never plain text)
|
||||
- Authorization codes use single-use pattern with replay protection
|
||||
- Optional PKCE support for enhanced security
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, Dict, Any
|
||||
from flask import current_app
|
||||
|
||||
|
||||
# V1 supported scopes
|
||||
SUPPORTED_SCOPES = ["create"]
|
||||
DEFAULT_SCOPE = "create"
|
||||
|
||||
# Token and code expiry defaults
|
||||
TOKEN_EXPIRY_DAYS = 90
|
||||
AUTH_CODE_EXPIRY_MINUTES = 10
|
||||
|
||||
|
||||
class TokenError(Exception):
|
||||
"""Base exception for token-related errors"""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidTokenError(TokenError):
|
||||
"""Raised when token is invalid or expired"""
|
||||
pass
|
||||
|
||||
|
||||
class InvalidAuthorizationCodeError(TokenError):
|
||||
"""Raised when authorization code is invalid, expired, or already used"""
|
||||
pass
|
||||
|
||||
|
||||
def generate_token() -> str:
|
||||
"""
|
||||
Generate a cryptographically secure random token
|
||||
|
||||
Returns:
|
||||
URL-safe base64-encoded random token (43 characters)
|
||||
"""
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
"""
|
||||
Generate SHA256 hash of token for secure storage
|
||||
|
||||
Args:
|
||||
token: Plain text token
|
||||
|
||||
Returns:
|
||||
Hexadecimal SHA256 hash
|
||||
"""
|
||||
return hashlib.sha256(token.encode()).hexdigest()
|
||||
|
||||
|
||||
def create_access_token(me: str, client_id: str, scope: str) -> str:
|
||||
"""
|
||||
Create and store an access token in the database
|
||||
|
||||
Args:
|
||||
me: User's identity URL
|
||||
client_id: Client application URL
|
||||
scope: Space-separated list of scopes
|
||||
|
||||
Returns:
|
||||
Plain text access token (return to client, never logged or stored)
|
||||
|
||||
Raises:
|
||||
TokenError: If token creation fails
|
||||
"""
|
||||
# Generate token
|
||||
token = generate_token()
|
||||
token_hash_value = hash_token(token)
|
||||
|
||||
# Calculate expiry
|
||||
# Use UTC to match SQLite's datetime('now') which returns UTC
|
||||
expires_at = (datetime.utcnow() + timedelta(days=TOKEN_EXPIRY_DAYS)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Store in database
|
||||
from starpunk.database import get_db
|
||||
|
||||
try:
|
||||
db = get_db(current_app)
|
||||
db.execute("""
|
||||
INSERT INTO tokens (token_hash, me, client_id, scope, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""", (token_hash_value, me, client_id, scope, expires_at))
|
||||
db.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
f"Created access token for client_id={client_id}, scope={scope}"
|
||||
)
|
||||
|
||||
return token
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to create access token: {e}")
|
||||
raise TokenError(f"Failed to create access token: {e}")
|
||||
|
||||
|
||||
def verify_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Verify an access token and return token information
|
||||
|
||||
Args:
|
||||
token: Plain text token to verify
|
||||
|
||||
Returns:
|
||||
Dictionary with token info: {me, client_id, scope}
|
||||
None if token is invalid, expired, or revoked
|
||||
"""
|
||||
if not token:
|
||||
return None
|
||||
|
||||
# Hash the token for lookup
|
||||
token_hash_value = hash_token(token)
|
||||
|
||||
from starpunk.database import get_db
|
||||
|
||||
try:
|
||||
db = get_db(current_app)
|
||||
row = db.execute("""
|
||||
SELECT me, client_id, scope, id
|
||||
FROM tokens
|
||||
WHERE token_hash = ?
|
||||
AND expires_at > datetime('now')
|
||||
AND revoked_at IS NULL
|
||||
""", (token_hash_value,)).fetchone()
|
||||
|
||||
if row:
|
||||
# Update last_used_at
|
||||
db.execute("""
|
||||
UPDATE tokens
|
||||
SET last_used_at = datetime('now')
|
||||
WHERE id = ?
|
||||
""", (row['id'],))
|
||||
db.commit()
|
||||
|
||||
return {
|
||||
'me': row['me'],
|
||||
'client_id': row['client_id'],
|
||||
'scope': row['scope']
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Token verification failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def revoke_token(token: str) -> bool:
|
||||
"""
|
||||
Revoke an access token (soft deletion)
|
||||
|
||||
Args:
|
||||
token: Plain text token to revoke
|
||||
|
||||
Returns:
|
||||
True if token was revoked, False if not found
|
||||
"""
|
||||
token_hash_value = hash_token(token)
|
||||
|
||||
from starpunk.database import get_db
|
||||
|
||||
try:
|
||||
db = get_db(current_app)
|
||||
cursor = db.execute("""
|
||||
UPDATE tokens
|
||||
SET revoked_at = datetime('now')
|
||||
WHERE token_hash = ?
|
||||
AND revoked_at IS NULL
|
||||
""", (token_hash_value,))
|
||||
db.commit()
|
||||
|
||||
return cursor.rowcount > 0
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Token revocation failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def create_authorization_code(
|
||||
me: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
scope: str = "",
|
||||
state: Optional[str] = None,
|
||||
code_challenge: Optional[str] = None,
|
||||
code_challenge_method: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Create and store an authorization code for token exchange
|
||||
|
||||
Args:
|
||||
me: User's identity URL
|
||||
client_id: Client application URL
|
||||
redirect_uri: Client's redirect URI (must match during exchange)
|
||||
scope: Space-separated list of requested scopes (can be empty)
|
||||
state: Client's state parameter (optional)
|
||||
code_challenge: PKCE code challenge (optional)
|
||||
code_challenge_method: PKCE method, typically 'S256' (optional)
|
||||
|
||||
Returns:
|
||||
Plain text authorization code (return to client)
|
||||
|
||||
Raises:
|
||||
TokenError: If code creation fails
|
||||
"""
|
||||
# Generate authorization code
|
||||
code = generate_token()
|
||||
code_hash_value = hash_token(code)
|
||||
|
||||
# Calculate expiry (short-lived)
|
||||
# Use UTC to match SQLite's datetime('now') which returns UTC
|
||||
expires_at = (datetime.utcnow() + timedelta(minutes=AUTH_CODE_EXPIRY_MINUTES)).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
# Store in database
|
||||
from starpunk.database import get_db
|
||||
|
||||
try:
|
||||
db = get_db(current_app)
|
||||
db.execute("""
|
||||
INSERT INTO authorization_codes (
|
||||
code_hash, me, client_id, redirect_uri, scope, state,
|
||||
code_challenge, code_challenge_method, expires_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", (
|
||||
code_hash_value, me, client_id, redirect_uri, scope, state,
|
||||
code_challenge, code_challenge_method, expires_at
|
||||
))
|
||||
db.commit()
|
||||
|
||||
current_app.logger.info(
|
||||
f"Created authorization code for client_id={client_id}, scope={scope}"
|
||||
)
|
||||
|
||||
return code
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to create authorization code: {e}")
|
||||
raise TokenError(f"Failed to create authorization code: {e}")
|
||||
|
||||
|
||||
def exchange_authorization_code(
|
||||
code: str,
|
||||
client_id: str,
|
||||
redirect_uri: str,
|
||||
me: str,
|
||||
code_verifier: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Exchange authorization code for access token
|
||||
|
||||
Args:
|
||||
code: Authorization code to exchange
|
||||
client_id: Client application URL (must match original request)
|
||||
redirect_uri: Redirect URI (must match original request)
|
||||
me: User's identity URL (must match original request)
|
||||
code_verifier: PKCE verifier (required if code_challenge was provided)
|
||||
|
||||
Returns:
|
||||
Dictionary with: {me, client_id, scope}
|
||||
|
||||
Raises:
|
||||
InvalidAuthorizationCodeError: If code is invalid, expired, used, or validation fails
|
||||
"""
|
||||
if not code:
|
||||
raise InvalidAuthorizationCodeError("No authorization code provided")
|
||||
|
||||
code_hash_value = hash_token(code)
|
||||
|
||||
from starpunk.database import get_db
|
||||
|
||||
try:
|
||||
db = get_db(current_app)
|
||||
|
||||
# Look up authorization code
|
||||
row = db.execute("""
|
||||
SELECT me, client_id, redirect_uri, scope, code_challenge,
|
||||
code_challenge_method, used_at
|
||||
FROM authorization_codes
|
||||
WHERE code_hash = ?
|
||||
AND expires_at > datetime('now')
|
||||
""", (code_hash_value,)).fetchone()
|
||||
|
||||
if not row:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"Authorization code is invalid or expired"
|
||||
)
|
||||
|
||||
# Check if already used (prevent replay attacks)
|
||||
if row['used_at']:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"Authorization code has already been used"
|
||||
)
|
||||
|
||||
# Validate parameters match original authorization request
|
||||
if row['client_id'] != client_id:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"client_id does not match authorization request"
|
||||
)
|
||||
|
||||
if row['redirect_uri'] != redirect_uri:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"redirect_uri does not match authorization request"
|
||||
)
|
||||
|
||||
if row['me'] != me:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"me parameter does not match authorization request"
|
||||
)
|
||||
|
||||
# Validate PKCE if code_challenge was provided
|
||||
if row['code_challenge']:
|
||||
if not code_verifier:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"code_verifier required (PKCE was used during authorization)"
|
||||
)
|
||||
|
||||
# Verify PKCE challenge
|
||||
if row['code_challenge_method'] == 'S256':
|
||||
# SHA256 hash of verifier
|
||||
computed_challenge = hashlib.sha256(
|
||||
code_verifier.encode()
|
||||
).hexdigest()
|
||||
else:
|
||||
# Plain (not recommended, but spec allows it)
|
||||
computed_challenge = code_verifier
|
||||
|
||||
if computed_challenge != row['code_challenge']:
|
||||
raise InvalidAuthorizationCodeError(
|
||||
"code_verifier does not match code_challenge"
|
||||
)
|
||||
|
||||
# Mark code as used
|
||||
db.execute("""
|
||||
UPDATE authorization_codes
|
||||
SET used_at = datetime('now')
|
||||
WHERE code_hash = ?
|
||||
""", (code_hash_value,))
|
||||
db.commit()
|
||||
|
||||
# Return authorization info for token creation
|
||||
return {
|
||||
'me': row['me'],
|
||||
'client_id': row['client_id'],
|
||||
'scope': row['scope']
|
||||
}
|
||||
|
||||
except InvalidAuthorizationCodeError:
|
||||
# Re-raise validation errors
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Authorization code exchange failed: {e}")
|
||||
raise InvalidAuthorizationCodeError(f"Code exchange failed: {e}")
|
||||
|
||||
|
||||
def validate_scope(requested_scope: str) -> str:
|
||||
"""
|
||||
Validate and filter requested scopes to supported ones
|
||||
|
||||
Args:
|
||||
requested_scope: Space-separated list of requested scopes
|
||||
|
||||
Returns:
|
||||
Space-separated list of valid scopes (may be empty)
|
||||
"""
|
||||
if not requested_scope:
|
||||
return ""
|
||||
|
||||
requested = set(requested_scope.split())
|
||||
supported = set(SUPPORTED_SCOPES)
|
||||
valid_scopes = requested & supported
|
||||
|
||||
return " ".join(sorted(valid_scopes)) if valid_scopes else ""
|
||||
|
||||
|
||||
def check_scope(required: str, granted: str) -> bool:
|
||||
"""
|
||||
Check if granted scopes include required scope
|
||||
|
||||
Args:
|
||||
required: Required scope (single scope string)
|
||||
granted: Granted scopes (space-separated string)
|
||||
|
||||
Returns:
|
||||
True if required scope is in granted scopes
|
||||
"""
|
||||
if not granted:
|
||||
# IndieAuth spec: no scope means no access
|
||||
return False
|
||||
|
||||
granted_scopes = set(granted.split())
|
||||
return required in granted_scopes
|
||||
Reference in New Issue
Block a user