Merge pull request #306 from autogpt-elendir/feat/oidc-auth
feat(auth): add generic OIDC browser sessions
This commit is contained in:
+20
-2
@@ -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"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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"])
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
Generated
+444
-82
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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 (
|
||||
|
||||
@@ -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({}))
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user