|
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
14 | 14 |
|
| 15 | +import datetime |
| 16 | +import json |
| 17 | + |
| 18 | +import mock |
15 | 19 | import pytest |
| 20 | +from six.moves import http_client |
| 21 | +from six.moves import urllib |
16 | 22 |
|
| 23 | +from google.auth import _helpers |
| 24 | +from google.auth import credentials |
17 | 25 | from google.auth import downscoped |
| 26 | +from google.auth import exceptions |
| 27 | +from google.auth import transport |
18 | 28 |
|
19 | 29 |
|
20 | 30 | EXPRESSION = ( |
|
36 | 46 | ) |
37 | 47 | OTHER_AVAILABLE_RESOURCE = "//storage.googleapis.com/projects/_/buckets/other-bucket" |
38 | 48 | OTHER_AVAILABLE_PERMISSIONS = ["inRole:roles/storage.objectCreator"] |
| 49 | +QUOTA_PROJECT_ID = "QUOTA_PROJECT_ID" |
| 50 | +GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange" |
| 51 | +REQUESTED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" |
| 52 | +TOKEN_EXCHANGE_ENDPOINT = "https://sts.googleapis.com/v1/token" |
| 53 | +SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token" |
| 54 | +SUCCESS_RESPONSE = { |
| 55 | + "access_token": "ACCESS_TOKEN", |
| 56 | + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", |
| 57 | + "token_type": "Bearer", |
| 58 | + "expires_in": 3600, |
| 59 | +} |
| 60 | +ERROR_RESPONSE = { |
| 61 | + "error": "invalid_grant", |
| 62 | + "error_description": "Subject token is invalid.", |
| 63 | + "error_uri": "https://tools.ietf.org/html/rfc6749", |
| 64 | +} |
| 65 | +CREDENTIAL_ACCESS_BOUNDARY_JSON = { |
| 66 | + "accessBoundary": { |
| 67 | + "accessBoundaryRules": [ |
| 68 | + { |
| 69 | + "availablePermissions": AVAILABLE_PERMISSIONS, |
| 70 | + "availableResource": AVAILABLE_RESOURCE, |
| 71 | + "availabilityCondition": { |
| 72 | + "expression": EXPRESSION, |
| 73 | + "title": TITLE, |
| 74 | + "description": DESCRIPTION, |
| 75 | + }, |
| 76 | + } |
| 77 | + ] |
| 78 | + } |
| 79 | +} |
| 80 | + |
| 81 | + |
| 82 | +class SourceCredentials(credentials.Credentials): |
| 83 | + def __init__(self, raise_error=False): |
| 84 | + super(SourceCredentials, self).__init__() |
| 85 | + self._counter = 0 |
| 86 | + self._raise_error = raise_error |
| 87 | + |
| 88 | + def refresh(self, request): |
| 89 | + if self._raise_error: |
| 90 | + raise exceptions.RefreshError( |
| 91 | + "Failed to refresh access token in source credentials." |
| 92 | + ) |
| 93 | + now = _helpers.utcnow() |
| 94 | + self._counter += 1 |
| 95 | + self.token = "ACCESS_TOKEN_{}".format(self._counter) |
| 96 | + self.expiry = now + datetime.timedelta(seconds=3600) |
39 | 97 |
|
40 | 98 |
|
41 | 99 | def make_availability_condition(expression, title=None, description=None): |
@@ -383,3 +441,212 @@ def test_to_json(self): |
383 | 441 | ] |
384 | 442 | } |
385 | 443 | } |
| 444 | + |
| 445 | + |
| 446 | +class TestCredentials(object): |
| 447 | + @staticmethod |
| 448 | + def make_credentials(source_credentials=SourceCredentials(), quota_project_id=None): |
| 449 | + availability_condition = make_availability_condition( |
| 450 | + EXPRESSION, TITLE, DESCRIPTION |
| 451 | + ) |
| 452 | + access_boundary_rule = make_access_boundary_rule( |
| 453 | + AVAILABLE_RESOURCE, AVAILABLE_PERMISSIONS, availability_condition |
| 454 | + ) |
| 455 | + rules = [access_boundary_rule] |
| 456 | + credential_access_boundary = make_credential_access_boundary(rules) |
| 457 | + |
| 458 | + return downscoped.Credentials( |
| 459 | + source_credentials, credential_access_boundary, quota_project_id |
| 460 | + ) |
| 461 | + |
| 462 | + @staticmethod |
| 463 | + def make_mock_request(data, status=http_client.OK): |
| 464 | + response = mock.create_autospec(transport.Response, instance=True) |
| 465 | + response.status = status |
| 466 | + response.data = json.dumps(data).encode("utf-8") |
| 467 | + |
| 468 | + request = mock.create_autospec(transport.Request) |
| 469 | + request.return_value = response |
| 470 | + |
| 471 | + return request |
| 472 | + |
| 473 | + @staticmethod |
| 474 | + def assert_request_kwargs(request_kwargs, headers, request_data): |
| 475 | + """Asserts the request was called with the expected parameters. |
| 476 | + """ |
| 477 | + assert request_kwargs["url"] == TOKEN_EXCHANGE_ENDPOINT |
| 478 | + assert request_kwargs["method"] == "POST" |
| 479 | + assert request_kwargs["headers"] == headers |
| 480 | + assert request_kwargs["body"] is not None |
| 481 | + body_tuples = urllib.parse.parse_qsl(request_kwargs["body"]) |
| 482 | + for (k, v) in body_tuples: |
| 483 | + assert v.decode("utf-8") == request_data[k.decode("utf-8")] |
| 484 | + assert len(body_tuples) == len(request_data.keys()) |
| 485 | + |
| 486 | + def test_default_state(self): |
| 487 | + credentials = self.make_credentials() |
| 488 | + |
| 489 | + # No token acquired yet. |
| 490 | + assert not credentials.token |
| 491 | + assert not credentials.valid |
| 492 | + # Expiration hasn't been set yet. |
| 493 | + assert not credentials.expiry |
| 494 | + assert not credentials.expired |
| 495 | + # No quota project ID set. |
| 496 | + assert not credentials.quota_project_id |
| 497 | + |
| 498 | + def test_with_quota_project(self): |
| 499 | + credentials = self.make_credentials() |
| 500 | + |
| 501 | + assert not credentials.quota_project_id |
| 502 | + |
| 503 | + quota_project_creds = credentials.with_quota_project("project-foo") |
| 504 | + |
| 505 | + assert quota_project_creds.quota_project_id == "project-foo" |
| 506 | + |
| 507 | + @mock.patch("google.auth._helpers.utcnow", return_value=datetime.datetime.min) |
| 508 | + def test_refresh(self, unused_utcnow): |
| 509 | + response = SUCCESS_RESPONSE.copy() |
| 510 | + # Test custom expiration to confirm expiry is set correctly. |
| 511 | + response["expires_in"] = 2800 |
| 512 | + expected_expiry = datetime.datetime.min + datetime.timedelta( |
| 513 | + seconds=response["expires_in"] |
| 514 | + ) |
| 515 | + headers = {"Content-Type": "application/x-www-form-urlencoded"} |
| 516 | + request_data = { |
| 517 | + "grant_type": GRANT_TYPE, |
| 518 | + "subject_token": "ACCESS_TOKEN_1", |
| 519 | + "subject_token_type": SUBJECT_TOKEN_TYPE, |
| 520 | + "requested_token_type": REQUESTED_TOKEN_TYPE, |
| 521 | + "options": urllib.parse.quote(json.dumps(CREDENTIAL_ACCESS_BOUNDARY_JSON)), |
| 522 | + } |
| 523 | + request = self.make_mock_request(status=http_client.OK, data=response) |
| 524 | + source_credentials = SourceCredentials() |
| 525 | + credentials = self.make_credentials(source_credentials=source_credentials) |
| 526 | + |
| 527 | + # Spy on calls to source credentials refresh to confirm the expected request |
| 528 | + # instance is used. |
| 529 | + with mock.patch.object( |
| 530 | + source_credentials, "refresh", wraps=source_credentials.refresh |
| 531 | + ) as wrapped_souce_cred_refresh: |
| 532 | + credentials.refresh(request) |
| 533 | + |
| 534 | + self.assert_request_kwargs(request.call_args[1], headers, request_data) |
| 535 | + assert credentials.valid |
| 536 | + assert credentials.expiry == expected_expiry |
| 537 | + assert not credentials.expired |
| 538 | + assert credentials.token == response["access_token"] |
| 539 | + # Confirm source credentials called with the same request instance. |
| 540 | + wrapped_souce_cred_refresh.assert_called_with(request) |
| 541 | + |
| 542 | + def test_refresh_token_exchange_error(self): |
| 543 | + request = self.make_mock_request( |
| 544 | + status=http_client.BAD_REQUEST, data=ERROR_RESPONSE |
| 545 | + ) |
| 546 | + credentials = self.make_credentials() |
| 547 | + |
| 548 | + with pytest.raises(exceptions.OAuthError) as excinfo: |
| 549 | + credentials.refresh(request) |
| 550 | + |
| 551 | + assert excinfo.match( |
| 552 | + r"Error code invalid_grant: Subject token is invalid. - https://tools.ietf.org/html/rfc6749" |
| 553 | + ) |
| 554 | + assert not credentials.expired |
| 555 | + assert credentials.token is None |
| 556 | + |
| 557 | + def test_refresh_source_credentials_refresh_error(self): |
| 558 | + # Initialize downscoped credentials with source credentials that raise |
| 559 | + # an error on refresh. |
| 560 | + credentials = self.make_credentials( |
| 561 | + source_credentials=SourceCredentials(raise_error=True) |
| 562 | + ) |
| 563 | + |
| 564 | + with pytest.raises(exceptions.RefreshError) as excinfo: |
| 565 | + credentials.refresh(mock.sentinel.request) |
| 566 | + |
| 567 | + assert excinfo.match(r"Failed to refresh access token in source credentials.") |
| 568 | + assert not credentials.expired |
| 569 | + assert credentials.token is None |
| 570 | + |
| 571 | + def test_apply_without_quota_project_id(self): |
| 572 | + headers = {} |
| 573 | + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) |
| 574 | + credentials = self.make_credentials() |
| 575 | + |
| 576 | + credentials.refresh(request) |
| 577 | + credentials.apply(headers) |
| 578 | + |
| 579 | + assert headers == { |
| 580 | + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) |
| 581 | + } |
| 582 | + |
| 583 | + def test_apply_with_quota_project_id(self): |
| 584 | + headers = {"other": "header-value"} |
| 585 | + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) |
| 586 | + credentials = self.make_credentials(quota_project_id=QUOTA_PROJECT_ID) |
| 587 | + |
| 588 | + credentials.refresh(request) |
| 589 | + credentials.apply(headers) |
| 590 | + |
| 591 | + assert headers == { |
| 592 | + "other": "header-value", |
| 593 | + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), |
| 594 | + "x-goog-user-project": QUOTA_PROJECT_ID, |
| 595 | + } |
| 596 | + |
| 597 | + def test_before_request(self): |
| 598 | + headers = {"other": "header-value"} |
| 599 | + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) |
| 600 | + credentials = self.make_credentials() |
| 601 | + |
| 602 | + # First call should call refresh, setting the token. |
| 603 | + credentials.before_request(request, "POST", "https://example.com/api", headers) |
| 604 | + |
| 605 | + assert headers == { |
| 606 | + "other": "header-value", |
| 607 | + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), |
| 608 | + } |
| 609 | + |
| 610 | + # Second call shouldn't call refresh (request should be untouched). |
| 611 | + credentials.before_request( |
| 612 | + mock.sentinel.request, "POST", "https://example.com/api", headers |
| 613 | + ) |
| 614 | + |
| 615 | + assert headers == { |
| 616 | + "other": "header-value", |
| 617 | + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]), |
| 618 | + } |
| 619 | + |
| 620 | + @mock.patch("google.auth._helpers.utcnow") |
| 621 | + def test_before_request_expired(self, utcnow): |
| 622 | + headers = {} |
| 623 | + request = self.make_mock_request(status=http_client.OK, data=SUCCESS_RESPONSE) |
| 624 | + credentials = self.make_credentials() |
| 625 | + credentials.token = "token" |
| 626 | + utcnow.return_value = datetime.datetime.min |
| 627 | + # Set the expiration to one second more than now plus the clock skew |
| 628 | + # accommodation. These credentials should be valid. |
| 629 | + credentials.expiry = ( |
| 630 | + datetime.datetime.min + _helpers.CLOCK_SKEW + datetime.timedelta(seconds=1) |
| 631 | + ) |
| 632 | + |
| 633 | + assert credentials.valid |
| 634 | + assert not credentials.expired |
| 635 | + |
| 636 | + credentials.before_request(request, "POST", "https://example.com/api", headers) |
| 637 | + |
| 638 | + # Cached token should be used. |
| 639 | + assert headers == {"authorization": "Bearer token"} |
| 640 | + |
| 641 | + # Next call should simulate 1 second passed. |
| 642 | + utcnow.return_value = datetime.datetime.min + datetime.timedelta(seconds=1) |
| 643 | + |
| 644 | + assert not credentials.valid |
| 645 | + assert credentials.expired |
| 646 | + |
| 647 | + credentials.before_request(request, "POST", "https://example.com/api", headers) |
| 648 | + |
| 649 | + # New token should be retrieved. |
| 650 | + assert headers == { |
| 651 | + "authorization": "Bearer {}".format(SUCCESS_RESPONSE["access_token"]) |
| 652 | + } |
0 commit comments