# Device authorization API

> The RFC 8628 device grant that vy login uses to receive an API key.

- [Request a device code](#request-a-device-code): `POST /v1/auth/device/code`
- [Poll for the API key](#poll-for-the-api-key): `POST /v1/auth/device/token`
- [Look up a device request](#look-up-a-device-request): `GET /v1/auth/device`
- [Approve a device](#approve-a-device): `POST /v1/auth/device/approve`
- [Deny a device](#deny-a-device): `POST /v1/auth/device/deny`

## Request a device code

`POST /v1/auth/device/code`

- **Authentication:** none on this route.
- **Who:** No authentication.

**Request body** (`application/json`, [`DeviceCodeRequest`](/docs/api/schemas#devicecoderequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `client_name` | `string \| null` | No | Shown to the approving user, such as `vy on build-host`; 1–64 characters. Defaults to `vy CLI`. |
| `scope` | [`null \| ApiKeyPresetDto`](/docs/api/schemas#apikeypresetdto) | No | The preset the CLI asks for; the approving user may choose another. Defaults to `create_and_use`. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | Show `user_code` and `verification_uri`, then poll `/v1/auth/device/token` every `interval` seconds | [`DeviceCodeResponse`](/docs/api/schemas#devicecoderesponse) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `429` | Too many requests; wait for `Retry-After`. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/auth/device/code" \
  -H "Content-Type: application/json" \
  -d '{
  "client_name": "vy on build-host",
  "scope": "use_existing"
}'
```

```json title="Response 200"
{
  "device_code": "string",
  "expires_in": 0,
  "interval": 0,
  "user_code": "BCDF-GHJK",
  "verification_uri": "https://console.example.com/device",
  "verification_uri_complete": "https://console.example.com/device?user_code=BCDF-GHJK"
}
```

## Poll for the API key

`POST /v1/auth/device/token`

Polls for the API key. Errors use the RFC 8628 shape, not problem details.

- **Authentication:** none on this route.
- **Who:** No authentication.

**Request body** (`application/json`, [`DeviceTokenRequest`](/docs/api/schemas#devicetokenrequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `device_code` | `string` | Yes |  |
| `grant_type` | `string` | Yes | `urn:ietf:params:oauth:grant-type:device_code` |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | Approved: the new API key, returned exactly once | [`DeviceTokenResponse`](/docs/api/schemas#devicetokenresponse) |
| `400` | `authorization_pending`, `slow_down` (add 5 s to the interval), `access_denied`, `expired_token`, `invalid_grant`, `invalid_request`, or `unsupported_grant_type` | [`DeviceTokenError`](/docs/api/schemas#devicetokenerror) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/auth/device/token" \
  -H "Content-Type: application/json" \
  -d '{
  "grant_type": "urn:ietf:params:oauth:grant-type:device_code",
  "device_code": "<device_code>"
}'
```

```json title="Response 200"
{
  "access_token": "vmy_…",
  "api_key": {
    "created_at": "2026-10-12T05:51:01Z",
    "created_by": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
    "expires_at": "2026-10-12T05:51:01Z",
    "id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
    "last_four": "x9Q2",
    "last_used_at": "2026-10-12T05:51:01Z",
    "name": "ci-runner",
    "organization_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
    "prefix": "vmy_AbCdEfGh",
    "preset": "read_only",
    "revoked_at": "2026-10-12T05:51:01Z"
  },
  "expires_in": 0,
  "scope": "read_only",
  "token_type": "Bearer"
}
```

## Look up a device request

`GET /v1/auth/device`

Shows a pending request so the user can check the device name and
requested scope before approving.

- **API key:** not accepted (`403 session_required`).
- **Browser session:** the `vmyard_session` cookie.
- **Who:** Any signed-in user.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `user_code` | query | `string` | Yes | The code the user typed; case, spaces, and dashes are ignored. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | — | [`DeviceAuthorizationDto`](/docs/api/schemas#deviceauthorizationdto) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | `session_required` for API keys | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Unknown, expired, or already decided code | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `429` | Too many requests; wait for `Retry-After`. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl "$VMYARD_API_URL/v1/auth/device?user_code=BCDF-GHJK" \
  -b "vmyard_session=$VMYARD_SESSION"
```

```json title="Response 200"
{
  "client_name": "vy on build-host",
  "expires_at": "2026-10-12T05:51:01Z",
  "requested_scope": "read_only",
  "user_code": "BCDF-GHJK"
}
```

## Approve a device

`POST /v1/auth/device/approve`

Approves a pending device for the `X-Organization-ID` organization. The
key is minted for this user when the device next polls, so it is bound by
the user's role there like any other key.

- **API key:** not accepted (`403 session_required`).
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Any member of the chosen organization.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Organization-ID` | header | `string (uuid)` | No | Organization the key will belong to. Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Request body** (`application/json`, [`DeviceApproveRequest`](/docs/api/schemas#deviceapproverequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `user_code` | `string` | Yes |  |
| `expires_in_days` | `integer \| null` | No | Key lifetime, 1–90 days; defaults to 90. 1–90. default `90`. |
| `name` | `string \| null` | No | Key name, 1–64 characters; defaults to `vy: <client_name>`. |
| `preset` | [`null \| ApiKeyPresetDto`](/docs/api/schemas#apikeypresetdto) | No | The key's preset; defaults to the requested scope. |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `200` | — | [`DeviceApproved`](/docs/api/schemas#deviceapproved) |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | CSRF failure or an API key (`session_required`) | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Unknown, expired, or already decided code, or not a member of the organization | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `422` | Invalid request (`invalid_request`); `field_errors` names the fields. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `429` | Too many requests; wait for `Retry-After`. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/auth/device/approve" \
  -b "vmyard_session=$VMYARD_SESSION" \
  -H "x-csrf-token: $VMYARD_CSRF" \
  -H "X-Organization-ID: $VMYARD_ORGANIZATION_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "user_code": "BCDF-GHJK",
  "preset": "use_existing",
  "expires_in_days": 30,
  "name": "vy: build-host"
}'
```

```json title="Response 200"
{
  "expires_in_days": 0,
  "organization_id": "6f1c2d3e-4b5a-4c7d-8e9f-0a1b2c3d4e5f",
  "preset": "read_only",
  "user_code": "BCDF-GHJK"
}
```

## Deny a device

`POST /v1/auth/device/deny`

- **API key:** not accepted (`403 session_required`).
- **Browser session:** the `vmyard_session` cookie and the `x-csrf-token` header.
- **Who:** Any signed-in user.

**Parameters**

| Name | In | Type | Required | Description |
| --- | --- | --- | --- | --- |
| `X-Organization-ID` | header | `string (uuid)` | No | Organization that records the audit event. Required with a browser session. Optional with an API key, which fixes the organization; when sent it must match. |

**Request body** (`application/json`, [`DeviceDecisionRequest`](/docs/api/schemas#devicedecisionrequest))

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `user_code` | `string` | Yes |  |

**Responses**

| Status | Description | Body |
| --- | --- | --- |
| `204` | Denied; the device's next poll gets `access_denied` | — |
| `401` | Not signed in, or the API key is malformed, expired, or revoked (`unauthenticated`, `invalid_api_key`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `403` | Refused for this caller: role, key preset, CSRF token, or organization (`forbidden`, `insufficient_scope`, `csrf_invalid`, `organization_mismatch`, `session_required`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `404` | Not found in this organization (`resource_not_found`). | [`ProblemDetails`](/docs/api/schemas#problemdetails) |
| `429` | Too many requests; wait for `Retry-After`. | [`ProblemDetails`](/docs/api/schemas#problemdetails) |

```sh title="Request"
curl -X POST "$VMYARD_API_URL/v1/auth/device/deny" \
  -b "vmyard_session=$VMYARD_SESSION" \
  -H "x-csrf-token: $VMYARD_CSRF" \
  -H "X-Organization-ID: $VMYARD_ORGANIZATION_ID" \
  -H "Content-Type: application/json" \
  -d '{
  "user_code": "BCDF-GHJK"
}'
```
