New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add poller to fetch WebAuthn OTP #6774
Add poller to fetch WebAuthn OTP #6774
Conversation
@@ -345,4 +348,75 @@ def test_verify_missing_api_key | |||
@cmd.verify_api_key :missing | |||
end | |||
end | |||
|
|||
class SignInFetcher < Gem::FakeFetcher |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
util_gem_signin
was getting too gnarly to add fetcher data so I extracted a helper class to setup the fetcher. After writing the rest of the tests for the other commands, I'm intending to follow up with another refactor to have some sort of a universal child fetcher for webauthn verifications since they all stub the same requests.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Done in f49fb73
lib/rubygems/gemcutter_utilities.rb
Outdated
def wait_for_otp(*threads) | ||
loop do | ||
threads.each do |otp_thread| | ||
next unless !otp_thread.alive? | ||
if otp = otp_thread[:otp] | ||
return otp | ||
elsif error = otp_thread[:error] | ||
alert_error error.message | ||
terminate_interaction(1) | ||
end | ||
end | ||
sleep 0.1 | ||
end | ||
ensure | ||
threads.each(&:exit) | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a better way to terminate once the poller or the listener succeeds?
90fbc97
to
8047416
Compare
18db2cd
to
dc9cdf7
Compare
lib/rubygems/gemcutter_utilities.rb
Outdated
Timeout.timeout(300) do | ||
loop do | ||
response = webauthn_verification_poll_response(webauthn_url, credentials) | ||
raise Gem::WebauthnVerificationError, response.message unless response.is_a?(Net::HTTPSuccess) | ||
|
||
require "json" | ||
parsed_response = JSON.parse(response.body) | ||
case parsed_response["status"] | ||
when "pending" | ||
sleep 5 | ||
when "success" | ||
Thread.current[:otp] = parsed_response["code"] | ||
break | ||
else | ||
raise Gem::WebauthnVerificationError, parsed_response["message"] | ||
end | ||
end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thinking of extracting this logic out into a class as a follow up
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why in followup?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Mostly to get the current functionality out as soon as possible and wanted to get people's thoughts on it. If you feel that extracting would be good to do in this PR, I can do that 👍
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
3856479
to
33ca40e
Compare
762e62e
to
57ad484
Compare
aabb69f
to
f49fb73
Compare
f49fb73
to
2c4afbb
Compare
url_with_port = "#{webauthn_url}?port=#{port}" | ||
say "You have enabled multi-factor authentication. Please visit #{url_with_port} to authenticate via security device. If you can't verify using WebAuthn but have OTP enabled, you can re-run the gem signin command with the `--otp [your_code]` option." | ||
|
||
threads = [WebauthnListener.listener_thread(host, server), WebauthnPoller.poll_thread(options, host, webauthn_url, credentials)] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If polling works for both Safari and any other browser, why not just use the polling alone instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good question, I toiled with this a bit. The original reason sockets were chosen was that the connection between the browser and the CLI cannot be phished + responses would be immediate. With polling, while very limited, there's still a risk that someone else can retrieve an OTP code if they have access to credentials + your unique link while also having up to a 5 second delay. With those reasons, I still think it would be worth to have polling as just a fallback.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, I agree that the socket is preferred, and if Safari ever lets us remove polling, we should.
def wait_for_otp_thread(*threads) | ||
loop do | ||
threads.each do |otp_thread| | ||
return otp_thread unless otp_thread.alive? | ||
end | ||
sleep 0.1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it perhaps worth adding a timeout on these threads where if they're left hanging for a couple minutes you kill the threads?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have added a 5 minute timeout for the poller since we don't want to hit the API indefinitely https://github.com/Shopify/rubygems/blob/polling-safari-fallback/lib/rubygems/gemcutter_utilities/webauthn_poller.rb#L47
Once 5 min is up, that thread would terminate safely which would trigger the other threads to terminate as well. Let me know if you think it makes more sense to move the timeout to this loop instead.
end | ||
|
||
def body | ||
"success" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it worth adding a bit more context here? I.e., "Success. You can return back to your terminal."
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The body isn't user facing but more of a status for the application to use to determine if verification was successful. Modifying the body would be a breaking change
Is there anything blocking this from moving forward? cc: @indirect @simi @segiddins |
Nothing from me! |
response = webauthn_verification_poll_response(webauthn_url, credentials) | ||
raise Gem::WebauthnVerificationError, response.message unless response.is_a?(Net::HTTPSuccess) | ||
|
||
require "json" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@deivid-rodriguez is it safe in here to require JSON? I did some experiment and we can also vendor pure Ruby JSON if needed. It is possible to also use YAML in API, but I'm not sure that will make any difference in here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding is that this is only used for gem push
, gem yank
, and gem owner
. If that's the case I think it's fine, at least for now, since there's no user code involved in these commands, so there should not be any conflicts.
2c4afbb
to
4eab352
Compare
4eab352
to
dead211
Compare
Thanks folks for taking a look at this! Hmm after rebasing, the tests appear to be a bit flaky now with the changes 🤔 Going to dig into this before this gets merged in. |
to prevent real TCPServers from being created and be leaked into other tests
548f3ca
to
96d6cb3
Compare
So to deal with the possibly flaky nature of using real TCPServers to test the commands (signin, owner add/remove, yank), I created a mock server object to mock that logic. I say possibly flaky since running I think it's better to mock the server as we aren't testing the WebAuthn listener logic (which interacts the TCPServer instance) in those tests but more on the output (we are stubbing the result of the WebAuthn listener anyways). We are still using real TCPServer instances on the Webauthn Listener tests to test requests coming in and out of the socket as realistically as possible. Let me know if you all think differently! Open to other suggestions. |
That sounds like a totally reasonable approach to me. 👍🏻 |
Happy to merge and release this whenever needed 👍. |
Thanks @deivid-rodriguez 🙌 I tested everything again and it all works well. If this can be a part of the next rubygems release, that would be perfect! |
Add poller to fetch WebAuthn OTP (cherry picked from commit 736f447)
What was the end-user or developer problem that led to this PR?
Fixes: rubygems/rubygems.org#3826
Discussed in the issue, we decided to rely on a polling solution for Safari users
RubyGems.org PR: rubygems/rubygems.org#3873
What is your fix for the problem, implemented in this PR?
If the user is using Safari, instead of redirecting the user to localhost after successful webauthn verification in the browser, we'll show the success page (since the auth on the browser was successful).
On the CLI, a poller is added that would poll
/api/v1/webauthn_verification/<token>/status
every 5 seconds.Since we can't be sure which browser the user will be using (finding the default browser won't be fool proof since a user can paste the link into any browser), so we'll have a socket and poller thread open.
Whichever thread terminates successfully with the otp will be used. This is done via a loop checking the status of the two threads.
Make sure the following tasks are checked
Testing
RUBYGEMS_HOST=http://localhost:3000 ruby -Ilib exe/gem signin
and webauthn verify using Safari