From ed7a49c56947f14b39370d5e46256e48c1739995 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 19 Oct 2026 19:06:45 +0000 Subject: [PATCH 1/7] fix(google-auth): validate Firebase issuer and warn when audience is omitted in verify_firebase_token --- .../google/oauth2/_id_token_async.py | 32 +++++++++- .../google-auth/google/oauth2/id_token.py | 31 +++++++++- .../google-auth/tests/oauth2/test_id_token.py | 57 ++++++++++++++++++ .../tests_async/oauth2/test_id_token.py | 60 +++++++++++++++++++ 4 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index 024a305c0808..af92cfecc8d4 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -61,6 +61,7 @@ import http.client as http_client import json import os +import warnings from google.auth import environment_vars, exceptions, jwt from google.auth.transport import requests @@ -184,8 +185,21 @@ async def verify_firebase_token( Returns: Mapping[str, Any]: The decoded token. + + Raises: + exceptions.InvalidValue: If the issuer or audience is invalid. + ValueError: If token verification fails. """ - return await verify_token( + if audience is None: + warnings.warn( + "Calling verify_firebase_token without an audience is insecure as " + "it accepts tokens from any Firebase project. Pass your Firebase " + "project ID as the 'audience' parameter.", + UserWarning, + stacklevel=2, + ) + + idinfo = await verify_token( id_token, request, audience=audience, @@ -193,6 +207,22 @@ async def verify_firebase_token( clock_skew_in_seconds=clock_skew_in_seconds, ) + token_aud = idinfo.get("aud") + if not isinstance(token_aud, str) or not token_aud: + raise exceptions.InvalidValue( + "Token does not contain a valid 'aud' claim." + ) + + expected_issuer = "{}{}".format( + sync_id_token._FIREBASE_ISSUER_PREFIX, token_aud + ) + if idinfo.get("iss") != expected_issuer: + raise exceptions.InvalidValue( + "Wrong issuer. 'iss' should be '{}'.".format(expected_issuer) + ) + + return idinfo + async def fetch_id_token(request, audience, bind_id_token=None): """Fetch the ID Token from the current environment. diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index c73d11b50f94..6d50001a104b 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -61,6 +61,7 @@ import json import os from typing import Any, Mapping, Union +import warnings from google.auth import environment_vars, exceptions, jwt, transport @@ -76,6 +77,7 @@ ) _GOOGLE_ISSUERS = ["accounts.google.com", "https://accounts.google.com"] +_FIREBASE_ISSUER_PREFIX = "https://securetoken.google.com/" def _fetch_certs(request, certs_url): @@ -226,8 +228,21 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second Returns: Mapping[str, Any]: The decoded token. + + Raises: + exceptions.InvalidValue: If the issuer or audience is invalid. + ValueError: If token verification fails. """ - return verify_token( + if audience is None: + warnings.warn( + "Calling verify_firebase_token without an audience is insecure as " + "it accepts tokens from any Firebase project. Pass your Firebase " + "project ID as the 'audience' parameter.", + UserWarning, + stacklevel=2, + ) + + idinfo = verify_token( id_token, request, audience=audience, @@ -235,6 +250,20 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second clock_skew_in_seconds=clock_skew_in_seconds, ) + token_aud = idinfo.get("aud") + if not isinstance(token_aud, str) or not token_aud: + raise exceptions.InvalidValue( + "Token does not contain a valid 'aud' claim." + ) + + expected_issuer = "{}{}".format(_FIREBASE_ISSUER_PREFIX, token_aud) + if idinfo.get("iss") != expected_issuer: + raise exceptions.InvalidValue( + "Wrong issuer. 'iss' should be '{}'.".format(expected_issuer) + ) + + return idinfo + def fetch_id_token_credentials(audience, request=None, bind_id_token=None): """Create the ID Token credentials from the current environment. diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 07fd2c3f1935..7e2f5b9337b5 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -237,6 +237,10 @@ def test_verify_oauth2_token_invalid_iss(verify_token): @mock.patch("google.oauth2.id_token.verify_token", autospec=True) def test_verify_firebase_token(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } result = id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience ) @@ -253,6 +257,10 @@ def test_verify_firebase_token(verify_token): @mock.patch("google.oauth2.id_token.verify_token", autospec=True) def test_verify_firebase_token_clock_skew(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } result = id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request, @@ -270,6 +278,55 @@ def test_verify_firebase_token_clock_skew(verify_token): ) +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token_invalid_iss(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/other-project-id", + } + + with pytest.raises(exceptions.InvalidValue, match="Wrong issuer"): + id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token_missing_aud(verify_token): + verify_token.return_value = { + "iss": "https://securetoken.google.com/", + } + + with pytest.raises( + exceptions.InvalidValue, match="Token does not contain a valid 'aud' claim" + ): + id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token_warns_without_audience(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } + + with pytest.warns( + UserWarning, + match="Calling verify_firebase_token without an audience is insecure", + ): + result = id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request + ) + + assert result == verify_token.return_value + + def test_fetch_id_token_credentials_optional_request(monkeypatch): monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 883a9ee9c5ec..18837d479f87 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -174,6 +174,10 @@ async def test_verify_oauth2_token_invalid_iss(verify_token): @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio async def test_verify_firebase_token(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } result = await id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience ) @@ -191,6 +195,10 @@ async def test_verify_firebase_token(verify_token): @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio async def test_verify_firebase_token_clock_skew(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } result = await id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request, @@ -208,6 +216,58 @@ async def test_verify_firebase_token_clock_skew(verify_token): ) +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token_invalid_iss(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/other-project-id", + } + + with pytest.raises(exceptions.InvalidValue, match="Wrong issuer"): + await id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token_missing_aud(verify_token): + verify_token.return_value = { + "iss": "https://securetoken.google.com/", + } + + with pytest.raises( + exceptions.InvalidValue, match="Token does not contain a valid 'aud' claim" + ): + await id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token_warns_without_audience(verify_token): + verify_token.return_value = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + } + + with pytest.warns( + UserWarning, + match="Calling verify_firebase_token without an audience is insecure", + ): + result = await id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request + ) + + assert result == verify_token.return_value + + @pytest.mark.asyncio @pytest.mark.parametrize( "call_kwargs,expected_bind_id_token", From b600b05c47a73bc0416478909b9fafb571351481 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 20 Oct 2026 15:47:54 +0000 Subject: [PATCH 2/7] chore(google-auth): address PR review feedback on formatting, docstrings, and test coverage --- .../google/oauth2/_id_token_async.py | 15 +++--- .../google-auth/google/oauth2/id_token.py | 13 +++-- .../google-auth/tests/oauth2/test_id_token.py | 54 ++++++++++++++----- .../tests_async/oauth2/test_id_token.py | 54 ++++++++++++++----- 4 files changed, 94 insertions(+), 42 deletions(-) diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index af92cfecc8d4..d78b483494fd 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -177,9 +177,10 @@ async def verify_firebase_token( id_token (Union[str, bytes]): The encoded token. request (google.auth.transport.Request): The object used to make HTTP requests. This must be an aiohttp request. - audience (str): The audience that this token is intended for. This is - typically your Firebase application ID. If None then the audience - is not verified. + audience (Optional[Union[str, Iterable[str]]]): The audience that this + token is intended for. This is your Firebase project ID. If + ``None``, a ``UserWarning`` is issued because tokens from any + Firebase project will be accepted. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -209,13 +210,9 @@ async def verify_firebase_token( token_aud = idinfo.get("aud") if not isinstance(token_aud, str) or not token_aud: - raise exceptions.InvalidValue( - "Token does not contain a valid 'aud' claim." - ) + raise exceptions.InvalidValue("Token does not contain a valid 'aud' claim.") - expected_issuer = "{}{}".format( - sync_id_token._FIREBASE_ISSUER_PREFIX, token_aud - ) + expected_issuer = "{}{}".format(sync_id_token._FIREBASE_ISSUER_PREFIX, token_aud) if idinfo.get("iss") != expected_issuer: raise exceptions.InvalidValue( "Wrong issuer. 'iss' should be '{}'.".format(expected_issuer) diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index 6d50001a104b..6028081ba2f0 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -60,8 +60,8 @@ import http.client as http_client import json import os -from typing import Any, Mapping, Union import warnings +from typing import Any, Mapping, Union from google.auth import environment_vars, exceptions, jwt, transport @@ -220,9 +220,10 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second id_token (Union[str, bytes]): The encoded token. request (google.auth.transport.Request): The object used to make HTTP requests. - audience (str): The audience that this token is intended for. This is - typically your Firebase application ID. If None then the audience - is not verified. + audience (Optional[Union[str, Iterable[str]]]): The audience that this + token is intended for. This is your Firebase project ID. If + ``None``, a ``UserWarning`` is issued because tokens from any + Firebase project will be accepted. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -252,9 +253,7 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second token_aud = idinfo.get("aud") if not isinstance(token_aud, str) or not token_aud: - raise exceptions.InvalidValue( - "Token does not contain a valid 'aud' claim." - ) + raise exceptions.InvalidValue("Token does not contain a valid 'aud' claim.") expected_issuer = "{}{}".format(_FIREBASE_ISSUER_PREFIX, token_aud) if idinfo.get("iss") != expected_issuer: diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 7e2f5b9337b5..9d9626ba8fe0 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -14,6 +14,7 @@ import json import os +import warnings from unittest import mock import pytest # type: ignore @@ -241,9 +242,11 @@ def test_verify_firebase_token(verify_token): "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", } - result = id_token.verify_firebase_token( - mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience - ) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + result = id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) assert result == verify_token.return_value verify_token.assert_called_once_with( @@ -278,12 +281,19 @@ def test_verify_firebase_token_clock_skew(verify_token): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + {"aud": "my-project-id"}, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/other-project-id", + }, + ], +) @mock.patch("google.oauth2.id_token.verify_token", autospec=True) -def test_verify_firebase_token_invalid_iss(verify_token): - verify_token.return_value = { - "aud": "my-project-id", - "iss": "https://securetoken.google.com/other-project-id", - } +def test_verify_firebase_token_invalid_iss(verify_token, invalid_payload): + verify_token.return_value = invalid_payload with pytest.raises(exceptions.InvalidValue, match="Wrong issuer"): id_token.verify_firebase_token( @@ -293,11 +303,20 @@ def test_verify_firebase_token_invalid_iss(verify_token): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + {"iss": "https://securetoken.google.com/"}, + {"aud": "", "iss": "https://securetoken.google.com/"}, + { + "aud": ["my-project-id"], + "iss": "https://securetoken.google.com/my-project-id", + }, + ], +) @mock.patch("google.oauth2.id_token.verify_token", autospec=True) -def test_verify_firebase_token_missing_aud(verify_token): - verify_token.return_value = { - "iss": "https://securetoken.google.com/", - } +def test_verify_firebase_token_missing_aud(verify_token, invalid_payload): + verify_token.return_value = invalid_payload with pytest.raises( exceptions.InvalidValue, match="Token does not contain a valid 'aud' claim" @@ -319,12 +338,21 @@ def test_verify_firebase_token_warns_without_audience(verify_token): with pytest.warns( UserWarning, match="Calling verify_firebase_token without an audience is insecure", - ): + ) as record: result = id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request ) + assert len(record) == 1 + assert record[0].filename == __file__ assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=None, + certs_url=id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=0, + ) def test_fetch_id_token_credentials_optional_request(monkeypatch): diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 18837d479f87..6f6e7b7dab08 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -14,6 +14,7 @@ import json import os +import warnings from unittest import mock import pytest # type: ignore @@ -178,9 +179,11 @@ async def test_verify_firebase_token(verify_token): "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", } - result = await id_token.verify_firebase_token( - mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience - ) + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + result = await id_token.verify_firebase_token( + mock.sentinel.token, mock.sentinel.request, audience=mock.sentinel.audience + ) assert result == verify_token.return_value verify_token.assert_called_once_with( @@ -216,13 +219,20 @@ async def test_verify_firebase_token_clock_skew(verify_token): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + {"aud": "my-project-id"}, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/other-project-id", + }, + ], +) @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio -async def test_verify_firebase_token_invalid_iss(verify_token): - verify_token.return_value = { - "aud": "my-project-id", - "iss": "https://securetoken.google.com/other-project-id", - } +async def test_verify_firebase_token_invalid_iss(verify_token, invalid_payload): + verify_token.return_value = invalid_payload with pytest.raises(exceptions.InvalidValue, match="Wrong issuer"): await id_token.verify_firebase_token( @@ -232,12 +242,21 @@ async def test_verify_firebase_token_invalid_iss(verify_token): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + {"iss": "https://securetoken.google.com/"}, + {"aud": "", "iss": "https://securetoken.google.com/"}, + { + "aud": ["my-project-id"], + "iss": "https://securetoken.google.com/my-project-id", + }, + ], +) @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio -async def test_verify_firebase_token_missing_aud(verify_token): - verify_token.return_value = { - "iss": "https://securetoken.google.com/", - } +async def test_verify_firebase_token_missing_aud(verify_token, invalid_payload): + verify_token.return_value = invalid_payload with pytest.raises( exceptions.InvalidValue, match="Token does not contain a valid 'aud' claim" @@ -260,12 +279,21 @@ async def test_verify_firebase_token_warns_without_audience(verify_token): with pytest.warns( UserWarning, match="Calling verify_firebase_token without an audience is insecure", - ): + ) as record: result = await id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request ) + assert len(record) == 1 + assert record[0].filename == __file__ assert result == verify_token.return_value + verify_token.assert_called_once_with( + mock.sentinel.token, + mock.sentinel.request, + audience=None, + certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, + clock_skew_in_seconds=0, + ) @pytest.mark.asyncio From 4b20928baac05db853b994162c01554e7e8ee6ae Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 22 Oct 2026 21:03:33 +0000 Subject: [PATCH 3/7] fix(google-auth): validate Firebase token sub claim is a non-empty string --- .../google/oauth2/_id_token_async.py | 6 ++- .../google-auth/google/oauth2/id_token.py | 6 ++- .../google-auth/tests/oauth2/test_id_token.py | 36 ++++++++++++++++++ .../tests_async/oauth2/test_id_token.py | 37 +++++++++++++++++++ 4 files changed, 83 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index d78b483494fd..3dbf7496dd6c 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -188,7 +188,7 @@ async def verify_firebase_token( Mapping[str, Any]: The decoded token. Raises: - exceptions.InvalidValue: If the issuer or audience is invalid. + exceptions.InvalidValue: If the issuer, audience, or subject is invalid. ValueError: If token verification fails. """ if audience is None: @@ -218,6 +218,10 @@ async def verify_firebase_token( "Wrong issuer. 'iss' should be '{}'.".format(expected_issuer) ) + token_sub = idinfo.get("sub") + if not isinstance(token_sub, str) or not token_sub: + raise exceptions.InvalidValue("Token does not contain a valid 'sub' claim.") + return idinfo diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index 6028081ba2f0..d64cc5c3677e 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -231,7 +231,7 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second Mapping[str, Any]: The decoded token. Raises: - exceptions.InvalidValue: If the issuer or audience is invalid. + exceptions.InvalidValue: If the issuer, audience, or subject is invalid. ValueError: If token verification fails. """ if audience is None: @@ -261,6 +261,10 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second "Wrong issuer. 'iss' should be '{}'.".format(expected_issuer) ) + token_sub = idinfo.get("sub") + if not isinstance(token_sub, str) or not token_sub: + raise exceptions.InvalidValue("Token does not contain a valid 'sub' claim.") + return idinfo diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 9d9626ba8fe0..7c34cf4d0c2c 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -241,6 +241,7 @@ def test_verify_firebase_token(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) @@ -263,6 +264,7 @@ def test_verify_firebase_token_clock_skew(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } result = id_token.verify_firebase_token( mock.sentinel.token, @@ -328,11 +330,45 @@ def test_verify_firebase_token_missing_aud(verify_token, invalid_payload): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + }, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "", + }, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": 123, + }, + ], +) +@mock.patch("google.oauth2.id_token.verify_token", autospec=True) +def test_verify_firebase_token_invalid_sub(verify_token, invalid_payload): + verify_token.return_value = invalid_payload + + with pytest.raises( + exceptions.InvalidValue, match="Token does not contain a valid 'sub' claim" + ): + id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + @mock.patch("google.oauth2.id_token.verify_token", autospec=True) def test_verify_firebase_token_warns_without_audience(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } with pytest.warns( diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 6f6e7b7dab08..9aa95d8b6fa5 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -178,6 +178,7 @@ async def test_verify_firebase_token(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } with warnings.catch_warnings(): warnings.simplefilter("error", UserWarning) @@ -201,6 +202,7 @@ async def test_verify_firebase_token_clock_skew(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } result = await id_token.verify_firebase_token( mock.sentinel.token, @@ -268,12 +270,47 @@ async def test_verify_firebase_token_missing_aud(verify_token, invalid_payload): ) +@pytest.mark.parametrize( + "invalid_payload", + [ + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + }, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "", + }, + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": 123, + }, + ], +) +@mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) +@pytest.mark.asyncio +async def test_verify_firebase_token_invalid_sub(verify_token, invalid_payload): + verify_token.return_value = invalid_payload + + with pytest.raises( + exceptions.InvalidValue, match="Token does not contain a valid 'sub' claim" + ): + await id_token.verify_firebase_token( + mock.sentinel.token, + mock.sentinel.request, + audience=mock.sentinel.audience, + ) + + @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio async def test_verify_firebase_token_warns_without_audience(verify_token): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", } with pytest.warns( From 9e766a5ec39f5ad81e3bef50dc3ebe10092f712c Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 22 Oct 2026 21:05:56 +0000 Subject: [PATCH 4/7] chore(google-auth): note in warning message that audience will be required in a future release --- packages/google-auth/google/oauth2/_id_token_async.py | 3 ++- packages/google-auth/google/oauth2/id_token.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index 3dbf7496dd6c..bb6b0daa0a19 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -195,7 +195,8 @@ async def verify_firebase_token( warnings.warn( "Calling verify_firebase_token without an audience is insecure as " "it accepts tokens from any Firebase project. Pass your Firebase " - "project ID as the 'audience' parameter.", + "project ID as the 'audience' parameter. The 'audience' parameter " + "will be required in a future release.", UserWarning, stacklevel=2, ) diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index d64cc5c3677e..7636e0107e48 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -238,7 +238,8 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second warnings.warn( "Calling verify_firebase_token without an audience is insecure as " "it accepts tokens from any Firebase project. Pass your Firebase " - "project ID as the 'audience' parameter.", + "project ID as the 'audience' parameter. The 'audience' parameter " + "will be required in a future release.", UserWarning, stacklevel=2, ) From 6c0b756f91e555cc547b2849dee873c325e4175d Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 27 Oct 2026 04:23:24 +0000 Subject: [PATCH 5/7] fix(google-auth): translate key-type mismatch and unhashable header TypeErrors in jwt.decode --- packages/google-auth/google/auth/jwt.py | 27 +++++++--- .../google-auth/tests/oauth2/test_id_token.py | 27 ++++++++++ packages/google-auth/tests/test_jwt.py | 52 +++++++++++++++++++ .../tests_async/oauth2/test_id_token.py | 26 ++++++++++ 4 files changed, 124 insertions(+), 8 deletions(-) diff --git a/packages/google-auth/google/auth/jwt.py b/packages/google-auth/google/auth/jwt.py index 9eff792e7bc2..96928a212c6a 100644 --- a/packages/google-auth/google/auth/jwt.py +++ b/packages/google-auth/google/auth/jwt.py @@ -269,8 +269,8 @@ def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds= try: verifier_cls = _ALGORITHM_TO_VERIFIER_CLASS[key_alg] - except KeyError as exc: - if key_alg in _CRYPTOGRAPHY_BASED_ALGORITHMS: + except (KeyError, TypeError) as exc: + if isinstance(key_alg, str) and key_alg in _CRYPTOGRAPHY_BASED_ALGORITHMS: raise exceptions.InvalidValue( "The key algorithm {} requires the cryptography package to be installed.".format( key_alg @@ -284,11 +284,12 @@ def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds= # use the certificate identified by the key ID in the token header. if isinstance(certs, Mapping): if key_id: - if key_id not in certs: + try: + certs_to_check = [certs[key_id]] + except (KeyError, TypeError) as exc: raise exceptions.MalformedError( "Certificate for key id {} not found.".format(key_id) - ) - certs_to_check = [certs[key_id]] + ) from exc # If there's no key id in the header, check against all of the certs. else: certs_to_check = certs.values() @@ -296,9 +297,19 @@ def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds= certs_to_check = certs # Verify that the signature matches the message. - if not crypt.verify_signature( - signed_section, signature, certs_to_check, verifier_cls - ): + try: + valid_signature = crypt.verify_signature( + signed_section, signature, certs_to_check, verifier_cls + ) + except TypeError as exc: + if certs_to_check is None: + raise + raise exceptions.InvalidValue( + "Certificate key type does not match signature algorithm {}.".format( + key_alg + ) + ) from exc + if not valid_signature: raise exceptions.MalformedError("Could not verify token signature.") # Verify the issued at and created times in the payload. diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 7c34cf4d0c2c..98f1b37a5c9b 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import json import os import warnings @@ -27,6 +28,8 @@ ) from google.oauth2 import id_token, service_account +PUBLIC_CERT_FILE = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") + SERVICE_ACCOUNT_FILE = os.path.join( os.path.dirname(__file__), "../data/service_account.json" ) @@ -391,6 +394,30 @@ def test_verify_firebase_token_warns_without_audience(verify_token): ) +@pytest.mark.parametrize("alg", ["ES256", "ES384"]) +def test_verify_firebase_token_mismatched_alg_for_rsa_certs(alg): + with open(PUBLIC_CERT_FILE, "r") as fh: + public_cert = fh.read() + request = make_request(200, {"1": public_cert}) + header = base64.urlsafe_b64encode( + json.dumps({"alg": alg, "kid": "1", "typ": "JWT"}).encode("utf-8") + ) + payload = base64.urlsafe_b64encode( + json.dumps( + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", + } + ).encode("utf-8") + ) + sig = base64.urlsafe_b64encode(b"sig") + token = b".".join([header, payload, sig]) + + with pytest.raises(exceptions.InvalidValue): + id_token.verify_firebase_token(token, request, audience="my-project-id") + + def test_fetch_id_token_credentials_optional_request(monkeypatch): monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) diff --git a/packages/google-auth/tests/test_jwt.py b/packages/google-auth/tests/test_jwt.py index 207648aa06fa..0d44e06f4f4f 100644 --- a/packages/google-auth/tests/test_jwt.py +++ b/packages/google-auth/tests/test_jwt.py @@ -361,6 +361,58 @@ def test_decode_missing_crytography_alg(monkeypatch): assert excinfo.match(r"cryptography") +@pytest.mark.parametrize( + "token_kwargs,certs,expected_alg", + [ + ({"use_es256_signer": True}, PUBLIC_CERT_BYTES, "ES256"), + ({"use_es256_signer": True}, {"1": PUBLIC_CERT_BYTES}, "ES256"), + ({"use_es384_signer": True}, PUBLIC_CERT_BYTES, "ES384"), + ({"use_es384_signer": True}, {"1": PUBLIC_CERT_BYTES}, "ES384"), + ({}, EC_PUBLIC_CERT_BYTES, "RS256"), + ({}, {"1": EC_PUBLIC_CERT_BYTES}, "RS256"), + ], +) +def test_decode_mismatched_cert_key_type( + token_factory, token_kwargs, certs, expected_alg +): + token = token_factory(**token_kwargs) + + with pytest.raises(exceptions.InvalidValue) as excinfo: + jwt.decode(token, certs=certs) + assert excinfo.match( + r"Certificate key type does not match signature algorithm {}".format( + expected_alg + ) + ) + + +def test_decode_missing_certs(token_factory): + with pytest.raises(TypeError): + jwt.decode(token_factory()) + + +def test_decode_unhashable_alg(): + headers = json.dumps({"kid": "1", "alg": ["RS256"]}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, "{}", "sig"]) + ) + + with pytest.raises(exceptions.InvalidValue) as excinfo: + jwt.decode(token, PUBLIC_CERT_BYTES) + assert excinfo.match(r"Unsupported signature algorithm") + + +def test_decode_unhashable_kid(): + headers = json.dumps({"kid": ["1"], "alg": "RS256"}) + token = b".".join( + map(lambda seg: base64.b64encode(seg.encode("utf-8")), [headers, "{}", "sig"]) + ) + + with pytest.raises(exceptions.MalformedError) as excinfo: + jwt.decode(token, {"1": PUBLIC_CERT_BYTES}) + assert excinfo.match(r"Certificate for key id .* not found") + + def test_roundtrip_explicit_key_id(token_factory): token = token_factory(key_id="3") certs = {"2": OTHER_CERT_BYTES, "3": PUBLIC_CERT_BYTES} diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 9aa95d8b6fa5..0bd2055235cd 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +import base64 import json import os import warnings @@ -333,6 +334,31 @@ async def test_verify_firebase_token_warns_without_audience(verify_token): ) +@pytest.mark.parametrize("alg", ["ES256", "ES384"]) +@pytest.mark.asyncio +async def test_verify_firebase_token_mismatched_alg_for_rsa_certs(alg): + with open(test_id_token.PUBLIC_CERT_FILE, "r") as fh: + public_cert = fh.read() + request = make_request(200, {"1": public_cert}) + header = base64.urlsafe_b64encode( + json.dumps({"alg": alg, "kid": "1", "typ": "JWT"}).encode("utf-8") + ) + payload = base64.urlsafe_b64encode( + json.dumps( + { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", + } + ).encode("utf-8") + ) + sig = base64.urlsafe_b64encode(b"sig") + token = b".".join([header, payload, sig]) + + with pytest.raises(exceptions.InvalidValue): + await id_token.verify_firebase_token(token, request, audience="my-project-id") + + @pytest.mark.asyncio @pytest.mark.parametrize( "call_kwargs,expected_bind_id_token", From a383cf996b66571402e355b06a4c435bb939f800 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 27 Oct 2026 14:52:34 +0000 Subject: [PATCH 6/7] chore(google-auth): use neutral spec-compliance wording in omitted audience warning and docstring --- packages/google-auth/google/oauth2/_id_token_async.py | 11 +++++------ packages/google-auth/google/oauth2/id_token.py | 11 +++++------ packages/google-auth/tests/oauth2/test_id_token.py | 5 ++++- .../google-auth/tests_async/oauth2/test_id_token.py | 5 ++++- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index bb6b0daa0a19..390399906c0f 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -179,8 +179,8 @@ async def verify_firebase_token( HTTP requests. This must be an aiohttp request. audience (Optional[Union[str, Iterable[str]]]): The audience that this token is intended for. This is your Firebase project ID. If - ``None``, a ``UserWarning`` is issued because tokens from any - Firebase project will be accepted. + ``None``, the audience is not verified and a ``UserWarning`` is + issued. Passing ``audience`` will be required in a future release. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -193,10 +193,9 @@ async def verify_firebase_token( """ if audience is None: warnings.warn( - "Calling verify_firebase_token without an audience is insecure as " - "it accepts tokens from any Firebase project. Pass your Firebase " - "project ID as the 'audience' parameter. The 'audience' parameter " - "will be required in a future release.", + "Calling verify_firebase_token without an 'audience' leaves the " + "token's audience claim unverified. Pass your Firebase project ID " + "as 'audience', which will be required in a future release.", UserWarning, stacklevel=2, ) diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index 7636e0107e48..c16e40a87c99 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -222,8 +222,8 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second HTTP requests. audience (Optional[Union[str, Iterable[str]]]): The audience that this token is intended for. This is your Firebase project ID. If - ``None``, a ``UserWarning`` is issued because tokens from any - Firebase project will be accepted. + ``None``, the audience is not verified and a ``UserWarning`` is + issued. Passing ``audience`` will be required in a future release. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -236,10 +236,9 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second """ if audience is None: warnings.warn( - "Calling verify_firebase_token without an audience is insecure as " - "it accepts tokens from any Firebase project. Pass your Firebase " - "project ID as the 'audience' parameter. The 'audience' parameter " - "will be required in a future release.", + "Calling verify_firebase_token without an 'audience' leaves the " + "token's audience claim unverified. Pass your Firebase project ID " + "as 'audience', which will be required in a future release.", UserWarning, stacklevel=2, ) diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 98f1b37a5c9b..5fb505d990e0 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -376,7 +376,10 @@ def test_verify_firebase_token_warns_without_audience(verify_token): with pytest.warns( UserWarning, - match="Calling verify_firebase_token without an audience is insecure", + match=( + "Calling verify_firebase_token without an 'audience' leaves the " + "token's audience claim unverified" + ), ) as record: result = id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 0bd2055235cd..704991774076 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -316,7 +316,10 @@ async def test_verify_firebase_token_warns_without_audience(verify_token): with pytest.warns( UserWarning, - match="Calling verify_firebase_token without an audience is insecure", + match=( + "Calling verify_firebase_token without an 'audience' leaves the " + "token's audience claim unverified" + ), ) as record: result = await id_token.verify_firebase_token( mock.sentinel.token, mock.sentinel.request From 8ece19c1822e5f72f818f92e4208b737d1699d11 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 28 Oct 2026 15:55:02 +0000 Subject: [PATCH 7/7] fix(google-auth): preserve TypeError for non-iterable certs and expand Firebase token tests --- packages/google-auth/google/auth/jwt.py | 6 +- .../google/oauth2/_id_token_async.py | 6 +- .../google-auth/google/oauth2/id_token.py | 6 +- .../google-auth/tests/oauth2/test_id_token.py | 58 +++++++++++++++++-- packages/google-auth/tests/test_jwt.py | 6 ++ .../tests_async/oauth2/test_id_token.py | 56 ++++++++++++++++-- 6 files changed, 120 insertions(+), 18 deletions(-) diff --git a/packages/google-auth/google/auth/jwt.py b/packages/google-auth/google/auth/jwt.py index 96928a212c6a..4f256b79cddc 100644 --- a/packages/google-auth/google/auth/jwt.py +++ b/packages/google-auth/google/auth/jwt.py @@ -41,10 +41,10 @@ """ try: - from collections.abc import Mapping + from collections.abc import Iterable, Mapping # Python 2.7 compatibility except ImportError: # pragma: NO COVER - from collections import Mapping # type: ignore + from collections import Iterable, Mapping # type: ignore import copy import datetime import json @@ -302,7 +302,7 @@ def decode(token, certs=None, verify=True, audience=None, clock_skew_in_seconds= signed_section, signature, certs_to_check, verifier_cls ) except TypeError as exc: - if certs_to_check is None: + if not isinstance(certs_to_check, Iterable): raise raise exceptions.InvalidValue( "Certificate key type does not match signature algorithm {}.".format( diff --git a/packages/google-auth/google/oauth2/_id_token_async.py b/packages/google-auth/google/oauth2/_id_token_async.py index 390399906c0f..2a1fe7466623 100644 --- a/packages/google-auth/google/oauth2/_id_token_async.py +++ b/packages/google-auth/google/oauth2/_id_token_async.py @@ -179,8 +179,8 @@ async def verify_firebase_token( HTTP requests. This must be an aiohttp request. audience (Optional[Union[str, Iterable[str]]]): The audience that this token is intended for. This is your Firebase project ID. If - ``None``, the audience is not verified and a ``UserWarning`` is - issued. Passing ``audience`` will be required in a future release. + ``None`` or empty, a ``UserWarning`` is issued. Passing a + non-empty ``audience`` will be required in a future release. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -191,7 +191,7 @@ async def verify_firebase_token( exceptions.InvalidValue: If the issuer, audience, or subject is invalid. ValueError: If token verification fails. """ - if audience is None: + if not audience: warnings.warn( "Calling verify_firebase_token without an 'audience' leaves the " "token's audience claim unverified. Pass your Firebase project ID " diff --git a/packages/google-auth/google/oauth2/id_token.py b/packages/google-auth/google/oauth2/id_token.py index c16e40a87c99..cac9fe2f18f6 100644 --- a/packages/google-auth/google/oauth2/id_token.py +++ b/packages/google-auth/google/oauth2/id_token.py @@ -222,8 +222,8 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second HTTP requests. audience (Optional[Union[str, Iterable[str]]]): The audience that this token is intended for. This is your Firebase project ID. If - ``None``, the audience is not verified and a ``UserWarning`` is - issued. Passing ``audience`` will be required in a future release. + ``None`` or empty, a ``UserWarning`` is issued. Passing a + non-empty ``audience`` will be required in a future release. clock_skew_in_seconds (int): The clock skew used for `iat` and `exp` validation. @@ -234,7 +234,7 @@ def verify_firebase_token(id_token, request, audience=None, clock_skew_in_second exceptions.InvalidValue: If the issuer, audience, or subject is invalid. ValueError: If token verification fails. """ - if audience is None: + if not audience: warnings.warn( "Calling verify_firebase_token without an 'audience' leaves the " "token's audience claim unverified. Pass your Firebase project ID " diff --git a/packages/google-auth/tests/oauth2/test_id_token.py b/packages/google-auth/tests/oauth2/test_id_token.py index 5fb505d990e0..b00844f79981 100644 --- a/packages/google-auth/tests/oauth2/test_id_token.py +++ b/packages/google-auth/tests/oauth2/test_id_token.py @@ -21,13 +21,18 @@ import pytest # type: ignore from google.auth import ( + _helpers, + crypt, environment_vars, exceptions, impersonated_credentials, + jwt, transport, ) from google.oauth2 import id_token, service_account +PRIVATE_KEY_FILE = os.path.join(os.path.dirname(__file__), "../data/privatekey.pem") + PUBLIC_CERT_FILE = os.path.join(os.path.dirname(__file__), "../data/public_cert.pem") SERVICE_ACCOUNT_FILE = os.path.join( @@ -366,8 +371,9 @@ def test_verify_firebase_token_invalid_sub(verify_token, invalid_payload): ) +@pytest.mark.parametrize("audience", [None, "", []]) @mock.patch("google.oauth2.id_token.verify_token", autospec=True) -def test_verify_firebase_token_warns_without_audience(verify_token): +def test_verify_firebase_token_warns_without_audience(verify_token, audience): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", @@ -382,7 +388,7 @@ def test_verify_firebase_token_warns_without_audience(verify_token): ), ) as record: result = id_token.verify_firebase_token( - mock.sentinel.token, mock.sentinel.request + mock.sentinel.token, mock.sentinel.request, audience=audience ) assert len(record) == 1 @@ -391,7 +397,7 @@ def test_verify_firebase_token_warns_without_audience(verify_token): verify_token.assert_called_once_with( mock.sentinel.token, mock.sentinel.request, - audience=None, + audience=audience, certs_url=id_token._GOOGLE_APIS_CERTS_URL, clock_skew_in_seconds=0, ) @@ -417,10 +423,54 @@ def test_verify_firebase_token_mismatched_alg_for_rsa_certs(alg): sig = base64.urlsafe_b64encode(b"sig") token = b".".join([header, payload, sig]) - with pytest.raises(exceptions.InvalidValue): + with pytest.raises( + exceptions.InvalidValue, + match="Certificate key type does not match signature algorithm {}".format(alg), + ): id_token.verify_firebase_token(token, request, audience="my-project-id") +def test_verify_firebase_token_end_to_end(): + with open(PRIVATE_KEY_FILE, "rb") as fh: + private_key = fh.read() + with open(PUBLIC_CERT_FILE, "r") as fh: + public_cert = fh.read() + + signer = crypt.RSASigner.from_string(private_key, "1") + now = _helpers.datetime_to_secs(_helpers.utcnow()) + + valid_payload = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", + "iat": now, + "exp": now + 300, + } + valid_token = jwt.encode(signer, valid_payload) + request = make_request(200, {"1": public_cert}) + + result = id_token.verify_firebase_token( + valid_token, request, audience="my-project-id" + ) + assert result == valid_payload + + cross_tenant_token = jwt.encode( + signer, + { + "aud": "other-project-id", + "iss": "https://securetoken.google.com/other-project-id", + "sub": "my-user-id", + "iat": now, + "exp": now + 300, + }, + ) + request = make_request(200, {"1": public_cert}) + with pytest.raises(exceptions.InvalidValue, match="Token has wrong audience"): + id_token.verify_firebase_token( + cross_tenant_token, request, audience="my-project-id" + ) + + def test_fetch_id_token_credentials_optional_request(monkeypatch): monkeypatch.delenv(environment_vars.CREDENTIALS, raising=False) diff --git a/packages/google-auth/tests/test_jwt.py b/packages/google-auth/tests/test_jwt.py index 0d44e06f4f4f..ffc3fe5a0112 100644 --- a/packages/google-auth/tests/test_jwt.py +++ b/packages/google-auth/tests/test_jwt.py @@ -391,6 +391,12 @@ def test_decode_missing_certs(token_factory): jwt.decode(token_factory()) +@pytest.mark.parametrize("certs", [None, object()]) +def test_decode_non_iterable_certs(token_factory, certs): + with pytest.raises(TypeError): + jwt.decode(token_factory(), certs=certs) + + def test_decode_unhashable_alg(): headers = json.dumps({"kid": "1", "alg": ["RS256"]}) token = b".".join( diff --git a/packages/google-auth/tests_async/oauth2/test_id_token.py b/packages/google-auth/tests_async/oauth2/test_id_token.py index 704991774076..c5f09cbf7c17 100644 --- a/packages/google-auth/tests_async/oauth2/test_id_token.py +++ b/packages/google-auth/tests_async/oauth2/test_id_token.py @@ -21,7 +21,7 @@ import pytest # type: ignore import google.auth.compute_engine._metadata -from google.auth import environment_vars, exceptions +from google.auth import _helpers, crypt, environment_vars, exceptions, jwt from google.oauth2 import _id_token_async as id_token from google.oauth2 import _service_account_async from google.oauth2 import id_token as sync_id_token @@ -305,9 +305,10 @@ async def test_verify_firebase_token_invalid_sub(verify_token, invalid_payload): ) +@pytest.mark.parametrize("audience", [None, "", []]) @mock.patch("google.oauth2._id_token_async.verify_token", autospec=True) @pytest.mark.asyncio -async def test_verify_firebase_token_warns_without_audience(verify_token): +async def test_verify_firebase_token_warns_without_audience(verify_token, audience): verify_token.return_value = { "aud": "my-project-id", "iss": "https://securetoken.google.com/my-project-id", @@ -322,7 +323,7 @@ async def test_verify_firebase_token_warns_without_audience(verify_token): ), ) as record: result = await id_token.verify_firebase_token( - mock.sentinel.token, mock.sentinel.request + mock.sentinel.token, mock.sentinel.request, audience=audience ) assert len(record) == 1 @@ -331,7 +332,7 @@ async def test_verify_firebase_token_warns_without_audience(verify_token): verify_token.assert_called_once_with( mock.sentinel.token, mock.sentinel.request, - audience=None, + audience=audience, certs_url=sync_id_token._GOOGLE_APIS_CERTS_URL, clock_skew_in_seconds=0, ) @@ -358,10 +359,55 @@ async def test_verify_firebase_token_mismatched_alg_for_rsa_certs(alg): sig = base64.urlsafe_b64encode(b"sig") token = b".".join([header, payload, sig]) - with pytest.raises(exceptions.InvalidValue): + with pytest.raises( + exceptions.InvalidValue, + match="Certificate key type does not match signature algorithm {}".format(alg), + ): await id_token.verify_firebase_token(token, request, audience="my-project-id") +@pytest.mark.asyncio +async def test_verify_firebase_token_end_to_end(): + with open(test_id_token.PRIVATE_KEY_FILE, "rb") as fh: + private_key = fh.read() + with open(test_id_token.PUBLIC_CERT_FILE, "r") as fh: + public_cert = fh.read() + + signer = crypt.RSASigner.from_string(private_key, "1") + now = _helpers.datetime_to_secs(_helpers.utcnow()) + + valid_payload = { + "aud": "my-project-id", + "iss": "https://securetoken.google.com/my-project-id", + "sub": "my-user-id", + "iat": now, + "exp": now + 300, + } + valid_token = jwt.encode(signer, valid_payload) + request = make_request(200, {"1": public_cert}) + + result = await id_token.verify_firebase_token( + valid_token, request, audience="my-project-id" + ) + assert result == valid_payload + + cross_tenant_token = jwt.encode( + signer, + { + "aud": "other-project-id", + "iss": "https://securetoken.google.com/other-project-id", + "sub": "my-user-id", + "iat": now, + "exp": now + 300, + }, + ) + request = make_request(200, {"1": public_cert}) + with pytest.raises(exceptions.InvalidValue, match="Token has wrong audience"): + await id_token.verify_firebase_token( + cross_tenant_token, request, audience="my-project-id" + ) + + @pytest.mark.asyncio @pytest.mark.parametrize( "call_kwargs,expected_bind_id_token",