1515Module for internal token verification.
1616"""
1717
18+ import re
19+ from urllib .parse import urlsplit
20+
1821import google .auth .exceptions
1922import google .oauth2 .id_token
2023import google .oauth2 .service_account
2932)
3033from google .auth import jwt
3134
35+ _CLOUD_RUN_HOST_SUFFIX = ".run.app"
36+
37+
38+ def _blocking_audience_matcher (project_id ):
39+ """Builds the `audience_matcher` for auth blocking tokens.
40+
41+ A blocking token's `aud` is the function's own URL. firebase-tools registers the
42+ cloudfunctions.net URL when it creates a blocking function and the run.app URL
43+ when it updates one, so either host has to be accepted.
44+
45+ The gen-1 host is matched as `<region>-<project-id>.cloudfunctions.net` with the
46+ region pinned to its naming shape, so a project id that merely ends with this one
47+ (`other-my-project` against `my-project`) is not accepted as a suffix. Matching
48+ the parsed host rather than a substring of the URL also keeps the expected text
49+ from being smuggled in via a path, query or fragment.
50+
51+ Deliberately stricter than firebase-admin-node, which substring-matches `aud`.
52+
53+ The run.app host is not project-scoped: any Cloud Run URL satisfies it, as in the
54+ Node SDK. The `iss` check is what ties the token to this project.
55+ """
56+ cloudfunctions_host = re .compile (
57+ rf"[a-z]+(?:-[a-z]+)*\d+-{ re .escape (project_id )} \.cloudfunctions\.net"
58+ )
59+
60+ def matches (audience ):
61+ if not isinstance (audience , str ):
62+ return False
63+ parts = urlsplit (audience )
64+ if parts .scheme != "https" :
65+ return False
66+ host = parts .hostname or ""
67+ return host .endswith (_CLOUD_RUN_HOST_SUFFIX ) or bool (cloudfunctions_host .fullmatch (host ))
68+
69+ return matches
70+
3271
3372# pylint: disable=consider-using-f-string
3473# mypy: ignore-errors
3574# TODO remove once firebase-admin supports this directly.
3675# Modified from src/firebase_admin/_token_gen.py to add
37- # support for app_check tokens (expected_audiences kwarg and
76+ # support for app_check tokens (audience_matcher kwarg and
3877# usage are new, plus None for audience on google.oauth2.id_token.verify_token call)
3978class _JWTVerifier :
4079 """Verifies Firebase JWTs (ID tokens or session cookies)."""
@@ -46,7 +85,8 @@ def __init__(self, **kwargs):
4685 self .url = kwargs .pop ("doc_url" )
4786 self .cert_url = kwargs .pop ("cert_url" )
4887 self .issuer = kwargs .pop ("issuer" )
49- self .expected_audiences = kwargs .pop ("expected_audiences" )
88+ self .audience_matcher = kwargs .pop ("audience_matcher" , None )
89+ self .expected_audience_msg = kwargs .pop ("expected_audience_msg" , None )
5090 if self .short_name [0 ].lower () in "aeiou" :
5191 self .articled_short_name = f"an { self .short_name } "
5292 else :
@@ -102,20 +142,12 @@ def verify(self, token, request):
102142 self .short_name , header .get ("alg" ), verify_id_token_msg
103143 )
104144 )
105- elif (
106- not emulated
107- and self .expected_audiences
108- and not (
109- isinstance (audience , str )
110- and any (expected in audience for expected in self .expected_audiences )
111- )
112- ):
113- expected = " or " .join (f'"{ expected } "' for expected in self .expected_audiences )
145+ elif not emulated and self .audience_matcher and not self .audience_matcher (audience ):
114146 error_message = (
115- f'Firebase { self .short_name } has incorrect "aud" (audience) claim. Expected { expected } but '
147+ f'Firebase { self .short_name } has incorrect "aud" (audience) claim. Expected { self . expected_audience_msg } but '
116148 f'got "{ audience } ". { project_id_match_msg } { verify_id_token_msg } '
117149 )
118- elif not emulated and not self .expected_audiences and audience != self .project_id :
150+ elif not emulated and not self .audience_matcher and audience != self .project_id :
119151 error_message = (
120152 f'Firebase { self .short_name } has incorrect "aud" (audience) claim. Expected "{ self .project_id } " but '
121153 f'got "{ audience } ". { project_id_match_msg } { verify_id_token_msg } '
@@ -144,9 +176,9 @@ def verify(self, token, request):
144176 verified_claims = google .oauth2 .id_token .verify_token (
145177 token ,
146178 request = request ,
147- # If expected_audiences is set then we have already verified
179+ # If audience_matcher is set then we have already verified
148180 # the audience above.
149- audience = (None if self .expected_audiences else self .project_id ),
181+ audience = (None if self .audience_matcher else self .project_id ),
150182 certs_url = self .cert_url ,
151183 )
152184 verified_claims ["uid" ] = verified_claims ["sub" ]
@@ -197,13 +229,11 @@ def __init__(self, app):
197229 issuer = _token_gen .ID_TOKEN_ISSUER_PREFIX ,
198230 invalid_token_error = InvalidAuthBlockingTokenError ,
199231 expired_token_error = ExpiredAuthBlockingTokenError ,
200- # firebase-tools registers the cloudfunctions.net URL when it creates a
201- # blocking function and the run.app URL when it updates one, so a token's
202- # audience is either form depending on how the function was last deployed.
203- expected_audiences = [
204- "run.app" ,
205- f"{ app .project_id } .cloudfunctions.net/" ,
206- ],
232+ audience_matcher = _blocking_audience_matcher (app .project_id ),
233+ expected_audience_msg = (
234+ "a https://*.run.app or "
235+ f"https://<region>-{ app .project_id } .cloudfunctions.net/ function URL"
236+ ),
207237 )
208238
209239 def verify_auth_blocking_token (self , auth_blocking_token ):
0 commit comments