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).
| Item | Value |
|---|---|
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 behalf | https://api.tino.vn/v1/<route> |
Device code grant_type | urn: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
- The CLI calls
POST /oauth/device_authorization→ gets adevice_code(keep it secret) and auser_code(show it to the customer). - The CLI prints: "Open https://auth.tino.vn/oauth/device and enter the code LPMR-SKST" — or opens
verification_uri_complete(code included). - The customer signs in to Tino (if needed), enters the code, sees the app name and the requested permissions, and clicks Allow or Deny.
- Meanwhile the CLI polls
POST /oauth/tokeneveryintervalseconds with thedevice_codeuntil it gets a token, is denied, or the code expires. - The CLI calls
https://api.tino.vn/v1/<route>withAuthorization: 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"
}| Field | Rule |
|---|---|
client_name | name shown to the customer when entering the code; at most 100 characters |
grant_types | among authorization_code, refresh_token, urn:ietf:params:oauth:grant-type:device_code |
redirect_uris | required with authorization_code: https://, http://localhost / 127.0.0.1 / [::1] (any port) or the app's own scheme (myapp://callback); no # |
scope | space-separated permissions (permission table in Sign in with Tino); empty = every permission an app may request |
logo_uri | optional, https:// |
token_endpoint_auth_method | none 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:
| Parameter | Value |
|---|---|
client_id | your client_id |
scope | space-separated permissions; empty = the app's permissions. Add offline_access to get a refresh token |
client_secret | confidential 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
}| Error | Meaning |
|---|---|
401 invalid_client | client_id unknown or disabled, or wrong client_secret |
400 unauthorized_client | the app does not have device code sign-in enabled |
400 invalid_scope | unknown 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:
| Parameter | Value |
|---|---|
grant_type | urn:ietf:params:oauth:grant-type:device_code |
device_code | the device_code from step 1 |
client_id | your client_id |
client_secret | confidential clients only |
While waiting, Tino returns HTTP 400 with error:
error | What the CLI does |
|---|---|
authorization_pending | the customer has not answered yet: wait interval seconds and poll again |
slow_down | polling too fast: add 5 seconds to the wait and keep the new interval |
access_denied | the customer clicked Deny: stop |
expired_token | more than 10 minutes passed: request a new code (step 1) |
invalid_grant | wrong 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_tokenexpires (expires_in86400 seconds, 24 hours):POST /oauth/tokenwithgrant_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/revokewithtoken=<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.jsonwith mode600(owner read/write only), or in the operating system keychain. - Never print tokens or the
device_codeto 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_accesswhen 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
- RFC 8628 — OAuth 2.0 Device Authorization Grant
- RFC 7591 — OAuth 2.0 Dynamic Client Registration
- Sign in with Tino (OAuth 2.0, OpenID Connect)
- Personal API tokens: customers create a token for their own scripts, no app registration needed