@@ -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 @@
@@ -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 @@
-
-
-
-
-
-
-
-
-
Proceed to complete {{ authStore.userInfo?.session_type }}:
-
-
-
-
-
-
-
-
-
-
-
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 @@
+
+
+
+
+ {{ status.message }}
+
+
+
+
+
+
+
+
+
+
+
Loading reset details…
+
+
+
+
+
+
{{ errorMessage }}
+
+
+
+
+
+
+
+
+
+
Click below to finish {{ sessionDescriptor }}.
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{ status.message }}
+
+
+
+
+
+
+
+
+
+
+
Checking your session…
+
+
+
+
+
+
{{ detailText }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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()