Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 131 additions & 5 deletions plugins/dashboard_auth/basic/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@
import logging
import os
import secrets
import tempfile
import time
from pathlib import Path
from typing import Any, Optional

from hermes_cli.dashboard_auth import (
Expand Down Expand Up @@ -107,6 +109,59 @@
LAST_SKIP_REASON: str = ""


# ---------------------------------------------------------------------------
# Session epoch (RAH-01: revoke_session()/password rotation invalidation)
# ---------------------------------------------------------------------------
#
# Access/refresh tokens are stateless HMAC blobs — verify_session() has no
# server-side session to check. revoke_session() alone therefore cannot
# invalidate anything already issued. To make "logout" and "password
# changed" actually reject prior tokens without turning this into a stateful
# session store, every minted token carries an "epoch" claim; verification
# rejects any token whose epoch doesn't match the provider's current one.
# The epoch is persisted (JSON, atomic replace) so it survives process
# restarts and is shared across multi-worker deployments that already share
# an explicit `secret` — the same file both workers' registrations read.


def _epoch_store_path() -> Path:
from hermes_constants import get_hermes_home

return get_hermes_home() / "dashboard_auth_basic_session_epoch.json"


def _load_epoch_state(path: Path) -> dict:
try:
data = json.loads(path.read_text(encoding="utf-8"))
if isinstance(data, dict):
return {
"epoch": int(data.get("epoch", 0)),
"fingerprint": str(data.get("fingerprint", "")),
}
except (OSError, ValueError, TypeError):
pass
return {"epoch": 0, "fingerprint": ""}


def _save_epoch_state(path: Path, epoch: int, fingerprint: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_path = tempfile.mkstemp(
dir=str(path.parent), suffix=".tmp", prefix=".epoch_"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump({"epoch": epoch, "fingerprint": fingerprint}, f)
f.flush()
os.fsync(f.fileno())
os.replace(tmp_path, path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise


# ---------------------------------------------------------------------------
# Password hashing (stdlib scrypt)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -212,6 +267,8 @@ def __init__(
password_hash: str,
secret: bytes,
ttl_seconds: int = _DEFAULT_TTL_SECONDS,
credential_fingerprint: Optional[str] = None,
epoch_store_path: Optional[Path] = None,
) -> None:
if not username:
raise ValueError("username must be non-empty")
Expand All @@ -224,6 +281,36 @@ def __init__(
self._secret = secret
self._ttl = max(60, int(ttl_seconds))

# credential_fingerprint is None for direct/test construction — the
# provider then behaves exactly as before (in-memory-only epoch,
# no disk state). register() always passes a real fingerprint
# derived from the credential source material (see module docstring
# above _epoch_store_path), so production instances get persisted,
# cross-restart/cross-worker session-epoch invalidation.
self._credential_fingerprint = credential_fingerprint
self._epoch_store_path = epoch_store_path or _epoch_store_path()
if credential_fingerprint is not None:
state = _load_epoch_state(self._epoch_store_path)
if state["fingerprint"] and state["fingerprint"] != credential_fingerprint:
# Credential source changed since the epoch file was last
# written — rotate the epoch so every previously issued
# token (signed under the old epoch) stops verifying.
self._epoch = state["epoch"] + 1
_save_epoch_state(
self._epoch_store_path, self._epoch, credential_fingerprint
)
elif state["fingerprint"] != credential_fingerprint:
# First time this credential is recorded (empty fingerprint
# on disk) — keep the existing epoch, just record it.
self._epoch = state["epoch"]
_save_epoch_state(
self._epoch_store_path, self._epoch, credential_fingerprint
)
else:
self._epoch = state["epoch"]
else:
self._epoch = 0

# ---- OAuth methods: not used (pure-password provider) ------------------

def start_login(self, *, redirect_uri: str) -> LoginStart:
Expand Down Expand Up @@ -266,6 +353,7 @@ def verify_session(self, *, access_token: str) -> Optional[Session]:
payload is None
or payload.get("kind") != "access"
or payload.get("exp", 0) <= int(time.time())
or payload.get("epoch", -1) != self._epoch
):
return None
return self._session_from_payload(access_token, "", payload)
Expand All @@ -278,14 +366,27 @@ def refresh_session(self, *, refresh_token: str) -> Session:
payload is None
or payload.get("kind") != "refresh"
or payload.get("exp", 0) <= int(time.time())
or payload.get("epoch", -1) != self._epoch
):
raise RefreshExpiredError("refresh token expired or invalid")
return self._mint_session(str(payload.get("sub", self._username)))

def revoke_session(self, *, refresh_token: str) -> None:
# Stateless tokens — nothing to revoke server-side. The session
# expires within its TTL. Best-effort no-op, must not raise.
# Stateless tokens: there is no per-session server state to delete.
# Instead, bump the session epoch — every access/refresh token
# already issued (signed under the old epoch) stops verifying
# immediately, in this instance and (once persisted) in every other
# worker/process that shares this provider's epoch store. This is a
# "logout everywhere" operation; BasicAuthProvider has exactly one
# identity (single configured username), so that is the correct
# granularity — there is no per-session/per-device state to target
# more narrowly without turning this into a stateful session store.
_ = refresh_token
self._epoch += 1
if self._credential_fingerprint is not None:
_save_epoch_state(
self._epoch_store_path, self._epoch, self._credential_fingerprint
)
return None

# ---- internals ---------------------------------------------------------
Expand All @@ -294,10 +395,16 @@ def _mint_session(self, user_id: str) -> Session:
now = int(time.time())
exp = now + self._ttl
access_token = _sign(
{"sub": user_id, "kind": "access", "exp": exp}, self._secret
{"sub": user_id, "kind": "access", "exp": exp, "epoch": self._epoch},
self._secret,
)
refresh_token = _sign(
{"sub": user_id, "kind": "refresh", "exp": now + _REFRESH_TTL_SECONDS},
{
"sub": user_id,
"kind": "refresh",
"exp": now + _REFRESH_TTL_SECONDS,
"epoch": self._epoch,
},
self._secret,
)
return Session(
Expand Down Expand Up @@ -451,19 +558,30 @@ def register(ctx) -> None:
"HERMES_DASHBOARD_BASIC_AUTH_PASSWORD", ""
).strip()
if plaintext_from_env:
# Fingerprint the plaintext itself, not the hash: hash_password()
# salts randomly, so the hash differs on every restart even when the
# password hasn't changed — hashing that would falsely look like a
# rotation and invalidate every session on every restart (RAH-01).
credential_source = plaintext_from_env
password_hash = hash_password(plaintext_from_env)
logger.info(
"dashboard-auth-basic: hashed env-supplied password in-memory "
"(overrides any config password_hash)."
)
elif not password_hash:
# config-only plaintext password.
# config-only plaintext password — same salting concern as above.
credential_source = plaintext
password_hash = hash_password(plaintext)
logger.info(
"dashboard-auth-basic: hashed plaintext password in-memory. "
"For production, precompute dashboard.basic_auth.password_hash "
"and remove the plaintext password from config."
)
else:
# A precomputed password_hash from config/env is stable across
# restarts already (the operator sets it once), so it's safe to
# fingerprint directly.
credential_source = password_hash

secret = _resolve_secret(section)

Expand All @@ -472,12 +590,20 @@ def register(ctx) -> None:
except ValueError:
ttl = _DEFAULT_TTL_SECONDS

# Binds the epoch-rotation fingerprint to *this* username too, so
# changing the configured username (a distinct identity) also
# invalidates prior sessions, not just a password change.
credential_fingerprint = hashlib.sha256(
f"{username}\x00{credential_source}".encode("utf-8")
).hexdigest()

try:
provider = BasicAuthProvider(
username=username,
password_hash=password_hash,
secret=secret,
ttl_seconds=ttl,
credential_fingerprint=credential_fingerprint,
)
except ValueError as exc:
LAST_SKIP_REASON = f"BasicAuthProvider construction failed: {exc}"
Expand Down
70 changes: 70 additions & 0 deletions tests/plugins/dashboard_auth/test_basic_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,76 @@ def test_revoke_is_silent(self, basic):
p = self._make(basic)
p.revoke_session(refresh_token="anything") # must not raise

def test_revoke_session_invalidates_prior_access_token(self, basic):
"""RAH-01: revoke_session() must not be a pure no-op — a token minted
before the call must stop verifying after it, on the same instance."""
p = self._make(basic)
s = p.complete_password_login(username="admin", password="hunter2")
assert p.verify_session(access_token=s.access_token) is not None
p.revoke_session(refresh_token=s.refresh_token)
assert p.verify_session(access_token=s.access_token) is None
with pytest.raises(RefreshExpiredError):
p.refresh_session(refresh_token=s.refresh_token)

def test_password_rotation_invalidates_prior_session_across_restart(
self, basic, tmp_path
):
"""RAH-01: rotating the password (same explicit secret, simulating a
process restart via a fresh provider instance) must invalidate
sessions minted under the old password — not just the process that
revoked. credential_fingerprint + a shared epoch_store_path model
what register() does across two real process starts."""
store = tmp_path / "epoch.json"
secret = secrets.token_bytes(32)
old_hash = basic.hash_password("old-password")
p_old = basic.BasicAuthProvider(
username="admin",
password_hash=old_hash,
secret=secret,
credential_fingerprint="fp-old-password",
epoch_store_path=store,
)
s = p_old.complete_password_login(username="admin", password="old-password")
assert p_old.verify_session(access_token=s.access_token) is not None

# Simulate a restart after rotating the password: a brand-new
# provider instance, same secret + epoch store, different fingerprint.
new_hash = basic.hash_password("new-password")
p_new = basic.BasicAuthProvider(
username="admin",
password_hash=new_hash,
secret=secret,
credential_fingerprint="fp-new-password",
epoch_store_path=store,
)
assert p_new.verify_session(access_token=s.access_token) is None

def test_same_password_across_restart_keeps_session_valid(self, basic, tmp_path):
"""A restart with the SAME credential fingerprint (nothing rotated)
must NOT bump the epoch — otherwise every restart would silently log
everyone out, breaking the documented explicit-secret portability
contract."""
store = tmp_path / "epoch.json"
secret = secrets.token_bytes(32)
h = basic.hash_password("hunter2")
p1 = basic.BasicAuthProvider(
username="admin",
password_hash=h,
secret=secret,
credential_fingerprint="fp-stable",
epoch_store_path=store,
)
s = p1.complete_password_login(username="admin", password="hunter2")

p2 = basic.BasicAuthProvider(
username="admin",
password_hash=h,
secret=secret,
credential_fingerprint="fp-stable",
epoch_store_path=store,
)
assert p2.verify_session(access_token=s.access_token) is not None

def test_oauth_methods_raise_not_implemented(self, basic):
p = self._make(basic)
with pytest.raises(NotImplementedError):
Expand Down
Loading