From 75e889ffe2b4dff0797c7bd11c5ec61c271decfb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 21 Sep 2025 15:20:12 +0200 Subject: [PATCH 1/3] Add oauth API --- src/hex_api_oauth.erl | 144 +++++++++++++++++++++++++++++++++ test/hex_api_SUITE.erl | 90 ++++++++++++++++++++- test/support/hex_http_test.erl | 62 ++++++++++++++ 3 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 src/hex_api_oauth.erl diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl new file mode 100644 index 0000000..a7e863c --- /dev/null +++ b/src/hex_api_oauth.erl @@ -0,0 +1,144 @@ +%% @doc +%% Hex HTTP API - OAuth. +-module(hex_api_oauth). +-export([ + device_authorization/2, + token/2, + revoke/2 +]). + +%% @doc +%% Initiates the OAuth device authorization flow. +%% +%% Returns device code, user code, and verification URIs for user authentication. +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{client_id => <<"cli">>, scope => <<"api:write">>}. +%% 3> hex_api_oauth:device_authorization(Config, Params). +%% {ok,{200, ..., #{ +%% <<"device_code">> => <<"...">>, +%% <<"user_code">> => <<"ABCD-1234">>, +%% <<"verification_uri">> => <<"https://hex.pm/oauth/device">>, +%% <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=ABCD-1234">>, +%% <<"expires_in">> => 600, +%% <<"interval">> => 5 +%% }}} +%% ''' +%% @end +-spec device_authorization(hex_core:config(), map()) -> hex_api:response(). +device_authorization(Config, Params) -> + Path = <<"oauth/device_authorization">>, + hex_api:post(Config, Path, Params). + +%% @doc +%% OAuth token endpoint supporting multiple grant types. +%% +%% Supported grant types: +%% - `authorization_code` - Exchange authorization code for token +%% - `urn:ietf:params:oauth:grant-type:device_code` - Poll for device authorization +%% - `refresh_token` - Refresh an existing token +%% - `urn:ietf:params:oauth:grant-type:token-exchange` - Token exchange (RFC 8693) +%% +%% ## Device Code Grant +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{ +%% grant_type => <<"urn:ietf:params:oauth:grant-type:device_code">>, +%% device_code => <<"...">>, +%% client_id => <<"cli">> +%% }. +%% 3> hex_api_oauth:token(Config, Params). +%% ''' +%% +%% Returns: +%% - `{ok, {200, _, Token}}` - Authorization complete +%% - `{ok, {400, _, #{<<"error">> => <<"authorization_pending">>}}}` - Still waiting +%% - `{ok, {400, _, #{<<"error">> => <<"slow_down">>}}}` - Polling too fast +%% - `{ok, {400, _, #{<<"error">> => <<"expired_token">>}}}` - Code expired +%% - `{ok, {403, _, #{<<"error">> => <<"access_denied">>}}}` - User denied +%% +%% ## Authorization Code Grant +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{ +%% grant_type => <<"authorization_code">>, +%% code => <<"...">>, +%% client_id => <<"...">>, +%% client_secret => <<"...">>, +%% redirect_uri => <<"...">>, +%% code_verifier => <<"...">> +%% }. +%% 3> hex_api_oauth:token(Config, Params). +%% ''' +%% +%% ## Refresh Token Grant +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{ +%% grant_type => <<"refresh_token">>, +%% refresh_token => <<"...">>, +%% client_id => <<"...">>, +%% client_secret => <<"...">> +%% }. +%% 3> hex_api_oauth:token(Config, Params). +%% ''' +%% +%% ## Token Exchange Grant (RFC 8693) +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{ +%% grant_type => <<"urn:ietf:params:oauth:grant-type:token-exchange">>, +%% subject_token => <<"...">>, +%% subject_token_type => <<"urn:x-oath:params:oauth:token-type:key">>, +%% client_id => <<"...">>, +%% scope => <<"api:read">> +%% }. +%% 3> hex_api_oauth:token(Config, Params). +%% ''' +%% +%% Successful response includes: +%% ``` +%% #{ +%% <<"access_token">> => <<"...">>, +%% <<"refresh_token">> => <<"...">>, +%% <<"token_type">> => <<"Bearer">>, +%% <<"expires_in">> => 3600 +%% } +%% ''' +%% @end +-spec token(hex_core:config(), map()) -> hex_api:response(). +token(Config, Params) -> + Path = <<"oauth/token">>, + hex_api:post(Config, Path, Params). + +%% @doc +%% Revokes an OAuth token (RFC 7009). +%% +%% Can revoke either access tokens or refresh tokens. +%% Returns 200 OK regardless of whether the token was found, +%% following RFC 7009 security recommendations. +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> Params = #{ +%% token => <<"...">>, +%% client_id => <<"cli">>, +%% token_type_hint => <<"access_token">> % optional +%% }. +%% 3> hex_api_oauth:revoke(Config, Params). +%% {ok, {200, ..., nil}} +%% ''' +%% @end +-spec revoke(hex_core:config(), map()) -> hex_api:response(). +revoke(Config, Params) -> + Path = <<"oauth/revoke">>, + hex_api:post(Config, Path, Params). \ No newline at end of file diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index 51abf6b..90525e0 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -19,7 +19,8 @@ suite() -> [{require, {ssl_certs, [test_pub, test_priv]}}]. all() -> - [package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test]. + [package_test, release_test, replace_test, user_test, owner_test, keys_test, auth_test, short_url_test, + oauth_device_flow_test, oauth_token_exchange_test, oauth_refresh_token_test, oauth_revoke_test]. package_test(_Config) -> {ok, {200, _, Package}} = hex_api_package:get(?CONFIG, <<"ecto">>), @@ -100,3 +101,90 @@ short_url_test(_Config) -> ?assert(is_binary(ShortURL)), ?assert(binary:match(ShortURL, <<"https://hex.pm/l/">>) =/= nomatch), ok. + +oauth_device_flow_test(_Config) -> + % Test device authorization initiation + DeviceParams = #{ + client_id => <<"cli">>, + scope => <<"api:write">> + }, + {ok, {200, _, DeviceResponse}} = hex_api_oauth:device_authorization(?CONFIG, DeviceParams), + #{ + <<"device_code">> := DeviceCode, + <<"user_code">> := UserCode, + <<"verification_uri">> := VerificationURI, + <<"verification_uri_complete">> := VerificationURIComplete, + <<"expires_in">> := ExpiresIn, + <<"interval">> := Interval + } = DeviceResponse, + ?assert(is_binary(DeviceCode)), + ?assert(is_binary(UserCode)), + ?assert(is_binary(VerificationURI)), + ?assert(is_binary(VerificationURIComplete)), + ?assert(is_integer(ExpiresIn)), + ?assert(is_integer(Interval)), + + % Test polling for token (should be pending initially) + PollParams = #{ + grant_type => <<"urn:ietf:params:oauth:grant-type:device_code">>, + device_code => DeviceCode, + client_id => <<"cli">> + }, + {ok, {400, _, PollResponse}} = hex_api_oauth:token(?CONFIG, PollParams), + #{<<"error">> := <<"authorization_pending">>} = PollResponse, + ok. + +oauth_token_exchange_test(_Config) -> + % Test token exchange + ExchangeParams = #{ + grant_type => <<"urn:ietf:params:oauth:grant-type:token-exchange">>, + subject_token => <<"test_api_key">>, + subject_token_type => <<"urn:x-oath:params:oauth:token-type:key">>, + client_id => <<"cli">>, + scope => <<"api:read">> + }, + {ok, {200, _, TokenResponse}} = hex_api_oauth:token(?CONFIG, ExchangeParams), + #{ + <<"access_token">> := AccessToken, + <<"token_type">> := <<"Bearer">>, + <<"expires_in">> := ExpiresIn + } = TokenResponse, + ?assert(is_binary(AccessToken)), + ?assert(is_integer(ExpiresIn)), + ok. + +oauth_refresh_token_test(_Config) -> + % Test token refresh + RefreshParams = #{ + grant_type => <<"refresh_token">>, + refresh_token => <<"test_refresh_token">>, + client_id => <<"cli">> + }, + {ok, {200, _, RefreshResponse}} = hex_api_oauth:token(?CONFIG, RefreshParams), + #{ + <<"access_token">> := NewAccessToken, + <<"refresh_token">> := NewRefreshToken, + <<"token_type">> := <<"Bearer">>, + <<"expires_in">> := ExpiresIn + } = RefreshResponse, + ?assert(is_binary(NewAccessToken)), + ?assert(is_binary(NewRefreshToken)), + ?assert(is_integer(ExpiresIn)), + ok. + +oauth_revoke_test(_Config) -> + % Test token revocation + RevokeParams = #{ + token => <<"test_access_token">>, + client_id => <<"cli">>, + token_type_hint => <<"access_token">> + }, + {ok, {200, _, nil}} = hex_api_oauth:revoke(?CONFIG, RevokeParams), + + % Test revoking non-existent token (should still return 200) + RevokeParams2 = #{ + token => <<"non_existent_token">>, + client_id => <<"cli">> + }, + {ok, {200, _, nil}} = hex_api_oauth:revoke(?CONFIG, RevokeParams2), + ok. diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 6473169..1330653 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -249,6 +249,68 @@ fixture(post, <>, _, {_, Body}) -> }, {ok, {201, api_headers(), term_to_binary(Payload)}}; +%% OAuth API + +fixture(post, <>, _, {_, Body}) -> + DecodedBody = binary_to_term(Body), + #{client_id := _ClientId, scope := _Scope} = DecodedBody, + DeviceCode = base64:encode(crypto:strong_rand_bytes(32)), + UserCode = iolist_to_binary([ + integer_to_binary(rand:uniform(9999)), "-", + integer_to_binary(rand:uniform(9999)) + ]), + Payload = #{ + <<"device_code">> => DeviceCode, + <<"user_code">> => UserCode, + <<"verification_uri">> => <<"https://hex.pm/oauth/device">>, + <<"verification_uri_complete">> => <<"https://hex.pm/oauth/device?user_code=", UserCode/binary>>, + <<"expires_in">> => 600, + <<"interval">> => 5 + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; + +fixture(post, <>, _, {_, Body}) -> + DecodedBody = binary_to_term(Body), + case maps:get(grant_type, DecodedBody) of + <<"urn:ietf:params:oauth:grant-type:device_code">> -> + % Simulate pending authorization + ErrorPayload = #{ + <<"error">> => <<"authorization_pending">>, + <<"error_description">> => <<"Authorization pending">> + }, + {ok, {400, api_headers(), term_to_binary(ErrorPayload)}}; + <<"urn:ietf:params:oauth:grant-type:token-exchange">> -> + % Simulate successful token exchange + AccessToken = base64:encode(crypto:strong_rand_bytes(32)), + Payload = #{ + <<"access_token">> => AccessToken, + <<"token_type">> => <<"Bearer">>, + <<"expires_in">> => 3600 + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; + <<"refresh_token">> -> + % Simulate successful token refresh + NewAccessToken = base64:encode(crypto:strong_rand_bytes(32)), + NewRefreshToken = base64:encode(crypto:strong_rand_bytes(32)), + Payload = #{ + <<"access_token">> => NewAccessToken, + <<"refresh_token">> => NewRefreshToken, + <<"token_type">> => <<"Bearer">>, + <<"expires_in">> => 3600 + }, + {ok, {200, api_headers(), term_to_binary(Payload)}}; + _ -> + ErrorPayload = #{ + <<"error">> => <<"unsupported_grant_type">>, + <<"error_description">> => <<"Unsupported grant type">> + }, + {ok, {400, api_headers(), term_to_binary(ErrorPayload)}} + end; + +fixture(post, <>, _, _) -> + % OAuth revoke always returns 200 OK per RFC 7009 + {ok, {200, api_headers(), term_to_binary(nil)}}; + %% Other fixture(Method, URI, _, _) -> From 94a912debff361db9fd0c4aa86a90545307a747f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Sun, 21 Sep 2025 15:31:37 +0200 Subject: [PATCH 2/3] Improve API --- src/hex_api_oauth.erl | 144 +++++++++++++++++---------------- test/hex_api_SUITE.erl | 52 ++++-------- test/support/hex_http_test.erl | 4 +- 3 files changed, 93 insertions(+), 107 deletions(-) diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl index a7e863c..0a4b2de 100644 --- a/src/hex_api_oauth.erl +++ b/src/hex_api_oauth.erl @@ -2,9 +2,11 @@ %% Hex HTTP API - OAuth. -module(hex_api_oauth). -export([ - device_authorization/2, - token/2, - revoke/2 + device_authorization/3, + poll_device_token/3, + exchange_token/4, + refresh_token/3, + revoke_token/3 ]). %% @doc @@ -16,8 +18,7 @@ %% %% ``` %% 1> Config = hex_core:default_config(). -%% 2> Params = #{client_id => <<"cli">>, scope => <<"api:write">>}. -%% 3> hex_api_oauth:device_authorization(Config, Params). +%% 2> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>). %% {ok,{200, ..., #{ %% <<"device_code">> => <<"...">>, %% <<"user_code">> => <<"ABCD-1234">>, @@ -28,31 +29,17 @@ %% }}} %% ''' %% @end --spec device_authorization(hex_core:config(), map()) -> hex_api:response(). -device_authorization(Config, Params) -> +-spec device_authorization(hex_core:config(), binary(), binary()) -> hex_api:response(). +device_authorization(Config, ClientId, Scope) -> Path = <<"oauth/device_authorization">>, + Params = #{ + <<"client_id">> => ClientId, + <<"scope">> => Scope + }, hex_api:post(Config, Path, Params). %% @doc -%% OAuth token endpoint supporting multiple grant types. -%% -%% Supported grant types: -%% - `authorization_code` - Exchange authorization code for token -%% - `urn:ietf:params:oauth:grant-type:device_code` - Poll for device authorization -%% - `refresh_token` - Refresh an existing token -%% - `urn:ietf:params:oauth:grant-type:token-exchange` - Token exchange (RFC 8693) -%% -%% ## Device Code Grant -%% -%% ``` -%% 1> Config = hex_core:default_config(). -%% 2> Params = #{ -%% grant_type => <<"urn:ietf:params:oauth:grant-type:device_code">>, -%% device_code => <<"...">>, -%% client_id => <<"cli">> -%% }. -%% 3> hex_api_oauth:token(Config, Params). -%% ''' +%% Polls the OAuth token endpoint for device authorization completion. %% %% Returns: %% - `{ok, {200, _, Token}}` - Authorization complete @@ -61,61 +48,81 @@ device_authorization(Config, Params) -> %% - `{ok, {400, _, #{<<"error">> => <<"expired_token">>}}}` - Code expired %% - `{ok, {403, _, #{<<"error">> => <<"access_denied">>}}}` - User denied %% -%% ## Authorization Code Grant +%% Examples: %% %% ``` %% 1> Config = hex_core:default_config(). -%% 2> Params = #{ -%% grant_type => <<"authorization_code">>, -%% code => <<"...">>, -%% client_id => <<"...">>, -%% client_secret => <<"...">>, -%% redirect_uri => <<"...">>, -%% code_verifier => <<"...">> -%% }. -%% 3> hex_api_oauth:token(Config, Params). +%% 2> hex_api_oauth:poll_device_token(Config, <<"cli">>, DeviceCode). +%% {ok, {200, _, #{ +%% <<"access_token">> => <<"...">>, +%% <<"refresh_token">> => <<"...">>, +%% <<"token_type">> => <<"Bearer">>, +%% <<"expires_in">> => 3600 +%% }}} %% ''' +%% @end +-spec poll_device_token(hex_core:config(), binary(), binary()) -> hex_api:response(). +poll_device_token(Config, ClientId, DeviceCode) -> + Path = <<"oauth/token">>, + Params = #{ + <<"grant_type">> => <<"urn:ietf:params:oauth:grant-type:device_code">>, + <<"device_code">> => DeviceCode, + <<"client_id">> => ClientId + }, + hex_api:post(Config, Path, Params). + +%% @doc +%% Exchanges a token for a new token with different scopes using RFC 8693 token exchange. %% -%% ## Refresh Token Grant +%% Examples: %% %% ``` %% 1> Config = hex_core:default_config(). -%% 2> Params = #{ -%% grant_type => <<"refresh_token">>, -%% refresh_token => <<"...">>, -%% client_id => <<"...">>, -%% client_secret => <<"...">> -%% }. -%% 3> hex_api_oauth:token(Config, Params). +%% 2> hex_api_oauth:exchange_token(Config, <<"cli">>, SubjectToken, <<"api:write">>). +%% {ok, {200, _, #{ +%% <<"access_token">> => <<"...">>, +%% <<"refresh_token">> => <<"...">>, +%% <<"token_type">> => <<"Bearer">>, +%% <<"expires_in">> => 3600 +%% }}} %% ''' +%% @end +-spec exchange_token(hex_core:config(), binary(), binary(), binary()) -> hex_api:response(). +exchange_token(Config, ClientId, SubjectToken, Scope) -> + Path = <<"oauth/token">>, + Params = #{ + <<"grant_type">> => <<"urn:ietf:params:oauth:grant-type:token-exchange">>, + <<"subject_token">> => SubjectToken, + <<"subject_token_type">> => <<"urn:ietf:params:oauth:token-type:access_token">>, + <<"client_id">> => ClientId, + <<"scope">> => Scope + }, + hex_api:post(Config, Path, Params). + +%% @doc +%% Refreshes an access token using a refresh token. %% -%% ## Token Exchange Grant (RFC 8693) +%% Examples: %% %% ``` %% 1> Config = hex_core:default_config(). -%% 2> Params = #{ -%% grant_type => <<"urn:ietf:params:oauth:grant-type:token-exchange">>, -%% subject_token => <<"...">>, -%% subject_token_type => <<"urn:x-oath:params:oauth:token-type:key">>, -%% client_id => <<"...">>, -%% scope => <<"api:read">> -%% }. -%% 3> hex_api_oauth:token(Config, Params). -%% ''' -%% -%% Successful response includes: -%% ``` -%% #{ +%% 2> hex_api_oauth:refresh_token(Config, <<"cli">>, RefreshToken). +%% {ok, {200, _, #{ %% <<"access_token">> => <<"...">>, %% <<"refresh_token">> => <<"...">>, %% <<"token_type">> => <<"Bearer">>, %% <<"expires_in">> => 3600 -%% } +%% }}} %% ''' %% @end --spec token(hex_core:config(), map()) -> hex_api:response(). -token(Config, Params) -> +-spec refresh_token(hex_core:config(), binary(), binary()) -> hex_api:response(). +refresh_token(Config, ClientId, RefreshToken) -> Path = <<"oauth/token">>, + Params = #{ + <<"grant_type">> => <<"refresh_token">>, + <<"refresh_token">> => RefreshToken, + <<"client_id">> => ClientId + }, hex_api:post(Config, Path, Params). %% @doc @@ -129,16 +136,15 @@ token(Config, Params) -> %% %% ``` %% 1> Config = hex_core:default_config(). -%% 2> Params = #{ -%% token => <<"...">>, -%% client_id => <<"cli">>, -%% token_type_hint => <<"access_token">> % optional -%% }. -%% 3> hex_api_oauth:revoke(Config, Params). +%% 2> hex_api_oauth:revoke_token(Config, <<"cli">>, Token). %% {ok, {200, ..., nil}} %% ''' %% @end --spec revoke(hex_core:config(), map()) -> hex_api:response(). -revoke(Config, Params) -> +-spec revoke_token(hex_core:config(), binary(), binary()) -> hex_api:response(). +revoke_token(Config, ClientId, Token) -> Path = <<"oauth/revoke">>, + Params = #{ + <<"token">> => Token, + <<"client_id">> => ClientId + }, hex_api:post(Config, Path, Params). \ No newline at end of file diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index 90525e0..daedc27 100644 --- a/test/hex_api_SUITE.erl +++ b/test/hex_api_SUITE.erl @@ -104,11 +104,9 @@ short_url_test(_Config) -> oauth_device_flow_test(_Config) -> % Test device authorization initiation - DeviceParams = #{ - client_id => <<"cli">>, - scope => <<"api:write">> - }, - {ok, {200, _, DeviceResponse}} = hex_api_oauth:device_authorization(?CONFIG, DeviceParams), + ClientId = <<"cli">>, + Scope = <<"api:write">>, + {ok, {200, _, DeviceResponse}} = hex_api_oauth:device_authorization(?CONFIG, ClientId, Scope), #{ <<"device_code">> := DeviceCode, <<"user_code">> := UserCode, @@ -125,25 +123,16 @@ oauth_device_flow_test(_Config) -> ?assert(is_integer(Interval)), % Test polling for token (should be pending initially) - PollParams = #{ - grant_type => <<"urn:ietf:params:oauth:grant-type:device_code">>, - device_code => DeviceCode, - client_id => <<"cli">> - }, - {ok, {400, _, PollResponse}} = hex_api_oauth:token(?CONFIG, PollParams), + {ok, {400, _, PollResponse}} = hex_api_oauth:poll_device_token(?CONFIG, ClientId, DeviceCode), #{<<"error">> := <<"authorization_pending">>} = PollResponse, ok. oauth_token_exchange_test(_Config) -> % Test token exchange - ExchangeParams = #{ - grant_type => <<"urn:ietf:params:oauth:grant-type:token-exchange">>, - subject_token => <<"test_api_key">>, - subject_token_type => <<"urn:x-oath:params:oauth:token-type:key">>, - client_id => <<"cli">>, - scope => <<"api:read">> - }, - {ok, {200, _, TokenResponse}} = hex_api_oauth:token(?CONFIG, ExchangeParams), + ClientId = <<"cli">>, + SubjectToken = <<"test_api_key">>, + Scope = <<"api:read">>, + {ok, {200, _, TokenResponse}} = hex_api_oauth:exchange_token(?CONFIG, ClientId, SubjectToken, Scope), #{ <<"access_token">> := AccessToken, <<"token_type">> := <<"Bearer">>, @@ -155,12 +144,9 @@ oauth_token_exchange_test(_Config) -> oauth_refresh_token_test(_Config) -> % Test token refresh - RefreshParams = #{ - grant_type => <<"refresh_token">>, - refresh_token => <<"test_refresh_token">>, - client_id => <<"cli">> - }, - {ok, {200, _, RefreshResponse}} = hex_api_oauth:token(?CONFIG, RefreshParams), + ClientId = <<"cli">>, + RefreshTokenValue = <<"test_refresh_token">>, + {ok, {200, _, RefreshResponse}} = hex_api_oauth:refresh_token(?CONFIG, ClientId, RefreshTokenValue), #{ <<"access_token">> := NewAccessToken, <<"refresh_token">> := NewRefreshToken, @@ -174,17 +160,11 @@ oauth_refresh_token_test(_Config) -> oauth_revoke_test(_Config) -> % Test token revocation - RevokeParams = #{ - token => <<"test_access_token">>, - client_id => <<"cli">>, - token_type_hint => <<"access_token">> - }, - {ok, {200, _, nil}} = hex_api_oauth:revoke(?CONFIG, RevokeParams), + ClientId = <<"cli">>, + Token = <<"test_access_token">>, + {ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, Token), % Test revoking non-existent token (should still return 200) - RevokeParams2 = #{ - token => <<"non_existent_token">>, - client_id => <<"cli">> - }, - {ok, {200, _, nil}} = hex_api_oauth:revoke(?CONFIG, RevokeParams2), + NonExistentToken = <<"non_existent_token">>, + {ok, {200, _, nil}} = hex_api_oauth:revoke_token(?CONFIG, ClientId, NonExistentToken), ok. diff --git a/test/support/hex_http_test.erl b/test/support/hex_http_test.erl index 1330653..ec3a039 100644 --- a/test/support/hex_http_test.erl +++ b/test/support/hex_http_test.erl @@ -253,7 +253,7 @@ fixture(post, <>, _, {_, Body}) -> fixture(post, <>, _, {_, Body}) -> DecodedBody = binary_to_term(Body), - #{client_id := _ClientId, scope := _Scope} = DecodedBody, + #{<<"client_id">> := _ClientId, <<"scope">> := _Scope} = DecodedBody, DeviceCode = base64:encode(crypto:strong_rand_bytes(32)), UserCode = iolist_to_binary([ integer_to_binary(rand:uniform(9999)), "-", @@ -271,7 +271,7 @@ fixture(post, <>, _, {_, Body}) -> fixture(post, <>, _, {_, Body}) -> DecodedBody = binary_to_term(Body), - case maps:get(grant_type, DecodedBody) of + case maps:get(<<"grant_type">>, DecodedBody) of <<"urn:ietf:params:oauth:grant-type:device_code">> -> % Simulate pending authorization ErrorPayload = #{ From 835a3e71f34333b846ada65a53b406da0e806f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Eric=20Meadows-J=C3=B6nsson?= Date: Mon, 22 Sep 2025 21:07:45 +0200 Subject: [PATCH 3/3] Add name parameter to device_authorization --- src/hex_api_oauth.erl | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl index 0a4b2de..4b99656 100644 --- a/src/hex_api_oauth.erl +++ b/src/hex_api_oauth.erl @@ -3,6 +3,7 @@ -module(hex_api_oauth). -export([ device_authorization/3, + device_authorization/4, poll_device_token/3, exchange_token/4, refresh_token/3, @@ -12,8 +13,20 @@ %% @doc %% Initiates the OAuth device authorization flow. %% +%% @see device_authorization/4 +%% @end +-spec device_authorization(hex_core:config(), binary(), binary()) -> hex_api:response(). +device_authorization(Config, ClientId, Scope) -> + device_authorization(Config, ClientId, Scope, []). + +%% @doc +%% Initiates the OAuth device authorization flow with optional parameters. +%% %% Returns device code, user code, and verification URIs for user authentication. %% +%% Options: +%% * `name' - A name to identify the token (e.g., hostname of the device) +%% %% Examples: %% %% ``` @@ -27,15 +40,21 @@ %% <<"expires_in">> => 600, %% <<"interval">> => 5 %% }}} +%% +%% 3> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>, [{name, <<"MyMachine">>}]). %% ''' %% @end --spec device_authorization(hex_core:config(), binary(), binary()) -> hex_api:response(). -device_authorization(Config, ClientId, Scope) -> +-spec device_authorization(hex_core:config(), binary(), binary(), proplists:proplist()) -> hex_api:response(). +device_authorization(Config, ClientId, Scope, Opts) -> Path = <<"oauth/device_authorization">>, - Params = #{ + Params0 = #{ <<"client_id">> => ClientId, <<"scope">> => Scope }, + Params = case proplists:get_value(name, Opts) of + undefined -> Params0; + Name -> Params0#{<<"name">> => Name} + end, hex_api:post(Config, Path, Params). %% @doc @@ -147,4 +166,4 @@ revoke_token(Config, ClientId, Token) -> <<"token">> => Token, <<"client_id">> => ClientId }, - hex_api:post(Config, Path, Params). \ No newline at end of file + hex_api:post(Config, Path, Params).