Skip to content

Commit 51b594e

Browse files
authored
Auth0 support (#16)
1 parent 48cc3f5 commit 51b594e

23 files changed

+1705
-22
lines changed

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"name": "asta",
1212
"source": "./",
1313
"description": "Paper search, citations, literature reports, and Semantic Scholar API tools",
14-
"version": "0.5.1",
14+
"version": "0.6",
1515
"author": {
1616
"name": "AI2 Asta Team"
1717
},

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "asta",
3-
"version": "0.5.1",
3+
"version": "0.6",
44
"description": "Asta science tools for Claude Code - paper search, citations, and more",
55
"author": {
66
"name": "AI2 Asta Team"

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,22 @@ uv tool upgrade asta
4848

4949
See `asta --help` for usage instructions.
5050

51+
## Authentication
52+
53+
The Asta CLI makes calls to Ai2-hosted APIs. To authenticate use of those APIs, you must
54+
create a login with your email address and a password.
55+
56+
```bash
57+
# Login (opens browser for authentication)
58+
asta auth login
59+
60+
# Check authentication status
61+
asta auth status
62+
63+
# Logout
64+
asta auth logout
65+
```
66+
5167
## Development
5268

5369
See [DEVELOPER.md](DEVELOPER.md) for contributor guidelines, architecture details, and development setup.

pyproject.toml

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,24 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "asta"
7-
version = "0.5.1"
7+
version = "0.6"
88
description = "Asta CLI for scientific literature review"
99
readme = "README.md"
1010
requires-python = ">=3.11"
1111
license = "Apache-2.0"
1212
dependencies = [
1313
"click>=8.0",
1414
"pydantic>=2.0",
15+
"pydantic-settings>=2.1.0",
1516
"pyhocon>=0.3.60",
1617
"pymupdf>=1.27.0",
1718
"pymupdf-layout>=1.27.0",
1819
"pymupdf4llm>=0.3.0",
20+
"httpx>=0.26.0",
21+
"rich>=13.7.0",
22+
"python-jose[cryptography]>=3.3.0",
23+
"keyring>=24.3.0",
24+
"platformdirs>=4.1.0",
1925
]
2026

2127
[project.scripts]

src/asta/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""Asta - Science literature research tools"""
22

3-
__version__ = "0.5.1"
3+
__version__ = "0.6"

src/asta/auth/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""Authentication module for Asta CLI."""
2+
3+
from asta.auth.token_manager import TokenManager
4+
5+
__all__ = ["TokenManager"]

src/asta/auth/device_flow.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
"""
2+
Auth0 Device Authorization Flow implementation.
3+
4+
Follows RFC 8628: https://tools.ietf.org/html/rfc8628
5+
"""
6+
7+
import asyncio
8+
import time
9+
from dataclasses import dataclass
10+
11+
import httpx
12+
13+
from .exceptions import AuthenticationError, AuthenticationTimeout
14+
15+
16+
@dataclass
17+
class DeviceCodeResponse:
18+
"""Response from /oauth/device/code endpoint."""
19+
20+
device_code: str
21+
user_code: str
22+
verification_uri: str
23+
verification_uri_complete: str
24+
expires_in: int
25+
interval: int
26+
27+
28+
@dataclass
29+
class TokenResponse:
30+
"""Response from /oauth/token endpoint."""
31+
32+
access_token: str
33+
refresh_token: str | None
34+
id_token: str | None
35+
token_type: str
36+
expires_in: int
37+
scope: str
38+
39+
40+
class DeviceAuthFlow:
41+
"""Implements Auth0 Device Authorization Flow."""
42+
43+
def __init__(
44+
self,
45+
domain: str,
46+
client_id: str,
47+
audience: str,
48+
scopes: str = "openid profile email offline_access",
49+
):
50+
self.domain = domain
51+
self.client_id = client_id
52+
self.audience = audience
53+
self.scopes = scopes
54+
self.device_code_url = f"https://{domain}/oauth/device/code"
55+
self.token_url = f"https://{domain}/oauth/token"
56+
57+
async def initiate(self) -> DeviceCodeResponse:
58+
"""
59+
Step 1: Initiate device authorization flow.
60+
61+
Returns device code and user instructions.
62+
"""
63+
async with httpx.AsyncClient() as client:
64+
response = await client.post(
65+
self.device_code_url,
66+
data={ # Use form data instead of JSON for better compatibility
67+
"client_id": self.client_id,
68+
"scope": self.scopes,
69+
"audience": self.audience,
70+
},
71+
)
72+
response.raise_for_status()
73+
data = response.json()
74+
75+
return DeviceCodeResponse(
76+
device_code=data["device_code"],
77+
user_code=data["user_code"],
78+
verification_uri=data["verification_uri"],
79+
verification_uri_complete=data.get(
80+
"verification_uri_complete",
81+
f"{data['verification_uri']}?user_code={data['user_code']}",
82+
),
83+
expires_in=data["expires_in"],
84+
interval=data["interval"],
85+
)
86+
87+
async def poll_for_token(
88+
self, device_code: str, interval: int, timeout: int = 900
89+
) -> TokenResponse:
90+
"""
91+
Step 2: Poll for access token.
92+
93+
Args:
94+
device_code: Device code from initiate()
95+
interval: Polling interval in seconds
96+
timeout: Max time to wait in seconds
97+
98+
Returns:
99+
Token response with access_token and refresh_token
100+
101+
Raises:
102+
AuthenticationTimeout: If user doesn't complete auth in time
103+
AuthenticationError: If auth fails or user denies
104+
"""
105+
start_time = time.time()
106+
current_interval = interval
107+
108+
async with httpx.AsyncClient() as client:
109+
while time.time() - start_time < timeout:
110+
await asyncio.sleep(current_interval)
111+
112+
try:
113+
response = await client.post(
114+
self.token_url,
115+
data={ # Use form data for better OAuth compatibility
116+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
117+
"device_code": device_code,
118+
"client_id": self.client_id,
119+
},
120+
)
121+
122+
# Success!
123+
if response.status_code == 200:
124+
data = response.json()
125+
return TokenResponse(
126+
access_token=data["access_token"],
127+
refresh_token=data.get("refresh_token"),
128+
id_token=data.get("id_token"),
129+
token_type=data["token_type"],
130+
expires_in=data["expires_in"],
131+
scope=data.get("scope", ""),
132+
)
133+
134+
# Handle errors
135+
error_data = response.json()
136+
error = error_data.get("error")
137+
138+
if error == "authorization_pending":
139+
# User hasn't completed auth yet
140+
continue
141+
elif error == "slow_down":
142+
# Increase polling interval
143+
current_interval += 5
144+
continue
145+
elif error == "expired_token":
146+
raise AuthenticationTimeout("Device code expired")
147+
elif error == "access_denied":
148+
raise AuthenticationError("User denied authorization")
149+
else:
150+
raise AuthenticationError(f"Authentication failed: {error}")
151+
152+
except httpx.HTTPError as e:
153+
raise AuthenticationError(f"Network error: {e}")
154+
155+
raise AuthenticationTimeout("Authentication timeout")
156+
157+
async def refresh_token(self, refresh_token: str) -> TokenResponse:
158+
"""
159+
Refresh an expired access token.
160+
161+
Args:
162+
refresh_token: Refresh token from previous authentication
163+
164+
Returns:
165+
New token response
166+
"""
167+
async with httpx.AsyncClient() as client:
168+
response = await client.post(
169+
self.token_url,
170+
data={ # Use form data for better OAuth compatibility
171+
"grant_type": "refresh_token",
172+
"client_id": self.client_id,
173+
"refresh_token": refresh_token,
174+
},
175+
)
176+
177+
if response.status_code != 200:
178+
error_data = response.json()
179+
raise AuthenticationError(
180+
f"Token refresh failed: {error_data.get('error')}"
181+
)
182+
183+
data = response.json()
184+
return TokenResponse(
185+
access_token=data["access_token"],
186+
refresh_token=data.get(
187+
"refresh_token", refresh_token
188+
), # May return same
189+
id_token=data.get("id_token"),
190+
token_type=data["token_type"],
191+
expires_in=data["expires_in"],
192+
scope=data.get("scope", ""),
193+
)

src/asta/auth/exceptions.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"""Authentication exceptions."""
2+
3+
4+
class AuthenticationError(Exception):
5+
"""Base authentication error."""
6+
7+
pass
8+
9+
10+
class AuthenticationTimeout(AuthenticationError):
11+
"""Authentication timed out."""
12+
13+
pass

src/asta/auth/storage.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""
2+
Secure token storage using platform-specific secure storage.
3+
4+
Falls back to file-based storage with restrictive permissions.
5+
"""
6+
7+
import json
8+
import os
9+
from pathlib import Path
10+
11+
import keyring
12+
from platformdirs import user_config_dir
13+
14+
APP_NAME = "asta-cli"
15+
TOKEN_FILE_NAME = "tokens.json"
16+
17+
18+
class TokenStorage:
19+
"""Manages secure storage of authentication tokens."""
20+
21+
def __init__(self, use_keyring: bool = True):
22+
self.use_keyring = use_keyring
23+
self.config_dir = Path(user_config_dir(APP_NAME, appauthor="AI2"))
24+
self.token_file = self.config_dir / TOKEN_FILE_NAME
25+
26+
# Ensure config directory exists
27+
self.config_dir.mkdir(parents=True, exist_ok=True)
28+
29+
def save_tokens(self, tokens: dict[str, str]) -> None:
30+
"""
31+
Save tokens securely.
32+
33+
Tries system keyring first, falls back to file with restrictive permissions.
34+
"""
35+
if self.use_keyring:
36+
try:
37+
keyring.set_password(APP_NAME, "tokens", json.dumps(tokens))
38+
return
39+
except Exception:
40+
# Keyring failed, fall back to file
41+
pass
42+
43+
# File-based storage
44+
with open(self.token_file, "w") as f:
45+
json.dump(tokens, f, indent=2)
46+
47+
# Set restrictive permissions (owner read/write only)
48+
os.chmod(self.token_file, 0o600)
49+
50+
def load_tokens(self) -> dict[str, str] | None:
51+
"""Load tokens from storage."""
52+
if self.use_keyring:
53+
try:
54+
token_json = keyring.get_password(APP_NAME, "tokens")
55+
if token_json:
56+
return json.loads(token_json)
57+
except Exception:
58+
pass
59+
60+
# Try file-based storage
61+
if self.token_file.exists():
62+
try:
63+
with open(self.token_file) as f:
64+
return json.load(f)
65+
except Exception:
66+
return None
67+
68+
return None
69+
70+
def delete_tokens(self) -> None:
71+
"""Delete stored tokens."""
72+
if self.use_keyring:
73+
try:
74+
keyring.delete_password(APP_NAME, "tokens")
75+
except Exception:
76+
pass
77+
78+
if self.token_file.exists():
79+
self.token_file.unlink()
80+
81+
def get_access_token(self) -> str | None:
82+
"""Get just the access token."""
83+
tokens = self.load_tokens()
84+
return tokens.get("access_token") if tokens else None
85+
86+
def get_refresh_token(self) -> str | None:
87+
"""Get just the refresh token."""
88+
tokens = self.load_tokens()
89+
return tokens.get("refresh_token") if tokens else None

0 commit comments

Comments
 (0)