Skip to content

Commit a042bb5

Browse files
authored
Add oauth API (#159)
1 parent 4f48e5f commit a042bb5

File tree

3 files changed

+300
-1
lines changed

3 files changed

+300
-1
lines changed

src/hex_api_oauth.erl

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
%% @doc
2+
%% Hex HTTP API - OAuth.
3+
-module(hex_api_oauth).
4+
-export([
5+
device_authorization/3,
6+
device_authorization/4,
7+
poll_device_token/3,
8+
exchange_token/4,
9+
refresh_token/3,
10+
revoke_token/3
11+
]).
12+
13+
%% @doc
14+
%% Initiates the OAuth device authorization flow.
15+
%%
16+
%% @see device_authorization/4
17+
%% @end
18+
-spec device_authorization(hex_core:config(), binary(), binary()) -> hex_api:response().
19+
device_authorization(Config, ClientId, Scope) ->
20+
device_authorization(Config, ClientId, Scope, []).
21+
22+
%% @doc
23+
%% Initiates the OAuth device authorization flow with optional parameters.
24+
%%
25+
%% Returns device code, user code, and verification URIs for user authentication.
26+
%%
27+
%% Options:
28+
%% * `name' - A name to identify the token (e.g., hostname of the device)
29+
%%
30+
%% Examples:
31+
%%
32+
%% ```
33+
%% 1> Config = hex_core:default_config().
34+
%% 2> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>).
35+
%% {ok,{200, ..., #{
36+
%% <<"device_code">> => <<"...">>,
37+
%% <<"user_code">> => <<"ABCD-1234">>,
38+
%% <<"verification_uri">> => <<"https://hex.pm/oauth/device">>,
39+
%% <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=ABCD-1234">>,
40+
%% <<"expires_in">> => 600,
41+
%% <<"interval">> => 5
42+
%% }}}
43+
%%
44+
%% 3> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>, [{name, <<"MyMachine">>}]).
45+
%% '''
46+
%% @end
47+
-spec device_authorization(hex_core:config(), binary(), binary(), proplists:proplist()) -> hex_api:response().
48+
device_authorization(Config, ClientId, Scope, Opts) ->
49+
Path = <<"oauth/device_authorization">>,
50+
Params0 = #{
51+
<<"client_id">> => ClientId,
52+
<<"scope">> => Scope
53+
},
54+
Params = case proplists:get_value(name, Opts) of
55+
undefined -> Params0;
56+
Name -> Params0#{<<"name">> => Name}
57+
end,
58+
hex_api:post(Config, Path, Params).
59+
60+
%% @doc
61+
%% Polls the OAuth token endpoint for device authorization completion.
62+
%%
63+
%% Returns:
64+
%% - `{ok, {200, _, Token}}` - Authorization complete
65+
%% - `{ok, {400, _, #{<<"error">> => <<"authorization_pending">>}}}` - Still waiting
66+
%% - `{ok, {400, _, #{<<"error">> => <<"slow_down">>}}}` - Polling too fast
67+
%% - `{ok, {400, _, #{<<"error">> => <<"expired_token">>}}}` - Code expired
68+
%% - `{ok, {403, _, #{<<"error">> => <<"access_denied">>}}}` - User denied
69+
%%
70+
%% Examples:
71+
%%
72+
%% ```
73+
%% 1> Config = hex_core:default_config().
74+
%% 2> hex_api_oauth:poll_device_token(Config, <<"cli">>, DeviceCode).
75+
%% {ok, {200, _, #{
76+
%% <<"access_token">> => <<"...">>,
77+
%% <<"refresh_token">> => <<"...">>,
78+
%% <<"token_type">> => <<"Bearer">>,
79+
%% <<"expires_in">> => 3600
80+
%% }}}
81+
%% '''
82+
%% @end
83+
-spec poll_device_token(hex_core:config(), binary(), binary()) -> hex_api:response().
84+
poll_device_token(Config, ClientId, DeviceCode) ->
85+
Path = <<"oauth/token">>,
86+
Params = #{
87+
<<"grant_type">> => <<"urn:ietf:params:oauth:grant-type:device_code">>,
88+
<<"device_code">> => DeviceCode,
89+
<<"client_id">> => ClientId
90+
},
91+
hex_api:post(Config, Path, Params).
92+
93+
%% @doc
94+
%% Exchanges a token for a new token with different scopes using RFC 8693 token exchange.
95+
%%
96+
%% Examples:
97+
%%
98+
%% ```
99+
%% 1> Config = hex_core:default_config().
100+
%% 2> hex_api_oauth:exchange_token(Config, <<"cli">>, SubjectToken, <<"api:write">>).
101+
%% {ok, {200, _, #{
102+
%% <<"access_token">> => <<"...">>,
103+
%% <<"refresh_token">> => <<"...">>,
104+
%% <<"token_type">> => <<"Bearer">>,
105+
%% <<"expires_in">> => 3600
106+
%% }}}
107+
%% '''
108+
%% @end
109+
-spec exchange_token(hex_core:config(), binary(), binary(), binary()) -> hex_api:response().
110+
exchange_token(Config, ClientId, SubjectToken, Scope) ->
111+
Path = <<"oauth/token">>,
112+
Params = #{
113+
<<"grant_type">> => <<"urn:ietf:params:oauth:grant-type:token-exchange">>,
114+
<<"subject_token">> => SubjectToken,
115+
<<"subject_token_type">> => <<"urn:ietf:params:oauth:token-type:access_token">>,
116+
<<"client_id">> => ClientId,
117+
<<"scope">> => Scope
118+
},
119+
hex_api:post(Config, Path, Params).
120+
121+
%% @doc
122+
%% Refreshes an access token using a refresh token.
123+
%%
124+
%% Examples:
125+
%%
126+
%% ```
127+
%% 1> Config = hex_core:default_config().
128+
%% 2> hex_api_oauth:refresh_token(Config, <<"cli">>, RefreshToken).
129+
%% {ok, {200, _, #{
130+
%% <<"access_token">> => <<"...">>,
131+
%% <<"refresh_token">> => <<"...">>,
132+
%% <<"token_type">> => <<"Bearer">>,
133+
%% <<"expires_in">> => 3600
134+
%% }}}
135+
%% '''
136+
%% @end
137+
-spec refresh_token(hex_core:config(), binary(), binary()) -> hex_api:response().
138+
refresh_token(Config, ClientId, RefreshToken) ->
139+
Path = <<"oauth/token">>,
140+
Params = #{
141+
<<"grant_type">> => <<"refresh_token">>,
142+
<<"refresh_token">> => RefreshToken,
143+
<<"client_id">> => ClientId
144+
},
145+
hex_api:post(Config, Path, Params).
146+
147+
%% @doc
148+
%% Revokes an OAuth token (RFC 7009).
149+
%%
150+
%% Can revoke either access tokens or refresh tokens.
151+
%% Returns 200 OK regardless of whether the token was found,
152+
%% following RFC 7009 security recommendations.
153+
%%
154+
%% Examples:
155+
%%
156+
%% ```
157+
%% 1> Config = hex_core:default_config().
158+
%% 2> hex_api_oauth:revoke_token(Config, <<"cli">>, Token).
159+
%% {ok, {200, ..., nil}}
160+
%% '''
161+
%% @end
162+
-spec revoke_token(hex_core:config(), binary(), binary()) -> hex_api:response().
163+
revoke_token(Config, ClientId, Token) ->
164+
Path = <<"oauth/revoke">>,
165+
Params = #{
166+
<<"token">> => Token,
167+
<<"client_id">> => ClientId
168+
},
169+
hex_api:post(Config, Path, Params).

test/hex_api_SUITE.erl

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ suite() ->
1919
[{require, {ssl_certs, [test_pub, test_priv]}}].
2020

2121
all() ->
22-
[package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test].
22+
[package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test,
23+
oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test].
2324

2425
package_test(_Config) ->
2526
{ok, {200, _, Package}} = hex_api_package:get(?CONFIG, <<"ecto">>),
@@ -100,3 +101,70 @@ short_url_test(_Config) ->
100101
?assert(is_binary(ShortURL)),
101102
?assert(binary:match(ShortURL, <<"https://hex.pm/l/">>) =/= nomatch),
102103
ok.
104+
105+
oauth_device_flow_test(_Config) ->
106+
% Test device authorization initiation
107+
ClientId = <<"cli">>,
108+
Scope = <<"api:write">>,
109+
{ok, {200, _, DeviceResponse}} = hex_api_oauth:device_authorization(?CONFIG, ClientId, Scope),
110+
#{
111+
<<"device_code">> := DeviceCode,
112+
<<"user_code">> := UserCode,
113+
<<"verification_uri">> := VerificationURI,
114+
<<"verification_uri_complete">> := VerificationURIComplete,
115+
<<"expires_in">> := ExpiresIn,
116+
<<"interval">> := Interval
117+
} = DeviceResponse,
118+
?assert(is_binary(DeviceCode)),
119+
?assert(is_binary(UserCode)),
120+
?assert(is_binary(VerificationURI)),
121+
?assert(is_binary(VerificationURIComplete)),
122+
?assert(is_integer(ExpiresIn)),
123+
?assert(is_integer(Interval)),
124+
125+
% Test polling for token (should be pending initially)
126+
{ok, {400, _, PollResponse}} = hex_api_oauth:poll_device_token(?CONFIG, ClientId, DeviceCode),
127+
#{<<"error">> := <<"authorization_pending">>} = PollResponse,
128+
ok.
129+
130+
oauth_token_exchange_test(_Config) ->
131+
% Test token exchange
132+
ClientId = <<"cli">>,
133+
SubjectToken = <<"test_api_key">>,
134+
Scope = <<"api:read">>,
135+
{ok, {200, _, TokenResponse}} = hex_api_oauth:exchange_token(?CONFIG, ClientId, SubjectToken, Scope),
136+
#{
137+
<<"access_token">> := AccessToken,
138+
<<"token_type">> := <<"Bearer">>,
139+
<<"expires_in">> := ExpiresIn
140+
} = TokenResponse,
141+
?assert(is_binary(AccessToken)),
142+
?assert(is_integer(ExpiresIn)),
143+
ok.
144+
145+
oauth_refresh_token_test(_Config) ->
146+
% Test token refresh
147+
ClientId = <<"cli">>,
148+
RefreshTokenValue = <<"test_refresh_token">>,
149+
{ok, {200, _, RefreshResponse}} = hex_api_oauth:refresh_token(?CONFIG, ClientId, RefreshTokenValue),
150+
#{
151+
<<"access_token">> := NewAccessToken,
152+
<<"refresh_token">> := NewRefreshToken,
153+
<<"token_type">> := <<"Bearer">>,
154+
<<"expires_in">> := ExpiresIn
155+
} = RefreshResponse,
156+
?assert(is_binary(NewAccessToken)),
157+
?assert(is_binary(NewRefreshToken)),
158+
?assert(is_integer(ExpiresIn)),
159+
ok.
160+
161+
oauth_revoke_test(_Config) ->
162+
% Test token revocation
163+
ClientId = <<"cli">>,
164+
Token = <<"test_access_token">>,
165+
{ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, Token),
166+
167+
% Test revoking non-existent token (should still return 200)
168+
NonExistentToken = <<"non_existent_token">>,
169+
{ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken),
170+
ok.

test/support/hex_http_test.erl

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,68 @@ fixture(post, <<?TEST_API_URL, "/short_url">>, _, {_, Body}) ->
249249
},
250250
{ok, {201, api_headers(), term_to_binary(Payload)}};
251251

252+
%% OAuth API
253+
254+
fixture(post, <<?TEST_API_URL, "/oauth/device_authorization">>, _, {_, Body}) ->
255+
DecodedBody = binary_to_term(Body),
256+
#{<<"client_id">> := _ClientId, <<"scope">> := _Scope} = DecodedBody,
257+
DeviceCode = base64:encode(crypto:strong_rand_bytes(32)),
258+
UserCode = iolist_to_binary([
259+
integer_to_binary(rand:uniform(9999)), "-",
260+
integer_to_binary(rand:uniform(9999))
261+
]),
262+
Payload = #{
263+
<<"device_code">> => DeviceCode,
264+
<<"user_code">> => UserCode,
265+
<<"verification_uri">> => <<"https://hex.pm/oauth/device">>,
266+
<<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=", UserCode/binary>>,
267+
<<"expires_in">> => 600,
268+
<<"interval">> => 5
269+
},
270+
{ok, {200, api_headers(), term_to_binary(Payload)}};
271+
272+
fixture(post, <<?TEST_API_URL, "/oauth/token">>, _, {_, Body}) ->
273+
DecodedBody = binary_to_term(Body),
274+
case maps:get(<<"grant_type">>, DecodedBody) of
275+
<<"urn:ietf:params:oauth:grant-type:device_code">> ->
276+
% Simulate pending authorization
277+
ErrorPayload = #{
278+
<<"error">> => <<"authorization_pending">>,
279+
<<"error_description">> => <<"Authorization pending">>
280+
},
281+
{ok, {400, api_headers(), term_to_binary(ErrorPayload)}};
282+
<<"urn:ietf:params:oauth:grant-type:token-exchange">> ->
283+
% Simulate successful token exchange
284+
AccessToken = base64:encode(crypto:strong_rand_bytes(32)),
285+
Payload = #{
286+
<<"access_token">> => AccessToken,
287+
<<"token_type">> => <<"Bearer">>,
288+
<<"expires_in">> => 3600
289+
},
290+
{ok, {200, api_headers(), term_to_binary(Payload)}};
291+
<<"refresh_token">> ->
292+
% Simulate successful token refresh
293+
NewAccessToken = base64:encode(crypto:strong_rand_bytes(32)),
294+
NewRefreshToken = base64:encode(crypto:strong_rand_bytes(32)),
295+
Payload = #{
296+
<<"access_token">> => NewAccessToken,
297+
<<"refresh_token">> => NewRefreshToken,
298+
<<"token_type">> => <<"Bearer">>,
299+
<<"expires_in">> => 3600
300+
},
301+
{ok, {200, api_headers(), term_to_binary(Payload)}};
302+
_ ->
303+
ErrorPayload = #{
304+
<<"error">> => <<"unsupported_grant_type">>,
305+
<<"error_description">> => <<"Unsupported grant type">>
306+
},
307+
{ok, {400, api_headers(), term_to_binary(ErrorPayload)}}
308+
end;
309+
310+
fixture(post, <<?TEST_API_URL, "/oauth/revoke">>, _, _) ->
311+
% OAuth revoke always returns 200 OK per RFC 7009
312+
{ok, {200, api_headers(), term_to_binary(nil)}};
313+
252314
%% Other
253315

254316
fixture(Method, URI, _, _) ->

0 commit comments

Comments
 (0)