Skip to content

Commit 9ea52a0

Browse files
committed
Close what the third CLI review found
Escape cmd.exe metacharacters before handing a server-supplied URL to the Windows browser opener. erts quotes an argument only when it contains a space or a quote, so a verification URI carrying a bare & reached cmd as a command separator. Classify a refresh response by what it says. Only 400 and 401 mean the refresh token is dead and clear the stored credentials; every other status, a transport failure, and a 200 whose body does not carry a usable access_token and expires_in leave the token in place. Renew on a token_expired challenge instead of resolving the same bearer again, and bound the renewals. The api path resolved with renewal disabled, so a token outside its expiry window came back unchanged and the request retried forever. The retry budget lives with the other retry state so it survives a reauthentication rather than resetting. Validate the device authorization, poll and refresh responses before destructuring them, so a malformed 200 returns an error the callers already handle instead of a badmatch, a badarith, or a badarg out of timer:sleep. Distinguish a missing sso_reauth_required from a malformed one. Missing still means nothing lapsed, for servers that do not send it; a value that is not a list of binaries no longer reads as an authoritative empty set that clears the stored organizations. Hold the device authentication lock over credential acquisition only. Taking a lock the same process already holds adds no reference, and releasing the inner one deleted the entry, so the outer body ran unlocked and a second process could start a concurrent device authentication.
1 parent c7cbc92 commit 9ea52a0

4 files changed

Lines changed: 717 additions & 119 deletions

File tree

src/hex_api_oauth.erl

Lines changed: 119 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
sso_reauth_required/1,
1313
revoke_token/3,
1414
client_credentials_token/4,
15-
client_credentials_token/5
15+
client_credentials_token/5,
16+
win_cmd_args/1
1617
]).
1718

1819
-export_type([oauth_tokens/0, device_auth_error/0]).
@@ -146,28 +147,47 @@ device_auth_flow(Config, ClientId, Scope, PromptUser) ->
146147
) -> {ok, oauth_tokens()} | {error, device_auth_error()}.
147148
device_auth_flow(Config, ClientId, Scope, PromptUser, Opts) ->
148149
case device_authorization(Config, ClientId, Scope, Opts) of
149-
{ok, {200, _, DeviceResponse}} when is_map(DeviceResponse) ->
150-
#{
151-
<<"device_code">> := DeviceCode,
152-
<<"user_code">> := UserCode,
153-
<<"verification_uri_complete">> := VerificationUri,
154-
<<"expires_in">> := ExpiresIn,
155-
<<"interval">> := IntervalSeconds
156-
} = DeviceResponse,
157-
ok = PromptUser(VerificationUri, UserCode),
158-
OpenBrowser = proplists:get_value(open_browser, Opts, false),
159-
case OpenBrowser of
160-
true -> open_browser(VerificationUri);
161-
false -> ok
162-
end,
163-
ExpiresAt = erlang:system_time(second) + ExpiresIn,
164-
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
150+
{ok, {200, _, DeviceResponse}} ->
151+
case device_authorization_fields(DeviceResponse) of
152+
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, IntervalSeconds} ->
153+
ok = PromptUser(VerificationUri, UserCode),
154+
OpenBrowser = proplists:get_value(open_browser, Opts, false),
155+
case OpenBrowser of
156+
true -> open_browser(VerificationUri);
157+
false -> ok
158+
end,
159+
ExpiresAt = erlang:system_time(second) + ExpiresIn,
160+
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
161+
error ->
162+
{error, {device_auth_failed, 200, DeviceResponse}}
163+
end;
165164
{ok, {Status, _, Body}} ->
166165
{error, {device_auth_failed, Status, Body}};
167166
{error, Reason} ->
168167
{error, Reason}
169168
end.
170169

170+
%% @private
171+
%% The fields the flow goes on to use, in the types it uses them as: the
172+
%% interval is slept on and the expiry is added to a timestamp.
173+
device_authorization_fields(#{
174+
<<"device_code">> := DeviceCode,
175+
<<"user_code">> := UserCode,
176+
<<"verification_uri_complete">> := VerificationUri,
177+
<<"expires_in">> := ExpiresIn,
178+
<<"interval">> := Interval
179+
}) when
180+
is_binary(DeviceCode),
181+
is_binary(UserCode),
182+
is_binary(VerificationUri),
183+
is_integer(ExpiresIn),
184+
is_integer(Interval),
185+
Interval >= 0
186+
->
187+
{ok, DeviceCode, UserCode, VerificationUri, ExpiresIn, Interval};
188+
device_authorization_fields(_DeviceResponse) ->
189+
error.
190+
171191
%% @private
172192
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
173193
Now = erlang:system_time(second),
@@ -177,17 +197,20 @@ poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt) ->
177197
false ->
178198
timer:sleep(IntervalSeconds * 1000),
179199
case poll_device_token(Config, ClientId, DeviceCode) of
180-
{ok, {200, _, TokenResponse}} when is_map(TokenResponse) ->
181-
#{
182-
<<"access_token">> := AccessToken,
183-
<<"expires_in">> := ExpiresIn
184-
} = TokenResponse,
185-
Tokens = #{
186-
access_token => AccessToken,
187-
expires_at => erlang:system_time(second) + ExpiresIn,
188-
sso_reauth_required => sso_reauth_required(TokenResponse)
189-
},
190-
{ok, put_refresh_token(Tokens, TokenResponse)};
200+
{ok, {200, _, TokenResponse}} ->
201+
case token_response_fields(TokenResponse) of
202+
{ok, AccessToken, ExpiresIn} ->
203+
Tokens = #{
204+
access_token => AccessToken,
205+
expires_at => erlang:system_time(second) + ExpiresIn
206+
},
207+
{ok,
208+
put_sso_reauth_required(
209+
put_refresh_token(Tokens, TokenResponse), TokenResponse
210+
)};
211+
error ->
212+
{error, {poll_failed, 200, TokenResponse}}
213+
end;
191214
{ok, {400, _, #{<<"error">> := <<"authorization_pending">>}}} ->
192215
poll_for_token_loop(Config, ClientId, DeviceCode, IntervalSeconds, ExpiresAt);
193216
{ok, {400, _, #{<<"error">> := <<"slow_down">>}}} ->
@@ -374,16 +397,25 @@ revoke_token(Config, ClientId, Token) ->
374397
},
375398
hex_api:post(Config, Path, Params).
376399

377-
%% @private
400+
%% @doc
378401
%% Organizations a token response says the session has to authenticate against
379-
%% their identity provider for. Older servers do not send the field at all,
380-
%% which means nothing is lapsed.
381-
-spec sso_reauth_required(map()) -> [binary()].
382-
sso_reauth_required(TokenResponse) ->
383-
case maps:get(<<"sso_reauth_required">>, TokenResponse, []) of
384-
Organizations when is_list(Organizations) -> Organizations;
385-
_Other -> []
386-
end.
402+
%% their identity provider for.
403+
%%
404+
%% Returns `{ok, []}' when the response does not carry the field, which is what
405+
%% servers that predate it send and means nothing is lapsed. Returns `error'
406+
%% when the field is there in a shape that cannot be read, which says nothing
407+
%% about what has lapsed and must not be taken for the empty set.
408+
%% @end
409+
-spec sso_reauth_required(map()) -> {ok, [binary()]} | error.
410+
sso_reauth_required(#{<<"sso_reauth_required">> := Organizations}) when is_list(Organizations) ->
411+
case lists:all(fun is_binary/1, Organizations) of
412+
true -> {ok, Organizations};
413+
false -> error
414+
end;
415+
sso_reauth_required(#{<<"sso_reauth_required">> := _Organizations}) ->
416+
error;
417+
sso_reauth_required(_TokenResponse) ->
418+
{ok, []}.
387419

388420
%%====================================================================
389421
%% Internal functions
@@ -413,7 +445,7 @@ spawn_browser(Url) ->
413445
{unix, _} ->
414446
{"xdg-open", [Url]};
415447
{win32, _} ->
416-
{"cmd", ["/c", "start", "", Url]}
448+
{"cmd", win_cmd_args(Url)}
417449
end,
418450
case os:find_executable(Cmd) of
419451
false ->
@@ -423,6 +455,37 @@ spawn_browser(Url) ->
423455
ok
424456
end.
425457

458+
%% @private
459+
%% `start' takes its first quoted argument as the window title, so the empty
460+
%% string keeps the URL in the position `start' opens.
461+
-spec win_cmd_args(string()) -> [string()].
462+
win_cmd_args(Url) ->
463+
["/c", "start", "", escape_win_cmd(Url)].
464+
465+
%% @private
466+
%% cmd.exe parses the command line before `start' sees it, and erts only quotes
467+
%% an argument that contains whitespace, so every character cmd acts on is
468+
%% prefixed with a caret. `%' is included because cmd expands `%NAME%' before it
469+
%% scans for separators.
470+
escape_win_cmd(Url) ->
471+
lists:flatmap(fun escape_win_cmd_character/1, Url).
472+
473+
%% @private
474+
escape_win_cmd_character(Character) when
475+
Character =:= $^;
476+
Character =:= $&;
477+
Character =:= $|;
478+
Character =:= $<;
479+
Character =:= $>;
480+
Character =:= $(;
481+
Character =:= $);
482+
Character =:= $";
483+
Character =:= $%
484+
->
485+
[$^, Character];
486+
escape_win_cmd_character(Character) ->
487+
[Character].
488+
426489
%% @private
427490
%% Whether a URL uses the http:// or https:// scheme.
428491
-spec valid_http_url(binary()) -> boolean().
@@ -433,6 +496,15 @@ valid_http_url(Url) when is_binary(Url) ->
433496
_ -> false
434497
end.
435498

499+
%% @private
500+
%% The access token and its lifetime, in the types the caller uses them as.
501+
token_response_fields(#{<<"access_token">> := AccessToken, <<"expires_in">> := ExpiresIn}) when
502+
is_binary(AccessToken), is_integer(ExpiresIn)
503+
->
504+
{ok, AccessToken, ExpiresIn};
505+
token_response_fields(_TokenResponse) ->
506+
error.
507+
436508
%% @private
437509
%% A response without a refresh token carries no key at all rather than a
438510
%% placeholder, so what a build tool stores is only ever a real token.
@@ -441,6 +513,15 @@ put_refresh_token(Tokens, #{<<"refresh_token">> := RefreshToken}) when is_binary
441513
put_refresh_token(Tokens, _TokenResponse) ->
442514
Tokens.
443515

516+
%% @private
517+
%% A response whose sso_reauth_required cannot be read carries no key, so the
518+
%% caller is not handed the empty set as if the server had sent it.
519+
put_sso_reauth_required(Tokens, TokenResponse) ->
520+
case sso_reauth_required(TokenResponse) of
521+
{ok, Organizations} -> Tokens#{sso_reauth_required => Organizations};
522+
error -> Tokens
523+
end.
524+
444525
%% @private
445526
%% Get the hostname of the current machine.
446527
-spec get_hostname() -> binary().

0 commit comments

Comments
 (0)