Merge pull request #306 from autogpt-elendir/feat/oidc-auth

feat(auth): add generic OIDC browser sessions
This commit is contained in:
Pouzor - Rémy Jardient
2026-07-28 00:29:41 +02:00
committed by GitHub
31 changed files with 2079 additions and 211 deletions
+20 -2
View File
@@ -1,4 +1,5 @@
# Backend - server-side only (NEVER commit .env)
# Generate: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
SQLITE_PATH=./data/homelab.db
# Uploaded media (floor plans) folder. Optional — defaults to <SQLITE_PATH dir>/uploads,
@@ -7,13 +8,30 @@ SQLITE_PATH=./data/homelab.db
# Set this to the URL(s) you use to access Homelable in your browser.
CORS_ORIGINS=["http://localhost:5173","http://localhost:3000"]
# Auth — default credentials: admin / admin
# Auth — local mode is the backward-compatible default.
AUTH_MODE=local
# Local credentials: admin / admin by default.
# ⚠️ Change before exposing on a network.
# Generate a new hash: python3 -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
# Generate a new hash: python3 -c "import bcrypt; print(bcrypt.hashpw(b'yourpassword', bcrypt.gensalt()).decode())"
# ⚠️ Keep the single quotes around the hash — bcrypt hashes contain \$ which Docker misinterprets without them.
AUTH_USERNAME=admin
AUTH_PASSWORD_HASH='$2b$12$RtMbyw17l4N5UGzeXMNAWuzCaVV.XFBY7ZetWheQhxcBDcxahapkG'
# OpenID Connect (optional alternative to local auth).
# Set AUTH_MODE=oidc and configure every value below. The callback must be
# registered exactly at the identity provider. Keep CORS_ORIGINS restricted to
# the Homelable browser origin; wildcard CORS is rejected in OIDC mode. OIDC
# also requires SECRET_KEY to contain at least 32 bytes.
# AUTH_MODE=oidc
# OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
# OIDC_CLIENT_ID=homelable
# OIDC_CLIENT_SECRET=replace-with-a-secret
# OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
# OIDC_SCOPES="openid profile email"
# OIDC_COOKIE_SECURE=true
# OIDC_SESSION_EXPIRE_MINUTES=480
# OIDC_TRANSACTION_EXPIRE_SECONDS=600
# Scanner — JSON array of CIDR ranges to scan
SCANNER_RANGES=["192.168.1.0/24"]
+5 -4
View File
@@ -31,11 +31,12 @@ jobs:
with:
node-version: '20'
- name: NPM Audit
run: cd frontend && npm audit --audit-level=high
# --omit=dev: only ship-time deps gate the build. Dev-only tooling
# (shadcn CLI and its transitive tree: hono, fast-uri, …) is never
# bundled or served, so its advisories must not fail release CI.
run: cd frontend && npm audit --omit=dev --audit-level=high
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Pip audit
# CVE-2024-23342 (ecdsa): timing attack on ECDSA — not exploitable (app uses HS256/HMAC only).
# python-jose forces ecdsa as a dependency with no fixed version available.
run: pip install pip-audit && pip-audit -r backend/requirements.txt --ignore-vuln CVE-2024-23342
run: pip install pip-audit && pip-audit -r backend/requirements.txt
+18
View File
@@ -29,6 +29,7 @@ Here's what Homelable can do. One line on what each feature is, then how to swit
15. [Gethomepage Widget](#15-gethomepage-widget-)
16. [MCP Server (AI integration)](#16-mcp-server-)
17. [Settings & Shortcuts](#17-settings--shortcuts)
18. [Authentication (Local / OpenID Connect)](#18-authentication-local--openid-connect-)
---
@@ -252,4 +253,21 @@ The AI can list nodes/edges/canvas/pending/scans, add/update/delete nodes and ed
---
## 18. Authentication (Local / OpenID Connect) 🔒
**What:** Homelable protects the app behind a login. Two exclusive modes, set once in `.env` with `AUTH_MODE`:
- **`local`** (default) — a single username + bcrypt-hashed password. Nothing changes for existing installs.
- **`oidc`** — sign in through your own identity provider (Authentik, Keycloak, Authelia, Google, …) via **Authorization Code + PKCE**. Good for SSO, per-user accounts at the IdP, MFA, and central account management.
**Use (OIDC):**
1. Register Homelable as a **confidential** client at your IdP and set the redirect URI to `https://<your-host>/api/v1/auth/oidc/callback`.
2. In `.env` set `AUTH_MODE=oidc` and the `OIDC_*` values (discovery URL, client id/secret, redirect URI, scopes), keep `CORS_ORIGINS` pinned to your browser origin, and give `SECRET_KEY` at least 32 bytes.
3. Restart the backend. The login screen now shows **Sign in with OpenID Connect**.
**How it protects you:** provider tokens are exchanged server-side and never touch the browser — Homelable issues its own short-lived, `__Host-`, HttpOnly, `SameSite=Lax` session cookie. Discovery metadata, issuer, audience, signature, expiry, nonce, state and PKCE are all validated; cookie-authenticated writes require a CSRF token and an allowed `Origin`; the status WebSocket authenticates from the cookie and validates `Origin`. Local Bearer auth and the MCP service key keep working unchanged. Full config, provider examples and troubleshooting: [docs/oidc-auth.md](./docs/oidc-auth.md).
> OIDC is Full-mode only — the no-backend standalone build has no login.
---
*Installing (Docker, Proxmox LXC, source) is covered in [INSTALLATION.md](./INSTALLATION.md). Running Home Assistant? See [homelable-hacs](https://github.com/Pouzor/homelable-hacs).*
+26 -2
View File
@@ -13,7 +13,7 @@ Open **http://localhost:3000** — login with `admin` / `admin`.
>
Generate a new hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode())'
```
@@ -21,7 +21,7 @@ docker compose exec backend python -c "from passlib.context import CryptContext;
- **`.env` file** (recommended): wrap the hash in single quotes → `AUTH_PASSWORD_HASH='$2b$12$...'`
- **`docker-compose.yml` `environment:` block**: escape every `$` as `$$` — use this command to generate a pre-escaped hash:
```bash
docker compose exec backend python -c "from passlib.context import CryptContext; print(CryptContext(schemes=['bcrypt']).hash('yourpassword').replace('\$', '\$\$'))"
docker compose exec backend python -c 'import bcrypt; print(bcrypt.hashpw(b"yourpassword", bcrypt.gensalt()).decode().replace("$", "$$"))'
```
## Quick Start — Frontend only
@@ -69,6 +69,7 @@ bash -c "$(curl -fsSL https://raw.githubusercontent.com/community-scripts/Proxmo
All configuration is done via `.env` (copied from `.env.example`):
```env
# Generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
SECRET_KEY=change_me_in_production
# Auth — default: admin / admin
@@ -82,6 +83,29 @@ SCANNER_RANGES=["192.168.1.0/24"]
STATUS_CHECKER_INTERVAL=60
```
### OpenID Connect (optional)
OIDC is an exclusive alternative to the local password. Existing installs stay
in `AUTH_MODE=local` unless explicitly changed.
```env
AUTH_MODE=oidc
CORS_ORIGINS=["https://homelable.example"]
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
```
Register `OIDC_REDIRECT_URI` exactly at the identity provider. Production OIDC
requires HTTPS, a non-wildcard `CORS_ORIGINS`, and a confidential client. The
backend uses Authorization Code with PKCE and keeps provider tokens out of the
browser. `SECRET_KEY` must contain at least 32 bytes in OIDC mode. Do not expose
`OIDC_CLIENT_SECRET` in Compose YAML or commit it to Git.
All settings are also editable in-app via the **Scan Network** button.
---
+45 -10
View File
@@ -1,31 +1,66 @@
import hmac
from dataclasses import dataclass
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from app.core.security import decode_token
from app.core.security import decode_token, get_oidc_session
bearer = HTTPBearer(auto_error=False)
def get_current_user(
@dataclass(frozen=True)
class AuthContext:
subject: str
display_name: str
auth_method: str
issuer: str | None = None
csrf_token: str | None = None
def get_auth_context(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer),
x_mcp_service_key: str | None = Header(default=None),
) -> str:
) -> AuthContext:
# 1. MCP service key (Docker-internal only — backend port is not externally exposed)
if x_mcp_service_key is not None:
if not settings.mcp_service_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="MCP service key not configured")
if not hmac.compare_digest(x_mcp_service_key.encode(), settings.mcp_service_key.encode()):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid MCP service key")
return "__mcp_service__"
return AuthContext(
subject="__mcp_service__",
display_name="MCP service",
auth_method="mcp",
)
# 2. Standard JWT bearer token
if credentials is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return username
if credentials is not None:
username = decode_token(credentials.credentials)
if not username:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token")
return AuthContext(
subject=username,
display_name=username,
auth_method="local",
)
# 3. OIDC session cookie (only active in exclusive OIDC mode)
if settings.auth_mode == "oidc":
oidc_session = get_oidc_session(request)
if oidc_session is not None:
return AuthContext(
subject=oidc_session.subject,
display_name=oidc_session.display_name,
auth_method="oidc",
issuer=oidc_session.issuer,
csrf_token=oidc_session.csrf_token,
)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated")
def get_current_user(context: AuthContext = Depends(get_auth_context)) -> str:
return context.subject
+97 -2
View File
@@ -1,12 +1,27 @@
import hmac
import logging
from collections.abc import Mapping
from typing import cast
from fastapi import APIRouter, HTTPException, status
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 settings
from app.core.security import create_access_token, verify_password
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):
@@ -19,8 +34,36 @@ class TokenResponse(BaseModel):
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)
@@ -29,3 +72,55 @@ async def login(body: LoginRequest) -> TokenResponse:
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()
response = RedirectResponse(url="/", 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
+28 -12
View File
@@ -3,7 +3,13 @@ import json
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.core.security import decode_token
from app.core.config import settings
from app.core.security import (
decode_oidc_session_token,
decode_token,
oidc_session_cookie_name,
origin_is_allowed,
)
router = APIRouter()
@@ -21,19 +27,29 @@ def _drop(websocket: WebSocket) -> None:
async def ws_status(websocket: WebSocket) -> None:
# Accept first so we can send a close frame with a reason code
await websocket.accept()
try:
# Expect the first message to be a JSON auth payload: {"token": "<jwt>"}
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
if not origin_is_allowed(websocket.headers.get("origin")):
await websocket.close(code=1008) # Policy Violation
return
if settings.auth_mode == "oidc":
cookie_token = websocket.cookies.get(oidc_session_cookie_name(), "")
if not cookie_token or decode_oidc_session_token(cookie_token) is None:
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
else:
# Local mode keeps the existing first-message bearer protocol.
try:
raw = await websocket.receive_text()
try:
payload = json.loads(raw)
token = payload.get("token", "")
except (json.JSONDecodeError, AttributeError):
token = ""
if not token or not decode_token(token):
await websocket.close(code=1008) # Policy Violation
return
except WebSocketDisconnect:
return
_connections.append(websocket)
try:
+43 -2
View File
@@ -1,8 +1,9 @@
import json
import logging
from pathlib import Path
from typing import Literal
from pydantic import model_validator
from pydantic import Field, model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
logger = logging.getLogger(__name__)
@@ -35,11 +36,22 @@ class Settings(BaseSettings):
access_token_expire_minutes: int = 1440 # 24h
# Auth — set AUTH_USERNAME and AUTH_PASSWORD_HASH in .env
auth_mode: Literal["local", "oidc"] = "local"
auth_username: str = "admin"
auth_password_hash: str = ""
# OpenID Connect — required only when AUTH_MODE=oidc.
oidc_discovery_url: str = ""
oidc_client_id: str = ""
oidc_client_secret: str = ""
oidc_redirect_uri: str = ""
oidc_scopes: str = "openid profile email"
oidc_cookie_secure: bool = True
oidc_session_expire_minutes: int = Field(default=480, ge=5, le=1440)
oidc_transaction_expire_seconds: int = Field(default=600, ge=60, le=3600)
@model_validator(mode="after")
def check_password_hash(self) -> "Settings":
def validate_auth_settings(self) -> "Settings":
h = self.auth_password_hash
if h and not h.startswith("$2"):
logger.error(
@@ -47,6 +59,35 @@ class Settings(BaseSettings):
"bcrypt hashes contain '$' signs — wrap the value in single quotes "
"in your .env file: AUTH_PASSWORD_HASH='$2b$12$...'"
)
if self.auth_mode == "oidc":
required = {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_CLIENT_ID": self.oidc_client_id,
"OIDC_CLIENT_SECRET": self.oidc_client_secret,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}
missing = [name for name, value in required.items() if not value]
if missing:
raise ValueError(f"AUTH_MODE=oidc requires: {', '.join(missing)}")
if len(self.secret_key.encode("utf-8")) < 32:
raise ValueError("SECRET_KEY must be at least 32 bytes when AUTH_MODE=oidc")
if "openid" not in self.oidc_scopes.split():
raise ValueError("OIDC_SCOPES must contain 'openid'")
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith(("https://", "http://")):
raise ValueError(f"{name} must be an HTTP(S) URL")
if self.oidc_cookie_secure:
for name, value in {
"OIDC_DISCOVERY_URL": self.oidc_discovery_url,
"OIDC_REDIRECT_URI": self.oidc_redirect_uri,
}.items():
if not value.startswith("https://"):
raise ValueError(f"{name} must use HTTPS when OIDC_COOKIE_SECURE=true")
if "*" in self.cors_origins:
raise ValueError("CORS_ORIGINS cannot contain '*' when AUTH_MODE=oidc")
return self
# Scanner
+181 -3
View File
@@ -1,10 +1,33 @@
import hmac
import secrets
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from typing import Any
import bcrypt
from jose import JWTError, jwt
import jwt
from authlib.integrations.starlette_client import OAuth, StarletteOAuth2App
from fastapi import Request, Response, status
from jwt import InvalidTokenError
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.responses import JSONResponse
from app.core.config import settings
ACCESS_TOKEN_USE = "access"
OIDC_SESSION_TOKEN_USE = "oidc_session"
SAFE_METHODS = frozenset({"GET", "HEAD", "OPTIONS", "TRACE"})
@dataclass(frozen=True)
class OIDCSession:
subject: str
issuer: str
display_name: str
csrf_token: str
def verify_password(plain: str, hashed: str) -> bool:
if not plain or not hashed:
@@ -21,14 +44,169 @@ def hash_password(password: str) -> str:
def create_access_token(subject: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": subject, "exp": expire}
payload = {"sub": subject, "exp": expire, "token_use": ACCESS_TOKEN_USE}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_token(token: str) -> str | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") not in (None, ACCESS_TOKEN_USE):
return None
sub = payload.get("sub")
return str(sub) if sub is not None else None
except JWTError:
except InvalidTokenError:
return None
def create_oidc_session_token(userinfo: Mapping[str, Any]) -> str:
subject = userinfo.get("sub")
issuer = userinfo.get("iss")
if not isinstance(subject, str) or not subject or not isinstance(issuer, str) or not issuer:
raise ValueError("OIDC userinfo must contain non-empty 'sub' and 'iss' claims")
display_name = next(
(
value
for value in (
userinfo.get("preferred_username"),
userinfo.get("name"),
userinfo.get("email"),
subject,
)
if isinstance(value, str) and value
),
subject,
)
now = datetime.now(timezone.utc)
payload = {
"sub": subject,
"oidc_issuer": issuer,
"name": display_name,
"csrf": secrets.token_urlsafe(32),
"iat": now,
"exp": now + timedelta(minutes=settings.oidc_session_expire_minutes),
"token_use": OIDC_SESSION_TOKEN_USE,
}
return str(jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm))
def decode_oidc_session_token(token: str) -> OIDCSession | None:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
if payload.get("token_use") != OIDC_SESSION_TOKEN_USE:
return None
subject = payload.get("sub")
issuer = payload.get("oidc_issuer")
display_name = payload.get("name")
csrf_token = payload.get("csrf")
if not isinstance(subject, str) or not subject:
return None
if not isinstance(issuer, str) or not issuer:
return None
if not isinstance(display_name, str) or not display_name:
return None
if not isinstance(csrf_token, str) or not csrf_token:
return None
return OIDCSession(
subject=subject,
issuer=issuer,
display_name=display_name,
csrf_token=csrf_token,
)
except InvalidTokenError:
return None
def oidc_session_cookie_name() -> str:
return "__Host-homelable-session" if settings.oidc_cookie_secure else "homelable-session"
def set_oidc_session_cookie(response: Response, token: str) -> None:
response.set_cookie(
key=oidc_session_cookie_name(),
value=token,
max_age=settings.oidc_session_expire_minutes * 60,
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def clear_oidc_session_cookie(response: Response) -> None:
response.delete_cookie(
key=oidc_session_cookie_name(),
secure=settings.oidc_cookie_secure,
httponly=True,
samesite="lax",
path="/",
)
def get_oidc_session(request: Request) -> OIDCSession | None:
token = request.cookies.get(oidc_session_cookie_name())
return decode_oidc_session_token(token) if token else None
def origin_is_allowed(origin: str | None) -> bool:
if not origin:
return False
allowed_origins = {value.rstrip("/") for value in settings.cors_origins}
return origin.rstrip("/") in allowed_origins
class OIDCCSRFMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if settings.auth_mode != "oidc" or request.method in SAFE_METHODS:
return await call_next(request)
# Explicit non-cookie credentials take precedence in get_current_user,
# so their requests keep the existing CSRF-free API behavior.
if request.headers.get("Authorization") or request.headers.get("X-MCP-Service-Key"):
return await call_next(request)
oidc_session = get_oidc_session(request)
if oidc_session is None:
return await call_next(request)
csrf_token = request.headers.get("X-Homelable-CSRF", "")
csrf_valid = hmac.compare_digest(csrf_token.encode(), oidc_session.csrf_token.encode())
if not csrf_valid or not origin_is_allowed(request.headers.get("Origin")):
return JSONResponse(
status_code=status.HTTP_403_FORBIDDEN,
content={"detail": "CSRF validation failed"},
)
return await call_next(request)
@lru_cache(maxsize=8)
def _build_oidc_client(
discovery_url: str,
client_id: str,
client_secret: str,
scopes: str,
) -> StarletteOAuth2App:
oauth = OAuth()
client = oauth.register(
name="oidc",
client_id=client_id,
client_secret=client_secret,
server_metadata_url=discovery_url,
client_kwargs={
"scope": scopes,
"code_challenge_method": "S256",
},
)
if client is None:
raise RuntimeError("Failed to register OIDC client")
return client
def get_oidc_client() -> StarletteOAuth2App:
return _build_oidc_client(
settings.oidc_discovery_url,
settings.oidc_client_id,
settings.oidc_client_secret,
settings.oidc_scopes,
)
+12 -1
View File
@@ -6,6 +6,7 @@ from typing import Any
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from starlette.middleware.sessions import SessionMiddleware
from app.api.routes import (
auth,
@@ -25,6 +26,7 @@ from app.api.routes import (
from app.api.routes import settings as settings_routes
from app.core.config import settings
from app.core.scheduler import start_scheduler, stop_scheduler
from app.core.security import OIDCCSRFMiddleware
from app.db.database import init_db
@@ -53,12 +55,21 @@ app = FastAPI(
lifespan=lifespan,
)
app.add_middleware(OIDCCSRFMiddleware)
app.add_middleware(
SessionMiddleware,
secret_key=settings.secret_key,
session_cookie=("__Host-homelable-oidc-flow" if settings.oidc_cookie_secure else "homelable-oidc-flow"),
max_age=settings.oidc_transaction_expire_seconds,
same_site="lax",
https_only=settings.oidc_cookie_secure,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE"],
allow_headers=["Authorization", "Content-Type"],
allow_headers=["Authorization", "Content-Type", "X-Homelable-CSRF", "X-MCP-Service-Key"],
)
app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"])
+3 -1
View File
@@ -6,8 +6,10 @@ aiosqlite==0.20.0
alembic==1.13.3
pydantic==2.9.2
pydantic-settings==2.5.2
python-jose[cryptography]==3.5.0
PyJWT==2.13.0
bcrypt==4.2.1
authlib==1.7.2
itsdangerous==2.2.0
python-multipart==0.0.31
apscheduler==3.10.4
python-nmap==0.7.1
+288 -3
View File
@@ -1,5 +1,56 @@
import time
from unittest.mock import AsyncMock, patch
from urllib.parse import parse_qs, urlparse
import pytest
from authlib.integrations.base_client.errors import OAuthError
from authlib.jose.errors import ExpiredTokenError, JoseError
from fastapi import WebSocketDisconnect
from httpx import AsyncClient
from starlette.responses import RedirectResponse
from starlette.testclient import TestClient
class FakeOIDCClient:
def __init__(self, token=None, error: OAuthError | JoseError | None = None):
self.token = token or {}
self.error = error
self.redirect_uri: str | None = None
async def authorize_redirect(self, _request, redirect_uri: str):
self.redirect_uri = redirect_uri
return RedirectResponse("https://idp.example/application/o/authorize/?state=test", status_code=302)
async def authorize_access_token(self, _request):
if self.error is not None:
raise self.error
return self.token
@pytest.fixture
def oidc_settings():
from app.core.config import settings
from app.core.security import _build_oidc_client
values = {
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "test-client-secret",
"oidc_redirect_uri": "http://test/api/v1/auth/oidc/callback",
"oidc_scopes": "openid profile email",
"oidc_cookie_secure": False,
"oidc_session_expire_minutes": 60,
"cors_origins": ["http://test"],
}
original = {name: getattr(settings, name) for name in values}
for name, value in values.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
yield settings
for name, value in original.items():
setattr(settings, name, value)
_build_oidc_client.cache_clear()
async def test_login_success(client: AsyncClient):
@@ -10,6 +61,12 @@ async def test_login_success(client: AsyncClient):
assert data["token_type"] == "bearer"
async def test_auth_config_defaults_to_local(client: AsyncClient):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {"mode": "local", "oidc_login_url": None}
async def test_login_wrong_password(client: AsyncClient):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "wrong"})
assert res.status_code == 401
@@ -76,7 +133,7 @@ async def test_expired_token_rejected(client: AsyncClient):
"""A JWT whose `exp` is in the past must be refused."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
@@ -97,14 +154,14 @@ async def test_token_signed_with_wrong_secret_rejected(client: AsyncClient):
"""A token signed with a different key must not be accepted."""
from datetime import datetime, timedelta, timezone
from jose import jwt
import jwt
from app.core.config import settings
payload = {
"sub": "admin",
"exp": datetime.now(timezone.utc) + timedelta(minutes=5),
}
forged = jwt.encode(payload, "different-secret", algorithm=settings.algorithm)
forged = jwt.encode(payload, "different-secret-that-is-at-least-32-bytes", algorithm=settings.algorithm)
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {forged}"})
assert res.status_code == 401
@@ -136,3 +193,231 @@ def test_verify_password_handles_empty_inputs():
assert verify_password("", h) is False
assert verify_password("hunter2", "") is False
assert verify_password("", "") is False
# --- OpenID Connect ---
async def test_oidc_config_exposes_login_url(client: AsyncClient, oidc_settings):
res = await client.get("/api/v1/auth/config")
assert res.status_code == 200
assert res.json() == {
"mode": "oidc",
"oidc_login_url": "/api/v1/auth/oidc/login",
}
async def test_local_login_is_disabled_in_oidc_mode(client: AsyncClient, oidc_settings):
res = await client.post("/api/v1/auth/login", json={"username": "admin", "password": "admin"})
assert res.status_code == 404
async def test_oidc_login_is_hidden_in_local_mode(client: AsyncClient):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 404
async def test_oidc_login_uses_configured_callback(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient()
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/login")
assert res.status_code == 302
assert res.headers["location"].startswith("https://idp.example/")
assert fake_client.redirect_uri == "http://test/api/v1/auth/oidc/callback"
async def test_oidc_client_enables_pkce_s256_and_nonce(oidc_settings):
from app.core.security import get_oidc_client
oidc_client = get_oidc_client()
metadata = {"authorization_endpoint": "https://idp.example/application/o/authorize/"}
with patch.object(oidc_client, "load_server_metadata", AsyncMock(return_value=metadata)):
authorization = await oidc_client.create_authorization_url(oidc_settings.oidc_redirect_uri)
query = parse_qs(urlparse(authorization["url"]).query)
assert query["response_type"] == ["code"]
assert query["code_challenge_method"] == ["S256"]
assert "code_challenge" in query
assert "code_verifier" not in query
assert query["nonce"] == [authorization["nonce"]]
assert authorization["code_verifier"]
async def test_oidc_callback_creates_session_for_api(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
callback = await client.get("/api/v1/auth/oidc/callback")
assert callback.status_code == 303
assert callback.headers["location"] == "/"
cookie = callback.headers["set-cookie"]
assert "homelable-session=" in cookie
assert "HttpOnly" in cookie
assert "SameSite=lax" in cookie
me = await client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["subject"] == "user-123"
assert me.json()["display_name"] == "alice"
assert me.json()["auth_method"] == "oidc"
assert me.json()["issuer"] == "https://idp.example/application/o/homelable/"
assert me.json()["csrf_token"]
protected = await client.get("/api/v1/nodes")
assert protected.status_code == 200
@pytest.mark.parametrize("userinfo", [None, {}, {"sub": "user-123"}, {"iss": "https://idp.example/"}])
async def test_oidc_callback_rejects_missing_identity_claims(client: AsyncClient, oidc_settings, userinfo):
token = {} if userinfo is None else {"userinfo": userinfo}
fake_client = FakeOIDCClient(token=token)
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
async def test_oidc_callback_hides_protocol_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=OAuthError(error="invalid_grant", description="sensitive detail"))
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "sensitive detail" not in res.text
async def test_oidc_callback_hides_claim_validation_error_details(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(error=ExpiredTokenError())
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
res = await client.get("/api/v1/auth/oidc/callback")
assert res.status_code == 401
assert res.json() == {"detail": "OIDC authentication failed"}
assert "expired" not in res.text.lower()
async def test_oidc_session_token_cannot_be_used_as_bearer(client: AsyncClient, oidc_settings):
from app.core.security import create_oidc_session_token
token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
res = await client.get("/api/v1/nodes", headers={"Authorization": f"Bearer {token}"})
assert res.status_code == 401
async def test_oidc_cookie_mutations_require_csrf_and_allowed_origin(client: AsyncClient, oidc_settings):
fake_client = FakeOIDCClient(token={
"userinfo": {
"iss": "https://idp.example/application/o/homelable/",
"sub": "user-123",
"preferred_username": "alice",
},
})
with patch("app.api.routes.auth.get_oidc_client", return_value=fake_client):
await client.get("/api/v1/auth/oidc/callback")
csrf_token = (await client.get("/api/v1/auth/me")).json()["csrf_token"]
missing = await client.post("/api/v1/auth/logout")
assert missing.status_code == 403
wrong_origin = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "https://evil.example", "X-Homelable-CSRF": csrf_token},
)
assert wrong_origin.status_code == 403
valid = await client.post(
"/api/v1/auth/logout",
headers={"Origin": "http://test", "X-Homelable-CSRF": csrf_token},
)
assert valid.status_code == 204
assert "Max-Age=0" in valid.headers["set-cookie"]
assert (await client.get("/api/v1/auth/me")).status_code == 401
async def test_local_bearer_logout_does_not_require_csrf(client: AsyncClient, headers):
res = await client.post("/api/v1/auth/logout", headers=headers)
assert res.status_code == 204
# --- WebSocket authentication ---
def _wait_for_connection_count(expected: int) -> None:
from app.api.routes import status as status_routes
deadline = time.monotonic() + 1
while len(status_routes._connections) != expected and time.monotonic() < deadline:
time.sleep(0.01)
assert len(status_routes._connections) == expected
def test_local_websocket_keeps_first_message_bearer_flow():
from app.core.security import create_access_token
from app.main import app
test_client = TestClient(app)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://localhost:3000"},
) as websocket:
websocket.send_json({"token": create_access_token("admin")})
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_websocket_rejects_untrusted_origin_before_auth():
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "https://evil.example"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
def test_oidc_websocket_uses_session_cookie_without_javascript_token(oidc_settings):
from app.core.security import create_oidc_session_token, oidc_session_cookie_name
from app.main import app
session_token = create_oidc_session_token({"iss": "https://idp.example/", "sub": "user-123"})
test_client = TestClient(app)
test_client.cookies.set(oidc_session_cookie_name(), session_token)
try:
with test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
):
_wait_for_connection_count(1)
_wait_for_connection_count(0)
finally:
test_client.close()
def test_oidc_websocket_rejects_missing_session_cookie(oidc_settings):
from app.main import app
test_client = TestClient(app)
try:
with (
pytest.raises(WebSocketDisconnect) as caught,
test_client.websocket_connect(
"/api/v1/status/ws/status",
headers={"Origin": "http://test"},
) as websocket,
):
websocket.receive_text()
assert caught.value.code == 1008
finally:
test_client.close()
+61
View File
@@ -167,3 +167,64 @@ def test_save_overrides_omits_mesh_credentials(tmp_path):
assert written["zigbee_sync_enabled"] is True
assert written["zigbee_sync_interval"] == 900
assert written["zwave_sync_interval"] == 1200
def _valid_oidc_settings(**overrides):
values = {
"secret_key": "test-secret-key-that-is-at-least-32-bytes",
"auth_mode": "oidc",
"oidc_discovery_url": "https://idp.example/application/o/homelable/.well-known/openid-configuration",
"oidc_client_id": "homelable",
"oidc_client_secret": "client-secret",
"oidc_redirect_uri": "https://homelable.example/api/v1/auth/oidc/callback",
"cors_origins": ["https://homelable.example"],
}
values.update(overrides)
return Settings(_env_file=None, **values)
def test_oidc_mode_requires_complete_client_configuration():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL.*OIDC_CLIENT_ID.*OIDC_CLIENT_SECRET.*OIDC_REDIRECT_URI"):
Settings(secret_key="test-secret", auth_mode="oidc", _env_file=None)
def test_oidc_mode_requires_openid_scope():
with pytest.raises(ValueError, match="OIDC_SCOPES must contain 'openid'"):
_valid_oidc_settings(oidc_scopes="profile email")
def test_oidc_mode_requires_strong_session_secret():
with pytest.raises(ValueError, match="SECRET_KEY must be at least 32 bytes"):
_valid_oidc_settings(secret_key="too-short")
def test_oidc_secure_cookie_requires_https_callback():
with pytest.raises(ValueError, match="must use HTTPS"):
_valid_oidc_settings(oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback")
def test_oidc_secure_cookie_requires_https_discovery():
with pytest.raises(ValueError, match="OIDC_DISCOVERY_URL must use HTTPS"):
_valid_oidc_settings(
oidc_discovery_url="http://idp.local/application/o/homelable/.well-known/openid-configuration"
)
def test_oidc_insecure_dev_mode_allows_http_urls():
configured = _valid_oidc_settings(
oidc_cookie_secure=False,
oidc_discovery_url="http://idp.local/.well-known/openid-configuration",
oidc_redirect_uri="http://localhost/api/v1/auth/oidc/callback",
)
assert configured.oidc_cookie_secure is False
def test_oidc_mode_rejects_wildcard_cors():
with pytest.raises(ValueError, match="CORS_ORIGINS cannot contain"):
_valid_oidc_settings(cors_origins=["*"])
def test_valid_oidc_configuration_is_accepted():
configured = _valid_oidc_settings()
assert configured.auth_mode == "oidc"
assert configured.oidc_cookie_secure is True
+192
View File
@@ -0,0 +1,192 @@
# OpenID Connect (OIDC) Authentication
Homelable can delegate login to your own identity provider (IdP) instead of the
built-in username/password. Sign-in uses the standard **Authorization Code flow
with PKCE**: the browser is redirected to your IdP, and on return Homelable
issues its own short-lived session cookie. Provider access/ID tokens are
exchanged server-side and **never** reach the browser.
Use it when you want single sign-on, per-user accounts managed at the IdP, MFA,
or central account revocation — Authentik, Keycloak, Authelia, Zitadel, Google,
Microsoft Entra ID, Okta, or any spec-compliant OpenID Connect provider.
> 🔒 **Server-dependent feature** — requires the Homelable backend. It is hidden
> in the no-backend standalone/demo build, which has no login at all.
---
## How it works
1. Homelable exposes its mode at `GET /api/v1/auth/config`. In OIDC mode the
login screen shows **Sign in with OpenID Connect**.
2. `GET /api/v1/auth/oidc/login` redirects to your IdP with `state`, `nonce`,
and a PKCE `code_challenge` (S256).
3. The IdP authenticates the user and redirects back to
`GET /api/v1/auth/oidc/callback`.
4. Homelable validates the response (signature via the IdP's JWKS, issuer,
audience, expiry, `nonce`, `state`, PKCE) and mints its **own** session token.
5. That token is stored in a `__Host-`, `HttpOnly`, `Secure`, `SameSite=Lax`
cookie. The browser never sees the provider tokens.
The display name shown in the app is the first present of
`preferred_username` → `name` → `email` → `sub`.
Local Bearer authentication and the MCP service key keep working unchanged, so
the API and the MCP server are unaffected.
---
## Prerequisites
1. A reachable **OpenID Connect** provider with a discovery document
(`.well-known/openid-configuration`).
2. Homelable served over **HTTPS** in production (required for the secure
session cookie).
3. A **confidential** OIDC client (client id **and** secret) registered at the
IdP, with Homelable's callback URL allow-listed as a redirect URI.
---
## Configuration
All settings live in `.env` (see `.env.example`). Switching to OIDC is
exclusive — it replaces local login. Existing installs stay on
`AUTH_MODE=local` until you change this.
```env
AUTH_MODE=oidc
# Pin CORS to the exact browser origin(s). Wildcard is rejected in OIDC mode.
CORS_ORIGINS=["https://homelable.example"]
# From your IdP:
OIDC_DISCOVERY_URL=https://idp.example/application/o/homelable/.well-known/openid-configuration
OIDC_CLIENT_ID=homelable
OIDC_CLIENT_SECRET=replace-with-a-secret
# Must EXACTLY match a redirect URI registered at the IdP.
OIDC_REDIRECT_URI=https://homelable.example/api/v1/auth/oidc/callback
OIDC_SCOPES="openid profile email"
OIDC_COOKIE_SECURE=true
OIDC_SESSION_EXPIRE_MINUTES=480
OIDC_TRANSACTION_EXPIRE_SECONDS=600
```
Then restart the backend (`docker compose restart backend`).
### Reference
| Variable | Required | Default | Notes |
|---|---|---|---|
| `AUTH_MODE` | yes | `local` | Set to `oidc` to enable. |
| `OIDC_DISCOVERY_URL` | yes | — | The IdP's `.well-known/openid-configuration` URL. |
| `OIDC_CLIENT_ID` | yes | — | Confidential client id. |
| `OIDC_CLIENT_SECRET` | yes | — | Client secret. Keep it out of Git and Compose YAML. |
| `OIDC_REDIRECT_URI` | yes | — | Must equal the registered redirect **exactly**, path included. |
| `OIDC_SCOPES` | no | `openid profile email` | Must contain `openid`. |
| `OIDC_COOKIE_SECURE` | no | `true` | `true` requires an HTTPS redirect URI and enables the `__Host-` cookie prefix. Set `false` only for local HTTP testing. |
| `OIDC_SESSION_EXPIRE_MINUTES` | no | `480` | App session lifetime (5–1440). |
| `OIDC_TRANSACTION_EXPIRE_SECONDS` | no | `600` | Lifetime of the short login-flow cookie holding `state`/`nonce`/PKCE (60–3600). |
| `SECRET_KEY` | yes | — | Must be **≥ 32 bytes** in OIDC mode. Generate: `python3 -c "import secrets; print(secrets.token_hex(32))"`. |
| `CORS_ORIGINS` | yes | — | Exact browser origin(s). `*` is rejected in OIDC mode. |
### Fail-closed validation
The backend refuses to start in OIDC mode unless the configuration is complete
and safe. It errors if any of the following is true:
- any of `OIDC_DISCOVERY_URL`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET`,
`OIDC_REDIRECT_URI` is missing;
- `SECRET_KEY` is shorter than 32 bytes;
- `OIDC_SCOPES` does not contain `openid`;
- `OIDC_DISCOVERY_URL` or `OIDC_REDIRECT_URI` is not an HTTP(S) URL;
- `OIDC_COOKIE_SECURE=true` but `OIDC_REDIRECT_URI` is not HTTPS;
- `CORS_ORIGINS` contains `*`.
---
## Provider setup
The callback URL to register everywhere is:
```
https://<your-host>/api/v1/auth/oidc/callback
```
### Authentik
1. **Applications → Providers → Create → OAuth2/OpenID Provider.**
2. Client type **Confidential**; add the redirect URI above.
3. Note the client id/secret and the discovery URL:
`https://authentik.example/application/o/<app-slug>/.well-known/openid-configuration`.
4. Create an **Application** bound to that provider.
### Keycloak
1. In your realm: **Clients → Create client** (OpenID Connect), **Client
authentication ON** (confidential).
2. Set **Valid redirect URIs** to the callback URL.
3. Discovery URL:
`https://kc.example/realms/<realm>/.well-known/openid-configuration`.
4. Copy the secret from the **Credentials** tab.
### Authelia
1. Add Homelable to the `identity_providers.oidc.clients` list with a
`client_secret`, the redirect URI, and scopes `openid profile email`.
2. Discovery URL: `https://auth.example/.well-known/openid-configuration`.
### Google
1. **Google Cloud Console → APIs & Services → Credentials → OAuth client ID**
(type *Web application*).
2. Add the callback URL under **Authorized redirect URIs** (Google requires
HTTPS).
3. Discovery URL: `https://accounts.google.com/.well-known/openid-configuration`.
Any other spec-compliant provider works the same way — point
`OIDC_DISCOVERY_URL` at its discovery document.
---
## Behind a reverse proxy
Homelable derives the redirect target from `OIDC_REDIRECT_URI`, so set it to the
**public** HTTPS URL your users reach — not the internal container address. Make
sure the proxy:
- terminates TLS and forwards `/api/v1/auth/oidc/*` to the backend;
- passes the original `Host` and `X-Forwarded-Proto` headers;
- does not strip the `Cookie` / `Set-Cookie` headers on those routes.
---
## Logout & sessions
- **Logout** clears the Homelable session cookie (`POST /api/v1/auth/logout`).
- Sessions are stateless JWTs signed with `SECRET_KEY`; there is no server-side
revocation, so a session stays valid until `OIDC_SESSION_EXPIRE_MINUTES`
elapses. Keep the lifetime modest for sensitive deployments.
- Logout is local to Homelable; it does not sign the user out at the IdP.
---
## Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
| IdP shows `redirect_uri_mismatch` | `OIDC_REDIRECT_URI` must equal the registered URI **exactly**, including scheme, host, port and the `/api/v1/auth/oidc/callback` path. |
| `OIDC authentication failed` (401) after IdP login | Wrong client secret, `openid` missing from scopes, or clock skew breaking `nonce`/`exp` — sync server time (NTP). |
| Logged out immediately / cookie not set | `OIDC_COOKIE_SECURE=true` while serving over HTTP. Serve HTTPS, or set `OIDC_COOKIE_SECURE=false` for local testing only. |
| `403 CSRF validation failed` on save/actions | The request `Origin` is not in `CORS_ORIGINS`, or a stale tab is missing the CSRF token — reload the page. |
| Backend won't start in OIDC mode | Re-read the error: a required `OIDC_*` value is missing, `SECRET_KEY` < 32 bytes, `CORS_ORIGINS` has `*`, or the redirect isn't HTTPS while the cookie is secure. |
| Login button doesn't appear | `AUTH_MODE` is still `local`, or the frontend can't reach `/api/v1/auth/config` — check the proxy and `CORS_ORIGINS`. |
---
## Reverting to local auth
Set `AUTH_MODE=local` (or remove it) and restart the backend. The
`AUTH_USERNAME` / `AUTH_PASSWORD_HASH` login is restored; the `OIDC_*` values are
ignored while in local mode.
+444 -82
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -31,7 +31,6 @@
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.5.0",
"tw-animate-css": "^1.4.0",
@@ -60,6 +59,7 @@
"globals": "^16.5.0",
"jsdom": "^28.1.0",
"lucide-react": "^1.7.0",
"shadcn": "^4.0.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
+16 -1
View File
@@ -51,6 +51,7 @@ import { WalkthroughInvite } from '@/walkthrough/WalkthroughInvite'
import { WalkthroughOverlay } from '@/walkthrough/WalkthroughOverlay'
import { DEMO_SCAN_RUNS, DEMO_PENDING_DEVICES } from '@/walkthrough/demoTourData'
import { useStatusPolling } from '@/hooks/useStatusPolling'
import { bootstrapAuth } from '@/auth/bootstrap'
import type { NodeData, EdgeData, CustomStyleDef, FloorMapConfig, NodeType } from '@/types'
import type { ZigbeeNode, ZigbeeEdge } from '@/components/zigbee/types'
import type { ZwaveNode, ZwaveEdge } from '@/components/zwave/types'
@@ -62,12 +63,19 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export default function App() {
const { loadCanvas, applyLayout, markSaved, markUnsaved, hasUnsavedChanges, editSeq, selectedNodeId, selectedNodeIds, addNode, updateNode, deleteNode, onConnect, updateEdge, deleteEdge, setProxmoxContainerMode, setNodeZIndex, editingGroupRectId, setEditingGroupRectId, editingTextId, setEditingTextId, nodes, edges, snapshotHistory, undo, redo, addToGroup, addToContainer, floorMap, setFloorMap } = useCanvasStore()
const canvasRef = useRef<HTMLDivElement>(null)
const { isAuthenticated } = useAuthStore()
const { isAuthenticated, isInitialized } = useAuthStore()
const authBootstrapStarted = useRef(false)
const { activeTheme, setTheme, customStyle, setCustomStyle } = useThemeStore()
const { activeDesignId, setDesigns, setActiveDesign } = useDesignStore()
useStatusPolling()
useEffect(() => {
if (STANDALONE || authBootstrapStarted.current) return
authBootstrapStarted.current = true
void bootstrapAuth()
}, [])
const [autosave, setAutosave] = useState<AutosaveSettings>(readAutosaveSettings)
useEffect(() => subscribeAutosaveSettings(setAutosave), [])
@@ -922,6 +930,13 @@ export default function App() {
const editNode = editNodeId ? nodes.find((n) => n.id === editNodeId) : null
const editEdge = editEdgeId ? edges.find((e) => e.id === editEdgeId) : null
if (!STANDALONE && !isInitialized) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-[#0d1117] text-sm text-muted-foreground">
Loading authentication…
</div>
)
}
if (!STANDALONE && !isAuthenticated) return <LoginPage />
return (
+26 -4
View File
@@ -6,7 +6,7 @@ type Interceptor<T> = {
}
interface MockInstance {
defaults: { baseURL?: string }
defaults: { baseURL?: string; withCredentials?: boolean }
interceptors: {
request: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
response: { use: (f: Interceptor<unknown>['fulfilled'], r?: Interceptor<unknown>['rejected']) => void }
@@ -25,9 +25,9 @@ const instances = hoisted.instances as MockInstance[]
vi.mock('axios', () => {
return {
default: {
create: (cfg: { baseURL?: string }) => {
create: (cfg: { baseURL?: string; withCredentials?: boolean }) => {
const inst: MockInstance = {
defaults: { baseURL: cfg?.baseURL },
defaults: { baseURL: cfg?.baseURL, withCredentials: cfg?.withCredentials },
interceptors: {
request: { use: (f: unknown, r?: unknown) => { inst.__req = { fulfilled: f as never, rejected: r as never } } },
response: { use: (f: unknown, r?: unknown) => { inst.__res = { fulfilled: f as never, rejected: r as never } } },
@@ -54,7 +54,7 @@ describe('api/client', () => {
const [api, publicApi] = instances
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({ token: null, csrfToken: null, isAuthenticated: false })
api.get.mockClear()
api.post.mockClear()
api.patch.mockClear()
@@ -66,6 +66,7 @@ describe('api/client', () => {
it('creates two axios instances with /api/v1 baseURL', () => {
expect(instances).toHaveLength(2)
expect(api.defaults.baseURL).toBe('/api/v1')
expect(api.defaults.withCredentials).toBe(true)
expect(publicApi.defaults.baseURL).toBe('/api/v1')
})
@@ -86,6 +87,18 @@ describe('api/client', () => {
expect((out as typeof cfg).headers.Authorization).toBeUndefined()
})
it('adds the in-memory OIDC CSRF token only to unsafe requests', () => {
useAuthStore.setState({ csrfToken: 'csrf-123' })
const post = { method: 'post', headers: {} as Record<string, string> }
const get = { method: 'get', headers: {} as Record<string, string> }
api.__req.fulfilled!(post)
api.__req.fulfilled!(get)
expect(post.headers['X-Homelable-CSRF']).toBe('csrf-123')
expect(get.headers['X-Homelable-CSRF']).toBeUndefined()
})
it('response interceptor passes through 2xx responses', () => {
const r = { status: 200, data: { ok: true } }
expect(api.__res.fulfilled!(r)).toBe(r)
@@ -125,6 +138,15 @@ describe('api/client', () => {
expect(api.post).toHaveBeenCalledWith('/auth/login', { username: 'u', password: 'p' })
})
it('authApi discovers auth publicly and manages the current session', () => {
mod.authApi.config()
expect(publicApi.get).toHaveBeenCalledWith('/auth/config')
mod.authApi.me()
expect(api.get).toHaveBeenCalledWith('/auth/me')
mod.authApi.logout()
expect(api.post).toHaveBeenCalledWith('/auth/logout')
})
it('canvasApi.load GETs /canvas', () => {
mod.canvasApi.load()
expect(api.get).toHaveBeenCalledWith('/canvas', expect.objectContaining({}))
+10 -1
View File
@@ -1,16 +1,22 @@
import axios from 'axios'
import { useAuthStore } from '@/stores/authStore'
import type { AuthMode, AuthUser } from '@/stores/authStore'
export const api = axios.create({
baseURL: '/api/v1',
withCredentials: true,
})
// Unauthenticated axios instance — no JWT, no 401 redirect (used for public endpoints)
const publicApi = axios.create({ baseURL: '/api/v1' })
api.interceptors.request.use((config) => {
const token = useAuthStore.getState().token
const { token, csrfToken } = useAuthStore.getState()
if (token) config.headers.Authorization = `Bearer ${token}`
const method = config.method?.toUpperCase() ?? 'GET'
if (csrfToken && !['GET', 'HEAD', 'OPTIONS'].includes(method)) {
config.headers['X-Homelable-CSRF'] = csrfToken
}
return config
})
@@ -23,8 +29,11 @@ api.interceptors.response.use(
)
export const authApi = {
config: () => publicApi.get<{ mode: AuthMode; oidc_login_url: string | null }>('/auth/config'),
login: (username: string, password: string) =>
api.post<{ access_token: string }>('/auth/login', { username, password }),
me: () => api.get<AuthUser>('/auth/me'),
logout: () => api.post('/auth/logout'),
}
export const canvasApi = {
@@ -0,0 +1,121 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useAuthStore } from '@/stores/authStore'
vi.mock('@/api/client', () => ({
authApi: {
config: vi.fn(),
me: vi.fn(),
},
}))
import { authApi } from '@/api/client'
import { bootstrapAuth } from '../bootstrap'
describe('bootstrapAuth', () => {
beforeEach(() => {
sessionStorage.clear()
useAuthStore.setState({
token: null,
isAuthenticated: false,
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
})
vi.mocked(authApi.config).mockReset()
vi.mocked(authApi.me).mockReset()
})
it('restores an OIDC cookie session without storing an IdP token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'oidc', oidc_login_url: '/api/v1/auth/oidc/login' },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'user-1',
display_name: 'Alice',
auth_method: 'oidc',
issuer: 'https://id.example/application/o/homelable/',
csrf_token: 'csrf-123',
},
} as never)
await bootstrapAuth()
const state = useAuthStore.getState()
expect(authApi.me).toHaveBeenCalledOnce()
expect(state.isAuthenticated).toBe(true)
expect(state.authMethod).toBe('oidc')
expect(state.token).toBeNull()
expect(state.csrfToken).toBe('csrf-123')
})
it('does not probe /auth/me for local mode without a persisted token', async () => {
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
await bootstrapAuth()
expect(authApi.me).not.toHaveBeenCalled()
expect(useAuthStore.getState()).toMatchObject({
authMode: 'local',
isAuthenticated: false,
isInitialized: true,
})
})
it('validates and retains a persisted local bearer token', async () => {
useAuthStore.setState({ token: 'local-token' })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockResolvedValue({
data: {
subject: 'admin',
display_name: 'admin',
auth_method: 'local',
issuer: null,
csrf_token: null,
},
} as never)
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: 'local-token',
isAuthenticated: true,
authMethod: 'local',
})
})
it('fails closed when the backend rejects the session', async () => {
useAuthStore.setState({ token: 'expired-token', isAuthenticated: true })
vi.mocked(authApi.config).mockResolvedValue({
data: { mode: 'local', oidc_login_url: null },
} as never)
vi.mocked(authApi.me).mockRejectedValue({ response: { status: 401 } })
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
token: null,
isAuthenticated: false,
isInitialized: true,
})
})
it('leaves the auth mode unknown when discovery is unavailable', async () => {
vi.mocked(authApi.config).mockRejectedValue(new Error('network down'))
await bootstrapAuth()
expect(useAuthStore.getState()).toMatchObject({
authMode: null,
isAuthenticated: false,
isInitialized: true,
})
})
})
+33
View File
@@ -0,0 +1,33 @@
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
/**
* Discover the configured authentication mode and restore a valid session.
* OIDC sessions are represented only by the backend's HttpOnly cookie; local
* sessions retain their existing bearer token until /auth/me validates it.
*/
export async function bootstrapAuth(): Promise<void> {
const store = useAuthStore.getState()
try {
const { data: config } = await authApi.config()
store.setAuthConfig(config.mode, config.oidc_login_url)
const token = useAuthStore.getState().token
if (config.mode === 'oidc' || token) {
try {
const { data: user } = await authApi.me()
useAuthStore.getState().authenticate(user)
} catch {
// The response interceptor clears invalid credentials on 401. Also
// clear them for malformed/unreachable session checks, failing closed.
useAuthStore.getState().logout()
}
}
} catch {
// Keep authMode unknown when discovery is unreachable. The login screen
// reports that state without guessing which authentication mode is active.
} finally {
useAuthStore.getState().finishInitialization()
}
}
+83 -51
View File
@@ -1,17 +1,19 @@
import { useState } from 'react'
import { Network, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Loader2 } from 'lucide-react'
import { Button, buttonVariants } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Logo } from '@/components/ui/Logo'
import { authApi } from '@/api/client'
import { useAuthStore } from '@/stores/authStore'
import { cn } from '@/lib/utils'
export function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const login = useAuthStore((s) => s.login)
const { authMode, oidcLoginUrl, login } = useAuthStore()
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
@@ -42,62 +44,92 @@ export function LoginPage() {
<div className="relative w-full max-w-sm px-4">
{/* Logo */}
<div className="flex flex-col items-center gap-3 mb-8">
<div className="flex items-center justify-center w-14 h-14 rounded-xl bg-[#00d4ff]/10 border border-[#00d4ff]/20">
<Network size={28} className="text-[#00d4ff]" />
</div>
<Logo size={64} showText={false} />
<div className="text-center">
<h1 className="text-xl font-semibold text-foreground tracking-wide">Homelable</h1>
<h1 className="text-xl font-semibold tracking-tight">
<span className="text-[#e6edf3]">Home</span>
<span className="text-[#00d4ff]">lable</span>
</h1>
<p className="text-xs text-muted-foreground mt-0.5">HomeLab Visualizer</p>
</div>
</div>
{/* Form */}
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
{authMode === 'local' && (
<>
<form
onSubmit={handleSubmit}
className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6"
>
<div className="flex flex-col gap-1.5">
<Label htmlFor="username" className="text-xs text-muted-foreground">Username</Label>
<Input
id="username"
autoComplete="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
placeholder="admin"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
</>
)}
{authMode === 'oidc' && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-muted-foreground">
Continue through the configured OpenID Connect provider.
</p>
<a
href={oidcLoginUrl ?? '/api/v1/auth/oidc/login'}
className={cn(
buttonVariants(),
'bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium',
)}
>
Sign in with OpenID Connect
</a>
</div>
)}
<div className="flex flex-col gap-1.5">
<Label htmlFor="password" className="text-xs text-muted-foreground">Password</Label>
<Input
id="password"
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="bg-[#21262d] border-[#30363d] focus-visible:ring-[#00d4ff]/50 text-sm"
required
/>
{authMode === null && (
<div className="flex flex-col gap-4 bg-[#161b22] border border-[#30363d] rounded-xl p-6 text-center">
<p className="text-sm text-[#f85149]">Could not load authentication configuration.</p>
<Button type="button" variant="outline" onClick={() => window.location.reload()}>
Retry
</Button>
</div>
{error && (
<p className="text-xs text-[#f85149] text-center">{error}</p>
)}
<Button
type="submit"
disabled={loading}
className="mt-1 bg-[#00d4ff] text-[#0d1117] hover:bg-[#00d4ff]/90 font-medium"
>
{loading ? <Loader2 size={15} className="animate-spin" /> : 'Sign in'}
</Button>
</form>
<p className="text-center text-[10px] text-muted-foreground/40 mt-4">
Credentials configured in <span className="font-mono">.env</span>
</p>
)}
</div>
</div>
)
@@ -1,5 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { act, render, screen, fireEvent, waitFor } from '@testing-library/react'
import { LoginPage } from '../LoginPage'
import { useAuthStore } from '@/stores/authStore'
@@ -13,7 +13,12 @@ import { authApi } from '@/api/client'
describe('LoginPage', () => {
beforeEach(() => {
useAuthStore.setState({ token: null, isAuthenticated: false })
useAuthStore.setState({
token: null,
isAuthenticated: false,
authMode: 'local',
oidcLoginUrl: null,
})
vi.mocked(authApi.login).mockReset()
})
@@ -30,6 +35,41 @@ describe('LoginPage', () => {
expect(screen.getByRole('button', { name: /sign in/i })).toBeDefined()
})
it('renders the official Homelable logo mark — not a generic lucide icon', () => {
render(<LoginPage />)
const svg = screen.getByTestId('logo-mark')
// The official mark from docs/logo/icon.svg, not lucide's Network glyph
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
expect(svg.classList.contains('lucide')).toBe(false)
expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('Homelable')
expect(screen.getByText('HomeLab Visualizer')).toBeDefined()
})
it('renders only the provider redirect in OIDC mode', () => {
useAuthStore.setState({
authMode: 'oidc',
oidcLoginUrl: '/api/v1/auth/oidc/login',
})
render(<LoginPage />)
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.queryByLabelText('Password')).toBeNull()
expect(screen.getByRole('link', { name: 'Sign in with OpenID Connect' }))
.toHaveAttribute('href', '/api/v1/auth/oidc/login')
})
it('does not guess an auth mode when discovery failed', () => {
useAuthStore.setState({ authMode: null })
render(<LoginPage />)
expect(screen.getByText('Could not load authentication configuration.')).toBeDefined()
expect(screen.queryByLabelText('Username')).toBeNull()
expect(screen.getByRole('button', { name: 'Retry' })).toBeDefined()
})
// ── Security checks ──────────────────────────────────────────────────────
it('password field type is "password" — not rendered as plain text', () => {
@@ -102,7 +142,9 @@ describe('LoginPage', () => {
await waitFor(() => {
expect((screen.getByRole('button', { name: '' }) as HTMLButtonElement).disabled).toBe(true)
})
resolve({ data: { access_token: 'tok' } })
await act(async () => {
resolve({ data: { access_token: 'tok' } })
})
})
it('calls authApi.login with credentials via POST body (not URL params)', async () => {
+11 -2
View File
@@ -5,7 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useCanvasStore } from '@/stores/canvasStore'
import { useDesignStore } from '@/stores/designStore'
import { useAuthStore } from '@/stores/authStore'
import { designsApi, mediaApi } from '@/api/client'
import { authApi, designsApi, mediaApi } from '@/api/client'
import * as standaloneStorage from '@/utils/standaloneStorage'
import { resolveDesignIcon, DEFAULT_DESIGN_ICON } from '@/utils/designIcons'
import { DesignModal, type DesignFormData } from '@/components/modals/DesignModal'
@@ -47,6 +47,15 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
const { nodes, hasUnsavedChanges, floorMap, setFloorMap } = useCanvasStore()
const floorMapEditNonce = useCanvasStore((s) => s.floorMapEditNonce)
const handleLogout = useCallback(async () => {
try {
await authApi.logout()
logout()
} catch {
toast.error('Could not end the current session')
}
}, [logout])
const openDesignModal = useCallback((m: { mode: 'create' | 'edit'; design?: Design }) => {
setOpenSeq((s) => s + 1)
setDesignModal(m)
@@ -294,7 +303,7 @@ export function Sidebar({ onAddNode, onAddGroupRect, onAddText, onScan, onZigbee
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
onClick={() => void handleLogout()}
/>
)}
</div>
@@ -1,5 +1,5 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
@@ -13,6 +13,9 @@ vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
vi.mock('@/api/client', () => ({
authApi: {
logout: vi.fn().mockResolvedValue({}),
},
scanApi: {
trigger: vi.fn().mockResolvedValue({}),
runs: vi.fn().mockResolvedValue({ data: [] }),
@@ -272,10 +275,14 @@ describe('Sidebar', () => {
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
it('ends the backend session before clearing local auth state', async () => {
const { authApi } = await import('@/api/client')
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
await waitFor(() => {
expect(authApi.logout).toHaveBeenCalledOnce()
expect(mockLogout).toHaveBeenCalledOnce()
})
})
})
+64 -12
View File
@@ -1,10 +1,21 @@
import { useId } from 'react';
interface LogoProps {
size?: number;
showText?: boolean;
className?: string;
}
/**
* Official Homelable mark — kept in sync with `docs/logo/icon.svg`.
* Wordmark colors follow `docs/logo/logo-md.svg` (Home = #e6edf3, lable = #00d4ff).
*/
export function Logo({ size = 32, showText = true, className = '' }: LogoProps) {
// Colons are legal in a URL fragment but confuse some tooling — strip them.
const uid = useId().replace(/:/g, '');
const bgGlow = `homelable-bg-glow-${uid}`;
const nodeGlow = `homelable-node-glow-${uid}`;
return (
<div className={`flex items-center gap-2 ${className}`}>
<svg
@@ -13,32 +24,73 @@ export function Logo({ size = 32, showText = true, className = '' }: LogoProps)
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-testid="logo-mark"
>
<defs>
<radialGradient id={bgGlow} cx="50%" cy="35%" r="55%">
<stop offset="0%" stopColor="#00d4ff" stopOpacity="0.08" />
<stop offset="100%" stopColor="#0d1117" stopOpacity="0" />
</radialGradient>
<filter id={nodeGlow}>
<feGaussianBlur stdDeviation="1.5" result="blur" />
<feMerge>
<feMergeNode in="blur" />
<feMergeNode in="SourceGraphic" />
</feMerge>
</filter>
</defs>
{/* Background */}
<circle cx="32" cy="32" r="32" fill="#0d1117" />
<circle cx="32" cy="32" r="32" fill={`url(#${bgGlow})`} />
{/* House body */}
<path
d="M32 12 L52 30 L48 30 L48 52 L16 52 L16 30 L12 30 Z"
d="M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z"
fill="#161b22"
stroke="#00d4ff"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<rect x="27" y="40" width="10" height="12" rx="1" fill="#0d1117" stroke="#00d4ff" strokeWidth="1" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" />
<circle cx="22" cy="38" r="2" fill="#39d353" />
<line x1="22" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="42" cy="38" r="2" fill="#39d353" />
<line x1="42" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="24" r="2" fill="#a855f7" />
<line x1="32" y1="24" x2="32" y2="30" stroke="#a855f7" strokeWidth="1" opacity="0.7" />
<circle cx="32" cy="33" r="3" fill="none" stroke="#00d4ff" strokeWidth="1.5" opacity="0.4" />
{/* Floor line (subtle) */}
<line x1="16" y1="30" x2="48" y2="30" stroke="#00d4ff" strokeWidth="0.5" opacity="0.25" />
{/* Door */}
<rect
x="27"
y="40"
width="10"
height="13"
rx="1.5"
fill="#0d1117"
stroke="#00d4ff"
strokeWidth="1"
opacity="0.9"
/>
{/* Network lines (drawn under nodes) */}
<line x1="32" y1="23" x2="32" y2="30" stroke="#a855f7" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="21" y1="38" x2="29" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
<line x1="43" y1="38" x2="35" y2="33" stroke="#39d353" strokeWidth="1.2" opacity="0.7" strokeLinecap="round" />
{/* Center hub */}
<circle cx="32" cy="33" r="5" fill="#00d4ff" opacity="0.12" />
<circle cx="32" cy="33" r="3" fill="#00d4ff" filter={`url(#${nodeGlow})`} />
{/* Peripheral nodes */}
<circle cx="21" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="43" cy="38" r="2.5" fill="#39d353" filter={`url(#${nodeGlow})`} />
<circle cx="32" cy="23" r="2" fill="#a855f7" filter={`url(#${nodeGlow})`} />
</svg>
{showText && (
<span
className="font-bold tracking-tight"
style={{ fontSize: size * 0.55, fontFamily: 'Inter, sans-serif' }}
>
<span style={{ color: '#00d4ff' }}>Home</span>
<span style={{ color: '#ffffff' }}>lable</span>
<span style={{ color: '#e6edf3' }}>Home</span>
<span style={{ color: '#00d4ff' }}>lable</span>
</span>
)}
</div>
@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Logo } from '../Logo'
describe('Logo', () => {
it('renders the official mark (house + network nodes), not a placeholder icon', () => {
render(<Logo />)
const svg = screen.getByTestId('logo-mark')
// Canonical house outline from docs/logo/icon.svg
expect(svg.querySelector('path')?.getAttribute('d'))
.toBe('M32 11 L53 30 L48 30 L48 53 L16 53 L16 30 L11 30 Z')
// Center hub + 3 peripheral nodes + hub glow + 2 background circles
expect(svg.querySelectorAll('circle').length).toBe(7)
})
it('uses the canonical wordmark colors (Home #e6edf3 / lable #00d4ff)', () => {
render(<Logo showText />)
expect(screen.getByText('Home')).toHaveStyle({ color: '#e6edf3' })
expect(screen.getByText('lable')).toHaveStyle({ color: '#00d4ff' })
})
it('hides the wordmark when showText is false', () => {
render(<Logo showText={false} />)
expect(screen.queryByText('Home')).toBeNull()
expect(screen.getByTestId('logo-mark')).toBeInTheDocument()
})
it('scales the svg to the requested size', () => {
render(<Logo size={64} />)
const svg = screen.getByTestId('logo-mark')
expect(svg.getAttribute('width')).toBe('64')
expect(svg.getAttribute('height')).toBe('64')
})
it('generates unique gradient/filter ids per instance — two logos can coexist', () => {
// Sidebar and Toolbar both render a Logo at the same time; duplicate SVG
// defs ids would make one instance reference the other's gradient.
render(<><Logo /><Logo /></>)
const [first, second] = screen.getAllByTestId('logo-mark')
const idOf = (svg: HTMLElement) => svg.querySelector('radialGradient')!.getAttribute('id')
expect(idOf(first)).not.toBe(idOf(second))
// Each instance must point at its own gradient
const gradientRef = (svg: HTMLElement) =>
svg.querySelectorAll('circle')[1].getAttribute('fill')
expect(gradientRef(first)).toBe(`url(#${idOf(first)!})`)
expect(gradientRef(second)).toBe(`url(#${idOf(second)!})`)
})
it('marks the svg aria-hidden — the adjacent wordmark carries the name', () => {
render(<Logo />)
expect(screen.getByTestId('logo-mark')).toHaveAttribute('aria-hidden', 'true')
})
it('forwards className to the wrapper', () => {
const { container } = render(<Logo className="my-class" />)
expect(container.firstElementChild).toHaveClass('my-class')
})
})
@@ -39,6 +39,7 @@ describe('useStatusPolling', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'local',
token: 'test-token',
} as ReturnType<typeof useAuthStore>)
@@ -58,6 +59,7 @@ describe('useStatusPolling', () => {
it('does not open WebSocket when not authenticated', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: false,
authMethod: null,
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
@@ -67,12 +69,28 @@ describe('useStatusPolling', () => {
it('does not open WebSocket when token is missing', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'local',
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(0)
})
it('uses the HttpOnly cookie and sends no credential in OIDC mode', () => {
vi.mocked(useAuthStore).mockReturnValue({
isAuthenticated: true,
authMethod: 'oidc',
token: null,
} as ReturnType<typeof useAuthStore>)
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
const ws = MockWebSocket.instances[0]
ws.onopen?.()
expect(ws.send).not.toHaveBeenCalled()
})
it('connects to correct ws:// URL', () => {
renderHook(() => useStatusPolling())
expect(MockWebSocket.instances).toHaveLength(1)
+10 -6
View File
@@ -25,10 +25,10 @@ const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
export function useStatusPolling() {
const wsRef = useRef<WebSocket | null>(null)
const { setNodeStatus, notifyScanDeviceFound, setServiceStatuses } = useCanvasStore()
const { isAuthenticated, token } = useAuthStore()
const { isAuthenticated, authMethod, token } = useAuthStore()
useEffect(() => {
if (STANDALONE || !isAuthenticated || !token) return
if (STANDALONE || !isAuthenticated || (authMethod !== 'oidc' && !token)) return
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws'
const host = window.location.host // includes port when non-standard
@@ -37,9 +37,13 @@ export function useStatusPolling() {
const ws = new WebSocket(url)
wsRef.current = ws
// Send token as first message (not in URL to avoid log/history exposure)
ws.onopen = () => {
ws.send(JSON.stringify({ token }))
// Local mode sends the bearer token as the first message (not in the URL
// where proxies may log it). OIDC mode is authenticated during the upgrade
// with the backend's HttpOnly session cookie, so React sends no credential.
if (token) {
ws.onopen = () => {
ws.send(JSON.stringify({ token }))
}
}
ws.onmessage = (event) => {
@@ -71,5 +75,5 @@ export function useStatusPolling() {
ws.close()
wsRef.current = null
}
}, [isAuthenticated, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
}, [isAuthenticated, authMethod, token, setNodeStatus, notifyScanDeviceFound, setServiceStatuses])
}
@@ -4,7 +4,17 @@ import { useAuthStore } from '@/stores/authStore'
describe('authStore', () => {
beforeEach(() => {
localStorage.clear()
useAuthStore.setState({ token: null, isAuthenticated: false })
sessionStorage.clear()
useAuthStore.setState({
token: null,
isAuthenticated: false,
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
})
})
it('starts unauthenticated', () => {
@@ -18,6 +28,8 @@ describe('authStore', () => {
const { token, isAuthenticated } = useAuthStore.getState()
expect(token).toBe('my-jwt-token')
expect(isAuthenticated).toBe(true)
expect(useAuthStore.getState().authMethod).toBe('local')
expect(useAuthStore.getState().isInitialized).toBe(true)
})
it('logout clears token and isAuthenticated', () => {
@@ -27,4 +39,36 @@ describe('authStore', () => {
expect(token).toBeNull()
expect(isAuthenticated).toBe(false)
})
it('stores OIDC identity and CSRF state without a browser-readable credential', () => {
useAuthStore.getState().setAuthConfig('oidc', '/api/v1/auth/oidc/login')
useAuthStore.getState().authenticate({
subject: 'user-1',
display_name: 'Alice',
auth_method: 'oidc',
issuer: 'https://id.example/application/o/homelable/',
csrf_token: 'csrf-123',
})
const state = useAuthStore.getState()
expect(state.token).toBeNull()
expect(state.isAuthenticated).toBe(true)
expect(state.authMethod).toBe('oidc')
expect(state.displayName).toBe('Alice')
expect(state.csrfToken).toBe('csrf-123')
expect(sessionStorage.getItem('homelable-auth') ?? '').not.toContain('csrf-123')
})
it('keeps a validated local token when applying /auth/me identity', () => {
useAuthStore.getState().login('local-token')
useAuthStore.getState().authenticate({
subject: 'admin',
display_name: 'admin',
auth_method: 'local',
issuer: null,
csrf_token: null,
})
expect(useAuthStore.getState().token).toBe('local-token')
})
})
+64 -2
View File
@@ -1,9 +1,29 @@
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export type AuthMode = 'local' | 'oidc'
export type AuthMethod = AuthMode
export interface AuthUser {
subject: string
display_name: string
auth_method: AuthMethod
issuer: string | null
csrf_token: string | null
}
interface AuthState {
token: string | null
isAuthenticated: boolean
isInitialized: boolean
authMode: AuthMode | null
authMethod: AuthMethod | null
oidcLoginUrl: string | null
displayName: string | null
csrfToken: string | null
setAuthConfig: (mode: AuthMode, oidcLoginUrl: string | null) => void
authenticate: (user: AuthUser) => void
finishInitialization: () => void
login: (token: string) => void
logout: () => void
}
@@ -13,14 +33,56 @@ export const useAuthStore = create<AuthState>()(
(set) => ({
token: null,
isAuthenticated: false,
login: (token) => set({ token, isAuthenticated: true }),
logout: () => set({ token: null, isAuthenticated: false }),
isInitialized: false,
authMode: null,
authMethod: null,
oidcLoginUrl: null,
displayName: null,
csrfToken: null,
setAuthConfig: (authMode, oidcLoginUrl) => set({ authMode, oidcLoginUrl }),
authenticate: (user) => set((state) => ({
token: user.auth_method === 'oidc' ? null : state.token,
isAuthenticated: true,
isInitialized: true,
authMethod: user.auth_method,
displayName: user.display_name,
csrfToken: user.csrf_token,
})),
finishInitialization: () => set({ isInitialized: true }),
login: (token) => set({
token,
isAuthenticated: true,
isInitialized: true,
authMethod: 'local',
displayName: null,
csrfToken: null,
}),
logout: () => set({
token: null,
isAuthenticated: false,
isInitialized: true,
authMethod: null,
displayName: null,
csrfToken: null,
}),
}),
{
name: 'homelable-auth',
// sessionStorage: scoped to the tab, cleared on browser close.
// Prevents XSS from other tabs stealing the token via localStorage.
storage: createJSONStorage(() => sessionStorage),
// OIDC credentials stay in HttpOnly cookies. Its CSRF token is deliberately
// memory-only and is recovered from /auth/me after every page load.
partialize: (state) => ({ token: state.token }),
// Ignore authentication flags written by older versions of the store. A
// persisted local token is revalidated by the bootstrap request instead.
merge: (persisted, current) => {
const token = (persisted as { token?: unknown } | null)?.token
return {
...current,
token: typeof token === 'string' ? token : null,
}
},
}
)
)