# Đăng nhập từ CLI và ứng dụng không có backend

Công cụ dòng lệnh (CLI), script và ứng dụng không có server riêng đăng nhập Tino theo kiểu `gh auth login`: CLI in ra một mã ngắn, khách mở `https://auth.tino.vn/oauth/device` trên trình duyệt bất kỳ (máy tính hay điện thoại), nhập mã và bấm Cho phép, CLI nhận token. Không cần redirect URI, không cần `client_secret`.

Cập nhật: 2026-10-27. Mọi endpoint có trong tài liệu discovery `https://api.tino.vn/.well-known/oauth-authorization-server` (`registration_endpoint`, `device_authorization_endpoint`, `token_endpoint`).

| Mục | Giá trị |
| --- | --- |
| Đăng ký ứng dụng (`registration_endpoint`) | `POST https://api.tino.vn/oauth/register` |
| Xin mã thiết bị (`device_authorization_endpoint`) | `POST https://api.tino.vn/oauth/device_authorization` |
| Trang khách nhập mã (`verification_uri`) | `https://auth.tino.vn/oauth/device` |
| Hỏi token, làm mới token (`token_endpoint`) | `POST https://api.tino.vn/oauth/token` |
| Thu hồi token (`revocation_endpoint`) | `POST https://api.tino.vn/oauth/revoke` |
| Gọi API thay khách | `https://api.tino.vn/v1/<route>` |
| `grant_type` của mã thiết bị | `urn:ietf:params:oauth:grant-type:device_code` |
| Mã khách nhập (`user_code`) | 8 chữ dạng `XXXX-XXXX`, hết hạn sau 600 giây |

Ứng dụng có trình duyệt và nhận được redirect (web, mobile, MCP client) dùng luồng Authorization Code + PKCE ở trang [Đăng nhập bằng Tino](/docs/oauth); trang này cho phần còn lại.

## Luồng đăng nhập bằng mã thiết bị

1. CLI gọi `POST /oauth/device_authorization` → nhận `device_code` (giữ bí mật) và `user_code` (cho khách xem).
2. CLI in ra: "Mở https://auth.tino.vn/oauth/device và nhập mã LPMR-SKST" — hoặc mở sẵn `verification_uri_complete` (đã kèm mã).
3. Khách đăng nhập Tino (nếu chưa), nhập mã, xem tên ứng dụng và các quyền xin, bấm Cho phép hoặc Từ chối.
4. Trong lúc đó CLI hỏi `POST /oauth/token` mỗi `interval` giây với `device_code`, tới khi nhận token, bị từ chối hoặc mã hết hạn.
5. CLI gọi `https://api.tino.vn/v1/<route>` với `Authorization: Bearer <access_token>`.

## Đăng ký ứng dụng

Có hai cách lấy `client_id`.

**Tự đăng ký (RFC 7591).** Gọi một lần lúc cài đặt hoặc lần chạy đầu, lưu `client_id` lại:

```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"}'
```

Câu trả lời (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"
}
```

| Trường | Quy tắc |
| --- | --- |
| `client_name` | tên hiện cho khách khi nhập mã; tối đa 100 ký tự |
| `grant_types` | trong số `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code` |
| `redirect_uris` | bắt buộc khi có `authorization_code`: `https://`, `http://localhost` / `127.0.0.1` / `[::1]` (mọi cổng) hoặc scheme riêng của ứng dụng (`myapp://callback`); không có `#` |
| `scope` | các quyền cách nhau dấu cách (bảng quyền ở [Đăng nhập bằng Tino](/docs/oauth)); bỏ trống = mọi quyền ứng dụng được xin |
| `logo_uri` | không bắt buộc, `https://` |
| `token_endpoint_auth_method` | chỉ `none`: ứng dụng tự đăng ký là client công khai, không có `client_secret` |

Đăng ký không cấp quyền gì: khách vẫn phải tự nhập mã và bấm Cho phép. Mỗi địa chỉ IP đăng ký tối đa 10 lần mỗi giờ (`429 too_many_requests`). Lỗi metadata: `400 invalid_client_metadata` hoặc `400 invalid_redirect_uri`, câu tiếng Anh trong `error_description`.

**Tino tạo ứng dụng.** Liên hệ Tino để tạo ứng dụng có bật "CLI (device code)"; dùng khi cần `client_secret` hoặc tên, logo cố định.

## Bước 1 — Xin mã thiết bị

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

| Tham số | Giá trị |
| --- | --- |
| `client_id` | client_id của bạn |
| `scope` | các quyền cách nhau dấu cách; bỏ trống = các quyền của ứng dụng. Thêm `offline_access` để có refresh token |
| `client_secret` | chỉ client confidential do Tino tạo |

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

Câu trả lời (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
}
```

| Lỗi | Nghĩa |
| --- | --- |
| `401 invalid_client` | `client_id` không có, bị tắt, hoặc sai `client_secret` |
| `400 unauthorized_client` | ứng dụng chưa bật đăng nhập bằng mã thiết bị |
| `400 invalid_scope` | quyền lạ hoặc ngoài quyền của ứng dụng |

`device_code` là bí mật của CLI: không in ra, không ghi log. Chỉ `user_code` và địa chỉ trang là cho khách xem.

## Bước 2 — Khách nhập mã

In `verification_uri` và `user_code` để khách gõ trên bất kỳ thiết bị nào; máy có trình duyệt thì mở sẵn `verification_uri_complete`. Trên trang của Tino, khách đăng nhập (nếu chưa), thấy tên ứng dụng, các quyền xin và thời gian mã còn hiệu lực, rồi bấm Cho phép hoặc Từ chối. Trang nhắc khách chỉ nhập mã vừa thấy trên máy của chính mình.

## Bước 3 — Hỏi token

Mỗi `interval` giây gọi `POST https://api.tino.vn/oauth/token`, body `application/x-www-form-urlencoded`:

| Tham số | Giá trị |
| --- | --- |
| `grant_type` | `urn:ietf:params:oauth:grant-type:device_code` |
| `device_code` | `device_code` ở bước 1 |
| `client_id` | client_id của bạn |
| `client_secret` | chỉ client confidential |

Trong lúc chờ, Tino trả HTTP 400 với `error`:

| `error` | Việc CLI làm |
| --- | --- |
| `authorization_pending` | khách chưa trả lời: chờ `interval` giây rồi hỏi tiếp |
| `slow_down` | hỏi nhanh quá: tăng khoảng chờ thêm 5 giây, giữ mức mới cho các lần sau |
| `access_denied` | khách bấm Từ chối: dừng |
| `expired_token` | quá 10 phút: xin mã mới (bước 1) |
| `invalid_grant` | `device_code` sai hoặc đã dùng: dừng |

Khách bấm Cho phép, lần hỏi kế tiếp nhận HTTP 200 (mỗi `device_code` chỉ đổi được một lần):

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

`refresh_token` chỉ có khi `scope` có `offline_access`.

## Bước 4 — Gọi API

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

Quyền trong `scope` quyết định route nào gọi được: bảng quyền và route ở [Đăng nhập bằng Tino](/docs/oauth), từng route ở [API Reference](/docs/reference).

## Làm mới và thu hồi

- Làm mới khi `access_token` hết hạn (`expires_in` 86400 giây, 24 giờ): `POST /oauth/token` với `grant_type=refresh_token`, `client_id`, `refresh_token`. Mỗi lần làm mới trả refresh token mới và vô hiệu hoá cái cũ; lưu ngay bản mới. Dùng lại refresh token cũ thì Tino thu hồi toàn bộ quyền của ứng dụng, khách phải nhập mã lại.
- Đăng xuất: `POST /oauth/revoke` với `token=<refresh_token>`, `client_id`. Khách cũng tự thu hồi được ở trang Ứng dụng được cấp quyền (`https://auth.tino.vn/me/apps`).

Chi tiết tham số: [Đăng nhập bằng Tino](/docs/oauth).

## Lưu token an toàn

- Lưu token trong file riêng của người dùng, ví dụ `~/.config/<tên-ứng-dụng>/token.json`, quyền `600` (chỉ chủ file đọc ghi); hoặc keychain của hệ điều hành.
- Không in token, `device_code` ra màn hình hay log; không đưa token vào tham số dòng lệnh (lộ qua danh sách tiến trình).
- Chỉ xin các quyền thật sự cần; thêm `offline_access` khi CLI cần chạy lâu mà không bắt khách nhập mã lại.

## Ví dụ bash

Cần `curl` và `jq`.

```bash
#!/usr/bin/env bash
set -euo pipefail
CLIENT_ID="${TINO_CLIENT_ID:?đặt 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 "Mở $(jq -r .verification_uri <<<"$start") và nhập mã $(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 "Đăng nhập không thành công: $(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 "Đã đăng nhập."
```

## Ví dụ Node.js

Node.js 18+ (có sẵn `fetch`), không cần thư viện.

```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(`Mở ${start.verification_uri} và nhập mã ${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(`Đăng nhập không thành công: ${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("Đã đăng nhập.");
```

## Ví dụ Python

Python 3, chỉ dùng thư viện chuẩn.

```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"Mở {start['verification_uri']} và nhập mã {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"Đăng nhập không thành công: {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("Đã đăng nhập.")
```

## Tham khảo

- RFC 8628 — OAuth 2.0 Device Authorization Grant
- RFC 7591 — OAuth 2.0 Dynamic Client Registration
- [Đăng nhập bằng Tino (OAuth 2.0, OpenID Connect)](/docs/oauth)
- [Token API cá nhân](/docs/personal-tokens): khách tự tạo token cho script của mình, không cần đăng ký ứng dụng
