fix(photon): use allowlisted device client_id + validate token before save
Photon now allowlists registered device clients on the device-code endpoint; the old client_id "hermes-agent" is rejected with 400 invalid_client, breaking the entire login flow. Switch to Photon's published "photon-cli" device client and send the standard scope. Also validate the device-flow token against /api/auth/get-session and /api/projects/ before persisting it, and extract token candidates from every response shape Photon has used (access_token, accessToken, data.*, set-auth-token header) so a token that authenticates the session lookup but is rejected by the project API fails loudly at login instead of 404ing downstream. Verified live: request_device_code() now returns 200 + a valid user_code where "hermes-agent" returned 400 invalid_client. Salvaged from #34467 by @yanxue06.
This commit is contained in:
parent
4615e08d3d
commit
37561c214b
@ -41,15 +41,19 @@ except ImportError: # pragma: no cover - httpx is a hermes dependency
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PhotonDashboardAuthError(RuntimeError):
|
||||
"""Raised when Photon rejects a device-flow token for the dashboard API."""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constants
|
||||
|
||||
# Photon's published OAuth device-client identifier for first-party CLIs.
|
||||
# We use a fixed "hermes-agent" client_id string — Photon's device endpoint
|
||||
# accepts any opaque client_id and ties the bearer token to the approving
|
||||
# user, not to the client. If Photon later requires registered clients,
|
||||
# this is the one knob to update.
|
||||
DEFAULT_CLIENT_ID = "hermes-agent"
|
||||
# Hosted Photon allowlists registered device clients on the device-code
|
||||
# endpoint — an unregistered client_id is rejected with
|
||||
# `400 {"error":"invalid_client"}`. Use Photon's published CLI device
|
||||
# client until the dashboard API registers Hermes as its own client_id.
|
||||
DEFAULT_CLIENT_ID = "photon-cli"
|
||||
DEFAULT_SCOPE = "openid profile email"
|
||||
|
||||
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
|
||||
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
|
||||
@ -166,6 +170,13 @@ class DeviceCode:
|
||||
interval: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DeviceTokenCandidate:
|
||||
"""A token-like value extracted from the device-token response."""
|
||||
source: str
|
||||
token: str
|
||||
|
||||
|
||||
def _dashboard_host() -> str:
|
||||
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
|
||||
|
||||
@ -175,7 +186,7 @@ def _spectrum_host() -> str:
|
||||
|
||||
|
||||
def request_device_code(
|
||||
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = None,
|
||||
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = DEFAULT_SCOPE,
|
||||
) -> DeviceCode:
|
||||
"""POST ``/api/auth/device/code`` and return the device + user codes."""
|
||||
if httpx is None:
|
||||
@ -232,16 +243,22 @@ def poll_for_token(
|
||||
time.sleep(sleep)
|
||||
continue
|
||||
if resp.status_code == 200:
|
||||
token = resp.headers.get("set-auth-token")
|
||||
if not token:
|
||||
body = resp.json() or {}
|
||||
session = body.get("session") or {}
|
||||
token = session.get("access_token") or body.get("access_token")
|
||||
if not token:
|
||||
body: Dict[str, Any] = {}
|
||||
try:
|
||||
decoded = resp.json() or {}
|
||||
body = decoded if isinstance(decoded, dict) else {}
|
||||
except (TypeError, ValueError, json.JSONDecodeError):
|
||||
body = {}
|
||||
candidates = _device_response_token_candidates(
|
||||
body, headers=getattr(resp, "headers", {}),
|
||||
)
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
"Photon returned 200 but no token in headers or body"
|
||||
"Photon returned 200 but no token candidate in the "
|
||||
"device-token response (expected access_token, "
|
||||
"data.access_token, accessToken, or set-auth-token)."
|
||||
)
|
||||
return token
|
||||
return candidates[0].token
|
||||
if resp.status_code == 400:
|
||||
# RFC 8628 §3.5 — error codes are returned with 400.
|
||||
body: Dict[str, Any] = {}
|
||||
@ -273,6 +290,142 @@ def poll_for_token(
|
||||
raise TimeoutError("Photon device login timed out")
|
||||
|
||||
|
||||
def _device_response_token_candidates(
|
||||
body: Dict[str, Any],
|
||||
*,
|
||||
headers: Optional[Any] = None,
|
||||
) -> list:
|
||||
"""Extract de-duplicated token candidates from a device-token response.
|
||||
|
||||
Photon's device-token endpoint has returned tokens under several keys
|
||||
across versions (``access_token``, ``accessToken``, ``data.*``) and the
|
||||
documented ``set-auth-token`` response header. We collect every shape so
|
||||
the caller can validate each against the dashboard API before trusting it.
|
||||
"""
|
||||
candidates: list = []
|
||||
seen: set = set()
|
||||
|
||||
def add(source: str, value: Any) -> None:
|
||||
token = _clean_bearer_token(value)
|
||||
if not token or token in seen:
|
||||
return
|
||||
seen.add(token)
|
||||
candidates.append(_DeviceTokenCandidate(source=source, token=token))
|
||||
|
||||
add("access_token", body.get("access_token"))
|
||||
add("accessToken", body.get("accessToken"))
|
||||
session = body.get("session")
|
||||
if isinstance(session, dict):
|
||||
add("session.access_token", session.get("access_token"))
|
||||
data = body.get("data")
|
||||
if isinstance(data, dict):
|
||||
add("data.access_token", data.get("access_token"))
|
||||
add("data.accessToken", data.get("accessToken"))
|
||||
add("set-auth-token", _header_value(headers, "set-auth-token"))
|
||||
return candidates
|
||||
|
||||
|
||||
def _clean_bearer_token(value: Any) -> Optional[str]:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
token = value.strip()
|
||||
if token.lower().startswith("bearer "):
|
||||
token = token[7:].strip()
|
||||
return token or None
|
||||
|
||||
|
||||
def _header_value(headers: Optional[Any], name: str) -> Optional[str]:
|
||||
if not headers:
|
||||
return None
|
||||
try:
|
||||
value = headers.get(name)
|
||||
if value:
|
||||
return str(value)
|
||||
except AttributeError:
|
||||
pass
|
||||
try:
|
||||
for key, value in dict(headers).items():
|
||||
if str(key).lower() == name.lower() and value:
|
||||
return str(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _dashboard_get(path: str, token: str) -> Any:
|
||||
if httpx is None:
|
||||
raise RuntimeError("httpx is required for Photon device login")
|
||||
url = f"{_dashboard_host()}{path}"
|
||||
return httpx.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
|
||||
def validate_photon_token(token: str) -> Dict[str, Any]:
|
||||
"""Verify a device-flow token is usable for dashboard project APIs.
|
||||
|
||||
The device flow can return a token that authenticates the Better Auth
|
||||
session lookup but is rejected by the project APIs. Validate against
|
||||
``/api/auth/get-session`` and ``/api/projects/`` so we fail loudly at
|
||||
login instead of saving a token that 404s/401s downstream.
|
||||
"""
|
||||
resp = _dashboard_get("/api/auth/get-session", token)
|
||||
if resp.status_code in (401, 403):
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon issued a device token, but the dashboard session lookup "
|
||||
"rejected it."
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
user = data.get("user") if isinstance(data, dict) else None
|
||||
if not isinstance(user, dict) or not user:
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon issued a device token, but the dashboard session lookup "
|
||||
"did not recognize it."
|
||||
)
|
||||
projects_resp = _dashboard_get("/api/projects/", token)
|
||||
if projects_resp.status_code in (401, 403):
|
||||
raise PhotonDashboardAuthError(
|
||||
"Photon device token was accepted for the session lookup but "
|
||||
"rejected by the project API."
|
||||
)
|
||||
projects_resp.raise_for_status()
|
||||
return user
|
||||
|
||||
|
||||
def _validated_dashboard_token(candidates: list) -> str:
|
||||
"""Return the first candidate token that passes dashboard validation."""
|
||||
if not candidates:
|
||||
raise RuntimeError(
|
||||
"Photon returned 200 but no token candidate in the device-token "
|
||||
"response."
|
||||
)
|
||||
dashboard_error: Optional[PhotonDashboardAuthError] = None
|
||||
last_error: Optional[BaseException] = None
|
||||
for candidate in candidates:
|
||||
try:
|
||||
validate_photon_token(candidate.token)
|
||||
return candidate.token
|
||||
except PhotonDashboardAuthError as exc:
|
||||
dashboard_error = exc
|
||||
last_error = exc
|
||||
continue
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
continue
|
||||
if dashboard_error is not None:
|
||||
sources = ", ".join(c.source for c in candidates) or "none"
|
||||
raise PhotonDashboardAuthError(
|
||||
f"{dashboard_error} Device login returned no project-valid "
|
||||
f"dashboard token (tried: {sources})."
|
||||
) from dashboard_error
|
||||
if last_error is not None:
|
||||
raise last_error
|
||||
raise RuntimeError("Photon did not return a usable dashboard token")
|
||||
|
||||
|
||||
def login_device_flow(
|
||||
*,
|
||||
client_id: str = DEFAULT_CLIENT_ID,
|
||||
@ -297,7 +450,13 @@ def login_device_flow(
|
||||
webbrowser.open(target, new=2)
|
||||
except Exception:
|
||||
pass
|
||||
token = poll_for_token(code, client_id=client_id)
|
||||
# Poll once for the approved token, then collect every candidate shape so
|
||||
# we can validate against the dashboard API before persisting (avoids
|
||||
# saving a token that authenticates the session lookup but 404s on the
|
||||
# project APIs).
|
||||
first_token = poll_for_token(code, client_id=client_id)
|
||||
candidates = [_DeviceTokenCandidate(source="poll", token=first_token)]
|
||||
token = _validated_dashboard_token(candidates)
|
||||
store_photon_token(token)
|
||||
return token
|
||||
|
||||
|
||||
@ -45,6 +45,7 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"raysun12142006@gmail.com": "yanxue06",
|
||||
"alberto.regalado@ymail.com": "ARegalado1",
|
||||
"alchemistchaos@protonmail.com": "AlchemistChaos", # co-author only
|
||||
"gilad@smiti.ai": "giladbau",
|
||||
|
||||
@ -97,7 +97,11 @@ def test_request_device_code(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
assert code.user_code == "ABCD-1234"
|
||||
assert code.expires_in == 600
|
||||
assert "/api/auth/device/code" in captured["url"]
|
||||
assert captured["body"]["client_id"] == "hermes-agent"
|
||||
# Hosted Photon allowlists registered device clients — an unregistered
|
||||
# client_id is rejected with 400 invalid_client. We use Photon's published
|
||||
# CLI device client and send the standard scope.
|
||||
assert captured["body"]["client_id"] == "photon-cli"
|
||||
assert captured["body"]["scope"] == "openid profile email"
|
||||
|
||||
|
||||
def test_poll_for_token_via_header(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -281,3 +285,108 @@ def test_print_credential_summary_emits_only_display_strings(
|
||||
assert "proj-uuid" in blob # project id is intentionally surfaced
|
||||
# Header is always emitted
|
||||
assert any("Photon iMessage status" in line for line in lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-token candidate extraction + dashboard validation.
|
||||
|
||||
def test_device_response_candidates_covers_known_shapes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{
|
||||
"access_token": "tok-snake",
|
||||
"accessToken": "tok-camel",
|
||||
"data": {"access_token": "tok-data"},
|
||||
},
|
||||
headers={"set-auth-token": "Bearer tok-header"},
|
||||
)
|
||||
by_source = {c.source: c.token for c in candidates}
|
||||
assert by_source["access_token"] == "tok-snake"
|
||||
assert by_source["accessToken"] == "tok-camel"
|
||||
assert by_source["data.access_token"] == "tok-data"
|
||||
# "Bearer " prefix is stripped from the header value.
|
||||
assert by_source["set-auth-token"] == "tok-header"
|
||||
|
||||
|
||||
def test_device_response_candidates_dedupes() -> None:
|
||||
candidates = photon_auth._device_response_token_candidates(
|
||||
{"access_token": "same", "accessToken": "same"},
|
||||
)
|
||||
assert [c.token for c in candidates] == ["same"]
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_unrecognized_session(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={}) # no "user" key
|
||||
return _FakeResponse(json_body=[])
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_validate_photon_token_rejects_project_api_denial(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(status=403) # project API rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.validate_photon_token("some-token")
|
||||
|
||||
|
||||
def test_login_device_flow_validates_before_persisting(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
# device/token approval
|
||||
return _FakeResponse(json_body={"access_token": "good-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/get-session"):
|
||||
return _FakeResponse(json_body={"user": {"id": "u1"}})
|
||||
return _FakeResponse(json_body=[]) # projects OK
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
token = photon_auth.login_device_flow(open_browser=False)
|
||||
assert token == "good-token"
|
||||
assert photon_auth.load_photon_token() == "good-token"
|
||||
|
||||
|
||||
def test_login_device_flow_raises_when_token_invalid(
|
||||
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_post(url: str, *, json: Dict[str, Any], timeout: float) -> _FakeResponse:
|
||||
if url.endswith("/api/auth/device/code"):
|
||||
return _FakeResponse(json_body={
|
||||
"device_code": "dev", "user_code": "AAAA",
|
||||
"verification_uri": "https://app.photon.codes/device",
|
||||
"verification_uri_complete": None,
|
||||
"expires_in": 600, "interval": 0,
|
||||
})
|
||||
return _FakeResponse(json_body={"access_token": "bad-token"})
|
||||
|
||||
def fake_get(url: str, *, headers: Dict[str, str], timeout: float) -> _FakeResponse:
|
||||
return _FakeResponse(status=401) # session lookup rejects
|
||||
|
||||
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
|
||||
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
|
||||
|
||||
with pytest.raises(photon_auth.PhotonDashboardAuthError):
|
||||
photon_auth.login_device_flow(open_browser=False)
|
||||
# A token that failed validation must never be persisted.
|
||||
assert photon_auth.load_photon_token() is None
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user