OAuth 2.1 flow implementations for MCP authorization.
This module handles the core OAuth flows:
- Authorization Code Flow with PKCE
- Client Credentials Flow
- Token refresh flow
Summary
Functions
Performs OAuth 2.1 client credentials flow.
Performs OAuth 2.1 client credentials flow with JWT client authentication (private_key_jwt).
Exchanges an authorization code for tokens.
Initiates a full re-authorization flow with an expanded scope set.
Refreshes an access token using a refresh token.
Starts the OAuth 2.1 authorization code flow with PKCE.
Validates an authorization response against its recorded transaction.
Types
@type token_response() :: %{ access_token: String.t(), token_type: String.t(), expires_in: non_neg_integer() | nil, refresh_token: String.t() | nil, scope: String.t() | nil }
Functions
@spec client_credentials_flow(client_credentials_params()) :: {:ok, token_response()} | {:error, term()}
Performs OAuth 2.1 client credentials flow.
@spec client_credentials_jwt_flow(jwt_credentials_params()) :: {:ok, token_response()} | {:error, term()}
Performs OAuth 2.1 client credentials flow with JWT client authentication (private_key_jwt).
Uses RFC 7523 Section 2.2 client assertions instead of a client secret.
@spec exchange_code_for_token(token_params()) :: {:ok, token_response()} | {:error, term()}
Exchanges an authorization code for tokens.
Include the transaction_id returned by start_authorization_flow/1 to bind
redemption to the validated callback and exact redirect URI. The transaction
is marked redeemed before the network request. If the token response is lost,
retry the complete authorization flow rather than reusing the code.
@spec reauthorize_with_scopes(auth_params(), [String.t()]) :: {:ok, String.t(), map()} | {:error, term()}
Initiates a full re-authorization flow with an expanded scope set.
Used when a refresh token is not available or the server does not support scope upgrades via refresh. This starts a new authorization code flow with the combined current + additional scopes.
@spec refresh_token(String.t(), String.t(), String.t(), keyword() | String.t() | nil) :: {:ok, token_response()} | {:error, term()}
Refreshes an access token using a refresh token.
Options
client_secret- Client secret for confidential clients (default: nil)scope- Space-separated scope string to request expanded scopes during refresh. Used for incremental scope upgrades (2025-11-25). If the authorization server supports it, the new token will have the expanded scope set.
@spec start_authorization_flow(auth_params()) :: {:ok, String.t(), map()} | {:error, term()}
Starts the OAuth 2.1 authorization code flow with PKCE.
Example
{:ok, auth_url, transaction} = OAuthFlow.start_authorization_flow(%{
client_id: "my-client",
redirect_uri: "http://localhost:8080/callback",
authorization_endpoint: "https://auth.example.com/oauth/authorize",
issuer: "https://auth.example.com",
scopes: ["mcp:read", "mcp:write"]
})The returned transaction contains the library-generated state, PKCE verifier,
issuer, exact redirect URI, and an opaque transaction_id. Keep it private,
pass it unchanged to validate_authorization_response/2, and pass either its
transaction_id or the transaction itself through the authorization facade
when exchanging the code.
State is always generated by ExMCP. A top-level :state is rejected, as are
reserved OAuth parameters inside :additional_params; callers cannot replace
the state, PKCE, redirect, resource, client, response-type, or scope fields.
Validates an authorization response against its recorded transaction.
Transactions returned by start_authorization_flow/1 are consumed atomically:
exactly one concurrent callback can succeed. The state value must match.
When the authorization response includes the RFC 9207 iss parameter, it must
exactly equal the issuer recorded when the flow started. If the authorization
server advertised authorization_response_iss_parameter_supported: true, the
transaction requires iss to be present as well. Issuers are identifiers and
are deliberately not URL-normalized. Validation succeeds with the authorization
code only after all checks pass.
Caller-constructed transaction maps without a transaction_id retain the 1.x
validation behavior for compatibility, but cannot provide process-independent
replay protection. Use the transaction returned by start_authorization_flow/1
for all new code.