Research: Auth token-target issuer guard (#4755)
No open [NEEDS CLARIFICATION]. This record consolidates a 3-lens opus grounding squad + a post-spec adversarial lens; every decision below was contested and resolved (per the adversarial-evidence posture).
D-1 — Canonical helper home: the auth package (server_target.py)
src/specify_cli/auth/server_target.py, reusing its in-module resolve_server_target and _normalize_url.
dependency (resolver, normalizer, SAAS_URL_ENV_VAR). saas_client already imports server_target freely and there is no LayerRule between sub-packages of specify_cli, so saas_client consuming it is the existing, legal pattern. CLAUDE.md's "saas_client is a client of upstream" governs API shape, not an import ban.
normalizer); homing in saas_client (rejected — wrong bounded context, and login/status/doctor would have to reach into a client module).
- Decision: home
resolve_token_endpoint(session)and the issuer decision in - Rationale: the issuer/target rule is auth-subsystem semantics; server_target.py already owns every
- Alternatives: a new top-level module (rejected — needless surface, would re-export the private
- Disposition: accepted.
D-2 — Refusal type: new IssuerTargetMismatchError(AuthenticationError)
refresh-path refusal must be a type the saas_client._usable_access_token bare-except cannot demote to "use the held token" → add it to _dead_session_errors().
fast path that returns a still-valid token is covered too).
- Decision: add to
auth/errors.py; carries issuer host, resolved host, and a stable remedy id. - Rationale:
RefreshTokenExpiredErrorimplies the token died (wrong remedy). CRITICAL: the - Disposition: accepted (adversarial M4 reinforced: the guard must gate the send, so the held-token
D-3 — Guard/resolve at the TokenManager boundary for refresh
error contract the in-lock flow does not express, and holding the lock across a raise.
- Decision: resolve + guard before entering
run_refresh_transaction/ the machine lock. - Rationale: refresh runs in-lock; resolving inside risks
ServerTargetSplitBrainErrorentering an - Disposition: accepted.
D-4 — Per-flow refusal adapters (refusal must not be swallowed into a send)
proceeds on logout, user warned server revoke was skipped.
hard raise (would break otherwise-working sessions).
flows; logout local teardown explicitly proceeds).
- refresh: raise (in
_dead_session_errors), guard before POST. - revoke: never raises →
RevokeOutcome.ISSUER_MISMATCH, guard before thetry; local teardown still - rehydrate: best-effort → fail-closed no-op + specific warning (hosts + remedy); not upgraded to a
- ws: wrap into
WebSocketProvisioningError; guard before URL build andget_access_token. - Disposition: accepted (adversarial M3: split-brain collapses into the same non-leak posture on all
D-5 — Legacy issuer_url = None → resolved target, no refusal
compare, no refusal, never the packaged default.
- Decision: route to
resolve_server_target(...).resolved_server_url(honours config.toml); no - Rationale: otherwise legacy self-hosted sessions keep leaking to the default host.
- Disposition: accepted.
D-6 — Preserve the display-vs-raise duality on unification
for the four flows + saas_client; _auth_saas_target.format_saas_mismatch_warning renders the same verdict as a display string (keeps RETURNING, does not start raising).
a raise-based helper would break the display path.
- Decision: the shared decision returns a verdict;
resolve_token_endpointis the raise wrapper - Rationale:
auth login/status/doctorrender the warning without aborting; collapsing them onto - Disposition: accepted (adversarial M1 — highest-value catch).
Scope decisions
label in _auth_saas_target.py) and #4265 (auth login mismatch exit code + canonical-URL comparison) as same-seam campsite, only on lines the unification already rewrites.
direct invocation, not an integration reproduction (adversarial M5).
epic. Parent #4755 under epic #3892 for tracking (operator tracker action).
- Closes #4755 only. Fold #4053 (dead
except ConfigurationErrorbranch + import + provenance - Dormant ws (Path 4): no caller since the sync transport retired → defence-in-depth; verified by
- Kept SEPARATE: #2941, #3279, local-write-safety siblings (#4756/#4757/#4812), #4760, dashboard-trust
Supply chain
No dependency added/upgraded/removed → supply-chain planning gate not engaged.