Skip to content
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

Merged
merged 11 commits into from Jul 28, 2023

Conversation

jenshenny
Copy link
Member

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

@@ -345,4 +348,75 @@ def test_verify_missing_api_key
@cmd.verify_api_key :missing
end
end

class SignInFetcher < Gem::FakeFetcher
Copy link
Member Author

@jenshenny jenshenny Jun 27, 2023

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.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in f49fb73

Comment on lines 272 to 290
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
Copy link
Member Author

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?

@jenshenny jenshenny force-pushed the polling-safari-fallback branch 6 times, most recently from 90fbc97 to 8047416 Compare June 28, 2023 20:21
@jenshenny jenshenny marked this pull request as ready for review June 28, 2023 21:23
Comment on lines 307 to 323
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
Copy link
Member Author

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

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why in followup?

Copy link
Member Author

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 👍

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

commit to extract the logic to a class 78a8b8a

I also moved the webauthn listener class to the gemcutter utilities namespace fd92d0d

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)]
Copy link
Contributor

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?

Copy link
Member Author

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.

Copy link
Member

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.

Comment on lines +281 to +286
def wait_for_otp_thread(*threads)
loop do
threads.each do |otp_thread|
return otp_thread unless otp_thread.alive?
end
sleep 0.1
Copy link
Contributor

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?

Copy link
Member Author

@jenshenny jenshenny Jul 17, 2023

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"
Copy link
Contributor

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."

Copy link
Member Author

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

https://github.com/rubygems/rubygems.org/blob/2f0f168e1a2bdd95cb7c548599fb498dfc48085c/app/assets/javascripts/webauthn.js#L138

@jenshenny
Copy link
Member Author

Is there anything blocking this from moving forward? cc: @indirect @simi @segiddins

@indirect
Copy link
Member

Nothing from me!

response = webauthn_verification_poll_response(webauthn_url, credentials)
raise Gem::WebauthnVerificationError, response.message unless response.is_a?(Net::HTTPSuccess)

require "json"
Copy link
Member

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.

Copy link
Member

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.

@jenshenny
Copy link
Member Author

Thanks folks for taking a look at this!

Hmm after rebasing, the tests appear to be a bit flaky now with the changes 🤔
https://github.com/rubygems/rubygems/actions/runs/5600005188/jobs/10241758022
https://github.com/rubygems/rubygems/actions/runs/5600328937/jobs/10242557190

Going to dig into this before this gets merged in.

to prevent real TCPServers from being created and be leaked into
other tests
@jenshenny
Copy link
Member Author

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 ruby-core on the same test seed passes + I couldn't find a way to reproduce the errors.

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.

@indirect
Copy link
Member

That sounds like a totally reasonable approach to me. 👍🏻

@deivid-rodriguez
Copy link
Member

Happy to merge and release this whenever needed 👍.

@jenshenny
Copy link
Member Author

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!

@deivid-rodriguez deivid-rodriguez merged commit 736f447 into rubygems:master Jul 28, 2023
83 checks passed
@jenshenny jenshenny deleted the polling-safari-fallback branch July 28, 2023 17:17
deivid-rodriguez added a commit that referenced this pull request Jul 31, 2023
Add poller to fetch WebAuthn OTP

(cherry picked from commit 736f447)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Add WebAuthn CLI fallback for Safari browsers
6 participants