Skip to content

Commit b42c56d

Browse files
authored
Add oauth client credentials grant (#162)
1 parent cdc6d80 commit b42c56d

File tree

3 files changed

+97
-2
lines changed

3 files changed

+97
-2
lines changed

src/hex_api_oauth.erl

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@
66
device_authorization/4,
77
poll_device_token/3,
88
refresh_token/3,
9-
revoke_token/3
9+
revoke_token/3,
10+
client_credentials_token/4,
11+
client_credentials_token/5
1012
]).
1113

1214
%% @doc
@@ -115,6 +117,59 @@ refresh_token(Config, ClientId, RefreshToken) ->
115117
},
116118
hex_api:post(Config, Path, Params).
117119

120+
%% @doc
121+
%% Exchanges an API key for an OAuth access token using the client credentials grant.
122+
%%
123+
%% @see client_credentials_token/5
124+
%% @end
125+
-spec client_credentials_token(hex_core:config(), binary(), binary(), binary()) -> hex_api:response().
126+
client_credentials_token(Config, ClientId, ApiKey, Scope) ->
127+
client_credentials_token(Config, ClientId, ApiKey, Scope, []).
128+
129+
%% @doc
130+
%% Exchanges an API key for an OAuth access token using the client credentials grant with optional parameters.
131+
%%
132+
%% This grant type allows exchanging a long-lived API key for a short-lived OAuth access token.
133+
%% The API key is sent as the client_secret parameter.
134+
%%
135+
%% Options:
136+
%% * `name' - A name to identify the token (e.g., hostname of the client)
137+
%%
138+
%% Returns:
139+
%% - `{ok, {200, _, Token}}` - Token exchange successful
140+
%% - `{ok, {400, _, #{<<"error">> => ...}}}` - Invalid request or scope
141+
%% - `{ok, {401, _, #{<<"error">> => ...}}}` - Invalid API key
142+
%%
143+
%% Examples:
144+
%%
145+
%% ```
146+
%% 1> Config = hex_core:default_config().
147+
%% 2> hex_api_oauth:client_credentials_token(Config, <<"cli">>, ApiKey, <<"api">>).
148+
%% {ok, {200, _, #{
149+
%% <<"access_token">> => <<"...">>,
150+
%% <<"token_type">> => <<"bearer">>,
151+
%% <<"expires_in">> => 1800,
152+
%% <<"scope">> => <<"api">>
153+
%% }}}
154+
%%
155+
%% 3> hex_api_oauth:client_credentials_token(Config, <<"cli">>, ApiKey, <<"api">>, [{name, <<"MyMachine">>}]).
156+
%% '''
157+
%% @end
158+
-spec client_credentials_token(hex_core:config(), binary(), binary(), binary(), proplists:proplist()) -> hex_api:response().
159+
client_credentials_token(Config, ClientId, ApiKey, Scope, Opts) ->
160+
Path = <<"oauth/token">>,
161+
Params0 = #{
162+
<<"grant_type">> => <<"client_credentials">>,
163+
<<"client_id">> => ClientId,
164+
<<"client_secret">> => ApiKey,
165+
<<"scope">> => Scope
166+
},
167+
Params = case proplists:get_value(name, Opts) of
168+
undefined -> Params0;
169+
Name -> Params0#{<<"name">> => Name}
170+
end,
171+
hex_api:post(Config, Path, Params).
172+
118173
%% @doc
119174
%% Revokes an OAuth token (RFC 7009).
120175
%%

test/hex_api_SUITE.erl

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ suite() ->
2020

2121
all() ->
2222
[package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test,
23-
oauth_device_flow_test, oauth_refresh_token_test, oauth_revoke_test,
23+
oauth_device_flow_test, oauth_refresh_token_test, oauth_revoke_test, oauth_client_credentials_test,
2424
publish_with_expect_header_test, publish_without_expect_header_test].
2525

2626
package_test(_Config) ->
@@ -159,6 +159,35 @@ oauth_revoke_test(_Config) ->
159159
{ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken),
160160
ok.
161161

162+
oauth_client_credentials_test(_Config) ->
163+
% Test client credentials token exchange without options
164+
ClientId = <<"cli">>,
165+
ApiKey = <<"test_api_key">>,
166+
Scope = <<"api">>,
167+
{ok, {200, _, TokenResponse}} = hex_api_oauth:client_credentials_token(?CONFIG, ClientId, ApiKey, Scope),
168+
#{
169+
<<"access_token">> := AccessToken,
170+
<<"token_type">> := <<"bearer">>,
171+
<<"expires_in">> := ExpiresIn,
172+
<<"scope">> := Scope
173+
} = TokenResponse,
174+
?assert(is_binary(AccessToken)),
175+
?assert(is_integer(ExpiresIn)),
176+
% Client credentials grant should not return a refresh token
177+
?assertEqual(false, maps:is_key(<<"refresh_token">>, TokenResponse)),
178+
179+
% Test client credentials token exchange with name option
180+
Name = <<"MyMachine">>,
181+
{ok, {200, _, TokenResponse2}} = hex_api_oauth:client_credentials_token(?CONFIG, ClientId, ApiKey, Scope, [{name, Name}]),
182+
#{
183+
<<"access_token">> := AccessToken2,
184+
<<"token_type">> := <<"bearer">>,
185+
<<"expires_in">> := ExpiresIn2
186+
} = TokenResponse2,
187+
?assert(is_binary(AccessToken2)),
188+
?assert(is_integer(ExpiresIn2)),
189+
ok.
190+
162191
publish_with_expect_header_test(_Config) ->
163192
% Test that send_100_continue => true includes Expect: 100-continue header
164193
Metadata = #{<<"name">> => <<"expect_test">>, <<"version">> => <<"1.0.0">>},

test/support/hex_http_test.erl

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,17 @@ fixture(post, <<?TEST_API_URL, "/oauth/token">>, _, {_, Body}) ->
329329
<<"expires_in">> => 3600
330330
},
331331
{ok, {200, api_headers(), term_to_binary(Payload)}};
332+
<<"client_credentials">> ->
333+
% Simulate successful client credentials token exchange
334+
#{<<"scope">> := Scope} = DecodedBody,
335+
AccessToken = base64:encode(crypto:strong_rand_bytes(32)),
336+
Payload = #{
337+
<<"access_token">> => AccessToken,
338+
<<"token_type">> => <<"bearer">>,
339+
<<"expires_in">> => 1800,
340+
<<"scope">> => Scope
341+
},
342+
{ok, {200, api_headers(), term_to_binary(Payload)}};
332343
_ ->
333344
ErrorPayload = #{
334345
<<"error">> => <<"unsupported_grant_type">>,

0 commit comments

Comments
 (0)