Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); Carry the organizations a session must re-authenticate for by ericmj · Pull Request #213 · hexpm/hex_core · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
237 changes: 190 additions & 47 deletions src/hex_api_oauth.erl
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,17 +8,24 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
sso_reauth_required/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
client_credentials_token/5,
win_cmd_args/1
]).

-export_type([oauth_tokens/0, device_auth_error/0]).

-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
refresh_token => binary(),
expires_at := integer(),
%% Organizations the session must authenticate against their identity
%% provider for. Their scopes are not in this token and re-requesting them
%% will not help; see sso_authorization/2.
sso_reauth_required => [binary()]
}.

-type device_auth_error() ::
Expand DownExpand Up@@ -140,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
case device_authorization(Config, ClientId, Scope, Opts) of
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := IntervalSeconds
} = DeviceResponse,
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {200, _, DeviceResponse}} ->
case device_authorization_fields(DeviceResponse) of
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
ok = PromptUser(VerificationUri, UserCode),
OpenBrowser = proplists:get_value(open_browser, Opts, false),
case OpenBrowser of
true -> open_browser(VerificationUri);
false -> ok
end,
ExpiresAt = erlang:system_time(second) + ExpiresIn,
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
error ->
{error, {device_auth_failed, 200, DeviceResponse}}
end;
{ok, {Status, _, Body}} ->
{error, {device_auth_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
end.

%% @private
%% The fields the flow goes on to use, in the types it uses them as: the
%% interval is slept on and the expiry is added to a timestamp.
device_authorization_fields(#{
<<"device_code">> := DeviceCode,
<<"user_code">> := UserCode,
<<"verification_uri_complete">> := VerificationUri,
<<"expires_in">> := ExpiresIn,
<<"interval">> := Interval
}) when
is_binary(DeviceCode),
is_binary(UserCode),
is_binary(VerificationUri),
is_integer(ExpiresIn),
is_integer(Interval),
Interval >= 0
->
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
device_authorization_fields(_DeviceResponse) ->
error.

%% @private
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
Now = erlang:system_time(second),
Expand All@@ -171,18 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
false ->
timer:sleep(IntervalSeconds * 1000),
case poll_device_token(Config, ClientId, DeviceCode) of
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
#{
<<"access_token">> := AccessToken,
<<"expires_in">> := ExpiresIn
} = TokenResponse,
RefreshToken = maps:get(<<"refresh_token">>, TokenResponse, undefined),
TokenExpiresAt = erlang:system_time(second) + ExpiresIn,
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
}};
{ok, {200, _, TokenResponse}} ->
case token_response_fields(TokenResponse) of
{ok, AccessToken, ExpiresIn} ->
Tokens = #{
access_token => AccessToken,
expires_at => erlang:system_time(second) + ExpiresIn
},
{ok,
put_sso_reauth_required(
put_refresh_token(Tokens, TokenResponse), TokenResponse
)};
error ->
{error, {poll_failed, 200, TokenResponse}}
end;
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
Expand All@@ -196,8 +224,12 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{error, {access_denied, Status, Body}};
{ok, {Status, _, Body}} ->
{error, {poll_failed, Status, Body}};
{error, Reason} ->
{error, Reason}
{error, _Reason} ->
%% A request that did not get through says nothing about the
%% authorization, which the user may be minutes into. The
%% device code lives on the server until it expires, so keep
%% polling until then.
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt)
end
end.

Expand DownExpand Up@@ -260,6 +292,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Requests a URL for authenticating the current session against organizations
%% that require single sign-on.
%%
%% The session the access token belongs to is the one being authorized: its
%% owner opens the URL in a browser, completes SSO, and the next token refresh
%% carries the scopes again. The URL is single-use and short-lived.
%%
%% Examples:
%%
%% ```
%% 1> Config = hex_core:default_config().
%% 2> hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(hex_core:config(), [binary()]) -> hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand DownExpand Up@@ -341,25 +397,55 @@ revoke_token(Config, ClientId, Token) ->
},
hex_api:post(Config, Path, Params).

%% @doc
%% Organizations a token response says the session has to authenticate against
%% their identity provider for.
%%
%% Returns `{ok, []}' when the response does not carry the field, which is what
%% servers that predate it send and means nothing is lapsed. Returns `error'
%% when the field is there in a shape that cannot be read, which says nothing
%% about what has lapsed and must not be taken for the empty set.
%% @end
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
case lists:all(fun is_binary/1, Organizations) of
true -> {ok, Organizations};
false -> error
end;
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
error;
sso_reauth_required(_TokenResponse) ->
{ok, []}.

%%====================================================================
%% Internal functions
%%====================================================================

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
%% Opens a URL in the default browser using the platform's opener: `open' on
%% macOS, `xdg-open' on Linux, `start' on Windows. Returns
%% `{error, browser_not_found}' when none of them exists, which is the ordinary
%% case on a headless machine, and `{error, invalid_url}' for anything that is
%% not an http(s) URL.
-spec open_browser(binary()) -> ok | {error, browser_not_found | invalid_url}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
UrlStr = binary_to_list(Url),
case valid_http_url(Url) of
true ->
spawn_browser(binary_to_list(Url));
false ->
{error, invalid_url}
end.

%% @private
spawn_browser(Url) ->
{Cmd, Args} =
case os:type() of
{unix, darwin} ->
{"open", [UrlStr]};
{"open", [Url]};
{unix, _} ->
{"xdg-open", [UrlStr]};
{"xdg-open", [Url]};
{win32, _} ->
{"cmd", ["/c", "start", "", UrlStr]}
{"cmd", win_cmd_args(Url)}
end,
case os:find_executable(Cmd) of
false ->
Expand All@@ -370,13 +456,70 @@ open_browser(Url) when is_binary(Url) ->
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
ensure_valid_http_url(Url) when is_binary(Url) ->
%% `start' takes its first quoted argument as the window title, so the empty
%% string keeps the URL in the position `start' opens.
-spec win_cmd_args(string()) -> [string()].
win_cmd_args(Url) ->
["/c", "start", "", escape_win_cmd(Url)].

%% @private
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
%% an argument that contains whitespace, so every character cmd acts on is
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
%% scans for separators.
escape_win_cmd(Url) ->
lists:flatmap(fun escape_win_cmd_character/1, Url).

%% @private
escape_win_cmd_character(Character) when
Character =:= $^;
Character =:= $&;
Character =:= $|;
Character =:= $<;
Character =:= $>;
Character =:= $(;
Character =:= $);
Character =:= $";
Character =:= $%
->
[$^, Character];
escape_win_cmd_character(Character) ->
[Character].

%% @private
%% Whether a URL uses the http:// or https:// scheme.
-spec valid_http_url(binary()) -> boolean().
valid_http_url(Url) when is_binary(Url) ->
case uri_string:parse(Url) of
#{scheme := <<"https">>} -> ok;
#{scheme := <<"http">>} -> ok;
_ -> throw({invalid_url, Url})
#{scheme := <<"https">>} -> true;
#{scheme := <<"http">>} -> true;
_ -> false
end.

%% @private
%% The access token and its lifetime, in the types the caller uses them as.
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
is_binary(AccessToken), is_integer(ExpiresIn)
->
{ok, AccessToken, ExpiresIn};
token_response_fields(_TokenResponse) ->
error.

%% @private
%% A response without a refresh token carries no key at all rather than a
%% placeholder, so what a build tool stores is only ever a real token.
put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary(RefreshToken) ->
Tokens#{refresh_token => RefreshToken};
put_refresh_token(Tokens, _TokenResponse) ->
Tokens.

%% @private
%% A response whose sso_reauth_required cannot be read carries no key, so the
%% caller is not handed the empty set as if the server had sent it.
put_sso_reauth_required(Tokens, TokenResponse) ->
case sso_reauth_required(TokenResponse) of
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
error -> Tokens
end.

%% @private
Expand Down
Loading
Loading