-
Notifications
You must be signed in to change notification settings - Fork 23
fix: secure login flow with copy-paste API key exchange #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
26d4945
ca9c413
c4595c3
1ca7103
8536d66
cdfad18
b38fe99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,4 @@ | ||
| import asyncio | ||
| import datetime as dt | ||
| from typing import Optional | ||
|
|
||
| import typer | ||
| from rich.console import Console | ||
|
|
@@ -15,20 +13,8 @@ | |
|
|
||
| console = Console() | ||
|
|
||
| POLL_INTERVAL_SECONDS = 2.0 | ||
| DEFAULT_TIMEOUT_SECONDS = 600.0 | ||
|
|
||
|
|
||
| def _parse_expires_at(value: Optional[str]) -> Optional[dt.datetime]: | ||
| if not value: | ||
| return None | ||
| try: | ||
| return dt.datetime.fromisoformat(value.replace("Z", "+00:00")) | ||
| except ValueError: | ||
| return None | ||
|
|
||
|
|
||
| async def _login(open_browser: bool, timeout_seconds: float) -> None: | ||
| async def _login(open_browser: bool) -> None: | ||
| async with RunpodGraphQLClient(require_api_key=False) as client: | ||
| request = await client.create_flash_auth_request() | ||
| request_id = request.get("id") | ||
|
|
@@ -45,46 +31,23 @@ async def _login(open_browser: bool, timeout_seconds: float) -> None: | |
| if open_browser: | ||
| typer.launch(auth_url) | ||
|
|
||
| expires_at = _parse_expires_at(request.get("expiresAt")) | ||
| deadline = dt.datetime.now(dt.timezone.utc) + dt.timedelta( | ||
| seconds=timeout_seconds | ||
| ) | ||
| if expires_at and expires_at < deadline: | ||
| deadline = expires_at | ||
|
|
||
| with console.status("[dim]Waiting for authorization...[/dim]"): | ||
| while True: | ||
| status_payload = await client.get_flash_auth_request_status(request_id) | ||
| status = status_payload.get("status") | ||
| api_key = status_payload.get("apiKey") | ||
|
|
||
| if api_key and status in {"APPROVED", "CONSUMED"}: | ||
| check_and_migrate_legacy_credentials() | ||
| path = save_api_key(api_key) | ||
| console.print( | ||
| f"[green]Logged in.[/green] Credentials saved to [dim]{path}[/dim]" | ||
| ) | ||
| console.print() | ||
| return | ||
|
|
||
| if status in {"DENIED", "EXPIRED", "CONSUMED"}: | ||
| raise RuntimeError(f"login failed: {status.lower()}") | ||
| api_key = console.input("Paste the API key shown after authorization: ").strip() | ||
|
KAJdev marked this conversation as resolved.
KAJdev marked this conversation as resolved.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
api_key = console.input("Paste the API key shown after authorization: ", password=True).strip()(or |
||
|
|
||
| if dt.datetime.now(dt.timezone.utc) >= deadline: | ||
| raise RuntimeError("login timed out") | ||
| if not api_key: | ||
| raise RuntimeError("no api key provided") | ||
|
KAJdev marked this conversation as resolved.
|
||
|
|
||
| await asyncio.sleep(POLL_INTERVAL_SECONDS) | ||
| check_and_migrate_legacy_credentials() | ||
| path = save_api_key(api_key) | ||
| console.print(f"[green]Logged in.[/green] Credentials saved to [dim]{path}[/dim]") | ||
|
Comment on lines
+36
to
+41
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Any non-empty string is written to the credentials file and the user sees "Logged in." — a typo or stray paste becomes a deferred silent failure that only surfaces as a 401 on the next command, far from the cause. At minimum check the format (e.g. |
||
| console.print() | ||
|
|
||
|
|
||
| def login_command( | ||
| no_open: bool = typer.Option(False, "--no-open", help="do not open the browser"), | ||
| timeout: float = typer.Option( | ||
| DEFAULT_TIMEOUT_SECONDS, "--timeout", help="max wait time in seconds" | ||
| ), | ||
| ): | ||
| """Authenticate and save a Runpod API key for flash.""" | ||
| try: | ||
| asyncio.run(_login(open_browser=not no_open, timeout_seconds=timeout)) | ||
| asyncio.run(_login(open_browser=not no_open)) | ||
| except RuntimeError as exc: | ||
| print_error(console, str(exc)) | ||
| raise typer.Exit(code=1) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| import json | ||
| import logging | ||
| import re | ||
| from dataclasses import dataclass | ||
|
|
@@ -34,6 +35,67 @@ class QBRequestLogBatch: | |
| ready_worker_ids: List[str] = field(default_factory=list) | ||
|
|
||
|
|
||
| @dataclass | ||
| class SSEEvent: | ||
| id: str | ||
| data: dict[str, Any] | ||
|
|
||
|
|
||
| @dataclass | ||
| class LogEvent: | ||
| source: str | ||
| line: str | ||
| ts: str | ||
|
|
||
|
|
||
| def parse_sse_event(data: str) -> Optional[SSEEvent]: | ||
| """ | ||
| Parses an SSE line into a dictionary | ||
| """ | ||
| if not data: | ||
| return None | ||
|
|
||
| try: | ||
| event_id_line, data_line = filter(bool, data.split("\n")) | ||
| event_id = event_id_line.split(":", 1)[1].strip() | ||
| data_json = data_line.split(":", 1)[1].strip() | ||
| data = json.loads(data_json) | ||
| return SSEEvent(id=event_id, data=data) | ||
| except Exception as e: | ||
| log.error("Failed to parse SSE event: %s", e) | ||
| return None | ||
|
KAJdev marked this conversation as resolved.
Comment on lines
+51
to
+66
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Two problems in this function:
Comment on lines
+64
to
+66
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
except (ValueError, IndexError, json.JSONDecodeError) as e:
log.error("Failed to parse SSE event %r: %s", data, e)
return None |
||
|
|
||
|
|
||
| def parse_log_event(data: dict[str, Any]) -> Optional[LogEvent]: | ||
| """ | ||
| Parses a log event from a dictionary | ||
| """ | ||
| try: | ||
| return LogEvent(source=data["source"], line=data["line"], ts=data["ts"]) | ||
| except Exception as e: | ||
| log.error("Failed to parse log event: %s", e) | ||
| return None | ||
|
|
||
|
|
||
| async def stream_pod_logs(pod_id: str, tail: int = 0): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| """ | ||
| Streams logs from pod using SSE | ||
| """ | ||
|
KAJdev marked this conversation as resolved.
|
||
| if tail < 0: | ||
| raise ValueError("tail must be greater than 0") | ||
|
KAJdev marked this conversation as resolved.
Comment on lines
+84
to
+85
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The guard rejects |
||
|
|
||
| url = f"{RUNPOD_HAPI_URL}/v1/pod/{pod_id}/logs?stream=true&tail={tail}" | ||
|
|
||
| async with get_authenticated_httpx_client() as client: | ||
| async with client.get(url) as response: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
async with client.stream("GET", url) as response:
response.raise_for_status()
async for line in response.aiter_lines():
...Without |
||
| async for line in response.aiter_lines(): | ||
| event = parse_sse_event(line) | ||
| if event: | ||
| log_event = parse_log_event(event.data) | ||
| if log_event: | ||
| yield log_event | ||
|
KAJdev marked this conversation as resolved.
|
||
|
|
||
|
KAJdev marked this conversation as resolved.
|
||
|
|
||
| class QBRequestLogFetcher: | ||
| def __init__( | ||
| self, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.