Đă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; trang này cho phần còn lại.
Luồng đăng nhập bằng mã thiết bị
- CLI gọi
POST /oauth/device_authorization→ nhậndevice_code(giữ bí mật) vàuser_code(cho khách xem). - 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ã). - 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.
- Trong lúc đó CLI hỏi
POST /oauth/tokenmỗiintervalgiây vớidevice_code, tới khi nhận token, bị từ chối hoặc mã hết hạn. - CLI gọi
https://api.tino.vn/v1/<route>vớiAuthorization: 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ườ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); 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 |
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ỗ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):
{
"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_tokenhết hạn (expires_in86400 giây, 24 giờ):POST /oauth/tokenvớigrant_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/revokevớitoken=<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ền600(chỉ chủ file đọc ghi); hoặc keychain của hệ điều hành. - Không in token,
device_codera 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_accesskhi 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
- 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)
- Token API cá nhân: khách tự tạo token cho script của mình, không cần đăng ký ứng dụng