test_sts.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. # Copyright 2020 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. import json
  15. import mock
  16. import pytest
  17. from six.moves import http_client
  18. from six.moves import urllib
  19. from google.auth import exceptions
  20. from google.auth import transport
  21. from google.oauth2 import sts
  22. from google.oauth2 import utils
  23. CLIENT_ID = "username"
  24. CLIENT_SECRET = "password"
  25. # Base64 encoding of "username:password"
  26. BASIC_AUTH_ENCODING = "dXNlcm5hbWU6cGFzc3dvcmQ="
  27. class TestStsClient(object):
  28. GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
  29. RESOURCE = "https://api.example.com/"
  30. AUDIENCE = "urn:example:cooperation-context"
  31. SCOPES = ["scope1", "scope2"]
  32. REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
  33. SUBJECT_TOKEN = "HEADER.SUBJECT_TOKEN_PAYLOAD.SIGNATURE"
  34. SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
  35. ACTOR_TOKEN = "HEADER.ACTOR_TOKEN_PAYLOAD.SIGNATURE"
  36. ACTOR_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
  37. TOKEN_EXCHANGE_ENDPOINT = "https://example.com/token.oauth2"
  38. ADDON_HEADERS = {"x-client-version": "0.1.2"}
  39. ADDON_OPTIONS = {"additional": {"non-standard": ["options"], "other": "some-value"}}
  40. SUCCESS_RESPONSE = {
  41. "access_token": "ACCESS_TOKEN",
  42. "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  43. "token_type": "Bearer",
  44. "expires_in": 3600,
  45. "scope": "scope1 scope2",
  46. }
  47. ERROR_RESPONSE = {
  48. "error": "invalid_request",
  49. "error_description": "Invalid subject token",
  50. "error_uri": "https://tools.ietf.org/html/rfc6749",
  51. }
  52. CLIENT_AUTH_BASIC = utils.ClientAuthentication(
  53. utils.ClientAuthType.basic, CLIENT_ID, CLIENT_SECRET
  54. )
  55. CLIENT_AUTH_REQUEST_BODY = utils.ClientAuthentication(
  56. utils.ClientAuthType.request_body, CLIENT_ID, CLIENT_SECRET
  57. )
  58. @classmethod
  59. def make_client(cls, client_auth=None):
  60. return sts.Client(cls.TOKEN_EXCHANGE_ENDPOINT, client_auth)
  61. @classmethod
  62. def make_mock_request(cls, data, status=http_client.OK):
  63. response = mock.create_autospec(transport.Response, instance=True)
  64. response.status = status
  65. response.data = json.dumps(data).encode("utf-8")
  66. request = mock.create_autospec(transport.Request)
  67. request.return_value = response
  68. return request
  69. @classmethod
  70. def assert_request_kwargs(cls, request_kwargs, headers, request_data):
  71. """Asserts the request was called with the expected parameters.
  72. """
  73. assert request_kwargs["url"] == cls.TOKEN_EXCHANGE_ENDPOINT
  74. assert request_kwargs["method"] == "POST"
  75. assert request_kwargs["headers"] == headers
  76. assert request_kwargs["body"] is not None
  77. body_tuples = urllib.parse.parse_qsl(request_kwargs["body"])
  78. for (k, v) in body_tuples:
  79. assert v.decode("utf-8") == request_data[k.decode("utf-8")]
  80. assert len(body_tuples) == len(request_data.keys())
  81. def test_exchange_token_full_success_without_auth(self):
  82. """Test token exchange success without client authentication using full
  83. parameters.
  84. """
  85. client = self.make_client()
  86. headers = self.ADDON_HEADERS.copy()
  87. headers["Content-Type"] = "application/x-www-form-urlencoded"
  88. request_data = {
  89. "grant_type": self.GRANT_TYPE,
  90. "resource": self.RESOURCE,
  91. "audience": self.AUDIENCE,
  92. "scope": " ".join(self.SCOPES),
  93. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  94. "subject_token": self.SUBJECT_TOKEN,
  95. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  96. "actor_token": self.ACTOR_TOKEN,
  97. "actor_token_type": self.ACTOR_TOKEN_TYPE,
  98. "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)),
  99. }
  100. request = self.make_mock_request(
  101. status=http_client.OK, data=self.SUCCESS_RESPONSE
  102. )
  103. response = client.exchange_token(
  104. request,
  105. self.GRANT_TYPE,
  106. self.SUBJECT_TOKEN,
  107. self.SUBJECT_TOKEN_TYPE,
  108. self.RESOURCE,
  109. self.AUDIENCE,
  110. self.SCOPES,
  111. self.REQUESTED_TOKEN_TYPE,
  112. self.ACTOR_TOKEN,
  113. self.ACTOR_TOKEN_TYPE,
  114. self.ADDON_OPTIONS,
  115. self.ADDON_HEADERS,
  116. )
  117. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  118. assert response == self.SUCCESS_RESPONSE
  119. def test_exchange_token_partial_success_without_auth(self):
  120. """Test token exchange success without client authentication using
  121. partial (required only) parameters.
  122. """
  123. client = self.make_client()
  124. headers = {"Content-Type": "application/x-www-form-urlencoded"}
  125. request_data = {
  126. "grant_type": self.GRANT_TYPE,
  127. "audience": self.AUDIENCE,
  128. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  129. "subject_token": self.SUBJECT_TOKEN,
  130. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  131. }
  132. request = self.make_mock_request(
  133. status=http_client.OK, data=self.SUCCESS_RESPONSE
  134. )
  135. response = client.exchange_token(
  136. request,
  137. grant_type=self.GRANT_TYPE,
  138. subject_token=self.SUBJECT_TOKEN,
  139. subject_token_type=self.SUBJECT_TOKEN_TYPE,
  140. audience=self.AUDIENCE,
  141. requested_token_type=self.REQUESTED_TOKEN_TYPE,
  142. )
  143. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  144. assert response == self.SUCCESS_RESPONSE
  145. def test_exchange_token_non200_without_auth(self):
  146. """Test token exchange without client auth responding with non-200 status.
  147. """
  148. client = self.make_client()
  149. request = self.make_mock_request(
  150. status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE
  151. )
  152. with pytest.raises(exceptions.OAuthError) as excinfo:
  153. client.exchange_token(
  154. request,
  155. self.GRANT_TYPE,
  156. self.SUBJECT_TOKEN,
  157. self.SUBJECT_TOKEN_TYPE,
  158. self.RESOURCE,
  159. self.AUDIENCE,
  160. self.SCOPES,
  161. self.REQUESTED_TOKEN_TYPE,
  162. self.ACTOR_TOKEN,
  163. self.ACTOR_TOKEN_TYPE,
  164. self.ADDON_OPTIONS,
  165. self.ADDON_HEADERS,
  166. )
  167. assert excinfo.match(
  168. r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
  169. )
  170. def test_exchange_token_full_success_with_basic_auth(self):
  171. """Test token exchange success with basic client authentication using full
  172. parameters.
  173. """
  174. client = self.make_client(self.CLIENT_AUTH_BASIC)
  175. headers = self.ADDON_HEADERS.copy()
  176. headers["Content-Type"] = "application/x-www-form-urlencoded"
  177. headers["Authorization"] = "Basic {}".format(BASIC_AUTH_ENCODING)
  178. request_data = {
  179. "grant_type": self.GRANT_TYPE,
  180. "resource": self.RESOURCE,
  181. "audience": self.AUDIENCE,
  182. "scope": " ".join(self.SCOPES),
  183. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  184. "subject_token": self.SUBJECT_TOKEN,
  185. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  186. "actor_token": self.ACTOR_TOKEN,
  187. "actor_token_type": self.ACTOR_TOKEN_TYPE,
  188. "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)),
  189. }
  190. request = self.make_mock_request(
  191. status=http_client.OK, data=self.SUCCESS_RESPONSE
  192. )
  193. response = client.exchange_token(
  194. request,
  195. self.GRANT_TYPE,
  196. self.SUBJECT_TOKEN,
  197. self.SUBJECT_TOKEN_TYPE,
  198. self.RESOURCE,
  199. self.AUDIENCE,
  200. self.SCOPES,
  201. self.REQUESTED_TOKEN_TYPE,
  202. self.ACTOR_TOKEN,
  203. self.ACTOR_TOKEN_TYPE,
  204. self.ADDON_OPTIONS,
  205. self.ADDON_HEADERS,
  206. )
  207. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  208. assert response == self.SUCCESS_RESPONSE
  209. def test_exchange_token_partial_success_with_basic_auth(self):
  210. """Test token exchange success with basic client authentication using
  211. partial (required only) parameters.
  212. """
  213. client = self.make_client(self.CLIENT_AUTH_BASIC)
  214. headers = {
  215. "Content-Type": "application/x-www-form-urlencoded",
  216. "Authorization": "Basic {}".format(BASIC_AUTH_ENCODING),
  217. }
  218. request_data = {
  219. "grant_type": self.GRANT_TYPE,
  220. "audience": self.AUDIENCE,
  221. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  222. "subject_token": self.SUBJECT_TOKEN,
  223. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  224. }
  225. request = self.make_mock_request(
  226. status=http_client.OK, data=self.SUCCESS_RESPONSE
  227. )
  228. response = client.exchange_token(
  229. request,
  230. grant_type=self.GRANT_TYPE,
  231. subject_token=self.SUBJECT_TOKEN,
  232. subject_token_type=self.SUBJECT_TOKEN_TYPE,
  233. audience=self.AUDIENCE,
  234. requested_token_type=self.REQUESTED_TOKEN_TYPE,
  235. )
  236. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  237. assert response == self.SUCCESS_RESPONSE
  238. def test_exchange_token_non200_with_basic_auth(self):
  239. """Test token exchange with basic client auth responding with non-200
  240. status.
  241. """
  242. client = self.make_client(self.CLIENT_AUTH_BASIC)
  243. request = self.make_mock_request(
  244. status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE
  245. )
  246. with pytest.raises(exceptions.OAuthError) as excinfo:
  247. client.exchange_token(
  248. request,
  249. self.GRANT_TYPE,
  250. self.SUBJECT_TOKEN,
  251. self.SUBJECT_TOKEN_TYPE,
  252. self.RESOURCE,
  253. self.AUDIENCE,
  254. self.SCOPES,
  255. self.REQUESTED_TOKEN_TYPE,
  256. self.ACTOR_TOKEN,
  257. self.ACTOR_TOKEN_TYPE,
  258. self.ADDON_OPTIONS,
  259. self.ADDON_HEADERS,
  260. )
  261. assert excinfo.match(
  262. r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
  263. )
  264. def test_exchange_token_full_success_with_reqbody_auth(self):
  265. """Test token exchange success with request body client authenticaiton
  266. using full parameters.
  267. """
  268. client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY)
  269. headers = self.ADDON_HEADERS.copy()
  270. headers["Content-Type"] = "application/x-www-form-urlencoded"
  271. request_data = {
  272. "grant_type": self.GRANT_TYPE,
  273. "resource": self.RESOURCE,
  274. "audience": self.AUDIENCE,
  275. "scope": " ".join(self.SCOPES),
  276. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  277. "subject_token": self.SUBJECT_TOKEN,
  278. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  279. "actor_token": self.ACTOR_TOKEN,
  280. "actor_token_type": self.ACTOR_TOKEN_TYPE,
  281. "options": urllib.parse.quote(json.dumps(self.ADDON_OPTIONS)),
  282. "client_id": CLIENT_ID,
  283. "client_secret": CLIENT_SECRET,
  284. }
  285. request = self.make_mock_request(
  286. status=http_client.OK, data=self.SUCCESS_RESPONSE
  287. )
  288. response = client.exchange_token(
  289. request,
  290. self.GRANT_TYPE,
  291. self.SUBJECT_TOKEN,
  292. self.SUBJECT_TOKEN_TYPE,
  293. self.RESOURCE,
  294. self.AUDIENCE,
  295. self.SCOPES,
  296. self.REQUESTED_TOKEN_TYPE,
  297. self.ACTOR_TOKEN,
  298. self.ACTOR_TOKEN_TYPE,
  299. self.ADDON_OPTIONS,
  300. self.ADDON_HEADERS,
  301. )
  302. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  303. assert response == self.SUCCESS_RESPONSE
  304. def test_exchange_token_partial_success_with_reqbody_auth(self):
  305. """Test token exchange success with request body client authentication
  306. using partial (required only) parameters.
  307. """
  308. client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY)
  309. headers = {"Content-Type": "application/x-www-form-urlencoded"}
  310. request_data = {
  311. "grant_type": self.GRANT_TYPE,
  312. "audience": self.AUDIENCE,
  313. "requested_token_type": self.REQUESTED_TOKEN_TYPE,
  314. "subject_token": self.SUBJECT_TOKEN,
  315. "subject_token_type": self.SUBJECT_TOKEN_TYPE,
  316. "client_id": CLIENT_ID,
  317. "client_secret": CLIENT_SECRET,
  318. }
  319. request = self.make_mock_request(
  320. status=http_client.OK, data=self.SUCCESS_RESPONSE
  321. )
  322. response = client.exchange_token(
  323. request,
  324. grant_type=self.GRANT_TYPE,
  325. subject_token=self.SUBJECT_TOKEN,
  326. subject_token_type=self.SUBJECT_TOKEN_TYPE,
  327. audience=self.AUDIENCE,
  328. requested_token_type=self.REQUESTED_TOKEN_TYPE,
  329. )
  330. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  331. assert response == self.SUCCESS_RESPONSE
  332. def test_exchange_token_non200_with_reqbody_auth(self):
  333. """Test token exchange with POST request body client auth responding
  334. with non-200 status.
  335. """
  336. client = self.make_client(self.CLIENT_AUTH_REQUEST_BODY)
  337. request = self.make_mock_request(
  338. status=http_client.BAD_REQUEST, data=self.ERROR_RESPONSE
  339. )
  340. with pytest.raises(exceptions.OAuthError) as excinfo:
  341. client.exchange_token(
  342. request,
  343. self.GRANT_TYPE,
  344. self.SUBJECT_TOKEN,
  345. self.SUBJECT_TOKEN_TYPE,
  346. self.RESOURCE,
  347. self.AUDIENCE,
  348. self.SCOPES,
  349. self.REQUESTED_TOKEN_TYPE,
  350. self.ACTOR_TOKEN,
  351. self.ACTOR_TOKEN_TYPE,
  352. self.ADDON_OPTIONS,
  353. self.ADDON_HEADERS,
  354. )
  355. assert excinfo.match(
  356. r"Error code invalid_request: Invalid subject token - https://tools.ietf.org/html/rfc6749"
  357. )