# Sign in from a CLI or an app without a backend

Command-line tools (CLIs), scripts and apps without their own server sign in to Tino the way `gh auth login` works: the CLI prints a short code, the customer opens `https://auth.tino.vn/oauth/device` in any browser (computer or phone), enters the code and clicks Allow, and the CLI receives a token. No redirect URI, no `client_secret`.

Updated: 2026-10-27. Every endpoint is in the discovery document `https://api.tino.vn/.well-known/oauth-authorization-server` (`registration_endpoint`, `device_authorization_endpoint`, `token_endpoint`).

| Item | Value |
| --- | --- |
| Register an app (`registration_endpoint`) | `POST https://api.tino.vn/oauth/register` |
| Request a device code (`device_authorization_endpoint`) | `POST https://api.tino.vn/oauth/device_authorization` |
| Page where the customer enters the code (`verification_uri`) | `https://auth.tino.vn/oauth/device` |
| Poll for and refresh tokens (`token_endpoint`) | `POST https://api.tino.vn/oauth/token` |
| Revoke tokens (`revocation_endpoint`) | `POST https://api.tino.vn/oauth/revoke` |
| Call the API on the customer's behalf | `https://api.tino.vn/v1/<route>` |
| Device code `grant_type` | `urn:ietf:params:oauth:grant-type:device_code` |
| Code the customer enters (`user_code`) | 8 letters as `XXXX-XXXX`, expires after 600 seconds |

Apps that have a browser and can receive a redirect (web, mobile, MCP clients) use the Authorization Code + PKCE flow in [Sign in with Tino](/en/docs/oauth); this page covers everything else.

## Device code sign-in flow

1. The CLI calls `POST /oauth/device_authorization` → gets a `device_code` (keep it secret) and a `user_code` (show it to the customer).
2. The CLI prints: "Open https://auth.tino.vn/oauth/device and enter the code LPMR-SKST" — or opens `verification_uri_complete` (code included).
3. The customer signs in to Tino (if needed), enters the code, sees the app name and the requested permissions, and clicks Allow or Deny.
4. Meanwhile the CLI polls `POST /oauth/token` every `interval` seconds with the `device_code` until it gets a token, is denied, or the code expires.
5. The CLI calls `https://api.tino.vn/v1/<route>` with `Authorization: Bearer <access_token>`.

## Registering an app

There are two ways to get a `client_id`.

**Self-registration (RFC 7591).** Call it once at install time or on first run, and store the `client_id`:

```bash
curl -X POST https://api.tino.vn/oauth/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"My CLI","grant_types":["urn:ietf:params:oauth:grant-type:device_code","refresh_token"],"scope":"offline_access profile:read services:read"}'
```

Response (HTTP 201):

```json
{
  "client_id": "…",
  "client_id_issued_at": 1793106000,
  "client_name": "My CLI",
  "grant_types": ["urn:ietf:params:oauth:grant-type:device_code", "refresh_token"],
  "token_endpoint_auth_method": "none",
  "scope": "offline_access profile:read services:read"
}
```

| Field | Rule |
| --- | --- |
| `client_name` | name shown to the customer when entering the code; at most 100 characters |
| `grant_types` | among `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code` |
| `redirect_uris` | required with `authorization_code`: `https://`, `http://localhost` / `127.0.0.1` / `[::1]` (any port) or the app's own scheme (`myapp://callback`); no `#` |
| `scope` | space-separated permissions (permission table in [Sign in with Tino](/en/docs/oauth)); empty = every permission an app may request |
| `logo_uri` | optional, `https://` |
| `token_endpoint_auth_method` | `none` only: self-registered apps are public clients without a `client_secret` |

Registering grants nothing: the customer still enters the code and clicks Allow. Each IP address can register at most 10 times per hour (`429 too_many_requests`). Metadata errors: `400 invalid_client_metadata` or `400 invalid_redirect_uri`, with an English sentence in `error_description`.

**Tino creates the app.** Contact Tino to create an app with "CLI (device code)" enabled; use this when you need a `client_secret` or a fixed name and logo.

## Step 1 — Request a device code

`POST https://api.tino.vn/oauth/device_authorization`, body `application/x-www-form-urlencoded`:

| Parameter | Value |
| --- | --- |
| `client_id` | your client_id |
| `scope` | space-separated permissions; empty = the app's permissions. Add `offline_access` to get a refresh token |
| `client_secret` | confidential clients created by Tino only |

```bash
curl -X POST https://api.tino.vn/oauth/device_authorization \
  -d client_id=YOUR_CLIENT_ID \
  -d "scope=offline_access services:read"
```

Response (HTTP 200):

```json
{
  "device_code": "…",
  "user_code": "LPMR-SKST",
  "verification_uri": "https://auth.tino.vn/oauth/device",
  "verification_uri_complete": "https://auth.tino.vn/oauth/device?user_code=LPMR-SKST",
  "expires_in": 600,
  "interval": 5
}
```

| Error | Meaning |
| --- | --- |
| `401 invalid_client` | `client_id` unknown or disabled, or wrong `client_secret` |
| `400 unauthorized_client` | the app does not have device code sign-in enabled |
| `400 invalid_scope` | unknown permission, or outside the app's permissions |

The `device_code` is the CLI's secret: never print or log it. Only the `user_code` and the page address are for the customer.

## Step 2 — The customer enters the code

Print `verification_uri` and `user_code` so the customer can type them on any device; on a machine with a browser, open `verification_uri_complete` directly. On Tino's page the customer signs in (if needed), sees the app name, the requested permissions and how long the code stays valid, then clicks Allow or Deny. The page reminds the customer to only enter a code they just saw on their own machine.

## Step 3 — Poll for the token

Every `interval` seconds call `POST https://api.tino.vn/oauth/token`, body `application/x-www-form-urlencoded`:

| Parameter | Value |
| --- | --- |
| `grant_type` | `urn:ietf:params:oauth:grant-type:device_code` |
| `device_code` | the `device_code` from step 1 |
| `client_id` | your client_id |
| `client_secret` | confidential clients only |

While waiting, Tino returns HTTP 400 with `error`:

| `error` | What the CLI does |
| --- | --- |
| `authorization_pending` | the customer has not answered yet: wait `interval` seconds and poll again |
| `slow_down` | polling too fast: add 5 seconds to the wait and keep the new interval |
| `access_denied` | the customer clicked Deny: stop |
| `expired_token` | more than 10 minutes passed: request a new code (step 1) |
| `invalid_grant` | wrong or already used `device_code`: stop |

Once the customer clicks Allow, the next poll gets HTTP 200 (each `device_code` can be exchanged only once):

```json
{
  "access_token": "tinoat_…",
  "token_type": "Bearer",
  "expires_in": 86400,
  "scope": "offline_access services:read",
  "refresh_token": "tinort_…"
}
```

`refresh_token` is only present when `scope` includes `offline_access`.

## Step 4 — Call the API

```bash
curl https://api.tino.vn/v1/service -H "Authorization: Bearer $ACCESS_TOKEN"
```

The permissions in `scope` decide which routes you can call: permission and route table in [Sign in with Tino](/en/docs/oauth), each route in the [API Reference](/en/docs/reference).

## Refreshing and revoking

- Refresh when the `access_token` expires (`expires_in` 86400 seconds, 24 hours): `POST /oauth/token` with `grant_type=refresh_token`, `client_id`, `refresh_token`. Each refresh returns a new refresh token and invalidates the old one; store the new one right away. Reusing an old refresh token makes Tino revoke all of the app's access, and the customer has to enter a code again.
- Sign out: `POST /oauth/revoke` with `token=<refresh_token>`, `client_id`. Customers can also revoke access themselves on the Authorized apps page (`https://auth.tino.vn/me/apps`).

Parameter details: [Sign in with Tino](/en/docs/oauth).

## Storing tokens safely

- Store tokens in a per-user file, for example `~/.config/<app-name>/token.json` with mode `600` (owner read/write only), or in the operating system keychain.
- Never print tokens or the `device_code` to the screen or logs; never pass tokens as command-line arguments (they show up in the process list).
- Only request the permissions you really need; add `offline_access` when the CLI must keep working without asking the customer for a code again.

## Bash example

Needs `curl` and `jq`.

```bash
#!/usr/bin/env bash
set -euo pipefail
CLIENT_ID="${TINO_CLIENT_ID:?set TINO_CLIENT_ID}"
API=https://api.tino.vn

start=$(curl -s -X POST "$API/oauth/device_authorization" -d client_id="$CLIENT_ID" -d "scope=offline_access services:read")
device_code=$(jq -r .device_code <<<"$start")
interval=$(jq -r .interval <<<"$start")
echo "Open $(jq -r .verification_uri <<<"$start") and enter the code $(jq -r .user_code <<<"$start")"

while true; do
  sleep "$interval"
  reply=$(curl -s -X POST "$API/oauth/token" \
    -d grant_type=urn:ietf:params:oauth:grant-type:device_code \
    -d device_code="$device_code" -d client_id="$CLIENT_ID")
  case "$(jq -r '.error // empty' <<<"$reply")" in
    "") break ;;
    authorization_pending) ;;
    slow_down) interval=$((interval + 5)) ;;
    *) echo "Sign-in failed: $(jq -r .error <<<"$reply")" >&2; exit 1 ;;
  esac
done

mkdir -p ~/.config/my-cli
( umask 077; printf '%s' "$reply" > ~/.config/my-cli/token.json )
echo "Signed in."
```

## Node.js example

Node.js 18+ (built-in `fetch`), no libraries.

```javascript
import { mkdir, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import { join } from "node:path";

const API = "https://api.tino.vn";
const clientId = process.env.TINO_CLIENT_ID;
const form = (body) => ({ method: "POST", body: new URLSearchParams(body) });

const start = await (await fetch(`${API}/oauth/device_authorization`, form({ client_id: clientId, scope: "offline_access services:read" }))).json();
console.log(`Open ${start.verification_uri} and enter the code ${start.user_code}`);

let interval = start.interval;
let token;
while (!token) {
  await new Promise((done) => setTimeout(done, interval * 1000));
  const reply = await (await fetch(`${API}/oauth/token`, form({
    grant_type: "urn:ietf:params:oauth:grant-type:device_code", device_code: start.device_code, client_id: clientId,
  }))).json();
  if (!reply.error) token = reply;
  else if (reply.error === "slow_down") interval += 5;
  else if (reply.error !== "authorization_pending") throw new Error(`Sign-in failed: ${reply.error}`);
}

const dir = join(homedir(), ".config", "my-cli");
await mkdir(dir, { recursive: true, mode: 0o700 });
await writeFile(join(dir, "token.json"), JSON.stringify(token), { mode: 0o600 });
console.log("Signed in.");
```

## Python example

Python 3, standard library only.

```python
import json, os, time, urllib.error, urllib.parse, urllib.request

API = "https://api.tino.vn"
CLIENT_ID = os.environ["TINO_CLIENT_ID"]

def post(path, body):
    request = urllib.request.Request(API + path, data=urllib.parse.urlencode(body).encode())
    try:
        with urllib.request.urlopen(request) as response:
            return json.load(response)
    except urllib.error.HTTPError as error:  # 400 authorization_pending, slow_down…
        return json.load(error)

start = post("/oauth/device_authorization", {"client_id": CLIENT_ID, "scope": "offline_access services:read"})
print(f"Open {start['verification_uri']} and enter the code {start['user_code']}")

interval = start["interval"]
while True:
    time.sleep(interval)
    reply = post("/oauth/token", {"grant_type": "urn:ietf:params:oauth:grant-type:device_code",
                                  "device_code": start["device_code"], "client_id": CLIENT_ID})
    error = reply.get("error")
    if not error:
        break
    if error == "slow_down":
        interval += 5
    elif error != "authorization_pending":
        raise SystemExit(f"Sign-in failed: {error}")

path = os.path.expanduser("~/.config/my-cli/token.json")
os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True)
with open(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as file:
    json.dump(reply, file)
print("Signed in.")
```

## References

- RFC 8628 — OAuth 2.0 Device Authorization Grant
- RFC 7591 — OAuth 2.0 Dynamic Client Registration
- [Sign in with Tino (OAuth 2.0, OpenID Connect)](/en/docs/oauth)
- [Personal API tokens](/en/docs/personal-tokens): customers create a token for their own scripts, no app registration needed
