Skip to content

Commit e864452

Browse files
committed
refactor(copilot): enhance token snapshot with copilot_access detection and debug logging
1 parent 4a340d3 commit e864452

File tree

6 files changed

+151
-18
lines changed

6 files changed

+151
-18
lines changed

ccproxy/api/routes/health.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
- /health: Detailed diagnostics (comprehensive status)
77
88
Follows IETF Health Check Response Format draft standard.
9-
TODO: health endpoint Content-Type header to only return application/health+json per IETF spec
109
"""
1110

1211
from datetime import UTC, datetime

ccproxy/cli/commands/auth.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,7 @@ def _extract_token_snapshot_duck_typing(
258258
access_token=access_token,
259259
refresh_token=refresh_token,
260260
expires_at=expires_at,
261-
extras=extras,
261+
extras={},
262262
)
263263

264264

@@ -387,6 +387,11 @@ def _coerce_datetime(value: Any) -> datetime | None:
387387
):
388388
expires_at = _coerce_datetime(created_at + expires_in)
389389

390+
if provider_normalized == "copilot":
391+
if "refresh_token_present" not in extras:
392+
extras["refresh_token_present"] = bool(refresh_token)
393+
extras.setdefault("id_token_present", bool(extras.get("has_copilot_token")))
394+
390395
return TokenSnapshot(
391396
provider=provider_normalized,
392397
account_id=account_id,

ccproxy/plugins/copilot/manager.py

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -71,26 +71,23 @@ async def create(
7171
def _build_token_snapshot(self, credentials: CopilotCredentials) -> TokenSnapshot:
7272
"""Construct a token snapshot for Copilot credentials."""
7373
access_token: str | None = None
74-
if credentials.copilot_token and credentials.copilot_token.token:
75-
access_token = credentials.copilot_token.token.get_secret_value()
76-
elif credentials.oauth_token.access_token:
77-
access_token = credentials.oauth_token.access_token.get_secret_value()
74+
copilot_token = credentials.copilot_token
75+
if copilot_token and copilot_token.token:
76+
access_token = copilot_token.token.get_secret_value()
7877

7978
refresh_token: str | None = None
80-
if credentials.oauth_token.refresh_token:
81-
refresh_token = credentials.oauth_token.refresh_token.get_secret_value()
79+
oauth_token = credentials.oauth_token
80+
if oauth_token.refresh_token:
81+
refresh_token = oauth_token.refresh_token.get_secret_value()
8282

8383
expires_at = None
84-
if credentials.copilot_token and credentials.copilot_token.expires_at:
85-
expires_at = credentials.copilot_token.expires_at
84+
if copilot_token and copilot_token.expires_at:
85+
expires_at = copilot_token.expires_at
8686
else:
87-
if (
88-
credentials.oauth_token.expires_in
89-
and credentials.oauth_token.created_at
90-
):
91-
expires_at = credentials.oauth_token.expires_at_datetime
87+
if oauth_token.expires_in and oauth_token.created_at:
88+
expires_at = oauth_token.expires_at_datetime
9289

93-
scope_value = credentials.oauth_token.scope or ""
90+
scope_value = oauth_token.scope or ""
9491
scopes = tuple(
9592
scope
9693
for scope in (item.strip() for item in scope_value.split(" "))
@@ -102,6 +99,14 @@ def _build_token_snapshot(self, credentials: CopilotCredentials) -> TokenSnapsho
10299
"has_copilot_token": bool(credentials.copilot_token),
103100
}
104101

102+
logger.debug(
103+
"copilot_token_snapshot",
104+
scopes=scopes,
105+
expires_at=expires_at,
106+
credentials=credentials,
107+
access_token=access_token,
108+
refresh_token=refresh_token,
109+
)
105110
return TokenSnapshot(
106111
provider="copilot",
107112
access_token=access_token,

ccproxy/plugins/copilot/oauth/provider.py

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
import httpx
77

8+
from ccproxy.auth.managers.token_snapshot import TokenSnapshot
89
from ccproxy.auth.oauth.protocol import ProfileLoggingMixin, StandardProfileFields
910
from ccproxy.auth.oauth.registry import CliAuthConfig, FlowType, OAuthProviderInfo
1011
from ccproxy.core.logging import get_plugin_logger
@@ -311,14 +312,62 @@ async def get_token_info(self) -> CopilotTokenInfo | None:
311312
with contextlib.suppress(Exception):
312313
profile = await self.get_user_profile()
313314

315+
copilot_access = False
316+
if profile is not None:
317+
features = getattr(profile, "features", {}) or {}
318+
copilot_access = bool(features.get("copilot_access"))
319+
if not copilot_access and getattr(profile, "subscription_type", None):
320+
copilot_access = True
321+
322+
if not copilot_access and credentials.copilot_token is not None:
323+
token = credentials.copilot_token
324+
indicative_flags = [
325+
getattr(token, "chat_enabled", None),
326+
getattr(token, "annotations_enabled", None),
327+
getattr(token, "individual", None),
328+
]
329+
if any(flag is True for flag in indicative_flags if flag is not None):
330+
copilot_access = True
331+
else:
332+
copilot_access = (
333+
True # Possession of a copilot token implies active access
334+
)
335+
336+
if not copilot_access:
337+
copilot_access = credentials.copilot_token is not None
338+
314339
return CopilotTokenInfo(
315340
provider="copilot",
316341
oauth_expires_at=oauth_expires_at,
317342
copilot_expires_at=copilot_expires_at,
318343
account_type=credentials.account_type,
319-
copilot_access=False, # TODO: Get from profile or credentials
344+
copilot_access=copilot_access,
320345
)
321346

347+
async def get_token_snapshot(self) -> TokenSnapshot | None:
348+
"""Return a token snapshot built from stored credentials."""
349+
350+
try:
351+
manager = await self.create_token_manager(storage=self.storage)
352+
snapshot = await manager.get_token_snapshot()
353+
if snapshot:
354+
return snapshot
355+
except Exception as exc: # pragma: no cover - defensive logging
356+
logger.debug("copilot_snapshot_via_manager_failed", error=str(exc))
357+
358+
try:
359+
credentials = await self.storage.load_credentials()
360+
if not credentials:
361+
return None
362+
363+
from ..manager import CopilotTokenManager
364+
365+
temp_manager = CopilotTokenManager(storage=self.storage)
366+
return temp_manager._build_token_snapshot(credentials)
367+
except Exception as exc: # pragma: no cover - defensive logging
368+
logger.debug("copilot_snapshot_from_credentials_failed", error=str(exc))
369+
return None
370+
322371
async def is_authenticated(self) -> bool:
323372
"""Check if user is authenticated with valid tokens.
324373

tests/plugins/copilot/unit/oauth/test_provider.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pytest
88
from pydantic import SecretStr
99

10+
from ccproxy.auth.managers.token_snapshot import TokenSnapshot
1011
from ccproxy.auth.oauth.protocol import StandardProfileFields
1112
from ccproxy.plugins.copilot.config import CopilotOAuthConfig
1213
from ccproxy.plugins.copilot.oauth.models import (
@@ -87,6 +88,7 @@ def mock_oauth_token(self) -> CopilotOAuthToken:
8788
return CopilotOAuthToken(
8889
access_token=SecretStr("gho_test_token"),
8990
token_type="bearer",
91+
refresh_token=SecretStr("gho_refresh_token"),
9092
expires_in=28800, # 8 hours
9193
created_at=now,
9294
scope="read:user",
@@ -285,6 +287,7 @@ async def test_get_user_profile_success(
285287
provider_type="copilot",
286288
email="test@example.com",
287289
display_name="Test User",
290+
features={"copilot_access": True},
288291
)
289292

290293
with patch.object(
@@ -367,6 +370,25 @@ async def test_get_token_info_success(
367370
assert result.account_type == "individual"
368371
assert result.oauth_expires_at is not None
369372
assert result.copilot_expires_at is not None
373+
assert result.copilot_access is True
374+
375+
async def test_get_token_info_falls_back_to_credentials(
376+
self,
377+
oauth_provider: CopilotOAuthProvider,
378+
mock_credentials: CopilotCredentials,
379+
) -> None:
380+
"""copilot_access derived from credentials when profile unavailable."""
381+
oauth_provider.storage.load_credentials.return_value = mock_credentials # type: ignore[attr-defined]
382+
383+
with patch.object(
384+
oauth_provider, "get_user_profile", new_callable=AsyncMock
385+
) as mock_get_profile:
386+
mock_get_profile.side_effect = RuntimeError("profile not available")
387+
388+
result = await oauth_provider.get_token_info()
389+
390+
assert isinstance(result, CopilotTokenInfo)
391+
assert result.copilot_access is True
370392

371393
async def test_get_token_info_no_credentials(
372394
self, oauth_provider: CopilotOAuthProvider
@@ -378,6 +400,40 @@ async def test_get_token_info_no_credentials(
378400

379401
assert result is None
380402

403+
async def test_get_token_snapshot_uses_manager(
404+
self, oauth_provider: CopilotOAuthProvider
405+
) -> None:
406+
manager = AsyncMock()
407+
manager.get_token_snapshot.return_value = TokenSnapshot(provider="copilot") # type: ignore[attr-defined]
408+
409+
with patch.object(
410+
oauth_provider, "create_token_manager", AsyncMock(return_value=manager)
411+
) as mock_create:
412+
snapshot = await oauth_provider.get_token_snapshot()
413+
414+
assert isinstance(snapshot, TokenSnapshot)
415+
assert snapshot.provider == "copilot"
416+
mock_create.assert_awaited_once()
417+
manager.get_token_snapshot.assert_awaited_once()
418+
419+
async def test_get_token_snapshot_fallback_to_credentials(
420+
self,
421+
oauth_provider: CopilotOAuthProvider,
422+
mock_credentials: CopilotCredentials,
423+
) -> None:
424+
oauth_provider.storage.load_credentials.return_value = mock_credentials # type: ignore[attr-defined]
425+
426+
with patch.object(
427+
oauth_provider,
428+
"create_token_manager",
429+
AsyncMock(side_effect=RuntimeError("boom")),
430+
):
431+
snapshot = await oauth_provider.get_token_snapshot()
432+
433+
assert isinstance(snapshot, TokenSnapshot)
434+
assert snapshot.provider == "copilot"
435+
assert snapshot.has_refresh_token() is True
436+
381437
async def test_is_authenticated_with_valid_tokens(
382438
self,
383439
oauth_provider: CopilotOAuthProvider,

tests/plugins/copilot/unit/test_manager.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from ccproxy.plugins.copilot.oauth.storage import CopilotOAuthStorage
1515

1616

17-
def _build_oauth_token(now: datetime) -> CopilotOAuthToken:
17+
def _build_oauth_token(now: datetime, with_refresh: bool = True) -> CopilotOAuthToken:
1818
"""Helper to create a valid OAuth token scoped for tests."""
1919

2020
return CopilotOAuthToken(
@@ -23,6 +23,7 @@ def _build_oauth_token(now: datetime) -> CopilotOAuthToken:
2323
scope="read:user",
2424
created_at=int(now.timestamp()),
2525
expires_in=3600,
26+
refresh_token=SecretStr("refresh-token") if with_refresh else None,
2627
)
2728

2829

@@ -50,6 +51,24 @@ def _build_credentials(
5051
)
5152

5253

54+
def test_token_snapshot_flags(tmp_path: Path) -> None:
55+
"""Token snapshot should flag refresh/id token presence."""
56+
57+
storage = CopilotOAuthStorage(credentials_path=tmp_path / "credentials.json")
58+
manager = CopilotTokenManager(storage=storage)
59+
60+
now = datetime.now(UTC)
61+
credentials = _build_credentials(
62+
now=now,
63+
refresh_in=1200,
64+
updated_offset=timedelta(seconds=30),
65+
)
66+
67+
snapshot = manager._build_token_snapshot(credentials)
68+
69+
assert snapshot.has_refresh_token() is True
70+
71+
5372
def test_is_expired_uses_refresh_window(tmp_path: Path) -> None:
5473
"""Manager should treat refresh window crossing as expiration trigger."""
5574

0 commit comments

Comments
 (0)