Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 56 additions & 21 deletions headroom/copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,9 @@ def read_cached_oauth_token() -> str | None:
return None


def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
def iter_oauth_token_candidates(
*, include_platform_secret_stores: bool = True
) -> list[CopilotTokenCandidate]:
"""Return reusable token candidates in safest-first discovery order."""

candidates: list[CopilotTokenCandidate] = []
Expand Down Expand Up @@ -804,25 +806,8 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
)
)

macos_copilot_token = _read_macos_keychain_oauth_token()
if macos_copilot_token:
candidates.append(
CopilotTokenCandidate(
token=macos_copilot_token,
source="macos-keychain:copilot-cli",
confidence="high",
)
)

linux_copilot_token = _read_linux_secret_oauth_token()
if linux_copilot_token:
candidates.append(
CopilotTokenCandidate(
token=linux_copilot_token,
source="linux-secret-service:copilot-cli",
confidence="high",
)
)
if include_platform_secret_stores:
candidates.extend(_platform_secret_store_oauth_token_candidates())

candidates.extend(_read_file_oauth_token_candidates())

Expand Down Expand Up @@ -850,6 +835,31 @@ def iter_oauth_token_candidates() -> list[CopilotTokenCandidate]:
return _dedupe_token_candidates(candidates)


def _platform_secret_store_oauth_token_candidates() -> list[CopilotTokenCandidate]:
"""Return OAuth candidates from platform credential stores."""
candidates: list[CopilotTokenCandidate] = []
macos_copilot_token = _read_macos_keychain_oauth_token()
if macos_copilot_token:
candidates.append(
CopilotTokenCandidate(
token=macos_copilot_token,
source="macos-keychain:copilot-cli",
confidence="high",
)
)

linux_copilot_token = _read_linux_secret_oauth_token()
if linux_copilot_token:
candidates.append(
CopilotTokenCandidate(
token=linux_copilot_token,
source="linux-secret-service:copilot-cli",
confidence="high",
)
)
return candidates


def _read_file_oauth_token_candidates() -> list[CopilotTokenCandidate]:
"""Return token candidates from Copilot/GitHub credential files."""

Expand Down Expand Up @@ -1113,9 +1123,34 @@ def resolve_subscription_bearer_token_details() -> CopilotSubscriptionTokenResol
api_url=_subscription_api_url_from_user_info_payload(payload),
)

for candidate in iter_oauth_token_candidates():
attempted_tokens: set[str] = set()
resolution = _resolve_subscription_oauth_token_candidates(
iter_oauth_token_candidates(include_platform_secret_stores=False),
attempted_tokens=attempted_tokens,
)
if resolution is not None:
return resolution

return _resolve_subscription_oauth_token_candidates(
[
candidate
for candidate in _platform_secret_store_oauth_token_candidates()
if candidate.token not in attempted_tokens
]
)


def _resolve_subscription_oauth_token_candidates(
candidates: list[CopilotTokenCandidate],
*,
attempted_tokens: set[str] | None = None,
) -> CopilotSubscriptionTokenResolution | None:
"""Return the first candidate GitHub accepts for subscription APIs."""
for candidate in candidates:
if not candidate.validate_for_subscription:
continue
if attempted_tokens is not None:
attempted_tokens.add(candidate.token)
if _is_copilot_api_token(candidate.token):
payload = _fetch_copilot_user_info(candidate.token)
if payload is not None:
Expand Down
154 changes: 148 additions & 6 deletions tests/test_copilot_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,7 +232,7 @@ def test_resolve_subscription_bearer_token_skips_invalid_generic_token(
monkeypatch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="ghp-generic",
source="env:GITHUB_TOKEN",
Expand Down Expand Up @@ -269,7 +269,7 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
monkeypatch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="gho-copilot",
source="macos-keychain:copilot-cli",
Expand All @@ -290,6 +290,94 @@ def test_resolve_subscription_bearer_token_does_not_fallback_to_unexchanged_oaut
assert copilot_auth.resolve_subscription_bearer_token() is None


def test_resolve_subscription_bearer_token_defers_keychain_when_saved_token_resolves(
monkeypatch: pytest.MonkeyPatch,
) -> None:
copilot_auth.save_headroom_copilot_oauth_token("gho-saved")
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_read_macos_keychain_oauth_token",
lambda: pytest.fail("Keychain must not be read when saved OAuth resolves"),
)
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_subscription_resolution_from_token_exchange",
lambda candidate: (
copilot_auth._subscription_resolution(
token="copilot-api",
source=f"{candidate.source}:token-exchange",
confidence="copilot-token-exchange",
api_url=copilot_auth.DEFAULT_API_URL,
refresh_oauth_token=candidate.token,
)
if candidate.token == "gho-saved"
else None
),
)

assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"


def test_resolve_subscription_bearer_token_reads_keychain_after_noninteractive_rejection(
monkeypatch: pytest.MonkeyPatch,
) -> None:
copilot_auth.save_headroom_copilot_oauth_token("gho-rejected")
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_read_macos_keychain_oauth_token",
lambda: "gho-keychain",
)
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_subscription_resolution_from_token_exchange",
lambda candidate: (
copilot_auth._subscription_resolution(
token="copilot-api",
source=f"{candidate.source}:token-exchange",
confidence="copilot-token-exchange",
api_url=copilot_auth.DEFAULT_API_URL,
refresh_oauth_token=candidate.token,
)
if candidate.token == "gho-keychain"
else None
),
)

assert copilot_auth.resolve_subscription_bearer_token() == "copilot-api"


def test_resolve_subscription_bearer_token_skips_duplicate_keychain_candidate(
monkeypatch: pytest.MonkeyPatch,
) -> None:
copilot_auth.save_headroom_copilot_oauth_token("gho-duplicate")
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_read_macos_keychain_oauth_token",
lambda: "gho-duplicate",
)
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)
attempted_tokens: list[str] = []
monkeypatch.setattr(
copilot_auth,
"_subscription_resolution_from_token_exchange",
lambda candidate: attempted_tokens.append(candidate.token) or None,
)

assert copilot_auth.resolve_subscription_bearer_token() is None
assert attempted_tokens == ["gho-duplicate"]


def test_subscription_enterprise_host_repro(
monkeypatch: pytest.MonkeyPatch,
) -> None:
Expand All @@ -304,7 +392,7 @@ def test_subscription_enterprise_host_repro(
monkeypatch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="gho-oauth",
source="headroom-copilot-auth:/tmp/copilot_auth.json",
Expand Down Expand Up @@ -360,7 +448,7 @@ def test_resolve_subscription_exchange_uses_cloud_enterprise_advertised_api(
monkeypatch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="gho-oauth",
source="env:GITHUB_COPILOT_TOKEN",
Expand Down Expand Up @@ -427,7 +515,7 @@ def _resolve_subscription_producer_path(
patch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="gho-oauth", source="test", confidence="test"
)
Expand All @@ -445,7 +533,7 @@ def _resolve_subscription_producer_path(
patch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
lambda **_kwargs: [
copilot_auth.CopilotTokenCandidate(
token="tid_api", source="test", confidence="test"
)
Expand Down Expand Up @@ -1615,3 +1703,57 @@ def __exit__(self, *args):
)

assert result == payload


def test_iter_oauth_token_candidates_includes_linux_secret_service_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: None)
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: "gho-linux")
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)

candidates = copilot_auth.iter_oauth_token_candidates()

assert [(candidate.source, candidate.token) for candidate in candidates] == [
("linux-secret-service:copilot-cli", "gho-linux"),
]


def test_iter_oauth_token_candidates_skips_platform_secret_stores_when_disabled(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.delenv("GITHUB_COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_COPILOT_TOKEN", raising=False)
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setattr(copilot_auth, "_read_windows_copilot_cli_oauth_token", lambda: None)
monkeypatch.setattr(
copilot_auth,
"_platform_secret_store_oauth_token_candidates",
lambda: pytest.fail("platform secret stores should not be read"),
)
monkeypatch.setattr(copilot_auth, "_read_file_oauth_token_candidates", lambda: [])
monkeypatch.setattr(copilot_auth, "_read_gh_cli_oauth_token", lambda: None)

assert copilot_auth.iter_oauth_token_candidates(include_platform_secret_stores=False) == []


def test_platform_secret_store_candidates_include_macos_keychain_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(copilot_auth, "_read_macos_keychain_oauth_token", lambda: "gho-macos")
monkeypatch.setattr(copilot_auth, "_read_linux_secret_oauth_token", lambda: None)

candidates = copilot_auth._platform_secret_store_oauth_token_candidates()

assert [(candidate.source, candidate.token) for candidate in candidates] == [
("macos-keychain:copilot-cli", "gho-macos"),
]
34 changes: 24 additions & 10 deletions tests/test_copilot_subscription_smoke.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,19 +100,33 @@ def test_subscription_rejects_generic_token_and_accepts_api_token(
)
# A generic GitHub token is present but cannot be exchanged for a Copilot
# API token; a valid Copilot API token is discoverable behind it.
non_platform_candidates = [
copilot_auth.CopilotTokenCandidate(
token="ghp-generic-pat",
source="env:GITHUB_TOKEN",
confidence="generic-github",
)
]
platform_candidates = [
copilot_auth.CopilotTokenCandidate(
token="tid_real_copilot",
source="macos-keychain:copilot-cli",
confidence="high",
)
]
monkeypatch.setattr(
copilot_auth,
"iter_oauth_token_candidates",
lambda: [
copilot_auth.CopilotTokenCandidate(
token="ghp-generic-pat", source="env:GITHUB_TOKEN", confidence="generic-github"
),
copilot_auth.CopilotTokenCandidate(
token="tid_real_copilot",
source="macos-keychain:copilot-cli",
confidence="high",
),
],
lambda *, include_platform_secret_stores=True: (
platform_candidates + non_platform_candidates
if include_platform_secret_stores
else non_platform_candidates
),
)
monkeypatch.setattr(
copilot_auth,
"_platform_secret_store_oauth_token_candidates",
lambda: platform_candidates,
)
monkeypatch.setattr(
copilot_auth,
Expand Down
Loading