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")