# OAuth provider and consent

Auth remains app-owned. Install the existing optional Better Auth peers and
create your `betterAuth()` instance with `jwt({ disableSettingJwtHeader: true })`
and `oauthProvider` (or the provider's MCP preset). Append `oauthGrantExtension`
**first** in its `extensions`. The helper comes from
`@aotter/mantle/auth/oauth` and binds issued access tokens to the current consent
ID and Better Auth’s consent reference without changing `referenceId`. Add
`oauthGrantRevocation()` from the same module to the plugins: it ends a user's
grants on a password reset or admin action (below), and `mantle generate --auth`
refuses an OAuth provider without it.
Configure
`consentPage: "/oauth/consent"`, loginPage,
resources, allowed/default client registration scopes, and public/native DCR
explicitly. For MCP clients use the SDK's `mcp` preset, `allowPublicClientPrelogin:
true`, and a `90d` registration client-secret expiration. The application selects
plugins and options; Mantle does not wrap Better Auth or run runtime DDL.

With Mantle's native adapters, `oauthProvider.resources` comes from runtime
configuration, including environment-derived identifiers. `schema.sql` contains
auth DDL only. On the first OAuth lookup of a configured resource (authorization,
token issuance, registration defaults or audience checks), the adapter validates
and inserts the missing row with insert-if-absent semantics. Concurrent requests
are safe; subsequent lookups preserve persisted policy edits. No resource deploy
step is required, and `resourceSeedMode` does not overwrite existing rows.
To retire a resource, set its persisted `disabled` policy (or configure it disabled
before its first lookup). Removing configuration does not disable an existing row.
Do not delete a configured resource row: a later lookup in a new adapter instance
can recreate it from configuration. On
`/oauth2/authorize` an invalid configured identifier surfaces as the provider's
`invalid_target` redirect rather than HTTP 500.

Auth initialization, cookie/bearer resolution and normal MCP/REST requests perform
no resource SELECT or INSERT. The adapter suppresses the provider's eager and
bulk lazy seeding. Invalid configured identifiers produce HTTP 500 with
`AUTH_CONFIGURATION_ERROR` and no write; unconfigured identifiers keep the
provider's normal rejection. Validation follows upstream's identifier validator
(or absolute URI without a fragment by default); unsupported signing algorithms
are dropped before insertion.

```ts
import { authRoutes } from "@aotter/mantle/auth/routes";
import { authLoader } from "@aotter/mantle/auth";
const auth = authLoader(() => import("./auth.js").then(m => m.auth));
const routes = authRoutes(auth, {
  methods: [{ kind: "email-otp" }],
  connectedAppsPage: "/admin/connected-apps", // optional; otherwise plain HTML
});
// In fetch: await routes(request) ?? otherRoutes(request).
```

`GET /api/auth/methods` exposes only the supplied method descriptions and does
not load auth. `basePath` must match Better Auth if changed. Better Auth owns all
`/api/auth/*` endpoints and RFC 8414/9728 well-known discovery routes. Mount those
well-known paths at the origin, before a catch-all or proxy; retain the external
issuer/resource URL and trusted forwarding rules. The SDK handler receives them
unchanged. A POST registration whose redirect URIs are all HTTP localhost,
127.0.0.1 or [::1] becomes `application_type: "native"` when omitted; explicit
types and mixed/external callbacks stay under the SDK's validation.

With the optional Admin UI selected, use `consentPage:
"/admin/oauth/consent"`, as in `docs/examples/app-shell/src/auth.ts`, and keep
`adminShell` mounted in the service entry. That page uses mantle-ui's `AuthCard`,
cards and sign-in buttons; it loads the verified request from
`/oauth/consent/data` and posts the original signed payload to `/oauth/consent`.
The UI displays the client, callback origin, requested scopes and background
access explanation. Keep Core's script-free `/oauth/consent` HTML as the
adapter-neutral fallback, not as the reference application's styled page.

`/oauth/consent` and `/oauth/consents` serve escaped, script-free English or zh-TW
HTML. Their `/data` routes serve the 0.2 UI JSON shapes. Consent approval and
`POST /oauth/consents/revoke` require a live cookie session and same-origin
mutation; bearer credentials cannot grant or disconnect apps. Consent completion
uses only the provider-validated signed query and redirect. All responses are
private/no-store. Connected-app redirects must be same-origin paths.

`resolveCaller(request, loader, { audience, scopes })` verifies token signature,
issuer, audience and request-bound DPoP through Better Auth, including a durable
single-use proof reservation. It then checks the live consent and session and
loads the current database user.
All of sub, azp, mantle_consent_id and the consent reference must match, and so
must sid while the token stands on its session (an `offline_access` token may
outlive it; see below). The consent must cover the resource and every token
scope. `scopes` adds a resource-specific scope floor (403 when missing).
Unbound legacy tokens fail closed. Disconnect deletes the grant first, revokes
its access/refresh tokens. Tokens issued without an active consent fail closed.
First-party `skip_consent` tokens instead require a live, enabled client with
`skipConsent` and allowed scopes, plus the same session or `offline_access`
rule as consented tokens. Already-issued
JWTs fail on the next call, and reconsenting cannot revive the old JWT lineage.

Grant lifetime follows Better Auth. Better Auth issues a refresh token only when
the grant includes `offline_access`; a client registered for `authorization_code`
may use `refresh_token` too, so registering only `authorization_code` does not
prevent long-lived grants.
CIMD is the primary client-discovery path; dynamic client registration (DCR)
remains available for older MCP clients. Both paths require the client to request
`offline_access` and the user to consent before a renewable grant is issued.
The DCR registration defaults below apply only to that compatibility path.

For renewable MCP connections, the examples set both `scopes` and
`clientRegistrationDefaultScopes` to `["mcp", "offline_access"]`, so a dynamically
registered client that omits `scope` is registered for `mcp offline_access`. The
client must request those registered scopes during authorization and the user must
consent to background access. If the client explicitly requests only `mcp`, OAuth
must not add a scope the user did not consent to: the resulting grant is bound to
the browser session and has no refresh token. The client must request
`offline_access`, or the user must reconnect and authorize it, to gain renewable
access. Declaring the `refresh_token` grant type alone is insufficient.
`offline_access` belongs in authorization-server metadata, not in the protected
resource's required scope floor; resource metadata continues to advertise `mcp`.

For renewable connections, the app-shell's MCP handler also supplies an explicit
OAuth linking hint:

```ts
const handleMcp = mcp({
  db: (env: Env) => env.DB,
  caller: resolveRequestCaller,
  authorizationScopes: ["offline_access"],
});
```

`authorizationScopes` adds scopes to the standard `WWW-Authenticate` challenge
when the caller is unauthenticated. With the default `requiredScopes`, that
challenge requests `mcp offline_access`. It does **not** change the API scope
floor, add scopes to a valid token, issue tokens or modify an already-signed
authorization request. An existing valid `mcp`-only token can still call a tool
that only requires `mcp`. The application must independently permit
`offline_access` in its provider and the user must explicitly consent.
Verify the actual requested scopes, refresh-token issuance and post-expiry
rotation in the intended client. Do not put `offline_access` in the resource's
required scope floor or silently add it after consent.

For an existing connection granted only `mcp`, deploy the allowed-scope change,
refresh the client's discovery/registration metadata, then reconnect or reauthorize
once with `offline_access` and obtain updated consent. Allowing a scope does not
add it to a stored consent. Do not edit consent rows, prolong access-token TTLs or
disable grant checks to avoid that step. Verify a refresh token was issued and
that the client can rotate it and resume MCP after access expiry; a successful
initial `tools/list` does not prove renewable authorization. The reference service's
local smoke exercises issuance, refresh after website sign-out, rotation retry
and revocation using real OAuth endpoints and a disposable D1.

Without `offline_access` the grant is bound to the browser session: its tokens
stop working when that session expires or the user signs out. A refresh that
narrows the scope to drop `offline_access` after the session ends returns
`invalid_grant`, so it never yields an unusable token. An `offline_access` grant
outlives the browser session. Expiry, deletion and sign-out of the session leave
it usable until its current refresh token expires or is revoked. Revocation
includes disconnect, refresh-token revocation and Better Auth's reuse detection.
Each refresh rotates the token with a fresh `refreshTokenExpiresIn` (30 days by
default), so an offline grant that keeps refreshing does not expire. Without a live session, its access tokens need an
unrevoked, unexpired `offline_access` refresh token for the same user and client.
The consent, client, scope, resource and ban checks still apply. An access token
issued before sign-out stays valid until it expires (one hour by default).
`oauthGrantRevocation()` ends every OAuth grant of the user once one of these
succeeds: revokes their refresh and access tokens, deletes their consents for
every client (the next authorization asks again, except for a first-party
`skip_consent` client, which never asks) and their unredeemed
authorization codes. The events and the sessions each one ends:

| Event | Sessions ended |
| --- | --- |
| Password reset (`/reset-password`, `/email-otp/reset-password`, `/phone-number/reset-password`) | all |
| Admin password change (`/admin/set-user-password`) | all |
| Password change with `revokeOtherSessions` | all; Better Auth gives the current device a new session |
| Admin ban (`/admin/ban-user`, or `/admin/update-user` setting `banned: true`) | all (Better Auth) |
| Admin session revocation (`/admin/revoke-user-sessions`) | all (Better Auth) |
| Email OTP or magic-link sign-in (`/sign-in/email-otp`, `/magic-link/verify`) that proves the address of an unverified user | all, and its password and linked accounts (Better Auth) |

The last row covers account pre-hijacking: someone who signed up with another
person's address before it was verified loses their OAuth grants as well as the
password, linked accounts and sessions that Better Auth strips when the owner
proves the address. Mantle ends these grants before Better Auth checks the code or
link, while the user is still unverified, so a failed OTP or magic-link attempt on an
unverified user also ends that user's OAuth grants.

Otherwise a failed attempt, session expiry, a password change without `revokeOtherSessions`,
and the user's own sign-out, `/revoke-sessions` and `/revoke-other-sessions`
("sign out everywhere") end no OAuth grant, as in Better Auth; the user ends a
connected app's grant by disconnecting it on the connected-apps page
(`/oauth/consents`). Unbanning does not restore grants. An access token issued
before the event is rejected on its next use, with one exception: an
`offline_access` access token of a first-party `skip_consent` client passes again,
until it expires (one hour by default), once the user grants that client offline
access anew, because it is checked against any live `offline_access` refresh token
of the same user and client. Better Auth's `/oauth2/introspect` reports a refreshed
token inactive once its original session has expired, while Mantle's resource
check accepts it; Mantle does not use introspection.
Database failures remain server errors, rather than silently authenticating.

For app-owned API keys/personal tokens, supply `resolveCredential(request)` in
`resolveCaller` options. It runs before OAuth/cookies; return a verified user
Caller or null when absent, and throw when presented but invalid. The same scope
floor applies. Any non-Bearer/DPoP Authorization header (including Basic from a staging
gateway), unless handled by the app-owned credential hook, is UNAUTHENTICATED
and never falls back to a cookie. The application owns credential verification;
Mantle loads live roles, bans and declared stored fields.

For the 0.2 legacy-DCR cleanup policy, call `pruneLegacyOAuthClients(auth)` from
an application-owned hourly scheduled job. It deletes only ownerless,
non-CIMD/non-reference clients older than 90 days; it runs no boot-time work.

When configuring session secondary storage, set `verification.storeInDatabase: true` and `session.storeSessionInDatabase: true`: live grants and durable DPoP single-use reservations must use the database, never a non-atomic session KV cache.

Auth routing also guards Better Auth’s raw `/api/auth/admin/*` endpoints (including percent-encoded admin paths): only a fresh primary-database `admin` cookie session may enter, and mutations require same origin. A stale admin snapshot in session KV cannot bypass demotion, ban or session deletion. App-owned auth mounts can reuse `guardAuthAdminRequest` from `@aotter/mantle/auth/admin` with a resolver that reads current authority.

Use primary database sessions without session secondaryStorage as the simple
safe configuration. If optional session caching is enabled, use the
**authority-bound** secondary-storage adapter from the identity/auth template
(`@aotter/mantle/auth/hardening`, #1499): every snapshot must be checked against the primary session and current
user before the SDK can read it, including raw SDK endpoints and session lists.
Arbitrary session KV snapshots are outside this secure configuration: middleware
may trust stale roles or refresh an expired primary session from a snapshot.
Merely setting storeSessionInDatabase true does not fix those SDK endpoints.
Mantle's caller and raw-admin guard validate primary authority before refreshing,
but do not make arbitrary SDK session caching safe. Keep verification
consumption and DPoP single-use reservations in the database.
