%%%-------------------------------------------------------------------
%%% @author Benoit Chesneau
%%% @copyright 2024-2026 Benoit Chesneau
%%% @doc Basic HTTP authentication provider for barrel_mcp.
%%%
%%% Implements HTTP Basic Authentication (RFC 7617).
%%% Suitable for simple deployments, development, or when using TLS.
%%%
%%% == Configuration Options ==
%%%
%%% <ul>
%%% <li>`credentials' - Map of username to password or auth info</li>
%%% <li>`verifier' - Custom verification function</li>
%%% <li>`realm' - Realm for WWW-Authenticate header</li>
%%% <li>`hash_passwords' - If true, stored passwords are SHA256 hashes</li>
%%% </ul>
%%%
%%% @see barrel_mcp_auth
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_mcp_auth_basic).
-behaviour(barrel_mcp_auth).
%% barrel_mcp_auth callbacks
-export([
init/1,
authenticate/2,
challenge/2
]).
%% Utilities
-export([
hash_password/1,
hash_password/2,
verify_password/2
]).
-define(PBKDF2_ITERATIONS, 100000).
-define(PBKDF2_SALT_BYTES, 16).
-define(PBKDF2_HASH_BYTES, 32).
%%====================================================================
%% barrel_mcp_auth callbacks
%%====================================================================
%% @doc Initialize the Basic auth provider.
-spec init(map()) -> {ok, map()}.
init(Opts) ->
State = #{
credentials => maps:get(credentials, Opts, #{}),
verifier => maps:get(verifier, Opts, undefined),
realm => maps:get(realm, Opts, <<"mcp">>),
hash_passwords => maps:get(hash_passwords, Opts, false)
},
{ok, State}.
%% @doc Authenticate a request using Basic auth.
-spec authenticate(map(), map()) ->
{ok, barrel_mcp_auth:auth_info()} | {error, barrel_mcp_auth:auth_error()}.
authenticate(Request, State) ->
Headers = maps:get(headers, Request, #{}),
case barrel_mcp_auth:extract_basic_auth(Headers) of
{ok, Username, Password} ->
verify_credentials(Username, Password, State);
{error, no_credentials} ->
{error, unauthorized}
end.
%% @doc Generate WWW-Authenticate challenge.
-spec challenge(barrel_mcp_auth:auth_error(), map()) ->
{integer(), map(), binary()}.
challenge(Reason, State) ->
Realm = maps:get(realm, State, <<"mcp">>),
{StatusCode, ErrorDesc} =
case Reason of
unauthorized ->
{401, <<"Authentication required">>};
invalid_credentials ->
{401, <<"Invalid username or password">>};
_ ->
{401, <<"Authentication failed">>}
end,
Body = iolist_to_binary(
json:encode(#{
<<"error">> => <<"unauthorized">>,
<<"error_description">> => ErrorDesc
})
),
Headers = #{
<<"www-authenticate">> => <<"Basic realm=\"", Realm/binary, "\", charset=\"UTF-8\"">>,
<<"content-type">> => <<"application/json">>
},
{StatusCode, Headers, Body}.
%%====================================================================
%% Credential verification
%%====================================================================
verify_credentials(Username, Password, #{verifier := Verifier}) when
is_function(Verifier, 2)
->
%% Custom verifier function
case Verifier(Username, Password) of
{ok, AuthInfo} when is_map(AuthInfo) ->
{ok, add_provider_metadata(AuthInfo)};
{error, _} = Error ->
Error
end;
verify_credentials(Username, Password, #{credentials := Creds, hash_passwords := HashPwd}) when
map_size(Creds) > 0
->
%% Lookup in credentials map. The unknown-user path runs the
%% same verify_password/2 work as the configured-user path so
%% timing doesn't leak username existence.
case maps:get(Username, Creds, undefined) of
undefined ->
_ = dummy_verify(Password, HashPwd),
{error, invalid_credentials};
ExpectedPassword when is_binary(ExpectedPassword) ->
verify_password(Username, Password, ExpectedPassword, HashPwd);
#{password := ExpectedPassword} = Info ->
case verify_password(Username, Password, ExpectedPassword, HashPwd) of
{ok, _} ->
AuthInfo = #{
subject => Username,
scopes => maps:get(scopes, Info, []),
claims => maps:get(claims, Info, #{}),
metadata => maps:get(metadata, Info, #{})
},
{ok, add_provider_metadata(AuthInfo)};
Error ->
Error
end
end;
verify_credentials(_Username, _Password, _State) ->
%% No credentials or verifier configured
{error, {error, no_credentials_configured}}.
verify_password(Username, Password, ExpectedPassword, true) ->
case verify_password(Password, ExpectedPassword) of
ok ->
{ok, #{subject => Username, scopes => [], claims => #{}}};
{error, invalid_credentials} ->
{error, invalid_credentials}
end;
verify_password(Username, Password, ExpectedPassword, false) ->
%% Plain-text comparison via constant-time hash compare.
HashedInput = legacy_sha256_hex(Password),
HashedExpected = legacy_sha256_hex(ExpectedPassword),
case crypto:hash_equals(HashedInput, HashedExpected) of
true ->
{ok, #{subject => Username, scopes => [], claims => #{}}};
false ->
{error, invalid_credentials}
end.
%%====================================================================
%% Utilities
%%====================================================================
%% @doc Hash a password using the default modern algorithm
%% (PBKDF2-SHA256). Use {@link hash_password/2} to choose
%% explicitly.
-spec hash_password(Password :: binary()) -> binary().
hash_password(Password) ->
hash_password(Password, #{}).
%% @doc Hash a password using the chosen algorithm.
%%
%% `Opts' may contain:
%% <ul>
%% <li>`algorithm' — `pbkdf2-sha256' (default) or `sha256-hex'
%% (deprecated; kept for migration only).</li>
%% <li>`iterations' — PBKDF2 iteration count (default 100000).</li>
%% </ul>
%%
%% Stored format for the modern hash:
%% `pbkdf2-sha256$<iters>$<base64(salt)>$<base64(hash)>'.
-spec hash_password(Password :: binary(), Opts :: map()) -> binary().
hash_password(Password, Opts) ->
case maps:get(algorithm, Opts, 'pbkdf2-sha256') of
'sha256-hex' ->
legacy_sha256_hex(Password);
'pbkdf2-sha256' ->
Iterations = maps:get(iterations, Opts, ?PBKDF2_ITERATIONS),
Salt = crypto:strong_rand_bytes(?PBKDF2_SALT_BYTES),
Hash = crypto:pbkdf2_hmac(
sha256,
Password,
Salt,
Iterations,
?PBKDF2_HASH_BYTES
),
iolist_to_binary([
<<"pbkdf2-sha256$">>,
integer_to_binary(Iterations),
<<"$">>,
base64:encode(Salt),
<<"$">>,
base64:encode(Hash)
])
end.
%% @doc Verify a plaintext `Password' against a `Stored' hash. Accepts
%% both the modern `pbkdf2-sha256$...' format and legacy hex SHA-256
%% digests (the latter for one release, with a logger warning on
%% match). Returns `ok' or `{error, invalid_credentials}'.
-spec verify_password(Password :: binary(), Stored :: binary()) ->
ok | {error, invalid_credentials}.
verify_password(Password, <<"pbkdf2-sha256$", Rest/binary>>) ->
case parse_pbkdf2(Rest) of
{ok, Iterations, Salt, ExpectedHash} ->
ActualHash = crypto:pbkdf2_hmac(
sha256,
Password,
Salt,
Iterations,
byte_size(ExpectedHash)
),
case crypto:hash_equals(ActualHash, ExpectedHash) of
true -> ok;
false -> {error, invalid_credentials}
end;
error ->
{error, invalid_credentials}
end;
verify_password(Password, Stored) when byte_size(Stored) =:= 64 ->
%% Legacy hex SHA-256.
case crypto:hash_equals(legacy_sha256_hex(Password), Stored) of
true ->
logger:warning(
"barrel_mcp_auth_basic: legacy sha256-hex "
"password hash accepted; rotate to "
"pbkdf2-sha256"
),
ok;
false ->
{error, invalid_credentials}
end;
verify_password(_Password, _Stored) ->
{error, invalid_credentials}.
parse_pbkdf2(Bin) ->
case binary:split(Bin, <<"$">>, [global]) of
[IterBin, SaltB64, HashB64] ->
try
{ok, binary_to_integer(IterBin), base64:decode(SaltB64), base64:decode(HashB64)}
catch
_:_ -> error
end;
_ ->
error
end.
legacy_sha256_hex(Password) ->
Digest = crypto:hash(sha256, Password),
encode_hex(Digest).
%% Lazily-built PBKDF2 hash used as a "fake check" target on the
%% unknown-user path so the verify_password/2 work cost matches
%% the configured-user path. Cached in `persistent_term' so we pay
%% the (~tens of ms) construction cost at most once per node.
%% Constant-work stand-in for the unknown-user path. Does the same
%% comparison work as verify_password/4 for the active `hash_passwords'
%% mode so request timing does not reveal whether a username exists.
%% Previously this always ran PBKDF2, making unknown users markedly
%% slower than known users when hash_passwords = false (still a
%% username-existence oracle, just inverted).
dummy_verify(Password, true) ->
verify_password(Password, dummy_hash());
dummy_verify(Password, false) ->
HashedInput = legacy_sha256_hex(Password),
HashedExpected = legacy_sha256_hex(<<"unused-timing-stand-in">>),
crypto:hash_equals(HashedInput, HashedExpected).
-define(DUMMY_HASH_KEY, {?MODULE, dummy_hash}).
dummy_hash() ->
case persistent_term:get(?DUMMY_HASH_KEY, undefined) of
undefined ->
H = hash_password(<<"unused-timing-stand-in">>),
persistent_term:put(?DUMMY_HASH_KEY, H),
H;
H ->
H
end.
encode_hex(Bin) ->
<<<<(hex_digit(N))>> || <<N:4>> <= Bin>>.
hex_digit(N) when N < 10 -> $0 + N;
hex_digit(N) -> $a + N - 10.
add_provider_metadata(AuthInfo) ->
Metadata = maps:get(metadata, AuthInfo, #{}),
AuthInfo#{metadata => Metadata#{provider => barrel_mcp_auth_basic}}.