From afcc9afdbc19e5c276eb99dbca32fabc7e17d019 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sat, 11 Oct 2025 20:00:05 +0200 Subject: [PATCH] Add oauth client credentials grant --- src/hex_api_oauth.erl | 57 +++++++++++++++++++++++++++++++++- test/hex_api_SUITE.erl | 31 +++++++++++++++++- test/support/hex_http_test.erl | 11 +++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl index f4f0432..ba01b1f 100644 --- a/src/hex_api_oauth.erl +++ b/src/hex_api_oauth.erl @@ -6,7 +6,9 @@ device_authorization/4, poll_device_token/3, refresh_token/3, - revoke_token/3 + revoke_token/3, + client_credentials_token/4, + client_credentials_token/5 ]). %% @doc @@ -115,6 +117,59 @@ refresh_token(Config, ClientId, RefreshToken) -> }, hex_api:post(Config, Path, Params). +%% @doc +%% Exchanges an API key for an OAuth access token using the client credentials grant. +%% +%% @see client_credentials_token/5 +%% @end +-spec client_credentials_token(hex_core:config(), binary(), binary(), binary()) -> hex_api:response(). +client_credentials_token(Config, ClientId, ApiKey, Scope) -> + client_credentials_token(Config, ClientId, ApiKey, Scope, []). + +%% @doc +%% Exchanges an API key for an OAuth access token using the client credentials grant with optional parameters. +%% +%% This grant type allows exchanging a long-lived API key for a short-lived OAuth access token. +%% The API key is sent as the client_secret parameter. +%% +%% Options: +%% * `name' - A name to identify the token (e.g., hostname of the client) +%% +%% Returns: +%% - `{ok, {200, _, Token}}` - Token exchange successful +%% - `{ok, {400, _, #{<<"error">> => ...}}}` - Invalid request or scope +%% - `{ok, {401, _, #{<<"error">> => ...}}}` - Invalid API key +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> hex_api_oauth:client_credentials_token(Config, <<"cli">>, ApiKey, <<"api">>). +%% {ok, {200, _, #{ +%% <<"access_token">> => <<"...">>, +%% <<"token_type">> => <<"bearer">>, +%% <<"expires_in">> => 1800, +%% <<"scope">> => <<"api">> +%% }}} +%% +%% 3> hex_api_oauth:client_credentials_token(Config, <<"cli">>, ApiKey, <<"api">>, [{name, <<"MyMachine">>}]). +%% ''' +%% @end +-spec client_credentials_token(hex_core:config(), binary(), binary(), binary(), proplists:proplist()) -> hex_api:response(). +client_credentials_token(Config, ClientId, ApiKey, Scope, Opts) -> + Path = <<"oauth/token">>, + Params0 = #{ + <<"grant_type">> => <<"client_credentials">>, + <<"client_id">> => ClientId, + <<"client_secret">> => ApiKey, + <<"scope">> => Scope + }, + Params = case proplists:get_value(name, Opts) of + undefined -> Params0; + Name -> Params0#{<<"name">> => Name} + end, + hex_api:post(Config, Path, Params). + %% @doc %% Revokes an OAuth token (RFC 7009). %% diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index b44e0ce..998e9cc 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -20,7 +20,7 @@ suite() -> all() -> [package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test, - oauth_device_flow_test, oauth_refresh_token_test, oauth_revoke_test, + oauth_device_flow_test, oauth_refresh_token_test, oauth_revoke_test, oauth_client_credentials_test, publish_with_expect_header_test, publish_without_expect_header_test]. package_test(_Config) -> @@ -159,6 +159,35 @@ oauth_revoke_test(_Config) -> {ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken), ok. +oauth_client_credentials_test(_Config) -> + % Test client credentials token exchange without options + ClientId = <<"cli">>, + ApiKey = <<"test_api_key">>, + Scope = <<"api">>, + {ok, {200, _, TokenResponse}} = hex_api_oauth:client_credentials_token(?CONFIG, ClientId, ApiKey, Scope), + #{ + <<"access_token">> := AccessToken, + <<"token_type">> := <<"bearer">>, + <<"expires_in">> := ExpiresIn, + <<"scope">> := Scope + } = TokenResponse, + ?assert(is_binary(AccessToken)), + ?assert(is_integer(ExpiresIn)), + % Client credentials grant should not return a refresh token + ?assertEqual(false, maps:is_key(<<"refresh_token">>, TokenResponse)), + + % Test client credentials token exchange with name option + Name = <<"MyMachine">>, + {ok, {200, _, TokenResponse2}} = hex_api_oauth:client_credentials_token(?CONFIG, ClientId, ApiKey, Scope, [{name, Name}]), + #{ + <<"access_token">> := AccessToken2, + <<"token_type">> := <<"bearer">>, + <<"expires_in">> := ExpiresIn2 + } = TokenResponse2, + ?assert(is_binary(AccessToken2)), + ?assert(is_integer(ExpiresIn2)), + ok. + publish_with_expect_header_test(_Config) -> % Test that send_100_continue => true includes Expect: 100-continue header Metadata = #{<<"name">> => <<"expect_test">>, <<"version">> => <<"1.0.0">>}, diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 9397afb..8de42bf 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -329,6 +329,17 @@ fixture(post, <>, _, {_, Body}) -> <<"expires_in">> => 3600 }, {ok, {200, api_headers(), term_to_binary(Payload)}}; + <<"client_credentials">> -> + % Simulate successful client credentials token exchange + #{<<"scope">> := Scope} = DecodedBody, + AccessToken = base64:encode(crypto:strong_rand_bytes(32)), + Payload = #{ + <<"access_token">> => AccessToken, + <<"token_type">> => <<"bearer">>, + <<"expires_in">> => 1800, + <<"scope">> => Scope + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; _ -> ErrorPayload = #{ <<"error">> => <<"unsupported_grant_type">>,