Skip to content

Commit e880091

Browse files
authored
Merge pull request #789 from amnweb/feature/oauth-device-flow
feat(auth): implement OAuth device flow for GitHub authentication
2 parents c00e200 + 833448d commit e880091

10 files changed

Lines changed: 565 additions & 48 deletions

File tree

docs/widgets/(Widget)-Copilot.md

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,32 +19,20 @@ The Copilot widget displays your GitHub Copilot premium request usage with a det
1919
## Requirements
2020

2121
- **GitHub Copilot Pro or Pro+** subscription
22-
- **GitHub Fine-grained Personal Access Token** with **Plan (read)** permission
2322

24-
## Getting Your GitHub Token
23+
## Authentication
2524

26-
### Step 1: Create a Fine-grained Personal Access Token
25+
The Copilot widget supports three ways to authenticate:
2726

28-
1. Go to [GitHub Fine-grained Token Settings](https://github.com/settings/personal-access-tokens/new)
29-
2. Fill in the token details below
27+
1. **OAuth sign-in (recommended)** - Leave the `token` option empty. When you click the widget it will open a sign-in dialog where you authorize with GitHub through your browser. The token is saved automatically and persists across restarts.
3028

31-
### Step 2: Configure the Token
29+
2. **Fine-grained Personal Access Token** - Create one at [GitHub Token Settings](https://github.com/settings/personal-access-tokens/new) with the **Plan (read)** account permission and paste it into the `token` option.
3230

33-
1. **Token name**: Enter a name (e.g., `YASB Copilot Widget`)
34-
2. **Expiration**: Choose your preference (e.g., 90 days, 1 year, or no expiration)
35-
3. **Repository access**: Select **"Public Repositories (read-only)"** or **"No access"**
31+
3. **Environment variable** - Set `token: env` and define the `YASB_COPILOT_TOKEN` environment variable with your token.
3632

37-
### Step 3: Set Account Permissions
33+
### Token storage
3834

39-
1. Expand the **"Account permissions"** section
40-
2. Find **"Plan"** in the list
41-
3. Set it to **"Read-only"**
42-
43-
### Step 4: Generate and Copy
44-
45-
1. Click **"Generate token"**
46-
2. **Copy the token** (starts with `github_pat_...`)
47-
3. Save it securely - you won't be able to see it again!
35+
OAuth tokens are stored in `%LOCALAPPDATA%\YASB\github_copilot_token`. To sign out, simply delete this file and restart YASB.
4836

4937
## Configuration
5038

@@ -86,7 +74,7 @@ copilot:
8674
|--------|------|---------|-------------|
8775
| `label` | string | `"{icon}"` | Label format for the bar. Supports `{icon}`, `{used}`, `{allowance}`, `{percentage}`, `{total_cost}` |
8876
| `label_alt` | string | `"{used}/{allowance}"` | Alternative label (toggle with right-click) |
89-
| `token` | string | `""` | GitHub fine-grained PAT (or `"env"` to use `YASB_COPILOT_TOKEN` env var) |
77+
| `token` | string | `""` | GitHub token. Leave empty to use OAuth sign-in, or set to `"env"` to read from `YASB_COPILOT_TOKEN` env var |
9078
| `plan` | string | `"pro"` | Your Copilot plan: `"pro"` (300 requests) or `"pro_plus"` (1500 requests) |
9179
| `tooltip` | boolean | `true` | Show tooltip on hover |
9280
| `update_interval` | integer | `3600` | Refresh interval in seconds (min: 300, max: 86400) |

docs/widgets/(Widget)-Github.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
| `label_alt` | string | `'{data} Notifications'` | The alternative format string for the label. Useful for displaying additional notification details. |
77
| `tooltip` | boolean | `true` | Whether to show the tooltip on hover. |
88
| `update_interval` | integer | `600` | The interval in seconds to update the notifications. Must be between 60 and 3600. |
9-
| `token` | string | `""` | The GitHub personal access token. |
9+
| `token` | string | `""` | GitHub personal access token. Leave empty to use OAuth sign-in, or set to `"env"` to read from `YASB_GITHUB_TOKEN`. |
1010
| `max_notification` | integer | `30` | The maximum number of notifications to display in the menu. |
1111
| `notification_dot` | dict | `{'enabled': true, 'corner': 'bottom_left', 'color': 'red', 'margin': [1, 1]}` | A dictionary specifying the notification dot settings for the widget. |
1212
| `only_unread` | boolean | `false` | Whether to show only unread notifications. |
@@ -72,7 +72,7 @@ github:
7272
- **comment:** The icon that prefixes the comment count badge when `show_comment_count` is enabled.
7373
- **tooltip:** Whether to show the tooltip on hover.
7474
- **update_interval:** The interval in seconds to update the notifications. Must be between 60 and 3600.
75-
- **token:** The GitHub personal access token. GitHub Personal access tokens (classic) https://github.com/settings/tokens you can set `token: env`, this means you have to set YASB_GITHUB_TOKEN in environment variable.
75+
- **token:** The GitHub personal access token. You can set `token: env` to read from the `YASB_GITHUB_TOKEN` environment variable, paste a [Personal Access Token (classic)](https://github.com/settings/tokens) directly, or leave it empty to sign in via OAuth when you click the widget.
7676
- **max_notification:** The maximum number of notifications to display in the menu, max 50.
7777
- **notification_dot:** A dictionary specifying the notification dot settings for the widget. This will show a dot on the icon (enclosed with the <span> tag).
7878
- **enabled:** Enable notification dot.
@@ -101,6 +101,20 @@ github:
101101
- **container_shadow:** Container shadow options.
102102
- **label_shadow:** Label shadow options.
103103

104+
## Authentication
105+
106+
The GitHub widget supports three ways to authenticate:
107+
108+
1. **OAuth sign-in (recommended)** - Leave the `token` option empty. When you click the widget it will open a sign-in dialog where you authorize with GitHub through your browser. The token is saved automatically and persists across restarts.
109+
110+
2. **Personal Access Token** - Generate a [Personal Access Token (classic)](https://github.com/settings/tokens) with the `notifications` scope and paste it into the `token` option.
111+
112+
3. **Environment variable** - Set `token: env` and define the `YASB_GITHUB_TOKEN` environment variable with your token.
113+
114+
### Token storage
115+
116+
OAuth tokens are stored in `%LOCALAPPDATA%\YASB\github_token`. To sign out, simply delete this file and restart YASB.
117+
104118
## Widget Style
105119
```css
106120
.github-widget {}

docs/widgets/(Widget)-Quick-Launch.md

Lines changed: 15 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -187,7 +187,7 @@ Click a result to copy the converted value to the clipboard.
187187
188188
### Developer Tools Provider
189189

190-
A collection of common developer utilities accessible from Quick Launch. All operations are local no network requests, no API keys, instant results.
190+
A collection of common developer utilities accessible from Quick Launch. All operations are local - no network requests, no API keys, instant results.
191191

192192
| Option | Type | Default | Description |
193193
| ---------- | ------ | ------- | -------------------------------------------- |
@@ -278,15 +278,16 @@ Browse GitHub notifications directly from Quick Launch. Type `gh` to see your no
278278
- Type `gh review` to filter notifications by title, repo, type, or reason.
279279
- Click a notification to open it in the browser and mark as read.
280280
- Right-click a notification for:
281-
- **Copy URL** copy the notification URL to clipboard.
282-
- **Mark as read** mark a single notification as read.
283-
- **Mark all as read** mark every notification as read.
281+
- **Copy URL** - copy the notification URL to clipboard.
282+
- **Mark as read** - mark a single notification as read.
283+
- **Mark all as read** - mark every notification as read.
284284

285285
> [!NOTE]
286286
> The provider fetches fresh notifications every time the popup opens (no stale cache). If the same GitHub token is used for both the bar widget and this provider, the bar widget automatically refreshes when the provider fetches new data or marks notifications as read.
287287
288-
> [!NOTE]
289-
> You need a GitHub Personal Access Token (classic) with the `notifications` scope. Generate one at https://github.com/settings/tokens. You can set the token via the `YASB_GITHUB_TOKEN` environment variable (recommended) or directly in the config.
288+
**Authentication:**
289+
290+
You can set a [Personal Access Token (classic)](https://github.com/settings/tokens) with the `notifications` scope in the `token` option, use `"env"` to read from the `YASB_GITHUB_TOKEN` environment variable, or leave it empty. When no token is configured, clicking the `gh` prefix result will open a sign-in dialog where you authorize with GitHub through your browser. The OAuth token is stored in `%LOCALAPPDATA%\YASB\github_token`.
290291

291292
### Hacker News Provider
292293

@@ -317,11 +318,11 @@ Clicking a story opens it in your default browser. Right-click a story to open t
317318

318319
**Usage examples:**
319320

320-
- `hn` Show all topic tiles
321-
- `hn frontpage` Load front page stories
322-
- `hn newest rust` Search newest stories for "rust"
323-
- `hn python` Search all of HN for "python"
324-
- `hn ask` Browse Ask HN posts
321+
- `hn` - Show all topic tiles
322+
- `hn frontpage` - Load front page stories
323+
- `hn newest rust` - Search newest stories for "rust"
324+
- `hn python` - Search all of HN for "python"
325+
- `hn ask` - Browse Ask HN posts
325326

326327
Each story result displays:
327328

@@ -330,8 +331,8 @@ Each story result displays:
330331

331332
**Context menu actions:**
332333

333-
- **Open HN comments** Opens the Hacker News discussion page
334-
- **Copy URL** Copies the story URL to clipboard
334+
- **Open HN comments** - Opens the Hacker News discussion page
335+
- **Copy URL** - Copies the story URL to clipboard
335336

336337
> [!NOTE]
337338
> Hacker News provider uses [hnrss.org](https://hnrss.org) RSS feeds. Results are cached in memory and on disk to minimize network requests. No API key is required.
@@ -461,7 +462,7 @@ Browse and launch SSH connections from your `~/.ssh/config` file. Type `ssh` to
461462
- Select **Edit connection** to open an inline form in the preview panel where you can change the host alias, hostname/IP, user, port, and identity file. The changes are written back to your SSH config file immediately on save.
462463

463464
> [!NOTE]
464-
> Hosts with a wildcard name (e.g. `Host *`) are automatically skipped. The provider re-reads the config file each time the popup is opened, so newly added hosts are picked up immediately. Editing a host rewrites only that `Host` block all other entries, comments, and blank lines are preserved.
465+
> Hosts with a wildcard name (e.g. `Host *`) are automatically skipped. The provider re-reads the config file each time the popup is opened, so newly added hosts are picked up immediately. Editing a host rewrites only that `Host` block - all other entries, comments, and blank lines are preserved.
465466
466467
### System Commands Provider
467468

src/core/utils/widgets/copilot/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,13 @@ def get_data(cls) -> CopilotUsageData:
102102
"""Get the current cached data."""
103103
return cls._data
104104

105+
@classmethod
106+
def set_token(cls, token: str) -> None:
107+
"""Update the token after OAuth and trigger an immediate fetch."""
108+
cls._token = token
109+
cls._username = "" # Reset cached username so it is re-fetched with new token
110+
cls.get_instance()._start_update()
111+
105112
@classmethod
106113
def refresh(cls) -> None:
107114
"""Manually trigger a data refresh."""

src/core/utils/widgets/github/api.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ def refresh(cls) -> None:
8383
"""Trigger an immediate data refresh (same as a timer tick)."""
8484
cls._on_timer()
8585

86+
@classmethod
87+
def set_token(cls, token: str) -> None:
88+
"""Update the token after OAuth and trigger an immediate fetch."""
89+
cls._token = token
90+
cls._on_timer()
91+
8692
@classmethod
8793
def register_callback(cls, callback: Callable) -> None:
8894
"""Register a callback to be called when data is updated."""
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import json
2+
import logging
3+
import time
4+
import urllib.error
5+
import urllib.parse
6+
import urllib.request
7+
from typing import Callable
8+
9+
from core.utils.utilities import app_data_path
10+
11+
_OAUTH_APPS = {
12+
"notifications": {"client_id": "Ov23li0KAXuxNzbEl9Jy", "scope": "notifications"},
13+
"copilot": {"client_id": "Iv23lixvCWMEuoUakkey", "scope": ""},
14+
}
15+
16+
_TOKEN_FILES = {
17+
"notifications": app_data_path("github_token"),
18+
"copilot": app_data_path("github_copilot_token"),
19+
}
20+
21+
22+
def get_saved_token(name: str = "notifications") -> str:
23+
"""Read a saved OAuth token from disk. Returns empty string if not found."""
24+
try:
25+
path = _TOKEN_FILES[name]
26+
if path.exists():
27+
return path.read_text(encoding="utf-8").strip()
28+
except Exception as e:
29+
logging.error(f"GitHubAuth: Failed to read saved token: {e}")
30+
return ""
31+
32+
33+
def save_token(token: str, name: str = "notifications") -> None:
34+
"""Persist an OAuth token to disk."""
35+
try:
36+
_TOKEN_FILES[name].write_text(token, encoding="utf-8")
37+
logging.info("GitHubAuth token saved successfully.")
38+
except Exception as e:
39+
logging.error(f"GitHubAuth failed to save token: {e}")
40+
41+
42+
def request_device_code(name: str = "notifications") -> dict:
43+
"""
44+
Request a device code from GitHub OAuth Device Flow.
45+
Returns dict with: device_code, user_code, verification_uri, expires_in, interval.
46+
Raises on network/API error.
47+
"""
48+
app = _OAUTH_APPS[name]
49+
params = {"client_id": app["client_id"]}
50+
if app["scope"]:
51+
params["scope"] = app["scope"]
52+
data = urllib.parse.urlencode(params).encode("utf-8")
53+
req = urllib.request.Request(
54+
"https://github.com/login/device/code",
55+
data=data,
56+
headers={"Accept": "application/json"},
57+
method="POST",
58+
)
59+
try:
60+
with urllib.request.urlopen(req, timeout=10) as resp:
61+
raw = resp.read().decode("utf-8")
62+
return json.loads(raw)
63+
except urllib.error.HTTPError as e:
64+
body = e.read().decode("utf-8", errors="replace")
65+
logging.error(f"GitHubAuth HTTP {e.code}: {body}")
66+
raise RuntimeError(f"GitHub returned HTTP {e.code}.\nDetails: {body}") from e
67+
except urllib.error.URLError as e:
68+
logging.error(f"GitHubAuth network error: {e}")
69+
raise RuntimeError("Network error.\nCheck your internet connection.") from e
70+
71+
72+
def poll_for_token(
73+
device_code: str,
74+
interval: int,
75+
on_success: Callable[[str], None],
76+
on_error: Callable[[str], None],
77+
stop_flag: Callable[[], bool],
78+
save_fn: Callable[[str], None] | None = None,
79+
name: str = "notifications",
80+
) -> None:
81+
"""
82+
Poll GitHub for an access token after the user has authorized the device.
83+
Runs in a background thread.
84+
85+
- on_success(token) is called when the user approves.
86+
- on_error(message) is called on any unrecoverable error.
87+
- stop_flag() should return True to cancel polling early.
88+
"""
89+
poll_interval = max(interval, 5)
90+
cid = _OAUTH_APPS[name]["client_id"]
91+
post_data_base = {
92+
"client_id": cid,
93+
"device_code": device_code,
94+
"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
95+
}
96+
97+
while not stop_flag():
98+
time.sleep(poll_interval)
99+
if stop_flag():
100+
return
101+
try:
102+
encoded = urllib.parse.urlencode(post_data_base).encode("utf-8")
103+
req = urllib.request.Request(
104+
"https://github.com/login/oauth/access_token",
105+
data=encoded,
106+
headers={"Accept": "application/json"},
107+
method="POST",
108+
)
109+
with urllib.request.urlopen(req, timeout=15) as resp:
110+
result = json.loads(resp.read().decode("utf-8"))
111+
112+
error = result.get("error")
113+
if error == "authorization_pending":
114+
continue
115+
elif error == "slow_down":
116+
poll_interval += 5
117+
continue
118+
elif error == "expired_token":
119+
on_error("Device code expired.\nPlease try again.")
120+
return
121+
elif error == "access_denied":
122+
on_error("Authorization was denied.")
123+
return
124+
elif error:
125+
on_error(f"Authorization failed: {error}")
126+
return
127+
128+
token = result.get("access_token", "")
129+
if token:
130+
_save = save_fn if save_fn is not None else save_token
131+
_save(token)
132+
on_success(token)
133+
return
134+
135+
except urllib.error.URLError:
136+
on_error("Network error.\nCheck your internet connection.")
137+
return
138+
except Exception as e:
139+
logging.error(f"GitHubAuth polling error: {e}")
140+
on_error(f"Unexpected error: {e}")
141+
return

0 commit comments

Comments
 (0)