Platrium Docs
ArchitectureAuthentication

Native Clients

How the iPhone app, the CLI and your scripts sign in, and why a token is just a token.

Browsers sign in with a cookie. Everything else (the iOS and macOS apps, a future Android app, the CLI, a FUSE mount, a CI script) signs in with a bearer token. There is one kind of token and one way to get it, whatever identity provider the user belongs to.

One token, optionally a device

A token is a random secret, plt_ followed by 256 bits, stored as a SHA-256 hash in auth_tokens. The secret is shown exactly once.

The only thing that makes a token a device is a link to a devices row:

ClientHas a devices row?Push notifications?Example
DeviceYes (name, platform, app_version, push token)YesPlatrium for iPhone
AppNoNoPlatrium CLI, a FUSE mount, a CI script

The two are bound together: deleting the device deletes its token, and revoking the token deletes its device. There are no revoked flags or half-alive states. If the row is gone, the credential is gone.

Signing In

Every client uses the same flow: the OAuth authorization-code grant with PKCE, reduced to what Platrium needs. The user signs in on the web with whichever identity provider they normally use (local password, and later OIDC or SAML, including MFA). Platrium never needs per-provider code for native clients.

Generate code_verifier, code_challenge = SHA256(verifier) Open /authorize?redirect_uri&code_challenge&state&name[&platform] Load consent page Not signed in? Normal login, then back here User presses Allow POST /api/auth/authorize (cookie session) redirect_to = redirect_uri?code=...&state=... Navigate to redirect_to Callback with one-time code POST /api/auth/token { code, code_verifier } { token, device_id? } Store token in the keychain Native app / CLI System browser Platrium web Engine
  • The consent page (/authorize in the web app) sits behind the normal login redirect, so a user who is not signed in logs in first and lands back on the approval screen.
  • POST /auth/authorize needs a browser session. A token cannot approve another client, so a leaked token cannot mint more tokens.
  • If the request includes platform (IOS, MACOS, ANDROID, ...), the client becomes a device. Without it, it becomes an app.

Why a code instead of the token in the redirect?

A custom URL scheme like platrium:// is not private to one app: another app can register the same scheme and receive the redirect. The redirect therefore only carries a single-use code that lives for 5 minutes in the KV store (namespace acod, keyed by the hash of the code). To turn it into a token the client must also present the code_verifier, which never left the app, so an interceptor cannot redeem a stolen code. The code is burned on the first attempt, right or wrong.

Where the code can be sent

redirect_uri is validated before a code is created:

  • a custom app scheme (platrium://callback; the iOS, macOS and Android apps use platrium://auth/callback)
  • http on a loopback host with any port (127.0.0.1, [::1], localhost), used by the CLI and FUSE drivers that listen for the callback
  • https URLs starting with an entry of AUTH_NATIVE_HTTPS_REDIRECTS (comma separated), used for universal links on iOS and app links on Android

Everything else, such as javascript:, file: or http://example.com, is refused.

CLI and FUSE

The CLI opens the browser at /authorize with redirect_uri=http://127.0.0.1:<port>/callback, listens on that port, and exchanges the code, the same way gh and gcloud do. Headless machines and CI create a token by hand under Devices & Apps and pass it as a bearer token. An RFC 8628 device grant is not implemented; if added later it would mint the same kind of token.

Using a Token

GET /api/auth/me
Authorization: Bearer plt_...

session.Bearer (after session.Middleware) validates the token and puts the same PlatriumSession in the request context that a cookie would, with the tenant read from the token row. Authorization, resolvers and actor.Principal do not know the difference. session.AuthInfoFromContext says which credential was used (SESSION, DEVICE or APP).

  • An invalid or revoked token gets 401 and never falls back to a cookie.
  • GraphQL subscriptions send {"Authorization": "Bearer ..."} in the connection_init payload.
  • Upload and download passports are a separate, short-lived mechanism and are unchanged.
  • POST /auth/token is rate limited per IP.

Endpoints

EndpointAuthPurpose
POST /api/auth/authorizebrowser sessionApprove a client, returns the redirect with the code
POST /api/auth/tokennone (rate limited)Exchange code and verifier for a token
GET /api/auth/clientssession or tokenList devices and apps, flags the current one
POST /api/auth/clientsbrowser sessionCreate an app token by hand
DELETE /api/auth/clients/{id}session or tokenRevoke a device or app
PUT / DELETE /api/auth/device/pushdevice tokenRegister or clear the APNs or FCM token
POST /api/auth/logoutsession or tokenDelete the calling token, or destroy the cookie session
GET /api/auth/mesession or tokenCurrent user, auth_kind and device_id

Push Registration

After signing in, a device registers where to reach it:

PUT /api/auth/device/push
Authorization: Bearer plt_...

{ "transport": "APNS", "token": "<apns device token>" }

The token is stored on the device row (push_transport, push_token, marked sensitive). The device is the one that owns the calling token, so a client can only ever set its own push token. The notification broker will read these when it fans events out to devices.

Not Yet

  • Browser sessions are not listed. They are still in-memory SCS sessions. Listing them under Devices & Apps needs a persistent session store.
  • No scopes. Tokens act as the user. A scopes column can be added when something needs narrower tokens.
  • Rate limiting covers /auth/token only. The password login endpoint is not limited yet.

On this page