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