ExMCP.Authorization.OAuthFlow (ex_mcp v1.0.0-rc.8)

Copy Markdown View Source

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

auth_params()

@type auth_params() :: %{
  optional(:issuer) => String.t(),
  optional(:require_issuer) => boolean(),
  optional(:resource) => String.t() | [String.t()],
  optional(:additional_params) => map(),
  client_id: String.t(),
  redirect_uri: String.t(),
  authorization_endpoint: String.t(),
  scopes: [String.t()]
}

client_credentials_params()

@type client_credentials_params() :: %{
  optional(:scopes) => [String.t()],
  optional(:resource) => String.t() | [String.t()],
  client_id: String.t(),
  client_secret: String.t(),
  token_endpoint: String.t()
}

jwt_credentials_params()

@type jwt_credentials_params() :: %{
  optional(:scopes) => [String.t()],
  optional(:resource) => String.t() | [String.t()],
  optional(:alg) => String.t(),
  optional(:kid) => String.t(),
  client_id: String.t(),
  private_key: JOSE.JWK.t(),
  token_endpoint: String.t()
}

token_params()

@type token_params() :: %{
  optional(:client_secret) => String.t(),
  optional(:resource) => String.t() | [String.t()],
  optional(:transaction_id) => String.t(),
  code: String.t(),
  code_verifier: String.t(),
  client_id: String.t(),
  redirect_uri: String.t(),
  token_endpoint: String.t()
}

token_response()

@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

client_credentials_flow(params)

@spec client_credentials_flow(client_credentials_params()) ::
  {:ok, token_response()} | {:error, term()}

Performs OAuth 2.1 client credentials flow.

client_credentials_jwt_flow(params)

@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.

exchange_code_for_token(params)

@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.

reauthorize_with_scopes(params, additional_scopes)

@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.

refresh_token(refresh_token, client_id, token_endpoint, opts \\ nil)

@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.

start_authorization_flow(params)

@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.

validate_authorization_response(response, transaction)

@spec validate_authorization_response(map(), map()) ::
  {:ok, String.t()} | {:error, term()}

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.