Homelable could only own the root of an origin. Behind an existing proxy at `https://home.example/homelab/` the built `index.html` still asked for `/assets/*`, which fell through to whatever owned the root — and when that answered `text/html` for a `<script>` under `nosniff`, the browser failed the load on an HTTP 200. The only workaround was patching the checkout and rebuilding on every upgrade. One build-time knob, `VITE_BASE_PATH`, becomes Vite's `base`. Vite rewrites the asset URLs it emits; everything the app builds by hand goes through the new `utils/basePath.ts` — the axios instances, the WebSocket URL, the live-view route, the local brand icons, and the OIDC login href. `resolveServerPath` covers what the *backend* hands back, which is always root-absolute because it cannot know where the SPA is mounted: uploaded floor-plan URLs already stored in a canvas are resolved at render time, so plans predating the move keep loading. The OIDC callback used to redirect to `/`, dropping subpath users at the origin root after login; it now reads the prefix back out of `OIDC_REDIRECT_URI`. The default is `/`, and stays a no-op there by construction: every helper returns the string it returned before, the root build output is unchanged and both nginx sites are byte-identical to what shipped — the Docker image copies `docker/nginx.conf` verbatim and the installer keeps its original heredoc. Only a non-default prefix takes the generated config. Those generated configs use `root`, never `alias`, since `alias` plus `try_files` mis-resolves `$uri` — the Docker build lands the bundle in the matching subdirectory, and the installer symlinks it under `/var/www/homelable`. Both accept either reverse-proxy style, prefix forwarded intact or already stripped, with no redirect loop between them, and `absolute_redirect off` stops the no-slash 301 from eating the port. Closes #334. ha-relevant: maybe
130 lines
4.4 KiB
Python
130 lines
4.4 KiB
Python
import hmac
|
|
import logging
|
|
from collections.abc import Mapping
|
|
from typing import cast
|
|
|
|
from authlib.integrations.base_client.errors import OAuthError
|
|
from authlib.jose.errors import JoseError
|
|
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
|
|
from pydantic import BaseModel
|
|
from starlette.responses import RedirectResponse
|
|
|
|
from app.api.deps import AuthContext, get_auth_context
|
|
from app.core.config import app_base_path_of, settings
|
|
from app.core.security import (
|
|
clear_oidc_session_cookie,
|
|
create_access_token,
|
|
create_oidc_session_token,
|
|
get_oidc_client,
|
|
set_oidc_session_cookie,
|
|
verify_password,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class LoginRequest(BaseModel):
|
|
username: str
|
|
password: str
|
|
|
|
|
|
class TokenResponse(BaseModel):
|
|
access_token: str
|
|
token_type: str = "bearer"
|
|
|
|
|
|
class AuthConfigResponse(BaseModel):
|
|
mode: str
|
|
oidc_login_url: str | None = None
|
|
|
|
|
|
class CurrentUserResponse(BaseModel):
|
|
subject: str
|
|
display_name: str
|
|
auth_method: str
|
|
issuer: str | None = None
|
|
csrf_token: str | None = None
|
|
|
|
|
|
def _require_oidc_mode() -> None:
|
|
if settings.auth_mode != "oidc":
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="OIDC login is disabled")
|
|
|
|
|
|
@router.get("/config", response_model=AuthConfigResponse)
|
|
async def auth_config() -> AuthConfigResponse:
|
|
return AuthConfigResponse(
|
|
mode=settings.auth_mode,
|
|
oidc_login_url="/api/v1/auth/oidc/login" if settings.auth_mode == "oidc" else None,
|
|
)
|
|
|
|
|
|
@router.post("/login", response_model=TokenResponse)
|
|
async def login(body: LoginRequest) -> TokenResponse:
|
|
if settings.auth_mode != "local":
|
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Local login is disabled")
|
|
# Always run both checks to prevent timing-based username enumeration.
|
|
# hmac.compare_digest is constant-time; verify_password (bcrypt) always runs.
|
|
username_ok = hmac.compare_digest(body.username, settings.auth_username)
|
|
password_ok = verify_password(body.password, settings.auth_password_hash)
|
|
if not username_ok or not password_ok:
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials")
|
|
token = create_access_token(body.username)
|
|
return TokenResponse(access_token=token)
|
|
|
|
|
|
@router.get("/oidc/login")
|
|
async def oidc_login(request: Request) -> Response:
|
|
_require_oidc_mode()
|
|
client = get_oidc_client()
|
|
return cast(Response, await client.authorize_redirect(request, settings.oidc_redirect_uri))
|
|
|
|
|
|
@router.get("/oidc/callback")
|
|
async def oidc_callback(request: Request) -> Response:
|
|
_require_oidc_mode()
|
|
client = get_oidc_client()
|
|
try:
|
|
token = await client.authorize_access_token(request)
|
|
except (OAuthError, JoseError) as exc:
|
|
logger.warning("OIDC callback rejected: %s", exc.error)
|
|
request.session.clear()
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
|
|
|
userinfo = token.get("userinfo")
|
|
if not isinstance(userinfo, Mapping):
|
|
request.session.clear()
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed")
|
|
try:
|
|
session_token = create_oidc_session_token(userinfo)
|
|
except ValueError:
|
|
request.session.clear()
|
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="OIDC authentication failed") from None
|
|
|
|
request.session.clear()
|
|
# Back to the SPA — which is not necessarily at the root of the origin.
|
|
response = RedirectResponse(
|
|
url=app_base_path_of(settings.oidc_redirect_uri), status_code=status.HTTP_303_SEE_OTHER
|
|
)
|
|
set_oidc_session_cookie(response, session_token)
|
|
return response
|
|
|
|
|
|
@router.get("/me", response_model=CurrentUserResponse)
|
|
async def current_user(context: AuthContext = Depends(get_auth_context)) -> CurrentUserResponse:
|
|
return CurrentUserResponse(
|
|
subject=context.subject,
|
|
display_name=context.display_name,
|
|
auth_method=context.auth_method,
|
|
issuer=context.issuer,
|
|
csrf_token=context.csrf_token,
|
|
)
|
|
|
|
|
|
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
|
|
async def logout(_: AuthContext = Depends(get_auth_context)) -> Response:
|
|
response = Response(status_code=status.HTTP_204_NO_CONTENT)
|
|
clear_oidc_session_cookie(response)
|
|
return response
|