Files
StarPunk/starpunk/routes/auth.py
Phil Skentelbery 2bd971f3d6 fix(auth): Implement IndieAuth endpoint discovery per W3C spec
BREAKING: Removes INDIELOGIN_URL config - endpoints are now properly
discovered from user's profile URL as required by W3C IndieAuth spec.

- auth.py: Uses discover_endpoints() to find authorization_endpoint
- config.py: Deprecation warning for obsolete INDIELOGIN_URL setting
- auth_external.py: Relaxed validation (allows auth-only flows)
- tests: Updated to mock endpoint discovery

This fixes a regression where admin login was hardcoded to use
indielogin.com instead of respecting the user's declared endpoints.

Version: 1.5.0-hotfix.1

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-17 13:52:36 -07:00

194 lines
5.5 KiB
Python

"""
Authentication routes for StarPunk
Handles IndieLogin authentication flow including login form, OAuth callback,
logout functionality, and IndieAuth endpoints for Micropub clients.
"""
from flask import (
Blueprint,
current_app,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from starpunk.auth import (
IndieLoginError,
InvalidStateError,
UnauthorizedError,
destroy_session,
handle_callback,
initiate_login,
require_auth,
verify_session,
)
from starpunk.auth_external import DiscoveryError
# Create blueprint
bp = Blueprint("auth", __name__, url_prefix="/auth")
@bp.route("/login", methods=["GET"])
def login_form():
"""
Display login form
If user is already authenticated, redirects to admin dashboard.
Otherwise shows login form for IndieLogin authentication.
Returns:
Redirect to dashboard if authenticated, otherwise login template
Template: templates/admin/login.html
"""
# Check if already logged in
session_token = request.cookies.get("starpunk_session")
if session_token and verify_session(session_token):
return redirect(url_for("admin.dashboard"))
return render_template("admin/login.html")
@bp.route("/login", methods=["POST"])
def login_initiate():
"""
Initiate IndieLogin authentication flow
Validates the submitted 'me' URL and redirects to IndieLogin.com
for authentication.
Form data:
me: User's personal website URL
Returns:
Redirect to IndieLogin.com or back to login form on error
Raises:
Flashes error message and redirects on validation failure
"""
me_url = request.form.get("me", "").strip()
if not me_url:
flash("Please enter your website URL", "error")
return redirect(url_for("auth.login_form"))
try:
# Initiate IndieAuth flow
auth_url = initiate_login(me_url)
return redirect(auth_url)
except ValueError as e:
flash(str(e), "error")
return redirect(url_for("auth.login_form"))
except DiscoveryError as e:
current_app.logger.error(f"Endpoint discovery failed for {me_url}: {e}")
flash("Unable to verify your profile URL. Please check that it's correct and try again.", "error")
return redirect(url_for("auth.login_form"))
@bp.route("/callback")
def callback():
"""
Handle IndieLogin callback
Processes the OAuth callback from IndieLogin.com, validates the
authorization code, state token, and issuer, then creates an
authenticated session using PKCE verification.
Query parameters:
code: Authorization code from IndieLogin
state: CSRF state token
iss: Issuer identifier (should be https://indielogin.com/)
Returns:
Redirect to admin dashboard on success, login form on failure
Sets:
session cookie (HttpOnly, Secure, SameSite=Lax, 30 day expiry)
"""
code = request.args.get("code")
state = request.args.get("state")
iss = request.args.get("iss") # Extract issuer parameter
if not code or not state:
flash("Missing authentication parameters", "error")
return redirect(url_for("auth.login_form"))
try:
# Handle callback and create session with PKCE verification
session_token = handle_callback(code, state, iss) # Pass issuer
# Create response with redirect
response = redirect(url_for("admin.dashboard"))
# Set secure session cookie
secure = current_app.config.get("SITE_URL", "").startswith("https://")
response.set_cookie(
"starpunk_session",
session_token,
httponly=True,
secure=secure,
samesite="Lax",
max_age=30 * 24 * 60 * 60, # 30 days
)
flash("Login successful!", "success")
return response
except InvalidStateError as e:
current_app.logger.error(f"Invalid state error: {e}")
flash("Authentication failed: Invalid state token (possible CSRF)", "error")
return redirect(url_for("auth.login_form"))
except UnauthorizedError as e:
current_app.logger.error(f"Unauthorized: {e}")
flash("Authentication failed: Not authorized as admin", "error")
return redirect(url_for("auth.login_form"))
except IndieLoginError as e:
current_app.logger.error(f"IndieLogin error: {e}")
flash(f"Authentication failed: {e}", "error")
return redirect(url_for("auth.login_form"))
except Exception as e:
current_app.logger.error(f"Unexpected auth error: {e}")
flash("Authentication failed: An unexpected error occurred", "error")
return redirect(url_for("auth.login_form"))
@bp.route("/logout", methods=["POST"])
@require_auth
def logout():
"""
Logout and destroy session
Destroys the user's session and clears the session cookie.
Requires authentication (user must be logged in to logout).
Returns:
Redirect to homepage with session cookie cleared
Decorator: @require_auth
"""
session_token = request.cookies.get("starpunk_session")
# Destroy session in database
if session_token:
try:
destroy_session(session_token)
except Exception as e:
current_app.logger.error(f"Error destroying session: {e}")
# Clear cookie and redirect
response = redirect(url_for("public.index"))
response.delete_cookie("starpunk_session")
flash("Logged out successfully", "success")
return response