Build on Anahita
OAuth scopes and clients
How scopes are defined and enforced, and how first-party clients are seeded.
The OAuth routes are in the API reference: auth-service, from /oauth/authorize and /oauth/token on.
Scopes
Scope identifiers are defined once, in Go, at
anahita-libs/constants/oauth_scopes.go. Every permission check must
reference an OAuthScope* constant — never a string literal. make test
runs a guard that walks the whole workspace and fails on any
HasScope("...") / RequireScope("...") with a literal argument.
That guard exists because the fleet once carried three generations of
scope names simultaneously. Since HasScope is a plain slice lookup, a
literal matching no constant fails closed and silently — the gate returns
"insufficient scope" forever, for everyone, with nothing in the logs
naming the cause. The compiler catches a mistyped constant; only the
guard catches a reintroduced literal.
Scopes describe API surface. Authority (admin / super-admin) is a
separate role check inside each handler's permission method, layered on
top of the write scope. There is deliberately no admin:* scope tier.
A scope family covers its sub-resources rather than giving each one its
own scope: actors:* covers avatars, covers, access, features and group
admins; media:* covers photos, articles, notes, topics and their
hashtag/location tags. Reads of public content need no scope at all — a
read scope gates the authenticated-visibility uplift, not the public
surface — which is why taxonomy has no read half.
A service with no family of its own borrows the one its actions belong to.
Filing an abuse report needs account:write: it is something a person does
on their own account's behalf. Reading and deciding reported cases needs
actors:read and actors:write, as the signup queue does.
The current catalog: openid, profile, email, offline_access,
actors:read, actors:write, media:read, media:write,
likes:write, socialgraph:read, socialgraph:write,
notifications:read, notifications:write, taxonomy:write,
account:read, account:write, oauth:read, oauth:write.
There is no scope for replies. A reply is a post, written under media:write.
comments:write was removed with comments: a client registered with it has to
be registered again without it, and the seeded clients are in
auth-defaults/oauth_seed_clients.json.
oauth:* is the sole super-admin-only family. A token carrying
oauth:write is equivalent to owning the identity provider — its holder
can add a redirect URI to any client and harvest authorization codes for
every person on the site.
Seeding clients
auth-defaults/oauth_seed_clients.json bootstraps the first-party
clients (anahita-web, anahita-mobile) on first boot.
The seed file is bootstrap-only, not a source of truth. A client_id
that already exists is left untouched, so editing scopes in this file
and restarting does nothing — the only signal is a single
already exists, skipping log line. That is the right default: it
guarantees a redeploy never silently reverts an edit made through the
/clients admin endpoint.
To apply a changed shipped scope list:
docker compose exec mysql mysql -uroot -proot anahita \
-e "DELETE FROM oauth_clients WHERE client_id = 'anahita-web';"
docker compose restart auth-service
This removes the client's row and nothing else: the schema has no foreign
keys, so its refresh tokens and authorizations stay. They name the client by
its client_id, so when the restart seeds the client again, they work with it
as before, and nobody is signed out. On a live site, PATCH /clients/:id
instead.
DELETE /clients/:id is different: it revokes the client's refresh tokens and
deletes its authorization codes, then the client. Everybody signed in with it
is signed out within 15 minutes, when their access token expires, and a client
made later under the same client_id starts with no tokens.
Scopes are validated against the catalog both at boot (a typo fails
startup) and at the /clients BREAD boundary (a bad scope returns 400
naming it).
Where the code is
| What | Where |
|---|---|
| The scope catalog | anahita-libs/constants/oauth_scopes.go (OAuthScopeDefinitions, OAuthScopeExists) |
| The guard against scope literals | anahita-libs/constants/oauth_scopes_guard_test.go (TestNoScopeStringLiterals, TestOnlyOAuthScopesAreSuperAdminOnly) |
| Checking requested scopes | auth-service/data/services/oauth_scopes.go (ValidateScopes, ValidateScopesForClient) |
| Seeding clients | auth-service/data/services/oauth_seed.go (SeedDefaultClients), auth-defaults/oauth_seed_clients.json; setting OAUTH_SEED_CLIENT_PATH |
The /clients endpoints |
auth-service/cmd/api/handlers/oauth_clients.go (DeleteClient, tested in oauth_clients_test.go), auth-service/cmd/api/permissions/oauth.go |
| Clients and what hangs off them | tables oauth_clients, oauth_authorizations, oauth_refresh_tokens |
From anahitasocial/anahita-services, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this