Provide user info in Remote-* headers. Caddy configuration improved.

This commit is contained in:
Leo Vasanko
2025-09-25 18:12:40 -06:00
parent b0a1bb72dc
commit e514ae010d
9 changed files with 109 additions and 51 deletions

11
API.md
View File

@@ -9,6 +9,17 @@ This document describes all API endpoints available in the PassKey Auth FastAPI
### HTTP Endpoints
GET /auth/ - Main authentication app
GET /auth/api/forward - Authentication validation for Caddy/Nginx (was /auth/forward-auth)
- On success returns `204 No Content` with the following headers:
- `Remote-User`: authenticated user UUID
- `Remote-Name`: display name
- `Remote-Groups`: comma-separated permission IDs (no spaces)
- `Remote-Org`: organization UUID
- `Remote-Org-Name`: organization display name
- `Remote-Role`: role UUID
- `Remote-Role-Name`: role display name
- `Remote-Session-Expires`: session expiry timestamp (ISO 8601)
- `Remote-Session-Type` (optional): session type metadata when available
- `Remote-Credential` (optional): credential UUID backing the session
POST /auth/validate - Token validation endpoint
POST /auth/user-info - Get authenticated user information
POST /auth/logout - Logout current user

View File

@@ -1,35 +0,0 @@
(auth) {
# Permission check (named arg: perm=...)
forward_auth localhost:4401 {
uri /auth/api/forward?{args.0}
copy_headers x-auth-*
}
}
localhost {
# Single definition for auth service endpoints (avoid duplicate matcher names)
@auth_api path /auth/*
handle @auth_api {
reverse_proxy localhost:4401
}
# Admin-protected paths
handle_path /admin/* {
import auth perm=auth:admin
# Respond with a message for the admin area
respond "Admin area (protected)" 200
}
# Reports-protected paths
handle_path /reports/* {
import auth perm=reports:view
# Respond with a message for the reports area
respond "Reports area (protected)" 200
}
# Unprotected (fallback)
handle {
# Respond with a public content message
respond "Public content" 200
}
}

30
caddy/Caddyfile Normal file
View File

@@ -0,0 +1,30 @@
localhost {
import auth/setup
# Only users with myapp:reports and auth admin permissions
handle_path /reports {
import auth/require perm=myapp:reports&perm=auth:admin
respond "Reports area (protected) for {http.request.header.remote-org-name}" 200
}
# Public paths (no auth)
@public path /favicon.ico /.well-known/*
handle @public {
reverse_proxy :3000
}
# Respond with user's display name
handle_path /hello {
import auth/require ""
respond "Hello, {http.request.header.remote-name}! Your permissions: {http.request.header.remote-groups}" 200
}
# Default route, requires authentication but no authorization
handle {
import auth/require ""
reverse_proxy :3000
}
}
localhost:4404 {
# Full site protected, /auth/ reserved for auth service
import auth/all perm=auth:admin {
reverse_proxy :3000
}
}

6
caddy/auth/all Normal file
View File

@@ -0,0 +1,6 @@
# Enable auth site at /auth (setup) and require authentication on all paths
import setup
handle {
import require {args[0]}
{block}
}

17
caddy/auth/require Normal file
View File

@@ -0,0 +1,17 @@
# Permission to use within your endpoints that need authentication/authorization, that
# is different depending on the route (otherwise use auth/all).
forward_auth {$AUTH_UPSTREAM:localhost:4401} {
uri /auth/api/forward?{args[0]}
copy_headers {
Remote-User
Remote-Name
Remote-Groups
Remote-Org
Remote-Org-Name
Remote-Role
Remote-Role-Name
Remote-Session-Expires
Remote-Session-Type
Remote-Credential
}
}

6
caddy/auth/setup Normal file
View File

@@ -0,0 +1,6 @@
# Setup auth service at /auth/ and remove any Remote-* headers sent by client (for security)
header -Remote-*
@auth_api path /auth /auth/*
handle @auth_api {
reverse_proxy {$AUTH_UPSTREAM:localhost:4401}
}

View File

@@ -1,8 +1,8 @@
import logging
from uuid import UUID, uuid4
from fastapi import Body, Cookie, FastAPI, HTTPException, Request
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi import Body, Cookie, FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse
from ..authsession import expires
from ..globals import db
@@ -24,12 +24,6 @@ async def general_exception_handler(_request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
@app.get("")
def adminapp_slashmissing(request: Request):
print("HERE")
return RedirectResponse(url=request.url_for("adminapp"))
@app.get("/")
async def adminapp(auth=Cookie(None)):
try:

View File

@@ -12,9 +12,11 @@ from fastapi import (
Request,
Response,
)
from fastapi.responses import JSONResponse
from fastapi.responses import FileResponse, JSONResponse
from fastapi.security import HTTPBearer
from passkey.util import frontend
from .. import aaguid
from ..authsession import delete_credential, expires, get_reset, get_session
from ..globals import db
@@ -49,21 +51,42 @@ async def validate_token(perm: list[str] = Query([]), auth=Cookie(None)):
@app.get("/forward")
async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)):
"""Forward auth validation for Caddy/Nginx (moved from /auth/forward-auth).
"""Forward auth validation for Caddy/Nginx.
Query Params:
- perm: repeated permission IDs the authenticated user must possess (ALL required).
Success: 204 No Content with x-auth-user-uuid header.
Success: 204 No Content with Remote-* headers describing the authenticated user.
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
"""
try:
ctx = await authz.verify(auth, perm)
return Response(
status_code=204, headers={"x-auth-user-uuid": str(ctx.session.user_uuid)}
)
except HTTPException as e: # pass through explicitly
raise e
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
session_info = ctx.session.info or {}
remote_headers: dict[str, str] = {
"Remote-User": str(ctx.user.uuid),
"Remote-Name": ctx.user.display_name,
"Remote-Groups": ",".join(sorted(role_permissions)),
"Remote-Org": str(ctx.org.uuid),
"Remote-Org-Name": ctx.org.display_name,
"Remote-Role": str(ctx.role.uuid),
"Remote-Role-Name": ctx.role.display_name,
"Remote-Session-Expires": ctx.session.expires.isoformat(),
}
session_type = session_info.get("type")
if session_type:
remote_headers["Remote-Session-Type"] = str(session_type)
if ctx.session.credential_uuid:
remote_headers["Remote-Credential"] = str(ctx.session.credential_uuid)
return Response(status_code=204, headers=remote_headers)
except HTTPException as e:
return FileResponse(frontend.file("index.html"), status_code=e.status_code)
@app.get("/settings")

View File

@@ -54,6 +54,12 @@ app.mount(
)
@app.get("/")
async def frontapp_redirect(request: Request):
"""Redirect root (in case accessed on backend) to the main authentication app."""
return RedirectResponse(request.url_for("frontapp"), status_code=303)
@app.get("/auth/")
async def frontapp():
"""Serve the main authentication app."""