Implement metadata for RestrictedForward, set by /auth/api/forward endpoint when returning the app. Use this to implement support for time-based reauth requirement.

This commit is contained in:
Leo Vasanko
2025-12-02 23:39:31 +00:00
parent aed48de38e
commit 10ce0126b0
8 changed files with 220 additions and 32 deletions
+20 -10
View File
@@ -24,7 +24,7 @@ from ..authsession import (
)
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import hostutil, passphrase, userinfo
from ..util import hostutil, htmlutil, passphrase, userinfo
from ..util.tokens import session_key
from . import authz, session, user
from .session import AUTH_COOKIE
@@ -109,18 +109,24 @@ async def forward_authentication(
request: Request,
response: Response,
perm: list[str] = Query([]),
max_age: str | None = Query(None),
auth=AUTH_COOKIE,
):
"""Forward auth validation for Caddy/Nginx.
Query Params:
- perm: repeated permission IDs the authenticated user must possess (ALL required).
- max_age: maximum age of authentication (e.g., "5m", "1h", "30s"). If the session
is older than this, user must re-authenticate.
Success: 204 No Content with Remote-* headers describing the authenticated user.
Failure (unauthenticated / unauthorized): 4xx JSON body with detail.
Failure (unauthenticated / unauthorized): 4xx with HTML page for authentication.
The HTML includes data attributes for mode and other metadata.
"""
try:
ctx = await authz.verify(auth, perm, host=request.headers.get("host"))
ctx = await authz.verify(
auth, perm, host=request.headers.get("host"), max_age=max_age
)
role_permissions = set(ctx.role.permissions or [])
if ctx.permissions:
role_permissions.update(permission.id for permission in ctx.permissions)
@@ -147,14 +153,18 @@ async def forward_authentication(
"Remote-Credential": str(ctx.session.credential_uuid),
}
return Response(status_code=204, headers=remote_headers)
except HTTPException as e:
# Let global handler clear cookie; still return HTML surface instead of JSON
html = frontend.file("int", "restricted", "index.html").read_bytes()
status = e.status_code
# If 401 we still want cookie cleared; rely on handler by raising again not feasible (we need HTML)
if status == 401:
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=status, media_type="text/html")
return Response(
html, status_code=e.status_code, media_type="text/html; charset=UTF-8"
)
@app.get("/settings")
+54 -7
View File
@@ -2,31 +2,76 @@ import logging
from fastapi import HTTPException
from ..util import permutil
from ..util import permutil, sessionutil
logger = logging.getLogger(__name__)
class AuthException(HTTPException):
"""Exception raised during authentication/authorization with metadata for the UI.
Attributes:
status_code: HTTP status code (401 for auth, 403 for authz)
detail: Error message
mode: UI mode ('login' or 'reauth')
clear_session: Whether to clear the session cookie (True for invalid sessions)
metadata: Additional data to pass to the frontend
"""
def __init__(
self,
status_code: int,
detail: str,
mode: str,
clear_session: bool = False,
**metadata,
):
super().__init__(status_code=status_code, detail=detail)
self.mode = mode
self.clear_session = clear_session
self.metadata = metadata
async def verify(
auth: str | None,
perm: list[str],
match=permutil.has_all,
host: str | None = None,
max_age: str | None = None,
):
"""Validate session token and optional list of required permissions.
Returns the session context.
Raises HTTPException on failure:
401: unauthenticated / invalid session
403: required permissions missing
Raises AuthException on failure with metadata for UI rendering.
"""
if not auth:
raise HTTPException(status_code=401, detail="Authentication required")
raise AuthException(
status_code=401,
detail="Authentication required",
mode="login",
)
ctx = await permutil.session_context(auth, host)
if not ctx:
raise HTTPException(status_code=401, detail="Session not found")
raise AuthException(
status_code=401,
detail="Your session has expired. Please sign in again.",
mode="login",
clear_session=True,
)
# Check max_age requirement if specified
if max_age:
try:
if not sessionutil.check_session_age(ctx.session, max_age):
raise AuthException(
status_code=401,
detail="Additional authentication required",
mode="reauth",
)
except ValueError as e:
# Invalid max_age format - log but don't fail the request
logger.warning(f"Invalid max_age format '{max_age}': {e}")
if not match(ctx, perm):
# Determine which permissions are missing for clearer diagnostics
@@ -39,6 +84,8 @@ async def verify(
perm,
ctx.role.permissions,
)
raise HTTPException(status_code=403, detail="Permission required")
raise AuthException(
status_code=403, mode="forbidden", detail="Permission required"
)
return ctx
+5
View File
@@ -59,6 +59,11 @@ app.mount(
StaticFiles(directory=frontend.file("auth", "assets")),
name="assets",
)
app.mount(
"/int/",
StaticFiles(directory=frontend.file("int"), html=True),
name="int",
)
@app.get("/auth/restricted/")
+47
View File
@@ -0,0 +1,47 @@
"""Utility functions for HTML manipulation."""
import re
def patch_html_data_attrs(html: bytes, **data_attrs: str) -> bytes:
"""Patch HTML by adding data attributes to the <html> tag.
If an <html> tag exists, adds data attributes to it.
If no <html> tag exists, prepends one with the data attributes.
Args:
html: The HTML content as bytes
**data_attrs: Key-value pairs for data attributes (e.g., mode='reauth')
Returns:
Modified HTML as bytes
Examples:
>>> patch_html_data_attrs(b'<html><body>test</body></html>', mode='reauth')
b'<html data-mode="reauth"><body>test</body></html>'
>>> patch_html_data_attrs(b'<body>test</body>', mode='reauth')
b'<html data-mode="reauth"><body>test</body>'
"""
if not data_attrs:
return html
html_str = html.decode("utf-8")
# Build the data attributes string
attrs_str = " ".join(f'data-{key}="{value}"' for key, value in data_attrs.items())
# Check if there's an <html> tag (case-insensitive, may have existing attributes)
html_tag_pattern = re.compile(r"<html([^>]*)>", re.IGNORECASE)
match = html_tag_pattern.search(html_str)
if match:
# Insert data attributes into existing <html> tag
existing_attrs = match.group(1)
new_tag = f"<html{existing_attrs} {attrs_str}>"
html_str = html_tag_pattern.sub(new_tag, html_str, count=1)
else:
# Prepend <html> tag with data attributes
html_str = f"<html {attrs_str}>" + html_str
return html_str.encode("utf-8")
+27
View File
@@ -0,0 +1,27 @@
"""Utility functions for session validation and checking."""
from datetime import datetime, timezone
from ..db import Session
from .timeutil import parse_duration
def check_session_age(session: Session, max_age: str | None) -> bool:
"""Check if a session satisfies the max_age requirement.
Args:
session: The session record to check
max_age: Maximum age string (e.g., "5m", "1h", "30s") or None
Returns:
True if session is recent enough or max_age is None, False if too old
Raises:
ValueError: If max_age format is invalid
"""
if not max_age:
return True
max_age_delta = parse_duration(max_age)
time_since_auth = datetime.now(timezone.utc) - session.renewed
return time_since_auth <= max_age_delta
+47
View File
@@ -0,0 +1,47 @@
"""Utility functions for parsing time durations."""
import re
from datetime import timedelta
def parse_duration(duration_str: str) -> timedelta:
"""Parse a duration string into a timedelta.
Supports units: s, m, min, h, d
Examples: "30s", "5m", "5min", "2h", "1d"
Args:
duration_str: A string like "30s", "5m", "2h"
Returns:
A timedelta object
Raises:
ValueError: If the format is invalid
"""
duration_str = duration_str.strip().lower()
# Pattern matches: number + unit
# Units: s (seconds), m/min (minutes), h (hours), d (days)
pattern = r"^(\d+(?:\.\d+)?)(s|m|min|h|d)$"
match = re.match(pattern, duration_str)
if not match:
raise ValueError(
f"Invalid duration format: '{duration_str}'. "
"Expected format like '30s', '5m', '5min', '2h', or '1d'"
)
value = float(match.group(1))
unit = match.group(2)
if unit == "s":
return timedelta(seconds=value)
elif unit in ("m", "min"):
return timedelta(minutes=value)
elif unit == "h":
return timedelta(hours=value)
elif unit == "d":
return timedelta(days=value)
else:
raise ValueError(f"Unsupported time unit: {unit}")