# Device login (OAuth device flow)

A CLI or coding agent that has no browser of its own gets a token without
anyone pasting a secret: it asks for a code, a person approves it in the
console, and the agent's next poll receives a scoped, expiring agent token
(`scb_agt_…`). This is the OAuth 2.0 Device Authorization Grant
([RFC 8628](https://www.rfc-editor.org/rfc/rfc8628)). `scribase login`,
`npx scribase login` and `npx scribase init` implement the client side; see
[Why you never need to paste a secret key](https://docs.scribase.com/docs/agents/no-secret-keys.md).

```text
agent                          control API                     person (console)
  | POST /v1/oauth/device/code  |                                     |
  |---------------------------->| device_code, user_code, URL         |
  |<----------------------------|                                     |
  | shows URL + SCRB-7F3K-Q2MX  |                                     |
  |                             |  /device?user_code=... (signed in)  |
  | POST /v1/oauth/token (poll) |<------------------------------------|
  | authorization_pending       |  lookup, then approve or deny       |
  | ...                         |                                     |
  | POST /v1/oauth/token        |                                     |
  | 200 access_token=scb_agt_…  |                                     |
```

## POST /v1/oauth/device/code

Anonymous. The body is `application/x-www-form-urlencoded` (as RFC 8628
specifies) or JSON.

| Field | Notes |
|---|---|
| `client_id` | Optional. Identifies the client, e.g. `scribase-cli` |
| `client_name` | Optional. Shown to the person approving, e.g. `Claude Code on laptop` (1 to 80 printable characters) |
| `scope` | Optional, space-separated. See below. Default `project:write sql:write migrations:write functions:deploy branches:write` |

Requested scopes: `org:read`, `project:read`, `project:write`,
`project:admin` (the person picks the project), `project:<id>:read|write|admin`
(a specific project), `sql:read`, `sql:write`, `migrations:write`,
`functions:deploy`, `branches:write`, `keys:secret`. The person can grant
less than was requested, never more.

```sh
curl -X POST "$SCRIBASE_API_URL/v1/oauth/device/code" \
  -d client_id=scribase-cli \
  --data-urlencode 'client_name=Claude Code' \
  --data-urlencode 'scope=project:write sql:write migrations:write'
```

**Response `200`:**

```json
{
  "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
  "user_code": "SCRB-7F3K-Q2MX",
  "verification_uri": "https://console.scribase.com/device",
  "verification_uri_complete": "https://console.scribase.com/device?user_code=SCRB-7F3K-Q2MX",
  "expires_in": 600,
  "interval": 5
}
```

User codes use only unambiguous characters (no `0`/`O`, `1`/`I`/`L`), and the
console accepts them with or without the `SCRB-` prefix, dashes or capitals.
Keep `device_code` private to the polling process; show the person only the
URL and `user_code`. Creation is rate limited per client address
(`429` with `Retry-After`).

## POST /v1/oauth/token

Anonymous. Poll no faster than `interval` seconds.

| Field | Value |
|---|---|
| `grant_type` | `urn:ietf:params:oauth:grant-type:device_code` |
| `device_code` | From the step above |
| `client_id` | Optional, the same as above |

While waiting, and on failure, the answer is `400` with an OAuth error:

| `error` | Meaning | What the client does |
|---|---|---|
| `authorization_pending` | Nobody has approved yet | Poll again after `interval` |
| `slow_down` | Polled too fast | Add 5 seconds to the interval (the response carries the new `interval`) |
| `access_denied` | The person clicked Deny | Stop |
| `expired_token` | Ten minutes passed | Start over |
| `invalid_grant` | Unknown code, or its token was already issued | Stop |
| `unsupported_grant_type` | Wrong `grant_type` | Fix the request |

Once approved, the next poll answers `200`, exactly once:

```json
{
  "access_token": "scb_agt_1p-rw-sqlw-mig-x2609260412_3f9a...",
  "token_type": "Bearer",
  "expires_in": 28800,
  "scope": "project:web:write sql:write migrations:write",
  "organization_id": "acme",
  "project_id": "web",
  "token_id": "7d1e..."
}
```

The token is an ordinary [agent token](https://docs.scribase.com/docs/agents/safety.md): bound to the
organization, limited to the approved scopes, listed, audited and revocable
under **Account > Access tokens**. A second poll with the same `device_code`
gets `invalid_grant`.

## Console routes

These back the console's `/device` page. They need a signed-in console
session; agent tokens are refused.

### POST /v1/oauth/device/lookup

`{ "user_code": "SCRB-7F3K-Q2MX" }` returns the pending request: client name,
requested scopes, the address it came from, when it was made and when it
expires. Lookups are rate limited per person, so codes cannot be guessed.

### POST /v1/oauth/device/approve

| Field | Notes |
|---|---|
| `user_code` | The code shown by the agent |
| `organization_id` | You must be a member |
| `project_id` | A project in that organization, or `*` for every project |
| `level` | `read`, `write` or `admin`, at most what was requested |
| `capabilities` | Subset of the requested `sql:read`, `sql:write`, `migrations:write`, `functions:deploy`, `branches:write`, `keys:secret` |
| `ttl` | 1 hour to 30 days: `1h`, `8h` (default), `24h`, `7d`, `30d` |

### POST /v1/oauth/device/deny

`{ "user_code": "SCRB-7F3K-Q2MX" }`. The agent's next poll gets
`access_denied`.

Every approval and denial is recorded: who, which client, from which
address, which scopes, and the id of the token that was issued.
