diff --git a/src/hex_api_oauth.erl b/src/hex_api_oauth.erl new file mode 100644 index 0000000..4b99656 --- /dev/null +++ b/src/hex_api_oauth.erl @@ -0,0 +1,169 @@ +%% @doc +%% Hex HTTP API - OAuth. +-module(hex_api_oauth). +-export([ + device_authorization/3, + device_authorization/4, + poll_device_token/3, + exchange_token/4, + refresh_token/3, + revoke_token/3 +]). + +%% @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: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>). +%% {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 +%% }}} +%% +%% 3> hex_api_oauth:device_authorization(Config, <<"cli">>, <<"api:write">>, [{name, <<"MyMachine">>}]). +%% ''' +%% @end +-spec device_authorization(hex_core:config(), binary(), binary(), proplists:proplist()) -> hex_api:response(). +device_authorization(Config, ClientId, Scope, Opts) -> + Path = <<"oauth/device_authorization">>, + 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 +%% Polls the OAuth token endpoint for device authorization completion. +%% +%% 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 +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 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. +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 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. +%% +%% Examples: +%% +%% ``` +%% 1> Config = hex_core:default_config(). +%% 2> hex_api_oauth:refresh_token(Config, <<"cli">>, RefreshToken). +%% {ok, {200, _, #{ +%% <<"access_token">> => <<"...">>, +%% <<"refresh_token">> => <<"...">>, +%% <<"token_type">> => <<"Bearer">>, +%% <<"expires_in">> => 3600 +%% }}} +%% ''' +%% @end +-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 +%% 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> hex_api_oauth:revoke_token(Config, <<"cli">>, Token). +%% {ok, {200, ..., nil}} +%% ''' +%% @end +-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). diff --git a/test/hex_api_SUITE.erl b/test/hex_api_SUITE.erl index 51abf6b..daedc27 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,70 @@ 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 + ClientId = <<"cli">>, + Scope = <<"api:write">>, + {ok, {200, _, DeviceResponse}} = hex_api_oauth:device_authorization(?CONFIG, ClientId, Scope), + #{ + <<"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) + {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 + 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">>, + <<"expires_in">> := ExpiresIn + } = TokenResponse, + ?assert(is_binary(AccessToken)), + ?assert(is_integer(ExpiresIn)), + ok. + +oauth_refresh_token_test(_Config) -> + % Test token refresh + ClientId = <<"cli">>, + RefreshTokenValue = <<"test_refresh_token">>, + {ok, {200, _, RefreshResponse}} = hex_api_oauth:refresh_token(?CONFIG, ClientId, RefreshTokenValue), + #{ + <<"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 + 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) + 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 6473169..ec3a039 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, _, _) ->