diff --git a/packages/deepctl-core/src/deepctl_core/auth.py b/packages/deepctl-core/src/deepctl_core/auth.py index 96f15d7..e24f7bf 100644 --- a/packages/deepctl-core/src/deepctl_core/auth.py +++ b/packages/deepctl-core/src/deepctl_core/auth.py @@ -50,10 +50,12 @@ class TokenResponse(BaseModel): access_token: str project_id: str - refresh_token: str | None = None # Present in new JWT-based device flow + refresh_token: str | None = None # Legacy dx-id response field token_type: str | None = None expires_in: int | None = None scope: str | None = None + dg_token: str | None = None + dg_expires_in: int | None = None @property def api_key(self) -> str: @@ -552,7 +554,7 @@ def _request_device_code(self) -> DeviceCodeResponse: payload = { "client_id": CLIENT_ID, "hostname": hostname, - "scopes": "admin", + "scopes": "openid profile email", } try: @@ -628,61 +630,31 @@ def _poll_for_token( def _store_token(self, token_response: TokenResponse) -> None: """Store authentication token. - Handles two server response shapes: - - New JWT flow: access_token is a short-lived JWT (expires_in=900), - refresh_token present. Stores JWT + refresh_token in keyring. - - Legacy flow: access_token is a Deepgram API key directly. - Stored under api-key.{profile} as before. + Stores a direct finite Deepgram credential from dx-id. The credential + expires independently of the OIDC access token and requires a new + device authorization when it expires. """ profile_name = self.config.profile or "default" - if token_response.refresh_token: - # New JWT-based device flow. - jwt = token_response.access_token - refresh_token = token_response.refresh_token - expires_in = token_response.expires_in or 900 + if token_response.dg_token: + api_key = token_response.dg_token + expires_in = token_response.dg_expires_in or 0 + if expires_in <= 0: + raise AuthenticationError( + "dx-id did not provide a finite Deepgram credential expiry" + ) expires_at = ( datetime.now(timezone.utc) + timedelta(seconds=expires_in) ).isoformat() - try: - # Clear any stale direct API key so get_api_key() doesn't - # return a cached dg_token from a previous session. - keyring.delete_password(KEYRING_SERVICE, f"api-key.{profile_name}") - except Exception: - pass - - try: - keyring.set_password(KEYRING_SERVICE, f"jwt.{profile_name}", jwt) - keyring.set_password( - KEYRING_SERVICE, f"refresh-token.{profile_name}", refresh_token - ) - console.print( - "[green]✓[/green] Session stored securely in system keyring" - ) - except Exception as e: - console.print( - f"[yellow]Warning:[/yellow] Could not store in keyring: {e}" - ) - - # Store expiry timestamps and project ID in config (non-sensitive). - profile = self.config.get_profile(profile_name) - profile.jwt_expires_at = expires_at - profile.project_id = token_response.project_id - self.config.save() - - else: - # Legacy flow: access_token is the Deepgram API key directly. - api_key = token_response.access_token - project_id = token_response.project_id keyring_available = False - try: keyring.set_password( KEYRING_SERVICE, f"api-key.{profile_name}", api_key ) - console.print( - "[green]✓[/green] API key stored securely in system keyring" + keyring.delete_password(KEYRING_SERVICE, f"jwt.{profile_name}") + keyring.delete_password( + KEYRING_SERVICE, f"refresh-token.{profile_name}" ) keyring_available = True except Exception as e: @@ -694,8 +666,39 @@ def _store_token(self, token_response: TokenResponse) -> None: self.config.create_profile( profile_name, api_key=api_key if not keyring_available else None, - project_id=project_id, + project_id=token_response.project_id, + ) + profile = self.config.get_profile(profile_name) + profile.dg_token_expires_at = expires_at + profile.jwt_expires_at = None + self.config.save() + console.print("[green]✓[/green] Finite API key stored securely in system keyring") + return + + # Legacy flow: access_token is the Deepgram API key directly. + api_key = token_response.access_token + project_id = token_response.project_id + keyring_available = False + + try: + keyring.set_password( + KEYRING_SERVICE, f"api-key.{profile_name}", api_key + ) + console.print( + "[green]✓[/green] API key stored securely in system keyring" ) + keyring_available = True + except Exception as e: + console.print( + f"[yellow]Warning:[/yellow] Could not store in keyring: {e}" + ) + console.print("API key will be stored in config file instead") + + self.config.create_profile( + profile_name, + api_key=api_key if not keyring_available else None, + project_id=project_id, + ) def logout(self, keep_config: bool = False) -> None: """Logout user and clear credentials. diff --git a/packages/deepctl-core/tests/unit/test_auth.py b/packages/deepctl-core/tests/unit/test_auth.py index faf4a9f..ea1c2bc 100644 --- a/packages/deepctl-core/tests/unit/test_auth.py +++ b/packages/deepctl-core/tests/unit/test_auth.py @@ -597,8 +597,8 @@ def test_is_authenticated_check_profile_only(self, mock_config): ) -class TestJWTFlow: - """Tests for the new JWT-based device flow.""" +class TestFiniteCredentialFlow: + """Tests for finite direct credentials returned by dx-id.""" def _make_auth(self, mock_config): with patch("deepctl_core.auth.httpx.Client"): @@ -606,17 +606,17 @@ def _make_auth(self, mock_config): return AuthManager(mock_config) # ------------------------------------------------------------------ - # _store_token — JWT path + # _store_token — finite direct credential path # ------------------------------------------------------------------ - def test_store_token_jwt_path_saves_to_keyring(self, mock_config): + def test_store_token_direct_credential_saves_to_keyring(self, mock_config): from deepctl_core.auth import TokenResponse token = TokenResponse( access_token="jwt-abc", - refresh_token="rt-xyz", + dg_token="dg-finite-key", + dg_expires_in=2592000, project_id="proj-1", - expires_in=900, ) auth = self._make_auth(mock_config) @@ -624,17 +624,18 @@ def test_store_token_jwt_path_saves_to_keyring(self, mock_config): auth._store_token(token) calls = {call[0][1]: call[0][2] for call in mock_kr.set_password.call_args_list} - assert calls["jwt.default"] == "jwt-abc" - assert calls["refresh-token.default"] == "rt-xyz" + assert calls["api-key.default"] == "dg-finite-key" + assert "jwt.default" not in calls + assert "refresh-token.default" not in calls - def test_store_token_jwt_path_clears_stale_dg_token(self, mock_config): + def test_store_token_direct_credential_clears_legacy_session(self, mock_config): from deepctl_core.auth import TokenResponse token = TokenResponse( access_token="jwt-abc", - refresh_token="rt-xyz", + dg_token="dg-finite-key", + dg_expires_in=2592000, project_id="proj-1", - expires_in=900, ) auth = self._make_auth(mock_config) @@ -642,7 +643,8 @@ def test_store_token_jwt_path_clears_stale_dg_token(self, mock_config): auth._store_token(token) deleted = [c[0][1] for c in mock_kr.delete_password.call_args_list] - assert "api-key.default" in deleted + assert "jwt.default" in deleted + assert "refresh-token.default" in deleted def test_store_token_legacy_path_saves_api_key(self, mock_config): from deepctl_core.auth import TokenResponse