Skip to main content

src/barrel_mcp_auth_apikey.erl

%%%-------------------------------------------------------------------
%%% @author Benoit Chesneau
%%% @copyright 2024-2026 Benoit Chesneau
%%% @doc API key authentication provider for barrel_mcp.
%%%
%%% Supports API key authentication via X-API-Key header, custom
%%% headers, or Authorization header with ApiKey scheme.
%%%
%%% == Configuration Options ==
%%%
%%% <ul>
%%%   <li>`keys' - Map of API key to auth info, or list of valid keys</li>
%%%   <li>`verifier' - Custom verification function</li>
%%%   <li>`header_name' - Custom header name (default: X-API-Key)</li>
%%%   <li>`hash_keys' - If true, stored keys are SHA256 hashes</li>
%%% </ul>
%%%
%%% @see barrel_mcp_auth
%%% @end
%%%-------------------------------------------------------------------
-module(barrel_mcp_auth_apikey).

-behaviour(barrel_mcp_auth).

%% barrel_mcp_auth callbacks
-export([
    init/1,
    authenticate/2,
    challenge/2
]).

%% Utilities
-export([
    hash_key/1,
    hash_key/2,
    verify_key/2,
    verify_key/3
]).

%%====================================================================
%% barrel_mcp_auth callbacks
%%====================================================================

%% @doc Initialize the API key provider.
-spec init(map()) -> {ok, map()}.
init(Opts) ->
    Keys = normalize_keys(maps:get(keys, Opts, #{})),
    State = #{
        keys => Keys,
        verifier => maps:get(verifier, Opts, undefined),
        header_name => maps:get(header_name, Opts, <<"x-api-key">>),
        hash_keys => maps:get(hash_keys, Opts, false),
        pepper => maps:get(pepper, Opts, undefined)
    },
    {ok, State}.

%% @doc Authenticate a request using API key.
-spec authenticate(map(), map()) ->
    {ok, barrel_mcp_auth:auth_info()} | {error, barrel_mcp_auth:auth_error()}.
authenticate(Request, State) ->
    Headers = maps:get(headers, Request, #{}),
    Opts = #{header_name => maps:get(header_name, State, <<"x-api-key">>)},
    case barrel_mcp_auth:extract_api_key(Headers, Opts) of
        {ok, Key} ->
            verify_against_state(Key, State);
        {error, no_key} ->
            {error, unauthorized}
    end.

%% @doc Generate authentication challenge.
-spec challenge(barrel_mcp_auth:auth_error(), map()) ->
    {integer(), map(), binary()}.
challenge(Reason, State) ->
    HeaderName = maps:get(header_name, State, <<"x-api-key">>),

    {StatusCode, ErrorCode, ErrorDesc} =
        case Reason of
            unauthorized ->
                {401, <<"invalid_request">>, <<"API key required">>};
            invalid_credentials ->
                {401, <<"invalid_key">>, <<"Invalid API key">>};
            _ ->
                {401, <<"invalid_key">>, <<"Authentication failed">>}
        end,

    Body = iolist_to_binary(
        json:encode(#{
            <<"error">> => ErrorCode,
            <<"error_description">> => ErrorDesc
        })
    ),

    Headers = #{
        <<"www-authenticate">> => <<"ApiKey header=\"", HeaderName/binary, "\"">>,
        <<"content-type">> => <<"application/json">>
    },

    {StatusCode, Headers, Body}.

%%====================================================================
%% Key verification
%%====================================================================

verify_against_state(Key, #{verifier := Verifier}) when is_function(Verifier, 1) ->
    case Verifier(Key) of
        {ok, AuthInfo} when is_map(AuthInfo) ->
            {ok, add_provider_metadata(AuthInfo)};
        {error, _} = Error ->
            Error
    end;
verify_against_state(Key, #{keys := Keys, hash_keys := HashKeys} = State) when
    map_size(Keys) > 0
->
    %% Lookup. With `hash_keys' = true the map keys are stored as
    %% legacy hex SHA-256 digests (or, going forward, the new
    %% `hmac-sha256$...' format).
    Pepper = maps:get(pepper, State, undefined),
    LookupKey =
        case HashKeys of
            true ->
                case Pepper of
                    undefined -> legacy_sha256_hex(Key);
                    _ -> hmac_format(Key, Pepper)
                end;
            false ->
                Key
        end,
    case maps:get(LookupKey, Keys, undefined) of
        undefined ->
            %% Try the alternate stored form for backward compat.
            case HashKeys andalso Pepper =/= undefined of
                true ->
                    LegacyKey = legacy_sha256_hex(Key),
                    case maps:get(LegacyKey, Keys, undefined) of
                        undefined -> {error, invalid_credentials};
                        Info -> info_to_reply(Info, LegacyKey)
                    end;
                false ->
                    {error, invalid_credentials}
            end;
        Info ->
            info_to_reply(Info, LookupKey)
    end;
verify_against_state(_Key, _State) ->
    {error, {error, no_keys_configured}}.

info_to_reply(true, LookupKey) ->
    {ok,
        add_provider_metadata(#{
            subject => LookupKey,
            scopes => [],
            claims => #{}
        })};
info_to_reply(AuthInfo, _LookupKey) when is_map(AuthInfo) ->
    {ok, add_provider_metadata(AuthInfo)}.

%%====================================================================
%% Utilities
%%====================================================================

%% @doc Hash an API key using the legacy SHA-256 hex format. Kept
%% for migration; use {@link hash_key/2} with a pepper for new
%% deployments.
-spec hash_key(Key :: binary()) -> binary().
hash_key(Key) ->
    legacy_sha256_hex(Key).

%% @doc Hash an API key with the chosen format.
%%
%% `Opts' may include:
%% <ul>
%%   <li>`pepper' (binary, required for the new format) — server-side
%%       secret mixed into the HMAC. Stored format becomes
%%       `hmac-sha256$<base64(hash)>'.</li>
%% </ul>
-spec hash_key(Key :: binary(), Opts :: map()) -> binary().
hash_key(Key, #{pepper := Pepper}) when is_binary(Pepper) ->
    hmac_format(Key, Pepper);
hash_key(Key, _) ->
    legacy_sha256_hex(Key).

%% @doc Constant-time comparison of a presented `Key' against a
%% `Stored' digest. Accepts only the legacy hex SHA-256 format —
%% the modern `hmac-sha256$...' format requires the server-side
%% pepper, which {@link verify_key/3} takes explicitly. This shim
%% rejects HMAC-format inputs so callers don't accidentally treat
%% them as verified.
-spec verify_key(Key :: binary(), Stored :: binary()) ->
    ok | {error, invalid_credentials} | {error, pepper_required}.
verify_key(_Key, <<"hmac-sha256$", _/binary>>) ->
    {error, pepper_required};
verify_key(Key, Stored) when byte_size(Stored) =:= 64 ->
    case crypto:hash_equals(legacy_sha256_hex(Key), Stored) of
        true -> ok;
        false -> {error, invalid_credentials}
    end;
verify_key(_Key, _Stored) ->
    {error, invalid_credentials}.

%% @doc Constant-time comparison of a presented `Key' against a
%% `Stored' digest, with the server-side `Pepper' used for the
%% `hmac-sha256$...' format. The `Pepper' is ignored for legacy
%% hex SHA-256 digests (they were produced without a pepper). Use
%% this from any code that owns the pepper (config tooling,
%% tests) — the auth provider's internal authenticate path goes
%% through `verify_against_state/2' which already has the pepper
%% in state.
-spec verify_key(
    Key :: binary(),
    Stored :: binary(),
    Pepper :: binary() | undefined
) ->
    ok | {error, invalid_credentials}.
verify_key(Key, <<"hmac-sha256$", _/binary>> = Stored, Pepper) when
    is_binary(Pepper)
->
    Computed = hmac_format(Key, Pepper),
    case crypto:hash_equals(Computed, Stored) of
        true -> ok;
        false -> {error, invalid_credentials}
    end;
verify_key(_Key, <<"hmac-sha256$", _/binary>>, undefined) ->
    {error, invalid_credentials};
verify_key(Key, Stored, _Pepper) when byte_size(Stored) =:= 64 ->
    case crypto:hash_equals(legacy_sha256_hex(Key), Stored) of
        true -> ok;
        false -> {error, invalid_credentials}
    end;
verify_key(_Key, _Stored, _Pepper) ->
    {error, invalid_credentials}.

%% Internal: build the new stored format `hmac-sha256$<b64(hash)>'.
hmac_format(Key, Pepper) ->
    Hash = crypto:mac(hmac, sha256, Pepper, Key),
    iolist_to_binary([<<"hmac-sha256$">>, base64:encode(Hash)]).

legacy_sha256_hex(Key) ->
    Digest = crypto:hash(sha256, Key),
    encode_hex(Digest).

encode_hex(Bin) ->
    <<<<(hex_digit(N))>> || <<N:4>> <= Bin>>.

hex_digit(N) when N < 10 -> $0 + N;
hex_digit(N) -> $a + N - 10.

normalize_keys(Keys) when is_map(Keys) ->
    Keys;
normalize_keys(Keys) when is_list(Keys) ->
    %% Convert list of keys to map with true values
    maps:from_list([{K, true} || K <- Keys]);
normalize_keys(_) ->
    #{}.

add_provider_metadata(AuthInfo) ->
    Metadata = maps:get(metadata, AuthInfo, #{}),
    AuthInfo#{metadata => Metadata#{provider => barrel_mcp_auth_apikey}}.