App authentication

The device flow — how a client with no credentials gets a token by asking a human — plus PKCE, rotation and revocation.

The app signs in with a device flow: it opens a login, shows the user a code, and polls until they approve it in a browser.

1. Open a login

POST/api/app/v1/auth/device

Unauthenticated by necessity — the app has no credentials at this point.

{
  "client": { "name": "TMC Desktop", "platform": "windows", "version": "1.4.0" },
  "codeChallenge": "<PKCE S256 challenge, base64url>"
}
{
  "deviceCode": "…",
  "userCode": "…",
  "verificationUri": "https://moddingcommunity.com/login/device",
  "verificationUriComplete": "https://moddingcommunity.com/login/device?code=…",
  "expiresIn": 600,
  "interval": 5
}

The client info is self-reported and untrusted — it is a label for the approval screen, nothing more.

verificationUriComplete is what the app actually opens; verificationUri is where the user goes when the code has to be typed.

What bounds this endpoint is its rate limit (10/minute, counted durably because this is the front door) and the grant’s own 10-minute lifetime: a flood of starts creates a flood of rows that expire on their own and that nobody can approve.

2. Poll

POST/api/app/v1/auth/token

Returns the credential pair exactly once, on the first call after approval.

{ "deviceCode": "…", "codeVerifier": "<PKCE verifier>" }

Every other outcome is an error envelope whose code the app branches on:

code Status Means
authorization_pending 202 Waiting for the human. Not a failure
slow_down 202 Polling too quickly
access_denied 400 The device was not approved
expired_token 400 The attempt expired. Start again

Pending and slow-down are 202, not 4xx

Waiting on a human is not a client error. Naming them as codes keeps the app from having to guess from a status code.

On success:

{
  "accessToken": "…",
  "refreshToken": "…",
  "expiresIn": 3600,
  "user": { "id": "…", "name": "…", "username": "…", "avatar": "…", "role": "…" }
}

Two rules that make polling safe

The grant is consumed atomically

Redemption is a conditional update from approved to claimed; only the update that actually changed a row mints a token. Two concurrent polls therefore cannot both succeed — not a theoretical race, since the app polls on a timer and on a deep-link wake.

PKCE is checked before anything is minted

A device code alone is inert, so one read off a screen or scraped from a log buys nothing.

3. Refresh

POST/api/app/v1/auth/refresh

Rotate the credential pair.

Unauthenticated in the bearer sense: the refresh token is the credential, and requiring a live access token alongside it would make refresh impossible in the one situation it exists for.

4. Revoke

POST/api/app/v1/auth/revokeToken

Sign this device out.

Revokes the whole lineage rather than the presented pair, so a sign-out is final: a refresh token the app still holds in its keychain cannot resurrect the session after the user asked for it to end.

The web player handoff

POST/api/app/v1/auth/handoff

Turn a web-player handoff code into a game session. Unauthenticated in the bearer sense — the code is the credential.

This is the endpoint that ends “sign in again, inside the canvas”. A member already signed into the site presses Play, the boot descriptor carries a single-use code, and the game’s loader posts it here to get a credential for the player it is about to seat.

What bounds it is what the code is: one use, sixty seconds, one member, one app — plus a durable rate limit, because it mints credentials and a cache outage must not turn it into an unmetered one.

A game session is bound to one app, so an app it does not agree with in a body is refused rather than obeyed.

Identity

GET/api/app/v1/meToken

Identity plus the settings that sync.

The app calls this on every launch. It is also how a stale access token is discovered: a token_expired here triggers one refresh-and-retry before the user is shown a signed-out state.

The settings it carries are deliberately a subset of your account settings — ones that only mean something in a browser, or that the app has no interface for, would become dead fields the app is nonetheless responsible for round-tripping correctly. Everything in it is a plain boolean or a short string, so an update is a partial of exactly that shape.