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
95 changes: 49 additions & 46 deletions packages/deepctl-core/src/deepctl_core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -552,7 +554,7 @@ def _request_device_code(self) -> DeviceCodeResponse:
payload = {
"client_id": CLIENT_ID,
"hostname": hostname,
"scopes": "admin",
"scopes": "openid profile email",
}

try:
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
26 changes: 14 additions & 12 deletions packages/deepctl-core/tests/unit/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -597,52 +597,54 @@ 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"):
with patch("deepctl_core.auth.keyring"):
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)

with patch("deepctl_core.auth.keyring") as mock_kr:
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)

with patch("deepctl_core.auth.keyring") as mock_kr:
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
Expand Down
Loading