Refactor API under /auth/api

This commit is contained in:
Leo Vasanko 2025-09-02 14:32:19 -06:00
parent 859cc9ed41
commit 312d23b79a
7 changed files with 208 additions and 240 deletions

View File

@ -167,7 +167,7 @@ async function load() {
loading.value = true
error.value = null
try {
const res = await fetch('/auth/user-info', { method: 'POST' })
const res = await fetch('/auth/api/user-info', { method: 'POST' })
const data = await res.json()
if (data.detail) throw new Error(data.detail)
info.value = data

View File

@ -46,7 +46,7 @@ const copyLink = async (event) => {
onMounted(async () => {
try {
const response = await fetch('/auth/create-link', { method: 'POST' })
const response = await fetch('/auth/api/create-link', { method: 'POST' })
const result = await response.json()
if (result.detail) throw new Error(result.detail)

View File

@ -9,7 +9,7 @@
:created-at="authStore.userInfo.user.created_at"
:last-seen="authStore.userInfo.user.last_seen"
:loading="authStore.isLoading"
update-endpoint="/auth/user/display-name"
update-endpoint="/auth/api/user/display-name"
@saved="authStore.loadUserInfo()"
/>

View File

@ -32,7 +32,7 @@ export const useAuthStore = defineStore('auth', {
}
},
async setSessionCookie(sessionToken) {
const response = await fetch('/auth/set-session', {
const response = await fetch('/auth/api/set-session', {
method: 'POST',
headers: {'Authorization': `Bearer ${sessionToken}`},
})
@ -87,7 +87,7 @@ export const useAuthStore = defineStore('auth', {
async loadUserInfo() {
const headers = {}
// Reset tokens are only passed via query param now, not Authorization header
const url = this.resetToken ? `/auth/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/user-info'
const url = this.resetToken ? `/auth/api/user-info?reset=${encodeURIComponent(this.resetToken)}` : '/auth/api/user-info'
const response = await fetch(url, { method: 'POST', headers })
let result = null
try {
@ -109,7 +109,7 @@ export const useAuthStore = defineStore('auth', {
},
async loadSettings() {
try {
const res = await fetch('/auth/settings')
const res = await fetch('/auth/api/settings')
if (!res.ok) return
const data = await res.json()
this.settings = data
@ -121,7 +121,7 @@ export const useAuthStore = defineStore('auth', {
}
},
async deleteCredential(uuid) {
const response = await fetch(`/auth/credential/${uuid}`, {method: 'Delete'})
const response = await fetch(`/auth/api/credential/${uuid}`, {method: 'Delete'})
const result = await response.json()
if (result.detail) throw new Error(`Server: ${result.detail}`)
@ -129,7 +129,7 @@ export const useAuthStore = defineStore('auth', {
},
async logout() {
try {
await fetch('/auth/logout', {method: 'POST'})
await fetch('/auth/api/logout', {method: 'POST'})
} catch (error) {
console.error('Logout error:', error)
}

View File

@ -1,51 +1,49 @@
"""Core (non-admin) HTTP API routes.
Contains endpoints for:
* Session validation
* Basic runtime settings
* Authenticated user info & credential listing
* User self-service updates (display name)
* Logout / set-session / credential deletion
Admin endpoints have been moved to `adminapp.py` and mounted at
`/auth/admin` by the main application.
"""
from contextlib import suppress
from uuid import UUID
from fastapi import Body, Cookie, Depends, FastAPI, HTTPException, Query, Response
from fastapi import (
Body,
Cookie,
Depends,
FastAPI,
HTTPException,
Query,
Request,
Response,
)
from fastapi.security import HTTPBearer
from .. import aaguid
from ..authsession import delete_credential, get_reset, get_session
from ..authsession import delete_credential, expires, get_reset, get_session
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import tokens
from ..util import passphrase, tokens
from ..util.tokens import session_key
from . import authz, session
bearer_auth = HTTPBearer(auto_error=True)
app = FastAPI()
def register_api_routes(app: FastAPI):
"""Register non-admin API routes on the FastAPI app."""
@app.post("/auth/validate")
@app.post("/validate")
async def validate_token(perm=Query(None), auth=Cookie(None)):
s = await authz.verify(auth, perm)
return {"valid": True, "user_uuid": str(s.user_uuid)}
@app.get("/auth/settings")
@app.get("/settings")
async def get_settings():
pk = global_passkey.instance
return {"rp_id": pk.rp_id, "rp_name": pk.rp_name}
@app.post("/auth/user-info")
@app.post("/user-info")
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
authenticated = False
try:
if reset:
if not tokens.passphrase.is_well_formed(reset): # type: ignore[attr-defined]
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
s = await get_reset(reset)
else:
@ -135,7 +133,8 @@ def register_api_routes(app: FastAPI):
"aaguid_info": aaguid_info,
}
@app.put("/auth/user/display-name")
@app.put("/user/display-name")
async def user_update_display_name(payload: dict = Body(...), auth=Cookie(None)):
if not auth:
raise HTTPException(status_code=401, detail="Authentication Required")
@ -148,33 +147,47 @@ def register_api_routes(app: FastAPI):
await db.instance.update_user_display_name(s.user_uuid, new_name)
return {"status": "ok"}
@app.post("/auth/logout")
@app.post("/logout")
async def api_logout(response: Response, auth=Cookie(None)):
"""Log out the current user by clearing the session cookie and deleting from database."""
if not auth:
return {"message": "Already logged out"}
# Remove from database if possible
try:
with suppress(Exception):
await db.instance.delete_session(session_key(auth))
except Exception:
...
response.delete_cookie("auth")
return {"message": "Logged out successfully"}
@app.post("/auth/set-session")
@app.post("/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
"""Set session cookie from Authorization Bearer session token (never via query)."""
user = await get_session(auth.credentials)
session.set_session_cookie(response, auth.credentials)
return {
"message": "Session cookie set successfully",
"user_uuid": str(user.user_uuid),
}
@app.delete("/auth/credential/{uuid}")
async def api_delete_credential(
response: Response, uuid: UUID, auth: str = Cookie(None)
):
@app.delete("/credential/{uuid}")
async def api_delete_credential(uuid: UUID, auth: str = Cookie(None)):
await delete_credential(uuid, auth)
return {"message": "Credential deleted successfully"}
@app.post("/create-link")
async def api_create_link(request: Request, auth=Cookie(None)):
s = await get_session(auth)
token = passphrase.generate()
await db.instance.create_session(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info=session.infodict(request, "device addition"),
)
origin = global_passkey.instance.origin.rstrip("/")
url = f"{origin}/auth/{token}"
return {
"message": "Registration link generated successfully",
"url": url,
"expires": expires().isoformat(),
}

View File

@ -4,12 +4,13 @@ from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import Cookie, FastAPI, HTTPException, Query, Request, Response
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from . import admin, authz, ws
from .api import register_api_routes
from .reset import register_reset_routes
from passkey.util import passphrase
from ..globals import passkey as global_passkey
from . import admin, api, authz, ws
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
@ -51,6 +52,7 @@ async def lifespan(app: FastAPI): # pragma: no cover - startup path
app = FastAPI(lifespan=lifespan)
app.mount("/auth/ws", ws.app)
app.mount("/auth/admin", admin.app)
app.mount("/auth/api", api.app)
# Global exception handlers
@ -67,6 +69,35 @@ async def general_exception_handler(request: Request, exc: Exception):
return JSONResponse(status_code=500, content={"detail": "Internal server error"})
# Serve static files
app.mount(
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
)
@app.get("/auth/")
async def redirect_to_index():
"""Serve the main authentication app."""
return FileResponse(STATIC_DIR / "index.html")
@app.get("/auth/{reset_token}")
async def reset_authentication(request: Request, reset_token: str):
"""Validate reset token and redirect with it as query parameter (no cookies).
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
- Read the token from location.search
- Use it via Authorization header or websocket query param
- history.replaceState to remove it from the address bar/history
"""
if not passphrase.is_well_formed(reset_token):
raise HTTPException(status_code=404)
origin = global_passkey.instance.origin
# Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens.
url = f"{origin}/auth/?reset={reset_token}"
return RedirectResponse(url=url, status_code=303)
@app.get("/auth/forward-auth")
async def forward_authentication(request: Request, perm=Query(None), auth=Cookie(None)):
"""A validation endpoint to use with Caddy forward_auth or Nginx auth_request.
@ -86,20 +117,3 @@ async def forward_authentication(request: Request, perm=Query(None), auth=Cookie
)
except HTTPException as e:
return FileResponse(STATIC_DIR / "index.html", e.status_code)
# Serve static files
app.mount(
"/auth/assets", StaticFiles(directory=STATIC_DIR / "assets"), name="static assets"
)
@app.get("/auth/")
async def redirect_to_index():
"""Serve the main authentication app."""
return FileResponse(STATIC_DIR / "index.html")
# Register API routes
register_api_routes(app)
register_reset_routes(app)

View File

@ -1,59 +0,0 @@
from pathlib import Path
from fastapi import Cookie, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
from ..authsession import expires, get_session
from ..globals import db
from ..globals import passkey as global_passkey
from ..util import passphrase, tokens
from . import session
# Local copy to avoid circular import with mainapp
STATIC_DIR = Path(__file__).parent.parent / "frontend-build"
def register_reset_routes(app):
"""Register all device addition/reset routes on the FastAPI app."""
@app.post("/auth/create-link")
async def api_create_link(request: Request, response: Response, auth=Cookie(None)):
"""Create a device addition link for the authenticated user."""
# Require authentication
s = await get_session(auth)
# Generate a human-readable token
token = passphrase.generate() # e.g., "cross.rotate.yin.note.evoke"
await db.instance.create_session(
user_uuid=s.user_uuid,
key=tokens.reset_key(token),
expires=expires(),
info=session.infodict(request, "device addition"),
)
# Generate the device addition link with pretty URL using configured origin
origin = global_passkey.instance.origin.rstrip("/")
path = request.url.path.removesuffix("create-link") + token # /auth/<token>
url = f"{origin}{path}"
return {
"message": "Registration link generated successfully",
"url": url,
"expires": expires().isoformat(),
}
@app.get("/auth/{reset_token}")
async def reset_authentication(request: Request, reset_token: str):
"""Validate reset token and redirect with it as query parameter (no cookies).
After validation we 303 redirect to /auth/?reset=<token>. The frontend will:
- Read the token from location.search
- Use it via Authorization header or websocket query param
- history.replaceState to remove it from the address bar/history
"""
if not passphrase.is_well_formed(reset_token):
raise HTTPException(status_code=404)
origin = global_passkey.instance.origin
# Do not verify existence/expiry here; frontend + user-info endpoint will handle invalid tokens.
redirect_url = f"{origin}/auth/?reset={reset_token}"
return RedirectResponse(url=redirect_url, status_code=303)