mirror of
https://github.com/usestrix/strix.git
synced 2026-10-15 04:18:10 +00:00
Add identity token sources for Anthropic Workload Identity Federation beyond a token file: - ANTHROPIC_IDENTITY_TOKEN_COMMAND: a command run on every exchange whose stdout is the JWT (run without a shell; errors never include stdout). Saved to the Strix config like the other ANTHROPIC_* settings. - `strix auth login entra [--manual] [--tenant ID] [--app ID]`: PKCE sign-in as a Microsoft Entra ID user, using the audience app as its own public client so the stored refresh token only reaches that audience. Tokens are refreshed automatically; defaults come from STRIX_ENTRA_TENANT_ID / STRIX_ENTRA_APP_ID. Source order: token file, command, literal token, Entra sign-in, GitHub Actions OIDC. `strix auth status` and `logout` now cover both ChatGPT and Entra, and `strix auth test [--config PATH]` runs one exchange with the source a scan would use, reporting the source, claims and result without printing any token. Docs: new "Anthropic via Microsoft Entra ID" guide (Entra and Console setup, Entra users on any host, Azure VM managed identities, everyday use, security, troubleshooting), plus the WIF settings in the configuration reference and `strix auth` in the CLI reference.
304 lines
11 KiB
Python
304 lines
11 KiB
Python
"""Tests for the `strix auth` CLI: subcommand routing and provider naming."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import base64
|
|
import json
|
|
import time
|
|
from typing import TYPE_CHECKING, Any
|
|
|
|
import pytest
|
|
|
|
from strix.config import anthropic_wif, codex, entra_user, loader
|
|
from strix.interface import auth_cli
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _tmp_store(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(codex, "AUTH_PATH", tmp_path / "home" / ".strix" / "subscription-auth.json")
|
|
monkeypatch.setattr(entra_user, "AUTH_PATH", tmp_path / "home" / ".strix" / "entra-auth.json")
|
|
for key in ("STRIX_ENTRA_TENANT_ID", "STRIX_ENTRA_APP_ID"):
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setattr(loader, "_override", tmp_path / "missing-config.json")
|
|
monkeypatch.setattr(loader, "_cached", None)
|
|
|
|
|
|
def test_login_provider_is_chatgpt() -> None:
|
|
assert auth_cli.LOGIN_PROVIDER == "chatgpt"
|
|
assert codex.PROVIDER in auth_cli._ACCEPTED_PROVIDERS
|
|
assert "chatgpt" in auth_cli._ACCEPTED_PROVIDERS
|
|
|
|
|
|
def test_unknown_subcommand_returns_usage_error() -> None:
|
|
assert auth_cli.run_auth(["bogus"]) == 2
|
|
|
|
|
|
def test_help_returns_zero() -> None:
|
|
assert auth_cli.run_auth(["--help"]) == 0
|
|
|
|
|
|
def test_status_not_signed_in() -> None:
|
|
assert auth_cli.run_auth(["status"]) == 1
|
|
|
|
|
|
def test_login_rejects_unsupported_provider(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _should_not_run(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
|
msg = "OAuth flow must not start for an unsupported provider"
|
|
raise AssertionError(msg)
|
|
|
|
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _should_not_run)
|
|
assert auth_cli.run_auth(["login", "gemini"]) == 2
|
|
|
|
|
|
def test_finish_requires_state_on_loopback(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
|
|
|
|
# Loopback (require_state=True): missing or mismatched state is rejected.
|
|
with pytest.raises(codex.CodexAuthError) as missing:
|
|
auth_cli._finish("code", None, "verifier", "expected", require_state=True)
|
|
assert missing.value.code == "state_mismatch"
|
|
with pytest.raises(codex.CodexAuthError) as mismatch:
|
|
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=True)
|
|
assert mismatch.value.code == "state_mismatch"
|
|
|
|
# Matching state proceeds to the exchange.
|
|
assert auth_cli._finish("code", "expected", "verifier", "expected", require_state=True) == {
|
|
"ok": True
|
|
}
|
|
|
|
|
|
def test_finish_manual_paste_allows_absent_state(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setattr(codex, "exchange_code", lambda *_: {"ok": True})
|
|
# Manual paste (require_state=False): a bare code with no state is accepted,
|
|
# but a present-and-wrong state is still rejected.
|
|
assert auth_cli._finish("code", None, "verifier", "expected", require_state=False) == {
|
|
"ok": True
|
|
}
|
|
with pytest.raises(codex.CodexAuthError):
|
|
auth_cli._finish("code", "wrong", "verifier", "expected", require_state=False)
|
|
|
|
|
|
def test_finish_rejects_missing_code() -> None:
|
|
with pytest.raises(codex.CodexAuthError) as exc:
|
|
auth_cli._finish(None, "expected", "verifier", "expected", require_state=True)
|
|
assert exc.value.code == "no_code"
|
|
|
|
|
|
def test_model_subcommand_removed() -> None:
|
|
assert auth_cli.run_auth(["model", "gpt-5.5"]) == 2
|
|
|
|
|
|
@pytest.mark.parametrize("provider", ["chatgpt", "codex", "ChatGPT"])
|
|
def test_login_accepts_provider_aliases(provider: str, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
reached = {"flow": False}
|
|
|
|
def _fake_flow(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
|
reached["flow"] = True
|
|
return {
|
|
"type": "oauth",
|
|
"provider": "codex",
|
|
"access": "a",
|
|
"refresh": "r",
|
|
"account_id": "acct",
|
|
"expires_at": 0,
|
|
}
|
|
|
|
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _fake_flow)
|
|
monkeypatch.setattr(codex, "save_record", lambda _record: None)
|
|
|
|
assert auth_cli.run_auth(["login", provider]) == 0
|
|
assert reached["flow"] is True
|
|
|
|
|
|
def _entra_record(**overrides: Any) -> dict[str, Any]:
|
|
record = {
|
|
"type": "oauth",
|
|
"provider": "entra",
|
|
"tenant": "tenant-id",
|
|
"app": "app-id",
|
|
"access": "entra-access",
|
|
"refresh": "entra-refresh",
|
|
"username": "user@example.com",
|
|
"expires_at": time.time() + 3600,
|
|
}
|
|
record.update(overrides)
|
|
return record
|
|
|
|
|
|
def _codex_record() -> dict[str, Any]:
|
|
return {
|
|
"type": "oauth",
|
|
"provider": "codex",
|
|
"access": "a",
|
|
"refresh": "r",
|
|
"account_id": "acct",
|
|
"expires_at": time.time() + 3600,
|
|
}
|
|
|
|
|
|
def test_login_entra_manual_paste_saves_the_sign_in(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[tuple[str, ...]] = []
|
|
|
|
def _exchange(tenant: str, app: str, code: str, verifier: str) -> dict[str, Any]:
|
|
calls.append((tenant, app, code))
|
|
assert verifier
|
|
return _entra_record(tenant=tenant, app=app)
|
|
|
|
monkeypatch.setattr(entra_user, "exchange_code", _exchange)
|
|
monkeypatch.setattr(
|
|
auth_cli.Console, "input", lambda *_args, **_kwargs: "http://localhost:8400/?code=abc"
|
|
)
|
|
|
|
argv = ["login", "entra", "--manual", "--tenant", "tenant-id", "--app", "app-id"]
|
|
assert auth_cli.run_auth(argv) == 0
|
|
assert calls == [("tenant-id", "app-id", "abc")]
|
|
saved = entra_user.read_record()
|
|
assert saved is not None
|
|
assert saved["tenant"] == "tenant-id"
|
|
|
|
|
|
def test_login_entra_uses_settings_for_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.setenv("STRIX_ENTRA_TENANT_ID", "tenant-from-env")
|
|
monkeypatch.setenv("STRIX_ENTRA_APP_ID", "app-from-env")
|
|
seen: dict[str, str] = {}
|
|
|
|
def _fake_flow(_console: Any, url: str, *_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
|
seen["url"] = url
|
|
return _entra_record()
|
|
|
|
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _fake_flow)
|
|
assert auth_cli.run_auth(["login", "entra"]) == 0
|
|
assert "/tenant-from-env/oauth2/v2.0/authorize" in seen["url"]
|
|
assert "client_id=app-from-env" in seen["url"]
|
|
|
|
|
|
def test_login_entra_without_ids_is_a_usage_error(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
def _should_not_run(*_args: Any, **_kwargs: Any) -> dict[str, Any]:
|
|
msg = "OAuth flow must not start without Entra IDs"
|
|
raise AssertionError(msg)
|
|
|
|
monkeypatch.setattr(auth_cli, "_run_oauth_flow", _should_not_run)
|
|
assert auth_cli.run_auth(["login", "entra"]) == 2
|
|
|
|
|
|
def test_status_reports_both_providers(capsys: pytest.CaptureFixture[str]) -> None:
|
|
entra_user.save_record(_entra_record())
|
|
assert auth_cli.run_auth(["status"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "ChatGPT subscription" in out
|
|
assert "Not signed in" in out
|
|
assert "Microsoft Entra ID" in out
|
|
assert "user@example.com" in out
|
|
|
|
|
|
def test_logout_signs_out_of_every_provider(capsys: pytest.CaptureFixture[str]) -> None:
|
|
codex.save_record(_codex_record())
|
|
entra_user.save_record(_entra_record())
|
|
assert auth_cli.run_auth(["logout"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert "ChatGPT subscription and Microsoft Entra ID" in out
|
|
assert codex.read_record() is None
|
|
assert entra_user.read_record() is None
|
|
|
|
|
|
def test_logout_when_signed_out(capsys: pytest.CaptureFixture[str]) -> None:
|
|
assert auth_cli.run_auth(["logout"]) == 0
|
|
assert "Not signed in" in capsys.readouterr().out
|
|
|
|
|
|
_WIF_ENV = {
|
|
"ANTHROPIC_FEDERATION_RULE_ID": "fdrl_test",
|
|
"ANTHROPIC_ORGANIZATION_ID": "org-test",
|
|
"ANTHROPIC_SERVICE_ACCOUNT_ID": "svac_test",
|
|
"STRIX_LLM": "anthropic/claude-sonnet-4-6",
|
|
}
|
|
|
|
|
|
def _fake_jwt(claims: dict[str, Any]) -> str:
|
|
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
|
|
return f"e30.{payload}.sig"
|
|
|
|
|
|
@pytest.fixture
|
|
def wif_env(monkeypatch: pytest.MonkeyPatch) -> str:
|
|
for key in (
|
|
"ANTHROPIC_WORKSPACE_ID",
|
|
"ANTHROPIC_IDENTITY_TOKEN_FILE",
|
|
"ANTHROPIC_IDENTITY_TOKEN_COMMAND",
|
|
"ACTIONS_ID_TOKEN_REQUEST_URL",
|
|
):
|
|
monkeypatch.delenv(key, raising=False)
|
|
for key, value in _WIF_ENV.items():
|
|
monkeypatch.setenv(key, value)
|
|
monkeypatch.setenv("LLM_API_KEY", "")
|
|
jwt = _fake_jwt({"iss": "https://idp.example", "sub": "subject-123"})
|
|
monkeypatch.setenv("ANTHROPIC_IDENTITY_TOKEN", jwt)
|
|
return jwt
|
|
|
|
|
|
def test_test_reports_source_claims_and_prefix_only(
|
|
wif_env: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
seen: list[str | None] = []
|
|
|
|
def _exchange(assertion: str | None = None) -> tuple[str, float]:
|
|
seen.append(assertion)
|
|
return "sk-ant-oat01-SECRETPART", time.time() + 600
|
|
|
|
monkeypatch.setattr(anthropic_wif, "exchange", _exchange)
|
|
assert auth_cli.run_auth(["test"]) == 0
|
|
out = capsys.readouterr().out
|
|
assert seen == [wif_env]
|
|
assert "ANTHROPIC_IDENTITY_TOKEN" in out
|
|
assert "subject-123" in out
|
|
assert "sk-ant-oat01-…" in out
|
|
assert "SECRETPART" not in out
|
|
assert wif_env not in out
|
|
assert "Note:" not in out
|
|
|
|
|
|
def test_test_without_federation_ids(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
for key in _WIF_ENV:
|
|
monkeypatch.delenv(key, raising=False)
|
|
assert auth_cli.run_auth(["test"]) == 1
|
|
|
|
|
|
def test_test_reports_exchange_failure(
|
|
wif_env: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
del wif_env
|
|
|
|
def _fail(_assertion: str | None = None) -> tuple[str, float]:
|
|
raise anthropic_wif.WifAuthError("token exchange failed: HTTP 401")
|
|
|
|
monkeypatch.setattr(anthropic_wif, "exchange", _fail)
|
|
assert auth_cli.run_auth(["test"]) == 1
|
|
assert "HTTP 401" in capsys.readouterr().out
|
|
|
|
|
|
def test_test_warns_when_an_api_key_wins(
|
|
wif_env: str, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
del wif_env
|
|
monkeypatch.setenv("LLM_API_KEY", "sk-ant-api-key")
|
|
monkeypatch.setattr(anthropic_wif, "exchange", lambda _a=None: ("sk-ant-oat01-x", time.time()))
|
|
assert auth_cli.run_auth(["test"]) == 0
|
|
assert "LLM_API_KEY is set" in capsys.readouterr().out
|
|
|
|
|
|
def test_test_reads_settings_from_config(
|
|
wif_env: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
del wif_env
|
|
config = tmp_path / "strix-config.json"
|
|
config.write_text(json.dumps({"env": _WIF_ENV}), encoding="utf-8")
|
|
for key in _WIF_ENV:
|
|
monkeypatch.delenv(key, raising=False)
|
|
monkeypatch.setattr(anthropic_wif, "exchange", lambda _a=None: ("sk-ant-oat01-x", time.time()))
|
|
assert auth_cli.run_auth(["test"]) == 1
|
|
monkeypatch.setattr(loader, "_cached", None)
|
|
assert auth_cli.run_auth(["test", "--config", str(config)]) == 0
|