Client Applications
Build a third party app that signs users in across their Aviato servers using Aviato Identity. Covers app registration, the pairing flow, talking to servers with an identity pass, renewals, and revocation.
This page is for developers building apps that connect to a user's Aviato servers. Phone apps, TV apps, desktop apps, browser extensions, command line tools, all of these can use Aviato Identity so a user signs in once on Aviato Tower and your app immediately has access to every server they are allowed on.
If you are an Aviato user trying to understand what Aviato Identity is, the user facing page is Aviato Tower & Identities. This guide assumes you have read that, and focuses on what your app has to do to participate.
When you want this
Reach for Aviato Identity in your app when:
- You want users to sign in without typing a server address, a username, or a password.
- You want the same sign in to work across every Aviato server they have access to.
- You do not want to handle credentials yourself, and you do not want to be in the path between the user and Aviato Tower at sign in time.
If your app only talks to one server you control, and your users are happy creating a username and password there, you can skip this entirely and use the REST API with API keys. Aviato Identity is optional. See Getting Started for the broader picture.
The short version
- You register your app once on Aviato Tower and receive an app id.
- When a user signs in, your app generates a fresh keypair, asks Tower to start a pairing, and shows the user an 8 digit code (or a deep link).
- The user opens tower.aviato.media/pair, confirms with their passkey, optionally narrows the list of servers they are sharing with you, and approves.
- Tower returns to your app a signed identity pass and the user's list of servers.
- Your app talks to each server directly using the identity pass. Tower is not in the loop after this.
- After 30 days your app silently renews the pass via Tower in the background. After 60 days, if it has not renewed, your app prompts the user to pair again.
That is the whole story. The rest of this page covers each step in detail and gives you the wire formats you need to build an integration without an SDK.
How users see your app
When a user is asked to confirm sign in for your app, Tower shows them three things:
- Your app name and icon (from your registration).
- Whether your app is verified by Aviato, or unverified.
- The server list they will be sharing with you, with a checkbox per server so they can untick any they would rather your app not see.
Unverified apps still work end to end. They just show a yellow "Unverified app" badge so the user knows what they are approving. Verified status is reserved for apps that have gone through a light review with Aviato (mostly: you control the domain you claim to control). Submit a request for verification from the developer area on Aviato Tower once your app is ready to publish.
Be honest in your app name and description. Tower asks the user to consent to your app, and impersonating a different app is grounds for verification revocation.
Registering your app
App registration is free and self serve. You will need an Aviato Tower account to start.
- Sign in to tower.aviato.media.
- Go to Developer → Apps → New app.
- Provide:
- App name. What users see in the consent screen. Keep it short.
- App slug. Lowercase identifier, ASCII only, unique across Tower. Becomes your app id.
- Icon. Square PNG or SVG. Optional but strongly recommended.
- Description. One or two sentences. Shown under the name in the consent screen.
- Website. Public URL where users can learn more.
- Platforms. Web, iOS, Android, tvOS, Apple TV, Windows, macOS, Linux, other. Used only for the icon row on the consent screen.
- Callback URLs. For the browser based flow only. List every origin or custom scheme your app will return users to. Wildcards are not supported.
Tower issues you an app id of the form app_<slug> (for example app_serenity). The app id is public. There is no app secret. Aviato Identity does not need one because the user's passkey, your client keypair, and the master identity key together are sufficient to prove who everyone is at every step.
You can edit any of this metadata later. Changes to the app name or icon take effect immediately. Changes that materially affect what users see (like a rename) may surface a notice on the consent screen for a short period.
The pairing flow
There are two ways a user can pair with your app. Both end with the same payload returned to your app.
Code based pairing (TVs, consoles, terminals)
Use this when your app cannot easily open a web browser, or when the user wants to authorize from a different device.
+--------------+ +----------------+
| Your app | | Aviato Tower |
+--------------+ +----------------+
| |
| 1. POST /api/identity/clients/pair/begin |
| { appId, clientPubKey, deviceName, platform }|
|------------------------------------------------>|
| |
| { requestId, pairingCode, pollUrl, |
| pairingUrl, expiresAt } |
|<------------------------------------------------|
| |
| 2. Display: pairingCode + pairingUrl QR |
| |
| --- meanwhile user opens tower.aviato.media |
| /pair on a phone or laptop, enters code, |
| confirms with passkey --- |
| |
| 3. GET /api/identity/clients/pair/{requestId} |
| (poll every 2 to 5 seconds) |
|------------------------------------------------>|
| |
| { status: "pending" } ... { status: |
| "completed", cert, servers } |
|<------------------------------------------------|
| |
| 4. Persist cert + servers locally. |
| Done. |Display both the code (XXXX-XXXX formatting helps) and a QR for pairingUrl. Users on a phone will scan; users on a laptop will type. Poll with a backoff that does not exceed once every 2 seconds.
Browser based pairing (desktop apps, web apps)
Use this when you can open a URL and have the user return to your app via a redirect or deep link.
+--------------+ +----------------+
| Your app | | Aviato Tower |
+--------------+ +----------------+
| |
| 1. POST /api/identity/clients/pair/begin |
| { appId, clientPubKey, deviceName, platform, |
| callbackUrl } |
|------------------------------------------------>|
| |
| { requestId, browserUrl, expiresAt } |
|<------------------------------------------------|
| |
| 2. Open browserUrl in the user's default |
| browser. The user may already have a Tower |
| session, in which case approval is one tap. |
| |
| 3. Tower redirects user back to callbackUrl |
| with ?requestId=...&result=ok (or error). |
| |
| 4. GET /api/identity/clients/pair/{requestId} |
|------------------------------------------------>|
| |
| { status: "completed", cert, servers } |
|<------------------------------------------------|The callback URL can be an https:// origin you control or a custom scheme registered to your app (com.example.myaviato://pair). Register each one ahead of time on your app page on Tower. Tower will reject any callback URL that does not match a registered entry.
Step by step, with code
The examples below use TypeScript and fetch. They do not depend on an SDK. We expect to publish @aviato-media/tower-sdk for JavaScript and TypeScript later, and Swift and Kotlin packages after that. Until then, this is the lowest level reference. Any language with Ed25519, SHA 256, and HTTP can implement it.
1. Generate a client keypair
Generate an Ed25519 keypair the first time your app runs and store the private half in the most secure storage available on the platform.
- iOS, macOS: Keychain Services, with
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. - Android: Android Keystore.
- Windows: DPAPI or, where available, a TPM backed key store.
- Linux: Secret Service API (libsecret).
- Web: A non extractable WebCrypto key in IndexedDB. (Ed25519 in WebCrypto: use the Ed25519 algorithm where available, or
@noble/curvesas a fallback.) - Plain Node / CLI tools: A file in a user controlled config directory, encrypted with an OS provided secret. Document where it lives.
The key is per device. If a user installs your app on a phone and a laptop, each gets its own. Do not sync the private key between devices.
import { ed25519 } from '@noble/curves/ed25519'
const privateKey = ed25519.utils.randomPrivateKey()
const publicKey = ed25519.getPublicKey(privateKey)
// store privateKey in OS secure storage, keyed by some app local id2. Start a pairing
const towerBase = 'https://tower.aviato.media'
const begin = await fetch(`${towerBase}/api/identity/clients/pair/begin`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
appId: 'app_serenity',
clientPubKey: bytesToHex(publicKey),
deviceName: 'Serenity on iPhone',
platform: 'ios',
// For browser based flow only:
callbackUrl: 'com.example.serenity://pair',
}),
}).then((r) => r.json())
// { requestId, pairingCode, pairingUrl, browserUrl, pollUrl, expiresAt }If you provided a callbackUrl, open browserUrl in the user's browser and wait for them to come back. Otherwise, show pairingCode and a QR of pairingUrl.
3. Wait for completion
Poll pollUrl every 2 to 5 seconds. Stop when the response is completed, error, or when expiresAt is reached. Pairings expire after 10 minutes.
async function waitForPairing (pollUrl: string, expiresAt: number) {
while (Date.now() < expiresAt) {
const res = await fetch(pollUrl).then((r) => r.json())
if (res.status === 'completed') return res
if (res.status === 'error') throw new Error(res.error.code)
await new Promise((r) => setTimeout(r, 3000))
}
throw new Error('pairing expired')
}
const { cert, servers } = await waitForPairing(begin.pollUrl, begin.expiresAt)The shape of cert and servers is described under Reference below. Persist both. The cert is not a secret in the same way the client private key is, but treat it as user data and keep it out of logs.
4. Sign in to each Aviato server
For every server in servers, your app does a one time handshake to get a session token for that server. Tower is not involved.
async function signInToServer (server, cert, privateKey, publicKey) {
// Ask the server for a challenge
const begin = await fetch(`${server.baseUrl}/api/auth/identity-session/begin`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cert }),
}).then((r) => r.json())
// { challenge: '<hex>' }
// Build and sign the assertion
const ts = Date.now()
const payload = canonicalize({
cert,
serverId: server.serverId,
challenge: begin.challenge,
ts,
})
const sig = bytesToBase64Url(ed25519.sign(payload, privateKey))
// Submit
const complete = await fetch(`${server.baseUrl}/api/auth/identity-session/complete`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ cert, serverId: server.serverId, challenge: begin.challenge, ts, sig }),
}).then((r) => r.json())
// { sessionToken, expiresAt }
return { server, ...complete }
}canonicalize is RFC 8785 JSON Canonicalization Scheme (JCS): sorted keys, no whitespace, UTF 8 bytes. Several small libraries implement it. The exact bytes you sign must be the exact bytes the server canonicalizes from the same fields, so do not invent your own canonicalization.
Store each session token alongside its server. From here on, your app talks to each server using its own session token in the Authorization: Bearer ... header. See API Overview for what those endpoints look like.
You can sign in to all the servers immediately on first pair, or lazily the first time the user navigates to each one. Lazy is usually nicer because some servers may be temporarily offline.
5. Handle servers that are unreachable
A server in the user's list may be on a LAN your device cannot reach, may be powered off, or may have been deactivated by its admin. Treat sign in failures per server as recoverable: keep the entry in your list, mark it offline, and retry next time the app comes to the foreground.
You can ask the server to confirm it still recognizes your client at any time with GET /api/auth/identity/me using your session token.
Renewing the pass
Identity passes are valid for 60 days. After day 30 they are eligible for renewal. Until day 60 the existing pass keeps working. Your app should:
- Every time the app comes to the foreground, check
cert.exp. - If
now > cert.iat + 30 days, request a renewal in the background. - On success, replace your stored cert. On
202 pending, retry later. - If
cert.exppasses without a successful renewal, prompt the user to pair again.
async function renewCert (cert, privateKey, publicKey) {
const ts = Date.now()
const payload = canonicalize({ kind: 'cert-renewal', clientId: cert.payload.clientId, currentCertExp: cert.payload.exp, ts })
const sig = bytesToBase64Url(ed25519.sign(payload, privateKey))
const res = await fetch(`${towerBase}/api/identity/clients/${cert.payload.clientId}/renew`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ currentCert: cert, ts, sig }),
})
if (res.status === 202) return { status: 'pending' }
if (!res.ok) throw new Error(`renewal failed: ${res.status}`)
return { status: 'ok', cert: await res.json() }
}A 202 from Tower means the user has not signed in to Tower since their last batch of renewal passes was consumed. The app should retry on a sensible schedule (daily is enough). The user just has to open Tower in a browser somewhere within their next 30 days, and the renewal will become available silently. If they never do, your app falls back to a fresh pair after day 60.
Revocation
A user can revoke any of their devices from Tower at any time. When they do, two things happen:
- Tower refuses any further renewals for that device. The current pass keeps working until
cert.exp. - A signed revocation record is published. The user's other devices may push that record to the servers your app is signed in to; the servers will then reject your session token on the next request.
What this looks like to your app:
- A server returns
401 unauthorizedwith anidentity_revokederror code on a request you expected to work. - Tower returns
403 revokedon a renewal attempt.
The right response in both cases is to clear local state for that user and prompt them to pair again. Do not silently retry; the user took an explicit action.
Cert format reference
The cert your app receives from Tower has two fields:
type IdentityPass = {
payload: string // base64url of canonical JSON bytes (see below)
sig: string // base64url Ed25519 signature by the user's identity key
}payload decodes to canonical JSON (RFC 8785 JCS, keys in lexicographic order):
type IdentityPassClaims = {
v: 1
appId: string // your app id (e.g. "app_serenity")
clientId: string // uuid v4, identifies this app install
clientPubKey: string // hex Ed25519 public key (the key your app generated)
deviceName: string
exp: number // expires at, unix seconds
iat: number // issued at, unix seconds
scope: string[] // ["servers:*"] currently
userId: string // Tower issued opaque user id
userPubKey: string // hex Ed25519 public key (user identity key)
}The appId claim binds the cert to the specific third-party app the user paired through. If you receive a cert whose appId does not match your own, reject it — the user paired through a different app and you should not act on its behalf.
The user-approved server list is not embedded in the cert payload; it is delivered alongside the cert in the same pairing response (see the servers field).
You verify a cert by:
- Decoding
payloadfrom base64url. - Verifying
sig(base64url, 64 bytes) againstpayloadbytes withuserPubKey. - Confirming
clientPubKeymatches the public half of your stored client private key. - Confirming
expis in the future.
You do not need to verify Tower's signature anywhere because Tower does not sign anything in this protocol. Every signature you check is either the user's identity key or your own client key.
Server list format
servers in the pairing response is an array:
type LinkedServer = {
serverId: string // hex Ed25519 public key of the server
baseUrl: string // https URL, or http for LAN servers
name: string // display name set by the user
linkedAt: number // unix milliseconds
}Servers are listed in the order the user has them on Tower. You may filter or reorder for your UI. Persist serverId to keep an entry stable across renames.
Endpoint reference
A compact list of the endpoints you need. All endpoints accept and return JSON.
On Aviato Tower
| Method | Path | Auth | What it does |
|---|---|---|---|
| POST | /api/identity/clients/pair/begin | None | Start a pairing. |
| GET | /api/identity/clients/pair/{requestId} | None | Poll for completion. |
| POST | /api/identity/clients/{clientId}/renew | Signed by client | Renew a pass. |
| GET | /api/identity/revocations?since={ts} | None | Public revocation feed (optional to use). |
On every Aviato server in servers
| Method | Path | Auth | What it does |
|---|---|---|---|
| POST | /api/auth/identity-session/begin | None | Start a session: send your cert, get back a challenge. |
| POST | /api/auth/identity-session/complete | None | Submit signed assertion, get back a session token. |
| GET | /api/auth/identity/me | Bearer session token | Reflects the user info derived from your cert. |
The full REST API of each Aviato server is described at API Overview and at /api/docs on a running server.
Security checklist
A short list of things to get right.
- Generate the client keypair on device. Never receive a private key from a server or another client. Each install is its own identity to the system.
- Store the private key in OS secure storage with a sensible accessibility (after first unlock, this device only).
- Verify the cert when you receive it. A malformed cert is grounds to refuse the pairing.
- Verify the challenge and timestamp the server sends back to you, so a man in the middle cannot replay an old session.
- Pin the Aviato Tower TLS certificate if your platform makes that easy. Tower's pinning rotation policy will be published alongside the SDK.
- Do not log secrets. The client private key never appears in logs. The session token your app holds for each server is a bearer credential too; treat it like a password.
- Honor revocation. When you get a
revokederror, clear state and pair again. - Refuse to act on behalf of a different app id. If your app has app id A and a cert claim says app id B, the user paired through a different app. Reject and pair again.
What an SDK will do for you, later
We plan to publish @aviato-media/tower-sdk for JavaScript and TypeScript first, then native packages for Swift and Kotlin. The SDK is going to wrap:
- Generating and persisting the client keypair using the platform's secure storage.
- The full pairing flow including code display, QR generation, deep link callback handling, and polling.
- Session sign in across every server in the user's list, with retries and offline handling.
- Background pass renewal, with a single
await tower.ensureFreshPass()you can call on app launch. - Revocation handling: a callback you register to be notified the moment a session fails with revoked.
Until those land, this page is the contract. Everything above is stable: changes to the wire format will be versioned and announced in advance.
Getting help
If you are building an app on Aviato Identity, reach out via the developer area on Aviato Tower or the public discussion channels listed there. We are particularly interested in feedback on:
- Platforms and languages we should publish SDKs for first.
- Pieces of the protocol that were unclear when you tried to implement them.
- Apps you have built or are building. We would like to keep a list of known good integrations once verification is open.