Skip to content

Commit 33d39cc

Browse files
authored
Remove OAuth token exchange (#1089)
We don't need a short lived write refresh token since the API server now enforces 2FA for all write actions.
1 parent 17703d7 commit 33d39cc

File tree

10 files changed

+197
-915
lines changed

10 files changed

+197
-915
lines changed

lib/hex/api/oauth.ex

Lines changed: 0 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -47,24 +47,6 @@ defmodule Hex.API.OAuth do
4747
:mix_hex_api_oauth.poll_device_token(config, @client_id, device_code)
4848
end
4949

50-
@doc """
51-
Exchanges a token for a new token with different scopes using RFC 8693 token exchange.
52-
53-
## Examples
54-
55-
iex> Hex.API.OAuth.exchange_token(subject_token, "api:write")
56-
{:ok, {200, _headers, %{
57-
"access_token" => "...",
58-
"refresh_token" => "...",
59-
"token_type" => "Bearer",
60-
"expires_in" => 3600
61-
}}}
62-
"""
63-
def exchange_token(subject_token, scope) do
64-
config = Client.config()
65-
:mix_hex_api_oauth.exchange_token(config, @client_id, subject_token, scope)
66-
end
67-
6850
@doc """
6951
Refreshes an access token using a refresh token.
7052

lib/hex/oauth.ex

Lines changed: 32 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,78 +4,73 @@ defmodule Hex.OAuth do
44
alias Hex.API.OAuth
55

66
@doc """
7-
Retrieves a valid access token for the given permission type.
7+
Retrieves a valid access token.
88
99
Automatically refreshes the token if it's expired.
1010
Returns {:error, :no_auth} if no tokens are available.
11+
12+
Since we now use 2FA for write operations, we use a single token for both read and write.
13+
The permission parameter is kept for backward compatibility but is no longer used.
1114
"""
1215
def get_token(permission) when permission in [:read, :write] do
13-
case get_stored_tokens() do
16+
case get_stored_token() do
1417
nil ->
1518
{:error, :no_auth}
1619

17-
tokens ->
18-
token_data = Map.get(tokens, to_string(permission))
19-
20-
if token_data && valid_token?(token_data) do
20+
token_data ->
21+
if valid_token?(token_data) do
2122
{:ok, token_data["access_token"]}
2223
else
2324
# Try to refresh the token
24-
refresh_token_if_possible(permission, token_data)
25+
refresh_token_if_possible(token_data)
2526
end
2627
end
2728
end
2829

2930
@doc """
30-
Stores OAuth tokens for both read and write permissions.
31+
Stores OAuth token data.
32+
33+
Since we now use 2FA for write operations, we only store a single token.
3134
3235
Expected format:
3336
%{
34-
"write" => %{
35-
"access_token" => "...",
36-
"refresh_token" => "...",
37-
"expires_at" => unix_timestamp
38-
},
39-
"read" => %{
40-
"access_token" => "...",
41-
"refresh_token" => "...",
42-
"expires_at" => unix_timestamp
43-
}
37+
"access_token" => "...",
38+
"refresh_token" => "...",
39+
"expires_at" => unix_timestamp
4440
}
4541
"""
46-
def store_tokens(tokens) do
47-
Hex.Config.update([{:"$oauth_tokens", tokens}])
48-
Hex.State.put(:oauth_tokens, tokens)
42+
def store_token(token_data) do
43+
Hex.Config.update([{:"$oauth_token", token_data}])
44+
Hex.State.put(:oauth_token, token_data)
4945
end
5046

5147
@doc """
5248
Clears all stored OAuth tokens.
5349
"""
5450
def clear_tokens do
55-
Hex.Config.remove([:"$oauth_tokens"])
56-
Hex.State.put(:oauth_tokens, nil)
51+
Hex.Config.remove([:"$oauth_token"])
52+
Hex.State.put(:oauth_token, nil)
5753
end
5854

5955
@doc """
6056
Checks if we have any OAuth tokens stored.
6157
"""
6258
def has_tokens? do
63-
get_stored_tokens() != nil
59+
get_stored_token() != nil
6460
end
6561

6662
@doc """
67-
Refreshes a token for the given permission type.
63+
Refreshes the stored OAuth token.
64+
65+
The permission parameter is kept for backward compatibility but is no longer used.
6866
"""
6967
def refresh_token(permission) when permission in [:read, :write] do
70-
case get_stored_tokens() do
68+
case get_stored_token() do
7169
nil ->
7270
{:error, :no_auth}
7371

74-
tokens ->
75-
permission_str = to_string(permission)
76-
token_data = Map.get(tokens, permission_str)
77-
78-
if token_data && token_data["refresh_token"] do
72+
token_data ->
73+
if token_data["refresh_token"] do
7974
case OAuth.refresh_token(token_data["refresh_token"]) do
8075
{:ok, {200, _, new_token_data}} ->
8176
# Update the token data with new values
@@ -86,9 +81,8 @@ defmodule Hex.OAuth do
8681
|> Map.put("expires_at", expires_at)
8782
|> Map.take(["access_token", "refresh_token", "expires_at"])
8883

89-
# Update stored tokens
90-
updated_tokens = Map.put(tokens, permission_str, new_token_data)
91-
store_tokens(updated_tokens)
84+
# Update stored token
85+
store_token(new_token_data)
9286

9387
{:ok, new_token_data["access_token"]}
9488

@@ -117,8 +111,8 @@ defmodule Hex.OAuth do
117111
|> Map.take(["access_token", "refresh_token", "expires_at"])
118112
end
119113

120-
defp get_stored_tokens do
121-
Hex.State.get(:oauth_tokens)
114+
defp get_stored_token do
115+
Hex.State.get(:oauth_token)
122116
end
123117

124118
defp valid_token?(token_data) do
@@ -133,8 +127,8 @@ defmodule Hex.OAuth do
133127
end
134128
end
135129

136-
defp refresh_token_if_possible(permission, token_data) do
137-
case refresh_token(permission) do
130+
defp refresh_token_if_possible(token_data) do
131+
case refresh_token(:write) do
138132
{:ok, access_token} ->
139133
{:ok, access_token}
140134

lib/mix/tasks/hex.ex

Lines changed: 22 additions & 132 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ defmodule Mix.Tasks.Hex do
179179
Hex.Shell.info("Waiting for authentication...")
180180

181181
case poll_for_token(device_code, interval) do
182-
{:ok, initial_token} ->
183-
exchange_and_store_tokens(initial_token)
182+
{:ok, token} ->
183+
store_token(token)
184184

185185
:error ->
186186
:error
@@ -232,108 +232,15 @@ defmodule Mix.Tasks.Hex do
232232
"-" <> String.slice(user_code, mid, String.length(user_code))
233233
end
234234

235-
defp exchange_and_store_tokens(initial_token) do
236-
# Parse the granted scopes from the initial token
237-
granted_scopes = parse_granted_scopes(initial_token["scope"] || "")
235+
defp store_token(token) do
236+
# Create token data with expiration time
237+
token_data = Hex.OAuth.create_token_data(token)
238238

239-
# Determine what tokens we can create based on granted scopes
240-
write_token = create_write_token(initial_token, granted_scopes)
241-
read_token = create_read_token(initial_token, granted_scopes)
242-
243-
# Store the tokens based on what we obtained
244-
case {write_token, read_token} do
245-
{nil, nil} ->
246-
# Couldn't get proper tokens - this is an error condition
247-
Hex.Shell.error("Failed to obtain proper access tokens")
248-
Hex.Shell.error("Please try authenticating again or check your permissions")
249-
:error
250-
251-
{write, read} ->
252-
tokens = %{
253-
"write" => if(write, do: Hex.OAuth.create_token_data(write)),
254-
"read" => if(read, do: Hex.OAuth.create_token_data(read))
255-
}
256-
257-
Hex.OAuth.store_tokens(tokens)
258-
Hex.Shell.info("Authentication completed successfully!")
259-
{:ok, tokens}
260-
end
261-
end
262-
263-
defp parse_granted_scopes(scope_string) when is_binary(scope_string) do
264-
String.split(scope_string, " ", trim: true)
265-
end
266-
267-
defp parse_granted_scopes(_), do: []
268-
269-
defp create_write_token(initial_token, granted_scopes) do
270-
# Check if we have write permission
271-
cond do
272-
"api" in granted_scopes || "api:write" in granted_scopes ->
273-
# We have write permission, exchange for api:write token
274-
case Hex.API.OAuth.exchange_token(initial_token["access_token"], "api:write") do
275-
{:ok, {200, _, write_token_response}} ->
276-
write_token_response
277-
278-
{:ok, {status, _, error}} ->
279-
Hex.Shell.warn("Could not exchange for write token (#{status}): #{inspect(error)}")
280-
nil
281-
282-
{:error, reason} ->
283-
Hex.Shell.warn("Write token exchange error: #{inspect(reason)}")
284-
nil
285-
end
286-
287-
true ->
288-
# No write permission granted
289-
nil
290-
end
291-
end
292-
293-
defp create_read_token(initial_token, granted_scopes) do
294-
# Always create a separate read token - they have different refresh rates and conditions
295-
# Determine what read scopes we can request
296-
read_scopes = build_read_scopes(granted_scopes)
297-
298-
if read_scopes != "" do
299-
case Hex.API.OAuth.exchange_token(initial_token["access_token"], read_scopes) do
300-
{:ok, {200, _, read_token_response}} ->
301-
read_token_response
302-
303-
{:ok, {status, _, error}} ->
304-
Hex.Shell.warn("Could not exchange for read token (#{status}): #{inspect(error)}")
305-
nil
306-
307-
{:error, reason} ->
308-
Hex.Shell.warn("Read token exchange error: #{inspect(reason)}")
309-
nil
310-
end
311-
else
312-
# No read scopes available
313-
nil
314-
end
315-
end
316-
317-
defp build_read_scopes(granted_scopes) do
318-
read_scopes = []
319-
320-
# Add repositories if granted
321-
read_scopes =
322-
if "repositories" in granted_scopes do
323-
["repositories" | read_scopes]
324-
else
325-
read_scopes
326-
end
327-
328-
# Add api:read if we have any API read permission
329-
read_scopes =
330-
if "api" in granted_scopes || "api:read" in granted_scopes || "api:write" in granted_scopes do
331-
["api:read" | read_scopes]
332-
else
333-
read_scopes
334-
end
335-
336-
Enum.join(read_scopes, " ")
239+
# Store a single token for both read and write operations
240+
# With 2FA now required for write operations, we don't need separate tokens
241+
Hex.OAuth.store_token(token_data)
242+
Hex.Shell.info("You are authenticated!")
243+
{:ok, token_data}
337244
end
338245

339246
@doc false
@@ -382,25 +289,22 @@ defmodule Mix.Tasks.Hex do
382289

383290
@doc false
384291
def revoke_existing_oauth_tokens do
385-
case Hex.Config.read()[:"$oauth_tokens"] do
292+
case Hex.Config.read()[:"$oauth_token"] do
386293
nil ->
387294
:ok
388295

389-
tokens when is_map(tokens) ->
390-
Enum.each(tokens, fn {_type, token_data} ->
391-
if access_token = token_data["access_token"] do
392-
case Hex.API.OAuth.revoke_token(access_token) do
393-
{:ok, {code, _, _}} when code in 200..299 ->
394-
:ok
296+
token_data when is_map(token_data) ->
297+
if access_token = token_data["access_token"] do
298+
case Hex.API.OAuth.revoke_token(access_token) do
299+
{:ok, {code, _, _}} when code in 200..299 ->
300+
:ok
395301

396-
_ ->
397-
:ok
398-
end
302+
_ ->
303+
:ok
399304
end
400-
end)
305+
end
401306

402-
Hex.Config.remove([:"$oauth_tokens"])
403-
Hex.Shell.info("Revoked existing OAuth tokens.")
307+
Hex.Config.remove([:"$oauth_token"])
404308

405309
_ ->
406310
:ok
@@ -414,33 +318,19 @@ defmodule Mix.Tasks.Hex do
414318
# Check for old write key
415319
if write_key = config[:"$write_key"] do
416320
# Try to revoke on server (might fail if already revoked or invalid)
417-
case Hex.API.Key.delete(write_key, key: write_key) do
418-
{:ok, {code, _, _}} when code in 200..299 ->
419-
Hex.Shell.info("Revoked old write API key.")
420-
421-
_ ->
422-
# Key might already be invalid, continue anyway
423-
:ok
424-
end
321+
_ = Hex.API.Key.delete(write_key, key: write_key)
425322
end
426323

427324
# Check for old read key (only if different from write key)
428325
if read_key = config[:"$read_key"] do
429326
if read_key != config[:"$write_key"] do
430-
case Hex.API.Key.delete(read_key, key: read_key) do
431-
{:ok, {code, _, _}} when code in 200..299 ->
432-
Hex.Shell.info("Revoked old read API key.")
433-
434-
_ ->
435-
:ok
436-
end
327+
_ = Hex.API.Key.delete(read_key, key: read_key)
437328
end
438329
end
439330

440331
# Remove from config if they existed
441332
if config[:"$write_key"] || config[:"$read_key"] do
442333
Hex.Config.remove([:"$write_key", :"$read_key"])
443-
Hex.Shell.info("Removed deprecated API keys from config.")
444334
end
445335
end
446336

mix.exs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ defmodule Hex.MixProject do
1717
elixirc_options: elixirc_options(Mix.env()),
1818
elixirc_paths: elixirc_paths(Mix.env()),
1919
test_ignore_filters: [
20-
"test/fixtures/**/*.exs",
20+
~r"^test/fixtures/",
2121
"test/setup_hexpm.exs"
2222
]
2323
]

0 commit comments

Comments
 (0)