Files
Manuel Emilio Urena dd0fc9e44f feat(auth): LinkConnect scope, OAuth introspection and a public-client revoke fix for Civitai Link (#4571)
* feat(auth): add the LinkConnect token scope (bit 27)

Opt-in like the App Blocks bits: excluded from Full, from every preset, from
tokenScopeGrid and from SENSITIVE_TOKEN_SCOPES, so no existing key is widened.
ALL_SCOPES becomes (1<<28)-1, which is what lets the hub decode the Civitai
Link desktop mask 159383553 instead of clamping it to 0.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* feat(auth): add RFC 7662 token introspection to the hub

POST /api/auth/oauth/introspect, for first-party services that receive an
opaque civitai_ access token and cannot verify it offline. Gated twice: the
caller must be a confidential client AND on OAUTH_INTROSPECTION_CLIENT_IDS.
Every token miss answers 200 {active:false} so the endpoint never confirms a
token exists. Own rate-limit bucket keyed on client_id; no CORS, no-store.

Registered in the hub endpoint ledger, which asserts an exact set precisely so
a new endpoint has to be looked at rather than landing unnoticed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* feat(auth): advertise introspection in OIDC discovery

introspection_endpoint + introspection_endpoint_auth_methods_supported, and
the first test of the discovery document (it also pins that scopes_supported
is derived from tokenScopeLabels, so a new scope bit needs no edit here).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* fix(auth): charge the introspect client bucket only after auth

The pre-auth rate limit was keyed on client_id, which is attacker-supplied
until the secret check passes — so a flood of bad credentials against a
guessed id could exhaust the real client's 60/min budget and block Civitai
Link pairing. Pre-auth work now charges a per-IP introspect-anon bucket
(120/min, above the per-client limit so it never binds first for a legitimate
caller); the client-keyed bucket is charged only once auth succeeds.

Also correct rate-limit.ts's identifier policy for revoke (documented
per-client, actually per-IP) and drop a comment claiming the route has no
CORS and no OPTIONS — hooks.server.ts adds both for /api/auth/* on an
allowlisted origin.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* feat(auth): register the civitai-link-desktop OAuth client (manual-apply SQL)

Public device-grant client, allowedScopes 159383553 (UserRead|VaultRead|
VaultWrite|LinkConnect), owner -1, accessMode open. No authorization_code: the
desktop app has no deep-link handler. A unit guard pins the SQL literal against
the enum, because nothing else checks a hand-typed bitmask in a migration.

Apply by hand AFTER the hub deploy and BEFORE the desktop release. We never run
prisma migrate deploy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(auth): document token introspection and Civitai Link pairing

oauth-developer-docs gains a Token Introspection section, the 429/no-slow_down
note on device polling, the opt-in-scope caveat under the scope table, and the
endpoint + rate-limit rows. centralized-auth-app's introspection reservation
now names link-service instead of "a non-JS app". New docs/features/civitai-link.md
covers both pairing flows, the instance model incl. the key-length rule, and the
site's polling contract; linked from the Core Systems Reference table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* test(oauth): declare the civitai-link-desktop allowedScopes grant

The population guard enumerates every migration whose live SQL writes
OauthClient.allowedScopes off the migrations tree and reconciles it against
DECLARED_MIGRATIONS, so registering civitai-link-desktop left it red until the
grant is declared. The declared mask 159383553 (UserRead | VaultRead |
VaultWrite | LinkConnect) is not a strict superset of Full, so
blocks.router.getMyAppAnalytics keeps its no-regression argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(auth): fix three false claims in the Link and OAuth docs

civitai-link.md put the "link-service half ships in its own PR" disclosure 35
lines below the pairing flow it qualifies, so a reader following the Core Systems
row read steps 3-4 as callable today; hoist it under the heading and drop the
present tense from the installId column and the second create path.

oauth-developer-docs.md called the civitai.com OAuth paths legacy 308-redirect
shims. REDIRECT_ENDPOINTS is {'authorize'} only — everything else is a
transparent reverse proxy, deliberately, because a cross-origin redirect strips
Authorization. A reader would have inferred a 308 for introspect, whose auth is
a Basic header.

Same section claimed X-RateLimit-Limit/-Remaining/-Reset are returned; the hub
sets none of them on any endpoint. State the 429 rate_limited response instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* feat(auth): let a public OAuth client revoke a token it holds

The Civitai Link desktop app signs out by posting its refresh token and
client_id to /api/auth/oauth/revoke, but it is a public client: it has
neither a session cookie nor a secret, so it never satisfied the endpoint's
authenticated-user gate. Revocation was skipped and the endpoint returned
200 anyway (RFC 7009 says never reveal whether a token existed), leaving a
30-day refresh token live behind a sign-out that looked successful.

Add one authorisation path alongside the existing two: a registered,
non-confidential client may revoke a token whose clientId is its own. RFC
7009 §2.1 — the presented token is itself the credential, so this grants
nothing the caller did not already hold. The confidential path still
requires a verified secret, a token belonging to another client is still
left alone, and every path still answers a bare 200.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(auth): state the rate-limit bucket key per endpoint

"Token endpoint: 20/min per client" was wrong for the endpoint it named —
token/+server.ts charges the token bucket on the client IP. The sweep of every
checkOAuthRateLimit call site found the revoke bullet wrong the same way, and
three keyed buckets documented nowhere.

One bucket now has one bullet, with its identifier stated: /token per IP;
/device and /device-token per client id, sharing one budget; authorize per user;
revoke per IP; introspection per client id plus the pre-auth per-IP bucket.
/session is left out — first-party BFF, absent from the Endpoints table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(auth): describe the revoke endpoint's three auth paths accurately

The line added with the public-client revoke change said a token is revoked
"only for the client it was issued to (a confidential client must also send
client_secret)". That is wrong in both directions for the confidential path:
a proven secret authenticates the client as its OWNER ACCOUNT, so it revokes
tokens that account holds whichever client issued them, and it cannot revoke
a token it issued to a different user.

Describe the public-client, confidential-client and session-cookie paths
separately, each by what it actually keys on. No behaviour change.

Also trims the guard comment to the half the code does not already state,
and extends the identical-response test to compare headers as well as status
and body — they are input-derived today, and comparing them is what keeps an
outcome from leaking into one later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* fix(auth): introspect a disabled account's token as inactive

The innerJoin on User caught only a hard-deleted row, and account deletion
here is soft (user.service.ts sets deletedAt + status, and leaves ApiKey
alone), so /introspect answered {active: true} for a token /userinfo already
401s — every other hub path that resolves a user from a token goes through
produceSessionUser, which filters deletedAt. That answer is the sole
authorization input link-service has before it mints a durable Link instance
key, and the desktop app holds a 30-day refresh token, so the exposure was
not bounded by the 1-hour access TTL.

bannedAt is filtered too. The hub does not enforce bans at the token layer in
general — it ships bannedAt on the SessionUser and lets consumers decide,
deliberately, so a banned user can still log in and read why. The exception is
legacy-exchange, which refuses a banned user because it MINTS something.
Introspection gates a mint of exactly that kind, in a service that cannot see
bannedAt at all, so the ban has to bind here or nowhere.

The test's db fake made this invisible: innerJoin was a no-op. It now resolves
joins with INNER semantics and scopes column references per table, username
moves to the User fixture so every assertion on it is evidence the join ran,
and the deleted/banned/no-user-row cases are pinned with a control that clears
the offending column and expects the same token to go live. Reverting either
where() fails 2 tests; reverting the join fails 5.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(link): trim the pairing internals, add the rollout prerequisites

Four things, all in the public Civitai Link doc.

The "key length is load-bearing" passage published link-service's pairing
rules — the short-key threshold, the keyspace, and what the socket does when
the two do not agree. This repo is world-readable, and the rule is to say what
a control does, not what defeats it. The shape survives; the numbers do not.

The Status note listed no rollout prerequisites, which existed only in an
untracked file: the client row is applied by hand and only after the deploy
that ships LinkConnect, link-service's confidential client is registered out
of band because its secret cannot live in a migration, and its id goes in the
hub's OAUTH_INTROSPECTION_CLIENT_IDS — which fails closed, so unset or stale
gives every introspection call a flat 401 that reads like a bad secret.

The branch registers civitai-link-desktop with an empty allowedOrigins, and
/token and /revoke answer 403 origin_not_allowed to a public-client request
carrying an Origin header. Pairing is unaffected (the device endpoints have no
such gate), so the constraint is now written down where it bites: refresh and
sign-out revocation must come from the Electron main process, which sends no
Origin.

The Design record pointed into docs/superpowers/, which is gitignored — a dead
link for every reader of a public repo and every teammate who clones. It now
points at the ClickUp task.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* docs(auth): correct the token bucket's identifier in rate-limit.ts

The header said "token: per-client (20/min)", but token/+server.ts charges
that bucket on the client IP; /device and /device-token are the two that key
it on client_id, sharing one budget across every install of an app. 4163027fc6
corrected the same claim in the shipped developer doc, so the code comment and
the doc were contradicting each other.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WAJA2T7boHxHCWrR2ze2Vu

* style(auth): format the Civitai Link OAuth client migration test

CI's added-file Prettier gate failed on this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJGfGDV6AbhrZ6ga5gx2cX

* fix(auth): bound the device flow per caller instead of per client id

/device and /device-token both charged the `token` bucket keyed on client_id.
A device-flow client id is public and identical across every install of an app,
so that was a single 20/min budget for the entire fleet — while /device-token is
polled every DEVICE_POLL_INTERVAL (5s) for up to the 15-minute code lifetime.
One in-flight sign-in spends ~12 of those 20, so roughly one user could sign in
at a time; past that /device answers 429 and the desktop app surfaces a generic
failure. It also let anyone who knew the public id exhaust the budget for
everyone.

/device now charges a per-IP bucket. /device-token charges a coarse per-IP
ceiling first — device_code is attacker-supplied until the redis lookup, so that
bounds how many buckets one caller can create — then a per-device_code bucket,
which is one sign-in attempt and is what RFC 8628's interval/slow_down mechanism
is scoped to.

The two new tests fail on a revert to client_id rather than passing quietly:
verified by reverting both call sites and watching exactly those two go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJGfGDV6AbhrZ6ga5gx2cX

* feat(auth): register the link-service introspection client without a secret

link-service has to present a confidential client to /introspect, but the
account-settings path stamps the creating user as the owner and mints a random
uuid — which cannot be named in OAUTH_INTROSPECTION_CLIENT_IDS ahead of time and
ties an internal service to a person. This follows the civitai-link-desktop
precedent instead: a stable literal id owned by the system account, registered
by a manual-apply migration.

The secret is deliberately absent. It is set per environment by hand, so it
never enters this repo — and because the stored value is salted with that
environment's NEXTAUTH_SECRET, a value from one environment would not work in
another anyway. Until it is set the row exists and cannot authenticate:
introspection reads `!client.secret` and answers invalid_client, so the gap
fails closed rather than 500ing.

grants is empty, so no bearer token can ever be minted for this client; it only
authenticates itself. That also keeps allowedScopes moot at 0, well clear of the
superset-of-Full case the scope-grants guard exists to catch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJGfGDV6AbhrZ6ga5gx2cX

* feat(auth): let a user deny a device authorization

The consent screen offered only Approve. Refusing meant closing the tab and
leaving the device to poll until the code expired fifteen minutes later.

Most of this already existed: `status: 'denied'` was modeled in the device-code
store, and device-token already answered `access_denied` for it. Only the write
path was missing. Adds POST /api/auth/oauth/device-deny, mirroring
device-approve — including the single-EVAL HSET + HPEXPIRE, so a denied code
cannot linger without a TTL — plus a `deny` form action, a Deny button, and a
denied state on the page.

A denied code is never stamped with a userId, so it cannot mint a token.

Also corrects the civitai-link-desktop client description, which said "Stable
Diffusion" and implied Link avoids downloading.

NOTE: that migration is ON CONFLICT DO NOTHING, so an environment which already
has the row needs the description updated by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJGfGDV6AbhrZ6ga5gx2cX

* test(auth): register device-deny in the hub endpoint ledger

`admin-route-ledger` pins the exact set of `+server.ts` endpoints in the app,
so the new device-deny route failed it until listed. That is the ledger doing
its job — it exists so a new endpoint cannot land without someone judging
whether it is privileged.

Caught by CI rather than locally: `pnpm run check` and the scope-grants test
were run before pushing, but not `test:apps:run`, which is the suite that
covers this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJGfGDV6AbhrZ6ga5gx2cX

* refactor(auth): create the introspection client from the UI, not a migration

The `civitai-link-service` row was registered by a committed migration with
`secret = NULL`, and the secret then set per environment by hand. Creating the
client through the hub's own UI instead mints and hashes the secret in that
environment, so no plaintext is generated by hand and prod's NEXTAUTH_SECRET
never leaves prod. Two form-only gaps are closed by one UPDATE afterwards: the
id (the form mints a uuid) and `grants`, which no schema exposes.

That leaves the migration not merely redundant but a trap. Migrations here are
applied by hand, and applying this one takes the `civitai-link-service` id — so
the rename that follows the UI create fails on the primary key. Deleting it also
restores docs/features/civitai-link.md, which already says no file in this repo
creates this client.

A guard replaces it: the migration tree is scanned and any file naming
`civitai-link-service` fails, with the reason. Verified by restoring the
migration — that test and the scope-grants population guard both go red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SXNkjkuNqxWjoBCTrse2zm

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 13:30:14 -04:00
..
@
2026-06-10 16:49:50 -06:00

@civitai/auth

Framework-agnostic auth for Civitai apps — JWT/session verification, the rich-user session client, and the spoke guard that first-party *.civitai.com apps use to gate themselves. The decision logic is pure (operates on a cookie header string), so each app's adapter is ~5 lines.

For the full mental model (hub vs spoke, tiers, the spoke→hub contract), read docs/auth/spoke-integration-guide.md. This README is the package reference.

Add to an app

// package.json
"@civitai/auth": "workspace:*"

Transpile (raw TS): Next transpilePackages: ['@civitai/auth'], Vite ssr.noExternal: ['@civitai/auth']. @civitai/redis, jose, zod come in transitively.

Env

All optional in the schema, but the spoke guard functionally needs:

Var Purpose
AUTH_JWT_ISSUER hub origin — verifies the JWT iss and builds the login redirect
AUTH_JWKS_URI hub public keys for local ES256 verification
AUTH_INTERNAL_TOKEN service secret for INTERNAL-authed read-through to the hub (/api/auth/identity)

Local dev against a local hub: point the two URLs at http://localhost:5173. See src/env.ts for the rest (signing keys, session max-age — hub-only).

Use — spoke guard (gate a first-party app)

import { createSpokeGuard } from '@civitai/auth';

export const guard = createSpokeGuard({ require: (u) => u.isModerator === true });
// guard.check(cookieHeader, returnUrl) -> { status: 'ok'|'login'|'forbidden', ... }

Then a tiny framework adapter (SvelteKit hooks.server.ts / Next proxy.ts) acts on the result: login → redirect to hub, forbidden → 403 or app-specific redirect, ok → set locals.user.

Other exports: createSessionClient (token→user, invalidate/refresh), createAuthVerifier, createDeviceAccountClient (account switching), createImpersonationClient, hubLoginUrl/hubLogoutUrl, and the SessionUser / SessionClaims types. Browser-safe constants are under @civitai/auth/client.

Gotchas

  • Redis is optional but coupled: the session client reads the shared session cache via @civitai/redis and fails open to a hub identity fetch when redis is absent. But if you set REDIS_URL you must also set REDIS_SYS_URL@civitai/redis's env load requires both (a partial config throws, caught as fail-open, so you silently lose the cache). See @civitai/redis.
  • No revocation without redis: omitting the isRevoked injection makes the gate signature+expiry only (a logged-out/banned token still resolves until expiry). Wire a redis client + isRevoked for real-time revocation.
  • Same registrable domain (*.civitai.com) → the session cookie is shared automatically; no login UI, no OAuth bridge, no cookie-domain config needed in a spoke.

Reference implementation: apps/moderator (src/lib/server/auth.ts + src/hooks.server.ts).