Skip to content

Commit 5c2bbe6

Browse files
committed
add auth to cloud cli calls
Signed-off-by: phernandez <paul@basicmachines.co>
1 parent 806d94a commit 5c2bbe6

3 files changed

Lines changed: 334 additions & 66 deletions

File tree

src/basic_memory/cli/auth.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""WorkOS OAuth Device Authorization for CLI."""
2+
3+
import json
4+
import os
5+
import time
6+
import webbrowser
7+
from pathlib import Path
8+
9+
import httpx
10+
from rich.console import Console
11+
12+
from basic_memory.config import ConfigManager
13+
14+
console = Console()
15+
16+
17+
class CLIAuth:
18+
"""Handles WorkOS OAuth Device Authorization for CLI tools."""
19+
20+
def __init__(self, client_id: str, authkit_domain: str):
21+
self.client_id = client_id
22+
self.authkit_domain = authkit_domain
23+
app_config = ConfigManager().config
24+
# Store tokens in data dir
25+
self.token_file = app_config.data_dir_path / "basic-memory-cloud.json"
26+
27+
async def request_device_authorization(self) -> dict | None:
28+
"""Request device authorization from WorkOS."""
29+
device_auth_url = f"{self.authkit_domain}/oauth2/device_authorization"
30+
31+
data = {"client_id": self.client_id, "scope": "openid profile email offline_access"}
32+
33+
try:
34+
async with httpx.AsyncClient() as client:
35+
response = await client.post(device_auth_url, data=data)
36+
37+
if response.status_code == 200:
38+
return response.json()
39+
else:
40+
console.print(
41+
f"[red]Device authorization failed: {response.status_code} - {response.text}[/red]"
42+
)
43+
return None
44+
except Exception as e:
45+
console.print(f"[red]Device authorization error: {e}[/red]")
46+
return None
47+
48+
def display_user_instructions(self, device_response: dict) -> None:
49+
"""Display user instructions for device authorization."""
50+
user_code = device_response["user_code"]
51+
verification_uri = device_response["verification_uri"]
52+
verification_uri_complete = device_response.get("verification_uri_complete")
53+
54+
console.print("\n[bold blue]🔐 Authentication Required[/bold blue]")
55+
console.print("\nTo authenticate, please visit:")
56+
console.print(f"[bold cyan]{verification_uri}[/bold cyan]")
57+
console.print(f"\nAnd enter this code: [bold yellow]{user_code}[/bold yellow]")
58+
59+
if verification_uri_complete:
60+
console.print("\nOr for one-click access, visit:")
61+
console.print(f"[bold green]{verification_uri_complete}[/bold green]")
62+
63+
# Try to open browser automatically
64+
try:
65+
console.print("\n[dim]Opening browser automatically...[/dim]")
66+
webbrowser.open(verification_uri_complete)
67+
except Exception:
68+
pass # Silently fail if browser can't be opened
69+
70+
console.print("\n[dim]Waiting for you to complete authentication in your browser...[/dim]")
71+
72+
async def poll_for_token(self, device_code: str, interval: int = 5) -> dict | None:
73+
"""Poll the token endpoint until user completes authentication."""
74+
token_url = f"{self.authkit_domain}/oauth2/token"
75+
76+
data = {
77+
"client_id": self.client_id,
78+
"device_code": device_code,
79+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
80+
}
81+
82+
max_attempts = 60 # 5 minutes with 5-second intervals
83+
current_interval = interval
84+
85+
for _attempt in range(max_attempts):
86+
try:
87+
async with httpx.AsyncClient() as client:
88+
response = await client.post(token_url, data=data)
89+
90+
if response.status_code == 200:
91+
return response.json()
92+
93+
# Parse error response
94+
try:
95+
error_data = response.json()
96+
error = error_data.get("error")
97+
except Exception:
98+
error = "unknown_error"
99+
100+
if error == "authorization_pending":
101+
# User hasn't completed auth yet, keep polling
102+
pass
103+
elif error == "slow_down":
104+
# Increase polling interval
105+
current_interval += 5
106+
console.print("[yellow]Slowing down polling rate...[/yellow]")
107+
elif error == "access_denied":
108+
console.print("[red]Authentication was denied by user[/red]")
109+
return None
110+
elif error == "expired_token":
111+
console.print("[red]Device code has expired. Please try again.[/red]")
112+
return None
113+
else:
114+
console.print(f"[red]Token polling error: {error}[/red]")
115+
return None
116+
117+
except Exception as e:
118+
console.print(f"[red]Token polling request error: {e}[/red]")
119+
120+
# Wait before next poll
121+
await self._async_sleep(current_interval)
122+
123+
console.print("[red]Authentication timeout. Please try again.[/red]")
124+
return None
125+
126+
async def _async_sleep(self, seconds: int) -> None:
127+
"""Async sleep utility."""
128+
import asyncio
129+
130+
await asyncio.sleep(seconds)
131+
132+
def save_tokens(self, tokens: dict) -> None:
133+
"""Save tokens to project root as .bm-auth.json."""
134+
token_data = {
135+
"access_token": tokens["access_token"],
136+
"refresh_token": tokens.get("refresh_token"),
137+
"expires_at": int(time.time()) + tokens.get("expires_in", 3600),
138+
"token_type": tokens.get("token_type", "Bearer"),
139+
}
140+
141+
with open(self.token_file, "w") as f:
142+
json.dump(token_data, f, indent=2)
143+
144+
# Secure the token file
145+
os.chmod(self.token_file, 0o600)
146+
147+
console.print(f"[green]✓ Tokens saved to {self.token_file}[/green]")
148+
149+
def load_tokens(self) -> dict | None:
150+
"""Load tokens from .bm-auth.json file."""
151+
if not self.token_file.exists():
152+
return None
153+
154+
try:
155+
with open(self.token_file) as f:
156+
return json.load(f)
157+
except (OSError, json.JSONDecodeError):
158+
return None
159+
160+
def is_token_valid(self, tokens: dict) -> bool:
161+
"""Check if stored token is still valid."""
162+
expires_at = tokens.get("expires_at", 0)
163+
# Add 60 second buffer for clock skew
164+
return time.time() < (expires_at - 60)
165+
166+
async def refresh_token(self, refresh_token: str) -> dict | None:
167+
"""Refresh access token using refresh token."""
168+
token_url = f"{self.authkit_domain}/oauth2/token"
169+
170+
data = {
171+
"client_id": self.client_id,
172+
"grant_type": "refresh_token",
173+
"refresh_token": refresh_token,
174+
}
175+
176+
try:
177+
async with httpx.AsyncClient() as client:
178+
response = await client.post(token_url, data=data)
179+
180+
if response.status_code == 200:
181+
return response.json()
182+
else:
183+
console.print(
184+
f"[red]Token refresh failed: {response.status_code} - {response.text}[/red]"
185+
)
186+
return None
187+
except Exception as e:
188+
console.print(f"[red]Token refresh error: {e}[/red]")
189+
return None
190+
191+
async def get_valid_token(self) -> str | None:
192+
"""Get valid access token, refresh if needed."""
193+
tokens = self.load_tokens()
194+
if not tokens:
195+
return None
196+
197+
if self.is_token_valid(tokens):
198+
return tokens["access_token"]
199+
200+
# Token expired - try to refresh if we have a refresh token
201+
refresh_token = tokens.get("refresh_token")
202+
if refresh_token:
203+
console.print("[yellow]Access token expired, refreshing...[/yellow]")
204+
205+
new_tokens = await self.refresh_token(refresh_token)
206+
if new_tokens:
207+
# Save new tokens (may include rotated refresh token)
208+
self.save_tokens(new_tokens)
209+
console.print("[green]✓ Token refreshed successfully[/green]")
210+
return new_tokens["access_token"]
211+
else:
212+
console.print("[yellow]Token refresh failed. Please run 'login' again.[/yellow]")
213+
return None
214+
else:
215+
console.print("[yellow]No refresh token available. Please run 'login' again.[/yellow]")
216+
return None
217+
218+
async def login(self) -> bool:
219+
"""Perform OAuth Device Authorization login flow."""
220+
console.print("[blue]Initiating WorkOS authentication...[/blue]")
221+
222+
# Step 1: Request device authorization
223+
device_response = await self.request_device_authorization()
224+
if not device_response:
225+
return False
226+
227+
# Step 2: Display user instructions
228+
self.display_user_instructions(device_response)
229+
230+
# Step 3: Poll for token
231+
device_code = device_response["device_code"]
232+
interval = device_response.get("interval", 5)
233+
234+
tokens = await self.poll_for_token(device_code, interval)
235+
if not tokens:
236+
return False
237+
238+
# Step 4: Save tokens
239+
self.save_tokens(tokens)
240+
241+
console.print("\n[green]✅ Successfully authenticated with WorkOS![/green]")
242+
return True
243+
244+
def logout(self) -> None:
245+
"""Remove stored authentication tokens."""
246+
if self.token_file.exists():
247+
self.token_file.unlink()
248+
console.print("[green]✓ Logged out successfully[/green]")
249+
else:
250+
console.print("[yellow]No stored authentication found[/yellow]")

0 commit comments

Comments
 (0)