Repository navigation
feat(llm-gateway): sign in with the OAuth device authorization grant - #145
Conversation
|
@peterj can you review this |
|
@copilot resolve the merge conflicts in this pull request |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Locking, deadline enforcement, response validation, and controller-delivered policy handling have unresolved correctness and reliability issues.
Review effort: Balanced
Findings: 5
Open (5)
Startup ignores managed device authorization policy · New Login lock held during unbounded IdP requests · New Device response fields and timing values are not validated · New Device enrollment polling can outlive expiration deadline · New Pending enrollment cap checked after IdP grant creation · New
What changed in this PR
Adds OAuth device authorization for headless controller enrollment and LLM gateway sign-in.
Changes:
- Implements device-grant initiation, polling, and token persistence.
- Adds gateway login API, status models, and CLI commands.
- Extends configuration, validation, protocol, schemas, and tests.
| File | Description |
|---|---|
schema/daemon-config.md |
Documents device authorization settings. |
schema/daemon-config.json |
Adds generated schema fields. |
crates/proto/proto/fleet.proto |
Extends enrollment protocol. |
crates/core/src/model.rs |
Adds login and enrollment status fields. |
crates/core/src/config.rs |
Adds configuration and validation. |
crates/controller/src/service.rs |
Routes device enrollment requests. |
crates/controller/src/oidc.rs |
Starts controller device grants. |
crates/client/src/lib.rs |
Adds bodyless POST support. |
crates/agentdesktop/src/main.rs |
Updates enrollment tests. |
crates/agent/src/oidc.rs |
Implements shared device polling. |
crates/agent/src/gateway_oidc.rs |
Implements gateway device login. |
crates/agent/src/enrollment.rs |
Publishes device enrollment status. |
crates/agent/src/daemon.rs |
Starts configured gateway login. |
crates/agent/src/cli.rs |
Adds enrollment and login commands. |
crates/agent/src/api.rs |
Adds login endpoint and 401 mapping. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
d4f4a44 to
cb43e06
Compare
|
Rebased onto current
#144 is rebased the same way, so this branch still stacks cleanly on it. |
cb43e06 to
a7154df
Compare
|
@devmittal02 can you resolve the conflicts? |
Headless daemons have no browser to complete the loopback redirect used for LLM gateway OIDC sign-in. Add an opt-in `deviceAuthorization` flag to `llmGateway.authentication` (type `oidc`). When set, the daemon signs in with RFC 8628: it logs a verification URL and user code, polls the token endpoint in the background, and stores the tokens once the user approves from any device. - `agentdesktop-headless login` / `POST /v1/llm-gateway/login` start or resume a sign-in and print the URL and code. - The credential endpoint never blocks in device mode: it returns 401 with the login command and any pending code when no valid or refreshable token exists. Refresh works as before. - Subscription auth requires a browser and is rejected in combination. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
- Start the device sign-in from managed policy. The startup path only sees the local config file, so a `deviceAuthorization: true` delivered by the controller never started a grant and credentials returned a 401 with no pending URL until someone ran `login`. The cached controller configuration at startup and every controller update now call `start_device_login_if_configured`, which is idempotent because `device_login` returns the pending grant. - Stop holding the global login lock across the identity provider round-trip. A per-account lock serialises starting a grant instead, so concurrent `login` calls still share one grant, and the provider request is bounded by a timeout so a stalled provider cannot wedge every `login` behind it. - Validate the device-authorization response (codes, verification URLs, nonzero lifetime) and cap the lifetime and poll interval, so a malformed response cannot publish an unusable status or overflow `Instant + Duration` in the poll task. - Never sleep past the expiry deadline while polling. Co-Authored-By: Claude Code <noreply@anthropic.com>
a7154df to
e1e107e
Compare
|
Addressed all five review findings (in a separate commit so the changes are easy to review) and rebased onto
Each new behaviour has a regression test, and I confirmed each one fails against the pre-fix code and passes against the fix. Re-ran the checklist from the description: |
|
@peterj done |

Summary
Refs #52. Builds on #144. This branch is stacked on #144's commit, so the diff includes it until #144 merges. Only the last commit is new.
#144 lets a headless host enroll with the controller using the device authorization grant. The LLM gateway sign-in has the same problem:
llmGateway.authenticationtypeoidconly supports the authorization-code redirect to127.0.0.1:51327, so a Linux server or cloud VM reached over SSH can't get a gateway credential.This PR adds an opt-in
deviceAuthorizationsetting to the OIDC gateway authentication. When it is set, the daemon signs in with the OAuth 2.0 Device Authorization Grant (RFC 8628) and never opens a browser.Flow
When the daemon starts, it finds the IdP's
device_authorization_endpointthrough OIDC discovery and starts the grant. It logs the verification URL and user code.The user can show the pending sign-in at any time, or start a new one:
logincalls the newPOST /v1/llm-gateway/login. If a sign-in is already pending it returns the same code, and once signed in it returnssignedIn.The daemon polls the token endpoint in the background with the polling loop from feat(enrollment): support the OAuth device authorization grant #144 (
authorization_pending,slow_down, deadline). When the user approves from any device, the daemon stores the tokens in the same secret store and format as the browser flow.GET /v1/llm-gateway/credentialnever blocks in device mode:401with an actionable message instead of starting a browser flow:LLM gateway sign-in required: run agentdesktop-headless login, or approve the pending sign-in at <url> with code <code>. Credential helpers (apiKeyHelperand similar) surface this message to the user.The global login lock is held only while the token store is read or written, not while polling. So the credential and login endpoints stay responsive while a sign-in is pending.
Changes
LlmGatewayAuthentication::Oidc.device_authorization(deviceAuthorization, defaultfalse).LlmGatewayLoginStatusmodel.programs.*.auth: subscriptioncombined withdeviceAuthorization, because subscription sign-in needs a browser.gateway_oidc.rs:device_login, plus a background completion task and a pending sign-in map keyed by token account.SignInRequirederror.device_authorization_endpointwhen present.daemon.rs: device mode at startup.api.rs:POST /v1/llm-gateway/login.SignInRequiredmaps to401. Other errors stay502.oidc.rs: sharespoll_device_tokenfrom feat(enrollment): support the OAuth device authorization grant #144. The error messages now say "device authorization" instead of "device enrollment", because the loop serves both flows.posthelper alongsideget.agentdesktop-headless login.schema/daemon-config.{json,md}is regenerated.IdP requirement
The IdP must allow the device authorization grant for the gateway's OIDC client. The flow is opt-in, so nothing changes for existing deployments.
Testing
cargo fmt --all --checkcargo clippy --workspace --all-targets -- -D warningscargo test --workspace. New unit tests cover:verification_uri_completepreference and fallbackSignInRequiredmessagecargo xtask schema(no diff)agentdesktop-headless daemon --userwithdeviceAuthorization: true, against a production Okta org:logintwice returned the same pending code.credentialfailed immediately with the401sign-in-required message that included the pending URL and code.loginthen returnedsignedIn, andcredentialreturned the access token.loginreportedsignedInandcredentialreturned the stored token with no new sign-in.cargo test -p agentdesktop-agent --test integrationon the same Linux host: 6 passed, 0 failed@peterj could you take a look when you get a chance?
🤖 Generated with Claude Code