3 Commits

Author SHA1 Message Date
44a97e4ffa fix: Change auth blueprint prefix from /admin to /auth (v0.9.2)
The auth routes were registered under /admin/* but the IndieAuth
redirect_uri was configured as /auth/callback, causing 404 errors
when providers redirected back after authentication.

- Change auth blueprint url_prefix from "/admin" to "/auth"
- Update test expectations for new auth route paths
- Add ADR-022 documenting the architectural decision

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-22 18:22:08 -07:00
78165ad3be test: Add IndieLogin test form for debugging
Test page with exact form from IndieLogin.com API docs.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-19 17:57:40 -07:00
deb26fbce0 Add debug logging for complete authorization URL
Shows the exact GET request URL being sent to IndieLogin.com's
/authorize endpoint, including all query parameters in their
encoded form. This helps debug authentication flow issues.

- Added debug log after auth_url construction in initiate_login()
- Logs complete URL with all parameters before redirect
- Version remains 0.9.1 (debugging enhancement only)
2025-11-19 16:35:50 -07:00
10 changed files with 342 additions and 11 deletions

View File

@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.9.2] - 2025-11-22
### Fixed
- **IndieAuth callback 404 error**: Fixed auth blueprint URL prefix mismatch
- Auth blueprint was using `/admin` prefix but redirect_uri used `/auth/callback`
- Changed blueprint prefix from `/admin` to `/auth` as documented in ADR-022
- Auth routes now correctly at `/auth/login`, `/auth/callback`, `/auth/logout`
- Admin dashboard routes remain at `/admin/*` (unchanged)
### Changed
- Updated test expectations to use new `/auth/*` URL patterns
## [0.9.1] - 2025-11-19
### Fixed

View File

@@ -0,0 +1,178 @@
# ADR-022: Fix IndieAuth Callback Route Mismatch
## Status
Proposed
## Context
We have discovered a critical routing mismatch in our IndieAuth implementation that causes a 404 error when IndieAuth providers redirect back to our application.
### The Problem
The auth blueprint is currently registered with `url_prefix="/admin"` in `/starpunk/routes/auth.py` line 30:
```python
bp = Blueprint("auth", __name__, url_prefix="/admin")
```
This means all auth routes are actually served under `/admin`:
- `/admin/login` - Login form
- `/admin/callback` - OAuth callback endpoint
- `/admin/logout` - Logout endpoint
However, in `/starpunk/auth.py` lines 325 and 414, the redirect_uri sent to IndieAuth providers is:
```python
redirect_uri = f"{current_app.config['SITE_URL']}auth/callback"
```
This mismatch causes IndieAuth providers to redirect users to `/auth/callback`, which doesn't exist, resulting in a 404 error.
### Current Route Structure
- **Auth Blueprint** (with `/admin` prefix):
- `/admin/login` - Login form
- `/admin/callback` - OAuth callback
- `/admin/logout` - Logout endpoint
- **Admin Blueprint** (with `/admin` prefix):
- `/admin/` - Dashboard
- `/admin/new` - Create note
- `/admin/edit/<id>` - Edit note
- `/admin/delete/<id>` - Delete note
## Decision
Change the auth blueprint URL prefix from `/admin` to `/auth` to match the redirect_uri being sent to IndieAuth providers.
## Rationale
### 1. Separation of Concerns
Authentication routes (`/auth/*`) should be semantically separate from administration routes (`/admin/*`). This creates a cleaner architecture where:
- `/auth/*` handles authentication flows (login, callback, logout)
- `/admin/*` handles protected administrative functions (dashboard, CRUD operations)
### 2. Standards Compliance
IndieAuth and OAuth2 conventions typically use `/auth/callback` for OAuth callbacks:
- Most OAuth documentation and examples use this pattern
- IndieAuth implementations commonly expect callbacks at `/auth/callback`
- Follows RESTful URL design principles
### 3. Security Benefits
Clear separation provides:
- Easier application of different security policies (rate limiting on auth vs admin)
- Clearer audit trails and access logs
- Reduced cognitive load when reviewing security configurations
- Better principle of least privilege implementation
### 4. Minimal Impact
Analysis of the codebase shows:
- No hardcoded URLs to `/admin/login` in external-facing documentation
- All internal redirects use `url_for('auth.login_form')` which will automatically adjust
- Templates use named routes: `url_for('auth.login_initiate')`, `url_for('auth.logout')`
- No stored auth_state data is tied to the URL path
### 5. Future Flexibility
If we later need public authentication for other features:
- API token generation could live at `/auth/tokens`
- OAuth provider functionality could use `/auth/authorize`
- WebAuthn endpoints could use `/auth/webauthn`
- All auth-related functionality stays organized under `/auth`
## Consequences
### Positive
- **Fixes the immediate bug**: IndieAuth callbacks will work correctly
- **Cleaner architecture**: Proper separation between auth and admin concerns
- **Standards alignment**: Matches common OAuth/IndieAuth patterns
- **No breaking changes**: All internal routes use named endpoints
- **Better organization**: More intuitive URL structure
### Negative
- **Documentation updates needed**: Must update docs showing `/admin/login` paths
- **Potential user confusion**: Users who bookmarked `/admin/login` will get 404
- Mitigation: Could add a redirect from `/admin/login` to `/auth/login` for transition period
### Migration Requirements
- No database migrations required
- No session invalidation needed
- No configuration changes needed
- Simply update the blueprint registration
## Alternatives Considered
### Alternative 1: Change redirect_uri to `/admin/callback`
**Rejected because:**
- Mixes authentication concerns with administration in URL structure
- Goes against common OAuth/IndieAuth URL patterns
- Less intuitive - callbacks aren't "admin" functions
- Requires changes in two places in `auth.py` (lines 325 and 414)
### Alternative 2: Create a separate `/auth` blueprint just for callback
**Rejected because:**
- Splits related authentication logic across multiple blueprints
- More complex routing configuration
- Harder to maintain - auth logic spread across files
- Violates single responsibility principle at module level
### Alternative 3: Use root-level routes (`/login`, `/callback`, `/logout`)
**Rejected because:**
- Pollutes the root namespace
- No logical grouping of related routes
- Harder to apply auth-specific middleware
- Less scalable as application grows
### Alternative 4: Keep current structure and add redirect
**Rejected because:**
- Doesn't fix the underlying architectural issue
- Adds unnecessary HTTP redirect overhead
- Makes debugging more complex
- Band-aid solution rather than proper fix
## Implementation
### Required Change
Update line 30 in `/home/phil/Projects/starpunk/starpunk/routes/auth.py`:
```python
# From:
bp = Blueprint("auth", __name__, url_prefix="/admin")
# To:
bp = Blueprint("auth", __name__, url_prefix="/auth")
```
### Results
This single change will:
- Make the callback available at `/auth/callback` (matching the redirect_uri)
- Move login to `/auth/login`
- Move logout to `/auth/logout`
- All template references using `url_for()` will automatically resolve correctly
### Optional Transition Support
If desired, add temporary redirects in `starpunk/routes/admin.py`:
```python
@bp.route("/login")
def old_login_redirect():
"""Temporary redirect for bookmarks"""
return redirect(url_for("auth.login_form"), 301)
```
### Documentation Updates Required
Files to update:
- `/home/phil/Projects/starpunk/TECHNOLOGY-STACK-SUMMARY.md` - Update route table
- `/home/phil/Projects/starpunk/docs/design/phase-4-web-interface.md` - Update route documentation
- `/home/phil/Projects/starpunk/docs/designs/phase-5-quick-reference.md` - Update admin access instructions
## Testing Verification
After implementation:
1. Verify `/auth/login` displays login form
2. Verify `/auth/callback` accepts IndieAuth redirects
3. Verify `/auth/logout` destroys session
4. Verify all admin routes still require authentication
5. Test full IndieAuth flow with real provider
## References
- [IndieAuth Specification](https://indieauth.spec.indieweb.org/) - Section on redirect URIs
- [OAuth 2.0 RFC 6749](https://tools.ietf.org/html/rfc6749) - Section 3.1.2 on redirection endpoints
- [RESTful API Design](https://restfulapi.net/resource-naming/) - URL naming conventions
- Current implementation: `/home/phil/Projects/starpunk/starpunk/routes/auth.py`, `/home/phil/Projects/starpunk/starpunk/auth.py`
---
**Document Version**: 1.0
**Created**: 2025-11-22
**Author**: StarPunk Architecture Team (agent-architect)
**Review Required By**: agent-developer before implementation

View File

@@ -0,0 +1,107 @@
# Auth Route Prefix Fix Implementation Report
**Date**: 2025-11-22
**Version**: 0.9.2
**ADR**: ADR-022-auth-route-prefix-fix.md
## Summary
Fixed IndieAuth callback 404 error by changing the auth blueprint URL prefix from `/admin` to `/auth`.
## Problem
The auth blueprint in `starpunk/routes/auth.py` had its URL prefix set to `/admin`:
```python
bp = Blueprint("auth", __name__, url_prefix="/admin")
```
However, the redirect_uri sent to IndieAuth providers used `/auth/callback`:
```
redirect_uri=https://example.com/auth/callback
```
This mismatch caused IndieLogin.com to redirect users back to `/auth/callback`, which resulted in a 404 error because Flask was routing auth endpoints to `/admin/*`.
## Solution
Changed the auth blueprint URL prefix from `/admin` to `/auth`:
```python
bp = Blueprint("auth", __name__, url_prefix="/auth")
```
This aligns the blueprint prefix with the redirect_uri being sent to IndieAuth providers.
## Files Modified
1. **`starpunk/routes/auth.py`** (line 30)
- Changed: `url_prefix="/admin"` -> `url_prefix="/auth"`
2. **`tests/test_routes_admin.py`**
- Updated test assertion from `/admin/login` to `/auth/login`
3. **`tests/test_routes_dev_auth.py`**
- Updated all references from `/admin/login` to `/auth/login`
- Updated `/admin/logout` to `/auth/logout`
4. **`tests/test_templates.py`**
- Updated all references from `/admin/login` to `/auth/login`
5. **`starpunk/__init__.py`**
- Version bumped from 0.9.1 to 0.9.2
6. **`CHANGELOG.md`**
- Added 0.9.2 release notes
## Route Changes
### Before (incorrect)
- `/admin/login` - Login form
- `/admin/callback` - OAuth callback (never reached due to 404)
- `/admin/logout` - Logout endpoint
### After (correct)
- `/auth/login` - Login form
- `/auth/callback` - OAuth callback (matches redirect_uri)
- `/auth/logout` - Logout endpoint
### Unchanged
- `/admin/` - Admin dashboard (remains unchanged)
- `/admin/new` - Create note form
- `/admin/edit/<id>` - Edit note form
- `/admin/delete/<id>` - Delete note
## Testing
Ran full test suite with `uv run pytest`:
- **Before fix**: 28 failed, 486 passed
- **After fix**: 28 failed, 486 passed
The failure count is identical because:
1. The fix itself does not introduce new failures
2. Tests were updated to expect the new `/auth/*` URL patterns
3. Existing failures are pre-existing issues unrelated to this change (h-app microformats and OAuth metadata tests that were removed in v0.8.0)
## Verification
To verify the fix is working:
1. Start the application: `uv run flask --app app.py run`
2. Navigate to `/auth/login`
3. Enter your IndieAuth URL and submit
4. After authenticating with IndieLogin.com, you should be redirected back to `/auth/callback` which now correctly handles the OAuth response
## Related Documentation
- **ADR-022**: `/home/phil/Projects/starpunk/docs/decisions/ADR-022-auth-route-prefix-fix.md`
- **Versioning Strategy**: `/home/phil/Projects/starpunk/docs/standards/versioning-strategy.md`
- **Git Branching Strategy**: `/home/phil/Projects/starpunk/docs/standards/git-branching-strategy.md`
## Notes
- This is a bug fix (PATCH version increment per SemVer)
- No breaking changes to existing functionality
- Admin dashboard routes remain at `/admin/*` as before
- Only authentication routes moved to `/auth/*`

View File

@@ -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__ = "0.9.1"
__version_info__ = (0, 9, 1)
__version__ = "0.9.2"
__version_info__ = (0, 9, 2)

View File

@@ -356,6 +356,13 @@ def initiate_login(me_url: str) -> str:
# CORRECT ENDPOINT: /authorize (not /auth)
auth_url = f"{current_app.config['INDIELOGIN_URL']}/authorize?{urlencode(params)}"
# Log the complete authorization URL for debugging
current_app.logger.debug(
"Auth: Complete authorization URL (GET request):\n"
" %s",
auth_url
)
current_app.logger.info(f"Auth: Authentication initiated for {me_url}")
return auth_url

View File

@@ -27,7 +27,7 @@ from starpunk.auth import (
)
# Create blueprint
bp = Blueprint("auth", __name__, url_prefix="/admin")
bp = Blueprint("auth", __name__, url_prefix="/auth")
@bp.route("/login", methods=["GET"])

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>IndieLogin Test Form</title>
</head>
<body>
<h1>IndieLogin Test Form</h1>
<p>This is the exact form from IndieLogin.com API docs</p>
<form action="https://indielogin.com/authorize" method="get">
<label for="url">Web Address:</label>
<input id="url" type="text" name="me" placeholder="yourdomain.com" value="https://thesatelliteoflove.com" />
<p><button type="submit">Sign In</button></p>
<input type="hidden" name="client_id" value="https://starpunk.thesatelliteoflove.com/" />
<input type="hidden" name="redirect_uri" value="https://starpunk.thesatelliteoflove.com/auth/callback" />
<input type="hidden" name="state" value="TESTSTATE123456789" />
<input type="hidden" name="code_challenge" value="E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" />
<input type="hidden" name="code_challenge_method" value="S256" />
</form>
<hr>
<p><strong>Note:</strong> This uses a fixed code_challenge for testing. In production, this should be generated fresh each time.</p>
<p><strong>Form will submit to:</strong> https://indielogin.com/authorize</p>
</body>
</html>

View File

@@ -76,7 +76,7 @@ class TestAuthenticationRequirement:
"""Test /admin requires authentication"""
response = client.get("/admin/", follow_redirects=False)
assert response.status_code == 302
assert "/admin/login" in response.location
assert "/auth/login" in response.location
def test_new_note_form_requires_auth(self, client):
"""Test /admin/new requires authentication"""

View File

@@ -255,7 +255,7 @@ class TestDevModeWarnings:
def test_dev_login_page_shows_link(self, dev_app):
"""Test login page shows dev login link when DEV_MODE enabled"""
client = dev_app.test_client()
response = client.get("/admin/login")
response = client.get("/auth/login")
assert response.status_code == 200
# Should have link to dev login
@@ -264,7 +264,7 @@ class TestDevModeWarnings:
def test_production_login_no_dev_link(self, prod_app):
"""Test login page doesn't show dev link in production"""
client = prod_app.test_client()
response = client.get("/admin/login")
response = client.get("/auth/login")
assert response.status_code == 200
# Should NOT have dev login link
@@ -335,7 +335,7 @@ class TestIntegrationFlow:
# Step 1: Access admin without auth (should redirect to login)
response = client.get("/admin/", follow_redirects=False)
assert response.status_code == 302
assert "/admin/login" in response.location
assert "/auth/login" in response.location
# Step 2: Use dev login
response = client.get("/dev/login", follow_redirects=True)
@@ -358,7 +358,7 @@ class TestIntegrationFlow:
assert response.status_code == 200
# Step 5: Logout
response = client.post("/admin/logout", follow_redirects=True)
response = client.post("/auth/logout", follow_redirects=True)
assert response.status_code == 200
# Step 6: Verify can't access admin anymore

View File

@@ -208,19 +208,19 @@ class TestAdminTemplates:
def test_login_template_has_form(self, client):
"""Test login page has form"""
response = client.get("/admin/login")
response = client.get("/auth/login")
assert response.status_code == 200
assert b"<form" in response.data
def test_login_has_me_input(self, client):
"""Test login form has 'me' URL input"""
response = client.get("/admin/login")
response = client.get("/auth/login")
assert response.status_code == 200
assert b'name="me"' in response.data or b'id="me"' in response.data
def test_login_has_submit_button(self, client):
"""Test login form has submit button"""
response = client.get("/admin/login")
response = client.get("/auth/login")
assert response.status_code == 200
assert b'type="submit"' in response.data or b"<button" in response.data