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:
Ray Sun
2026-06-08 15:52:33 -07:00
committed by Teknium
parent 4615e08d3d
commit 37561c214b
3 changed files with 286 additions and 17 deletions
+175 -16
View File
@@ -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