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 ()}.
147148device_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
172192poll_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
441513put_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