Chapter 15: OAuth 2.0 Server Implementation
This chapter explores how Pierre implements a full OAuth 2.0 authorization server for secure MCP client authentication. You’ll learn about RFC 7591 dynamic client registration, PKCE (RFC 7636), authorization code flow, and JWT-based access tokens.
OAuth 2.0 Server Architecture
Pierre implements a standards-compliant OAuth 2.0 authorization server:
┌──────────────┐ ┌──────────────┐
│ MCP Client │ │ Pierre │
│ (SDK) │ │ OAuth 2.0 │
│ │ │ Server │
└──────────────┘ └──────────────┘
│ │
│ 1. POST /oauth2/register │
│ (dynamic client registration) │
├─────────────────────────────────►│
│ │
│ client_id, client_secret │
│◄─────────────────────────────────┤
│ │
│ 2. GET /oauth2/authorize │
│ (with PKCE code_challenge) │
├─────────────────────────────────►│
│ │
│ Redirect to login page │
│◄─────────────────────────────────┤
│ │
│ 3. POST /oauth2/login │
│ (user credentials) │
├─────────────────────────────────►│
│ │
│ Redirect with auth code │
│◄─────────────────────────────────┤
│ │
│ 4. POST /oauth2/token │
│ (exchange code + verifier) │
├─────────────────────────────────►│
│ │
│ access_token (JWT) │
│◄─────────────────────────────────┤
OAuth 2.0 flow: Pierre supports authorization code flow with PKCE (mandatory for security).
OAuth Context and Routes
The OAuth server shares context across all endpoint handlers:
Source: src/routes/oauth2.rs:36-49
#![allow(unused)]
fn main() {
/// OAuth 2.0 server context shared across all handlers
#[derive(Clone)]
pub struct OAuth2Context {
/// Database for client and token storage
pub database: Arc<Database>,
/// Authentication manager for JWT operations
pub auth_manager: Arc<AuthManager>,
/// JWKS manager for public key operations
pub jwks_manager: Arc<JwksManager>,
/// Server configuration
pub config: Arc<ServerConfig>,
/// Rate limiter for OAuth endpoints
pub rate_limiter: Arc<OAuth2RateLimiter>,
}
}
Route registration:
Source: src/routes/oauth2.rs:69-97
#![allow(unused)]
fn main() {
impl OAuth2Routes {
/// Create all OAuth 2.0 routes with context
pub fn routes(context: OAuth2Context) -> Router {
Router::new()
// RFC 8414: OAuth 2.0 Authorization Server Metadata
.route(
"/.well-known/oauth-authorization-server",
get(Self::handle_discovery),
)
// RFC 7517: JWKS endpoint
.route("/.well-known/jwks.json", get(Self::handle_jwks))
// RFC 7591: Dynamic Client Registration
.route("/oauth2/register", post(Self::handle_client_registration))
// OAuth 2.0 Authorization endpoint
.route("/oauth2/authorize", get(Self::handle_authorization))
// OAuth 2.0 Token endpoint
.route("/oauth2/token", post(Self::handle_token))
// Login page and submission
.route("/oauth2/login", get(Self::handle_oauth_login_page))
.route("/oauth2/login", post(Self::handle_oauth_login_submit))
// Token validation endpoints
.route(
"/oauth2/validate-and-refresh",
post(Self::handle_validate_and_refresh),
)
.route("/oauth2/token-validate", post(Self::handle_token_validate))
.with_state(context)
}
}
}
Endpoints:
/.well-known/oauth-authorization-server: OAuth discovery (RFC 8414)/.well-known/jwks.json: Public keys for JWT verification/oauth2/register: Dynamic client registration (RFC 7591)/oauth2/authorize: Authorization endpoint (user consent)/oauth2/token: Token endpoint (code exchange)
OAuth Discovery Endpoint
The discovery endpoint advertises server capabilities (RFC 8414):
Source: src/routes/oauth2.rs:100-128
#![allow(unused)]
fn main() {
/// Handle OAuth 2.0 discovery (RFC 8414)
async fn handle_discovery(State(context): State<OAuth2Context>) -> Json<serde_json::Value> {
let issuer_url = context.config.oauth2_server.issuer_url.clone();
// Use spawn_blocking for JSON serialization (CPU-bound operation)
let discovery_json = tokio::task::spawn_blocking(move || {
serde_json::json!({
"issuer": issuer_url,
"authorization_endpoint": format!("{issuer_url}/oauth2/authorize"),
"token_endpoint": format!("{issuer_url}/oauth2/token"),
"registration_endpoint": format!("{issuer_url}/oauth2/register"),
"jwks_uri": format!("{issuer_url}/.well-known/jwks.json"),
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"scopes_supported": ["fitness:read", "activities:read", "profile:read"],
"response_modes_supported": ["query"],
"code_challenge_methods_supported": ["S256"]
})
})
.await
.unwrap_or_else(|_| {
serde_json::json!({
"error": "internal_error",
"error_description": "Failed to generate discovery document"
})
});
Json(discovery_json)
}
}
Discovery response (example):
{
"issuer": "http://localhost:8081",
"authorization_endpoint": "http://localhost:8081/oauth2/authorize",
"token_endpoint": "http://localhost:8081/oauth2/token",
"registration_endpoint": "http://localhost:8081/oauth2/register",
"jwks_uri": "http://localhost:8081/.well-known/jwks.json",
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token"],
"response_types_supported": ["code"],
"token_endpoint_auth_methods_supported": ["client_secret_post", "client_secret_basic"],
"scopes_supported": ["fitness:read", "activities:read", "profile:read"],
"response_modes_supported": ["query"],
"code_challenge_methods_supported": ["S256"]
}
Key fields:
code_challenge_methods_supported: ["S256"]: Only SHA-256 PKCE (no plain method for security)grant_types_supported: Authorization code, client credentials, refresh tokentoken_endpoint_auth_methods_supported: Client authentication methods
Dynamic Client Registration (rfc 7591)
MCP clients register dynamically to obtain OAuth credentials:
Source: src/oauth2_server/models.rs:11-26
#![allow(unused)]
fn main() {
/// OAuth 2.0 Client Registration Request (RFC 7591)
#[derive(Debug, Deserialize)]
pub struct ClientRegistrationRequest {
/// Redirect URIs for authorization code flow
pub redirect_uris: Vec<String>,
/// Optional client name for display
pub client_name: Option<String>,
/// Optional client URI for information
pub client_uri: Option<String>,
/// Grant types the client can use
pub grant_types: Option<Vec<String>>,
/// Response types the client can use
pub response_types: Option<Vec<String>>,
/// Scopes the client can request
pub scope: Option<String>,
}
}
Client registration handler:
Source: src/oauth2_server/client_registration.rs:39-108
#![allow(unused)]
fn main() {
/// Register a new OAuth 2.0 client (RFC 7591)
///
/// # Errors
/// Returns an error if client registration validation fails or database storage fails
pub async fn register_client(
&self,
request: ClientRegistrationRequest,
) -> Result<ClientRegistrationResponse, OAuth2Error> {
// Validate request
Self::validate_registration_request(&request)?;
// Generate client credentials
let client_id = Self::generate_client_id();
let client_secret = Self::generate_client_secret()?;
let client_secret_hash = Self::hash_client_secret(&client_secret)?;
// Set default values - only authorization_code by default for security (RFC 8252 best practices)
// Clients must explicitly request client_credentials if needed
let grant_types = request
.grant_types
.unwrap_or_else(|| vec!["authorization_code".to_owned()]);
let response_types = request
.response_types
.unwrap_or_else(|| vec!["code".to_owned()]);
let created_at = Utc::now();
let expires_at = Some(created_at + Duration::days(365)); // 1 year expiry
// Create client record
let client = OAuth2Client {
id: Uuid::new_v4().to_string(),
client_id: client_id.clone(),
client_secret_hash,
redirect_uris: request.redirect_uris.clone(),
grant_types: grant_types.clone(),
response_types: response_types.clone(),
client_name: request.client_name.clone(),
client_uri: request.client_uri.clone(),
scope: request.scope.clone(),
created_at,
expires_at,
};
// Store in database
self.store_client(&client).await.map_err(|e| {
tracing::error!(error = %e, client_id = %client_id, "Failed to store OAuth2 client registration in database");
OAuth2Error::invalid_request("Failed to store client registration")
})?;
// Return registration response
let default_client_uri = Self::get_default_client_uri();
Ok(ClientRegistrationResponse {
client_id,
client_secret,
client_id_issued_at: Some(created_at.timestamp()),
client_secret_expires_at: expires_at.map(|dt| dt.timestamp()),
redirect_uris: request.redirect_uris,
grant_types,
response_types,
client_name: request.client_name,
client_uri: request.client_uri.or(Some(default_client_uri)),
scope: request
.scope
.or_else(|| Some("fitness:read activities:read profile:read".to_owned())),
})
}
}
Security measures:
- Argon2 hashing: Client secrets hashed before storage (never plaintext)
- 365-day expiry: Client registrations expire after 1 year
- Default grant types: Only
authorization_codeby default (least privilege) - Redirect URI validation: URIs validated during registration
Rust Idioms: Argon2 for Credential Hashing
Pierre uses Argon2 (winner of Password Hashing Competition) for client secret hashing:
Conceptual implementation (from client_registration.rs):
#![allow(unused)]
fn main() {
use argon2::{
password_hash::{rand_core::OsRng, PasswordHasher, SaltString},
Argon2,
};
fn hash_client_secret(secret: &str) -> Result<String, OAuth2Error> {
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let password_hash = argon2
.hash_password(secret.as_bytes(), &salt)
.map_err(|e| OAuth2Error::invalid_request("Failed to hash client secret"))?;
Ok(password_hash.to_string())
}
}
Why Argon2:
- Memory-hard: Resistant to GPU/ASIC attacks
- Tunable: Adjustable time/memory cost parameters
- Winner of PHC: Industry-standard recommendation
- Constant-time: Safe against timing attacks
Authorization Endpoint with PKCE
The authorization endpoint requires PKCE (Proof Key for Code Exchange) for security:
Source: src/oauth2_server/endpoints.rs:70-156
#![allow(unused)]
fn main() {
/// Handle authorization request (GET /oauth/authorize)
///
/// # Errors
/// Returns an error if client validation fails, invalid parameters, or authorization code generation fails
pub async fn authorize(
&self,
request: AuthorizeRequest,
user_id: Option<Uuid>, // From authentication
tenant_id: Option<String>, // From JWT claims
) -> Result<AuthorizeResponse, OAuth2Error> {
// Validate client
let client = self
.client_manager
.get_client(&request.client_id)
.await
.map_err(|e| {
tracing::error!(
"Client lookup failed for client_id={}: {:#}",
request.client_id,
e
);
OAuth2Error::invalid_client()
})?;
// Validate response type
if request.response_type != "code" {
return Err(OAuth2Error::invalid_request(
"Only 'code' response_type is supported",
));
}
// Validate redirect URI
if !client.redirect_uris.contains(&request.redirect_uri) {
return Err(OAuth2Error::invalid_request("Invalid redirect_uri"));
}
// Validate PKCE parameters (RFC 7636)
if let Some(ref code_challenge) = request.code_challenge {
// Validate code_challenge format (base64url-encoded, 43-128 characters)
if code_challenge.len() < 43 || code_challenge.len() > 128 {
return Err(OAuth2Error::invalid_request(
"code_challenge must be between 43 and 128 characters",
));
}
// Validate code_challenge_method - only S256 is allowed (RFC 7636 security best practice)
let method = request.code_challenge_method.as_deref().unwrap_or("S256");
if method != "S256" {
return Err(OAuth2Error::invalid_request(
"code_challenge_method must be 'S256' (plain method is not supported for security reasons)",
));
}
} else {
// PKCE is required for authorization code flow
return Err(OAuth2Error::invalid_request(
"code_challenge is required for authorization_code flow (PKCE)",
));
}
// User authentication required
let user_id =
user_id.ok_or_else(|| OAuth2Error::invalid_request("User authentication required"))?;
// Generate authorization code with tenant isolation and state binding
let tenant_id = tenant_id.unwrap_or_else(|| user_id.to_string());
let auth_code = self
.generate_authorization_code(AuthCodeParams {
client_id: &request.client_id,
user_id,
tenant_id: &tenant_id,
redirect_uri: &request.redirect_uri,
scope: request.scope.as_deref(),
state: request.state.as_deref(),
code_challenge: request.code_challenge.as_deref(),
code_challenge_method: request.code_challenge_method.as_deref(),
})
.await
.map_err(|e| {
tracing::error!(
"Failed to generate authorization code for client_id={}: {:#}",
request.client_id,
e
);
OAuth2Error::invalid_request("Failed to generate authorization code")
})?;
Ok(AuthorizeResponse {
code: auth_code,
state: request.state,
})
}
}
PKCE validation:
- Required:
code_challengemandatory (no fallback to plain OAuth) - S256 only: SHA-256 method required (plain method rejected for security)
- Length validation: 43-128 characters (base64url-encoded SHA-256)
PKCE Flow Explained
PKCE prevents authorization code interception attacks:
Client generates random verifier:
verifier = random(43-128 chars)
Client creates challenge:
challenge = base64url(sha256(verifier))
Authorization request includes challenge:
GET /oauth2/authorize?
client_id=...&
redirect_uri=...&
code_challenge=<challenge>&
code_challenge_method=S256
Server stores challenge with authorization code
Token request includes verifier:
POST /oauth2/token
grant_type=authorization_code&
code=<auth_code>&
code_verifier=<verifier>&
...
Server validates:
if base64url(sha256(verifier)) == stored_challenge:
issue_token()
else:
reject_request()
Security benefit: Even if authorization code is intercepted, attacker cannot exchange it without the original code_verifier (which never leaves the client).
Token Endpoint
The token endpoint exchanges authorization codes for JWT access tokens:
Source: src/oauth2_server/endpoints.rs:163-186
#![allow(unused)]
fn main() {
/// Handle token request (POST /oauth/token)
///
/// # Errors
/// Returns an error if client validation fails or token generation fails
pub async fn token(&self, request: TokenRequest) -> Result<TokenResponse, OAuth2Error> {
// ALWAYS validate client credentials for ALL grant types (RFC 6749 Section 6)
// RFC 6749 Section 6 states: "If the client type is confidential or the client was issued
// client credentials, the client MUST authenticate with the authorization server"
// MCP clients are confidential clients, so authentication is REQUIRED
self.client_manager
.validate_client(&request.client_id, &request.client_secret)
.await
.inspect_err(|e| {
tracing::error!(
client_id = %request.client_id,
grant_type = %request.grant_type,
error = ?e,
"OAuth client validation failed"
);
})?;
match request.grant_type.as_str() {
"authorization_code" => self.handle_authorization_code_grant(request).await,
"client_credentials" => self.handle_client_credentials_grant(request),
"refresh_token" => self.handle_refresh_token_grant(request).await,
_ => Err(OAuth2Error::unsupported_grant_type()),
}
}
}
Grant types:
authorization_code: Exchange authorization code for access token (with PKCE verification)client_credentials: Machine-to-machine authentication (no user context)refresh_token: Renew expired access token without re-authentication
Constant-Time Client Validation
Client credential validation uses constant-time comparison to prevent timing attacks:
Source: src/oauth2_server/client_registration.rs:114-153
#![allow(unused)]
fn main() {
/// Validate client credentials
///
/// # Errors
/// Returns an error if client is not found, credentials are invalid, or client is expired
pub async fn validate_client(
&self,
client_id: &str,
client_secret: &str,
) -> Result<OAuth2Client, OAuth2Error> {
tracing::debug!("Validating OAuth client: {}", client_id);
let client = self.get_client(client_id).await.map_err(|e| {
tracing::warn!("OAuth client {} not found: {}", client_id, e);
OAuth2Error::invalid_client()
})?;
tracing::debug!("OAuth client {} found, validating secret", client_id);
// Verify client secret using constant-time comparison via Argon2
let parsed_hash = PasswordHash::new(&client.client_secret_hash).map_err(|e| {
tracing::error!("Failed to parse stored password hash: {}", e);
OAuth2Error::invalid_client()
})?;
let argon2 = Argon2::default();
if argon2
.verify_password(client_secret.as_bytes(), &parsed_hash)
.is_err()
{
tracing::warn!("OAuth client {} secret validation failed", client_id);
return Err(OAuth2Error::invalid_client());
}
// Check if client is expired
if let Some(expires_at) = client.expires_at {
if Utc::now() > expires_at {
tracing::warn!("OAuth client {} has expired", client_id);
return Err(OAuth2Error::invalid_client());
}
}
tracing::info!("OAuth client {} validated successfully", client_id);
Ok(client)
}
}
Constant-time guarantee: Argon2’s verify_password uses constant-time comparison to prevent timing side-channel attacks.
Rust Idioms: Constant-Time Operations
Timing attack vulnerability:
#![allow(unused)]
fn main() {
// VULNERABLE: Early return leaks information about secret length
if client_secret.len() != stored_secret.len() {
return Err(...); // Attacker learns length immediately
}
for (a, b) in client_secret.bytes().zip(stored_secret.bytes()) {
if a != b {
return Err(...); // Attacker learns position of mismatch
}
}
}
Constant-time solution (Argon2):
#![allow(unused)]
fn main() {
// SECURE: Always takes same time regardless of input
argon2.verify_password(client_secret.as_bytes(), &parsed_hash)
}
Why this matters: Timing attacks can recover secrets character-by-character by measuring response times.
Multi-Tenant OAuth Management
Pierre provides tenant-specific OAuth credential isolation:
Source: src/tenant/oauth_manager.rs:14-46
#![allow(unused)]
fn main() {
/// Credential configuration for storing OAuth credentials
#[derive(Debug, Clone)]
pub struct CredentialConfig {
/// OAuth client ID (public)
pub client_id: String,
/// OAuth client secret (to be encrypted)
pub client_secret: String,
/// OAuth redirect URI
pub redirect_uri: String,
/// OAuth scopes
pub scopes: Vec<String>,
/// User who configured these credentials
pub configured_by: Uuid,
}
/// Per-tenant OAuth credentials with decrypted secret
#[derive(Debug, Clone)]
pub struct TenantOAuthCredentials {
/// Tenant ID that owns these credentials
pub tenant_id: Uuid,
/// OAuth provider name
pub provider: String,
/// OAuth client ID (public)
pub client_id: String,
/// OAuth client secret (decrypted)
pub client_secret: String,
/// OAuth redirect URI
pub redirect_uri: String,
/// OAuth scopes
pub scopes: Vec<String>,
/// Daily rate limit for this tenant
pub rate_limit_per_day: u32,
}
}
Credential resolution:
Source: src/tenant/oauth_manager.rs:76-100
#![allow(unused)]
fn main() {
/// Load OAuth credentials for a specific tenant and provider
///
/// # Errors
///
/// Returns an error if no credentials are found for the tenant/provider combination
pub async fn get_credentials(
&self,
tenant_id: Uuid,
provider: &str,
database: &Database,
) -> Result<TenantOAuthCredentials> {
// Priority 1: Try tenant-specific credentials first (in-memory cache, then database)
if let Some(credentials) = self
.try_tenant_specific_credentials(tenant_id, provider, database)
.await
{
return Ok(credentials);
}
// Priority 2: Fallback to server-level OAuth configuration
if let Some(credentials) = self.try_server_level_credentials(tenant_id, provider) {
return Ok(credentials);
}
// No credentials found - return error
Err(AppError::not_found(format!(
"No OAuth credentials configured for tenant {} and provider {}. Configure {}_CLIENT_ID and {}_CLIENT_SECRET environment variables, or provide tenant-specific credentials via the MCP OAuth configuration tool.",
tenant_id, provider, provider.to_uppercase(), provider.to_uppercase()
)).into())
}
}
Credential priority:
- Tenant-specific credentials (highest priority): Custom OAuth apps per tenant
- Server-level credentials (fallback): Shared OAuth apps from environment variables
- Error (no credentials): Inform user how to configure
OAuth Rate Limiting
Pierre implements rate limiting for OAuth endpoints:
Source: src/routes/oauth2.rs:136-149
#![allow(unused)]
fn main() {
async fn handle_client_registration(
State(context): State<OAuth2Context>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
Json(request): Json<ClientRegistrationRequest>,
) -> Response {
// Extract client IP from connection using Axum's ConnectInfo extractor
let client_ip = addr.ip();
let rate_status = context.rate_limiter.check_rate_limit("register", client_ip);
if rate_status.is_limited {
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "too_many_requests",
"error_description": "Rate limit exceeded"
})),
)
.into_response();
}
// ... continue registration
}
}
Rate-limited endpoints:
/oauth2/register: Prevent client registration spam/oauth2/authorize: Prevent authorization request floods/oauth2/token: Prevent token exchange brute-forcing
Key Takeaways
-
RFC compliance: Pierre implements RFC 7591 (client registration), RFC 7636 (PKCE), RFC 8414 (discovery).
-
PKCE mandatory: Authorization code flow requires PKCE with SHA-256 (no plain method).
-
Argon2 hashing: Client secrets hashed with Argon2 (memory-hard, constant-time verification).
-
Constant-time validation: Client credential verification prevents timing attacks.
-
JWT access tokens: OAuth access tokens are JWTs (same format as Pierre authentication tokens).
-
Multi-tenant isolation: Tenant-specific OAuth credentials with separate rate limits.
-
Discovery endpoint: RFC 8414 metadata allows clients to auto-discover OAuth configuration.
-
365-day expiry: Client registrations expire after 1 year (security best practice).
-
Rate limiting: OAuth endpoints protected against abuse with IP-based rate limiting.
-
Grant type defaults: Only
authorization_codeby default (least privilege principle).
Next Chapter: Chapter 16: OAuth 2.0 Client for Fitness Providers - Learn how Pierre acts as an OAuth client to connect to fitness providers like Strava and Fitbit.