ConduitMcp.Client.OAuth.TokenStore behaviour (ConduitMCP v0.11.0)

Copy Markdown View Source

Where the OAuth client keeps tokens and registered clients.

Configure one as store: {module, opts} in the client's OAuth options; opts is passed as the last argument of every callback. The default is ConduitMcp.Client.OAuth.TokenStore.ETS: in memory, lost when the client (or, for a ConduitMcp.Client.Hub server, the hub) stops. Implement this behaviour to persist tokens (a database, the OS keychain, an encrypted file).

Keys

  • Tokens are stored under {principal, issuer, resource}: the host's identity for the user (:principal, nil when unused), the authorization server's issuer, and the MCP server's canonical resource URI. A token is therefore never used for another server, another authorization server, or another user - even when two servers trust the same authorization server (OA-5).
  • Registered clients (dynamic client registration) are stored under the issuer alone: credentials issued by one authorization server are never presented to another (OA-6).

Values

Tokens are ConduitMcp.Client.OAuth.Tokens structs; the access and refresh tokens inside are ConduitMcp.Client.Secrets. A client is a string-keyed map: "client_id", "client_secret" (a Secret or nil), "token_endpoint_auth_method" and "redirect_uris". A store that serializes values must call ConduitMcp.Client.Secret.expose/1 and wrap again on the way back, and should encrypt them at rest.

Single-flight refresh

transaction/3 runs fun while holding a lock on key, so that of many processes finding the same expired token only one refreshes it and the others re-read the store inside the lock and find the new token. It is optional: the default (transaction/4 here) is a node-local :global.trans/4 on {ConduitMcp.Client.OAuth.TokenStore, key}. A store shared across nodes (a database) should implement it with its own lock, for example SELECT ... FOR UPDATE.

Summary

Functions

Runs fun under the store's lock for key: the store's own transaction/3 when it has one, else a node-local :global.trans/4.

Types

client()

@type client() :: %{optional(String.t()) => term()}

issuer()

@type issuer() :: String.t()

key()

@type key() :: {principal :: term(), issuer :: String.t(), resource :: String.t()}

store()

@type store() :: {module(), keyword()}

Callbacks

delete(arg1, arg2, opts)

@callback delete(key() | issuer(), :tokens | :client, opts :: keyword()) :: :ok

fetch_client(issuer, opts)

@callback fetch_client(issuer(), opts :: keyword()) :: {:ok, client()} | :error

fetch_tokens(key, opts)

@callback fetch_tokens(key(), opts :: keyword()) ::
  {:ok, ConduitMcp.Client.OAuth.Tokens.t()} | :error

put_client(issuer, client, opts)

@callback put_client(issuer(), client(), opts :: keyword()) :: :ok | {:error, term()}

put_tokens(key, t, opts)

@callback put_tokens(key(), ConduitMcp.Client.OAuth.Tokens.t(), opts :: keyword()) ::
  :ok | {:error, term()}

transaction(key, function, opts)

(optional)
@callback transaction(key(), (-> result), opts :: keyword()) :: result when result: var

Functions

transaction(arg, key, fun)

@spec transaction(store(), key(), (-> result)) :: result when result: var

Runs fun under the store's lock for key: the store's own transaction/3 when it has one, else a node-local :global.trans/4.