Make auth/admin apps API calls use apiFetch, a new function that asks for permission by iframe if needed. Implement max-age checks for API authz.verify as well along with a custom exception type that carries metadata.

This commit is contained in:
Leo Vasanko
2025-12-03 11:17:02 -06:00
parent 7b0a9c2a2a
commit 1f75e0a305
15 changed files with 411 additions and 147 deletions
+45 -7
View File
@@ -28,6 +28,22 @@ async def value_error_handler(_request, exc: ValueError): # pragma: no cover -
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
)
@app.exception_handler(Exception)
async def general_exception_handler(_request, exc: Exception):
logging.exception("Unhandled exception in admin app")
@@ -157,6 +173,7 @@ async def admin_delete_org(org_uuid: UUID, request: Request, auth=AUTH_COOKIE):
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if ctx.org.uuid == org_uuid:
raise ValueError("Cannot delete the organization you belong to")
@@ -306,6 +323,7 @@ async def admin_delete_role(
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
role = await db.instance.get_role(role_uuid)
if role.org_uuid != org_uuid:
@@ -419,12 +437,15 @@ async def admin_create_user_registration_link(
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
# Check if user has existing credentials
credentials = await db.instance.get_credentials_by_user_uuid(user_uuid)
@@ -474,7 +495,9 @@ async def admin_get_user_detail(
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
user = await db.instance.get_user_by_uuid(user_uuid)
cred_ids = await db.instance.get_credentials_by_user_uuid(user_uuid)
creds: list[dict] = []
@@ -621,7 +644,9 @@ async def admin_update_user_display_name(
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -650,12 +675,15 @@ async def admin_delete_user_credential(
["auth:admin", f"auth:org:{org_uuid}"],
match=permutil.has_any,
host=request.headers.get("host"),
max_age="5m",
)
if (
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
await db.instance.delete_credential(credential_uuid, user_uuid)
return {"status": "ok"}
@@ -684,7 +712,9 @@ async def admin_delete_user_session(
"auth:admin" not in ctx.role.permissions
and f"auth:org:{org_uuid}" not in ctx.role.permissions
):
raise HTTPException(status_code=403, detail="Insufficient permissions")
raise authz.AuthException(
status_code=403, detail="Insufficient permissions", mode="forbidden"
)
try:
target_key = tokens.decode_session_key(session_id)
@@ -734,7 +764,11 @@ async def admin_create_permission(
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
from ..db import Permission as PermDC
@@ -806,7 +840,11 @@ async def admin_delete_permission(
auth=AUTH_COOKIE,
):
await authz.verify(
auth, ["auth:admin"], host=request.headers.get("host"), match=permutil.has_all
auth,
["auth:admin"],
host=request.headers.get("host"),
match=permutil.has_all,
max_age="5m",
)
querysafe.assert_safe(permission_id, field="permission_id")
+52 -11
View File
@@ -57,6 +57,22 @@ async def value_error_handler(_request: Request, exc: ValueError):
return JSONResponse(status_code=400, content={"detail": str(exc)})
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request: Request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
)
@app.exception_handler(Exception)
async def general_exception_handler(_request: Request, exc: Exception):
logging.exception("Unhandled exception in API app")
@@ -96,7 +112,9 @@ async def validate_token(
renewed = True
except ValueError:
# Session disappeared, e.g. due to concurrent logout; global handler will clear
raise HTTPException(status_code=401, detail="Session expired")
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
return {
"valid": True,
"user_uuid": str(ctx.session.user_uuid),
@@ -120,8 +138,11 @@ async def forward_authentication(
is older than this, user must re-authenticate.
Success: 204 No Content with Remote-* headers describing the authenticated user.
Failure (unauthenticated / unauthorized): 4xx with HTML page for authentication.
The HTML includes data attributes for mode and other metadata.
Failure (unauthenticated / unauthorized): 4xx response.
- If Accept header contains "text/html": HTML page for authentication
with data attributes for mode and other metadata.
- Otherwise: JSON response with error details and an `iframe` field
pointing to /auth/restricted/?mode=... for iframe-based authentication.
"""
try:
ctx = await authz.verify(
@@ -154,17 +175,37 @@ async def forward_authentication(
}
return Response(status_code=204, headers=remote_headers)
except authz.AuthException as e:
# Authentication/authorization failed - return HTML with metadata
html = frontend.file("int", "forward", "index.html").read_bytes()
# Inject mode and any additional metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
# Clear cookie only if session is invalid (not for reauth)
if e.clear_session:
session.clear_session_cookie(response)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
# Check Accept header to decide response format
accept = request.headers.get("accept", "")
wants_html = "text/html" in accept
if wants_html:
# Browser request - return HTML with metadata
html = frontend.file("int", "forward", "index.html").read_bytes()
# Inject mode and any additional metadata
data_attrs = {"mode": e.mode, **e.metadata}
html = htmlutil.patch_html_data_attrs(html, **data_attrs)
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
else:
# API request - return JSON with iframe src link
iframe_url = f"/auth/restricted/?mode={e.mode}"
return JSONResponse(
status_code=e.status_code,
content={
"detail": e.detail,
"auth": {
"mode": e.mode,
"iframe": iframe_url,
**e.metadata,
},
},
)
@app.get("/settings")
+1 -1
View File
@@ -63,7 +63,7 @@ async def verify(
# Check max_age requirement if specified
if max_age:
try:
if not sessionutil.check_session_age(ctx.session, max_age):
if not sessionutil.check_session_age(ctx, max_age):
raise AuthException(
status_code=401,
detail="Additional authentication required",
+43 -8
View File
@@ -8,6 +8,7 @@ from fastapi import (
Request,
Response,
)
from fastapi.responses import JSONResponse
from ..authsession import (
delete_credential,
@@ -17,12 +18,28 @@ from ..authsession import (
from ..globals import db
from ..util import hostutil, passphrase, tokens
from ..util.tokens import decode_session_key, session_key
from . import session
from . import authz, session
from .session import AUTH_COOKIE
app = FastAPI()
@app.exception_handler(authz.AuthException)
async def auth_exception_handler(_request, exc: authz.AuthException):
"""Handle AuthException with auth info for UI."""
return JSONResponse(
status_code=exc.status_code,
content={
"detail": exc.detail,
"auth": {
"mode": exc.mode,
"iframe": f"/auth/restricted/?mode={exc.mode}",
**exc.metadata,
},
},
)
@app.put("/display-name")
async def user_update_display_name(
request: Request,
@@ -31,11 +48,15 @@ async def user_update_display_name(
auth=AUTH_COOKIE,
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
new_name = (payload.get("display_name") or "").strip()
if not new_name:
raise HTTPException(status_code=400, detail="display_name required")
@@ -52,7 +73,9 @@ async def api_logout_all(request: Request, response: Response, auth=AUTH_COOKIE)
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError:
raise HTTPException(status_code=401, detail="Session expired")
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
)
await db.instance.delete_sessions_for_user(s.user_uuid)
session.clear_session_cookie(response)
return {"message": "Logged out from all hosts"}
@@ -66,11 +89,15 @@ async def api_delete_session(
auth=AUTH_COOKIE,
):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
raise authz.AuthException(
status_code=401, detail="Authentication Required", mode="login"
)
try:
current_session = await get_session(auth, host=request.headers.get("host"))
except ValueError as exc:
raise HTTPException(status_code=401, detail="Session expired") from exc
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from exc
try:
target_key = decode_session_key(session_id)
@@ -97,10 +124,14 @@ async def api_delete_credential(
uuid: UUID,
auth: str = AUTH_COOKIE,
):
# Require recent authentication for sensitive operation
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
try:
await delete_credential(uuid, auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
return {"message": "Credential deleted successfully"}
@@ -110,10 +141,14 @@ async def api_create_link(
response: Response,
auth=AUTH_COOKIE,
):
# Require recent authentication for sensitive operation
await authz.verify(auth, [], host=request.headers.get("host"), max_age="5m")
try:
s = await get_session(auth, host=request.headers.get("host"))
except ValueError as e:
raise HTTPException(status_code=401, detail="Session expired") from e
raise authz.AuthException(
status_code=401, detail="Session expired", mode="login"
) from e
token = passphrase.generate()
expiry = expires()
await db.instance.create_reset_token(
+23 -2
View File
@@ -123,10 +123,26 @@ async def websocket_register_add(
@app.websocket("/authenticate")
@websocket_error_handler
async def websocket_authenticate(ws: WebSocket):
async def websocket_authenticate(ws: WebSocket, auth=AUTH_COOKIE):
origin = ws.headers["origin"]
host = origin.split("://", 1)[1]
options, challenge = passkey.instance.auth_generate_options()
# If there's an existing session, restrict to that user's credentials (reauth)
session_user_uuid = None
credential_ids = None
if auth:
try:
session = await get_session(auth, host=host)
session_user_uuid = session.user_uuid
credential_ids = await db.instance.get_credentials_by_user_uuid(
session_user_uuid
)
except ValueError:
pass # Invalid/expired session - allow normal authentication
options, challenge = passkey.instance.auth_generate_options(
credential_ids=credential_ids
)
await ws.send_json(options)
# Wait for the client to use his authenticator to authenticate
credential = passkey.instance.auth_parse(await ws.receive_json())
@@ -137,6 +153,11 @@ async def websocket_authenticate(ws: WebSocket):
raise ValueError(
f"This passkey is no longer registered with {passkey.instance.rp_name}"
)
# If reauth mode, verify the credential belongs to the session's user
if session_user_uuid and stored_cred.user_uuid != session_user_uuid:
raise ValueError("This passkey belongs to a different account")
# Verify the credential matches the stored data
passkey.instance.auth_verify(credential, challenge, stored_cred, origin=origin)
# Update both credential and user's last_seen timestamp