%%%------------------------------------------------------------------- %%% @author Benoit Chesneau %%% @copyright 2024-2026 Benoit Chesneau %%% @doc Authentication behaviour and utilities for barrel_mcp. %%% %%% This module defines the `barrel_mcp_auth' behaviour for implementing %%% pluggable authentication providers. It also provides utility functions %%% for extracting credentials from HTTP headers. %%% %%% == Built-in Providers == %%% %%% %%% %%% == Implementing a Custom Provider == %%% %%% To create a custom authentication provider, implement the %%% `barrel_mcp_auth' behaviour: %%% %%% ``` %%% -module(my_auth_provider). %%% -behaviour(barrel_mcp_auth). %%% %%% -export([init/1, authenticate/2, challenge/2]). %%% %%% init(Opts) -> %%% {ok, Opts}. %%% %%% authenticate(Request, State) -> %%% Headers = maps:get(headers, Request, #{}), %%% case barrel_mcp_auth:extract_bearer_token(Headers) of %%% {ok, Token} -> verify_token(Token); %%% {error, no_token} -> {error, unauthorized} %%% end. %%% %%% challenge(Reason, _State) -> %%% {401, #{<<"www-authenticate">> => <<"Bearer">>}, <<>>}. %%% ''' %%% %%% == Configuring Authentication == %%% %%% Pass authentication configuration when starting the HTTP server: %%% %%% ``` %%% barrel_mcp:start_http(#{ %%% port => 9090, %%% auth => #{ %%% provider => barrel_mcp_auth_bearer, %%% provider_opts => #{secret => <<"my-secret">>}, %%% required_scopes => [<<"read">>] %%% } %%% }). %%% ''' %%% %%% @end %%%------------------------------------------------------------------- -module(barrel_mcp_auth). %% API -export([ authenticate/3, challenge_response/2, get_auth_info/1, extract_bearer_token/1, extract_api_key/2, extract_basic_auth/1, auth_headers/1 ]). %% Types -export_type([ auth_provider/0, auth_info/0, auth_error/0, auth_config/0 ]). %%==================================================================== %% Types %%==================================================================== -type auth_provider() :: module(). %% Module implementing the barrel_mcp_auth behaviour. -type auth_info() :: #{ subject => binary(), issuer => binary(), audience => binary() | [binary()], scopes => [binary()], expires_at => integer(), claims => map(), metadata => map() }. %% Authentication information returned on successful auth. %% Contains subject (user/client ID), issuer, audience, scopes, %% expiration timestamp, token claims, and provider metadata. -type auth_error() :: unauthorized | invalid_token | expired_token | insufficient_scope | invalid_credentials | {error, term()}. %% Possible authentication error reasons. -type auth_config() :: #{ provider := module(), provider_opts => map(), provider_state => term(), realm => binary(), required_scopes => [binary()] }. %% Authentication configuration for the HTTP server. %%==================================================================== %% Behaviour Callbacks %%==================================================================== %% Initialize the authentication provider. %% Called once when the HTTP server starts. -callback init(Opts :: map()) -> {ok, State :: term()} | {error, term()}. %% Authenticate an incoming request. -callback authenticate(Request :: map(), State :: term()) -> {ok, auth_info()} | {error, auth_error()}. %% Generate a challenge response for failed authentication. -callback challenge(Reason :: auth_error(), State :: term()) -> {StatusCode :: integer(), Headers :: map(), Body :: binary()}. %% Optional: declare which HTTP request headers carry credentials so %% the HTTP transport can build the CORS `Access-Control-Allow-Headers' %% list and read the right inputs in `extract_headers/1'. Returning %% the empty list means "no auth header" (e.g. cookie-based, or none). -callback auth_headers(State :: term()) -> [binary()]. -optional_callbacks([init/1, auth_headers/1]). %%==================================================================== %% API %%==================================================================== %% @doc Authenticate a request using the configured provider. %% %% Delegates authentication to the configured provider and optionally %% checks for required scopes after successful authentication. %% %% @param Config Authentication configuration %% @param Request Map containing `headers' key with HTTP headers %% @param State Provider state (usually same as Config) %% @returns `{ok, AuthInfo}' on success, `{error, Reason}' on failure -spec authenticate(auth_config(), map(), term()) -> {ok, auth_info()} | {error, auth_error()}. authenticate(#{provider := Provider} = Config, Request, State) -> ProviderState = maps:get(provider_state, State, undefined), case Provider:authenticate(Request, ProviderState) of {ok, AuthInfo} -> %% Check required scopes if configured case maps:get(required_scopes, Config, []) of [] -> {ok, AuthInfo}; RequiredScopes -> check_scopes(RequiredScopes, AuthInfo) end; {error, _} = Error -> Error end. %% @doc Generate a challenge response for failed authentication. %% %% Creates an HTTP response with appropriate status code, headers %% (including WWW-Authenticate), and error body. %% %% @param Config Authentication configuration %% @param Reason The authentication error reason %% @returns Tuple of `{StatusCode, Headers, Body}' -spec challenge_response(auth_config(), auth_error()) -> {integer(), map(), binary()}. challenge_response(#{provider := Provider} = Config, Reason) -> ProviderState = maps:get(provider_state, Config, undefined), Provider:challenge(Reason, ProviderState). %% @doc Return the list of HTTP request headers (lower-case) the %% configured provider expects to read credentials from. Used by the %% HTTP transport to build CORS `Access-Control-Allow-Headers' and to %% extract inputs in the request handler. Falls back to a sensible %% default per built-in provider when the provider does not export %% `auth_headers/1'. -spec auth_headers(auth_config()) -> [binary()]. auth_headers(#{provider := barrel_mcp_auth_none}) -> []; auth_headers(#{provider := Provider} = Config) -> case erlang:function_exported(Provider, auth_headers, 1) of true -> ProviderState = maps:get(provider_state, Config, undefined), Provider:auth_headers(ProviderState); false -> default_auth_headers(Provider, Config) end. default_auth_headers(barrel_mcp_auth_bearer, _) -> [<<"authorization">>]; default_auth_headers(barrel_mcp_auth_basic, _) -> [<<"authorization">>]; default_auth_headers(barrel_mcp_auth_apikey, Config) -> Opts = maps:get(provider_opts, Config, #{}), Custom = maps:get(header_name, Opts, undefined), Base = [<<"x-api-key">>, <<"authorization">>], case Custom of undefined -> Base; H when is_binary(H) -> [string:lowercase(H) | Base] end; default_auth_headers(barrel_mcp_auth_custom, _) -> [<<"authorization">>, <<"x-api-key">>]; default_auth_headers(_, _) -> []. %% @doc Get authentication info from a context map. %% %% Extracts authentication information that was added to the context %% after successful authentication. %% %% @param Context Map that may contain `auth_info' key %% @returns The auth_info map if present, `undefined' otherwise -spec get_auth_info(map()) -> auth_info() | undefined. get_auth_info(#{auth_info := AuthInfo}) -> AuthInfo; get_auth_info(_) -> undefined. %% @doc Extract Bearer token from Authorization header. %% %% Parses the Authorization header and extracts the token value %% from a Bearer authentication scheme. %% %% == Example == %% %% ``` %% Headers = #{<<"authorization">> => <<"Bearer abc123">>}, %% {ok, <<"abc123">>} = barrel_mcp_auth:extract_bearer_token(Headers). %% ''' %% %% @param Headers Map of HTTP headers (case-insensitive lookup) %% @returns `{ok, Token}' if Bearer token found, `{error, no_token}' otherwise -spec extract_bearer_token(Headers :: map()) -> {ok, binary()} | {error, no_token}. extract_bearer_token(Headers) -> case get_authorization_header(Headers) of {ok, <<"Bearer ", Token/binary>>} -> {ok, string:trim(Token)}; {ok, <<"bearer ", Token/binary>>} -> {ok, string:trim(Token)}; _ -> {error, no_token} end. %% @doc Extract API key from headers. %% %% Looks for API key in the following locations (in order): %%
    %%%
  1. Custom header specified by `header_name' option
  2. %%%
  3. X-API-Key header
  4. %%%
  5. Authorization header with ApiKey scheme
  6. %%%
%% %% == Example == %% %% ``` %% Headers = #{<<"x-api-key">> => <<"my-key">>}, %% {ok, <<"my-key">>} = barrel_mcp_auth:extract_api_key(Headers, #{}). %% ''' %% %% @param Headers Map of HTTP headers %% @param Opts Options map, may contain `header_name' for custom header %% @returns `{ok, Key}' if found, `{error, no_key}' otherwise -spec extract_api_key(Headers :: map(), Opts :: map()) -> {ok, binary()} | {error, no_key}. extract_api_key(Headers, #{header_name := HeaderName}) -> HeaderNameLower = string:lowercase(HeaderName), case find_header(HeaderNameLower, Headers) of {ok, Key} -> {ok, Key}; error -> {error, no_key} end; extract_api_key(Headers, _Opts) -> %% Default header names case find_header(<<"x-api-key">>, Headers) of {ok, Key} -> {ok, Key}; error -> case find_header(<<"authorization">>, Headers) of {ok, <<"ApiKey ", Key/binary>>} -> {ok, string:trim(Key)}; {ok, <<"apikey ", Key/binary>>} -> {ok, string:trim(Key)}; _ -> {error, no_key} end end. %% @doc Extract Basic authentication credentials. %% %% Parses the Authorization header for Basic authentication scheme %% and decodes the base64-encoded credentials. %% %% == Example == %% %% ``` %% Encoded = base64:encode(<<"user:pass">>), %% Headers = #{<<"authorization">> => <<"Basic ", Encoded/binary>>}, %% {ok, <<"user">>, <<"pass">>} = barrel_mcp_auth:extract_basic_auth(Headers). %% ''' %% %% @param Headers Map of HTTP headers %% @returns `{ok, Username, Password}' if found and valid, %% `{error, no_credentials}' otherwise -spec extract_basic_auth(Headers :: map()) -> {ok, Username :: binary(), Password :: binary()} | {error, no_credentials}. extract_basic_auth(Headers) -> case get_authorization_header(Headers) of {ok, <<"Basic ", Encoded/binary>>} -> decode_basic_auth(string:trim(Encoded)); {ok, <<"basic ", Encoded/binary>>} -> decode_basic_auth(string:trim(Encoded)); _ -> {error, no_credentials} end. %%==================================================================== %% Internal functions %%==================================================================== get_authorization_header(Headers) -> find_header(<<"authorization">>, Headers). find_header(Name, Headers) when is_map(Headers) -> NameLower = string:lowercase(Name), %% Headers might have different case maps:fold( fun(K, V, Acc) -> case Acc of {ok, _} -> Acc; error -> case string:lowercase(K) of NameLower -> {ok, V}; _ -> error end end end, error, Headers ); find_header(_, _) -> error. decode_basic_auth(Encoded) -> try Decoded = base64:decode(Encoded), case binary:split(Decoded, <<":">>) of [Username, Password] -> {ok, Username, Password}; [Username] -> %% Password might be empty {ok, Username, <<>>}; _ -> {error, no_credentials} end catch _:_ -> {error, no_credentials} end. check_scopes(RequiredScopes, #{scopes := TokenScopes} = AuthInfo) when is_list(TokenScopes) -> case lists:all(fun(S) -> lists:member(S, TokenScopes) end, RequiredScopes) of true -> {ok, AuthInfo}; false -> {error, insufficient_scope} end; check_scopes(_RequiredScopes, _AuthInfo) -> %% required_scopes is configured but the provider did not surface a %% scopes list (or surfaced a non-list). Fail closed: a missing %% claim is not implicit consent. {error, insufficient_scope}.