Skip to content

feat(llm-gateway): sign in with the OAuth device authorization grant - #145

Merged
peterj merged 2 commits into
agentdesktop-dev:mainfrom
devmittal02:feat/gateway-device-authorization
Oct 6, 2026
Merged

peterj merged 2 commits into
agentdesktop-dev:mainfrom
devmittal02:feat/gateway-device-authorization

Conversation

@devmittal02

Copy link
Copy Markdown
Contributor

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.authentication type oidc only supports the authorization-code redirect to 127.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 deviceAuthorization setting 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.

llmGateway:
  url: https://gateway.example.com/
  authentication:
    type: oidc
    issuer: https://idp.example.com
    clientId: agentdesktop
    scopes: [openid, offline_access]
    deviceAuthorization: true

Flow

  1. When the daemon starts, it finds the IdP's device_authorization_endpoint through OIDC discovery and starts the grant. It logs the verification URL and user code.

  2. The user can show the pending sign-in at any time, or start a new one:

    $ agentdesktop-headless login
    awaitingAuthentication
    Sign in at: https://idp.example.com/activate?user_code=ABCD-EFGH
    Code: ABCD-EFGH
    

    login calls the new POST /v1/llm-gateway/login. If a sign-in is already pending it returns the same code, and once signed in it returns signedIn.

  3. 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.

  4. GET /v1/llm-gateway/credential never blocks in device mode:

    • It returns the stored token, refreshing it with the refresh token when needed, exactly as today.
    • If no valid or refreshable token exists, it returns 401 with 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 (apiKeyHelper and 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

  • Core:
    • LlmGatewayAuthentication::Oidc.device_authorization (deviceAuthorization, default false).
    • New LlmGatewayLoginStatus model.
    • Config validation rejects programs.*.auth: subscription combined with deviceAuthorization, because subscription sign-in needs a browser.
  • Agent:
    • gateway_oidc.rs:
      • device_login, plus a background completion task and a pending sign-in map keyed by token account.
      • Typed SignInRequired error.
      • Discovery validates device_authorization_endpoint when present.
    • daemon.rs: device mode at startup.
    • api.rs:
      • POST /v1/llm-gateway/login.
      • SignInRequired maps to 401. Other errors stay 502.
    • oidc.rs: shares poll_device_token from 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.
  • Client: post helper alongside get.
  • CLI: 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 --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace. New unit tests cover:
    • the device authorization request form
    • verification_uri_complete preference and fallback
    • the default poll interval
    • the SignInRequired message
    • opt-in config parsing and validation
  • cargo xtask schema (no diff)
  • End to end on Ubuntu 24.04 x86_64, using a release build of agentdesktop-headless daemon --user with deviceAuthorization: true, against a production Okta org:
    • On startup the daemon logged the verification URL and code and served the local API immediately.
    • Running login twice returned the same pending code.
    • Before approval, credential failed immediately with the 401 sign-in-required message that included the pending URL and code.
    • I approved the code from a browser on a different machine. The daemon logged that sign-in completed. login then returned signedIn, and credential returned the access token.
    • After restarting the daemon, login reported signedIn and credential returned the stored token with no new sign-in.
  • cargo test -p agentdesktop-agent --test integration on the same Linux host: 6 passed, 0 failed

@peterj could you take a look when you get a chance?

🤖 Generated with Claude Code

@devmittal02

Copy link
Copy Markdown
Contributor Author

@peterj can you review this

@peterj

peterj commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

@copilot resolve the merge conflicts in this pull request

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Medium severity

Open (5)
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.

Comment thread crates/agent/src/daemon.rs
Comment thread crates/agent/src/gateway_oidc.rs Outdated
Comment thread crates/agent/src/gateway_oidc.rs Outdated
Comment thread crates/agent/src/oidc.rs Outdated
Comment thread crates/controller/src/oidc.rs
@devmittal02
devmittal02 force-pushed the feat/gateway-device-authorization branch from d4f4a44 to cb43e06 Compare October 6, 2026 18:03
@devmittal02

Copy link
Copy Markdown
Contributor Author

Rebased onto current main and resolved the conflicts (both were two independent fields added to the same LoginOptions struct — kept both). Re-ran the full test/lint checklist from the PR description after rebasing:

  • cargo fmt --all --check
  • cargo clippy --workspace --all-targets -- -D warnings
  • cargo test --workspace (device-authorization unit tests pass; the pre-existing --test integration failures are environment-only — they fail identically on main without this branch, needing a Linux-built daemon binary)
  • cargo xtask schema (no diff)

#144 is rebased the same way, so this branch still stacks cleanly on it.

@devmittal02
devmittal02 force-pushed the feat/gateway-device-authorization branch from cb43e06 to a7154df Compare October 6, 2026 19:00
@peterj

peterj commented Oct 6, 2026

Copy link
Copy Markdown
Collaborator

@devmittal02 can you resolve the conflicts?

devmittal02 and others added 2 commits October 6, 2026 12:13
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>
@devmittal02
devmittal02 force-pushed the feat/gateway-device-authorization branch from a7154df to e1e107e Compare October 6, 2026 19:14
@devmittal02

Copy link
Copy Markdown
Contributor Author

Addressed all five review findings (in a separate commit so the changes are easy to review) and rebased onto main now that #144 has merged, so this PR is down to its own two commits with no conflicts.

  • Managed policy never started a sign-in — the startup path only saw the local config file. 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.
  • Global login lock held across the provider round-trip — replaced with a per-account start lock so concurrent login calls still share a single grant (just dropping the lock would have started several), and the provider request is bounded by a timeout so a stalled provider can't wedge every login behind it.
  • Device response not validated — codes, verification URLs and a nonzero lifetime are now checked, matching the controller's device_enrollment_response, and the lifetime and poll interval are capped so a huge expires_in can't overflow Instant + Duration and panic the poll task.
  • Polling could sleep past the deadline — the sleep is now bounded by the deadline, on top of the per-request timeout from feat(enrollment): support the OAuth device authorization grant #144.
  • Pending slot before the IdP call — this is the fix that landed with feat(enrollment): support the OAuth device authorization grant #144 (the capacity reservation in begin_device), so it's already in this branch's base.

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: cargo fmt --all --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test for the agent and controller crates, and cargo xtask schema (no diff).

@devmittal02

Copy link
Copy Markdown
Contributor Author

@peterj done

@peterj
peterj merged commit bd296af into agentdesktop-dev:main Oct 6, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants