# Browser-Approved CLI & MCP Login (/docs/auth/device-authorization)



<StoryFrame label="Device authorization" title="The terminal requests access. The browser shows exactly what will be granted." accent="commit">
  <StoryFact label="Approval">
    Client, tenant, environment, scopes, and duration
  </StoryFact>

  <StoryFact label="Credential">
    Short-lived access with a rotating refresh family
  </StoryFact>

  <StoryFact label="Exposure">
    No broad API key in terminal or model-visible configuration
  </StoryFact>
</StoryFrame>

Ductor uses an OAuth 2.0 device-style flow for interactive CLI and MCP clients.

## Enablement [#enablement]

```yaml
auth:
  enabled: true
  api_key_enabled: true

device_auth:
  enabled: true
  issuer: https://ductor.example.com
  device_ttl: 10m
  access_ttl: 1h
  refresh_ttl: 720h
  poll_interval: 5s
  max_duration: 24h
  allow_http: false
```

Supply the 32-byte-or-longer pepper through `DUCTOR_DEVICE_AUTH_PEPPER`; do not put
it in a committed config file. HTTPS is required except for explicitly enabled
loopback development. Migration 477 registers the public `ductor-cli` and
`ductor-mcp` clients.

## User flow [#user-flow]

```bash
ductor auth login --issuer https://ductor.example.com
```

The CLI opens the browser when possible and prints only the verification URL and
short human code. The approval page shows requested scope and duration. Read-only is
the default. Write, destructive, wildcard, or admin scopes require visible elevated
approval, and the approving person must already possess every requested permission.

<ProcessRail label="Consent path" title="A human-readable grant becomes a bounded credential">
  <ProcessStep label="01" title="Request">
    The CLI creates a short-lived device grant and receives a human code.
  </ProcessStep>

  <ProcessStep label="02" title="Review" tone="gold">
    The browser displays client, tenant, environment, scopes, and duration.
  </ProcessStep>

  <ProcessStep label="03" title="Approve" tone="commit">
    An authenticated user grants only permissions they already possess.
  </ProcessStep>

  <ProcessStep label="04" title="Rotate">
    The CLI redeems once, then rotates refresh credentials on every use.
  </ProcessStep>
</ProcessRail>

<BoundaryPanel label="Device-flow security boundary">
  <Boundary title="Designed for interactive clients">
    CLI and MCP gain narrow, expiring access with server-side revocation.
  </Boundary>

  <Boundary title="Never a secret-pasting workflow" tone="halt">
    Human codes cannot redeem credentials, and URLs contain no secrets.
  </Boundary>
</BoundaryPanel>

The CLI stores returned credentials in the operating-system user config directory
with owner-only permissions. `ductor auth status` is redacted; `ductor auth revoke`
revokes the server-side refresh family before deleting the local file.

## Protocol endpoints [#protocol-endpoints]

* `POST /oauth/device/authorization`
* `POST /oauth/token`
* `POST /oauth/revoke`
* `GET /oauth/device`
* authenticated `POST /oauth/device/verify`, `/approve`, and `/deny`
* `GET /.well-known/oauth-authorization-server`

Protocol POST bodies are form-encoded. Polling returns the standard
`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, and
`invalid_grant` results. An early poll durably increases the interval by five
seconds.

## Security properties [#security-properties]

* Device and refresh secrets are 256-bit random values; Postgres stores only
  SHA-256 digests. The human code uses HMAC-SHA-256 with the deployment pepper.
* A SameSite=Strict, HttpOnly browser binding ties approval to the initiating user
  and tenant. A stolen human code alone cannot poll or redeem.
* The URL contains no secret or code. Responses use `no-store` and `no-referrer`.
* Completion inserts the access key and refresh family and consumes the grant in
  one transaction. Plaintext credentials are returned once.
* Refresh rotates every use. Reuse of an old token atomically revokes the entire
  family and current access credential.
* Client disablement revokes its families immediately. MCP remains request-stateless
  after login and still authorizes every tool call.

<Callout type="warn" title="Approved duration is a hard ceiling">
  Refresh cannot extend consent past the duration shown in the browser. Individual access
  credentials use the shorter of the configured access TTL and the remaining approved window.
</Callout>

## Related [#related]

<Cards>
  <Card title="MCP Servers" href="/docs/ai/mcp-server">
    Stateless consumer and platform-admin tool surfaces.
  </Card>

  <Card title="API Keys" href="/docs/auth/api-keys">
    Server-side credential lifecycle and revocation.
  </Card>
</Cards>
