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
20 changes: 10 additions & 10 deletions lib/hex/api/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -26,36 +26,36 @@ defmodule Hex.API.OAuth do
config = Client.config()

case :mix_hex_api_oauth.device_auth_flow(config, @client_id, scopes, prompt_user, opts) do
{:ok, tokens} -> {:ok, drop_empty_sso_reauth_required(tokens)}
{:ok, tokens} -> {:ok, drop_empty_organization_reauth_required(tokens)}
other -> other
end
end

# :mix_hex_api_oauth reports "nothing is flagged" as an empty list. A stored
# token map carries the key only when there is something in it.
defp drop_empty_sso_reauth_required(%{sso_reauth_required: []} = tokens) do
Map.delete(tokens, :sso_reauth_required)
defp drop_empty_organization_reauth_required(%{organization_reauth_required: []} = tokens) do
Map.delete(tokens, :organization_reauth_required)
end

defp drop_empty_sso_reauth_required(tokens), do: tokens
defp drop_empty_organization_reauth_required(tokens), do: tokens

@doc """
Requests a URL for authenticating this session against organizations that
require single sign-on.
Requests a browser URL to complete the organization's SSO and 2FA requirements
for this OAuth session.

## Examples

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

Hex.Auth.with_session_api(
:read,
config,
fn config -> :mix_hex_api_oauth.sso_authorization(config, organizations) end,
fn config -> :mix_hex_api_oauth.organization_authorization(config, organizations) end,
auth_inline: false
)
end
Expand Down
38 changes: 19 additions & 19 deletions lib/hex/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ defmodule Hex.Auth do
@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.
Satisfying an organization's SSO or 2FA requirements grants scopes absent
from the current access token. Refreshing retrieves those scopes without
waiting for the token to expire.
"""
def refresh_tokens(config) do
:mix_hex_cli_auth.refresh_tokens(config)
Expand All @@ -65,7 +65,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,
organization_reauth: &organization_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 @@ -94,10 +94,10 @@ defmodule Hex.Auth do
defp persist_oauth_tokens(repo, access_token, refresh_token, expires_at)

defp persist_oauth_tokens(:global, access_token, refresh_token, expires_at) do
# The flagged organizations arrive through the sso_reauth callback, not with
# The flagged organizations arrive through the organization_reauth callback, not with
# the token, so carry them over instead of dropping them on every refresh.
token_data =
token_map(access_token, expires_at, refresh_token, Hex.OAuth.sso_reauth_required())
token_map(access_token, expires_at, refresh_token, Hex.OAuth.organization_reauth_required())

Hex.OAuth.store_token(token_data)
:ok
Expand All @@ -111,15 +111,15 @@ defmodule Hex.Auth do
:ok
end

defp token_map(access_token, expires_at, refresh_token, sso_reauth_required \\ []) do
defp token_map(access_token, expires_at, refresh_token, organization_reauth_required \\ []) do
token_data = %{access_token: access_token, expires_at: expires_at}

token_data =
if is_binary(refresh_token),
do: Map.put(token_data, :refresh_token, refresh_token),
else: token_data

put_sso_reauth(token_data, sso_reauth_required)
put_organization_reauth(token_data, organization_reauth_required)
end

# Invoked by hex_cli_auth when the stored global OAuth token is expired and
Expand All @@ -141,25 +141,25 @@ 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
# Invoked by hex_cli_auth after every token grant with outstanding organization
# authentication requirements. Store them with the token so commands can select
# the organizations they need, including when reusing a token without refreshing.
defp organization_reauth(organizations) do
token_data = Hex.State.get(:oauth_token)

if is_map(token_data) and Map.get(token_data, :sso_reauth_required, []) != organizations do
Hex.OAuth.store_token(put_sso_reauth(token_data, organizations))
if is_map(token_data) and
Map.get(token_data, :organization_reauth_required, []) != organizations do
Hex.OAuth.store_token(put_organization_reauth(token_data, organizations))
end

:ok
end

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

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

# A prompt answers :eof when there is nothing on stdin to read, which is what
# an OTP challenge in CI gets.
Expand Down
8 changes: 4 additions & 4 deletions lib/hex/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,12 @@ defmodule Hex.OAuth do
end

@doc """
The organizations the stored session has to authenticate through their
identity provider for before it can reach them again.
The organizations and authentication requirements the stored session must
satisfy before it can reach them again.
"""
def sso_reauth_required do
def organization_reauth_required do
case Hex.State.get(:oauth_token) do
%{sso_reauth_required: organizations} when is_list(organizations) -> organizations
%{organization_reauth_required: organizations} when is_list(organizations) -> organizations
_token_data -> []
end
end
Expand Down
96 changes: 46 additions & 50 deletions lib/hex/remote_converger.ex
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ defmodule Hex.RemoteConverger do

organizations = user_oauth_organizations(prefetches)
check_and_refresh_auth(organizations)
check_sso_reauth(organizations)
check_organization_reauth(organizations)
Registry.prefetch(prefetches)

locked = prepare_locked(lock, old_lock, deps)
Expand Down Expand Up @@ -968,70 +968,62 @@ defmodule Hex.RemoteConverger do
# 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
# than a 403 at a time, and it is why a member of ten organizations who
# depends on two is asked about two.
@doc false
def check_sso_reauth(organizations) do
Hex.OAuth.sso_reauth_required()
|> Enum.filter(&(&1 in organizations))
|> prompt_sso_reauth()
def check_organization_reauth(organizations) do
Hex.OAuth.organization_reauth_required()
|> Enum.filter(&(&1.organization in organizations))
|> prompt_organization_reauth()
end

defp prompt_sso_reauth([]), do: :ok
defp prompt_organization_reauth([]), do: :ok

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

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

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

defp unavailable(organizations, reason) do
defp unavailable(entries, reason) do
Hex.Shell.warn(
"#{sso_subject(organizations)} SSO authentication, but #{reason}. " <>
"Packages from #{names(organizations)} will not be available."
"#{requirements(entries)}, but #{reason}. Packages from #{names(entries)} 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}}}
defp start_organization_reauth(entries) do
case Hex.API.OAuth.organization_authorization(Enum.map(entries, & &1.organization)) do
{:ok, {status, _, %{"verification_uri" => uri}}}
when status in 200..299 and is_binary(uri) ->
uri = Hex.Utils.printable_ascii(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)

# The server's message never names a client command, since it cannot know
# which client asked, so the mix task goes in here. A full `mix hex.user
# auth` re-establishes organization access at approval, which makes it
# the fallback whatever kept the in-place flow from starting.
{:ok, {_status, _headers, %{"message" => message}}} when is_binary(message) ->

# The server decides whether the request was completed; the refresh
# reads its answer, however long the prompt sat open.
case Hex.Shell.prompt("Open #{uri} to authenticate, then press enter") do
answer when is_binary(answer) -> finish_organization_reauth(entries)
_ -> unavailable(entries, "authentication was cancelled")
end

{:ok, {_status, _, %{"message" => message}}} when is_binary(message) ->
Hex.Shell.warn(
"Could not start SSO authentication: #{Hex.Utils.escape_terminal(message)}. " <>
"Run `mix hex.user auth` to authenticate again."
"Could not start organization authentication: #{Hex.Utils.escape_terminal(message)}. Run `mix hex.user auth` to authenticate again."
)

_other ->
_ ->
Hex.Shell.warn(
"Could not start SSO authentication. Run `mix hex.user auth` to authenticate again."
"Could not start organization authentication. Run `mix hex.user auth` to authenticate again."
)
end
end

# Opening a browser is a convenience on top of the printed URL, so nothing it
# does is worth ending a resolution over: System.cmd/2 raises when the
# platform has no opener installed.
defp open_browser(uri) do
case URI.parse(uri) do
%URI{scheme: scheme} when scheme in ["http", "https"] ->
Expand All @@ -1041,32 +1033,36 @@ defmodule Hex.RemoteConverger do
_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
defp finish_organization_reauth(entries) do
config = Hex.API.Client.config([])
names = Enum.map(entries, & &1.organization)

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

defp sso_subject([organization]), do: "#{organization} requires"
defp sso_subject(organizations), do: "#{names(organizations)} require"
defp requirements(entries) do
Enum.map_join(entries, "; ", fn entry ->
reasons =
Enum.map_join(entry.requirements, ", ", fn
"tfa" -> "2FA enrollment required"
"sso" -> "SSO authentication required"
end)

"#{entry.organization}: #{reasons}"
end)
end

defp names(organizations), do: Enum.join(organizations, ", ")
defp names(entries), do: Enum.map_join(entries, ", ", & &1.organization)

# The organizations among the prefetched repositories that the stored user
# session authenticates for. An organization with its own key does not touch
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.19.0 (9ea52a0), do not edit manually
%% Vendored from hex_core v0.19.0 (68d8345), 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.19.0 (9ea52a0), do not edit manually
%% Vendored from hex_core v0.19.0 (68d8345), 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.19.0 (9ea52a0), do not edit manually
%% Vendored from hex_core v0.19.0 (68d8345), 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.19.0 (9ea52a0), do not edit manually
%% Vendored from hex_core v0.19.0 (68d8345), do not edit manually

%% @doc
%% Hex HTTP API - Keys.
Expand Down
Loading
Loading