Skip to content

feat: support OIDC device authorization grant for headless CLI login #2793

Description

@jhjaggars

User Story

As an operator running the OpenShell CLI on a headless machine (no display, no local browser) against an OIDC-secured gateway, I want to authenticate interactively as myself using the standard device authorization grant, so that I don't need a confidential client/service-account credential just to log in as a human user.

Problem Statement

The CLI's interactive OIDC login (openshell gateway add/openshell gateway login with --oidc-issuer) only implements the Authorization Code + PKCE grant. That flow binds an ephemeral 127.0.0.1:<random-port> callback listener on the machine running the CLI and either launches a local browser or prints a URL for the user to open manually — but the OAuth redirect always targets that random loopback port on the CLI host. On a headless host with no browser and no inbound access, there is no way to complete this redirect without manually tunneling a randomly chosen port back to the headless machine before a 120-second timeout expires.

The only fully headless path today is the Client Credentials grant, which requires a confidential client and OPENSHELL_OIDC_CLIENT_SECRET. That's appropriate for service/automation identities, but it does not let a human authenticate as themselves (their own subject, roles, workspace membership) from a headless host.

Impact / Why This Matters

Operators who administer sandboxes from bastion hosts, CI runners used interactively, remote dev boxes, or other displayless environments currently cannot log in to an OIDC gateway as themselves without either:

  • Provisioning and distributing a confidential client secret they shouldn't need (weakens the security model — turns a personal login into a shared service credential), or
  • Manually engineering an SSH port-forward around a randomly chosen ephemeral port within a two-minute window, which is impractical and not documented anywhere.

This blocks a legitimate, common workflow and pushes users toward using automation credentials for interactive human access, which is a worse security posture (harder to attribute actions to a specific user, harder to revoke individually, no natural token expiry tied to a session).

Proposed Design

Add support for the OAuth 2.0 Device Authorization Grant (RFC 8628) as an additional interactive login mode for OIDC gateways, alongside the existing Authorization Code + PKCE flow.

User-facing workflow:

  1. User runs openshell gateway login <name> (or gateway add ... --oidc-issuer ...) on a headless host.

  2. The CLI detects it cannot or should not launch a local browser (e.g., no display, OPENSHELL_NO_BROWSER=1, or an explicit new flag such as --device-code) and falls back to the device authorization grant instead of failing outright.

  3. The CLI prints a short verification URL and a human-readable code, e.g.:

    To authenticate, open this URL on any device with a browser:
      https://idp.example.com/device
    and enter code: WDJB-MJHT
    
    Waiting for approval...
    
  4. The user completes login and code entry on a separate device (phone, laptop) with no need for any network path back to the headless host.

  5. The CLI polls the token endpoint in the background and, once approved, stores the resulting token bundle exactly as the existing Authorization Code flow does today (~/.config/openshell/gateways/<name>/oidc_token.json), including refresh-token based renewal.

  6. On denial or expiry, the CLI reports a clear error and exits non-zero.

This should require no new gateway-side (openshell-server) changes — it only affects how the CLI acquires tokens from the OIDC provider. It does require the target IdP's OIDC client to have the device grant enabled, which is IdP-side configuration outside OpenShell's control; the CLI should surface a clear error if the discovery document has no device_authorization_endpoint or the IdP rejects the grant.

Acceptance Criteria

  • openshell gateway login/gateway add can complete OIDC authentication against a public client without any local browser and without a client secret, using the device authorization grant.
  • The CLI clearly prints the verification URL and user code, and indicates it is waiting for approval.
  • The CLI respects the IdP's polling interval and handles authorization_pending, slow_down, access_denied, and expired_token per RFC 8628.
  • Acquired tokens (access + refresh) are stored and refreshed the same way as the existing Authorization Code flow.
  • Falls back gracefully (clear error message) when the issuer's discovery document does not advertise a device_authorization_endpoint.
  • Documentation in docs/reference/gateway-auth.mdx describes the new mode and when it applies.

Alternatives Considered

  • SSH tunnel the existing PKCE callback port: Works in principle but requires a fixed, predictable callback port (not currently supported — the CLI always binds 127.0.0.1:0) and manual tunnel setup by the user every time. Fragile and undocumented; doesn't scale as a supported workflow.
  • Require a confidential client + Client Credentials grant for headless human login: Already works today but authenticates as a shared service identity rather than the individual user, which is a weaker security and auditability posture for interactive human access.
  • Do nothing: Leaves headless interactive OIDC login unsupported, forcing users onto one of the two options above.

Agent Investigation

Reviewed crates/openshell-cli/src/oidc_auth.rs: only oidc_browser_auth_flow (Authorization Code + PKCE, public client, ephemeral 127.0.0.1:0 callback listener, 120s timeout) and oidc_client_credentials_flow (confidential client via OPENSHELL_OIDC_CLIENT_SECRET) are implemented. No device_code/RFC 8628 support exists anywhere in openshell-cli, openshell-sdk, or openshell-bootstrap. docs/reference/gateway-auth.mdx confirms the documented behavior matches the code.

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions