OAuth 2.1 Implementation
AgentTrust ID’s OAuth 2.1 authorization server with mandatory PKCE, supporting MCP compliance and agent authentication.
Overview
AgentTrust ID implements OAuth 2.1 to authenticate MCP clients and issue AgentTrust ID JWT tokens. Key properties:
- PKCE required — S256 code challenge method is mandatory (no plain)
- Authorization code flow only — no implicit or client credentials grants
- AgentTrust ID JWT integration — OAuth token exchange issues the same JWT tokens used across AgentTrust ID
- Dynamic client registration — RFC 7591 support for automated MCP client onboarding
PKCE Flow
API Reference
GET /.well-known/oauth-authorization-server — Server Metadata
RFC 8414 discovery endpoint. Returns server capabilities.
curl http://localhost:8080/.well-known/oauth-authorization-serverResponse:
{
"issuer": "https://api.agenttrust.id/",
"authorization_endpoint": "https://api.agenttrust.id/oauth/authorize",
"token_endpoint": "https://api.agenttrust.id/oauth/token",
"registration_endpoint": "https://api.agenttrust.id/oauth/register",
"scopes_supported": ["read", "write", "admin", "mcp:tools", "mcp:resources", "mcp:prompts"],
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code"],
"token_endpoint_auth_methods_supported": ["none", "client_secret_post"],
"code_challenge_methods_supported": ["S256"]
}Auth methods: The token endpoint supports
none(public clients relying on PKCE only) andclient_secret_post(secret sent as a form parameter in the request body). HTTP Basic Auth (client_secret_basic) is not implemented — the token handler readsclient_idexclusively from the request body (form or JSON), not from theAuthorizationheader. Seeinternal/oauth2/handler.goToken().
GET /oauth/authorize — Authorization Endpoint
Validates params and redirects with an authorization code.
| Parameter | Required | Description |
|---|---|---|
response_type | yes | Must be code |
client_id | yes | Registered client ID |
redirect_uri | yes | Must match registered URI |
code_challenge | yes | S256 PKCE challenge |
code_challenge_method | yes | Must be S256 |
agent_id | yes | AgentTrust ID agent requesting authorization |
org_id | yes | Organization scope |
scope | no | Space-separated scopes |
state | no | CSRF protection value |
POST /oauth/token — Token Exchange
Accepts application/x-www-form-urlencoded or application/json.
curl -X POST http://localhost:8080/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=authorization_code\
&code=auth-code-here\
&redirect_uri=http://localhost:3000/callback\
&client_id=client-id-here\
&code_verifier=original-verifier"Response:
{
"access_token": "eyJhbGciOiJFZERTQSIs...",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "read write mcp:tools"
}Note: Access tokens are JWTs signed with EdDSA (Ed25519), not HS256 or RS256.
Response includes Cache-Control: no-store and Pragma: no-cache headers.
POST /oauth/register — Dynamic Client Registration
RFC 7591 endpoint. Requires X-Org-ID header.
curl -X POST http://localhost:8080/oauth/register \
-H "X-Org-ID: org-uuid" \
-H "Content-Type: application/json" \
-d '{
"client_name": "my-mcp-client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"scope": ["read", "mcp:tools"]
}'Response (201):
{
"client_id": "generated-hex-id",
"client_secret": "generated-hex-secret",
"client_name": "my-mcp-client",
"redirect_uris": ["http://localhost:3000/callback"],
"grant_types": ["authorization_code"],
"scope": ["read", "mcp:tools"]
}client_secret is only returned at registration (stored as SHA-256 hash). Public clients omit the secret.
Token, Secret, and Code Sizes
All random values are generated via crypto/rand and hex-encoded. Source: internal/oauth2/server.go randomHex().
| Value | Bytes | Hex chars | Example |
|---|---|---|---|
| Client ID | 16 | 32 | a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6 |
| Client secret | 32 | 64 | 64-char hex string |
| Authorization code | 64 | 128 | 128-char hex string |
AgentTrust ID JWT Integration
The OAuth token endpoint issues the same JWT tokens used by AgentTrust ID’s internal auth service. When an MCP client exchanges an authorization code, it receives an AgentTrust ID JWT that works across all AgentTrust ID services:
OAuth code exchange --> AgentTrust ID TokenService.IssueToken() --> Standard AgentTrust ID JWTThis means OAuth-authenticated MCP clients get the same authorization checks (Guardian, policies, revocation) as API-key-authenticated agents.
agent_id and org_id Flow
During token exchange, agent_id and org_id are not taken from the token request body. They come from the stored authorization code record that was created during the /oauth/authorize step.
1. /oauth/authorize?agent_id=...&org_id=...
--> Stored in oauth2_auth_codes row alongside the code
2. POST /oauth/token (body: grant_type, code, client_id, redirect_uri, code_verifier)
--> ExchangeToken() looks up the auth code row
--> Reads ac.AgentID and ac.OrgID from that row
--> Passes ac.AgentID and ac.Scope to TokenService.IssueToken()The token request itself only carries client_id for client validation, not agent identity. This prevents a malicious token request from overriding the agent_id that was authorized. Source: internal/oauth2/server.go ExchangeToken().
AgentTrust ID Scope Integration
OAuth scopes flow into the AgentTrust ID authorization system through two paths:
Path 1: AgentTrust ID OAuth (standard agents)
The OAuth token endpoint calls crypto.TokenService.IssueToken() with the scopes from the stored authorization code. These scopes are embedded in the AgentTrust ID JWT claims and become the agent’s authorized scope for all downstream Guardian policy checks.
/oauth/authorize (scope=read+mcp:tools)
--> stored in oauth2_auth_codes.scope
/oauth/token
--> ExchangeToken() reads ac.Scope
--> TokenService.IssueToken(AgentID, Scope, TTL)
--> JWT claims include scope arrayPath 2: MCP OAuth (CIMD-based clients)
The MCP auth handler (internal/mcp/auth_handler.go Token()) generates an MCP-specific bearer token and stores its scope in Redis via ScopeEnforcer.StoreTokenScope(). During subsequent MCP proxy requests, ScopeEnforcer.GetTokenScope() retrieves the token’s scope, and ScopeEnforcer.CheckScopeCeiling() validates that each requested action falls within the scope ceiling.
POST /mcp/oauth/token (scope=mcp:tool_call)
--> ScopeEnforcer.StoreTokenScope(token, scope, 1h) [Redis key: mcp_token_scope:<token>]
MCP proxy request with Bearer token
--> ScopeEnforcer.GetTokenScope(token) [reads from Redis]
--> ScopeEnforcer.CheckScopeCeiling(ceiling, action) [enforces scope boundary]These scopes become the AgentTrust ID session scope_ceiling — the maximum set of permissions for the session. See AGENTTRUST.md for the session scope-enforcement model.
Security Considerations
- PKCE is mandatory — requests without
code_challengeare rejected - Only S256 — plain code challenge method is not supported
- Single-use codes — authorization codes are marked as used after exchange
- Redirect URI validation — must exactly match a registered URI
- Client secrets — SHA-256 hashed, never stored in plain text
- Confidential vs. public clients — confidential clients get a secret; public clients use PKCE only
- Confidential client secret validation — for confidential clients,
ExchangeToken()validates the presentedclient_secretagainst the stored SHA-256 hash with a constant-time comparison, in addition toclient_idmatch,redirect_urimatch, code expiry, single-use, and PKCE. Public clients (no secret) rely on PKCE. Source:internal/oauth2/server.goExchangeToken()andinternal/oauth2/handler.goToken()