Tino Developers
View .md

Sign in from a CLI or an app without a backend

Command-line tools (CLIs), scripts and apps without their own server sign in to Tino the way gh auth login works: the CLI prints a short code, the customer opens https://auth.tino.vn/oauth/device in any browser (computer or phone), enters the code and clicks Allow, and the CLI receives a token. No redirect URI, no client_secret.

Updated: 2026-10-27. Every endpoint is in the discovery document https://api.tino.vn/.well-known/oauth-authorization-server (registration_endpoint, device_authorization_endpoint, token_endpoint).

ItemValue
Register an app (registration_endpoint)POST https://api.tino.vn/oauth/register
Request a device code (device_authorization_endpoint)POST https://api.tino.vn/oauth/device_authorization
Page where the customer enters the code (verification_uri)https://auth.tino.vn/oauth/device
Poll for and refresh tokens (token_endpoint)POST https://api.tino.vn/oauth/token
Revoke tokens (revocation_endpoint)POST https://api.tino.vn/oauth/revoke
Call the API on the customer's behalfhttps://api.tino.vn/v1/<route>
Device code grant_typeurn:ietf:params:oauth:grant-type:device_code
Code the customer enters (user_code)8 letters as XXXX-XXXX, expires after 600 seconds

Apps that have a browser and can receive a redirect (web, mobile, MCP clients) use the Authorization Code + PKCE flow in Sign in with Tino; this page covers everything else.

Device code sign-in flow

  1. The CLI calls POST /oauth/device_authorization → gets a device_code (keep it secret) and a user_code (show it to the customer).
  2. The CLI prints: "Open https://auth.tino.vn/oauth/device and enter the code LPMR-SKST" — or opens verification_uri_complete (code included).
  3. The customer signs in to Tino (if needed), enters the code, sees the app name and the requested permissions, and clicks Allow or Deny.
  4. Meanwhile the CLI polls POST /oauth/token every interval seconds with the device_code until it gets a token, is denied, or the code expires.
  5. The CLI calls https://api.tino.vn/v1/<route> with Authorization: Bearer <access_token>.

Registering an app

There are two ways to get a client_id.

Self-registration (RFC 7591). Call it once at install time or on first run, and store the client_id:

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

Response (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"
}
FieldRule
client_namename shown to the customer when entering the code; at most 100 characters
grant_typesamong authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code
redirect_urisrequired with authorization_code: https://, http://localhost / 127.0.0.1 / [::1] (any port) or the app's own scheme (myapp://callback); no #
scopespace-separated permissions (permission table in Sign in with Tino); empty = every permission an app may request
logo_urioptional, https://
token_endpoint_auth_methodnone only: self-registered apps are public clients without a client_secret

Registering grants nothing: the customer still enters the code and clicks Allow. Each IP address can register at most 10 times per hour (429 too_many_requests). Metadata errors: 400 invalid_client_metadata or 400 invalid_redirect_uri, with an English sentence in error_description.

Tino creates the app. Contact Tino to create an app with "CLI (device code)" enabled; use this when you need a client_secret or a fixed name and logo.

Step 1 — Request a device code

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

ParameterValue
client_idyour client_id
scopespace-separated permissions; empty = the app's permissions. Add offline_access to get a refresh token
client_secretconfidential clients created by Tino only
curl -X POST https://api.tino.vn/oauth/device_authorization \
  -d client_id=YOUR_CLIENT_ID \
  -d "scope=offline_access services:read"

Response (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
}
ErrorMeaning
401 invalid_clientclient_id unknown or disabled, or wrong client_secret
400 unauthorized_clientthe app does not have device code sign-in enabled
400 invalid_scopeunknown permission, or outside the app's permissions

The device_code is the CLI's secret: never print or log it. Only the user_code and the page address are for the customer.

Step 2 — The customer enters the code

Print verification_uri and user_code so the customer can type them on any device; on a machine with a browser, open verification_uri_complete directly. On Tino's page the customer signs in (if needed), sees the app name, the requested permissions and how long the code stays valid, then clicks Allow or Deny. The page reminds the customer to only enter a code they just saw on their own machine.

Step 3 — Poll for the token

Every interval seconds call POST https://api.tino.vn/oauth/token, body application/x-www-form-urlencoded:

ParameterValue
grant_typeurn:ietf:params:oauth:grant-type:device_code
device_codethe device_code from step 1
client_idyour client_id
client_secretconfidential clients only

While waiting, Tino returns HTTP 400 with error:

errorWhat the CLI does
authorization_pendingthe customer has not answered yet: wait interval seconds and poll again
slow_downpolling too fast: add 5 seconds to the wait and keep the new interval
access_deniedthe customer clicked Deny: stop
expired_tokenmore than 10 minutes passed: request a new code (step 1)
invalid_grantwrong or already used device_code: stop

Once the customer clicks Allow, the next poll gets HTTP 200 (each device_code can be exchanged only once):

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

refresh_token is only present when scope includes offline_access.

Step 4 — Call the API

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

The permissions in scope decide which routes you can call: permission and route table in Sign in with Tino, each route in the API Reference.

Refreshing and revoking

  • Refresh when the access_token expires (expires_in 86400 seconds, 24 hours): POST /oauth/token with grant_type=refresh_token, client_id, refresh_token. Each refresh returns a new refresh token and invalidates the old one; store the new one right away. Reusing an old refresh token makes Tino revoke all of the app's access, and the customer has to enter a code again.
  • Sign out: POST /oauth/revoke with token=<refresh_token>, client_id. Customers can also revoke access themselves on the Authorized apps page (https://auth.tino.vn/me/apps).

Parameter details: Sign in with Tino.

Storing tokens safely

  • Store tokens in a per-user file, for example ~/.config/<app-name>/token.json with mode 600 (owner read/write only), or in the operating system keychain.
  • Never print tokens or the device_code to the screen or logs; never pass tokens as command-line arguments (they show up in the process list).
  • Only request the permissions you really need; add offline_access when the CLI must keep working without asking the customer for a code again.

Bash example

Needs curl and jq.

#!/usr/bin/env bash
set -euo pipefail
CLIENT_ID="${TINO_CLIENT_ID:?set 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 "Open $(jq -r .verification_uri <<<"$start") and enter the code $(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 "Sign-in failed: $(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 "Signed in."

Node.js example

Node.js 18+ (built-in fetch), no libraries.

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(`Open ${start.verification_uri} and enter the code ${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(`Sign-in failed: ${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("Signed in.");

Python example

Python 3, standard library only.

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"Open {start['verification_uri']} and enter the code {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"Sign-in failed: {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("Signed in.")

References