Skip to content

Commit ac2fa9a

Browse files
authored
Accept ASTA_TOKEN env var (#28)
1 parent df4ce25 commit ac2fa9a

File tree

5 files changed

+129
-52
lines changed

5 files changed

+129
-52
lines changed

src/asta/auth/storage.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,13 @@
22
Secure token storage using platform-specific secure storage.
33
44
Falls back to file-based storage with restrictive permissions.
5+
6+
Both keyring and file are kept in sync to prevent token loss when
7+
refresh token rotation is enabled (rotated tokens are single-use).
58
"""
69

710
import json
11+
import logging
812
import os
913
from pathlib import Path
1014

@@ -14,6 +18,8 @@
1418
APP_NAME = "asta-cli"
1519
TOKEN_FILE_NAME = "tokens.json"
1620

21+
logger = logging.getLogger(__name__)
22+
1723

1824
class TokenStorage:
1925
"""Manages secure storage of authentication tokens."""
@@ -26,36 +32,49 @@ def __init__(self, use_keyring: bool = True):
2632
# Ensure config directory exists
2733
self.config_dir.mkdir(parents=True, exist_ok=True)
2834

35+
def _save_to_file(self, tokens: dict[str, str]) -> None:
36+
"""Save tokens to file with restrictive permissions."""
37+
with open(self.token_file, "w") as f:
38+
json.dump(tokens, f, indent=2)
39+
os.chmod(self.token_file, 0o600)
40+
2941
def save_tokens(self, tokens: dict[str, str]) -> None:
3042
"""
3143
Save tokens securely.
3244
33-
Tries system keyring first, falls back to file with restrictive permissions.
45+
Writes to both keyring and file to prevent token loss when
46+
refresh token rotation invalidates old tokens. If keyring
47+
fails, falls back to file-only storage.
3448
"""
49+
keyring_ok = False
3550
if self.use_keyring:
3651
try:
3752
keyring.set_password(APP_NAME, "tokens", json.dumps(tokens))
38-
return
39-
except Exception:
40-
# Keyring failed, fall back to file
41-
pass
53+
keyring_ok = True
54+
except Exception as e:
55+
logger.debug("Keyring save failed, using file storage: %s", e)
4256

43-
# File-based storage
44-
with open(self.token_file, "w") as f:
45-
json.dump(tokens, f, indent=2)
57+
# Always save to file as well — ensures consistency if keyring
58+
# becomes unavailable on a future read after token rotation.
59+
self._save_to_file(tokens)
4660

47-
# Set restrictive permissions (owner read/write only)
48-
os.chmod(self.token_file, 0o600)
61+
if not keyring_ok and self.use_keyring:
62+
logger.debug("Tokens saved to file only (keyring unavailable)")
4963

5064
def load_tokens(self) -> dict[str, str] | None:
51-
"""Load tokens from storage."""
65+
"""
66+
Load tokens from storage.
67+
68+
Prefers keyring, falls back to file. Both should be in sync
69+
since save_tokens writes to both.
70+
"""
5271
if self.use_keyring:
5372
try:
5473
token_json = keyring.get_password(APP_NAME, "tokens")
5574
if token_json:
5675
return json.loads(token_json)
57-
except Exception:
58-
pass
76+
except Exception as e:
77+
logger.debug("Keyring load failed, trying file: %s", e)
5978

6079
# Try file-based storage
6180
if self.token_file.exists():
@@ -68,7 +87,7 @@ def load_tokens(self) -> dict[str, str] | None:
6887
return None
6988

7089
def delete_tokens(self) -> None:
71-
"""Delete stored tokens."""
90+
"""Delete stored tokens from all backends."""
7291
if self.use_keyring:
7392
try:
7493
keyring.delete_password(APP_NAME, "tokens")

src/asta/auth/token_manager.py

Lines changed: 59 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
High-level token management with automatic refresh.
33
"""
44

5+
import asyncio
56
import json
7+
import logging
68
import time
79
import urllib.error
810
import urllib.request
@@ -16,6 +18,12 @@
1618
from .exceptions import AuthenticationError
1719
from .storage import TokenStorage
1820

21+
logger = logging.getLogger(__name__)
22+
23+
# Retry settings for token refresh
24+
REFRESH_MAX_RETRIES = 3
25+
REFRESH_RETRY_DELAY = 2 # seconds, doubles each retry
26+
1927

2028
class TokenManager:
2129
"""Manages authentication tokens with automatic refresh."""
@@ -121,26 +129,58 @@ async def get_valid_access_token(self) -> str:
121129
"Please re-authenticate with 'asta auth login'."
122130
)
123131

124-
try:
125-
token_response = await self.flow.refresh_token(refresh_token)
126-
127-
# Save new tokens
128-
self.storage.save_tokens(
129-
{
130-
"access_token": token_response.access_token,
131-
"refresh_token": token_response.refresh_token or refresh_token,
132-
"id_token": token_response.id_token,
133-
"expires_at": int(time.time()) + token_response.expires_in,
134-
}
135-
)
136-
137-
return token_response.access_token
132+
# Retry refresh with exponential backoff to handle transient errors
133+
last_error = None
134+
for attempt in range(REFRESH_MAX_RETRIES):
135+
try:
136+
if attempt > 0:
137+
delay = REFRESH_RETRY_DELAY * (2 ** (attempt - 1))
138+
logger.info(
139+
"Retrying token refresh (attempt %d/%d) after %ds",
140+
attempt + 1,
141+
REFRESH_MAX_RETRIES,
142+
delay,
143+
)
144+
await asyncio.sleep(delay)
145+
146+
token_response = await self.flow.refresh_token(refresh_token)
147+
148+
# Save new tokens
149+
self.storage.save_tokens(
150+
{
151+
"access_token": token_response.access_token,
152+
"refresh_token": token_response.refresh_token
153+
or refresh_token,
154+
"id_token": token_response.id_token,
155+
"expires_at": int(time.time()) + token_response.expires_in,
156+
}
157+
)
158+
159+
if attempt > 0:
160+
logger.info(
161+
"Token refresh succeeded on attempt %d", attempt + 1
162+
)
163+
164+
return token_response.access_token
165+
166+
except AuthenticationError:
167+
# Auth errors (invalid grant, denied) won't be fixed by retry
168+
raise
169+
170+
except Exception as e:
171+
last_error = e
172+
logger.warning(
173+
"Token refresh attempt %d/%d failed: %s",
174+
attempt + 1,
175+
REFRESH_MAX_RETRIES,
176+
e,
177+
)
138178

139-
except Exception as e:
140-
raise AuthenticationError(
141-
f"Token refresh failed: {e}. "
142-
f"Please re-authenticate with 'asta auth login'."
143-
)
179+
raise AuthenticationError(
180+
f"Token refresh failed after {REFRESH_MAX_RETRIES} attempts: "
181+
f"{last_error}. "
182+
f"Please re-authenticate with 'asta auth login'."
183+
)
144184

145185
return access_token
146186

src/asta/commands/auth.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -189,17 +189,37 @@ def status():
189189

190190
@auth.command(name="print-token")
191191
@click.option("--raw", is_flag=True, help="Print raw base64-encoded token")
192-
def print_token(raw):
192+
@click.option(
193+
"--refresh", is_flag=True, help="Refresh the token if expired before printing"
194+
)
195+
def print_token(raw, refresh):
193196
"""Print the stored access token."""
194-
storage = TokenStorage()
195-
tokens = storage.load_tokens()
197+
if refresh:
198+
# Go through TokenManager to trigger auto-refresh if needed
199+
from asta.auth.exceptions import AuthenticationError
196200

197-
if not tokens or not tokens.get("access_token"):
198-
console.print("❌ [red]No token found[/red]")
199-
console.print(" Run [cyan]asta auth login[/cyan] to authenticate")
200-
raise click.Abort()
201+
try:
202+
settings = get_auth_settings()
203+
manager = TokenManager(
204+
auth0_domain=settings.auth0_domain,
205+
client_id=settings.auth0_client_id,
206+
audience=settings.auth0_audience,
207+
gateway_url=settings.gateway_url,
208+
)
209+
access_token = asyncio.run(manager.get_valid_access_token())
210+
except AuthenticationError as e:
211+
console.print(f"❌ [red]{e}[/red]")
212+
raise click.Abort()
213+
else:
214+
storage = TokenStorage()
215+
tokens = storage.load_tokens()
216+
217+
if not tokens or not tokens.get("access_token"):
218+
console.print("❌ [red]No token found[/red]")
219+
console.print(" Run [cyan]asta auth login[/cyan] to authenticate")
220+
raise click.Abort()
201221

202-
access_token = tokens["access_token"]
222+
access_token = tokens["access_token"]
203223

204224
if raw:
205225
# Print raw base64-encoded token

src/asta/utils/auth_helper.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""Helper utilities for authentication in CLI commands."""
22

33
import asyncio
4+
import os
45

56
from asta.auth.exceptions import AuthenticationError
67
from asta.auth.token_manager import TokenManager
@@ -22,6 +23,9 @@ def get_access_token() -> str:
2223
This function handles token refresh automatically. If the token
2324
is expired and a refresh token is available, it will be refreshed.
2425
"""
26+
if token := os.environ.get("ASTA_TOKEN"):
27+
return token
28+
2529
try:
2630
# Get auth settings from config
2731
settings = get_auth_settings()
@@ -39,10 +43,8 @@ def get_access_token() -> str:
3943
return asyncio.run(manager.get_valid_access_token())
4044

4145
except AuthenticationError:
42-
# Re-raise with user-friendly message
43-
raise AuthenticationError(
44-
"Not authenticated. Please run 'asta auth login' to authenticate."
45-
)
46+
# Re-raise with original message preserved (includes refresh failure details)
47+
raise
4648
except Exception as e:
4749
# Convert any other errors to AuthenticationError with helpful message
4850
raise AuthenticationError(

tests/test_auth_helper.py

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,13 +54,11 @@ def test_get_access_token_without_token(self):
5454
)
5555
MockManager.return_value = mock_manager
5656

57-
with pytest.raises(
58-
AuthenticationError, match="Please run 'asta auth login'"
59-
):
57+
with pytest.raises(AuthenticationError):
6058
get_access_token()
6159

6260
def test_get_access_token_refresh_failure(self):
63-
"""Test that token refresh failures are handled with helpful message."""
61+
"""Test that token refresh failures are re-raised with original message."""
6462
from asta.auth.exceptions import AuthenticationError
6563
from asta.utils.auth_helper import get_access_token
6664

@@ -81,9 +79,7 @@ def test_get_access_token_refresh_failure(self):
8179
)
8280
MockManager.return_value = mock_manager
8381

84-
with pytest.raises(
85-
AuthenticationError, match="Please run 'asta auth login'"
86-
):
82+
with pytest.raises(AuthenticationError):
8783
get_access_token()
8884

8985
def test_get_access_token_other_error(self):

0 commit comments

Comments
 (0)