mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
The stub auth provider's _sign/_unsign helpers joined payload and HMAC
with a 'b"."' separator and recovered the parts via bytes.rsplit. HMAC-SHA256
digests are random bytes, so ~12% of the time the digest contains 0x2E
('.') and rsplit picks the wrong split point -- HMAC verification then
spuriously rejects valid tokens.
test_stub_refresh_round_trips was failing ~25% of the time in isolation
because of this.
Switch to a fixed-length suffix (32 bytes, sliced off in _unsign): no
separator means no collision class. After the fix, 10/10 runs pass.
184 lines
6.5 KiB
Python
184 lines
6.5 KiB
Python
"""Stub auth provider + shared fixtures for dashboard-auth tests.
|
|
|
|
NOT a pytest conftest.py — this is an importable helper module. Phase 2
|
|
of the dashboard-OAuth plan; used by Phase 3's end-to-end gate tests.
|
|
|
|
Import via::
|
|
|
|
from tests.hermes_cli.conftest_dashboard_auth import StubAuthProvider
|
|
|
|
The stub bounces straight back to the callback with a fake code so tests
|
|
can complete the OAuth round trip in-process without external network.
|
|
|
|
Tokens are HMAC-signed JSON blobs (not real JWTs) — just enough structure
|
|
for ``verify_session`` to detect tampering and expiry.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import hashlib
|
|
import hmac
|
|
import json
|
|
import secrets
|
|
import time
|
|
|
|
from hermes_cli.dashboard_auth.base import (
|
|
DashboardAuthProvider,
|
|
InvalidCodeError,
|
|
LoginStart,
|
|
RefreshExpiredError,
|
|
Session,
|
|
)
|
|
|
|
_STUB_SECRET = b"stub-test-secret-not-for-prod"
|
|
# Length of HMAC-SHA256 digest. We append this many trailing bytes of
|
|
# signature after ``raw`` in ``_sign``; ``_unsign`` slices them back off
|
|
# rather than splitting on a separator. (A separator byte chosen
|
|
# arbitrarily, e.g. ``b"."``, fails ~12% of the time when the HMAC
|
|
# digest happens to contain that byte — ``bytes.rsplit`` then splits at
|
|
# the wrong index and HMAC verification spuriously rejects the token.)
|
|
_SIG_LEN = hashlib.sha256().digest_size
|
|
|
|
|
|
def _sign(payload: dict) -> str:
|
|
"""Produce a tamper-evident opaque token.
|
|
|
|
Not a real JWT — just a base64(JSON || HMAC-SHA256) blob with enough
|
|
structure to round-trip through verify_session. The signature is
|
|
appended as a fixed-length suffix (no separator) so binary HMAC bytes
|
|
can't be confused with a delimiter.
|
|
"""
|
|
raw = json.dumps(payload, separators=(",", ":")).encode()
|
|
sig = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
|
|
return base64.urlsafe_b64encode(raw + sig).decode()
|
|
|
|
|
|
def _unsign(token: str) -> dict | None:
|
|
"""Inverse of ``_sign``; returns None on any tamper/decode failure."""
|
|
try:
|
|
blob = base64.urlsafe_b64decode(token.encode())
|
|
if len(blob) <= _SIG_LEN:
|
|
return None
|
|
raw, sig = blob[:-_SIG_LEN], blob[-_SIG_LEN:]
|
|
expected = hmac.new(_STUB_SECRET, raw, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(sig, expected):
|
|
return None
|
|
return json.loads(raw)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
class StubAuthProvider(DashboardAuthProvider):
|
|
"""Local fake IDP for E2E tests.
|
|
|
|
``start_login`` returns a redirect to
|
|
``{redirect_uri}?code=stub_code&state={s}`` so the test harness can
|
|
walk the full round trip in-process without talking to anything
|
|
external. ``access_token`` is an HMAC-signed JSON blob;
|
|
``verify_session`` decodes and checks ``exp``.
|
|
"""
|
|
|
|
name = "stub"
|
|
display_name = "Stub IdP (test only)"
|
|
|
|
def __init__(self, default_ttl: int = 3600):
|
|
self._default_ttl = default_ttl
|
|
# state → verifier mapping, cleared on complete_login
|
|
self._state_to_verifier: dict[str, str] = {}
|
|
|
|
def start_login(self, *, redirect_uri: str) -> LoginStart:
|
|
state = secrets.token_urlsafe(16)
|
|
verifier = secrets.token_urlsafe(32)
|
|
self._state_to_verifier[state] = verifier
|
|
return LoginStart(
|
|
redirect_url=f"{redirect_uri}?code=stub_code&state={state}",
|
|
cookie_payload={
|
|
"hermes_session_pkce": f"state={state};verifier={verifier}",
|
|
},
|
|
)
|
|
|
|
def complete_login(
|
|
self, *, code: str, state: str, code_verifier: str, redirect_uri: str,
|
|
) -> Session:
|
|
if code != "stub_code":
|
|
raise InvalidCodeError(
|
|
f"stub expects code='stub_code', got {code!r}"
|
|
)
|
|
expected_verifier = self._state_to_verifier.get(state)
|
|
if expected_verifier is None or expected_verifier != code_verifier:
|
|
raise InvalidCodeError("stub state/verifier mismatch")
|
|
del self._state_to_verifier[state]
|
|
|
|
now = int(time.time())
|
|
exp = now + self._default_ttl
|
|
return Session(
|
|
user_id="stub-user-1",
|
|
email="stub@example.test",
|
|
display_name="Stub User",
|
|
org_id="stub-org-1",
|
|
provider=self.name,
|
|
expires_at=exp,
|
|
access_token=_sign({
|
|
"sub": "stub-user-1",
|
|
"email": "stub@example.test",
|
|
"name": "Stub User",
|
|
"org_id": "stub-org-1",
|
|
"exp": exp,
|
|
}),
|
|
refresh_token=_sign({
|
|
"sub": "stub-user-1",
|
|
"kind": "refresh",
|
|
"exp": now + 30 * 86400,
|
|
}),
|
|
)
|
|
|
|
def verify_session(self, *, access_token: str):
|
|
payload = _unsign(access_token)
|
|
# ``<=`` so default_ttl=0 produces a born-expired token. This
|
|
# matches what Phase 6's silent-refresh tests need ("set a 0-TTL
|
|
# access token; the next request should refresh transparently").
|
|
if payload is None or payload.get("exp", 0) <= int(time.time()):
|
|
return None
|
|
return Session(
|
|
user_id=payload["sub"],
|
|
email=payload["email"],
|
|
display_name=payload["name"],
|
|
org_id=payload["org_id"],
|
|
provider=self.name,
|
|
expires_at=payload["exp"],
|
|
access_token=access_token,
|
|
refresh_token="", # not surfaced on verify
|
|
)
|
|
|
|
def refresh_session(self, *, refresh_token: str) -> Session:
|
|
payload = _unsign(refresh_token)
|
|
# ``<=`` for symmetry with verify_session — a 0-TTL token is
|
|
# treated as expired.
|
|
if payload is None or payload.get("exp", 0) <= int(time.time()):
|
|
raise RefreshExpiredError("stub refresh token expired/invalid")
|
|
now = int(time.time())
|
|
exp = now + self._default_ttl
|
|
return Session(
|
|
user_id=payload["sub"],
|
|
email="stub@example.test",
|
|
display_name="Stub User",
|
|
org_id="stub-org-1",
|
|
provider=self.name,
|
|
expires_at=exp,
|
|
access_token=_sign({
|
|
"sub": payload["sub"],
|
|
"email": "stub@example.test",
|
|
"name": "Stub User",
|
|
"org_id": "stub-org-1",
|
|
"exp": exp,
|
|
}),
|
|
refresh_token=_sign({
|
|
"sub": payload["sub"],
|
|
"kind": "refresh",
|
|
"exp": now + 30 * 86400,
|
|
}),
|
|
)
|
|
|
|
def revoke_session(self, *, refresh_token: str) -> None:
|
|
# Stub is in-memory; nothing to revoke server-side.
|
|
return None
|