Skip to content

Commit 802e31f

Browse files
committed
Add WebauthnListener class
1 parent 9aa3d79 commit 802e31f

File tree

2 files changed

+204
-0
lines changed

2 files changed

+204
-0
lines changed

lib/rubygems/webauthn_listener.rb

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# frozen_string_literal: true
2+
require_relative "webauthn_listener/response/response_ok"
3+
require_relative "webauthn_listener/response/response_no_content"
4+
require_relative "webauthn_listener/response/response_bad_request"
5+
require_relative "webauthn_listener/response/response_not_found"
6+
require_relative "webauthn_listener/response/response_method_not_allowed"
7+
8+
##
9+
# The WebauthnListener class retrieves an OTP after a user successfully WebAuthns with the Gem host.
10+
# An instance opens a socket from the provided server and listens for a request from the Gem host.
11+
# The request should be a GET request to the root path and contains the OTP code in the form
12+
# of a query parameter `code`. The listener will return the code which will be used as the OTP for
13+
# API requests.
14+
#
15+
# Types of responses sent by the listener after receiving a request:
16+
# - 200 OK: OTP code was successfully retrieved
17+
# - 204 No Content: If the request was an OPTIONS request
18+
# - 400 Bad Request: If the request did not contain a query parameter `code`
19+
# - 404 Not Found: The request was not to the root path
20+
# - 405 Method Not Allowed: OTP code was not retrieved because the request was not a GET/OPTIONS request
21+
#
22+
# Example usage:
23+
#
24+
# server = TCPServer.new(0)
25+
# otp = Gem::WebauthnListener.wait_for_otp_code("https://rubygems.example", server)
26+
#
27+
28+
class Gem::WebauthnListener
29+
attr_reader :host
30+
31+
def initialize(host)
32+
@host = host
33+
end
34+
35+
def self.wait_for_otp_code(host, server)
36+
new(host).fetch_otp_from_connection(server)
37+
end
38+
39+
def fetch_otp_from_connection(server)
40+
loop do
41+
socket = server.accept
42+
request_line = socket.gets
43+
44+
method, req_uri, _protocol = request_line.split(' ')
45+
req_uri = URI.parse(req_uri)
46+
47+
# TODO: should check for the gem host
48+
49+
unless root_path?(req_uri)
50+
ResponseNotFound.send(socket, host)
51+
raise Gem::WebauthnVerificationError, "Page at #{req_uri.path} not found."
52+
end
53+
54+
case method.upcase
55+
when "OPTIONS"
56+
ResponseNoContent.send(socket, host)
57+
next # will be GET
58+
when "GET"
59+
if otp = parse_otp_from_uri(req_uri)
60+
ResponseOk.send(socket, host)
61+
return otp
62+
end
63+
ResponseBadRequest.send(socket, host)
64+
raise Gem::WebauthnVerificationError, "Did not receive OTP from #{host}."
65+
else
66+
ResponseMethodNotAllowed.send(socket, host)
67+
raise Gem::WebauthnVerificationError, "Invalid HTTP method #{method.upcase} received."
68+
end
69+
end
70+
end
71+
72+
private
73+
74+
def root_path?(uri)
75+
uri.path == "/"
76+
end
77+
78+
def parse_otp_from_uri(uri)
79+
require "cgi"
80+
81+
return if uri.query.nil?
82+
CGI.parse(uri.query).dig("code", 0)
83+
end
84+
end
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
# frozen_string_literal: true
2+
require_relative "helper"
3+
require "rubygems/webauthn_listener"
4+
5+
class WebauthnListenerTest < Gem::TestCase
6+
def setup
7+
super
8+
@server = TCPServer.new('localhost', 0)
9+
@port = @server.addr[1].to_s
10+
end
11+
12+
def test_wait_for_otp_code_get_follows_options
13+
wait_for_otp_code
14+
Gem::FakeBrowser.options URI("http://localhost:#{@port}?code=xyz")
15+
Gem::FakeBrowser.get URI("http://localhost:#{@port}?code=xyz")
16+
# TODO: add an assertion here
17+
end
18+
19+
def test_wait_for_otp_code_options_request
20+
wait_for_otp_code
21+
response = Gem::FakeBrowser.options URI("http://localhost:#{@port}?code=xyz")
22+
23+
assert response.is_a? Net::HTTPNoContent
24+
assert_equal Gem.host, response["access-control-allow-origin"]
25+
assert_equal "POST", response["access-control-allow-methods"]
26+
assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
27+
assert_equal "close", response["Connection"]
28+
end
29+
30+
def test_wait_for_otp_code_get_request
31+
wait_for_otp_code
32+
response = Gem::FakeBrowser.get URI("http://localhost:#{@port}?code=xyz")
33+
34+
assert response.is_a? Net::HTTPOK
35+
assert_equal "text/plain", response["Content-Type"]
36+
assert_equal "7", response["Content-Length"]
37+
assert_equal Gem.host, response["access-control-allow-origin"]
38+
assert_equal "POST", response["access-control-allow-methods"]
39+
assert_equal "Content-Type, Authorization, x-csrf-token", response["access-control-allow-headers"]
40+
assert_equal "close", response["Connection"]
41+
assert_equal "success", response.body
42+
43+
@thread.join
44+
assert_equal "xyz", @thread[:otp]
45+
end
46+
47+
def test_wait_for_otp_code_invalid_post_req_method
48+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Invalid HTTP method POST received.")
49+
response = Gem::FakeBrowser.post URI("http://localhost:#{@port}?code=xyz")
50+
51+
assert response
52+
assert response.is_a? Net::HTTPMethodNotAllowed
53+
assert_equal "GET, OPTIONS", response["allow"]
54+
assert_equal "close", response["Connection"]
55+
56+
@thread.join
57+
assert_nil @thread[:otp]
58+
end
59+
60+
def test_wait_for_otp_code_incorrect_path
61+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Page at /path not found.")
62+
response = Gem::FakeBrowser.post URI("http://localhost:#{@port}/path?code=xyz")
63+
64+
assert response.is_a? Net::HTTPNotFound
65+
assert_equal "close", response["Connection"]
66+
67+
@thread.join
68+
assert_nil @thread[:otp]
69+
end
70+
71+
def test_wait_for_otp_code_no_params_response
72+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
73+
response = Gem::FakeBrowser.get URI("http://localhost:#{@port}")
74+
75+
assert response.is_a? Net::HTTPBadRequest
76+
assert_equal "text/plain", response["Content-Type"]
77+
assert_equal "22", response["Content-Length"]
78+
assert_equal "close", response["Connection"]
79+
assert_equal "missing code parameter", response.body
80+
81+
@thread.join
82+
assert_nil @thread[:otp]
83+
end
84+
85+
def test_wait_for_otp_code_incorrect_params
86+
wait_for_otp_code_expect_error_with_message("Security device verification failed: Did not receive OTP from https://rubygems.org.")
87+
response = Gem::FakeBrowser.get URI("http://localhost:#{@port}?param=xyz")
88+
89+
assert response.is_a? Net::HTTPBadRequest
90+
assert_equal "text/plain", response["Content-Type"]
91+
assert_equal "22", response["Content-Length"]
92+
assert_equal "close", response["Connection"]
93+
assert_equal "missing code parameter", response.body
94+
95+
@thread.join
96+
assert_nil @thread[:otp]
97+
end
98+
99+
private
100+
101+
def wait_for_otp_code
102+
@thread = Thread.new do
103+
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
104+
end
105+
@thread.abort_on_exception = true
106+
@thread.report_on_exception = false
107+
end
108+
109+
def wait_for_otp_code_expect_error_with_message(message)
110+
@thread = Thread.new do
111+
error = assert_raise Gem::WebauthnVerificationError do
112+
Thread.current[:otp] = Gem::WebauthnListener.wait_for_otp_code(Gem.host, @server)
113+
end
114+
115+
assert_equal message, error.message
116+
end
117+
@thread.abort_on_exception = true
118+
@thread.report_on_exception = false
119+
end
120+
end

0 commit comments

Comments
 (0)