diff --git a/frontend/int/forward/RestrictedForward.vue b/frontend/int/forward/RestrictedForward.vue index 4206aab..8e3f205 100644 --- a/frontend/int/forward/RestrictedForward.vue +++ b/frontend/int/forward/RestrictedForward.vue @@ -2,7 +2,6 @@ @@ -16,20 +15,21 @@ import { goBack } from '@/utils/helpers' const basePath = computed(() => uiBasePath()) -// Detect mode from URL parameters +// Detect mode from data attribute on html tag only +// (RestrictedApi uses URL query, RestrictedForward uses data injected by server) const authMode = computed(() => { - const params = new URLSearchParams(window.location.search) - return params.get('mode') === 'reauth' ? 'reauth' : 'login' + const htmlElement = document.documentElement + const dataMode = htmlElement.getAttribute('data-mode') + if (dataMode === 'reauth') return 'reauth' + if (dataMode === 'forbidden') return 'forbidden' + return 'login' }) function handleAuthenticated() { + // Reload page to re-trigger forward auth validation location.reload() } -function handleLogout() { - window.location.reload() -} - function returnHome() { const target = basePath.value || '/auth/' if (window.location.pathname !== target) history.replaceState(null, '', target) diff --git a/frontend/src/components/RestrictedAuth.vue b/frontend/src/components/RestrictedAuth.vue index d3c355e..85bb200 100644 --- a/frontend/src/components/RestrictedAuth.vue +++ b/frontend/src/components/RestrictedAuth.vue @@ -29,8 +29,8 @@ - - + + @@ -49,7 +49,7 @@ const props = defineProps({ mode: { type: String, default: 'login', - validator: (value) => ['login', 'reauth'].includes(value) + validator: (value) => ['login', 'reauth', 'forbidden'].includes(value) } }) @@ -69,15 +69,17 @@ const canAuthenticate = computed(() => { if (initializing.value) return false // In reauth mode, allow authentication even if already authenticated if (props.mode === 'reauth') return true - // In login view or initial state, allow if not authenticated - return currentView.value !== 'forbidden' + // In forbidden mode or forbidden view, don't allow authentication + if (props.mode === 'forbidden' || currentView.value === 'forbidden') return false + // In login view or initial state, allow authentication + return true }) const headingTitle = computed(() => { if (props.mode === 'reauth') { return `🔐 Additional Verification Required` } - if (currentView.value === 'forbidden') return '🚫 Forbidden' + if (props.mode === 'forbidden' || currentView.value === 'forbidden') return '🚫 Forbidden' return `🔐 ${settings.value?.rp_name || location.origin}` }) @@ -85,7 +87,10 @@ const headerMessage = computed(() => { if (props.mode === 'reauth') { return 'Please verify your identity to continue with this action.' } - return currentView.value === 'forbidden' ? 'You lack the required permissions.' : 'Please sign in with your passkey.' + if (props.mode === 'forbidden' || currentView.value === 'forbidden') { + return 'You lack the required permissions.' + } + return 'Please sign in with your passkey.' }) const userDisplayName = computed(() => userInfo.value?.user?.user_name || 'User') diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 6e502ec..d2d9a5b 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -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") diff --git a/passkey/fastapi/authz.py b/passkey/fastapi/authz.py index 90920f4..3114af2 100644 --- a/passkey/fastapi/authz.py +++ b/passkey/fastapi/authz.py @@ -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 diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index 2b24128..daa8469 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -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/") diff --git a/passkey/util/htmlutil.py b/passkey/util/htmlutil.py new file mode 100644 index 0000000..ed42e9f --- /dev/null +++ b/passkey/util/htmlutil.py @@ -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 tag. + + If an tag exists, adds data attributes to it. + If no 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'test', mode='reauth') + b'test' + + >>> patch_html_data_attrs(b'test', mode='reauth') + b'test' + """ + 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 tag (case-insensitive, may have existing attributes) + html_tag_pattern = re.compile(r"]*)>", re.IGNORECASE) + match = html_tag_pattern.search(html_str) + + if match: + # Insert data attributes into existing tag + existing_attrs = match.group(1) + new_tag = f"" + html_str = html_tag_pattern.sub(new_tag, html_str, count=1) + else: + # Prepend tag with data attributes + html_str = f"" + html_str + + return html_str.encode("utf-8") diff --git a/passkey/util/sessionutil.py b/passkey/util/sessionutil.py new file mode 100644 index 0000000..83de3cd --- /dev/null +++ b/passkey/util/sessionutil.py @@ -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 diff --git a/passkey/util/timeutil.py b/passkey/util/timeutil.py new file mode 100644 index 0000000..455e2e1 --- /dev/null +++ b/passkey/util/timeutil.py @@ -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}")