%%%------------------------------------------------------------------- %%% @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 == %%% %%% %%% %%% @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: %% %% %% Stored format for the modern hash: %% `pbkdf2-sha256$$$'. -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))>> || <> <= 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}}.