ruchern.dev Docs

OAuth Provider Integration

How a client application authenticates users and calls protected ruchern.dev routes using the OAuth 2.1 / OIDC provider.

ruchern.dev is its own OAuth 2.1 / OIDC provider. A client application authenticates a user through the Authorization Code flow with PKCE, receives a JWT access token, and sends that token as a bearer against protected routes such as POST /api/usage/ingest.

The provider is implemented with the @better-auth/oauth-provider plugin (oauthProvider) paired with the jwt() plugin in apps/web/src/lib/auth.ts. Public clients (no secret) are supported, and clients self-register via dynamic client registration, so no manual provider-side configuration is required to onboard a new client.

Discovery

Everything a client needs to configure itself is published at the discovery document.

GET /api/auth/.well-known/openid-configuration
https://ruchern.dev/api/auth/.well-known/openid-configuration

The JSON Web Key Set used to verify JWT access tokens is published at:

GET /api/auth/jwks

MCP clients can discover the authorization server from the MCP API's RFC 9728 protected-resource metadata, which is also advertised in the WWW-Authenticate header on a 401/403 from /api/mcp:

GET /.well-known/oauth-protected-resource

Endpoints

PurposeEndpoint
Discovery/api/auth/.well-known/openid-configuration
Authorize/api/auth/oauth2/authorize
Token/api/auth/oauth2/token
UserInfo/api/auth/oauth2/userinfo
Dynamic client registration/api/auth/oauth2/register
Token introspection/api/auth/oauth2/introspect
JWKS/api/auth/jwks

Supported Behaviour

  • Authorization Code flow with PKCE — PKCE (S256) is required for all clients.
  • Public clients — clients with token_endpoint_auth_method: "none" (no client secret) are supported.
  • Dynamic client registration — clients register themselves at /api/auth/oauth2/register; unauthenticated registration is allowed.
  • Consent — the user approves the requested scopes at /consent before a code is issued.

Scopes

ScopeMeaning
openidVerify the user's identity (issues an ID token).
profileRead basic profile information.
emailRead the user's email address.
offline_accessIssue a refresh token so the client can stay signed in.
mcpAccess the MCP API (/api/mcp). Required for any token used against MCP tools; an identity-only token is rejected with 403 insufficient_scope.

Client Integration

1. Register the client

Register once at the dynamic client registration endpoint. A public client uses token_endpoint_auth_method: "none" and declares its redirect URI.

curl -X POST "https://ruchern.dev/api/auth/oauth2/register" \
  -H "Content-Type: application/json" \
  -d '{
    "client_name": "My Client",
    "redirect_uris": ["http://localhost:8765/callback"],
    "token_endpoint_auth_method": "none",
    "grant_types": ["authorization_code", "refresh_token"],
    "response_types": ["code"]
  }'

The response includes a client_id. Persist it; a public client has no secret to store.

2. Generate a PKCE pair

Create a random code_verifier and derive the code_challenge with SHA-256.

code_challenge = BASE64URL(SHA256(code_verifier))
code_challenge_method = S256

3. Send the user to authorize

Open the authorize URL in a browser. Pass resource (RFC 8707) set to the API base URL so the issued access token is a JWT verifiable via JWKS. The user signs in (if needed) and approves the scopes at /consent.

GET /api/auth/oauth2/authorize
  ?response_type=code
  &client_id=<client_id>
  &redirect_uri=<redirect_uri>
  &code_challenge=<code_challenge>
  &code_challenge_method=S256
  &scope=openid%20email%20mcp
  &resource=https://ruchern.dev
  &state=<state>

Include the mcp scope when the token will be used against the MCP API (/api/mcp); without it the MCP route rejects the token with 403 insufficient_scope.

After approval the provider redirects to the client's redirect_uri with code and state.

4. Exchange the code for tokens

curl -X POST "https://ruchern.dev/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=<code>" \
  -d "redirect_uri=<redirect_uri>" \
  -d "client_id=<client_id>" \
  -d "code_verifier=<code_verifier>" \
  -d "resource=https://ruchern.dev"

Pass resource in the token request body so Better Auth mints a JWT access token verifiable against the JWKS. Without it an opaque token is returned, which cannot be verified locally.

The response contains an access_token (JWT), and a refresh_token if offline_access was requested.

5. Call protected routes

Send the access token as a bearer.

curl -X POST "https://ruchern.dev/api/usage/ingest" \
  -H "Authorization: Bearer <access_token>" \
  -H "Content-Type: application/json" \
  -d '{ "rows": [] }'

6. Refresh the token

When offline_access was granted, exchange the refresh token for a new access token.

curl -X POST "https://ruchern.dev/api/auth/oauth2/token" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=<refresh_token>" \
  -d "client_id=<client_id>" \
  -d "resource=https://ruchern.dev"

Pass resource on every refresh request. Better Auth only mints a JWT access token when resource is present; omitting it returns an opaque token that cannot be verified locally.

How the Server Validates a Token

Protected routes call validateMcpAuth (apps/web/src/lib/api/mcp-auth.ts), which tries three strategies in order:

  1. Better Auth session — cookie or session bearer for browser/admin callers.
  2. OAuth access token — the JWT is verified locally against the provider's JWKS via serverClient.verifyAccessToken (apps/web/src/lib/server-client.ts), with audience set to NEXT_PUBLIC_BASE_URL. The token's issuing client (azp) is checked against the oauthClient table and rejected if it has been disabled, then the owning user and role are loaded by the token sub.
  3. Static MCP token — the legacy BLOG_MCP_AUTH_TOKEN bearer, deprecated and marked for deletion; retained only until headless clients migrate to OAuth.

Because the JWT carries the resource/audience as its aud, a client must pass resource at the authorize step for its token to validate against an API protected this way. The azp claim carries the client id and scope carries the granted scopes.

The MCP API additionally requires the mcp scope: /api/mcp returns 403 insufficient_scope for an authenticated OAuth token that does not carry it, and 401 (both with a WWW-Authenticate challenge advertising the required scope and the protected-resource metadata URL) when no valid token is supplied.

Access and refresh tokens are stored hashed in the provider's database.

Client Checklist

  • Register a public client and store the returned client_id.
  • Generate a fresh PKCE code_verifier/code_challenge per authorization.
  • Pass resource=https://ruchern.dev (or the target API base URL) at authorize and on every token refresh so the issued access token is a verifiable JWT.
  • Request offline_access if the client needs to refresh without re-prompting.
  • Persist tokens securely; treat the JWT as opaque other than honouring its expiry.

Source Files

  • apps/web/src/lib/auth.ts configures the oauthProvider and jwt() plugins.
  • apps/web/src/lib/server-client.ts exposes serverClient.verifyAccessToken for local JWKS verification.
  • apps/web/src/lib/api/mcp-auth.ts validates incoming bearers on protected routes.
  • apps/web/src/app/consent/ and apps/web/src/components/auth/consent-form.tsx render the consent screen.
  • apps/web/src/schema/auth.ts holds the oauthClient, oauthAccessToken, oauthRefreshToken, oauthConsent, and jwks tables (generated via pnpm auth:generate).

On this page