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
/api/app/v1/auth/deviceUnauthenticated 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
/api/app/v1/auth/tokenReturns 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
/api/app/v1/auth/refreshRotate 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.
Reuse signs every device out
A retired refresh token coming back is reported as its own code, because it means something specific: the whole lineage is revoked and every device is signed out. The app shows that as a security notice rather than a generic “please sign in”.
4. Revoke
/api/app/v1/auth/revokeTokenSign 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
/api/app/v1/auth/handoffTurn 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.
What it mints is deliberately not a device token
The redeemer is a loader the app’s operator uploaded, running on our origin — code we did not review. So the pair returned here is a game credential, bound to the app the code was minted for, and every route refuses it unless it has explicitly opted in.
A member’s installs, subscriptions, reviews and profile are all on the other side of that line. What a game token can do is learn who is playing, read and write that player’s own avatar, and file their statistics.
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
/api/app/v1/meTokenIdentity 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.