test_challenges.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. # Copyright 2021 Google LLC
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. #
  7. # http://www.apache.org/licenses/LICENSE-2.0
  8. #
  9. # Unless required by applicable law or agreed to in writing, software
  10. # distributed under the License is distributed on an "AS IS" BASIS,
  11. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  12. # See the License for the specific language governing permissions and
  13. # limitations under the License.
  14. """Tests for the reauth module."""
  15. import base64
  16. import os
  17. import sys
  18. import mock
  19. import pytest # type: ignore
  20. import pyu2f # type: ignore
  21. from google.auth import exceptions
  22. from google.oauth2 import challenges
  23. from google.oauth2.webauthn_types import (
  24. AuthenticationExtensionsClientInputs,
  25. AuthenticatorAssertionResponse,
  26. GetRequest,
  27. GetResponse,
  28. PublicKeyCredentialDescriptor,
  29. )
  30. def test_get_user_password():
  31. with mock.patch("getpass.getpass", return_value="foo"):
  32. assert challenges.get_user_password("") == "foo"
  33. def test_security_key():
  34. metadata = {
  35. "status": "READY",
  36. "challengeId": 2,
  37. "challengeType": "SECURITY_KEY",
  38. "securityKey": {
  39. "applicationId": "security_key_application_id",
  40. "challenges": [
  41. {
  42. "keyHandle": "some_key",
  43. "challenge": base64.urlsafe_b64encode(
  44. "some_challenge".encode("ascii")
  45. ).decode("ascii"),
  46. }
  47. ],
  48. "relyingPartyId": "security_key_application_id",
  49. },
  50. }
  51. mock_key = mock.Mock()
  52. challenge = challenges.SecurityKeyChallenge()
  53. # Test the case that security key challenge is passed with applicationId and
  54. # relyingPartyId the same.
  55. os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None)
  56. with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key):
  57. with mock.patch(
  58. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  59. ) as mock_authenticate:
  60. mock_authenticate.return_value = "security key response"
  61. assert challenge.name == "SECURITY_KEY"
  62. assert challenge.is_locally_eligible
  63. assert challenge.obtain_challenge_input(metadata) == {
  64. "securityKey": "security key response"
  65. }
  66. mock_authenticate.assert_called_with(
  67. "security_key_application_id",
  68. [{"key": mock_key, "challenge": b"some_challenge"}],
  69. print_callback=sys.stderr.write,
  70. )
  71. # Test the case that webauthn plugin is available
  72. os.environ["GOOGLE_AUTH_WEBAUTHN_PLUGIN"] = "plugin"
  73. with mock.patch(
  74. "google.oauth2.challenges.SecurityKeyChallenge._obtain_challenge_input_webauthn",
  75. return_value={"securityKey": "security key response"},
  76. ):
  77. assert challenge.obtain_challenge_input(metadata) == {
  78. "securityKey": "security key response"
  79. }
  80. os.environ.pop('"GOOGLE_AUTH_WEBAUTHN_PLUGIN"', None)
  81. # Test the case that security key challenge is passed with applicationId and
  82. # relyingPartyId different, first call works.
  83. metadata["securityKey"]["relyingPartyId"] = "security_key_relying_party_id"
  84. sys.stderr.write("metadata=" + str(metadata) + "\n")
  85. with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key):
  86. with mock.patch(
  87. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  88. ) as mock_authenticate:
  89. mock_authenticate.return_value = "security key response"
  90. assert challenge.name == "SECURITY_KEY"
  91. assert challenge.is_locally_eligible
  92. assert challenge.obtain_challenge_input(metadata) == {
  93. "securityKey": "security key response"
  94. }
  95. mock_authenticate.assert_called_with(
  96. "security_key_relying_party_id",
  97. [{"key": mock_key, "challenge": b"some_challenge"}],
  98. print_callback=sys.stderr.write,
  99. )
  100. # Test the case that security key challenge is passed with applicationId and
  101. # relyingPartyId different, first call fails, requires retry.
  102. metadata["securityKey"]["relyingPartyId"] = "security_key_relying_party_id"
  103. with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key):
  104. with mock.patch(
  105. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  106. ) as mock_authenticate:
  107. assert challenge.name == "SECURITY_KEY"
  108. assert challenge.is_locally_eligible
  109. mock_authenticate.side_effect = [
  110. pyu2f.errors.U2FError(pyu2f.errors.U2FError.DEVICE_INELIGIBLE),
  111. "security key response",
  112. ]
  113. assert challenge.obtain_challenge_input(metadata) == {
  114. "securityKey": "security key response"
  115. }
  116. calls = [
  117. mock.call(
  118. "security_key_relying_party_id",
  119. [{"key": mock_key, "challenge": b"some_challenge"}],
  120. print_callback=sys.stderr.write,
  121. ),
  122. mock.call(
  123. "security_key_application_id",
  124. [{"key": mock_key, "challenge": b"some_challenge"}],
  125. print_callback=sys.stderr.write,
  126. ),
  127. ]
  128. mock_authenticate.assert_has_calls(calls)
  129. # Test various types of exceptions.
  130. with mock.patch("pyu2f.model.RegisteredKey", return_value=mock_key):
  131. with mock.patch(
  132. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  133. ) as mock_authenticate:
  134. mock_authenticate.side_effect = pyu2f.errors.U2FError(
  135. pyu2f.errors.U2FError.DEVICE_INELIGIBLE
  136. )
  137. assert challenge.obtain_challenge_input(metadata) is None
  138. with mock.patch(
  139. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  140. ) as mock_authenticate:
  141. mock_authenticate.side_effect = pyu2f.errors.U2FError(
  142. pyu2f.errors.U2FError.TIMEOUT
  143. )
  144. assert challenge.obtain_challenge_input(metadata) is None
  145. with mock.patch(
  146. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  147. ) as mock_authenticate:
  148. mock_authenticate.side_effect = pyu2f.errors.PluginError()
  149. assert challenge.obtain_challenge_input(metadata) is None
  150. with mock.patch(
  151. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  152. ) as mock_authenticate:
  153. mock_authenticate.side_effect = pyu2f.errors.U2FError(
  154. pyu2f.errors.U2FError.BAD_REQUEST
  155. )
  156. with pytest.raises(pyu2f.errors.U2FError):
  157. challenge.obtain_challenge_input(metadata)
  158. with mock.patch(
  159. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  160. ) as mock_authenticate:
  161. mock_authenticate.side_effect = pyu2f.errors.NoDeviceFoundError()
  162. assert challenge.obtain_challenge_input(metadata) is None
  163. with mock.patch(
  164. "pyu2f.convenience.authenticator.CompositeAuthenticator.Authenticate"
  165. ) as mock_authenticate:
  166. mock_authenticate.side_effect = pyu2f.errors.UnsupportedVersionException()
  167. with pytest.raises(pyu2f.errors.UnsupportedVersionException):
  168. challenge.obtain_challenge_input(metadata)
  169. with mock.patch.dict("sys.modules"):
  170. sys.modules["pyu2f"] = None
  171. with pytest.raises(exceptions.ReauthFailError) as excinfo:
  172. challenge.obtain_challenge_input(metadata)
  173. assert excinfo.match(r"pyu2f dependency is required")
  174. def test_security_key_webauthn():
  175. metadata = {
  176. "status": "READY",
  177. "challengeId": 2,
  178. "challengeType": "SECURITY_KEY",
  179. "securityKey": {
  180. "applicationId": "security_key_application_id",
  181. "challenges": [
  182. {
  183. "keyHandle": "some_key",
  184. "challenge": base64.urlsafe_b64encode(
  185. "some_challenge".encode("ascii")
  186. ).decode("ascii"),
  187. }
  188. ],
  189. "relyingPartyId": "security_key_application_id",
  190. },
  191. }
  192. challenge = challenges.SecurityKeyChallenge()
  193. sk = metadata["securityKey"]
  194. sk_challenges = sk["challenges"]
  195. application_id = sk["applicationId"]
  196. allow_credentials = []
  197. for sk_challenge in sk_challenges:
  198. allow_credentials.append(
  199. PublicKeyCredentialDescriptor(id=sk_challenge["keyHandle"])
  200. )
  201. extension = AuthenticationExtensionsClientInputs(appid=application_id)
  202. get_request = GetRequest(
  203. origin=challenges.REAUTH_ORIGIN,
  204. rpid=application_id,
  205. challenge=challenge._unpadded_urlsafe_b64recode(sk_challenge["challenge"]),
  206. timeout_ms=challenges.WEBAUTHN_TIMEOUT_MS,
  207. allow_credentials=allow_credentials,
  208. user_verification="required",
  209. extensions=extension,
  210. )
  211. assertion_resp = AuthenticatorAssertionResponse(
  212. client_data_json="clientDataJSON",
  213. authenticator_data="authenticatorData",
  214. signature="signature",
  215. user_handle="userHandle",
  216. )
  217. get_response = GetResponse(
  218. id="id",
  219. response=assertion_resp,
  220. authenticator_attachment="authenticatorAttachment",
  221. client_extension_results="clientExtensionResults",
  222. )
  223. response = {
  224. "clientData": get_response.response.client_data_json,
  225. "authenticatorData": get_response.response.authenticator_data,
  226. "signatureData": get_response.response.signature,
  227. "applicationId": "security_key_application_id",
  228. "keyHandle": get_response.id,
  229. "securityKeyReplyType": 2,
  230. }
  231. mock_handler = mock.Mock()
  232. mock_handler.get.return_value = get_response
  233. # Test success case
  234. assert challenge._obtain_challenge_input_webauthn(metadata, mock_handler) == {
  235. "securityKey": response
  236. }
  237. mock_handler.get.assert_called_with(get_request)
  238. # Test exceptions
  239. # Missing Values
  240. sk = metadata["securityKey"]
  241. metadata["securityKey"] = None
  242. with pytest.raises(exceptions.InvalidValue):
  243. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  244. metadata["securityKey"] = sk
  245. c = metadata["securityKey"]["challenges"]
  246. metadata["securityKey"]["challenges"] = None
  247. with pytest.raises(exceptions.InvalidValue):
  248. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  249. metadata["securityKey"]["challenges"] = []
  250. with pytest.raises(exceptions.InvalidValue):
  251. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  252. metadata["securityKey"]["challenges"] = c
  253. aid = metadata["securityKey"]["applicationId"]
  254. metadata["securityKey"]["applicationId"] = None
  255. with pytest.raises(exceptions.InvalidValue):
  256. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  257. metadata["securityKey"]["applicationId"] = aid
  258. rpi = metadata["securityKey"]["relyingPartyId"]
  259. metadata["securityKey"]["relyingPartyId"] = None
  260. with pytest.raises(exceptions.InvalidValue):
  261. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  262. metadata["securityKey"]["relyingPartyId"] = rpi
  263. kh = metadata["securityKey"]["challenges"][0]["keyHandle"]
  264. metadata["securityKey"]["challenges"][0]["keyHandle"] = None
  265. with pytest.raises(exceptions.InvalidValue):
  266. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  267. metadata["securityKey"]["challenges"][0]["keyHandle"] = kh
  268. ch = metadata["securityKey"]["challenges"][0]["challenge"]
  269. metadata["securityKey"]["challenges"][0]["challenge"] = None
  270. with pytest.raises(exceptions.InvalidValue):
  271. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  272. metadata["securityKey"]["challenges"][0]["challenge"] = ch
  273. # Handler Exceptions
  274. mock_handler.get.side_effect = exceptions.MalformedError
  275. with pytest.raises(exceptions.MalformedError):
  276. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  277. mock_handler.get.side_effect = exceptions.InvalidResource
  278. with pytest.raises(exceptions.InvalidResource):
  279. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  280. mock_handler.get.side_effect = exceptions.ReauthFailError
  281. with pytest.raises(exceptions.ReauthFailError):
  282. challenge._obtain_challenge_input_webauthn(metadata, mock_handler)
  283. @mock.patch("getpass.getpass", return_value="foo")
  284. def test_password_challenge(getpass_mock):
  285. challenge = challenges.PasswordChallenge()
  286. with mock.patch("getpass.getpass", return_value="foo"):
  287. assert challenge.is_locally_eligible
  288. assert challenge.name == "PASSWORD"
  289. assert challenges.PasswordChallenge().obtain_challenge_input({}) == {
  290. "credential": "foo"
  291. }
  292. with mock.patch("getpass.getpass", return_value=None):
  293. assert challenges.PasswordChallenge().obtain_challenge_input({}) == {
  294. "credential": " "
  295. }
  296. def test_saml_challenge():
  297. challenge = challenges.SamlChallenge()
  298. assert challenge.is_locally_eligible
  299. assert challenge.name == "SAML"
  300. with pytest.raises(exceptions.ReauthSamlChallengeFailError):
  301. challenge.obtain_challenge_input(None)