Honor GitHub device-flow polling intervals
Update the OAuth settings poller to wait for the provider interval, carry slow_down interval updates forward, and respect provider expiration times so GitHub Copilot device-code auth can complete after browser authorization. Dispatch the legacy device-login endpoint by provider_id and add regressions plus DOX guidance for provider-aware device polling.
Alessandro committed
Jun 4, 2026 at 11:52 UTC
85e28d0799b3aa4c9573b22606dce7a44a4b0a3d
5 files changed
+127
-9
plugins/_oauth/AGENTS.md
+1
@@ -26,6 +26,7 @@
26
- Provider cards and model slot actions must be driven by backend provider status. Do not reintroduce hardcoded frontend provider lists or fallback provider catalogs.
27
- OAuth account surfaces in settings, discovery, and onboarding must use the provider registry/status summary rather than Codex-only frontend state.
28
- OAuth settings pending-auth controls such as device codes, manual callback input, and provider setup fields must render inline under the relevant provider row, not as a detached section below all providers.
29
+- OAuth device-code polling must honor provider `interval`, `expires_at`, and `slow_down` updates; do not poll immediately or keep a stale fixed interval after a provider asks the client to slow down.
30
- OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
31
- `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
32
- OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
plugins/_oauth/api/start_device_login.py
+25
-1
@@ -6,4 +6,28 @@ from plugins._oauth.helpers.providers import CODEX_PROVIDER_ID, get_provider
6
7
class StartDeviceLogin(ApiHandler):
8
async def process(self, input: dict, request: Request) -> dict:
9
- return get_provider(CODEX_PROVIDER_ID).start_login(input, request).to_dict()
9
+ raw_provider_id = _provider_id(input)
10
+ try:
11
+ return get_provider(raw_provider_id).start_login(input, request).to_dict()
12
+ except Exception as exc:
13
+ return {
14
+ "ok": False,
15
+ "provider_id": _provider_id_label(raw_provider_id),
16
+ "error": str(exc),
17
+ }
18
+
19
+
20
+def _provider_id(input: dict) -> object:
21
+ if "provider_id" not in input or input.get("provider_id") is None:
22
+ return CODEX_PROVIDER_ID
23
+ value = input.get("provider_id")
24
+ if isinstance(value, str) and not value.strip():
25
+ return CODEX_PROVIDER_ID
26
+ return value
27
+
28
+
29
+def _provider_id_label(value: object) -> str:
30
+ if value is None:
31
+ return CODEX_PROVIDER_ID
32
+ text = str(value).strip()
33
+ return text or CODEX_PROVIDER_ID
plugins/_oauth/webui/oauth-config-store.js
+35
-7
@@ -758,9 +758,23 @@ export const store = createStore("oauthConfig", {
758
startPolling(providerId = CODEX_PROVIDER) {
759
this.stopPolling(providerId);
760
this.pollStartedAt = { ...this.pollStartedAt, [providerId]: Date.now() };
761
+ const clearTimer = () => {
762
+ if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
763
+ const timers = { ...this.pollTimers };
764
+ delete timers[providerId];
765
+ this.pollTimers = timers;
766
+ if (providerId === CODEX_PROVIDER) this.pollTimer = null;
767
+ };
768
+ const schedule = (delayMs) => {
769
+ clearTimer();
770
+ this.pollTimers = { ...this.pollTimers, [providerId]: window.setTimeout(tick, delayMs) };
771
+ if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
772
+ };
773
const tick = async () => {
774
+ clearTimer();
775
const device = this.devices[providerId];
776
if (!device?.attempt_id) return;
777
+ let nextDelay = Math.max(1500, Number(device.interval || 5) * 1000);
778
try {
779
const response = await callJsonApi(POLL_LOGIN_API, {
780
provider_id: providerId,
@@ -782,6 +796,16 @@ export const store = createStore("oauthConfig", {
796
void toastFrontendSuccess(`${this.providerLabel(providerId)} connected.`, "OAuth Connections");
797
return;
798
}
799
+ if (response.interval || response.expires_at) {
800
+ const updatedDevice = {
801
+ ...device,
802
+ interval: response.interval || device.interval,
803
+ expires_at: response.expires_at || device.expires_at,
804
+ };
805
+ this.devices = { ...this.devices, [providerId]: updatedDevice };
806
+ if (providerId === CODEX_PROVIDER) this.device = updatedDevice;
807
+ nextDelay = Math.max(1500, Number(updatedDevice.interval || 5) * 1000);
808
+ }
809
} catch (error) {
810
if (this.connectingProvider === providerId) this.connectingProvider = "";
811
this.connecting = Boolean(this.connectingProvider);
@@ -789,17 +813,21 @@ export const store = createStore("oauthConfig", {
813
void toastFrontendError(messageOf(error), "OAuth Connections");
814
return;
815
}
792
- if (Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS) {
816
+ const expiresAt = Number(this.devices[providerId]?.expires_at || 0);
817
+ const timedOut = expiresAt > 0
818
+ ? Date.now() / 1000 > expiresAt
819
+ : Date.now() - Number(this.pollStartedAt[providerId] || 0) > MAX_POLL_MS;
820
+ if (timedOut) {
821
if (this.connectingProvider === providerId) this.connectingProvider = "";
822
this.connecting = Boolean(this.connectingProvider);
823
this.clearProviderDevice(providerId);
824
this.stopPolling(providerId);
825
+ return;
826
}
827
+ schedule(nextDelay);
828
};
799
- const delay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
800
- this.pollTimers = { ...this.pollTimers, [providerId]: window.setInterval(tick, delay) };
801
- if (providerId === CODEX_PROVIDER) this.pollTimer = this.pollTimers[providerId];
802
- void tick();
829
+ const initialDelay = Math.max(1500, Number(this.devices[providerId]?.interval || 5) * 1000);
830
+ schedule(initialDelay);
831
},
832
833
pollProvider(providerId = CODEX_PROVIDER) {
@@ -834,7 +862,7 @@ export const store = createStore("oauthConfig", {
862
863
stopPolling(providerId = "") {
864
if (providerId) {
837
- if (this.pollTimers[providerId]) window.clearInterval(this.pollTimers[providerId]);
865
+ if (this.pollTimers[providerId]) window.clearTimeout(this.pollTimers[providerId]);
866
const timers = { ...this.pollTimers };
867
delete timers[providerId];
868
this.pollTimers = timers;
@@ -846,7 +874,7 @@ export const store = createStore("oauthConfig", {
874
}
875
876
for (const timer of Object.values(this.pollTimers || {})) {
849
- if (timer) window.clearInterval(timer);
877
+ if (timer) window.clearTimeout(timer);
878
}
879
this.pollTimers = {};
880
this.pollStartedAt = {};
tests/test_oauth_providers.py
+51
-1
@@ -540,7 +540,7 @@ def test_manual_callback_dispatches_to_xai_provider_without_active_attempt():
540
assert "no active xai grok sign-in attempt" in response["error"].lower()
541
542
543
-def test_start_device_login_wrapper_calls_codex_provider(monkeypatch):
543
+def test_start_device_login_wrapper_defaults_to_codex_provider(monkeypatch):
544
calls = []
545
546
class FakeProvider:
@@ -576,6 +576,56 @@ def test_start_device_login_wrapper_calls_codex_provider(monkeypatch):
576
assert response["attempt_id"] == "attempt-1"
577
578
579
+def test_start_device_login_with_provider_id_calls_github_provider(monkeypatch):
580
+ calls = []
581
+
582
+ class FakeProvider:
583
+ def start_login(self, input, request):
584
+ calls.append((input, request))
585
+ return LoginStartResult(
586
+ ok=True,
587
+ provider_id=GITHUB_COPILOT_PROVIDER_ID,
588
+ flow="device_code",
589
+ attempt_id="github-attempt-1",
590
+ verification_url="https://github.com/login/device",
591
+ user_code="1234-5678",
592
+ )
593
+
594
+ monkeypatch.setattr(
595
+ start_device_login_api,
596
+ "get_provider",
597
+ lambda provider_id: calls.append(("provider_id", provider_id)) or FakeProvider(),
598
+ )
599
+
600
+ request = FakeRequest()
601
+ payload = {"provider_id": GITHUB_COPILOT_PROVIDER_ID, "enterprise_domain": ""}
602
+ response = asyncio.run(
603
+ start_device_login_api.StartDeviceLogin(None, None).process(payload, request)
604
+ )
605
+
606
+ assert calls[0] == ("provider_id", GITHUB_COPILOT_PROVIDER_ID)
607
+ assert calls[1] == (payload, request)
608
+ assert response["ok"] is True
609
+ assert response["provider_id"] == GITHUB_COPILOT_PROVIDER_ID
610
+ assert response["flow"] == "device_code"
611
+ assert response["attempt_id"] == "github-attempt-1"
612
+ assert response["verification_url"] == "https://github.com/login/device"
613
+ assert response["user_code"] == "1234-5678"
614
+
615
+
616
+def test_start_device_login_unknown_provider_returns_structured_error():
617
+ response = asyncio.run(
618
+ start_device_login_api.StartDeviceLogin(None, None).process(
619
+ {"provider_id": "missing"},
620
+ FakeRequest(),
621
+ )
622
+ )
623
+
624
+ assert response["ok"] is False
625
+ assert response["provider_id"] == "missing"
626
+ assert "Unknown OAuth provider" in response["error"]
627
+
628
+
629
def test_poll_device_login_wrapper_calls_codex_provider(monkeypatch):
630
calls = []
631
tests/test_oauth_static.py
+15
@@ -126,6 +126,21 @@ def test_browser_callback_completion_is_observed_from_modal():
126
assert "this.providerConnected(providerId)" in store_js
127
128
129
+def test_device_polling_honors_provider_interval_updates():
130
+ store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")
131
+
132
+ start_polling = store_js.split("startPolling(providerId = CODEX_PROVIDER)", 1)[1].split(
133
+ "pollProvider(providerId = CODEX_PROVIDER)",
134
+ 1,
135
+ )[0]
136
+ assert "window.setTimeout(tick, delayMs)" in start_polling
137
+ assert "window.setInterval(tick" not in start_polling
138
+ assert "void tick();" not in start_polling
139
+ assert "interval: response.interval || device.interval" in start_polling
140
+ assert "expires_at: response.expires_at || device.expires_at" in start_polling
141
+ assert "Date.now() / 1000 > expiresAt" in start_polling
142
+
143
+
144
def test_usage_plan_catalog_stays_backend_only_on_oauth_settings_page():
145
config_html = (PROJECT_ROOT / "plugins/_oauth/webui/config.html").read_text(encoding="utf-8")
146
store_js = (PROJECT_ROOT / "plugins/_oauth/webui/oauth-config-store.js").read_text(encoding="utf-8")