diff --git a/API.md b/API.md index ddc1c26..8939563 100644 --- a/API.md +++ b/API.md @@ -1,29 +1,104 @@ # PassKey Auth API Documentation -This document describes all API endpoints available in the PassKey Auth FastAPI application, that by default listens on `localhost:4401` ("for authentication required"). +This document lists the HTTP and WebSocket endpoints exposed by the PassKey Auth +service and how they behave depending on whether a dedicated authentication host +(`--auth-host` / environment `PASSKEY_AUTH_HOST`) is configured. -### HTTP Endpoints +## Base Paths & Host Modes -GET /auth/ - Main authentication app -GET /auth/admin/ - Admin app for managing organisations, users and permissions -GET /auth/{reset_token} - Process password reset/share token -POST /auth/api/user-info - Get authenticated user information -POST /auth/api/logout - Logout and delete session -POST /auth/api/set-session - Set session cookie from Authorization header -POST /auth/api/create-link - Create device addition link -DELETE /auth/api/credential/{uuid} - Delete specific credential -DELETE /auth/api/session/{session_id} - Terminate an active session -POST /auth/api/validate - Session validation and renewal endpoint (fetch regularly) -GET /auth/api/forward - Authentication validation for Caddy/Nginx - - On success returns `204 No Content` with [user info](Headers.md) - - Otherwise returns - * `401 Unauthorized` - authentication required - * `403 Forbidden` - missing required permissions - * Serves the authentication app for a login or permission denied page - - Does not renew session! +Two deployment modes: -### WebAuthn/Passkey endpoints (WebSockets) +1. Multi‑host (default – no `--auth-host` provided) + - All endpoints are reachable on any host under the `/auth/` prefix. + - A convenience root (`/`) also serves the main app. -WS /auth/ws/register - Register new user with passkey -WS /auth/ws/add_credential - Add new credential for existing user -WS /auth/ws/authenticate - Authenticate user with passkey +2. Dedicated auth host (`--auth-host auth.example.com`) + - The specified auth host serves the UI at the root (`/`, `/admin/`, reset tokens, etc.). + - Other (non‑auth) hosts expose only non‑restricted API endpoints; UI is redirected to the auth host. + - Restricted endpoints on non‑auth hosts return `404` instead of redirecting. + +### Path Mapping When Auth Host Enabled + +| Purpose | On Auth Host | On Other Hosts (incoming) | Action | +|---------|--------------|---------------------------|--------| +| Main UI | `/` | `/auth/` or `/` | Redirect -> auth host `/` (strip leading `/auth` if present) | +| Admin UI root | `/admin/` | `/auth/admin/` or `/admin/` | Redirect -> auth host `/admin/` (strip `/auth`) | +| Reset / device addition token | `/{token}` | `/auth/{token}` | Redirect -> auth host `/{token}` (strip `/auth`) | +| Static assets | `/auth/assets/*` | `/auth/assets/*` | Served directly (no redirect) | +| Unrestricted API | `/auth/api/...` | `/auth/api/...` | Served directly | +| Restricted API (admin,user,ws namespaces) | `/auth/api/{admin|user|ws}*` | same path | 404 on non‑auth hosts | +| WebSocket (register/auth) | `/auth/ws/*` | `/auth/ws/*` | 404 on non‑auth hosts | + +Notes: +- “Strip `/auth`” means only when the path starts with that exact segment. +- A reset token is a single path segment validated by server logic; malformed tokens 404. +- Method and body are preserved for UI redirects (307 Temporary Redirect). + +## HTTP UI Endpoints + +| Method | Path (multi‑host) | Path (auth host) | Description | +|--------|-------------------|------------------|-------------| +| GET | `/auth/` | `/` | Main authentication SPA | +| GET | `/auth/admin/` | `/admin/` | Admin SPA root | +| GET | `/auth/{reset_token}` | `/{reset_token}` | Reset / device addition SPA (token validated) | +| GET | `/auth/restricted` | `/restricted` | Restricted / permission denied SPA | + +## Core API (Unrestricted – available on all hosts) + +Always under `/auth/api/` (even on auth host): + +| Method | Path | Description | +|--------|------|-------------| +| POST | `/auth/api/validate` | Validate & (conditionally) renew session | +| GET | `/auth/api/forward` | Auth proxy endpoint for reverse proxies (204 or 4xx) | +| POST | `/auth/api/set-session` | Set cookie from Bearer token | +| POST | `/auth/api/logout` | Logout current session | +| POST | `/auth/api/user-info` | Authenticated user + context info (also handles reset tokens) | +| POST | `/auth/api/create-link` | Create a device addition link (reset token) | +| DELETE | `/auth/api/credential/{uuid}` | Delete user credential | +| DELETE | `/auth/api/session/{session_id}` | Terminate a specific session | +| POST | `/auth/api/user/logout-all` | Terminate all sessions for the user | +| PUT | `/auth/api/user/display-name` | Update display name | + +## Restricted API Namespaces + +When `--auth-host` is set, requests to these paths on non‑auth hosts return 404: + +| Namespace | Examples | +|-----------|----------| +| `/auth/api/admin` | `/auth/api/admin/orgs`, `/auth/api/admin/orgs/{uuid}` ... | +| `/auth/api/user` | Segment prefix – includes `/auth/api/user/...` endpoints (logout-all, display-name, session, credential) | +| `/auth/api/ws` | (Reserved / future) | + +## WebSockets (Passkey) + +| Path | Description | Host Mode Behavior | +|------|-------------|--------------------| +| `/auth/ws/register` | Register new credential (new or existing user) | 404 on non‑auth hosts when auth host configured | +| `/auth/ws/authenticate` | Authenticate user & issue session | 404 on non‑auth hosts when auth host configured | + +## Redirection & Status Codes + +| Scenario | Response | +|----------|----------| +| UI path on non‑auth host (auth host configured) | 307 redirect to auth host; `/auth` prefix stripped | +| Reset token UI path on non‑auth host | 307 redirect (token preserved) | +| Restricted API on non‑auth host | 404 | +| Unrestricted API on any host | Normal response | +| No auth host configured | All hosts behave like multi-host mode (no redirects; everything accessible) | + +## Headers for /auth/api/forward +See `Headers.md` for details of headers returned on success (204). + +## Notes for Integrators +1. Always use absolute `/auth/api/...` paths for programmatic requests (they do not move when an auth host is introduced). +2. Bookmark / deep links to UI should resolve correctly after redirection if users access via a non-auth application host. +3. Treat 404 from restricted namespaces on non-auth hosts as a signal to direct users to the central auth site. + +## Environment & CLI Summary +| Option | Effect | +|--------|--------| +| `--auth-host` / `PASSKEY_AUTH_HOST` | Enables dedicated host mode, root-mounts UI there, restricts certain namespaces elsewhere | + +--- +This document reflects current behavior of the middleware-based host routing logic. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 0eed397..4d25060 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -2,10 +2,7 @@
- +

Loading...

@@ -17,27 +14,17 @@ diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index 2625e83..9ae327d 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -10,6 +10,7 @@ import AdminOrgDetail from './AdminOrgDetail.vue' import AdminUserDetail from './AdminUserDetail.vue' import AdminDialogs from './AdminDialogs.vue' import { useAuthStore } from '@/stores/auth' +import { getSettings, adminUiPath, makeUiHref } from '@/utils/settings' const info = ref(null) const loading = ref(true) @@ -289,10 +290,8 @@ function deletePermission(p) { onMounted(async () => { window.addEventListener('hashchange', parseHash) - await authStore.loadSettings() - if (authStore.settings?.rp_name) { - document.title = authStore.settings.rp_name + ' Admin' - } + const settings = await getSettings() + if (settings?.rp_name) document.title = settings.rp_name + ' Admin' load() }) @@ -324,14 +323,14 @@ const selectedUser = computed(() => { const pageHeading = computed(() => { if (selectedUser.value) return 'Admin: User' if (selectedOrg.value) return 'Admin: Org' - return (authStore.settings?.rp_name || 'Master') + ' Admin' + return ((authStore.settings?.rp_name) || 'Master') + ' Admin' }) // Breadcrumb entries for admin app. const breadcrumbEntries = computed(() => { const entries = [ - { label: 'Auth', href: authStore.uiHref() }, - { label: 'Admin', href: authStore.adminHomeHref() } + { label: 'Auth', href: makeUiHref() }, + { label: 'Admin', href: adminUiPath() } ] // Determine organization for user view if selectedOrg not explicitly chosen. let orgForUser = null diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue deleted file mode 100644 index f2e2d2b..0000000 --- a/frontend/src/components/LoginView.vue +++ /dev/null @@ -1,57 +0,0 @@ - - - - - diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index b828f86..2d6508f 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -88,6 +88,7 @@ import NameEditForm from '@/components/NameEditForm.vue' import SessionList from '@/components/SessionList.vue' import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue' import { useAuthStore } from '@/stores/auth' +import { adminUiPath, makeUiHref } from '@/utils/settings' import passkey from '@/utils/passkey' const authStore = useAuthStore() @@ -147,7 +148,7 @@ const terminateSession = async (session) => { const logoutEverywhere = async () => { await authStore.logoutEverywhere() } const openNameDialog = () => { newName.value = authStore.userInfo?.user?.user_name || ''; showNameDialog.value = true } const isAdmin = computed(() => !!(authStore.userInfo?.is_global_admin || authStore.userInfo?.is_org_admin)) -const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: authStore.uiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: authStore.adminHomeHref() }); return entries }) +const breadcrumbEntries = computed(() => { const entries = [{ label: 'Auth', href: makeUiHref() }]; if (isAdmin.value) entries.push({ label: 'Admin', href: adminUiPath() }); return entries }) const saveName = async () => { const name = newName.value.trim() diff --git a/frontend/src/reset/ResetApp.vue b/frontend/src/reset/ResetApp.vue index e9dfb84..e7dc926 100644 --- a/frontend/src/reset/ResetApp.vue +++ b/frontend/src/reset/ResetApp.vue @@ -63,6 +63,7 @@ diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 9c7f180..30f7f3f 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -5,7 +5,6 @@ export const useAuthStore = defineStore('auth', { state: () => ({ // Auth State userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info} - settings: null, // Server provided settings (/auth/settings) isLoading: false, // UI State @@ -17,15 +16,6 @@ export const useAuthStore = defineStore('auth', { }, }), getters: { - uiBasePath(state) { - const configured = state.settings?.ui_base_path || '/auth/' - if (!configured.endsWith('/')) return `${configured}/` - return configured - }, - adminUiPath() { - const base = this.uiBasePath - return base === '/' ? '/admin/' : `${base}admin/` - }, }, actions: { setLoading(flag) { @@ -43,15 +33,6 @@ export const useAuthStore = defineStore('auth', { }, duration) } }, - uiHref(suffix = '') { - const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix - if (!trimmed) return this.uiBasePath - if (this.uiBasePath === '/') return `/${trimmed}` - return `${this.uiBasePath}${trimmed}` - }, - adminHomeHref() { - return this.adminUiPath - }, async setSessionCookie(sessionToken) { const response = await fetch('/auth/api/set-session', { method: 'POST', @@ -113,19 +94,6 @@ export const useAuthStore = defineStore('auth', { this.userInfo = result console.log('User info loaded:', result) }, - async loadSettings() { - try { - const res = await fetch('/auth/api/settings') - if (!res.ok) return - const data = await res.json() - this.settings = data - if (data?.rp_name) { - document.title = data.rp_name - } - } catch (_) { - // ignore - } - }, async deleteCredential(uuid) { const response = await fetch(`/auth/api/user/credential/${uuid}`, {method: 'Delete'}) const result = await response.json() diff --git a/frontend/src/utils/passkey.js b/frontend/src/utils/passkey.js index 52dc09d..74f25fb 100644 --- a/frontend/src/utils/passkey.js +++ b/frontend/src/utils/passkey.js @@ -1,13 +1,21 @@ import { startRegistration, startAuthentication } from '@simplewebauthn/browser' import aWebSocket from '@/utils/awaitable-websocket' +import { getSettings } from '@/utils/settings' + +// Generic path normalizer: if an auth_host is configured and differs from current +// host, return absolute URL (scheme derived by aWebSocket). Otherwise, keep as-is. +async function makeUrl(path) { + const s = await getSettings() + const h = s?.auth_host + return h && location.host !== h ? `//${h}${path}` : path +} export async function register(resetToken = null, displayName = null) { let params = [] if (resetToken) params.push(`reset=${encodeURIComponent(resetToken)}`) if (displayName) params.push(`name=${encodeURIComponent(displayName)}`) const qs = params.length ? `?${params.join('&')}` : '' - const url = `/auth/ws/register${qs}` - const ws = await aWebSocket(url) + const ws = await aWebSocket(await makeUrl(`/auth/ws/register${qs}`)) try { const optionsJSON = await ws.receive_json() const registrationResponse = await startRegistration({ optionsJSON }) @@ -23,7 +31,7 @@ export async function register(resetToken = null, displayName = null) { } export async function authenticate() { - const ws = await aWebSocket('/auth/ws/authenticate') + const ws = await aWebSocket(await makeUrl('/auth/ws/authenticate')) try { const optionsJSON = await ws.receive_json() const authResponse = await startAuthentication({ optionsJSON }) diff --git a/frontend/src/utils/settings.js b/frontend/src/utils/settings.js new file mode 100644 index 0000000..47cc3e8 --- /dev/null +++ b/frontend/src/utils/settings.js @@ -0,0 +1,29 @@ +let _settingsPromise = null +let _settings = null + +export function getSettingsCached() { return _settings } + +export async function getSettings() { + if (_settings) return _settings + if (_settingsPromise) return _settingsPromise + _settingsPromise = fetch('/auth/api/settings') + .then(r => (r.ok ? r.json() : {})) + .then(obj => { _settings = obj || {}; return _settings }) + .catch(() => { _settings = {}; return _settings }) + return _settingsPromise +} + +export function uiBasePath() { + const base = _settings?.ui_base_path || '/auth/' + if (base === '/') return '/' + return base.endsWith('/') ? base : base + '/' +} + +export function adminUiPath() { return uiBasePath() === '/' ? '/admin/' : uiBasePath() + 'admin/' } + +export function makeUiHref(suffix = '') { + const trimmed = suffix.startsWith('/') ? suffix.slice(1) : suffix + if (!trimmed) return uiBasePath() + if (uiBasePath() === '/') return '/' + trimmed + return uiBasePath() + trimmed +} \ No newline at end of file diff --git a/passkey/authsession.py b/passkey/authsession.py index 4de957f..d4cc66c 100644 --- a/passkey/authsession.py +++ b/passkey/authsession.py @@ -85,10 +85,14 @@ async def get_session(token: str, host: str | None = None) -> Session: normalized_host = hostutil.normalize_host(host) if not normalized_host: raise ValueError("Invalid host") - if session.host is None: + current = session.host + if current is None: + # First time binding: store exact host:port (or IPv6 form) now. await db.instance.set_session_host(session.key, normalized_host) session.host = normalized_host - elif session.host != normalized_host: + elif current == normalized_host: + pass # exact match ok + else: raise ValueError("Invalid or expired session token") return session diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py index 4bde091..08e8c9b 100644 --- a/passkey/fastapi/admin.py +++ b/passkey/fastapi/admin.py @@ -35,6 +35,10 @@ async def general_exception_handler(_request, exc: Exception): @app.get("/") async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")): + """Serve admin SPA only for authenticated users with admin/org permissions. + + On missing/invalid session or insufficient permissions, serve restricted SPA. + """ try: await authz.verify( auth, @@ -44,7 +48,9 @@ async def adminapp(request: Request, auth=Cookie(None, alias="__Host-auth")): ) return FileResponse(frontend.file("admin/index.html")) except HTTPException as e: - return FileResponse(frontend.file("index.html"), status_code=e.status_code) + return FileResponse( + frontend.file("restricted", "index.html"), status_code=e.status_code + ) # -------------------- Organizations -------------------- diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index de0f0d1..7d261c9 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -38,6 +38,17 @@ bearer_auth = HTTPBearer(auto_error=True) app = FastAPI() + +@app.exception_handler(HTTPException) +async def http_exception_handler(_request: Request, exc: HTTPException): + """Ensure auth cookie is cleared on 401 responses (JSON responses only).""" + if exc.status_code == 401: + resp = JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + session.clear_session_cookie(resp) + return resp + return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail}) + + # Refresh only if at least this much of the session lifetime has been *consumed*. # Consumption is derived from (now + EXPIRES) - current_expires. # This guarantees a minimum spacing between DB writes even with frequent /validate calls. @@ -68,7 +79,11 @@ async def validate_token( renewed max-age. This keeps active users logged in without needing a separate refresh endpoint. """ - ctx = await authz.verify(auth, perm, host=request.headers.get("host")) + try: + ctx = await authz.verify(auth, perm, host=request.headers.get("host")) + except HTTPException: + # Global handler will clear cookie if 401 + raise renewed = False if auth: current_expiry = session_expiry(ctx.session) @@ -83,7 +98,7 @@ async def validate_token( session.set_session_cookie(response, auth) renewed = True except ValueError: - # Session disappeared, e.g. due to concurrent logout + # Session disappeared, e.g. due to concurrent logout; global handler will clear raise HTTPException(status_code=401, detail="Session expired") return { "valid": True, @@ -95,6 +110,7 @@ async def validate_token( @app.get("/forward") async def forward_authentication( request: Request, + response: Response, perm: list[str] = Query([]), auth=Cookie(None, alias="__Host-auth"), ): @@ -135,8 +151,13 @@ async def forward_authentication( } 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("restricted", "index.html").read_bytes() - return Response(html, status_code=e.status_code, media_type="text/html") + 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: + session.clear_session_cookie(response) + return Response(html, status_code=status, media_type="text/html") @app.get("/settings") @@ -153,7 +174,10 @@ async def get_settings(): @app.post("/user-info") async def api_user_info( - request: Request, reset: str | None = None, auth=Cookie(None, alias="__Host-auth") + request: Request, + response: Response, + reset: str | None = None, + auth=Cookie(None, alias="__Host-auth"), ): authenticated = False session_record = None @@ -339,11 +363,17 @@ async def api_user_info( @app.put("/user/display-name") async def user_update_display_name( - request: Request, payload: dict = Body(...), auth=Cookie(None, alias="__Host-auth") + request: Request, + response: Response, + payload: dict = Body(...), + auth=Cookie(None, alias="__Host-auth"), ): if not auth: raise HTTPException(status_code=401, detail="Authentication Required") - s = await get_session(auth, host=request.headers.get("host")) + 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 new_name = (payload.get("display_name") or "").strip() if not new_name: raise HTTPException(status_code=400, detail="display_name required") @@ -362,7 +392,6 @@ async def api_logout( try: await get_session(auth, host=request.headers.get("host")) except ValueError: - response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") return {"message": "Already logged out"} with suppress(Exception): await db.instance.delete_session(session_key(auth)) @@ -379,10 +408,9 @@ async def api_logout_all( try: s = await get_session(auth, host=request.headers.get("host")) except ValueError: - response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") raise HTTPException(status_code=401, detail="Session expired") await db.instance.delete_sessions_for_user(s.user_uuid) - response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + session.clear_session_cookie(response) return {"message": "Logged out from all hosts"} @@ -398,7 +426,6 @@ async def api_delete_session( try: current_session = await get_session(auth, host=request.headers.get("host")) except ValueError as exc: - response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") raise HTTPException(status_code=401, detail="Session expired") from exc try: @@ -415,7 +442,7 @@ async def api_delete_session( await db.instance.delete_session(target_key) current_terminated = target_key == session_key(auth) if current_terminated: - response.delete_cookie(session.AUTH_COOKIE_NAME, path="/") + session.clear_session_cookie(response) # explicit because 200 return {"status": "ok", "current_session_terminated": current_terminated} @@ -433,15 +460,28 @@ async def api_set_session( @app.delete("/user/credential/{uuid}") async def api_delete_credential( - request: Request, uuid: UUID, auth: str = Cookie(None, alias="__Host-auth") + request: Request, + response: Response, + uuid: UUID, + auth: str = Cookie(None, alias="__Host-auth"), ): - await delete_credential(uuid, auth, host=request.headers.get("host")) + 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 return {"message": "Credential deleted successfully"} @app.post("/user/create-link") -async def api_create_link(request: Request, auth=Cookie(None, alias="__Host-auth")): - s = await get_session(auth, host=request.headers.get("host")) +async def api_create_link( + request: Request, + response: Response, + auth=Cookie(None, alias="__Host-auth"), +): + 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 token = passphrase.generate() expiry = expires() await db.instance.create_reset_token( diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index e617055..7ed9f22 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -2,7 +2,7 @@ import logging import os from contextlib import asynccontextmanager -from fastapi import Cookie, FastAPI, HTTPException, Request +from fastapi import Cookie, FastAPI, HTTPException, Request, Response from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles @@ -46,6 +46,40 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path app = FastAPI(lifespan=lifespan) + + +@app.middleware("http") +async def auth_host_redirect(request, call_next): # pragma: no cover + cfg = hostutil.configured_auth_host() + if not cfg: + return await call_next(request) + cur = hostutil.normalize_host(request.headers.get("host")) + if not cur or cur == hostutil.normalize_host(cfg): + return await call_next(request) + p = request.url.path or "/" + ui = {"/", "/admin", "/admin/", "/auth/", "/auth/admin", "/auth/admin/"} + restricted = p.startswith( + ("/auth/api/admin", "/auth/api/user", "/auth/api/ws", "/auth/ws/") + ) + ui_match = p in ui + if not ui_match: + # Treat reset token pages as UI (dynamic). Accept single-segment tokens. + if p.startswith("/auth/"): + t = p[6:] + if t and "/" not in t and passphrase.is_well_formed(t): + ui_match = True + else: + t = p[1:] + if t and "/" not in t and passphrase.is_well_formed(t): + ui_match = True + if not (ui_match or restricted): + return await call_next(request) + if restricted: + return Response(status_code=404) + newp = p[5:] or "/" if ui_match and p.startswith("/auth") else p + return RedirectResponse(f"{request.url.scheme}://{cfg}{newp}", 307) + + app.mount("/auth/admin/", admin.app) app.mount("/auth/api/", api.app) app.mount("/auth/ws/", ws.app) @@ -59,8 +93,26 @@ app.mount( @app.get("/") @app.get("/auth/") -async def frontapp(): - return FileResponse(frontend.file("index.html")) +async def frontapp( + request: Request, response: Response, auth=Cookie(None, alias="__Host-auth") +): + """Serve the user profile SPA only for authenticated sessions; otherwise restricted SPA. + + Login / authentication UX is centralized in the restricted app. + """ + if not auth: + return FileResponse(frontend.file("restricted", "index.html"), status_code=401) + from ..authsession import get_session # local import + + try: + await get_session(auth, host=request.headers.get("host")) + return FileResponse(frontend.file("index.html")) + except Exception: + if auth: + from . import session as session_mod + + session_mod.clear_session_cookie(response) + return FileResponse(frontend.file("restricted", "index.html"), status_code=401) @app.get("/admin", include_in_schema=False) @@ -71,7 +123,7 @@ async def admin_root_redirect(): @app.get("/admin/", include_in_schema=False) async def admin_root(request: Request, auth=Cookie(None, alias="__Host-auth")): - return await admin.adminapp(request, auth) # Delegate to handler of /auth/admin/ + return await admin.adminapp(request, auth) # Delegated (enforces access control) @app.get("/{reset}") diff --git a/passkey/fastapi/session.py b/passkey/fastapi/session.py index 290a440..f2f5ff3 100644 --- a/passkey/fastapi/session.py +++ b/passkey/fastapi/session.py @@ -35,3 +35,17 @@ def set_session_cookie(response: Response, token: str) -> None: path="/", samesite="lax", ) + + +def clear_session_cookie(response: Response) -> None: + # FastAPI's delete_cookie does not set the secure attribute + response.set_cookie( + key=AUTH_COOKIE_NAME, + value="", + max_age=0, + expires=0, + httponly=True, + secure=True, + path="/", + samesite="lax", + ) diff --git a/passkey/util/hostutil.py b/passkey/util/hostutil.py index 0f23746..78b32be 100644 --- a/passkey/util/hostutil.py +++ b/passkey/util/hostutil.py @@ -73,14 +73,20 @@ def reload_config() -> None: def normalize_host(raw_host: str | None) -> str | None: - """Normalize a Host header or hostname by stripping port and lowercasing.""" + """Normalize a Host header preserving port (exact match required).""" if not raw_host: return None candidate = raw_host.strip() if not candidate: return None - # Ensure urlsplit can parse bare hosts (prepend //) + # urlsplit to parse (add // for scheme-less); prefer netloc to retain port. parsed = urlsplit(candidate if "//" in candidate else f"//{candidate}") - host = parsed.hostname or parsed.path or "" - host = host.strip("[]") # Remove IPv6 brackets if present - return host.lower() if host else None + netloc = parsed.netloc or parsed.path or "" + # Strip IPv6 brackets around host part but retain port suffix. + if netloc.startswith("["): + # format: [ipv6]:port or [ipv6] + if "]" in netloc: + host_part, _, rest = netloc.partition("]") + port_part = rest.lstrip(":") + netloc = host_part.strip("[]") + (f":{port_part}" if port_part else "") + return netloc.lower() or None