Data Model: Auth token-target issuer guard (#4755)

No persisted schema change and no migration. The relevant state already exists.

StoredSession (existing, unchanged)

sessions). Already persisted (session.py:145,203,230) and already preserved across refresh (refresh.py:181). This mission consumes it for target selection; it does not modify the field.

  • issuer_url: str | None — the host the session was minted against (None for pre-#176 legacy
  • access_token, refresh_token — the credentials that must reach only the issuer.

ResolvedServerTarget (existing, unchanged)

configured_server_url, env_server_url, override_mode. Used as the comparison RHS and the endpoint source.

  • From resolve_server_target(process_wide_override=False): resolved_server_url,

IssuerTargetMismatchError (new)

AuthenticationError subclass in auth/errors.py:

  • issuer_url: str — normalized issuer endpoint.
  • resolved_url: str — normalized resolved target endpoint.
  • remedy: str — stable remedy identifier ("auth login --force").
  • Message: names issuer host, resolved host, source, remedy; contains no token material.
  • Membership: added to saas_client.auth._dead_session_errors().

RevokeOutcome (existing enum, extended)

POST issued; local logout teardown still proceeds.

  • + ISSUER_MISMATCH — revoke refused because the resolved target differs from the session issuer; no

Invariants

  • For any session with a non-null issuer, no token is transmitted to a host ≠ the issuer.
  • The comparison endpoint and the request URL are byte-identical after the one canonical normalization.
  • Refusal diagnostics carry zero token material.