test_downscoped.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  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. import datetime
  15. import http.client as http_client
  16. import json
  17. import urllib
  18. import mock
  19. import pytest # type: ignore
  20. from google.auth import _helpers
  21. from google.auth import credentials
  22. from google.auth import downscoped
  23. from google.auth import exceptions
  24. from google.auth import transport
  25. from google.auth.credentials import TokenState
  26. EXPRESSION = (
  27. "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-a')"
  28. )
  29. TITLE = "customer-a-objects"
  30. DESCRIPTION = (
  31. "Condition to make permissions available for objects starting with customer-a"
  32. )
  33. AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/example-bucket"
  34. AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectViewer"]
  35. OTHER_EXPRESSION = (
  36. "resource.name.startsWith('projects/_/buckets/example-bucket/objects/customer-b')"
  37. )
  38. OTHER_TITLE = "customer-b-objects"
  39. OTHER_DESCRIPTION = (
  40. "Condition to make permissions available for objects starting with customer-b"
  41. )
  42. OTHER_AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/other-bucket"
  43. OTHER_AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectCreator"]
  44. QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID"
  45. GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
  46. REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
  47. TOKEN_EXCHANGE_ENDPOINT = "https://sts.googleapis.com/v1/token"
  48. SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
  49. SUCCESS_RESPONSE = {
  50. "access_token": "ACCESS_TOKEN",
  51. "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
  52. "token_type": "Bearer",
  53. "expires_in": 3600,
  54. }
  55. ERROR_RESPONSE = {
  56. "error": "invalid_grant",
  57. "error_description": "Subject token is invalid.",
  58. "error_uri": "https://tools.ietf.org/html/rfc6749",
  59. }
  60. CREDENTIAL_ACCESS_BOUNDARY_JSON = {
  61. "accessBoundary": {
  62. "accessBoundaryRules": [
  63. {
  64. "availablePermissions": AVAILABLE_PERMISSIONS,
  65. "availableResource": AVAILABLE_RESOURCE,
  66. "availabilityCondition": {
  67. "expression": EXPRESSION,
  68. "title": TITLE,
  69. "description": DESCRIPTION,
  70. },
  71. }
  72. ]
  73. }
  74. }
  75. class SourceCredentials(credentials.Credentials):
  76. def __init__(self, raise_error=False, expires_in=3600):
  77. super(SourceCredentials, self).__init__()
  78. self._counter = 0
  79. self._raise_error = raise_error
  80. self._expires_in = expires_in
  81. def refresh(self, request):
  82. if self._raise_error:
  83. raise exceptions.RefreshError(
  84. "Failed to refresh access token in source credentials."
  85. )
  86. now = _helpers.utcnow()
  87. self._counter += 1
  88. self.token = "ACCESS_TOKEN_{}".format(self._counter)
  89. self.expiry = now + datetime.timedelta(seconds=self._expires_in)
  90. def make_availability_condition(expression, title=None, description=None):
  91. return downscoped.AvailabilityCondition(expression, title, description)
  92. def make_access_boundary_rule(
  93. available_resource, available_permissions, availability_condition=None
  94. ):
  95. return downscoped.AccessBoundaryRule(
  96. available_resource, available_permissions, availability_condition
  97. )
  98. def make_credential_access_boundary(rules):
  99. return downscoped.CredentialAccessBoundary(rules)
  100. class TestAvailabilityCondition(object):
  101. def test_constructor(self):
  102. availability_condition = make_availability_condition(
  103. EXPRESSION, TITLE, DESCRIPTION
  104. )
  105. assert availability_condition.expression == EXPRESSION
  106. assert availability_condition.title == TITLE
  107. assert availability_condition.description == DESCRIPTION
  108. def test_constructor_required_params_only(self):
  109. availability_condition = make_availability_condition(EXPRESSION)
  110. assert availability_condition.expression == EXPRESSION
  111. assert availability_condition.title is None
  112. assert availability_condition.description is None
  113. def test_setters(self):
  114. availability_condition = make_availability_condition(
  115. EXPRESSION, TITLE, DESCRIPTION
  116. )
  117. availability_condition.expression = OTHER_EXPRESSION
  118. availability_condition.title = OTHER_TITLE
  119. availability_condition.description = OTHER_DESCRIPTION
  120. assert availability_condition.expression == OTHER_EXPRESSION
  121. assert availability_condition.title == OTHER_TITLE
  122. assert availability_condition.description == OTHER_DESCRIPTION
  123. def test_invalid_expression_type(self):
  124. with pytest.raises(TypeError) as excinfo:
  125. make_availability_condition([EXPRESSION], TITLE, DESCRIPTION)
  126. assert excinfo.match("The provided expression is not a string.")
  127. def test_invalid_title_type(self):
  128. with pytest.raises(TypeError) as excinfo:
  129. make_availability_condition(EXPRESSION, False, DESCRIPTION)
  130. assert excinfo.match("The provided title is not a string or None.")
  131. def test_invalid_description_type(self):
  132. with pytest.raises(TypeError) as excinfo:
  133. make_availability_condition(EXPRESSION, TITLE, False)
  134. assert excinfo.match("The provided description is not a string or None.")
  135. def test_to_json_required_params_only(self):
  136. availability_condition = make_availability_condition(EXPRESSION)
  137. assert availability_condition.to_json() == {"expression": EXPRESSION}
  138. def test_to_json_(self):
  139. availability_condition = make_availability_condition(
  140. EXPRESSION, TITLE, DESCRIPTION
  141. )
  142. assert availability_condition.to_json() == {
  143. "expression": EXPRESSION,
  144. "title": TITLE,
  145. "description": DESCRIPTION,
  146. }
  147. class TestAccessBoundaryRule(object):
  148. def test_constructor(self):
  149. availability_condition = make_availability_condition(
  150. EXPRESSION, TITLE, DESCRIPTION
  151. )
  152. access_boundary_rule = make_access_boundary_rule(
  153. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  154. )
  155. assert access_boundary_rule.available_resource == AVAILABLE_RESOURCE
  156. assert access_boundary_rule.available_permissions == tuple(
  157. AVAILABLE_PERMISSIONS
  158. )
  159. assert access_boundary_rule.availability_condition == availability_condition
  160. def test_constructor_required_params_only(self):
  161. access_boundary_rule = make_access_boundary_rule(
  162. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS
  163. )
  164. assert access_boundary_rule.available_resource == AVAILABLE_RESOURCE
  165. assert access_boundary_rule.available_permissions == tuple(
  166. AVAILABLE_PERMISSIONS
  167. )
  168. assert access_boundary_rule.availability_condition is None
  169. def test_setters(self):
  170. availability_condition = make_availability_condition(
  171. EXPRESSION, TITLE, DESCRIPTION
  172. )
  173. other_availability_condition = make_availability_condition(
  174. OTHER_EXPRESSION, OTHER_TITLE, OTHER_DESCRIPTION
  175. )
  176. access_boundary_rule = make_access_boundary_rule(
  177. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  178. )
  179. access_boundary_rule.available_resource = OTHER_AVAILABLE_RESOURCE
  180. access_boundary_rule.available_permissions = OTHER_AVAILABLE_PERMISSIONS
  181. access_boundary_rule.availability_condition = other_availability_condition
  182. assert access_boundary_rule.available_resource == OTHER_AVAILABLE_RESOURCE
  183. assert access_boundary_rule.available_permissions == tuple(
  184. OTHER_AVAILABLE_PERMISSIONS
  185. )
  186. assert (
  187. access_boundary_rule.availability_condition == other_availability_condition
  188. )
  189. def test_invalid_available_resource_type(self):
  190. availability_condition = make_availability_condition(
  191. EXPRESSION, TITLE, DESCRIPTION
  192. )
  193. with pytest.raises(TypeError) as excinfo:
  194. make_access_boundary_rule(
  195. None, AVAILABLE_PERMISSIONS, availability_condition
  196. )
  197. assert excinfo.match("The provided available_resource is not a string.")
  198. def test_invalid_available_permissions_type(self):
  199. availability_condition = make_availability_condition(
  200. EXPRESSION, TITLE, DESCRIPTION
  201. )
  202. with pytest.raises(TypeError) as excinfo:
  203. make_access_boundary_rule(
  204. AVAILABLE_RESOURCE, [0, 1, 2], availability_condition
  205. )
  206. assert excinfo.match(
  207. "Provided available_permissions are not a list of strings."
  208. )
  209. def test_invalid_available_permissions_value(self):
  210. availability_condition = make_availability_condition(
  211. EXPRESSION, TITLE, DESCRIPTION
  212. )
  213. with pytest.raises(ValueError) as excinfo:
  214. make_access_boundary_rule(
  215. AVAILABLE_RESOURCE,
  216. ["roles/storage.objectViewer"],
  217. availability_condition,
  218. )
  219. assert excinfo.match("available_permissions must be prefixed with 'inRole:'.")
  220. def test_invalid_availability_condition_type(self):
  221. with pytest.raises(TypeError) as excinfo:
  222. make_access_boundary_rule(
  223. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, {"foo": "bar"}
  224. )
  225. assert excinfo.match(
  226. "The provided availability_condition is not a 'google.auth.downscoped.AvailabilityCondition' or None."
  227. )
  228. def test_to_json(self):
  229. availability_condition = make_availability_condition(
  230. EXPRESSION, TITLE, DESCRIPTION
  231. )
  232. access_boundary_rule = make_access_boundary_rule(
  233. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  234. )
  235. assert access_boundary_rule.to_json() == {
  236. "availablePermissions": AVAILABLE_PERMISSIONS,
  237. "availableResource": AVAILABLE_RESOURCE,
  238. "availabilityCondition": {
  239. "expression": EXPRESSION,
  240. "title": TITLE,
  241. "description": DESCRIPTION,
  242. },
  243. }
  244. def test_to_json_required_params_only(self):
  245. access_boundary_rule = make_access_boundary_rule(
  246. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS
  247. )
  248. assert access_boundary_rule.to_json() == {
  249. "availablePermissions": AVAILABLE_PERMISSIONS,
  250. "availableResource": AVAILABLE_RESOURCE,
  251. }
  252. class TestCredentialAccessBoundary(object):
  253. def test_constructor(self):
  254. availability_condition = make_availability_condition(
  255. EXPRESSION, TITLE, DESCRIPTION
  256. )
  257. access_boundary_rule = make_access_boundary_rule(
  258. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  259. )
  260. rules = [access_boundary_rule]
  261. credential_access_boundary = make_credential_access_boundary(rules)
  262. assert credential_access_boundary.rules == tuple(rules)
  263. def test_setters(self):
  264. availability_condition = make_availability_condition(
  265. EXPRESSION, TITLE, DESCRIPTION
  266. )
  267. access_boundary_rule = make_access_boundary_rule(
  268. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  269. )
  270. rules = [access_boundary_rule]
  271. other_availability_condition = make_availability_condition(
  272. OTHER_EXPRESSION, OTHER_TITLE, OTHER_DESCRIPTION
  273. )
  274. other_access_boundary_rule = make_access_boundary_rule(
  275. OTHER_AVAILABLE_RESOURCE,
  276. OTHER_AVAILABLE_PERMISSIONS,
  277. other_availability_condition,
  278. )
  279. other_rules = [other_access_boundary_rule]
  280. credential_access_boundary = make_credential_access_boundary(rules)
  281. credential_access_boundary.rules = other_rules
  282. assert credential_access_boundary.rules == tuple(other_rules)
  283. def test_add_rule(self):
  284. availability_condition = make_availability_condition(
  285. EXPRESSION, TITLE, DESCRIPTION
  286. )
  287. access_boundary_rule = make_access_boundary_rule(
  288. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  289. )
  290. rules = [access_boundary_rule] * 9
  291. credential_access_boundary = make_credential_access_boundary(rules)
  292. # Add one more rule. This should not raise an error.
  293. additional_access_boundary_rule = make_access_boundary_rule(
  294. OTHER_AVAILABLE_RESOURCE, OTHER_AVAILABLE_PERMISSIONS
  295. )
  296. credential_access_boundary.add_rule(additional_access_boundary_rule)
  297. assert len(credential_access_boundary.rules) == 10
  298. assert credential_access_boundary.rules[9] == additional_access_boundary_rule
  299. def test_add_rule_invalid_value(self):
  300. availability_condition = make_availability_condition(
  301. EXPRESSION, TITLE, DESCRIPTION
  302. )
  303. access_boundary_rule = make_access_boundary_rule(
  304. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  305. )
  306. rules = [access_boundary_rule] * 10
  307. credential_access_boundary = make_credential_access_boundary(rules)
  308. # Add one more rule to exceed maximum allowed rules.
  309. with pytest.raises(ValueError) as excinfo:
  310. credential_access_boundary.add_rule(access_boundary_rule)
  311. assert excinfo.match(
  312. "Credential access boundary rules can have a maximum of 10 rules."
  313. )
  314. assert len(credential_access_boundary.rules) == 10
  315. def test_add_rule_invalid_type(self):
  316. availability_condition = make_availability_condition(
  317. EXPRESSION, TITLE, DESCRIPTION
  318. )
  319. access_boundary_rule = make_access_boundary_rule(
  320. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  321. )
  322. rules = [access_boundary_rule]
  323. credential_access_boundary = make_credential_access_boundary(rules)
  324. # Add an invalid rule to exceed maximum allowed rules.
  325. with pytest.raises(TypeError) as excinfo:
  326. credential_access_boundary.add_rule("invalid")
  327. assert excinfo.match(
  328. "The provided rule does not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
  329. )
  330. assert len(credential_access_boundary.rules) == 1
  331. assert credential_access_boundary.rules[0] == access_boundary_rule
  332. def test_invalid_rules_type(self):
  333. with pytest.raises(TypeError) as excinfo:
  334. make_credential_access_boundary(["invalid"])
  335. assert excinfo.match(
  336. "List of rules provided do not contain a valid 'google.auth.downscoped.AccessBoundaryRule'."
  337. )
  338. def test_invalid_rules_value(self):
  339. availability_condition = make_availability_condition(
  340. EXPRESSION, TITLE, DESCRIPTION
  341. )
  342. access_boundary_rule = make_access_boundary_rule(
  343. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  344. )
  345. too_many_rules = [access_boundary_rule] * 11
  346. with pytest.raises(ValueError) as excinfo:
  347. make_credential_access_boundary(too_many_rules)
  348. assert excinfo.match(
  349. "Credential access boundary rules can have a maximum of 10 rules."
  350. )
  351. def test_to_json(self):
  352. availability_condition = make_availability_condition(
  353. EXPRESSION, TITLE, DESCRIPTION
  354. )
  355. access_boundary_rule = make_access_boundary_rule(
  356. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  357. )
  358. rules = [access_boundary_rule]
  359. credential_access_boundary = make_credential_access_boundary(rules)
  360. assert credential_access_boundary.to_json() == {
  361. "accessBoundary": {
  362. "accessBoundaryRules": [
  363. {
  364. "availablePermissions": AVAILABLE_PERMISSIONS,
  365. "availableResource": AVAILABLE_RESOURCE,
  366. "availabilityCondition": {
  367. "expression": EXPRESSION,
  368. "title": TITLE,
  369. "description": DESCRIPTION,
  370. },
  371. }
  372. ]
  373. }
  374. }
  375. class TestCredentials(object):
  376. @staticmethod
  377. def make_credentials(source_credentials=SourceCredentials(), quota_project_id=None):
  378. availability_condition = make_availability_condition(
  379. EXPRESSION, TITLE, DESCRIPTION
  380. )
  381. access_boundary_rule = make_access_boundary_rule(
  382. AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition
  383. )
  384. rules = [access_boundary_rule]
  385. credential_access_boundary = make_credential_access_boundary(rules)
  386. return downscoped.Credentials(
  387. source_credentials, credential_access_boundary, quota_project_id
  388. )
  389. @staticmethod
  390. def make_mock_request(data, status=http_client.OK):
  391. response = mock.create_autospec(transport.Response, instance=True)
  392. response.status = status
  393. response.data = json.dumps(data).encode("utf-8")
  394. request = mock.create_autospec(transport.Request)
  395. request.return_value = response
  396. return request
  397. @staticmethod
  398. def assert_request_kwargs(request_kwargs, headers, request_data):
  399. """Asserts the request was called with the expected parameters.
  400. """
  401. assert request_kwargs["url"] == TOKEN_EXCHANGE_ENDPOINT
  402. assert request_kwargs["method"] == "POST"
  403. assert request_kwargs["headers"] == headers
  404. assert request_kwargs["body"] is not None
  405. body_tuples = urllib.parse.parse_qsl(request_kwargs["body"])
  406. for (k, v) in body_tuples:
  407. assert v.decode("utf-8") == request_data[k.decode("utf-8")]
  408. assert len(body_tuples) == len(request_data.keys())
  409. def test_default_state(self):
  410. credentials = self.make_credentials()
  411. # No token acquired yet.
  412. assert not credentials.token
  413. assert not credentials.valid
  414. # Expiration hasn't been set yet.
  415. assert not credentials.expiry
  416. assert not credentials.expired
  417. # No quota project ID set.
  418. assert not credentials.quota_project_id
  419. def test_with_quota_project(self):
  420. credentials = self.make_credentials()
  421. assert not credentials.quota_project_id
  422. quota_project_creds = credentials.with_quota_project("project-foo")
  423. assert quota_project_creds.quota_project_id == "project-foo"
  424. @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min)
  425. def test_refresh(self, unused_utcnow):
  426. response = SUCCESS_RESPONSE.copy()
  427. # Test custom expiration to confirm expiry is set correctly.
  428. response["expires_in"] = 2800
  429. expected_expiry = datetime.datetime.min + datetime.timedelta(
  430. seconds=response["expires_in"]
  431. )
  432. headers = {"Content-Type": "application/x-www-form-urlencoded"}
  433. request_data = {
  434. "grant_type": GRANT_TYPE,
  435. "subject_token": "ACCESS_TOKEN_1",
  436. "subject_token_type": SUBJECT_TOKEN_TYPE,
  437. "requested_token_type": REQUESTED_TOKEN_TYPE,
  438. "options": urllib.parse.quote(json.dumps(CREDENTIAL_ACCESS_BOUNDARY_JSON)),
  439. }
  440. request = self.make_mock_request(status=http_client.OK, data=response)
  441. source_credentials = SourceCredentials()
  442. credentials = self.make_credentials(source_credentials=source_credentials)
  443. # Spy on calls to source credentials refresh to confirm the expected request
  444. # instance is used.
  445. with mock.patch.object(
  446. source_credentials, "refresh", wraps=source_credentials.refresh
  447. ) as wrapped_souce_cred_refresh:
  448. credentials.refresh(request)
  449. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  450. assert credentials.valid
  451. assert credentials.expiry == expected_expiry
  452. assert not credentials.expired
  453. assert credentials.token == response["access_token"]
  454. # Confirm source credentials called with the same request instance.
  455. wrapped_souce_cred_refresh.assert_called_with(request)
  456. @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min)
  457. def test_refresh_without_response_expires_in(self, unused_utcnow):
  458. response = SUCCESS_RESPONSE.copy()
  459. # Simulate the response is missing the expires_in field.
  460. # The downscoped token expiration should match the source credentials
  461. # expiration.
  462. del response["expires_in"]
  463. expected_expires_in = 1800
  464. # Simulate the source credentials generates a token with 1800 second
  465. # expiration time. The generated downscoped token should have the same
  466. # expiration time.
  467. source_credentials = SourceCredentials(expires_in=expected_expires_in)
  468. expected_expiry = datetime.datetime.min + datetime.timedelta(
  469. seconds=expected_expires_in
  470. )
  471. headers = {"Content-Type": "application/x-www-form-urlencoded"}
  472. request_data = {
  473. "grant_type": GRANT_TYPE,
  474. "subject_token": "ACCESS_TOKEN_1",
  475. "subject_token_type": SUBJECT_TOKEN_TYPE,
  476. "requested_token_type": REQUESTED_TOKEN_TYPE,
  477. "options": urllib.parse.quote(json.dumps(CREDENTIAL_ACCESS_BOUNDARY_JSON)),
  478. }
  479. request = self.make_mock_request(status=http_client.OK, data=response)
  480. credentials = self.make_credentials(source_credentials=source_credentials)
  481. # Spy on calls to source credentials refresh to confirm the expected request
  482. # instance is used.
  483. with mock.patch.object(
  484. source_credentials, "refresh", wraps=source_credentials.refresh
  485. ) as wrapped_souce_cred_refresh:
  486. credentials.refresh(request)
  487. self.assert_request_kwargs(request.call_args[1], headers, request_data)
  488. assert credentials.valid
  489. assert credentials.expiry == expected_expiry
  490. assert not credentials.expired
  491. assert credentials.token == response["access_token"]
  492. # Confirm source credentials called with the same request instance.
  493. wrapped_souce_cred_refresh.assert_called_with(request)
  494. def test_refresh_token_exchange_error(self):
  495. request = self.make_mock_request(
  496. status=http_client.BAD_REQUEST, data=ERROR_RESPONSE
  497. )
  498. credentials = self.make_credentials()
  499. with pytest.raises(exceptions.OAuthError) as excinfo:
  500. credentials.refresh(request)
  501. assert excinfo.match(
  502. r"Error code invalid_grant: Subject token is invalid. - https://tools.ietf.org/html/rfc6749"
  503. )
  504. assert not credentials.expired
  505. assert credentials.token is None
  506. def test_refresh_source_credentials_refresh_error(self):
  507. # Initialize downscoped credentials with source credentials that raise
  508. # an error on refresh.
  509. credentials = self.make_credentials(
  510. source_credentials=SourceCredentials(raise_error=True)
  511. )
  512. with pytest.raises(exceptions.RefreshError) as excinfo:
  513. credentials.refresh(mock.sentinel.request)
  514. assert excinfo.match(r"Failed to refresh access token in source credentials.")
  515. assert not credentials.expired
  516. assert credentials.token is None
  517. def test_apply_without_quota_project_id(self):
  518. headers = {}
  519. request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
  520. credentials = self.make_credentials()
  521. credentials.refresh(request)
  522. credentials.apply(headers)
  523. assert headers == {
  524. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"])
  525. }
  526. def test_apply_with_quota_project_id(self):
  527. headers = {"other": "header-value"}
  528. request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
  529. credentials = self.make_credentials(quota_project_id=QUOTA_PROJECT_ID)
  530. credentials.refresh(request)
  531. credentials.apply(headers)
  532. assert headers == {
  533. "other": "header-value",
  534. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
  535. "x-goog-user-project": QUOTA_PROJECT_ID,
  536. }
  537. def test_before_request(self):
  538. headers = {"other": "header-value"}
  539. request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
  540. credentials = self.make_credentials()
  541. # First call should call refresh, setting the token.
  542. credentials.before_request(request, "POST", "https://example.com/api", headers)
  543. assert headers == {
  544. "other": "header-value",
  545. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
  546. }
  547. # Second call shouldn't call refresh (request should be untouched).
  548. credentials.before_request(
  549. mock.sentinel.request, "POST", "https://example.com/api", headers
  550. )
  551. assert headers == {
  552. "other": "header-value",
  553. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]),
  554. }
  555. @mock.patch("google.auth._helpers.utcnow")
  556. def test_before_request_expired(self, utcnow):
  557. headers = {}
  558. request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE)
  559. credentials = self.make_credentials()
  560. credentials.token = "token"
  561. utcnow.return_value = datetime.datetime.min
  562. # Set the expiration to one second more than now plus the clock skew
  563. # accommodation. These credentials should be valid.
  564. credentials.expiry = (
  565. datetime.datetime.min
  566. + _helpers.REFRESH_THRESHOLD
  567. + datetime.timedelta(seconds=1)
  568. )
  569. assert credentials.valid
  570. assert not credentials.expired
  571. assert credentials.token_state == TokenState.FRESH
  572. credentials.before_request(request, "POST", "https://example.com/api", headers)
  573. # Cached token should be used.
  574. assert headers == {"authorization": "Bearer token"}
  575. # Next call should simulate 1 second passed.
  576. utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1)
  577. assert not credentials.valid
  578. assert credentials.expired
  579. assert credentials.token_state == TokenState.STALE
  580. credentials.before_request(request, "POST", "https://example.com/api", headers)
  581. assert credentials.token_state == TokenState.FRESH
  582. # New token should be retrieved.
  583. assert headers == {
  584. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"])
  585. }
  586. utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=6000)
  587. assert not credentials.valid
  588. assert credentials.expired
  589. assert credentials.token_state == TokenState.INVALID
  590. credentials.before_request(request, "POST", "https://example.com/api", headers)
  591. assert credentials.token_state == TokenState.FRESH
  592. # New token should be retrieved.
  593. assert headers == {
  594. "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"])
  595. }