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
7 changes: 5 additions & 2 deletions lib/hex/api/auth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,17 @@ defmodule Hex.API.Auth do

alias Hex.API.Client

def get(domain, resource, auth) do
def get(domain, resource, auth \\ []) do
config = Client.config(auth)

params = %{
domain: to_string(domain),
resource: to_string(resource)
}

:mix_hex_api_auth.test(config, params)
Hex.Auth.with_api(:read, config, &:mix_hex_api_auth.test(&1, params),
auth_inline: false,
optional: true
)
end
end
10 changes: 5 additions & 5 deletions lib/hex/api/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ defmodule Hex.API.Client do
:mix_hex_core.default_config()
| http_adapter: {Hex.HTTP, %{}},
api_url: Hex.State.fetch!(:api_url),
http_user_agent_fragment: user_agent_fragment()
http_user_agent_fragment: user_agent_fragment(),
cli_auth_callbacks: Hex.Auth.callbacks()
}

config
Expand All @@ -26,17 +27,16 @@ defmodule Hex.API.Client do
opts[:user] && opts[:pass] ->
# For basic auth, add it as an HTTP header
base64 = Base.encode64("#{opts[:user]}:#{opts[:pass]}")
headers = Map.get(config, :http_headers, %{})
headers = Map.put(headers, "authorization", "Basic #{base64}")
Map.put(config, :http_headers, headers)
token = "Basic #{base64}"
Map.put(config, :api_key, token)

true ->
config
end
end

defp maybe_put_otp(config, opts) do
if otp = opts[:otp] do
if otp = opts[:otp] || Hex.State.get(:api_otp) do
Map.put(config, :api_otp, otp)
else
config
Expand Down
84 changes: 37 additions & 47 deletions lib/hex/api/key.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,80 +3,70 @@ defmodule Hex.API.Key do

alias Hex.API.Client

def new(name, permissions, auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config = Client.config(auth_with_otp)
def new(name, permissions, auth \\ []) do
config = Client.config(auth)

# Convert permissions to binary map format expected by hex_core
permissions =
Enum.map(permissions, fn perm ->
Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end)
end)
# Convert permissions to binary map format expected by hex_core
permissions =
Enum.map(permissions, fn perm ->
Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end)
end)

:mix_hex_api_key.add(config, to_string(name), permissions)
end)
Hex.Auth.with_api(:write, config, &:mix_hex_api_key.add(&1, to_string(name), permissions))
end

def get(auth) do
def get(auth \\ []) do
config = Client.config(auth)
:mix_hex_api_key.list(config)

Hex.Auth.with_api(:read, config, &:mix_hex_api_key.list(&1))
end

def delete(name, auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config = Client.config(auth_with_otp)
:mix_hex_api_key.delete(config, to_string(name))
end)
def delete(name, auth \\ []) do
config = Client.config(auth)

Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete(&1, to_string(name)))
end

def delete_all(auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config = Client.config(auth_with_otp)
:mix_hex_api_key.delete_all(config)
end)
def delete_all(auth \\ []) do
config = Client.config(auth)

Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete_all(&1))
end

defmodule Organization do
@moduledoc false

alias Hex.API.Client

def new(organization, name, permissions, auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config =
Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization)))
def new(organization, name, permissions, auth \\ []) do
config =
Client.config(Keyword.put(auth, :api_organization, to_string(organization)))

# Convert permissions to binary map format expected by hex_core
permissions =
Enum.map(permissions, fn perm ->
Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end)
end)
# Convert permissions to binary map format expected by hex_core
permissions =
Enum.map(permissions, fn perm ->
Map.new(perm, fn {k, v} -> {to_string(k), to_string(v)} end)
end)

:mix_hex_api_key.add(config, to_string(name), permissions)
end)
Hex.Auth.with_api(:write, config, &:mix_hex_api_key.add(&1, to_string(name), permissions))
end

def get(organization, auth) do
def get(organization, auth \\ []) do
config = Client.config(Keyword.put(auth, :api_organization, to_string(organization)))
:mix_hex_api_key.list(config)
Hex.Auth.with_api(:read, config, &:mix_hex_api_key.list(&1))
end

def delete(organization, name, auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config =
Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization)))
def delete(organization, name, auth \\ []) do
config =
Client.config(Keyword.put(auth, :api_organization, to_string(organization)))

:mix_hex_api_key.delete(config, to_string(name))
end)
Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete(&1, to_string(name)))
end

def delete_all(organization, auth) do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config =
Client.config(Keyword.put(auth_with_otp, :api_organization, to_string(organization)))
def delete_all(organization, auth \\ []) do
config = Client.config(Keyword.put(auth, :api_organization, to_string(organization)))

:mix_hex_api_key.delete_all(config)
end)
Hex.Auth.with_api(:write, config, &:mix_hex_api_key.delete_all(&1))
end
end
end
103 changes: 23 additions & 80 deletions lib/hex/api/oauth.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,99 +5,42 @@ defmodule Hex.API.OAuth do

@client_id "78ea6566-89fd-481e-a1d6-7d9d78eacca8"

@doc """
Initiates the OAuth device authorization flow.

Returns device code, user code, and verification URIs for user authentication.
Optionally accepts a name parameter to identify the token.

## Examples

iex> Hex.API.OAuth.device_authorization("api")
{:ok, {200, _headers, %{
"device_code" => "...",
"user_code" => "ABCD-1234",
"verification_uri" => "https://hex.pm/oauth/device",
"verification_uri_complete" => "https://hex.pm/oauth/device?user_code=ABCD-1234",
"expires_in" => 600,
"interval" => 5
}}}
"""
def device_authorization(scopes, name \\ nil) do
config = Client.config()
opts = if name, do: [name: name], else: []
:mix_hex_api_oauth.device_authorization(config, @client_id, scopes, opts)
end

@doc """
Polls the OAuth token endpoint for device authorization completion.

## Examples

iex> Hex.API.OAuth.poll_device_token(device_code)
{:ok, {200, _headers, %{
"access_token" => "...",
"refresh_token" => "...",
"token_type" => "Bearer",
"expires_in" => 3600
}}}
"""
def poll_device_token(device_code) do
config = Client.config()
:mix_hex_api_oauth.poll_device_token(config, @client_id, device_code)
end
@doc false
def client_id, do: @client_id

@doc """
Refreshes an access token using a refresh token.
Runs the complete OAuth device authorization flow.

## Examples

iex> Hex.API.OAuth.refresh_token(refresh_token)
{:ok, {200, _headers, %{
"access_token" => "...",
"refresh_token" => "...",
"token_type" => "Bearer",
"expires_in" => 3600
}}}
"""
def refresh_token(refresh_token) do
config = Client.config()
:mix_hex_api_oauth.refresh_token(config, @client_id, refresh_token)
end

@doc """
Exchanges an API key for a short-lived OAuth access token using the client credentials grant.

Optionally accepts a custom API URL for the OAuth exchange endpoint.
See `:mix_hex_api_oauth.device_auth_flow/5` for more details.

## Examples

iex> Hex.API.OAuth.exchange_api_key(api_key, "api")
{:ok, {200, _headers, %{
"access_token" => "...",
"token_type" => "bearer",
"expires_in" => 1800,
"scope" => "api"
}}}
iex> prompt_fn = fn uri, code -> IO.puts("Visit \#{uri} and enter: \#{code}") end
iex> Hex.API.OAuth.device_auth_flow("api", prompt_fn)
{:ok, %{access_token: "...", refresh_token: "...", expires_at: 1234567890}}

iex> Hex.API.OAuth.exchange_api_key(api_key, "api", nil, "https://custom.hex.pm")
{:ok, {200, _headers, %{...}}}
iex> Hex.API.OAuth.device_auth_flow("api", prompt_fn, open_browser: true)
{:ok, %{access_token: "...", refresh_token: "...", expires_at: 1234567890}}
"""
def exchange_api_key(api_key, scopes, name \\ nil, api_url \\ nil) do
def device_auth_flow(scopes, prompt_user, opts \\ []) do
config = Client.config()

config =
if api_url do
Map.put(config, :api_url, api_url)
else
config
end
case :mix_hex_api_oauth.device_auth_flow(config, @client_id, scopes, prompt_user, opts) do
{:ok, tokens} -> {:ok, drop_undefined_refresh_token(tokens)}
other -> other
end
end

scope_string = if is_list(scopes), do: Enum.join(scopes, " "), else: scopes
opts = if name, do: [name: name], else: []
:mix_hex_api_oauth.client_credentials_token(config, @client_id, api_key, scope_string, opts)
# :mix_hex_api_oauth always includes a :refresh_token key, using the atom
# :undefined when the server didn't return one. Drop it so stored token maps
# only ever contain a binary refresh token (or no key at all).
defp drop_undefined_refresh_token(%{refresh_token: refresh} = tokens)
when refresh in [:undefined, nil] do
Map.delete(tokens, :refresh_token)
end

defp drop_undefined_refresh_token(tokens), do: tokens

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

Expand Down
57 changes: 41 additions & 16 deletions lib/hex/api/package.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,50 +5,75 @@ defmodule Hex.API.Package do

def get(repo, name, auth \\ []) when name != "" do
config = Client.build_config(repo, auth)
:mix_hex_api_package.get(config, to_string(name))

Hex.Auth.with_api(
:read,
config,
&:mix_hex_api_package.get(&1, to_string(name)),
auth_inline: false,
optional: true
)
end

def search(repo, search, auth \\ []) do
config = Client.build_config(repo, auth)
search_params = [{:sort, "downloads"}]

:mix_hex_api_package.search(config, to_string(search), search_params)
Hex.Auth.with_api(
:read,
config,
&:mix_hex_api_package.search(&1, to_string(search), search_params),
auth_inline: false,
optional: true
)
end

defmodule Owner do
@moduledoc false

alias Hex.API.Client

def add(repo, package, owner, level, transfer, auth) when package != "" do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config = Client.build_config(repo, auth_with_otp)
def add(repo, package, owner, level, transfer, auth \\ []) when package != "" do
config =
Client.build_config(repo, auth)

:mix_hex_api_package_owner.add(
config,
Hex.Auth.with_api(
:write,
config,
&:mix_hex_api_package_owner.add(
&1,
to_string(package),
to_string(owner),
to_string(level),
transfer
)
end)
)
end

def delete(repo, package, owner, auth) when package != "" do
Mix.Tasks.Hex.with_otp_retry(auth, fn auth_with_otp ->
config = Client.build_config(repo, auth_with_otp)
def delete(repo, package, owner, auth \\ []) when package != "" do
config = Client.build_config(repo, auth)

:mix_hex_api_package_owner.delete(
config,
Hex.Auth.with_api(
:write,
config,
&:mix_hex_api_package_owner.delete(
&1,
to_string(package),
to_string(owner)
)
end)
)
end

def get(repo, package, auth) when package != "" do
def get(repo, package, auth \\ []) when package != "" do
config = Client.build_config(repo, auth)
:mix_hex_api_package_owner.list(config, to_string(package))

Hex.Auth.with_api(
:read,
config,
&:mix_hex_api_package_owner.list(&1, to_string(package)),
auth_inline: false,
optional: true
)
end
end
end
Loading
Loading