diff --git a/frontend/index.html b/frontend/index.html index e0b47ea..a8c4221 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,7 +3,7 @@ - Authentication + Auth Profile
diff --git a/frontend/reset/index.html b/frontend/reset/index.html new file mode 100644 index 0000000..12122ab --- /dev/null +++ b/frontend/reset/index.html @@ -0,0 +1,12 @@ + + + + + + Complete Passkey Setup + + +
+ + + diff --git a/frontend/restricted/index.html b/frontend/restricted/index.html new file mode 100644 index 0000000..b8b9fcb --- /dev/null +++ b/frontend/restricted/index.html @@ -0,0 +1,12 @@ + + + + + + Access Restricted + + +
+ + + diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4d99848..55edb02 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -7,8 +7,6 @@ - -
@@ -26,21 +24,10 @@ import StatusMessage from '@/components/StatusMessage.vue' import LoginView from '@/components/LoginView.vue' import ProfileView from '@/components/ProfileView.vue' import DeviceLinkView from '@/components/DeviceLinkView.vue' -import ResetView from '@/components/ResetView.vue' -import PermissionDeniedView from '@/components/PermissionDeniedView.vue' - const store = useAuthStore() const initialized = ref(false) onMounted(async () => { - // Detect restricted mode: - // We only allow full functionality on the exact /auth/ (or /auth) path. - // Any other path (including /, /foo, /auth/admin, etc.) is treated as restricted - // so the app will only show login or permission denied views. - const path = location.pathname - if (!(path === '/auth/' || path === '/auth')) { - store.setRestrictedMode(true) - } // Load branding / settings first (non-blocking for auth flow) await store.loadSettings() // Was an error message passed in the URL hash? @@ -49,23 +36,11 @@ onMounted(async () => { store.showMessage(decodeURIComponent(message), 'error') history.replaceState(null, '', location.pathname) } - // Capture reset token from query parameter and then remove it - const params = new URLSearchParams(location.search) - const reset = params.get('reset') - if (reset) { - store.resetToken = reset - // Remove query param to avoid lingering in history / clipboard - const targetPath = '/auth/' - const currentPath = location.pathname.endsWith('/') ? location.pathname : location.pathname + '/' - history.replaceState(null, '', currentPath.startsWith('/auth') ? '/auth/' : targetPath) - } try { await store.loadUserInfo() - initialized.value = true - store.selectView() } catch (error) { console.log('Failed to load user info:', error) - store.currentView = 'login' + } finally { initialized.value = true store.selectView() } diff --git a/frontend/src/admin/AdminApp.vue b/frontend/src/admin/AdminApp.vue index c60fe42..2625e83 100644 --- a/frontend/src/admin/AdminApp.vue +++ b/frontend/src/admin/AdminApp.vue @@ -330,8 +330,8 @@ const pageHeading = computed(() => { // Breadcrumb entries for admin app. const breadcrumbEntries = computed(() => { const entries = [ - { label: 'Auth', href: '/auth/' }, - { label: 'Admin', href: '/auth/admin/' } + { label: 'Auth', href: authStore.uiHref() }, + { label: 'Admin', href: authStore.adminHomeHref() } ] // Determine organization for user view if selectedOrg not explicitly chosen. let orgForUser = null diff --git a/frontend/src/assets/style.css b/frontend/src/assets/style.css index ef6c04b..a3257f6 100644 --- a/frontend/src/assets/style.css +++ b/frontend/src/assets/style.css @@ -162,7 +162,6 @@ a:focus-visible { .view-header h1 { margin: 0; - font-size: clamp(1.85rem, 2.5vw + 1rem, 2.6rem); font-weight: 600; color: var(--color-heading); } diff --git a/frontend/src/components/LoginView.vue b/frontend/src/components/LoginView.vue index 5bde772..f2e2d2b 100644 --- a/frontend/src/components/LoginView.vue +++ b/frontend/src/components/LoginView.vue @@ -32,13 +32,7 @@ const handleLogin = async () => { authStore.showMessage('Starting authentication...', 'info') await authStore.authenticate() authStore.showMessage('Authentication successful!', 'success', 2000) - if (authStore.restrictedMode) { - location.reload() - } else if (location.pathname === '/auth/') { - authStore.currentView = 'profile' - } else { - location.reload() - } + authStore.currentView = 'profile' } catch (error) { authStore.showMessage(error.message, 'error') } diff --git a/frontend/src/components/PermissionDeniedView.vue b/frontend/src/components/PermissionDeniedView.vue deleted file mode 100644 index 8d2fdf5..0000000 --- a/frontend/src/components/PermissionDeniedView.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - diff --git a/frontend/src/components/ProfileView.vue b/frontend/src/components/ProfileView.vue index 99514f2..9face71 100644 --- a/frontend/src/components/ProfileView.vue +++ b/frontend/src/components/ProfileView.vue @@ -3,7 +3,7 @@

đź‘‹ Welcome!

- +

Manage your account details and passkeys.

@@ -144,6 +144,12 @@ const openNameDialog = () => { 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 saveName = async () => { const name = newName.value.trim() if (!name) { diff --git a/frontend/src/components/ResetView.vue b/frontend/src/components/ResetView.vue deleted file mode 100644 index a2bfbf8..0000000 --- a/frontend/src/components/ResetView.vue +++ /dev/null @@ -1,94 +0,0 @@ - - - - - diff --git a/frontend/src/reset/ResetApp.vue b/frontend/src/reset/ResetApp.vue new file mode 100644 index 0000000..e9dfb84 --- /dev/null +++ b/frontend/src/reset/ResetApp.vue @@ -0,0 +1,258 @@ + + + + + diff --git a/frontend/src/reset/main.js b/frontend/src/reset/main.js new file mode 100644 index 0000000..51f6e05 --- /dev/null +++ b/frontend/src/reset/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import ResetApp from './ResetApp.vue' +import '@/assets/style.css' + +createApp(ResetApp).mount('#app') diff --git a/frontend/src/restricted/RestrictedApp.vue b/frontend/src/restricted/RestrictedApp.vue new file mode 100644 index 0000000..ab9e779 --- /dev/null +++ b/frontend/src/restricted/RestrictedApp.vue @@ -0,0 +1,207 @@ + + + + + diff --git a/frontend/src/restricted/main.js b/frontend/src/restricted/main.js new file mode 100644 index 0000000..1f85834 --- /dev/null +++ b/frontend/src/restricted/main.js @@ -0,0 +1,5 @@ +import { createApp } from 'vue' +import RestrictedApp from './RestrictedApp.vue' +import '@/assets/style.css' + +createApp(RestrictedApp).mount('#app') diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 0688c1f..3bc05d9 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -7,8 +7,6 @@ export const useAuthStore = defineStore('auth', { userInfo: null, // Contains the full user info response: {user, credentials, aaguid_info, session_type, authenticated} settings: null, // Server provided settings (/auth/settings) isLoading: false, - resetToken: null, // transient reset token - restrictedMode: false, // Anywhere other than /auth/: restrict to login or permission denied // UI State currentView: 'login', @@ -18,7 +16,21 @@ export const useAuthStore = defineStore('auth', { show: false }, }), + 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) { + this.isLoading = !!flag + }, showMessage(message, type = 'info', duration = 3000) { this.status = { message, @@ -31,8 +43,17 @@ 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', { + const response = await fetch('/auth/api/set-session', { method: 'POST', headers: {'Authorization': `Bearer ${sessionToken}`}, }) @@ -40,9 +61,6 @@ export const useAuthStore = defineStore('auth', { if (result.detail) { throw new Error(result.detail) } - // On successful session establishment, discard any reset token to avoid - // sending stale Authorization headers on subsequent API calls. - this.resetToken = null return result }, async register() { @@ -51,6 +69,7 @@ export const useAuthStore = defineStore('auth', { const result = await register() await this.setSessionCookie(result.session_token) await this.loadUserInfo() + this.selectView() return result } finally { this.isLoading = false @@ -63,6 +82,7 @@ export const useAuthStore = defineStore('auth', { await this.setSessionCookie(result.session_token) await this.loadUserInfo() + this.selectView() return result } finally { @@ -70,25 +90,12 @@ export const useAuthStore = defineStore('auth', { } }, selectView() { - if (this.restrictedMode) { - // In restricted mode only allow login or show permission denied if already authenticated - if (!this.userInfo) this.currentView = 'login' - else if (this.userInfo.authenticated) this.currentView = 'permission-denied' - else this.currentView = 'login' // do not expose reset/registration flows outside /auth/ - return - } if (!this.userInfo) this.currentView = 'login' else if (this.userInfo.authenticated) this.currentView = 'profile' - else this.currentView = 'reset' - }, - setRestrictedMode(flag) { - this.restrictedMode = !!flag + else this.currentView = 'login' }, async loadUserInfo() { - const headers = {} - // Reset tokens are only passed via query param now, not Authorization header - const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info' - const response = await fetch(url, { method: 'POST', headers }) + const response = await fetch('/auth/api/user-info', { method: 'POST' }) let result = null try { result = await response.json() diff --git a/frontend/vite.config.js b/frontend/vite.config.js index fe9f9ee..33cd485 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -35,6 +35,10 @@ export default defineConfig(({ command, mode }) => ({ if (url === '/auth/' || url === '/auth') return '/' if (url === '/auth/admin' || url === '/auth/admin/') return '/admin/' if (url.startsWith('/auth/assets/')) return url.replace(/^\/auth/, '') + if (/^\/auth\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' + if (/^\/([a-z]+\.){4}[a-z]+\/?$/.test(url)) return '/reset/index.html' + if (url === '/auth/restricted' || url === '/auth/restricted/') return '/restricted/index.html' + if (url === '/restricted' || url === '/restricted/') return '/restricted/index.html' // Everything else (including /auth/admin/* APIs) should proxy. } } @@ -47,7 +51,9 @@ export default defineConfig(({ command, mode }) => ({ rollupOptions: { input: { index: resolve(__dirname, 'index.html'), - admin: resolve(__dirname, 'admin/index.html') + admin: resolve(__dirname, 'admin/index.html'), + reset: resolve(__dirname, 'reset/index.html'), + restricted: resolve(__dirname, 'restricted/index.html') }, output: {} } diff --git a/passkey/bootstrap.py b/passkey/bootstrap.py index 43c18cc..9734a87 100644 --- a/passkey/bootstrap.py +++ b/passkey/bootstrap.py @@ -14,7 +14,7 @@ import uuid7 from . import authsession, globals from .db import Org, Permission, Role, User -from .util import passphrase, tokens +from .util import hostutil, passphrase, tokens def _init_logger() -> logging.Logger: @@ -47,7 +47,8 @@ async def _create_and_log_admin_reset_link(user_uuid, message, session_type) -> expires=authsession.expires(), info={"type": session_type}, ) - reset_link = f"{globals.passkey.instance.origin}/auth/{token}" + base = hostutil.auth_site_base_url() + reset_link = f"{base}{token}" logger.info(ADMIN_RESET_MESSAGE, message, reset_link) return reset_link diff --git a/passkey/fastapi/__main__.py b/passkey/fastapi/__main__.py index 4262f93..878a985 100644 --- a/passkey/fastapi/__main__.py +++ b/passkey/fastapi/__main__.py @@ -94,6 +94,13 @@ def add_common_options(p: argparse.ArgumentParser) -> None: ) p.add_argument("--rp-name", help="Relying Party name (default: same as rp-id)") p.add_argument("--origin", help="Origin URL (default: https://)") + p.add_argument( + "--auth-host", + help=( + "Dedicated host (optionally with scheme/port) to serve the auth UI at the root," + " e.g. auth.example.com or https://auth.example.com" + ), + ) def main(): @@ -168,6 +175,16 @@ def main(): os.environ["PASSKEY_RP_NAME"] = args.rp_name if origin: os.environ["PASSKEY_ORIGIN"] = origin + if getattr(args, "auth_host", None): + os.environ["PASSKEY_AUTH_HOST"] = args.auth_host + else: + # Preserve pre-set env variable if CLI option omitted + args.auth_host = os.environ.get("PASSKEY_AUTH_HOST") + + if getattr(args, "auth_host", None): + from passkey.util import hostutil as _hostutil # local import + + _hostutil.reload_config() # One-time initialization + bootstrap before starting any server processes. # Lifespan in worker processes will call globals.init with bootstrap disabled. diff --git a/passkey/fastapi/admin.py b/passkey/fastapi/admin.py index 62aca67..9360ec5 100644 --- a/passkey/fastapi/admin.py +++ b/passkey/fastapi/admin.py @@ -6,7 +6,6 @@ from fastapi.responses import FileResponse, JSONResponse from ..authsession import expires from ..globals import db -from ..globals import passkey as global_passkey from ..util import frontend, hostutil, passphrase, permutil, querysafe, tokens from . import authz @@ -358,10 +357,8 @@ async def admin_create_user_registration_link( expires=expires(), info={"type": "device addition", "created_by_admin": True}, ) - origin = hostutil.effective_origin( - request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id - ) - url = f"{origin}/auth/{token}" + base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host")) + url = f"{base}{token}" return {"url": url, "expires": expires().isoformat()} diff --git a/passkey/fastapi/api.py b/passkey/fastapi/api.py index 6b08906..7f6f11a 100644 --- a/passkey/fastapi/api.py +++ b/passkey/fastapi/api.py @@ -13,7 +13,7 @@ from fastapi import ( Request, Response, ) -from fastapi.responses import FileResponse, JSONResponse +from fastapi.responses import JSONResponse from fastapi.security import HTTPBearer from passkey.util import frontend @@ -112,13 +112,20 @@ async def forward_authentication(perm: list[str] = Query([]), auth=Cookie(None)) } return Response(status_code=204, headers=remote_headers) except HTTPException as e: - return FileResponse(frontend.file("index.html"), status_code=e.status_code) + html = frontend.file("restricted", "index.html").read_bytes() + return Response(html, status_code=e.status_code, media_type="text/html") @app.get("/settings") async def get_settings(): pk = global_passkey.instance - return {"rp_id": pk.rp_id, "rp_name": pk.rp_name} + base_path = hostutil.ui_base_path() + return { + "rp_id": pk.rp_id, + "rp_name": pk.rp_name, + "ui_base_path": base_path, + "auth_host": hostutil.configured_auth_host(), + } @app.post("/user-info") @@ -267,10 +274,8 @@ async def api_create_link(request: Request, auth=Cookie(None)): expires=expires(), info=session.infodict(request, "device addition"), ) - origin = hostutil.effective_origin( - request.url.scheme, request.headers.get("host"), global_passkey.instance.rp_id - ) - url = f"{origin}/auth/{token}" + base = hostutil.auth_site_base_url(request.url.scheme, request.headers.get("host")) + url = f"{base}{token}" return { "message": "Registration link generated successfully", "url": url, diff --git a/passkey/fastapi/mainapp.py b/passkey/fastapi/mainapp.py index 50866ba..23069ae 100644 --- a/passkey/fastapi/mainapp.py +++ b/passkey/fastapi/mainapp.py @@ -2,11 +2,11 @@ import logging import os from contextlib import asynccontextmanager -from fastapi import FastAPI, HTTPException, Request +from fastapi import Cookie, FastAPI, HTTPException from fastapi.responses import FileResponse, RedirectResponse from fastapi.staticfiles import StaticFiles -from passkey.util import frontend, passphrase +from passkey.util import frontend, hostutil, passphrase from . import admin, api, ws @@ -53,26 +53,37 @@ app.mount( "/auth/assets/", StaticFiles(directory=frontend.file("assets")), name="assets" ) +# Navigable URLs are defined here. We support both / and /auth/ as the base path +# / is used on a dedicated auth site, /auth/ on app domains with auth + @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.""" return FileResponse(frontend.file("index.html")) +@app.get("/admin", include_in_schema=False) +@app.get("/auth/admin", include_in_schema=False) +async def admin_root_redirect(): + return RedirectResponse(f"{hostutil.ui_base_path()}admin/", status_code=307) + + +@app.get("/admin/", include_in_schema=False) +async def admin_root(auth=Cookie(None)): + return await admin.adminapp(auth) # Delegate to handler of /auth/admin/ + + +@app.get("/{reset}") @app.get("/auth/{reset}") -async def reset_link(request: Request, reset: str): - """Pretty URL for reset links.""" - if reset == "admin": - # Admin app missing trailing slash lands here, be friendly to user - return RedirectResponse(request.url_for("adminapp"), status_code=303) +async def reset_link(reset: str): + """Serve the SPA directly with an injected reset token.""" if not passphrase.is_well_formed(reset): raise HTTPException(status_code=404) - url = request.url_for("frontapp").include_query_params(reset=reset) - return RedirectResponse(url, status_code=303) + return FileResponse(frontend.file("reset", "index.html")) + + +@app.get("/restricted", include_in_schema=False) +@app.get("/auth/restricted", include_in_schema=False) +async def restricted_view(): + return FileResponse(frontend.file("restricted", "index.html")) diff --git a/passkey/fastapi/reset.py b/passkey/fastapi/reset.py index 9f2103c..60f95d6 100644 --- a/passkey/fastapi/reset.py +++ b/passkey/fastapi/reset.py @@ -17,7 +17,7 @@ from uuid import UUID from passkey import authsession as _authsession from passkey import globals as _g -from passkey.util import passphrase +from passkey.util import hostutil, passphrase from passkey.util import tokens as _tokens @@ -69,7 +69,8 @@ async def _create_reset(user, role_name: str): expires=_authsession.expires(), info={"type": "manual reset", "role": role_name}, ) - return f"{_g.passkey.instance.origin}/auth/{token}", token + base = hostutil.auth_site_base_url() + return f"{base}{token}", token async def _main(query: str | None) -> int: diff --git a/passkey/util/hostutil.py b/passkey/util/hostutil.py index d6551e0..126815a 100644 --- a/passkey/util/hostutil.py +++ b/passkey/util/hostutil.py @@ -1,24 +1,67 @@ -"""Utilities for host validation and origin determination.""" +"""Utilities for determining the auth UI host and base URLs.""" + +import os +from functools import lru_cache +from urllib.parse import urlparse from ..globals import passkey as global_passkey +_AUTH_HOST_ENV = "PASSKEY_AUTH_HOST" -def effective_origin(scheme: str, host: str | None, rp_id: str) -> str: - """Determine the effective origin for a request. - Uses the provided host if it's compatible with the relying party ID, - otherwise falls back to the configured origin. +def _default_origin_scheme() -> str: + origin_url = urlparse(global_passkey.instance.origin) + return origin_url.scheme or "https" - Args: - scheme: The URL scheme (e.g. "https") - host: The host header value (e.g. "example.com" or "sub.example.com:8080") - rp_id: The relying party ID (e.g. "example.com") - Returns: - The effective origin URL to use - """ +@lru_cache(maxsize=1) +def _load_config() -> tuple[str | None, str] | None: + raw = os.getenv(_AUTH_HOST_ENV) + if not raw: + return None + candidate = raw.strip() + if not candidate: + return None + parsed = urlparse(candidate if "://" in candidate else f"//{candidate}") + netloc = parsed.netloc or parsed.path + if not netloc: + return None + return (parsed.scheme or None, netloc.strip("/")) + + +def configured_auth_host() -> str | None: + cfg = _load_config() + return cfg[1] if cfg else None + + +def is_root_mode() -> bool: + return _load_config() is not None + + +def ui_base_path() -> str: + return "/" if is_root_mode() else "/auth/" + + +def _format_base_url(scheme: str, netloc: str) -> str: + scheme_part = scheme or _default_origin_scheme() + base = f"{scheme_part}://{netloc}" + return base if base.endswith("/") else f"{base}/" + + +def auth_site_base_url(scheme: str | None = None, host: str | None = None) -> str: + cfg = _load_config() + if cfg: + cfg_scheme, cfg_host = cfg + scheme_to_use = cfg_scheme or scheme or _default_origin_scheme() + return _format_base_url(scheme_to_use, cfg_host) + if host: - hostname = host.split(":")[0] # Remove port if present - if hostname == rp_id or hostname.endswith(f".{rp_id}"): - return f"{scheme}://{host}" - return global_passkey.instance.origin + scheme_to_use = scheme or _default_origin_scheme() + return _format_base_url(scheme_to_use, host.strip("/")) + + origin = global_passkey.instance.origin.rstrip("/") + return f"{origin}/auth/" + + +def reload_config() -> None: + _load_config.cache_clear()