Tino Developers
Xem .md

Đă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ụcGiá 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áchhttps://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; 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:

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):

{
  "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ườngQuy tắc
client_nametên hiện cho khách khi nhập mã; tối đa 100 ký tự
grant_typestrong số authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code
redirect_urisbắ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ó #
scopecác quyền cách nhau dấu cách (bảng quyền ở Đăng nhập bằng Tino); bỏ trống = mọi quyền ứng dụng được xin
logo_urikhông bắt buộc, https://
token_endpoint_auth_methodchỉ 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_idclient_id của bạn
scopecá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_secretchỉ client confidential do Tino tạo
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):

{
  "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ỗiNghĩa
401 invalid_clientclient_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_scopequyề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_typeurn:ietf:params:oauth:grant-type:device_code
device_codedevice_code ở bước 1
client_idclient_id của bạn
client_secretchỉ client confidential

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

errorViệc CLI làm
authorization_pendingkhách chưa trả lời: chờ interval giây rồi hỏi tiếp
slow_downhỏ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_deniedkhách bấm Từ chối: dừng
expired_tokenquá 10 phút: xin mã mới (bước 1)
invalid_grantdevice_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):

{
  "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

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, từng route ở API 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.

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.

#!/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.

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.

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