Skip to content

OAuth for assistants

API tokens authenticate a program you run. OAuth answers a different question: how does an assistant you did not write, running on someone else’s infrastructure, get permission to read your inventory, and how do you take that permission back?

The answer is an OAuth 2.1 authorization server in the NextPKI console. You sign in, you see who is asking, you decide, and you can revoke it later from Connected applications.

Read only. certs:read, and nothing else. This is not a default you can widen with a parameter: the authorization server rejects any other scope, and the database enforces it as a constraint on the grant row. An assistant cannot open a renewal, order a certificate, change settings or spend money.

If you want automation that renews, that is an API token with certs:renew, held by software you operate. The two paths are deliberately separate: one is a person delegating reading to a third party, the other is your own system acting for you.

Endpoint Norm Purpose
GET /.well-known/oauth-authorization-server RFC 8414 Discovery. Start here; do not hardcode the rest
GET /oauth/authorize OAuth 2.1 Authorization request and the consent screen
POST /oauth/token OAuth 2.1 Code to token, refresh, token exchange
POST /oauth/introspect RFC 7662 Token state, for resource servers only

The MCP server publishes GET /.well-known/oauth-protected-resource (RFC 9728), which is how a client discovers which authorization server to talk to.

Four things are mandatory, and requests without them are rejected rather than silently accepted with a weaker guarantee.

PKCE with S256. code_challenge_method=plain is not offered, because it does not protect anything. The metadata advertises code_challenge_methods_supported: ["S256"] so a client can verify support before starting.

A resource parameter (RFC 8707) naming the resource the token is for. Without it the server would not know what to bind the token to, so it refuses to guess. The token is then valid only for that resource, which is the point.

A client_id that is an HTTPS URL pointing to a Client ID Metadata Document. The server fetches your metadata there and reads client_name and redirect_uris from it. There is no registration endpoint and no registration form.

An exact redirect_uri from that document. Matching is exact, never by prefix, because prefix matching is the classic open redirect: https://good.example.com would also accept https://good.example.com.attacker.test. Schemes are limited to https, or http for localhost during development.

Dynamic Client Registration is not offered

Section titled “Dynamic Client Registration is not offered”

RFC 7591 is deprecated in the MCP authorization specification and retained there only for backwards compatibility. Client ID Metadata Documents are the supported path: no registration database, no mass-registration abuse to defend against, and your client identity is a URL you already control.

  1. Your client discovers the authorization server from the protected resource metadata.
  2. It sends the user to /oauth/authorize with client_id, redirect_uri, resource, scope=certs:read, state, and the PKCE challenge.
  3. Not signed in? The console signs the user in first, then returns to exactly that request.
  4. The consent screen names the application, shows the host it will send the user back to, explains what the access allows, and asks which organisation’s data is being shared when the user belongs to more than one.
  5. On approval the user returns to your redirect_uri with code, state and iss. The iss parameter (RFC 9207) lets you detect a mix-up attack; check it.
  6. Your client exchanges the code at /oauth/token with the PKCE verifier and gets an access token and a refresh token.

The consent screen shows the redirect host on purpose. A familiar application name pointing at an unfamiliar host is what a phishing attempt looks like, and only the person approving can tell the difference.

Artefact Lifetime Notes
Authorization code 2 minutes Single use
Access token 15 minutes Short by design; a leak is worth little
Refresh token 30 days Rotated on every use
Exchanged service token 5 minutes Never leaves the MCP server

Presenting an authorization code twice, or a refresh token that has already been rotated, is treated as evidence that someone else has it. The response is not just an error for that one request: the entire grant falls, along with every token issued under it.

This follows RFC 9700 and it has a consequence worth designing around. A client that crashes after rotating and then retries with the old refresh token will lose its access and the user will have to approve again. Store the rotated token before you use it, and treat invalid_grant on refresh as “start a new authorization”, not as “retry”.

Why your access token does not work against the API

Section titled “Why your access token does not work against the API”

An access token issued for the MCP server carries that server as its audience. If you present it to api.nextpki.com, you get 401, and that is correct: the token was not issued for the API.

Internally the MCP server exchanges it (RFC 8693) for a short-lived token whose audience is the API, authenticating itself as a confidential client to do so. That exchange is recorded in the audit log as oauth.token.exchange, so “which assistant read what, on whose behalf, and when” is an answerable question.

The alternative, passing your token straight through, is forbidden by the MCP specification for exactly this reason: the API could no longer tell whether it was the intended recipient.

You do not implement any of this. It matters only because it explains why a token that works fine against the MCP server returns 401 from the API.

In the console, Connected applications:

  • Konto → Verbundene Apps shows the access you granted. Any role can reach it, because any member can grant it.
  • Settings → Verbundene Anwendungen shows every grant in the organisation, for owners and admins.

Each entry names the application, its client identity, the resource, the rights, when it was granted, when it was last used and how many tokens are currently valid. That last-used column is the one that tells you whether an access is still needed.

Revocation is immediate and takes the tokens with it. An assistant does not keep reading until its access token happens to expire. Every revocation is written to the audit log as oauth.grant.revoke.

  • Discover, do not hardcode: read the protected resource metadata, then the authorization server metadata.
  • Generate a fresh PKCE verifier per authorization request.
  • Send resource in both the authorization request and the token request.
  • Verify iss on the authorization response.
  • Verify state.
  • Ask for certs:read. Asking for more fails rather than being trimmed, so you find out immediately instead of wondering why a call returns 403.
  • Persist the rotated refresh token before using it.
  • Treat invalid_grant on refresh as “the grant is gone”, not “retry”.