# Đăng nhập bằng Tino — OAuth 2.0 và OpenID Connect

Ứng dụng của bạn đăng nhập khách bằng tài khoản Tino và gọi API Tino thay khách qua OAuth 2.0 Authorization Code + PKCE (S256), kèm OpenID Connect. Issuer là `https://api.tino.vn`; trang đăng nhập và đồng ý là `https://auth.tino.vn/oauth/consent`.

Cập nhật: 2026-10-27. Nguồn sự thật cho endpoint, quyền và claim: tài liệu discovery `https://api.tino.vn/.well-known/openid-configuration`.

## Tổng quan

Mọi endpoint đều có trong tài liệu discovery: đọc `https://api.tino.vn/.well-known/openid-configuration` (hoặc `/.well-known/oauth-authorization-server`, cùng nội dung) thay vì ghi cứng địa chỉ.

| Mục | Giá trị |
| --- | --- |
| Issuer (`iss`) | `https://api.tino.vn` |
| Trang đăng nhập + đồng ý (`authorization_endpoint`) | `https://auth.tino.vn/oauth/consent` |
| Đổi code, 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` |
| UserInfo (`userinfo_endpoint`) | `GET https://api.tino.vn/oauth/userinfo` |
| Khoá công khai (`jwks_uri`) | `GET https://api.tino.vn/oauth/jwks` |
| Gọi API thay khách (`resource_gateway`) | `https://api.tino.vn/v1/<route>` |
| Đăng ký ứng dụng (`registration_endpoint`) | `POST https://api.tino.vn/oauth/register` — client công khai tự đăng ký, xem [Đăng nhập từ CLI](/docs/cli-login) |
| Xin mã thiết bị (`device_authorization_endpoint`) | `POST https://api.tino.vn/oauth/device_authorization` — CLI, xem [Đăng nhập từ CLI](/docs/cli-login) |
| Luồng hỗ trợ | `response_type=code`; `grant_type` = `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code` |
| PKCE | bắt buộc, chỉ `S256` |
| Xác thực client ở token endpoint | `client_secret_post` (client có secret) hoặc `none` (client công khai + PKCE); không hỗ trợ HTTP Basic |
| Ký ID token | `RS256` |

Không có implicit flow, hybrid flow, client credentials hay password grant.

## Luồng đăng nhập

<!-- diagram:flow -->

1. Ứng dụng chuyển trình duyệt của khách tới `https://auth.tino.vn/oauth/consent` kèm `client_id`, `scope`, `code_challenge`.
2. Khách đăng nhập (nếu chưa), chọn tài khoản (nếu trình duyệt có từ hai tài khoản), đồng ý cấp quyền.
3. Tino chuyển trình duyệt về `redirect_uri` của ứng dụng với `?code=…&state=…`.
4. Ứng dụng gọi `POST https://api.tino.vn/oauth/token` với `code` + `code_verifier`.
5. Tino trả `access_token`, `id_token` (khi có `openid`), `refresh_token` (khi có `offline_access`).
6. Ứng dụng gọi `https://api.tino.vn/v1/<route>` với `Authorization: Bearer <access_token>`.

Trình duyệt chỉ mang `code` về ứng dụng; ứng dụng tự gọi api.tino.vn để đổi code lấy token, kèm `code_verifier` chứng minh cùng một lượt đăng nhập.

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

Tino tạo ứng dụng cho bạn và cấp `client_id` (cùng `client_secret` nếu ứng dụng có server). Client công khai (CLI, ứng dụng chạy trên máy khách, MCP client) cũng tự đăng ký được qua `POST https://api.tino.vn/oauth/register` (RFC 7591), xem [Đăng nhập từ CLI](/docs/cli-login).

| Loại client | Dùng khi | Xác thực ở `/oauth/token` |
| --- | --- | --- |
| Confidential (có `client_secret`) | Ứng dụng web đổi code ở server | `client_id` + `client_secret` trong body form (`client_secret_post`) + PKCE |
| Public (không secret) | Ứng dụng chạy trên máy khách: SPA, mobile, CLI | chỉ `client_id` + PKCE |

Khi xin ứng dụng, gửi Tino: tên và logo (https) hiện trên trang đồng ý, danh sách redirect URI, các quyền cần (mục Gọi API của Tino), và ứng dụng có giữ được secret ở server không.

Quy tắc redirect URI:

- So khớp nguyên chuỗi với URI đã đăng ký (kể cả đường dẫn và query); không nhận URI có `#fragment`.
- Phải là `https://`. Riêng khi phát triển trên máy: `http://localhost` hoặc `http://127.0.0.1`.
- URI loopback đăng ký không kèm cổng khớp với mọi cổng (RFC 8252 mục 7.3), hợp cho ứng dụng CLI nghe cổng ngẫu nhiên.

`client_id` có thể là URL của một Client ID Metadata Document (discovery báo `client_id_metadata_document_supported: true`), nhưng chỉ các URL Tino đã đăng ký trước mới được nhận.

## Bước 1 — Chuyển khách tới trang đồng ý

Ứng dụng tạo `code_verifier`, `state`, `nonce` ngẫu nhiên cho mỗi lần đăng nhập, giữ chúng (cookie httpOnly hoặc session), rồi chuyển trình duyệt tới `https://auth.tino.vn/oauth/consent` kèm các tham số trên query.

| Tham số | Bắt buộc | Giá trị |
| --- | --- | --- |
| `response_type` | có | `code` |
| `client_id` | có | client_id Tino cấp |
| `redirect_uri` | có | đúng nguyên chuỗi đã đăng ký |
| `scope` | có | các quyền cách nhau dấu cách, trong số quyền ứng dụng được cấp; `openid` để có ID token, `offline_access` để có refresh token |
| `state` | nên có | chuỗi ngẫu nhiên chống CSRF; Tino trả nguyên về |
| `code_challenge` | có | `BASE64URL(SHA256(code_verifier))`, không dấu `=` |
| `code_challenge_method` | có | `S256` |
| `nonce` | nên có (OpenID) | chuỗi ngẫu nhiên tối đa 255 ký tự; có lại trong ID token |
| `prompt` | không | `none`: khách đã đăng nhập và đã cấp đủ quyền thì chuyển về ngay, không dừng ở màn nào |

Ví dụ:

```text
https://auth.tino.vn/oauth/consent?response_type=code&client_id=YOUR_CLIENT_ID&redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&scope=openid%20profile%20email%20offline_access%20services%3Aread&state=af0ifjsldkj&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM&code_challenge_method=S256&nonce=n-0S6_WzA2Mj
```

Tạo PKCE (Node.js 18+):

```javascript
import { createHash, randomBytes } from "node:crypto";

const codeVerifier = randomBytes(32).toString("base64url"); // 43 ký tự
const codeChallenge = createHash("sha256").update(codeVerifier).digest("base64url");
```

Trên trang đồng ý, khách chưa đăng nhập thì đăng nhập trước (OTP, passkey, Google, Zalo, GitHub). Trình duyệt có từ hai tài khoản Tino trở lên thì khách chọn tài khoản. Khách đã cấp đủ các quyền này cho ứng dụng thì không phải đồng ý lại; xin thêm quyền thì hỏi lại.

Kết quả, Tino chuyển trình duyệt về `redirect_uri`:

| Tình huống | Query về `redirect_uri` |
| --- | --- |
| Khách bấm Cho phép | `code=…&state=…` |
| Khách bấm Từ chối | `error=access_denied&state=…` |
| `scope` lạ hoặc ngoài quyền của ứng dụng | `error=invalid_scope&state=…` |
| `client_id` không có hoặc bị tắt, `redirect_uri` chưa đăng ký | không chuyển về: trang của Tino báo lỗi |

Trước khi dùng `code`, so `state` nhận được với `state` đã giữ; khác nhau thì bỏ.

## Bước 2 — Đổi code lấy token

Đổi `code` trong 60 giây, mỗi code dùng một lần: `POST https://api.tino.vn/oauth/token`, body `application/x-www-form-urlencoded`. Client confidential gọi từ server và gửi `client_secret` trong body; không dùng header `Authorization: Basic`.

```bash
curl -X POST https://api.tino.vn/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d code=AUTH_CODE \
  -d redirect_uri=https://app.example.com/auth/callback \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET \
  -d code_verifier=CODE_VERIFIER
```

| Tham số | Giá trị |
| --- | --- |
| `grant_type` | `authorization_code` |
| `code` | code nhận ở `redirect_uri` |
| `redirect_uri` | đúng URI đã gửi ở bước 1 |
| `client_id` | client_id của bạn |
| `client_secret` | chỉ client confidential |
| `code_verifier` | chuỗi gốc đã tạo `code_challenge` |

Câu trả lời (HTTP 200):

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

| Token | Có khi | Thời hạn mặc định |
| --- | --- | --- |
| `access_token` (`tinoat_…`, chuỗi mờ, không phải JWT) | luôn có | 86400 giây (24 giờ) |
| `id_token` (JWT RS256) | `scope` có `openid` | 86400 giây (24 giờ) |
| `refresh_token` (`tinort_…`) | `scope` có `offline_access` | 2592000 giây (30 ngày) |
| `code` | — | 60 giây, dùng một lần |

Thời hạn do Tino cấu hình và có thể đổi: đọc `expires_in` của mỗi câu trả lời thay vì ghi cứng. Giữ token ở server (hoặc kho bảo mật của thiết bị), không đưa vào URL hay localStorage.

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

Mỗi lần làm mới trả một refresh token mới và vô hiệu hoá cái cũ (xoay vòng). Dùng lại một refresh token đã xoay thì Tino thu hồi toàn bộ quyền của ứng dụng trên tài khoản đó; khách phải đăng nhập và đồng ý lại.

```bash
curl -X POST https://api.tino.vn/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=tinort_… \
  -d client_id=YOUR_CLIENT_ID \
  -d client_secret=YOUR_CLIENT_SECRET
```

- `scope` (không bắt buộc): thu hẹp quyền của token mới; vượt quyền đã cấp thì `invalid_scope` và refresh token cũ vẫn dùng được.
- Câu trả lời cùng dạng bước 2. Lưu ngay `refresh_token` mới trước khi dùng `access_token`.
- Nhiều tiến trình cùng làm mới một refresh token: chỉ một lần thắng, lần kia bị coi là dùng lại. Gom việc làm mới về một chỗ (khoá theo tài khoản).
- `invalid_grant` khi làm mới: token hết hạn, đã thu hồi hoặc khách đã thu hồi quyền ứng dụng. Đưa khách đăng nhập lại từ bước 1.

Thu hồi khi khách đăng xuất khỏi ứng dụng (RFC 7009): `POST https://api.tino.vn/oauth/revoke` với `token=<refresh_token>`, `client_id`, `client_secret` (client confidential). Refresh token và access token đi kèm cùng hết hiệu lực; endpoint luôn trả HTTP 200.

Khách cũng tự thu hồi được ở trang Ứng dụng được cấp quyền (`https://auth.tino.vn/me/apps`); token của ứng dụng hết hiệu lực ngay, lần gọi sau nhận 401 `invalid_token`.

## ID token và UserInfo

ID token là JWT ký `RS256`; kiểm chữ ký bằng khoá có `kid` trùng header, lấy từ `https://api.tino.vn/oauth/jwks` (lưu cache, tải lại khi gặp `kid` lạ). Dùng thư viện OpenID Connect có sẵn (openid-client, jose, Authlib…) thay vì tự viết.

| Claim | Có khi | Kiểm hoặc dùng |
| --- | --- | --- |
| `iss` | luôn có | phải là `https://api.tino.vn` |
| `aud`, `azp` | luôn có | phải là client_id của bạn |
| `exp`, `iat` | luôn có | còn hạn |
| `nonce` | đã gửi `nonce` ở bước 1 | phải trùng giá trị đã giữ |
| `auth_time` | khi Tino biết | lúc khách đăng nhập (unix giây) |
| `sub` | luôn có | mã khách hàng Tino (chuỗi): khoá để gắn tài khoản trong ứng dụng của bạn |
| `email` | scope `email` | email của tài khoản (hoặc của liên hệ phụ khi khách đăng nhập bằng tài khoản liên hệ) |
| `given_name`, `family_name`, `name` | scope `profile` | tên, họ, họ tên |

Ghép tài khoản theo `sub`, không theo `email`: khách đổi được email.

UserInfo trả cùng các claim (`sub` và các claim theo scope), cần access token có `openid`:

```bash
curl https://api.tino.vn/oauth/userinfo -H "Authorization: Bearer tinoat_…"
```

```json
{ "sub": "26", "email": "binh@example.com", "given_name": "Bình", "family_name": "Trần", "name": "Bình Trần" }
```

Access token thiếu `openid` thì nhận 403 `insufficient_scope`.

## Gọi API của Tino

Ứng dụng gọi API Tino thay khách qua gateway `https://api.tino.vn/v1/<route>` với `Authorization: Bearer <access_token>`. Mỗi route thuộc một quyền; route ngoài quyền của token trả 403 `insufficient_scope`.

```bash
curl https://api.tino.vn/v1/details -H "Authorization: Bearer tinoat_…"
```

`GET /v1/details` chạy route `GET /details` của UserAPI bằng tài khoản của khách; body và câu trả lời giữ nguyên dạng của UserAPI.

| Quyền (`scope`) | Khách thấy trên trang đồng ý | Ví dụ route | Số route |
| --- | --- | --- | --- |
| `openid` | Xác thực danh tính của bạn | ID token, `/oauth/userinfo` | — |
| `profile` | Họ tên | claim `name`, `given_name`, `family_name` | — |
| `email` | Địa chỉ email | claim `email` | — |
| `offline_access` | Duy trì truy cập khi bạn không trực tuyến | có `refresh_token` | — |
| `profile:read` | Xem thông tin tài khoản | `GET /v1/details`, `GET /v1/contacts` | 11 |
| `profile:write` | Sửa thông tin tài khoản | `PUT /v1/notifications/@id/ack` | 1 |
| `services:read` | Xem dịch vụ, gồm cả thông tin đăng nhập dịch vụ | `GET /v1/service`, `GET /v1/service/@id` | 28 |
| `services:write` | Quản lý dịch vụ của bạn | `POST /v1/service/@id/renew`, `POST /v1/service/@id/vms/@vmid/reboot` | 14 |
| `domains:read` | Xem tên miền | `GET /v1/domain`, `GET /v1/domain/@id` | 17 |
| `domains:write` | Đăng ký, gia hạn và quản lý tên miền | `POST /v1/domain/@id/renew`, `POST /v1/domain/@id/reg` | 12 |
| `dns:read` | Xem bản ghi DNS | `GET /v1/dns`, `GET /v1/domain/@id/dns` | 7 |
| `dns:write` | Sửa bản ghi DNS | `POST /v1/domain/@id/dns`, `DELETE /v1/domain/@id/dns/@index` | 10 |
| `billing:read` | Xem hoá đơn và thanh toán | `GET /v1/invoices`, `GET /v1/balance` | 13 |
| `billing:write` | Thanh toán, nạp tiền | `POST /v1/addfunds` | 7 |
| `orders:write` | Đặt mua dịch vụ: giỏ hàng, báo giá, tạo đơn (trả bằng số dư cần thêm quyền Thanh toán) | `POST /v1/quote`, `POST /v1/carts`, `POST /v1/order` | 8 |
| `tickets:read` | Xem ticket hỗ trợ | `GET /v1/tickets`, `GET /v1/tickets/@number` | 9 |
| `tickets:write` | Tạo và trả lời ticket | `POST /v1/tickets`, `POST /v1/tickets/@number` | 4 |

Quyền của dịch vụ (gateway `/v1` mở từ 27/10/2026): gọi `https://api.tino.vn/v1/service/<id dịch vụ>/<dịch vụ>/…` cho dịch vụ của chính khách.

| Quyền (`scope`) | Khách thấy trên trang đồng ý | Route | Hướng dẫn |
| --- | --- | --- | --- |
| `tinopay:read` | Xem TinoPay: tài khoản nhận tiền, giao dịch, webhook | 16 | [Bắt đầu với TinoPay](/docs/tinopay-start) |
| `tinopay:write` | Quản lý TinoPay: tài khoản ảo, webhook, thông báo, tích hợp Sapo | 21 | [Bắt đầu với TinoPay](/docs/tinopay-start) |
| `builder:read` | Xem website TinoPage Builder | 6 | [Bắt đầu với TinoPage Builder](/docs/builder-start) |
| `builder:write` | Quản lý website TinoPage Builder: tạo, đăng, tên miền riêng | 7 | [Bắt đầu với TinoPage Builder](/docs/builder-start) |
| `waf:read` | Xem website được TinoWAF bảo vệ và nhật ký sự kiện | 8 | [TinoWAF](/docs/waf) |
| `waf:write` | Quản lý TinoWAF: website, mức bảo vệ, máy chủ gốc, luật IP | 11 | [TinoWAF](/docs/waf) |

`tinopay:owner` (tạo API token TinoPay, liên kết tài khoản ngân hàng nhận tiền) như `domains:owner`: chỉ token API cá nhân có giới hạn IP.

Xin ít quyền nhất đủ dùng: khách thấy từng dòng trên trang đồng ý. `domains:owner` (lấy mã EPP, đổi chủ thể tên miền) và `domains:registrant` ([hồ sơ chủ thể tên miền đang chờ](/docs/domain-registrant)) chỉ dành cho [token API cá nhân](/docs/personal-tokens); ứng dụng không bao giờ được cấp. Ba lệnh đặt đơn có thể trừ số dư (`POST /v1/carts/{uuid}/confirm`, `POST /v1/order`, `POST /v1/order/{product_id}`) cần cả `orders:write` và `billing:write`.

Từng route, tham số, ví dụ request và response: [Gọi API Tino](/docs/api).

## Lỗi thường gặp

`/oauth/token` và `/oauth/revoke` trả lỗi dạng `{"error": "<mã>", "error_description": "<câu tiếng Anh>"}`. `/oauth/userinfo` và gateway `/v1` kèm header `WWW-Authenticate: Bearer error="<mã>"`.

| Endpoint | HTTP | `error` | Nguyên nhân | Xử lý |
| --- | --- | --- | --- | --- |
| `/oauth/token` | 401 | `invalid_client` | sai `client_id` hoặc `client_secret`, ứng dụng bị tắt | kiểm cấu hình; không gửi secret qua HTTP Basic |
| `/oauth/token` | 400 | `invalid_grant` | code sai, hết 60 giây, đã dùng, khác `redirect_uri` hoặc `code_verifier`; quyền đã bị thu hồi | cho khách đăng nhập lại từ bước 1 |
| `/oauth/token` (refresh) | 400 | `invalid_grant` | refresh token hết hạn, đã thu hồi hoặc đã dùng lại (cả quyền bị thu hồi) | cho khách đăng nhập lại |
| `/oauth/token` (refresh) | 400 | `invalid_scope` | `scope` thu hẹp vượt quyền đã cấp | refresh token cũ vẫn dùng được |
| `/oauth/token` | 400 | `unsupported_grant_type` | `grant_type` khác `authorization_code` và `refresh_token` | — |
| `/oauth/userinfo`, `/v1/*` | 401 | `invalid_token` | access token sai, hết hạn hoặc khách đã thu hồi quyền | làm mới token; vẫn 401 thì đăng nhập lại |
| `/oauth/userinfo`, `/v1/*` | 403 | `insufficient_scope` | token không có quyền của route (UserInfo: thiếu `openid`) | xin thêm quyền ở bước 1 |
| `/v1/*` | 403 | `ipnotallowed` | IP gọi API đang bị Tino chặn | liên hệ Tino |
| trang đồng ý | — | `access_denied` (về `redirect_uri`) | khách bấm Từ chối | báo khách, cho thử lại |
| trang đồng ý | — | `invalid_scope` (về `redirect_uri`) | scope lạ hoặc ngoài quyền của ứng dụng | sửa `scope` |

Lỗi nghiệp vụ của từng route `/v1/*` (ví dụ dịch vụ không tồn tại) giữ nguyên câu trả lời của UserAPI, thường là HTTP 200 kèm `{"error": ["<mã>"]}`.

## Tham khảo

- [OpenID discovery của Tino](https://api.tino.vn/.well-known/openid-configuration): endpoint, quyền, claim đang chạy trên production
- [Protected resource metadata](https://api.tino.vn/.well-known/oauth-protected-resource) (RFC 9728): cho client MCP; xem [Kết nối Claude, ChatGPT với Tino (MCP)](/docs/mcp)
- [JWKS](https://api.tino.vn/oauth/jwks): khoá công khai kiểm ID token
- [RFC 6749](https://www.rfc-editor.org/rfc/rfc6749) OAuth 2.0
- [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) PKCE
- [RFC 7009](https://www.rfc-editor.org/rfc/rfc7009) thu hồi token
- [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) ứng dụng native
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
