OAuth / OIDC Endpoints
Modgud implements the OpenID Connect protocol via OpenIddict 7. Every endpoint is realm-scoped via the host header — each realm has its own issuer, discovery document, JWKS, and token surface.
Cryptographic constraints
| Setting | Value |
|---|---|
| Access-token signing algorithm | RS256 |
| ID-token signing algorithm | RS256 |
| PKCE method | S256 (plain is rejected) |
| Realm signing keys | RSA, one keypair per realm |
The signing keys live in RealmSigningKey Marten documents, rotated on demand from admin. The JWKS endpoint exposes the public set.
Discovery
| Endpoint | Description |
|---|---|
GET /.well-known/openid-configuration | OIDC discovery document for the current realm |
GET /.well-known/oauth-authorization-server | RFC 8414 authorization-server metadata — the same document served at a second path, so a spec-strict MCP client that probes only this alias still discovers the realm |
GET /.well-known/jwks | JSON Web Key Set (for JWT validation) |
Example discovery for realm acme.example.com:
https://acme.example.com/.well-known/openid-configuration→ Returns issuer: "https://acme.example.com" plus the realm's endpoint URLs. Tokens from this discovery are valid only in this realm.
Implemented via RealmIssuerHandler (see OAuth implementation).
The discovery document advertises only enabled scopes that are public-listed (OAuthScope.ShowInDiscoveryDocument = true). The implicit-scope-per-API entries default to false (private), so they don't leak the realm's resource-server inventory. See Concepts: OAuth for the RealmScopesSupportedHandler rationale.
It also advertises pushed_authorization_request_endpoint (PAR) and dpop_signing_alg_values_supported (DPoP).
Endpoint map
All under /connect/..., all realm-scoped via the domain:
| Endpoint | Method | Purpose |
|---|---|---|
/connect/authorize | GET/POST | Authorization endpoint (Code + PKCE). Also accepts a request_uri from /connect/par. |
/connect/par | POST | Pushed Authorization Request endpoint (RFC 9126). Back-channel; returns a one-time request_uri. |
| (client's logout URI) | POST | Outbound: OpenID Connect Back-Channel Logout 1.0 logout token (logout_token= form field) sent to a client's registered URI when a session ends. Discovery: backchannel_logout_supported, backchannel_logout_session_supported. See logout propagation. |
/connect/token | POST | Token endpoint (code exchange, client credentials, refresh, device) |
/connect/userinfo | GET/POST | UserInfo endpoint (claims plus eligible per-Audience resource_access) |
/connect/introspect | POST | Token introspection |
/connect/revoke | POST | Token revocation |
/connect/logout | GET/POST | End-session endpoint (RP-initiated logout) |
/connect/device | POST | Device-authorization endpoint (CLI / TV / set-top boxes) |
/connect/verify | GET | User-verification endpoint for the device flow |
/connect/register | POST | Dynamic Client Registration (RFC 7591). Registration only — there is no RFC 7592 management surface. |
/connect/consent | GET/POST | Consent ticket resolve + decision (the SPA calls these after /connect/authorize redirects it to /consent?ticket=…). A deny decision re-enters /connect/authorize with a deny marker so the client gets a standard error=access_denied redirect, symmetric with an approve. |
/connect/passkey/begin | POST | Anonymous — begin a usernameless WebAuthn assertion ceremony for the urn:cocoar:passkey grant |
/connect/passkey | GET | Bearer-authenticated — list the signed-in token subject's own passkeys |
/connect/passkey/{id} | DELETE | Bearer-authenticated — revoke one of the token subject's own passkeys |
Supported flows
The discovery doc lists grant_types_supported:
| Grant type | Use case |
|---|---|
authorization_code | Standard interactive login (web, SPA, mobile). PKCE required (S256). |
refresh_token | Token rotation. Single-use — each refresh issues a new refresh-token and invalidates the old one. |
client_credentials | Server-to-server. Must be linked to a ServiceAccount (the SA-managed mutation guard rejects free-standing CC clients). |
urn:ietf:params:oauth:grant-type:device_code | Device flow for input-constrained clients. |
urn:cocoar:otp | Native cookieless login/registration via an emailed one-time code — see Native cookieless grants. |
urn:cocoar:magic | Native cookieless login via a magic-link token. |
urn:cocoar:passkey | Native cookieless login via a WebAuthn assertion begun at /connect/passkey/begin. |
The urn:cocoar:* grants are disabled by default (the per-realm/App NativeGrants flag) and are for clients that can't hold a session cookie — no browser redirect, no /connect/authorize round-trip.
response_modes_supported: query, form_post, fragment. response_types_supported: code (no implicit, no hybrid).
Authorization Code + PKCE
Request
GET /connect/authorize?
client_id=acme-web&
redirect_uri=https://acme.example.com/callback&
response_type=code&
scope=openid+profile+email+roles+permissions&
state=<csrf>&
code_challenge=<base64url(sha256(verifier))>&
code_challenge_method=S256If not logged in → 302 to the realm's /login (the cookie auth handler returns 401 outside the OAuth flow; /connect/authorize is the exception that drives the login UX). After successful login and consent → 302 to the redirect_uri with ?code=…&state=….
Token exchange
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
code=…
redirect_uri=https://acme.example.com/callback
code_verifier=…
client_id=acme-web
client_secret=… # for confidential clientsResponse:
{
"access_token": "…", // reference id or JWT (per client choice)
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": "…", // if offline_access requested
"id_token": "…" // if openid requested
}Pushed Authorization Requests (PAR)
RFC 9126. Instead of putting the full authorization request in the browser's address bar, the client pushes it to the back-channel /connect/par endpoint and receives a one-time request_uri. It then sends only client_id + request_uri to /connect/authorize, so request parameters (scopes, resource, redirect_uri, PKCE challenge) never traverse the front channel where they could be logged, tampered with, or leaked via the referrer.
PAR is offered, not required — every realm advertises pushed_authorization_request_endpoint in discovery, but require_pushed_authorization_requests is not set, so direct browser and device flows keep working unchanged. Every client is permitted to use it.
1. Push the request
POST /connect/par
Content-Type: application/x-www-form-urlencoded
Authorization: Basic <base64(client_id:client_secret)> # confidential clients
# or, for a client with a registered JSON Web Key Set (private_key_jwt, RFC 7523):
# client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
# client_assertion=<JWT signed with the client's private key: header typ=client-authentication+jwt,
# iss=sub=client_id, aud=token endpoint, jti, exp ≤ 5 min>
response_type=code
client_id=acme-web
redirect_uri=https://acme.example.com/callback
scope=openid+profile+permissions
resource=https://acme-api.example.com
state=<csrf>
code_challenge=<base64url(sha256(verifier))>
code_challenge_method=S256Public PKCE clients omit the Authorization header and send client_id in the body. Response (201 Created):
{
"request_uri": "urn:ietf:params:oauth:request_uri:6esc_11ACC5bwc014ltc14eY22c",
"expires_in": 90
}2. Authorize with the request_uri
GET /connect/authorize?client_id=acme-web&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3A6esc_11ACC5bwc014ltc14eY22cThe server resolves the stored request, runs login + consent as usual, and redirects to the redirect_uri with ?code=…&state=…. The request_uri is single-use and short-lived; an unknown or expired one is rejected. From here, the token exchange is identical to the Authorization Code + PKCE flow above — the same code_verifier and resource apply.
DPoP (sender-constrained tokens)
RFC 9449. DPoP binds an access token to a public key the client proves possession of, so a leaked token is worthless without the matching private key. The realm advertises the signing algorithms it accepts in discovery as dpop_signing_alg_values_supported (the EC + RSA family: ES256/384/512, RS256/384/512, PS256/384/512).
DPoP is offered, not required by default — a client that presents a proof gets a bound token, one that doesn't gets an ordinary bearer token. Two per-client opt-ins tighten that (set in the client editor or via the admin API): Require DPoP rejects a tokenless request, and Require DPoP nonce additionally demands a server nonce.
1. Present a proof at the token endpoint
The client signs a short-lived proof JWT (typ: dpop+jwt, header jwk = its public key, payload htm/htu/iat/jti) and sends it in the DPoP header of the token request:
POST /connect/token
Content-Type: application/x-www-form-urlencoded
DPoP: <proof-jwt>
grant_type=authorization_code&code=…&code_verifier=…&client_id=…The access token comes back with token_type: DPoP and a confirmation claim binding it to the proof key's RFC 7638 thumbprint:
{ "token_type": "DPoP", "access_token": "…", "cnf": { "jkt": "<thumbprint>" } }A malformed, stale, or replayed proof (jti is single-use within its window) is rejected with error: invalid_dpop_proof.
2. Call the resource server
The client presents the token under the DPoP auth scheme and a fresh proof — this one adds ath (a hash of the access token) and targets the RS's method + URL:
GET /todos
Authorization: DPoP <access-token>
DPoP: <proof-jwt-with-ath>The .NET resource-server library enforces the binding on both token formats: a bound token presented as a plain Bearer, or with a proof whose key doesn't match cnf.jkt, is rejected.
3. Refresh tokens are bound too
A refresh token issued to a DPoP client is bound to the same key. Redeeming it requires a proof for that key — a refresh with no proof, or a proof for a different key, is rejected with invalid_dpop_proof. Rotation preserves the binding.
Server nonces
When Require DPoP nonce is set, a proof must also carry a valid server-issued nonce. The first proof has none, so the token endpoint answers 400 with a DPoP-Nonce response header and error: use_dpop_nonce; the client retries the same request with the nonce embedded in a new proof (the authorization code / refresh token is not consumed by the challenge, so the retry works). A nonce is valid for a few minutes and reused across requests until it lapses, then re-challenged.
Client Credentials
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials
client_id=acme-cron
client_secret=…
scope=billing.readThe client must be linked to a Service Account (LinkedServiceAccountId). The sub claim in the resulting token is the Service Account's id; the SA is treated as a non-human principal that goes through the normal Group→Role→Permission resolver.
Refresh Token
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
refresh_token=…
client_id=acme-web
client_secret=…Single-use with rotation: every use issues a new refresh token and invalidates the old one. Replay attempts return invalid_grant.
Device flow
For CLI tools, set-top boxes, anything without a browser.
1. Device requests a code
POST /connect/device
Content-Type: application/x-www-form-urlencoded
client_id=acme-cli
scope=openid+profileResponse:
{
"device_code": "…",
"user_code": "ABCD-EFGH",
"verification_uri": "https://acme.example.com/connect/verify",
"verification_uri_complete": "https://acme.example.com/connect/verify?user_code=ABCD-EFGH",
"expires_in": 600,
"interval": 5
}2. User visits the verification URL
GET /connect/verify shows a form for the user_code. After login + consent the device-code is approved.
3. Device polls the token endpoint
POST /connect/token
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:device_code
device_code=…
client_id=acme-cliReturns authorization_pending until the user has verified; then a normal token response.
UserInfo
Accepts both GET and POST (per the OIDC spec):
GET /connect/userinfo
Authorization: Bearer <access_token>Returns the claims for the bearer token. It also returns the same Keycloak-shaped resource_access claim as the access-token principal when at least one token audience resolves to a registered OAuth API linked to an App and roles and/or permissions was granted:
{
"sub": "abc123…",
"email": "alice@example.com",
"resource_access": {
"billing-api": {
"roles": ["Editor"],
"permissions": ["invoice:read", "invoice:write"]
}
}
}- The key is the exact OAuth API Audience, not its linked App slug.
rolesis emitted whenscope=roleswas granted.permissionsis emitted whenscope=permissionswas granted, bypass-pre-expanded and narrowed to the matching OAuth API'sPermissionIdssubset.- Audiences that do not resolve to a registered OAuth API with a linked App are skipped. If no eligible block remains, the whole claim is absent.
See Apps and resource_access for the full emission story.
Introspection
POST /connect/introspect
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
token=<token>Returns active: true/false plus the token claims authorized for that introspection caller, including the same audience-keyed resource_access object when eligible. Used by resource servers that hold reference tokens (server-side opaque) to validate them against the issuer.
Revocation
POST /connect/revoke
Authorization: Basic <base64(client_id:client_secret)>
Content-Type: application/x-www-form-urlencoded
token=<token>
token_type_hint=access_token # or refresh_tokenReference tokens become immediately invalid; JWTs can't actually be revoked server-side (they self-validate against JWKS), but their parent authorization is killed so any associated refresh tokens stop working.
Dynamic Client Registration (DCR)
RFC 7591 registration only, scoped to the realm. Disabled by default; enabled per-realm in Realm Settings → Dynamic Client Registration. There is no RFC 7592 management surface — GET/PUT/DELETE on a per-client registration URL is not implemented. The endpoint mints a client and is done; later changes go through the admin UI.
Register a new client
POST /connect/register
Content-Type: application/json
{
"client_name": "Some MCP Server",
"redirect_uris": ["https://mcp.example.com/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid profile",
"token_endpoint_auth_method": "none"
}Response (201 Created — RFC 7591 §3.2.1):
{
"client_id": "dcr_…",
"client_id_issued_at": 1735689600,
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"redirect_uris": ["https://mcp.example.com/callback"],
"client_name": "Some MCP Server",
"scope": "openid profile"
}The response echoes the sanitized registration plus the assigned client_id and client_id_issued_at. No client_secret is issued (public PKCE clients only), and there is no registration_access_token / registration_client_uri — RFC 7592 management is out of scope. DCR-registered clients are marked [unverified] in their display name to flag them in admin grids (the consent page renders the same marker).
DCR constraints
- Accepted
token_endpoint_auth_methodvalues:none(PKCE-only public client),client_secret_basicandclient_secret_post.private_key_jwtneeds a registered key set and is therefore available to admin-registered clients only. - Redirect URIs must be HTTPS or
localhost; deep-link schemes are rejected. - Triple opt-in: the realm must enable DCR globally; the requested scopes must be per-Scope-DCR-allowed; the resource server (if any) must be per-API-DCR-allowed.
- Unverified DCR clients with no recent
last_used_atactivity are garbage-collected by the dailydcr-gcQuartz job (default TTL: 90 days).
Per-realm isolation
Each realm has:
- Its own OAuth clients (
OAuthApplicationStatein the tenant store) - Its own scopes (
OAuthScopeState) - Its own API resources (
OAuthApiState) - Its own authorizations + tokens
- Its own issuer (realm domain via
RealmIssuerHandler) - Its own discovery document and JWKS
Tokens from realm A are invalid in realm B — issuer mismatch alone suffices for rejection. Identical client_id strings in two realms are different clients.
Per-client token format
Per client you can choose between Reference Token (default) and JWT:
| Format | Storage | Validation | Revocation |
|---|---|---|---|
| Reference | Server-side OpenIddictTokenDocument | API calls /connect/introspect | Immediate |
| JWT | Self-contained | API verifies locally with JWKS | Effective only on refresh expiry |
Switched per request via AccessTokenTypeHandler. Reference tokens are the right default for first-party apps (cheap revocation, no extra trust in the JWT lib version on the RS side); JWTs are the right pick for high-throughput RS scenarios where the introspection call would dominate latency.
OAuth admin endpoints
For managing the OAuth entities (clients, scopes, APIs) see Admin API → OAuth Clients/Scopes/APIs.