Skip to content

Commit

Permalink
fix: add SAML challenge to reauth (#819)
Browse files Browse the repository at this point in the history
* fix: add SAML challenge to reauth

* add enable_reauth_refresh flag

* address comments

* fix unit test

* address comments

* update

* update

* update

* update

* 🦉 Updates from OwlBot

See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md

Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
Co-authored-by: Tres Seaver <tseaver@palladion.com>
  • Loading branch information
3 people committed Sep 7, 2021
1 parent 45c4491 commit 13aed5f
Show file tree
Hide file tree
Showing 12 changed files with 150 additions and 3 deletions.
4 changes: 4 additions & 0 deletions google/auth/exceptions.py
Expand Up @@ -57,3 +57,7 @@ def __init__(self, message=None):
super(ReauthFailError, self).__init__(
"Reauthentication failed. {0}".format(message)
)


class ReauthSamlChallengeFailError(ReauthFailError):
"""An exception for SAML reauth challenge failures."""
1 change: 1 addition & 0 deletions google/oauth2/_credentials_async.py
Expand Up @@ -75,6 +75,7 @@ async def refresh(self, request):
self._client_secret,
scopes=self._scopes,
rapt_token=self._rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
)

self.token = access_token
Expand Down
9 changes: 9 additions & 0 deletions google/oauth2/_reauth_async.py
Expand Up @@ -248,6 +248,7 @@ async def refresh_grant(
client_secret,
scopes=None,
rapt_token=None,
enable_reauth_refresh=False,
):
"""Implements the reauthentication flow.
Expand All @@ -265,6 +266,9 @@ async def refresh_grant(
token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').
rapt_token (Optional(str)): The rapt token for reauth.
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
should be used. The default value is False. This option is for
gcloud only, other users should use the default value.
Returns:
Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The
Expand Down Expand Up @@ -299,6 +303,11 @@ async def refresh_grant(
== reauth._REAUTH_NEEDED_ERROR_RAPT_REQUIRED
)
):
if not enable_reauth_refresh:
raise exceptions.RefreshError(
"Reauthentication is needed. Please run `gcloud auth login --update-adc` to reauthenticate."
)

rapt_token = await get_rapt_token(
request, client_id, client_secret, refresh_token, token_uri, scopes=scopes
)
Expand Down
28 changes: 27 additions & 1 deletion google/oauth2/challenges.py
Expand Up @@ -25,6 +25,9 @@


REAUTH_ORIGIN = "https://accounts.google.com"
SAML_CHALLENGE_MESSAGE = (
"Please run `gcloud auth login` to complete reauthentication with SAML."
)


def get_user_password(text):
Expand Down Expand Up @@ -148,7 +151,30 @@ def obtain_challenge_input(self, metadata):
return None


class SamlChallenge(ReauthChallenge):
"""Challenge that asks the users to browse to their ID Providers.
Currently SAML challenge is not supported. When obtaining the challenge
input, exception will be raised to instruct the users to run
`gcloud auth login` for reauthentication.
"""

@property
def name(self):
return "SAML"

@property
def is_locally_eligible(self):
return True

def obtain_challenge_input(self, metadata):
# Magic Arch has not fully supported returning a proper dedirect URL
# for programmatic SAML users today. So we error our here and request
# users to use gcloud to complete a login.
raise exceptions.ReauthSamlChallengeFailError(SAML_CHALLENGE_MESSAGE)


AVAILABLE_CHALLENGES = {
challenge.name: challenge
for challenge in [SecurityKeyChallenge(), PasswordChallenge()]
for challenge in [SecurityKeyChallenge(), PasswordChallenge(), SamlChallenge()]
}
11 changes: 11 additions & 0 deletions google/oauth2/credentials.py
Expand Up @@ -54,6 +54,9 @@ class Credentials(credentials.ReadOnlyScoped, credentials.CredentialsWithQuotaPr
credentials = credentials.with_quota_project('myproject-123)
Reauth is disabled by default. To enable reauth, set the
`enable_reauth_refresh` parameter to True in the constructor. Note that
reauth feature is intended for gcloud to use only.
If reauth is enabled, `pyu2f` dependency has to be installed in order to use security
key reauth feature. Dependency can be installed via `pip install pyu2f` or `pip install
google-auth[reauth]`.
Expand All @@ -73,6 +76,7 @@ def __init__(
expiry=None,
rapt_token=None,
refresh_handler=None,
enable_reauth_refresh=False,
):
"""
Args:
Expand Down Expand Up @@ -109,6 +113,8 @@ def __init__(
refresh tokens are provided and tokens are obtained by calling
some external process on demand. It is particularly useful for
retrieving downscoped tokens from a token broker.
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
should be used. This flag is for gcloud to use only.
"""
super(Credentials, self).__init__()
self.token = token
Expand All @@ -123,6 +129,7 @@ def __init__(
self._quota_project_id = quota_project_id
self._rapt_token = rapt_token
self.refresh_handler = refresh_handler
self._enable_reauth_refresh = enable_reauth_refresh

def __getstate__(self):
"""A __getstate__ method must exist for the __setstate__ to be called
Expand Down Expand Up @@ -151,6 +158,7 @@ def __setstate__(self, d):
self._client_secret = d.get("_client_secret")
self._quota_project_id = d.get("_quota_project_id")
self._rapt_token = d.get("_rapt_token")
self._enable_reauth_refresh = d.get("_enable_reauth_refresh")
# The refresh_handler setter should be used to repopulate this.
self._refresh_handler = None

Expand Down Expand Up @@ -241,6 +249,7 @@ def with_quota_project(self, quota_project_id):
default_scopes=self.default_scopes,
quota_project_id=quota_project_id,
rapt_token=self.rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
)

@_helpers.copy_docstring(credentials.Credentials)
Expand Down Expand Up @@ -296,6 +305,7 @@ def refresh(self, request):
self._client_secret,
scopes=scopes,
rapt_token=self._rapt_token,
enable_reauth_refresh=self._enable_reauth_refresh,
)

self.token = access_token
Expand Down Expand Up @@ -366,6 +376,7 @@ def from_authorized_user_info(cls, info, scopes=None):
client_secret=info.get("client_secret"),
quota_project_id=info.get("quota_project_id"), # may not exist
expiry=expiry,
rapt_token=info.get("rapt_token"), # may not exist
)

@classmethod
Expand Down
9 changes: 9 additions & 0 deletions google/oauth2/reauth.py
Expand Up @@ -275,6 +275,7 @@ def refresh_grant(
client_secret,
scopes=None,
rapt_token=None,
enable_reauth_refresh=False,
):
"""Implements the reauthentication flow.
Expand All @@ -292,6 +293,9 @@ def refresh_grant(
token has a wild card scope (e.g.
'https://www.googleapis.com/auth/any-api').
rapt_token (Optional(str)): The rapt token for reauth.
enable_reauth_refresh (Optional[bool]): Whether reauth refresh flow
should be used. The default value is False. This option is for
gcloud only, other users should use the default value.
Returns:
Tuple[str, Optional[str], Optional[datetime], Mapping[str, str], str]: The
Expand Down Expand Up @@ -324,6 +328,11 @@ def refresh_grant(
or response_data.get("error_subtype") == _REAUTH_NEEDED_ERROR_RAPT_REQUIRED
)
):
if not enable_reauth_refresh:
raise exceptions.RefreshError(
"Reauthentication is needed. Please run `gcloud auth login --update-adc` to reauthenticate."
)

rapt_token = get_rapt_token(
request, client_id, client_secret, refresh_token, token_uri, scopes=scopes
)
Expand Down
8 changes: 8 additions & 0 deletions tests/data/authorized_user_with_rapt_token.json
@@ -0,0 +1,8 @@
{
"client_id": "123",
"client_secret": "secret",
"refresh_token": "alabalaportocala",
"type": "authorized_user",
"rapt_token": "rapt"
}

8 changes: 8 additions & 0 deletions tests/oauth2/test_challenges.py
Expand Up @@ -130,3 +130,11 @@ def test_password_challenge(getpass_mock):
assert challenges.PasswordChallenge().obtain_challenge_input({}) == {
"credential": " "
}


def test_saml_challenge():
challenge = challenges.SamlChallenge()
assert challenge.is_locally_eligible
assert challenge.name == "SAML"
with pytest.raises(exceptions.ReauthSamlChallengeFailError):
challenge.obtain_challenge_input(None)
24 changes: 24 additions & 0 deletions tests/oauth2/test_credentials.py
Expand Up @@ -51,6 +51,7 @@ def make_credentials(cls):
client_id=cls.CLIENT_ID,
client_secret=cls.CLIENT_SECRET,
rapt_token=cls.RAPT_TOKEN,
enable_reauth_refresh=True,
)

def test_default_state(self):
Expand Down Expand Up @@ -149,6 +150,7 @@ def test_refresh_success(self, unused_utcnow, refresh_grant):
self.CLIENT_SECRET,
None,
self.RAPT_TOKEN,
True,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -219,6 +221,7 @@ def test_refresh_with_refresh_token_and_refresh_handler(
self.CLIENT_SECRET,
None,
self.RAPT_TOKEN,
False,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -422,6 +425,7 @@ def test_credentials_with_scopes_requested_refresh_success(
scopes=scopes,
default_scopes=default_scopes,
rapt_token=self.RAPT_TOKEN,
enable_reauth_refresh=True,
)

# Refresh credentials
Expand All @@ -436,6 +440,7 @@ def test_credentials_with_scopes_requested_refresh_success(
self.CLIENT_SECRET,
scopes,
self.RAPT_TOKEN,
True,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -484,6 +489,7 @@ def test_credentials_with_only_default_scopes_requested(
client_secret=self.CLIENT_SECRET,
default_scopes=default_scopes,
rapt_token=self.RAPT_TOKEN,
enable_reauth_refresh=True,
)

# Refresh credentials
Expand All @@ -498,6 +504,7 @@ def test_credentials_with_only_default_scopes_requested(
self.CLIENT_SECRET,
default_scopes,
self.RAPT_TOKEN,
True,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -549,6 +556,7 @@ def test_credentials_with_scopes_returned_refresh_success(
client_secret=self.CLIENT_SECRET,
scopes=scopes,
rapt_token=self.RAPT_TOKEN,
enable_reauth_refresh=True,
)

# Refresh credentials
Expand All @@ -563,6 +571,7 @@ def test_credentials_with_scopes_returned_refresh_success(
self.CLIENT_SECRET,
scopes,
self.RAPT_TOKEN,
True,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -615,6 +624,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error(
client_secret=self.CLIENT_SECRET,
scopes=scopes,
rapt_token=self.RAPT_TOKEN,
enable_reauth_refresh=True,
)

# Refresh credentials
Expand All @@ -632,6 +642,7 @@ def test_credentials_with_scopes_refresh_failure_raises_refresh_error(
self.CLIENT_SECRET,
scopes,
self.RAPT_TOKEN,
True,
)

# Check that the credentials have the token and expiry
Expand Down Expand Up @@ -731,6 +742,7 @@ def test_from_authorized_user_file(self):
assert creds.refresh_token == info["refresh_token"]
assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
assert creds.scopes is None
assert creds.rapt_token is None

scopes = ["email", "profile"]
creds = credentials.Credentials.from_authorized_user_file(
Expand All @@ -742,6 +754,18 @@ def test_from_authorized_user_file(self):
assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
assert creds.scopes == scopes

def test_from_authorized_user_file_with_rapt_token(self):
info = AUTH_USER_INFO.copy()
file_path = os.path.join(DATA_DIR, "authorized_user_with_rapt_token.json")

creds = credentials.Credentials.from_authorized_user_file(file_path)
assert creds.client_secret == info["client_secret"]
assert creds.client_id == info["client_id"]
assert creds.refresh_token == info["refresh_token"]
assert creds.token_uri == credentials._GOOGLE_OAUTH2_TOKEN_ENDPOINT
assert creds.scopes is None
assert creds.rapt_token == "rapt"

def test_to_json(self):
info = AUTH_USER_INFO.copy()
expiry = datetime.datetime(2020, 8, 14, 15, 54, 1)
Expand Down
23 changes: 22 additions & 1 deletion tests/oauth2/test_reauth.py
Expand Up @@ -270,6 +270,7 @@ def test_refresh_grant_failed():
"client_secret",
scopes=["foo", "bar"],
rapt_token="rapt_token",
enable_reauth_refresh=True,
)
assert excinfo.match(r"Bad request")
mock_token_request.assert_called_with(
Expand Down Expand Up @@ -298,11 +299,31 @@ def test_refresh_grant_success():
"google.oauth2.reauth.get_rapt_token", return_value="new_rapt_token"
):
assert reauth.refresh_grant(
MOCK_REQUEST, "token_uri", "refresh_token", "client_id", "client_secret"
MOCK_REQUEST,
"token_uri",
"refresh_token",
"client_id",
"client_secret",
enable_reauth_refresh=True,
) == (
"access_token",
"refresh_token",
None,
{"access_token": "access_token"},
"new_rapt_token",
)


def test_refresh_grant_reauth_refresh_disabled():
with mock.patch(
"google.oauth2._client._token_endpoint_request_no_throw"
) as mock_token_request:
mock_token_request.side_effect = [
(False, {"error": "invalid_grant", "error_subtype": "rapt_required"}),
(True, {"access_token": "access_token"}),
]
with pytest.raises(exceptions.RefreshError) as excinfo:
reauth.refresh_grant(
MOCK_REQUEST, "token_uri", "refresh_token", "client_id", "client_secret"
)
assert excinfo.match(r"Reauthentication is needed")

0 comments on commit 13aed5f

Please sign in to comment.