# Sign in with Tino — OAuth 2.0 and OpenID Connect

Your app signs customers in with their Tino account and calls the Tino API on their behalf through OAuth 2.0 Authorization Code + PKCE (S256), with OpenID Connect. The issuer is `https://api.tino.vn`; the sign-in and consent page is `https://auth.tino.vn/oauth/consent`.

Updated: 2026-10-27. Source of truth for endpoints, scopes and claims: the discovery document `https://api.tino.vn/.well-known/openid-configuration`.

## Overview

Every endpoint is listed in the discovery document: read `https://api.tino.vn/.well-known/openid-configuration` (or `/.well-known/oauth-authorization-server`, same content) instead of hard-coding addresses.

| Item | Value |
| --- | --- |
| Issuer (`iss`) | `https://api.tino.vn` |
| Sign-in + consent page (`authorization_endpoint`) | `https://auth.tino.vn/oauth/consent` |
| Code exchange, token refresh (`token_endpoint`) | `POST https://api.tino.vn/oauth/token` |
| Token revocation (`revocation_endpoint`) | `POST https://api.tino.vn/oauth/revoke` |
| UserInfo (`userinfo_endpoint`) | `GET https://api.tino.vn/oauth/userinfo` |
| Public keys (`jwks_uri`) | `GET https://api.tino.vn/oauth/jwks` |
| Calling the API on the customer's behalf (`resource_gateway`) | `https://api.tino.vn/v1/<route>` |
| App registration (`registration_endpoint`) | `POST https://api.tino.vn/oauth/register` — public clients register themselves, see [Sign in from a CLI](/en/docs/cli-login) |
| Device code request (`device_authorization_endpoint`) | `POST https://api.tino.vn/oauth/device_authorization` — CLIs, see [Sign in from a CLI](/en/docs/cli-login) |
| Supported flows | `response_type=code`; `grant_type` = `authorization_code`, `refresh_token`, `urn:ietf:params:oauth:grant-type:device_code` |
| PKCE | required, `S256` only |
| Client authentication at the token endpoint | `client_secret_post` (clients with a secret) or `none` (public clients + PKCE); HTTP Basic is not supported |
| ID token signing | `RS256` |

There is no implicit flow, hybrid flow, client credentials grant or password grant.

## Sign-in flow

<!-- diagram:flow -->

1. The app redirects the customer's browser to `https://auth.tino.vn/oauth/consent` with `client_id`, `scope`, `code_challenge`.
2. The customer signs in (if not signed in yet), chooses an account (if the browser has two or more accounts), and approves the requested scopes.
3. Tino redirects the browser back to the app's `redirect_uri` with `?code=…&state=…`.
4. The app calls `POST https://api.tino.vn/oauth/token` with `code` + `code_verifier`.
5. Tino returns `access_token`, `id_token` (with `openid`), `refresh_token` (with `offline_access`).
6. The app calls `https://api.tino.vn/v1/<route>` with `Authorization: Bearer <access_token>`.

The browser only carries the `code` back to the app; the app itself calls api.tino.vn to exchange the code for tokens, sending `code_verifier` to prove it is the same sign-in.

## Registering an app

Tino creates the app for you and issues a `client_id` (plus a `client_secret` if the app has a server). Public clients (CLIs, apps running on the customer's machine, MCP clients) can also register themselves with `POST https://api.tino.vn/oauth/register` (RFC 7591), see [Sign in from a CLI](/en/docs/cli-login).

| Client type | Use when | Authentication at `/oauth/token` |
| --- | --- | --- |
| Confidential (with `client_secret`) | Web apps that exchange the code on a server | `client_id` + `client_secret` in the form body (`client_secret_post`) + PKCE |
| Public (no secret) | Apps that run on the user's device: SPA, mobile, CLI | `client_id` + PKCE only |

When requesting an app, send Tino: the name and logo (https) shown on the consent page, the list of redirect URIs, the scopes you need (see the Calling the Tino API section), and whether the app can keep a secret on a server.

Redirect URI rules:

- Exact string match against the registered URI (including path and query); URIs with a `#fragment` are not accepted.
- Must be `https://`. Only for local development: `http://localhost` or `http://127.0.0.1`.
- A loopback URI registered without a port matches any port (RFC 8252 section 7.3), which suits CLI apps that listen on a random port.

`client_id` can be the URL of a Client ID Metadata Document (discovery reports `client_id_metadata_document_supported: true`), but only URLs that Tino has registered in advance are accepted.

## Step 1 — Send the customer to the consent page

For each sign-in, the app generates random `code_verifier`, `state` and `nonce` values, keeps them (httpOnly cookie or session), then redirects the browser to `https://auth.tino.vn/oauth/consent` with the following parameters in the query string.

| Parameter | Required | Value |
| --- | --- | --- |
| `response_type` | yes | `code` |
| `client_id` | yes | the client_id issued by Tino |
| `redirect_uri` | yes | exactly the registered string |
| `scope` | yes | space-separated scopes, from the scopes granted to the app; `openid` to get an ID token, `offline_access` to get a refresh token |
| `state` | recommended | random string against CSRF; Tino returns it unchanged |
| `code_challenge` | yes | `BASE64URL(SHA256(code_verifier))`, without `=` padding |
| `code_challenge_method` | yes | `S256` |
| `nonce` | recommended (OpenID) | random string of up to 255 characters; returned in the ID token |
| `prompt` | no | `none`: if the customer is signed in and has already granted all scopes, redirect back immediately without stopping at any screen |

Example:

```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
```

Generating PKCE (Node.js 18+):

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

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

On the consent page, a customer who is not signed in signs in first (OTP, passkey, Google, Zalo, GitHub). If the browser has two or more Tino accounts, the customer chooses an account. If the customer has already granted these scopes to the app, they do not have to approve again; a request for more scopes asks again.

Result: Tino redirects the browser back to `redirect_uri`:

| Situation | Query sent to `redirect_uri` |
| --- | --- |
| The customer clicks Allow | `code=…&state=…` |
| The customer clicks Deny | `error=access_denied&state=…` |
| Unknown `scope` or outside the app's scopes | `error=invalid_scope&state=…` |
| `client_id` missing or disabled, `redirect_uri` not registered | no redirect: a Tino page shows the error |

Before using the `code`, compare the received `state` with the stored `state`; if they differ, discard it.

## Step 2 — Exchange the code for tokens

Exchange the `code` within 60 seconds; each code can be used once: `POST https://api.tino.vn/oauth/token`, body `application/x-www-form-urlencoded`. Confidential clients call from the server and send `client_secret` in the body; do not use the `Authorization: Basic` header.

```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
```

| Parameter | Value |
| --- | --- |
| `grant_type` | `authorization_code` |
| `code` | the code received at `redirect_uri` |
| `redirect_uri` | the same URI sent in step 1 |
| `client_id` | your client_id |
| `client_secret` | confidential clients only |
| `code_verifier` | the original string used to create `code_challenge` |

Response (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 | Present when | Default lifetime |
| --- | --- | --- |
| `access_token` (`tinoat_…`, opaque string, not a JWT) | always | 86400 seconds (24 hours) |
| `id_token` (JWT RS256) | `scope` includes `openid` | 86400 seconds (24 hours) |
| `refresh_token` (`tinort_…`) | `scope` includes `offline_access` | 2592000 seconds (30 days) |
| `code` | — | 60 seconds, single use |

Lifetimes are configured by Tino and may change: read `expires_in` from each response instead of hard-coding them. Keep tokens on the server (or in the device's secure storage), never in URLs or localStorage.

## Refreshing and revoking tokens

Each refresh returns a new refresh token and invalidates the old one (rotation). If a rotated refresh token is reused, Tino revokes all of the app's access to that account; the customer must sign in and approve again.

```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` (optional): narrows the scopes of the new token; exceeding the granted scopes returns `invalid_scope`, and the old refresh token stays usable.
- The response has the same shape as in step 2. Store the new `refresh_token` immediately, before using the `access_token`.
- If several processes refresh the same refresh token, only one wins; the others count as reuse. Keep refreshing in one place (a lock per account).
- `invalid_grant` on refresh: the token expired, was revoked, or the customer revoked the app's access. Send the customer through sign-in again from step 1.

Revoke when the customer signs out of the app (RFC 7009): `POST https://api.tino.vn/oauth/revoke` with `token=<refresh_token>`, `client_id`, `client_secret` (confidential clients). The refresh token and its access token both stop working; the endpoint always returns HTTP 200.

Customers can also revoke access themselves on the Authorized apps page (`https://auth.tino.vn/en/me/apps`); the app's tokens stop working immediately, and the next call gets 401 `invalid_token`.

## ID token and UserInfo

The ID token is a JWT signed with `RS256`; verify the signature with the key whose `kid` matches the header, from `https://api.tino.vn/oauth/jwks` (cache it, reload when you see an unknown `kid`). Use an existing OpenID Connect library (openid-client, jose, Authlib…) instead of writing your own.

| Claim | Present when | Check or use |
| --- | --- | --- |
| `iss` | always | must be `https://api.tino.vn` |
| `aud`, `azp` | always | must be your client_id |
| `exp`, `iat` | always | not expired |
| `nonce` | `nonce` was sent in step 1 | must match the stored value |
| `auth_time` | when Tino knows it | when the customer signed in (unix seconds) |
| `sub` | always | Tino customer ID (string): the key for linking the account in your app |
| `email` | scope `email` | the account's email (or the contact's email when the customer signs in with a contact account) |
| `given_name`, `family_name`, `name` | scope `profile` | given name, family name, full name |

Match accounts by `sub`, not by `email`: customers can change their email.

UserInfo returns the same claims (`sub` and the claims for each scope) and needs an access token with `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" }
```

An access token without `openid` gets 403 `insufficient_scope`.

## Calling the Tino API

The app calls the Tino API on the customer's behalf through the gateway `https://api.tino.vn/v1/<route>` with `Authorization: Bearer <access_token>`. Each route belongs to one scope; a route outside the token's scopes returns 403 `insufficient_scope`.

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

`GET /v1/details` runs the UserAPI route `GET /details` with the customer's account; the body and response keep the UserAPI format.

| Scope (`scope`) | What the customer sees on the consent page | Example routes | Routes |
| --- | --- | --- | --- |
| `openid` | Verify your identity | ID token, `/oauth/userinfo` | — |
| `profile` | Your name | claims `name`, `given_name`, `family_name` | — |
| `email` | Email address | claim `email` | — |
| `offline_access` | Keep access while you're offline | issues a `refresh_token` | — |
| `profile:read` | View account details | `GET /v1/details`, `GET /v1/contacts` | 11 |
| `profile:write` | Edit account details | `PUT /v1/notifications/@id/ack` | 1 |
| `services:read` | View services, including service login details | `GET /v1/service`, `GET /v1/service/@id` | 28 |
| `services:write` | Manage your services | `POST /v1/service/@id/renew`, `POST /v1/service/@id/vms/@vmid/reboot` | 14 |
| `domains:read` | View domains | `GET /v1/domain`, `GET /v1/domain/@id` | 17 |
| `domains:write` | Register, renew and manage domains | `POST /v1/domain/@id/renew`, `POST /v1/domain/@id/reg` | 12 |
| `dns:read` | View DNS records | `GET /v1/dns`, `GET /v1/domain/@id/dns` | 7 |
| `dns:write` | Edit DNS records | `POST /v1/domain/@id/dns`, `DELETE /v1/domain/@id/dns/@index` | 10 |
| `billing:read` | View invoices and payments | `GET /v1/invoices`, `GET /v1/balance` | 13 |
| `billing:write` | Make payments and top up | `POST /v1/addfunds` | 7 |
| `orders:write` | Order services: cart, quotes, create orders (paying from balance also needs the Payments permission) | `POST /v1/quote`, `POST /v1/carts`, `POST /v1/order` | 8 |
| `tickets:read` | View support tickets | `GET /v1/tickets`, `GET /v1/tickets/@number` | 9 |
| `tickets:write` | Create and reply to tickets | `POST /v1/tickets`, `POST /v1/tickets/@number` | 4 |

Service scopes (the `/v1` gateway opened them on 2026-10-27): call `https://api.tino.vn/v1/service/<service id>/<service>/…` for the customer's own services.

| Scope | What the customer sees on the consent page | Routes | Guide |
| --- | --- | --- | --- |
| `tinopay:read` | View TinoPay: receiving accounts, transactions, webhooks | 16 | [Getting started with TinoPay](/en/docs/tinopay-start) |
| `tinopay:write` | Manage TinoPay: virtual accounts, webhooks, notifications, Sapo integration | 21 | [Getting started with TinoPay](/en/docs/tinopay-start) |
| `builder:read` | View TinoPage Builder websites | 6 | [Getting started with TinoPage Builder](/en/docs/builder-start) |
| `builder:write` | Manage TinoPage Builder websites: create, publish, custom domains | 7 | [Getting started with TinoPage Builder](/en/docs/builder-start) |
| `waf:read` | View websites protected by TinoWAF and the event log | 8 | [TinoWAF](/en/docs/waf) |
| `waf:write` | Manage TinoWAF: websites, protection level, origin servers, IP rules | 11 | [TinoWAF](/en/docs/waf) |

`tinopay:owner` (create TinoPay API tokens, link receiving bank accounts) works like `domains:owner`: personal API tokens with an IP restriction only.

Request the fewest scopes you need: the customer sees each line on the consent page. `domains:owner` (get the EPP code, change the domain registrant) and `domains:registrant` ([registrant dossier of waiting domains](/en/docs/domain-registrant)) are only for [personal API tokens](/en/docs/personal-tokens); apps are never granted them. The three order commands that can charge the balance (`POST /v1/carts/{uuid}/confirm`, `POST /v1/order`, `POST /v1/order/{product_id}`) need both `orders:write` and `billing:write`.

Each route, its parameters, request and response examples: [Calling the Tino API](/en/docs/api).

## Common errors

`/oauth/token` and `/oauth/revoke` return errors as `{"error": "<code>", "error_description": "<English sentence>"}`. `/oauth/userinfo` and the `/v1` gateway add the header `WWW-Authenticate: Bearer error="<code>"`.

| Endpoint | HTTP | `error` | Cause | Fix |
| --- | --- | --- | --- | --- |
| `/oauth/token` | 401 | `invalid_client` | wrong `client_id` or `client_secret`, app disabled | check the configuration; do not send the secret via HTTP Basic |
| `/oauth/token` | 400 | `invalid_grant` | wrong code, older than 60 seconds, already used, different `redirect_uri` or `code_verifier`; access was revoked | send the customer through sign-in again from step 1 |
| `/oauth/token` (refresh) | 400 | `invalid_grant` | refresh token expired, revoked or reused (which also revokes access) | send the customer through sign-in again |
| `/oauth/token` (refresh) | 400 | `invalid_scope` | the narrowed `scope` exceeds the granted scopes | the old refresh token stays usable |
| `/oauth/token` | 400 | `unsupported_grant_type` | `grant_type` other than `authorization_code` and `refresh_token` | — |
| `/oauth/userinfo`, `/v1/*` | 401 | `invalid_token` | the access token is wrong, expired, or the customer revoked access | refresh the token; if still 401, sign in again |
| `/oauth/userinfo`, `/v1/*` | 403 | `insufficient_scope` | the token lacks the route's scope (UserInfo: missing `openid`) | request more scopes in step 1 |
| `/v1/*` | 403 | `ipnotallowed` | the calling IP is blocked by Tino | contact Tino |
| consent page | — | `access_denied` (to `redirect_uri`) | the customer clicked Deny | tell the customer, let them try again |
| consent page | — | `invalid_scope` (to `redirect_uri`) | unknown scope or outside the app's scopes | fix `scope` |

Business errors of each `/v1/*` route (for example, a service that does not exist) keep the UserAPI response, usually HTTP 200 with `{"error": ["<code>"]}`.

## References

- [Tino OpenID discovery](https://api.tino.vn/.well-known/openid-configuration): endpoints, scopes, claims running in production
- [Protected resource metadata](https://api.tino.vn/.well-known/oauth-protected-resource) (RFC 9728): for MCP clients; see [Connect Claude, ChatGPT to Tino (MCP)](/en/docs/mcp)
- [JWKS](https://api.tino.vn/oauth/jwks): public keys for verifying ID tokens
- [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) token revocation
- [RFC 8252](https://www.rfc-editor.org/rfc/rfc8252) native apps
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
