Skip to content
Draft
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
Binary file added hex-2.5.1.tar.gz
Binary file not shown.
25 changes: 25 additions & 0 deletions lib/hex/api/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,31 @@ defmodule Hex.API.OAuth do

defp drop_undefined_refresh_token(tokens), do: tokens

@doc """
Requests a URL for authenticating this session against organizations that
require single sign-on.

## Examples

iex> Hex.API.OAuth.sso_authorization(["acme"])
{:ok, {201, _headers, %{"verification_uri" => "https://hex.pm/sso/authorize/...",
"expires_in" => 600}}}
"""
def sso_authorization(organizations) do
config = Client.config()

Hex.Auth.with_api(:read, config, fn config ->
:mix_hex_api_oauth.sso_authorization(config, Enum.map(organizations, &to_string/1))
end)
end

@doc """
Opens a URL in the default browser.
"""
def open_browser(url) do
:mix_hex_api_oauth.open_browser(url)
end

@doc """
Revokes an OAuth token (access or refresh token).

Expand Down
32 changes: 32 additions & 0 deletions lib/hex/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@ defmodule Hex.Auth do
end
end

@doc """
Refresh the stored OAuth token now, whether or not it has expired.

Authenticating a session against an organization's identity provider grants
scopes the current access token was minted without, and this is how they are
picked up without waiting the token out.
"""
def refresh_tokens(config) do
:mix_hex_cli_auth.refresh_tokens(config)
end

@doc """
Execute a function with preemptive authentication using the provided auth data.
"""
Expand All @@ -41,6 +52,7 @@ defmodule Hex.Auth do
get_oauth_tokens: &get_oauth_tokens/0,
persist_oauth_tokens: &persist_oauth_tokens/4,
clear_oauth_tokens: &clear_oauth_tokens/0,
sso_reauth: &sso_reauth/1,
prompt_otp: &prompt_otp/1,
get_client_id: &Hex.API.OAuth.client_id/0,
should_authenticate: &should_authenticate/1
Expand Down Expand Up @@ -128,6 +140,26 @@ defmodule Hex.Auth do
:ok
end

# Invoked by hex_cli_auth after every token grant with the organizations the
# server says this session has to authenticate through their identity
# provider for. Store them with the token rather than acting on them: which
# ones matter depends on what the running command needs, and a later run that
# reuses this token without refreshing it would otherwise have no idea.
defp sso_reauth(organizations) do
token_data = Hex.State.get(:oauth_token)

if is_map(token_data) and Hex.OAuth.sso_reauth_required() != organizations do
Hex.OAuth.store_token(put_sso_reauth(token_data, organizations))
end

:ok
end

defp put_sso_reauth(token_data, []), do: Map.delete(token_data, :sso_reauth_required)

defp put_sso_reauth(token_data, organizations),
do: Map.put(token_data, :sso_reauth_required, organizations)

defp prompt_otp(message) do
case Hex.Shell.prompt(message) do
nil ->
Expand Down
11 changes: 11 additions & 0 deletions lib/hex/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@ defmodule Hex.OAuth do
Hex.State.put(:oauth_token, token_data)
end

@doc """
The organizations the stored session has to authenticate through their
identity provider for before it can reach them again.
"""
def sso_reauth_required do
case Hex.State.get(:oauth_token) do
%{sso_reauth_required: organizations} when is_list(organizations) -> organizations
_token_data -> []
end
end

@doc """
Clears all stored OAuth tokens.
"""
Expand Down
109 changes: 109 additions & 0 deletions lib/hex/remote_converger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ defmodule Hex.RemoteConverger do
|> verify_prefetches()

check_and_refresh_auth(prefetches)
check_sso_reauth(prefetches)
Registry.prefetch(prefetches)

locked = prepare_locked(lock, old_lock, deps)
Expand Down Expand Up @@ -937,6 +938,114 @@ defmodule Hex.RemoteConverger do
end
end

# The organizations a resolution can need are exactly the ones its own
# dependencies name: a published package's dependencies come from the public
# repository or from its own organization, so nothing private turns up part
# way through. That is what makes one prompt for the batch possible rather
# than a 403 at a time, and it is why a member of ten SSO organizations who
# depends on two is asked about two.
@doc false
def check_sso_reauth(prefetches) do
needed =
prefetches
|> Enum.flat_map(fn
{"hexpm:" <> organization = repo, _package} ->
# An organization authenticated with its own key does not touch the
# stored token, so nothing about it is worth asking.
if repo_requires_user_oauth?(repo), do: [organization], else: []

{_repo, _package} ->
[]
end)
|> MapSet.new()

Hex.OAuth.sso_reauth_required()
|> Enum.filter(&MapSet.member?(needed, &1))
|> prompt_sso_reauth()
end

defp prompt_sso_reauth([]), do: :ok

defp prompt_sso_reauth(organizations) do
cond do
Hex.State.fetch!(:offline) ->
unavailable(organizations, "Hex is offline")

Hex.State.get(:api_key) ->
unavailable(organizations, "HEX_API_KEY authenticates as itself")

Hex.Shell.yes?("#{sso_subject(organizations)} SSO authentication. Authenticate now?") ->
start_sso_reauth(organizations)

true ->
Hex.Shell.warn("Packages from #{names(organizations)} will not be available.")
end
end

defp unavailable(organizations, reason) do
Hex.Shell.warn(
"#{sso_subject(organizations)} SSO authentication, but #{reason}. " <>
"Packages from #{names(organizations)} will not be available."
)
end

defp start_sso_reauth(organizations) do
case Hex.API.OAuth.sso_authorization(organizations) do
{:ok, {status, _headers, %{"verification_uri" => uri}}}
when status in 200..299 and is_binary(uri) ->
# The URL goes in the prompt rather than beside it: `mix deps.get
# --quiet` swallows info output, and asking someone to finish something
# in a browser without telling them where is a dead end.
open_browser(uri)
Hex.Shell.prompt("Open #{uri} to authenticate, then press enter")
finish_sso_reauth(organizations)

{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn("Could not start SSO authentication: #{message}")

_other ->
Hex.Shell.warn("Could not start SSO authentication.")
end
end

# Opening a browser is a convenience on top of the printed URL, so nothing it
# does is worth ending a resolution over.
defp open_browser(uri) do
case URI.parse(uri) do
%URI{scheme: scheme} when scheme in ["http", "https"] ->
try do
Hex.API.OAuth.open_browser(uri)
catch
_kind, _reason -> :ok
end

_other ->
:ok
end
end

# The session and its refresh token are untouched by all this; what changed is
# what the session may reach, so a refresh is what picks it up.
defp finish_sso_reauth(organizations) do
config = Hex.API.Client.config([])

with :ok <- Hex.Auth.refresh_tokens(config),
[] <- Enum.filter(Hex.OAuth.sso_reauth_required(), &(&1 in organizations)) do
:ok
else
_other ->
Hex.Shell.warn(
"#{sso_subject(organizations)} SSO authentication. " <>
"Packages from #{names(organizations)} will not be available."
)
end
end

defp sso_subject([organization]), do: "#{organization} requires"
defp sso_subject(organizations), do: "#{names(organizations)} require"

defp names(organizations), do: Enum.join(organizations, ", ")

@doc false
def auth_preflight_required?(prefetches) do
prefetches
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_advisory.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Display-time deduplication of security advisories.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_auth.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Authentication.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_key.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Keys.
Expand Down
64 changes: 54 additions & 10 deletions src/mix_hex_api_oauth.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - OAuth.
Expand All @@ -10,6 +10,8 @@
device_auth_flow/5,
poll_device_token/3,
refresh_token/3,
sso_authorization/2,
open_browser/1,
revoke_token/3,
client_credentials_token/4,
client_credentials_token/5
Expand All @@ -20,7 +22,11 @@
-type oauth_tokens() :: #{
access_token := binary(),
refresh_token => binary() | undefined,
expires_at := integer()
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 Down Expand Up @@ -183,7 +189,8 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
{ok, #{
access_token => AccessToken,
refresh_token => RefreshToken,
expires_at => TokenExpiresAt
expires_at => TokenExpiresAt,
sso_reauth_required => sso_reauth_required(TokenResponse)
}};
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
Expand Down Expand Up @@ -262,6 +269,30 @@ refresh_token(Config, ClientId, RefreshToken) ->
},
mix_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 = mix_hex_core:default_config().
%% 2> mix_hex_api_oauth:sso_authorization(Config, [<<"acme">>]).
%% {ok, {201, _, #{
%% <<"verification_uri">> => <<"https://hex.pm/sso/authorize/...">>,
%% <<"expires_in">> => 600
%% }}}
%% '''
%% @end
-spec sso_authorization(mix_hex_core:config(), [binary()]) -> mix_hex_api:response().
sso_authorization(Config, Organizations) ->
Path = <<"oauth/sso_authorization">>,
mix_hex_api:post(Config, Path, #{<<"organizations">> => Organizations}).

%% @doc
%% Exchanges an API key for an OAuth access token using the client credentials grant.
%%
Expand Down Expand Up @@ -343,13 +374,13 @@ revoke_token(Config, ClientId, Token) ->
},
mix_hex_api:post(Config, Path, Params).

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

%% @private
%% Open a URL in the default browser.
%% Uses platform-specific commands: open (macOS), xdg-open (Linux), start (Windows).
%% @doc
%% Opens a URL in the default browser.
%%
%% Uses 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.
%% @end
-spec open_browser(binary()) -> ok | {error, browser_not_found}.
open_browser(Url) when is_binary(Url) ->
ok = ensure_valid_http_url(Url),
Expand All @@ -371,6 +402,19 @@ open_browser(Url) when is_binary(Url) ->
ok
end.

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

%% @private
%% Older servers do not send the field at all, which means nothing is lapsed.
-spec sso_reauth_required(map()) -> [binary()].
sso_reauth_required(TokenResponse) ->
case maps:get(<<"sso_reauth_required">>, TokenResponse, []) of
Organizations when is_list(Organizations) -> Organizations;
_Other -> []
end.

%% @private
%% Validates that a URL uses http:// or https:// scheme.
-spec ensure_valid_http_url(binary()) -> ok.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_organization.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Organizations.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_organization_member.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Organization Members.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_package.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Packages.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_package_owner.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Package Owners.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_release.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Releases.
Expand Down
2 changes: 1 addition & 1 deletion src/mix_hex_api_short_url.erl
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
%% Vendored from hex_core v0.18.0 (d6a6a5a), do not edit manually
%% Vendored from hex_core v0.19.0 (766ae61), do not edit manually

%% @doc
%% Hex HTTP API - Short URLs.
Expand Down
Loading
Loading