From 34484f63adc3a812f53110a8244617868eca5152 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Mon, 28 Sep 2026 14:58:00 +0100 Subject: [PATCH 1/5] fix(identity): accept cloudfunctions.net auth blocking token audiences The verifier only accepted an audience containing run.app, but firebase-tools registers the cloudfunctions.net URL when it creates a blocking function and the run.app URL when it updates one. A newly created blocking function therefore rejected every token with a 500 until the next deploy moved it to the update path. Accept either form, matching the Node SDK. The existing sys.modules mock in the identity tests is replaced with an attribute patch, since it only held while nothing had imported the real token_verifier module. --- .../private/token_verifier.py | 25 +++- tests/test_identity_fn.py | 8 +- tests/test_token_verifier.py | 122 ++++++++++++++++++ 3 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 tests/test_token_verifier.py diff --git a/src/firebase_functions/private/token_verifier.py b/src/firebase_functions/private/token_verifier.py index 096b1135..fd424821 100644 --- a/src/firebase_functions/private/token_verifier.py +++ b/src/firebase_functions/private/token_verifier.py @@ -46,7 +46,7 @@ def __init__(self, **kwargs): self.url = kwargs.pop("doc_url") self.cert_url = kwargs.pop("cert_url") self.issuer = kwargs.pop("issuer") - self.expected_audience = kwargs.pop("expected_audience") + self.expected_audiences = kwargs.pop("expected_audiences") if self.short_name[0].lower() in "aeiou": self.articled_short_name = f"an {self.short_name}" else: @@ -102,12 +102,17 @@ def verify(self, token, request): self.short_name, header.get("alg"), verify_id_token_msg ) ) - elif not emulated and self.expected_audience and self.expected_audience not in audience: + elif ( + not emulated + and self.expected_audiences + and not any(expected in audience for expected in self.expected_audiences) + ): + expected = " or ".join(f'"{expected}"' for expected in self.expected_audiences) error_message = ( - f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected "{self.expected_audience}" but ' + f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected {expected} but ' f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}' ) - elif not emulated and not self.expected_audience and audience != self.project_id: + elif not emulated and not self.expected_audiences and audience != self.project_id: error_message = ( f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected "{self.project_id}" but ' f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}' @@ -136,9 +141,9 @@ def verify(self, token, request): verified_claims = google.oauth2.id_token.verify_token( token, request=request, - # If expected_audience is set then we have already verified + # If expected_audiences is set then we have already verified # the audience above. - audience=(None if self.expected_audience else self.project_id), + audience=(None if self.expected_audiences else self.project_id), certs_url=self.cert_url, ) verified_claims["uid"] = verified_claims["sub"] @@ -189,7 +194,13 @@ def __init__(self, app): issuer=_token_gen.ID_TOKEN_ISSUER_PREFIX, invalid_token_error=InvalidAuthBlockingTokenError, expired_token_error=ExpiredAuthBlockingTokenError, - expected_audience="run.app", # v2 only + # firebase-tools registers the cloudfunctions.net URL when it creates a + # blocking function and the run.app URL when it updates one, so a token's + # audience is either form depending on how the function was last deployed. + expected_audiences=[ + "run.app", + f"{app.project_id}.cloudfunctions.net/", + ], ) def verify_auth_blocking_token(self, auth_blocking_token): diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index b3d43fbf..ac5b7f39 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -9,6 +9,7 @@ from werkzeug.test import EnvironBuilder from firebase_functions import core, identity_fn +from firebase_functions.private import _identity_fn token_verifier_mock = MagicMock() token_verifier_mock.verify_auth_blocking_token = Mock( @@ -20,9 +21,8 @@ "iat": 0, } ) -mocked_modules = { - "firebase_functions.private.token_verifier": token_verifier_mock, -} +# Patch the reference _identity_fn holds rather than the sys.modules entry, which +# `import ... as` bypasses once anything else has imported the real module. class TestIdentity(unittest.TestCase): @@ -38,7 +38,7 @@ def init(): nonlocal hello hello = "world" - with patch.dict("sys.modules", mocked_modules): + with patch.object(_identity_fn, "_token_verifier", token_verifier_mock): app = Flask(__name__) func = Mock(__name__="example_func", return_value=identity_fn.BeforeSignInResponse()) diff --git a/tests/test_token_verifier.py b/tests/test_token_verifier.py new file mode 100644 index 00000000..32ab8817 --- /dev/null +++ b/tests/test_token_verifier.py @@ -0,0 +1,122 @@ +# Copyright 2026 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +""" +Tests for auth blocking token verification. +""" + +import base64 +import datetime +import json +import time + +import firebase_admin +import google.oauth2.id_token +import pytest +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.x509.oid import NameOID + +from firebase_functions.private import token_verifier + +PROJECT_ID = "test-project" +KEY_ID = "test-key-id" +RUN_APP_AUDIENCE = "https://before-create-7kndfybk7q-ue.a.run.app" +CLOUDFUNCTIONS_AUDIENCE = f"https://us-east1-{PROJECT_ID}.cloudfunctions.net/before_create" + + +@pytest.fixture(scope="module") +def signing_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(autouse=True) +def _signing_certs(monkeypatch, signing_key): + """Serve the test signing key in place of Google's public certs.""" + subject = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "test")]) + now = datetime.datetime.now(datetime.timezone.utc) + certificate = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(subject) + .public_key(signing_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - datetime.timedelta(days=1)) + .not_valid_after(now + datetime.timedelta(days=1)) + .sign(signing_key, hashes.SHA256()) + ) + certs = {KEY_ID: certificate.public_bytes(serialization.Encoding.PEM).decode()} + monkeypatch.setattr(google.oauth2.id_token, "_fetch_certs", lambda request, certs_url: certs) + + +@pytest.fixture(autouse=True) +def app(): + app = firebase_admin.initialize_app( + options={"projectId": PROJECT_ID}, name="token-verifier-test" + ) + yield app + firebase_admin.delete_app(app) + + +def _segment(value: dict) -> str: + return base64.urlsafe_b64encode(json.dumps(value).encode()).decode().rstrip("=") + + +def _token(signing_key, audience: str, issuer: str | None = None) -> str: + header = {"alg": "RS256", "kid": KEY_ID, "typ": "JWT"} + payload = { + "aud": audience, + "iss": issuer or f"https://securetoken.google.com/{PROJECT_ID}", + "sub": "uid123", + "iat": int(time.time()) - 10, + "exp": int(time.time()) + 3600, + "event_type": "beforeCreate", + } + signing_input = f"{_segment(header)}.{_segment(payload)}" + signature = signing_key.sign(signing_input.encode(), padding.PKCS1v15(), hashes.SHA256()) + return f"{signing_input}.{base64.urlsafe_b64encode(signature).decode().rstrip('=')}" + + +def _verify(app, token: str): + return token_verifier.AuthBlockingTokenVerifier(app).verify_auth_blocking_token(token) + + +@pytest.mark.parametrize("audience", [RUN_APP_AUDIENCE, CLOUDFUNCTIONS_AUDIENCE]) +def test_accepts_both_audience_forms(app, signing_key, audience): + assert _verify(app, _token(signing_key, audience))["uid"] == "uid123" + + +@pytest.mark.parametrize( + "audience", + [ + "https://us-east1-other-project.cloudfunctions.net/before_create", + f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create", + "https://example.com/before_create", + ], +) +def test_rejects_foreign_audience(app, signing_key, audience): + with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"aud"'): + _verify(app, _token(signing_key, audience)) + + +def test_rejects_wrong_issuer(app, signing_key): + token = _token(signing_key, RUN_APP_AUDIENCE, issuer="https://securetoken.google.com/other") + with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"iss"'): + _verify(app, token) + + +def test_rejects_token_signed_by_another_key(app): + other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with pytest.raises(token_verifier.InvalidAuthBlockingTokenError): + _verify(app, _token(other_key, CLOUDFUNCTIONS_AUDIENCE)) From 7848ad4a1525833258e29e47c042a67e533083cd Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 30 Sep 2026 14:21:20 +0100 Subject: [PATCH 2/5] test(identity): pin signature failure in wrong-key blocking token test The bare pytest.raises passed for any InvalidAuthBlockingTokenError, including an audience rejection, so it did not pin what its name says. --- tests/test_token_verifier.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_token_verifier.py b/tests/test_token_verifier.py index 32ab8817..45f02395 100644 --- a/tests/test_token_verifier.py +++ b/tests/test_token_verifier.py @@ -118,5 +118,5 @@ def test_rejects_wrong_issuer(app, signing_key): def test_rejects_token_signed_by_another_key(app): other_key = rsa.generate_private_key(public_exponent=65537, key_size=2048) - with pytest.raises(token_verifier.InvalidAuthBlockingTokenError): + with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match="signature"): _verify(app, _token(other_key, CLOUDFUNCTIONS_AUDIENCE)) From ff27308df9d43b9e3c7b6544ea21f8bf241fcff4 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 30 Sep 2026 14:23:52 +0100 Subject: [PATCH 3/5] chore(identity): address review cleanups on blocking token audiences Reject a non-string "aud" with InvalidAuthBlockingTokenError rather than letting the membership test raise TypeError, fix the class comment to name the renamed kwarg, drop a stale test comment, and declare cryptography in the dev group since the tests import it directly. --- pyproject.toml | 1 + src/firebase_functions/private/token_verifier.py | 7 +++++-- tests/test_identity_fn.py | 2 -- uv.lock | 2 ++ 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ded479a3..3fed6aac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,7 @@ dev = [ "pytest>=7.1.2,<10", "setuptools>=63.4.2", "pytest-cov>=3.0.0", + "cryptography>=3.4.0", "mypy>=1.0.0", "sphinx>=6.1.3", "sphinxcontrib-napoleon>=0.7", diff --git a/src/firebase_functions/private/token_verifier.py b/src/firebase_functions/private/token_verifier.py index fd424821..6ed514fb 100644 --- a/src/firebase_functions/private/token_verifier.py +++ b/src/firebase_functions/private/token_verifier.py @@ -34,7 +34,7 @@ # mypy: ignore-errors # TODO remove once firebase-admin supports this directly. # Modified from src/firebase_admin/_token_gen.py to add -# support for app_check tokens (expected_audience kwarg and +# support for app_check tokens (expected_audiences kwarg and # usage are new, plus None for audience on google.oauth2.id_token.verify_token call) class _JWTVerifier: """Verifies Firebase JWTs (ID tokens or session cookies).""" @@ -105,7 +105,10 @@ def verify(self, token, request): elif ( not emulated and self.expected_audiences - and not any(expected in audience for expected in self.expected_audiences) + and not ( + isinstance(audience, str) + and any(expected in audience for expected in self.expected_audiences) + ) ): expected = " or ".join(f'"{expected}"' for expected in self.expected_audiences) error_message = ( diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index ac5b7f39..19421a3d 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -21,8 +21,6 @@ "iat": 0, } ) -# Patch the reference _identity_fn holds rather than the sys.modules entry, which -# `import ... as` bypasses once anything else has imported the real module. class TestIdentity(unittest.TestCase): diff --git a/uv.lock b/uv.lock index 79483697..e537a66c 100644 --- a/uv.lock +++ b/uv.lock @@ -472,6 +472,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "build" }, + { name = "cryptography" }, { name = "google-cloud-tasks" }, { name = "mypy" }, { name = "pytest" }, @@ -502,6 +503,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "build", specifier = ">=1.0.0" }, + { name = "cryptography", specifier = ">=3.4.0" }, { name = "google-cloud-tasks", specifier = ">=2.13.1" }, { name = "mypy", specifier = ">=1.0.0" }, { name = "pytest", specifier = ">=7.1.2,<10" }, From 16fd338bdef83a96d766541c5292901c35dfd679 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Wed, 30 Sep 2026 15:20:39 +0100 Subject: [PATCH 4/5] fix(identity): match the blocking token audience on the parsed host The audience check substring-matched the token's `aud`, so it accepted a URL that merely contained the expected text: a project id ending with this one (`other-my-project` against `my-project`), or the text placed in a path, query or fragment on any host. Match the parsed host instead, with the gen-1 region pinned to its naming shape. This is deliberately stricter than firebase-admin-node, which substring-matches. The run.app form stays un-scoped to the project, as in Node, so `iss` is still what ties a token to this project. `expected_audiences` becomes `audience_matcher`, since the list was only a truthiness flag plus error text once matching moved to the host. --- .../private/token_verifier.py | 74 +++++++++++++------ tests/test_token_verifier.py | 38 +++++++++- 2 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/firebase_functions/private/token_verifier.py b/src/firebase_functions/private/token_verifier.py index 6ed514fb..23dad3af 100644 --- a/src/firebase_functions/private/token_verifier.py +++ b/src/firebase_functions/private/token_verifier.py @@ -15,6 +15,9 @@ Module for internal token verification. """ +import re +from urllib.parse import urlsplit + import google.auth.exceptions import google.oauth2.id_token import google.oauth2.service_account @@ -29,12 +32,48 @@ ) from google.auth import jwt +_CLOUD_RUN_HOST_SUFFIX = ".run.app" + + +def _blocking_audience_matcher(project_id): + """Builds the `audience_matcher` for auth blocking tokens. + + A blocking token's `aud` is the function's own URL. firebase-tools registers the + cloudfunctions.net URL when it creates a blocking function and the run.app URL + when it updates one, so either host has to be accepted. + + The gen-1 host is matched as `-.cloudfunctions.net` with the + region pinned to its naming shape, so a project id that merely ends with this one + (`other-my-project` against `my-project`) is not accepted as a suffix. Matching + the parsed host rather than a substring of the URL also keeps the expected text + from being smuggled in via a path, query or fragment. + + Deliberately stricter than firebase-admin-node, which substring-matches `aud`. + + The run.app host is not project-scoped: any Cloud Run URL satisfies it, as in the + Node SDK. The `iss` check is what ties the token to this project. + """ + cloudfunctions_host = re.compile( + rf"[a-z]+(?:-[a-z]+)*\d+-{re.escape(project_id)}\.cloudfunctions\.net" + ) + + def matches(audience): + if not isinstance(audience, str): + return False + parts = urlsplit(audience) + if parts.scheme != "https": + return False + host = parts.hostname or "" + return host.endswith(_CLOUD_RUN_HOST_SUFFIX) or bool(cloudfunctions_host.fullmatch(host)) + + return matches + # pylint: disable=consider-using-f-string # mypy: ignore-errors # TODO remove once firebase-admin supports this directly. # Modified from src/firebase_admin/_token_gen.py to add -# support for app_check tokens (expected_audiences kwarg and +# support for app_check tokens (audience_matcher kwarg and # usage are new, plus None for audience on google.oauth2.id_token.verify_token call) class _JWTVerifier: """Verifies Firebase JWTs (ID tokens or session cookies).""" @@ -46,7 +85,8 @@ def __init__(self, **kwargs): self.url = kwargs.pop("doc_url") self.cert_url = kwargs.pop("cert_url") self.issuer = kwargs.pop("issuer") - self.expected_audiences = kwargs.pop("expected_audiences") + self.audience_matcher = kwargs.pop("audience_matcher", None) + self.expected_audience_msg = kwargs.pop("expected_audience_msg", None) if self.short_name[0].lower() in "aeiou": self.articled_short_name = f"an {self.short_name}" else: @@ -102,20 +142,12 @@ def verify(self, token, request): self.short_name, header.get("alg"), verify_id_token_msg ) ) - elif ( - not emulated - and self.expected_audiences - and not ( - isinstance(audience, str) - and any(expected in audience for expected in self.expected_audiences) - ) - ): - expected = " or ".join(f'"{expected}"' for expected in self.expected_audiences) + elif not emulated and self.audience_matcher and not self.audience_matcher(audience): error_message = ( - f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected {expected} but ' + f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected {self.expected_audience_msg} but ' f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}' ) - elif not emulated and not self.expected_audiences and audience != self.project_id: + elif not emulated and not self.audience_matcher and audience != self.project_id: error_message = ( f'Firebase {self.short_name} has incorrect "aud" (audience) claim. Expected "{self.project_id}" but ' f'got "{audience}". {project_id_match_msg} {verify_id_token_msg}' @@ -144,9 +176,9 @@ def verify(self, token, request): verified_claims = google.oauth2.id_token.verify_token( token, request=request, - # If expected_audiences is set then we have already verified + # If audience_matcher is set then we have already verified # the audience above. - audience=(None if self.expected_audiences else self.project_id), + audience=(None if self.audience_matcher else self.project_id), certs_url=self.cert_url, ) verified_claims["uid"] = verified_claims["sub"] @@ -197,13 +229,11 @@ def __init__(self, app): issuer=_token_gen.ID_TOKEN_ISSUER_PREFIX, invalid_token_error=InvalidAuthBlockingTokenError, expired_token_error=ExpiredAuthBlockingTokenError, - # firebase-tools registers the cloudfunctions.net URL when it creates a - # blocking function and the run.app URL when it updates one, so a token's - # audience is either form depending on how the function was last deployed. - expected_audiences=[ - "run.app", - f"{app.project_id}.cloudfunctions.net/", - ], + audience_matcher=_blocking_audience_matcher(app.project_id), + expected_audience_msg=( + "a https://*.run.app or " + f"https://-{app.project_id}.cloudfunctions.net/ function URL" + ), ) def verify_auth_blocking_token(self, auth_blocking_token): diff --git a/tests/test_token_verifier.py b/tests/test_token_verifier.py index 45f02395..cfd1b165 100644 --- a/tests/test_token_verifier.py +++ b/tests/test_token_verifier.py @@ -92,8 +92,23 @@ def _verify(app, token: str): return token_verifier.AuthBlockingTokenVerifier(app).verify_auth_blocking_token(token) -@pytest.mark.parametrize("audience", [RUN_APP_AUDIENCE, CLOUDFUNCTIONS_AUDIENCE]) -def test_accepts_both_audience_forms(app, signing_key, audience): +@pytest.mark.parametrize( + "audience", + [ + RUN_APP_AUDIENCE, + CLOUDFUNCTIONS_AUDIENCE, + # Run hosts: the current form carries the project number, the legacy form a + # hash, and a revision tag prefixes the service with `tag---`. + "https://beforecreate-123456789.us-central1.run.app", + "https://tag---beforecreate-123456789.us-central1.run.app", + # Regions the SupportedRegion enum predates, and a hypothetical shape with + # more than one hyphen, since the region is matched by shape. + f"https://northamerica-northeast1-{PROJECT_ID}.cloudfunctions.net/before_create", + f"https://me-west1-{PROJECT_ID}.cloudfunctions.net/before_create", + f"https://us-far-west1-{PROJECT_ID}.cloudfunctions.net/before_create", + ], +) +def test_accepts_function_url_audiences(app, signing_key, audience): assert _verify(app, _token(signing_key, audience))["uid"] == "uid123" @@ -101,8 +116,18 @@ def test_accepts_both_audience_forms(app, signing_key, audience): "audience", [ "https://us-east1-other-project.cloudfunctions.net/before_create", - f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create", "https://example.com/before_create", + # A project id ending with this one must not be accepted as a suffix. + f"https://us-east1-other-{PROJECT_ID}.cloudfunctions.net/before_create", + f"https://us-east1-x1-{PROJECT_ID}.cloudfunctions.net/before_create", + f"https://us-east1-a-{PROJECT_ID}.cloudfunctions.net/before_create", + # The expected host must be the host, not text anywhere in the URL. + f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create", + f"https://example.com/{PROJECT_ID}.cloudfunctions.net/before_create", + f"https://example.com#us-east1-{PROJECT_ID}.cloudfunctions.net/", + "https://example.com/?x=run.app", + "https://run.app.example.com/before_create", + f"http://us-east1-{PROJECT_ID}.cloudfunctions.net/before_create", ], ) def test_rejects_foreign_audience(app, signing_key, audience): @@ -110,6 +135,13 @@ def test_rejects_foreign_audience(app, signing_key, audience): _verify(app, _token(signing_key, audience)) +def test_accepts_any_run_host(app, signing_key): + """The run.app form is not project-scoped; `iss` is what pins the project.""" + assert _verify(app, _token(signing_key, "https://svc-999.us-central1.run.app"))["uid"] == ( + "uid123" + ) + + def test_rejects_wrong_issuer(app, signing_key): token = _token(signing_key, RUN_APP_AUDIENCE, issuer="https://securetoken.google.com/other") with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"iss"'): From 61614c9cd2b7e7d2ca586c2eaf04fbc6468c7fd8 Mon Sep 17 00:00:00 2001 From: Izaak Gough Date: Fri, 2 Oct 2026 14:00:38 +0100 Subject: [PATCH 5/5] refactor(identity): match blocking token audiences on host suffix only iss already binds the token to the project, so aud only needs to tell blocking tokens apart from regular ID tokens. Drops the region regex. --- .../private/token_verifier.py | 46 ++++++------------- tests/test_token_verifier.py | 28 +++++------ 2 files changed, 24 insertions(+), 50 deletions(-) diff --git a/src/firebase_functions/private/token_verifier.py b/src/firebase_functions/private/token_verifier.py index 23dad3af..3b21f06a 100644 --- a/src/firebase_functions/private/token_verifier.py +++ b/src/firebase_functions/private/token_verifier.py @@ -15,7 +15,6 @@ Module for internal token verification. """ -import re from urllib.parse import urlsplit import google.auth.exceptions @@ -32,41 +31,25 @@ ) from google.auth import jwt -_CLOUD_RUN_HOST_SUFFIX = ".run.app" +_BLOCKING_HOST_SUFFIXES = (".run.app", ".cloudfunctions.net") -def _blocking_audience_matcher(project_id): - """Builds the `audience_matcher` for auth blocking tokens. +def _blocking_audience_matcher(audience): + """The `audience_matcher` for auth blocking tokens. A blocking token's `aud` is the function's own URL. firebase-tools registers the cloudfunctions.net URL when it creates a blocking function and the run.app URL when it updates one, so either host has to be accepted. - The gen-1 host is matched as `-.cloudfunctions.net` with the - region pinned to its naming shape, so a project id that merely ends with this one - (`other-my-project` against `my-project`) is not accepted as a suffix. Matching - the parsed host rather than a substring of the URL also keeps the expected text - from being smuggled in via a path, query or fragment. - - Deliberately stricter than firebase-admin-node, which substring-matches `aud`. - - The run.app host is not project-scoped: any Cloud Run URL satisfies it, as in the - Node SDK. The `iss` check is what ties the token to this project. + Neither host is project-scoped: `iss` ties the token to this project. The `aud` + check only has to separate blocking tokens from regular ID tokens, whose `aud` is + the bare project id. """ - cloudfunctions_host = re.compile( - rf"[a-z]+(?:-[a-z]+)*\d+-{re.escape(project_id)}\.cloudfunctions\.net" - ) - - def matches(audience): - if not isinstance(audience, str): - return False - parts = urlsplit(audience) - if parts.scheme != "https": - return False - host = parts.hostname or "" - return host.endswith(_CLOUD_RUN_HOST_SUFFIX) or bool(cloudfunctions_host.fullmatch(host)) - - return matches + if not isinstance(audience, str): + return False + parts = urlsplit(audience) + host = parts.hostname or "" + return parts.scheme == "https" and host.endswith(_BLOCKING_HOST_SUFFIXES) # pylint: disable=consider-using-f-string @@ -229,11 +212,8 @@ def __init__(self, app): issuer=_token_gen.ID_TOKEN_ISSUER_PREFIX, invalid_token_error=InvalidAuthBlockingTokenError, expired_token_error=ExpiredAuthBlockingTokenError, - audience_matcher=_blocking_audience_matcher(app.project_id), - expected_audience_msg=( - "a https://*.run.app or " - f"https://-{app.project_id}.cloudfunctions.net/ function URL" - ), + audience_matcher=_blocking_audience_matcher, + expected_audience_msg="a https://*.run.app or https://*.cloudfunctions.net function URL", ) def verify_auth_blocking_token(self, auth_blocking_token): diff --git a/tests/test_token_verifier.py b/tests/test_token_verifier.py index cfd1b165..f3f9d159 100644 --- a/tests/test_token_verifier.py +++ b/tests/test_token_verifier.py @@ -101,11 +101,7 @@ def _verify(app, token: str): # hash, and a revision tag prefixes the service with `tag---`. "https://beforecreate-123456789.us-central1.run.app", "https://tag---beforecreate-123456789.us-central1.run.app", - # Regions the SupportedRegion enum predates, and a hypothetical shape with - # more than one hyphen, since the region is matched by shape. f"https://northamerica-northeast1-{PROJECT_ID}.cloudfunctions.net/before_create", - f"https://me-west1-{PROJECT_ID}.cloudfunctions.net/before_create", - f"https://us-far-west1-{PROJECT_ID}.cloudfunctions.net/before_create", ], ) def test_accepts_function_url_audiences(app, signing_key, audience): @@ -115,12 +111,9 @@ def test_accepts_function_url_audiences(app, signing_key, audience): @pytest.mark.parametrize( "audience", [ - "https://us-east1-other-project.cloudfunctions.net/before_create", + # A regular Firebase ID token for the same project. + PROJECT_ID, "https://example.com/before_create", - # A project id ending with this one must not be accepted as a suffix. - f"https://us-east1-other-{PROJECT_ID}.cloudfunctions.net/before_create", - f"https://us-east1-x1-{PROJECT_ID}.cloudfunctions.net/before_create", - f"https://us-east1-a-{PROJECT_ID}.cloudfunctions.net/before_create", # The expected host must be the host, not text anywhere in the URL. f"https://us-east1-{PROJECT_ID}.cloudfunctions.net.example.com/before_create", f"https://example.com/{PROJECT_ID}.cloudfunctions.net/before_create", @@ -135,15 +128,16 @@ def test_rejects_foreign_audience(app, signing_key, audience): _verify(app, _token(signing_key, audience)) -def test_accepts_any_run_host(app, signing_key): - """The run.app form is not project-scoped; `iss` is what pins the project.""" - assert _verify(app, _token(signing_key, "https://svc-999.us-central1.run.app"))["uid"] == ( - "uid123" +@pytest.mark.parametrize( + "other_project", + ["other-project", f"other-{PROJECT_ID}", f"x1-{PROJECT_ID}", f"a-{PROJECT_ID}"], +) +def test_rejects_function_url_from_another_project(app, signing_key, other_project): + token = _token( + signing_key, + f"https://us-east1-{other_project}.cloudfunctions.net/before_create", + issuer=f"https://securetoken.google.com/{other_project}", ) - - -def test_rejects_wrong_issuer(app, signing_key): - token = _token(signing_key, RUN_APP_AUDIENCE, issuer="https://securetoken.google.com/other") with pytest.raises(token_verifier.InvalidAuthBlockingTokenError, match='"iss"'): _verify(app, token)