Authentication
OAuth 2.1 with PKCE and dynamic client registration for MCP clients, bearer tokens for scripts, and cookie sessions for the web app.
Three credentials, one server. MCP clients run OAuth 2.1 against fermium.app; scripts use a static bearer token; the web app uses a session cookie.
OAuth 2.1 for MCP clients
fermium.app is both the MCP resource server (/mcp) and its authorization server. A conforming client needs no configuration beyond the server URL.
1. Discovery
An unauthenticated request to /mcp returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://fermium.app/.well-known/oauth-protected-resource/mcp"
Content-Type: application/json
{"error":"unauthorized","detail":"authenticate via the MCP OAuth flow (your client should open a browser), or supply 'Authorization: Bearer dsk_...'"}
If a token was sent and rejected, the challenge also carries error="invalid_token".
Protected-resource metadata (RFC 9728), at /.well-known/oauth-protected-resource and /.well-known/oauth-protected-resource/mcp:
{
"resource": "https://fermium.app/mcp",
"authorization_servers": ["https://fermium.app"],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"]
}
Authorization-server metadata (RFC 8414), at /.well-known/oauth-authorization-server and /.well-known/oauth-authorization-server/mcp:
{
"issuer": "https://fermium.app",
"authorization_endpoint": "https://fermium.app/oauth/authorize",
"token_endpoint": "https://fermium.app/oauth/token",
"registration_endpoint": "https://fermium.app/oauth/register",
"revocation_endpoint": "https://fermium.app/oauth/revoke",
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"],
"scopes_supported": ["mcp"]
}
The metadata, register, token and revoke endpoints answer OPTIONS preflights and send Access-Control-Allow-Origin: *, so browser-based clients can drive the flow with fetch.
2. Dynamic client registration (RFC 7591)
curl -X POST https://fermium.app/oauth/register \
-H "Content-Type: application/json" \
-d '{"client_name":"My agent","redirect_uris":["http://127.0.0.1:53211/callback"]}'
{
"client_id": "ahc_9Qx...",
"client_id_issued_at": 1757952000,
"client_name": "My agent",
"redirect_uris": ["http://127.0.0.1:53211/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "mcp"
}
Clients are public: no secret is issued, PKCE is mandatory instead. Rules for redirect_uris: 1 to 10 entries, each up to 512 characters, no fragment, no javascript:, data:, file:, vbscript: or blob: schemes, and http: only for loopback hosts (localhost, 127.0.0.1, [::1]). Registration is rate limited per IP.
3. Authorization
Open the browser to:
https://fermium.app/oauth/authorize
?client_id=ahc_9Qx...
&redirect_uri=http%3A%2F%2F127.0.0.1%3A53211%2Fcallback
&response_type=code
&code_challenge=<BASE64URL(SHA256(code_verifier))>
&code_challenge_method=S256
&scope=mcp
&state=<random>
The client_id and redirect_uri are checked first; a mismatch is a plain 400, never a redirect. Other errors (unsupported_response_type, invalid_request for missing or non-S256 PKCE, access_denied) redirect back to redirect_uri with error and error_description.
If the browser has no session, the user is sent through Google sign-in and returned here. The first authorization for a given client shows a consent page with a connection-duration choice naming the client and the account; a POST with action=approve records consent. The default is 30 days, with choices up to 365 days. Enterprise policy can enforce a shorter maximum. Later authorizations reuse the original deadline; prompt=consent asks for a new choice.
On success the browser lands on redirect_uri?code=...&state=.... Codes are single-use and expire after 300 seconds.
4. Token exchange
curl -X POST https://fermium.app/oauth/token \
-d grant_type=authorization_code \
-d client_id=ahc_9Qx... \
-d code=... \
-d code_verifier=... \
-d redirect_uri=http://127.0.0.1:53211/callback
{
"access_token": "dsk_3f9a1c2b_...",
"token_type": "Bearer",
"expires_in": 2591999,
"refresh_token": "dsr_7b0d...",
"scope": "mcp"
}
The body is form-encoded. client_id, redirect_uri and the PKCE verifier must match the code. Access tokens live up to 30 days, capped at the selected authorization deadline. Refresh tokens remain valid only within that original authorization period and are rotated on every use:
curl -X POST https://fermium.app/oauth/token \
-d grant_type=refresh_token -d client_id=ahc_9Qx... -d refresh_token=dsr_7b0d...
The response has the same shape with a new access token and a new refresh token; the old access token is revoked. Token responses carry Cache-Control: no-store.
5. Using the token
POST /mcp HTTP/1.1
Host: fermium.app
Authorization: Bearer dsk_3f9a1c2b_...
Content-Type: application/json
6. Revocation (RFC 7009)
curl -X POST https://fermium.app/oauth/revoke -d token=dsk_3f9a1c2b_... -d client_id=ahc_9Qx...
Accepts an access or refresh token and returns 200 even for unknown tokens. Revoking a token in the app (Settings, then API keys and tokens) also revokes the grant, so the client cannot refresh.
Error shape
OAuth endpoints return RFC 6749 errors:
{ "error": "invalid_grant", "error_description": "PKCE verification failed" }
Errors you may see: invalid_client_metadata, invalid_redirect_uri, invalid_client (401, unknown client_id), invalid_grant (bad or expired code, mismatch, failed PKCE, bad refresh token), unsupported_grant_type, temporarily_unavailable (429).
Headless tokens
For CI and scripts without a browser, mint a static token in the app under Settings, then API keys and tokens, or with POST /api/tokens from a signed-in session. Tokens look like dsk_<8 hex prefix>_<secret>; the default lifetime is 365 days, 0 means never, and the plaintext is shown once. Only a SHA-256 hash is stored; lookup is by prefix and a constant-time compare.
Send it as Authorization: Bearer dsk_... to /mcp or to the REST endpoints under /api, which honour it when the request carries no session cookie. Access tokens from the OAuth flow are the same kind of token, so anything that accepts one accepts the other. Every use updates last_used_at, visible in the app.
Revoke with the app or POST /api/tokens/{id}/revoke. Revocation is immediate.
Cookie sessions for the web app
Signing in with Google at /auth/login sets a __Host- prefixed session cookie on fermium.app: HttpOnly, Secure, SameSite=Lax, 90-day lifetime, signed with the server secret. The /api/* endpoints read this cookie. State-changing requests (POST, PATCH, PUT, DELETE) to /api/*, /auth/* and /oauth/authorize are rejected with 403 when an Origin header is present and is not https://fermium.app.
POST /auth/logout clears the cookie. Sessions are not stored server-side and cannot be revoked individually.
The content host uses a separate __Host- prefixed viewer cookie, valid for 12 hours, set only after the workspace sign-in handoff. It never leaves the content host.