Remodel reset token handling due to browsers sometimes refusing to set the cookie when opening the link (from another site).

This commit is contained in:
Leo Vasanko
2025-08-30 15:54:17 -06:00
parent 4f094a7016
commit 3e5c0065d5
8 changed files with 113 additions and 89 deletions

View File

@@ -52,47 +52,42 @@ def register_api_routes(app: FastAPI):
}
@app.post("/auth/user-info")
async def api_user_info(response: Response, auth=Cookie(None)):
async def api_user_info(reset: str | None = None, auth=Cookie(None)):
"""Get user information.
- For authenticated sessions: return full context (org/role/permissions/credentials)
- For reset tokens: return only basic user information to drive reset flow
"""
reset = False
authenticated = False
try:
if auth is None:
raise ValueError("Auth cookie missing")
reset = passphrase.is_well_formed(auth)
s = await (get_reset if reset else get_session)(auth)
except ValueError as e:
if reset:
print(e, reset, auth, tokens.reset_key(auth).hex())
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
s = await get_reset(reset)
else:
print(e, reset, auth)
raise HTTPException(
status_code=401,
detail="Authentication Required",
headers={"WWW-Authenticate": "Bearer"},
)
if auth is None:
raise ValueError("Authentication Required")
s = await get_session(auth)
authenticated = True
except ValueError as e:
raise HTTPException(401, str(e))
u = await db.instance.get_user_by_uuid(s.user_uuid)
# Minimal response for reset tokens
if reset:
u = await db.instance.get_user_by_uuid(s.user_uuid)
if not authenticated:
return {
"authenticated": False,
"session_type": s.info.get("type"),
"user": {
"user_uuid": str(u.uuid),
"user_name": u.display_name,
"created_at": u.created_at.isoformat() if u.created_at else None,
"last_seen": u.last_seen.isoformat() if u.last_seen else None,
"visits": u.visits,
},
}
# Full context for authenticated sessions
assert authenticated and auth is not None
ctx = await db.instance.get_session_context(session_key(auth))
u = await db.instance.get_user_by_uuid(s.user_uuid)
credential_ids = await db.instance.get_credentials_by_user_uuid(s.user_uuid)
credentials: list[dict] = []
@@ -224,25 +219,22 @@ def register_api_routes(app: FastAPI):
async def admin_update_org(
org_uuid: UUID, payload: dict = Body(...), auth=Cookie(None)
):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
if not (is_global_admin or (is_org_admin and ctx.org.uuid == org_uuid)):
raise ValueError("Insufficient permissions")
from ..db import Org as OrgDC
# Only global admins can modify org definitions (simpler rule)
_, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin:
raise ValueError("Global admin required")
from ..db import Org as OrgDC # local import to avoid cycles
current = await db.instance.get_organization(str(org_uuid))
display_name = payload.get("display_name") or current.display_name
permissions = (
payload.get("permissions")
if "permissions" in payload
else current.permissions
) or []
permissions = payload.get("permissions") or current.permissions or []
org = OrgDC(uuid=org_uuid, display_name=display_name, permissions=permissions)
await db.instance.update_organization(org)
return {"status": "ok"}
@app.delete("/auth/admin/orgs/{org_uuid}")
async def admin_delete_org(org_uuid: UUID, auth=Cookie(None)):
ctx, is_global_admin, is_org_admin = await _get_ctx_and_admin_flags(auth)
ctx, is_global_admin, _ = await _get_ctx_and_admin_flags(auth)
if not is_global_admin:
# Org admins cannot delete at all (avoid self-lockout)
raise ValueError("Global admin required")
@@ -372,7 +364,6 @@ def register_api_routes(app: FastAPI):
role_uuid=role_obj.uuid,
visits=0,
created_at=None,
last_seen=None,
)
await db.instance.create_user(user)
return {"uuid": str(user_uuid)}
@@ -582,10 +573,8 @@ def register_api_routes(app: FastAPI):
@app.post("/auth/set-session")
async def api_set_session(response: Response, auth=Depends(bearer_auth)):
"""Set session cookie from Authorization header. Fetched after login by WebSocket."""
"""Set session cookie from Authorization Bearer session token (never via query)."""
user = await get_session(auth.credentials)
if not user:
raise ValueError("Invalid Authorization header.")
session.set_session_cookie(response, auth.credentials)
return {

View File

@@ -1,4 +1,4 @@
import logging
from pathlib import Path
from fastapi import Cookie, HTTPException, Request, Response
from fastapi.responses import RedirectResponse
@@ -9,6 +9,9 @@ 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."""
@@ -28,9 +31,10 @@ def register_reset_routes(app):
info=session.infodict(request, "device addition"),
)
# Generate the device addition link with pretty URL
path = request.url.path.removesuffix("create-link") + token
url = f"{request.headers['origin']}{path}"
# 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",
@@ -39,31 +43,17 @@ def register_reset_routes(app):
}
@app.get("/auth/{reset_token}")
async def reset_authentication(
request: Request,
reset_token: str,
):
"""Verifies the token and redirects to auth app for credential registration."""
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
try:
# Get session token to validate it exists and get user_id
key = tokens.reset_key(reset_token)
sess = await db.instance.get_session(key)
if not sess:
raise ValueError("Invalid or expired registration token")
response = RedirectResponse(url=f"{origin}/auth/", status_code=303)
session.set_session_cookie(response, reset_token)
print(response.headers)
return response
except Exception as e:
# On any error, redirect to auth app
if isinstance(e, ValueError):
msg = str(e)
else:
logging.exception("Internal Server Error in reset_authentication")
msg = "Internal Server Error"
return RedirectResponse(url=f"{origin}/auth/#{msg}", status_code=303)
# 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)

View File

@@ -54,12 +54,26 @@ async def register_chat(
@app.websocket("/register")
@websocket_error_handler
async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
"""Register a new credential for an existing user."""
async def websocket_register_add(
ws: WebSocket, reset: str | None = None, auth=Cookie(None)
):
"""Register a new credential for an existing user.
Supports either:
- Normal session via auth cookie
- Reset token supplied as ?reset=... (auth cookie ignored)
"""
origin = ws.headers["origin"]
# Try to get either a regular session or a reset session
reset = passphrase.is_well_formed(auth)
s = await (get_reset if reset else get_session)(auth)
is_reset = False
if reset is not None:
if not passphrase.is_well_formed(reset):
raise ValueError("Invalid reset token")
is_reset = True
s = await get_reset(reset)
else:
if not auth:
raise ValueError("Authentication Required")
s = await get_session(auth)
user_uuid = s.user_uuid
# Get user information to get the user_name
@@ -73,23 +87,19 @@ async def websocket_register_add(ws: WebSocket, auth=Cookie(None)):
# to satisfy the sessions.credential_uuid foreign key (now enforced).
await db.instance.create_credential(credential)
if reset:
if is_reset:
# Invalidate the one-time reset session only after credential persisted
await db.instance.delete_session(s.key)
token = await create_session(
auth = await create_session(
user_uuid, credential.uuid, infodict(ws, "authenticated")
)
else:
# Existing session continues; we don't need to create a new one here.
token = auth
assert isinstance(token, str) and len(token) == 16
assert isinstance(auth, str) and len(auth) == 16
await ws.send_json(
{
"user_uuid": str(user.uuid),
"credential_uuid": str(credential.uuid),
"session_token": token,
"session_token": auth,
"message": "New credential added successfully",
}
)