From f9b8ae513b209a43f233d77bc18b4cd8fdd21aff Mon Sep 17 00:00:00 2001 From: exaby73 Date: Wed, 4 Dec 2024 01:54:19 +0530 Subject: [PATCH 1/6] feat: Implement before_email_sent and before_sms_sent blocking functions --- src/firebase_functions/identity_fn.py | 153 +++++++++++++++++- src/firebase_functions/options.py | 21 ++- .../private/_identity_fn.py | 23 ++- 3 files changed, 182 insertions(+), 15 deletions(-) diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index 453707b9..f920df3c 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -191,7 +191,7 @@ class AdditionalUserInfo: The additional user info component of the auth event context. """ - provider_id: str + provider_id: str | None """The provider identifier.""" profile: dict[str, _typing.Any] | None @@ -206,6 +206,12 @@ class AdditionalUserInfo: recaptcha_score: float | None """The user's reCAPTCHA score, if available.""" + email: str | None + """The user's email, if available.""" + + phone_number: str | None + """The user's phone number, if available.""" + @_dataclasses.dataclass(frozen=True) class Credential: @@ -237,6 +243,8 @@ class Credential: sign_in_method: str """The user's sign-in method.""" +EmailType = _typing.Literal["EMAIL_SIGN_IN", "PASSWORD_RESET"] +SmsType = _typing.Literal["SIGN_IN_OR_SIGN_UP", "MULTI_FACTOR_SIGN_IN", "MULTI_FACTOR_ENROLLMENT"] @_dataclasses.dataclass(frozen=True) class AuthBlockingEvent: @@ -244,7 +252,7 @@ class AuthBlockingEvent: Defines an auth event for identitytoolkit v2 auth blocking events. """ - data: AuthUserRecord + data: AuthUserRecord | None # This is None for beforeEmailSent and beforeSmsSent events """ The UserRecord passed to auth blocking functions from the identity platform. """ @@ -280,6 +288,12 @@ class AuthBlockingEvent: credential: Credential | None """An object containing information about the user's credential.""" + email_type: EmailType | None + """The type of email event.""" + + sms_type: SmsType | None + """The type of SMS event.""" + timestamp: _dt.datetime """ The time the event was triggered.""" @@ -323,6 +337,22 @@ class BeforeSignInResponse(BeforeCreateResponse, total=False): """The user's session claims object if available.""" +class BeforeEmailSentResponse(_typing.TypedDict, total=False): + """ + The handler response type for 'before_email_sent' blocking events. + """ + + recaptcha_action_override: RecaptchaActionOptions | None + + +class BeforeSmsSentResponse(BeforeEmailSentResponse, total=False): + """ + The handler response type for 'before_sms_sent' blocking events. + """ + + recaptcha_action_override: RecaptchaActionOptions | None + + BeforeUserCreatedCallable = _typing.Callable[[AuthBlockingEvent], BeforeCreateResponse | None] """ @@ -335,6 +365,18 @@ class BeforeSignInResponse(BeforeCreateResponse, total=False): The type of the callable for 'before_user_signed_in' blocking events. """ +BeforeEmailSentCallable = _typing.Callable[[AuthBlockingEvent], + BeforeEmailSentResponse | None] +""" +The type of the callable for 'before_email_sent' blocking events. +""" + +BeforeSmsSentCallable = _typing.Callable[[AuthBlockingEvent], + BeforeSmsSentResponse | None] +""" +The type of the callable for 'before_sms_sent' blocking events. +""" + @_util.copy_func_kwargs(_options.BlockingOptions) def before_user_signed_in( @@ -442,3 +484,110 @@ def before_user_created_wrapped(request: _Request) -> _Response: return before_user_created_wrapped return before_user_created_decorator + +@_util.copy_func_kwargs(_options.BlockingOptions) +def before_email_sent( + **kwargs, +) -> _typing.Callable[[BeforeEmailSentCallable], BeforeEmailSentCallable]: + """ + Handles an event that is triggered before a user's email is sent. + + Example: + + .. code-block:: python + + from firebase_functions import identity_fn + + @identity_fn.before_email_sent() + def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeEmailSentResponse | None: + pass + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BlockingOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.identity_fn.AuthBlockingEvent` \\], + :exc:`firebase_functions.identity_fn.BeforeEmailSentResponse` \\| `None` \\] + A function that takes a AuthBlockingEvent and optionally returns BeforeEmailSentResponse. + """ + options = _options.BlockingOptions(**kwargs) + + def before_email_sent_decorator(func: BeforeEmailSentCallable): + from firebase_functions.private._identity_fn import event_type_before_email_sent + + @_functools.wraps(func) + def before_email_sent_wrapped(request: _Request) -> _Response: + from firebase_functions.private._identity_fn import before_operation_handler + return before_operation_handler( + func, + event_type_before_email_sent, + request, + ) + + _util.set_func_endpoint_attr( + before_email_sent_wrapped, + options._endpoint( + func_name=func.__name__, + event_type=event_type_before_email_sent, + ), + ) + _util.set_required_apis_attr( + before_email_sent_wrapped, + options._required_apis(), + ) + return before_email_sent_wrapped + + return before_email_sent_decorator + + +@_util.copy_func_kwargs(_options.BlockingOptions) +def before_sms_sent( + **kwargs, +) -> _typing.Callable[[BeforeSmsSentCallable], BeforeSmsSentCallable]: + """ + Handles an event that is triggered before a user's SMS is sent. + + Example: + + .. code-block:: python + + from firebase_functions import identity_fn + + @identity_fn.before_sms_sent() + def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentResponse | None: + pass + + :param \\*\\*kwargs: Options. + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BlockingOptions` + :rtype: :exc:`typing.Callable` + \\[ \\[ :exc:`firebase_functions.identity_fn.AuthBlockingEvent` \\], + :exc:`firebase_functions.identity_fn.BeforeSmsSentResponse` \\| `None` \\] + A function that takes a AuthBlockingEvent and optionally returns BeforeSmsSentResponse. + """ + options = _options.BlockingOptions(**kwargs) + + def before_sms_sent_decorator(func: BeforeSmsSentCallable): + from firebase_functions.private._identity_fn import event_type_before_sms_sent + + @_functools.wraps(func) + def before_sms_sent_wrapped(request: _Request) -> _Response: + from firebase_functions.private._identity_fn import before_operation_handler + return before_operation_handler( + func, + event_type_before_sms_sent, + request, + ) + + _util.set_func_endpoint_attr( + before_sms_sent_wrapped, + options._endpoint( + func_name=func.__name__, + event_type=event_type_before_sms_sent, + ), + ) + _util.set_required_apis_attr( + before_sms_sent_wrapped, + options._required_apis(), + ) + return before_sms_sent_wrapped + + return before_sms_sent_decorator \ No newline at end of file diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 2f7db7da..65a0aebd 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -994,17 +994,24 @@ def _endpoint( self, **kwargs, ) -> _manifest.ManifestEndpoint: + from firebase_functions.private._identity_fn import event_type_before_create, event_type_before_sign_in + assert kwargs["event_type"] is not None + blocking_trigger_options: _manifest.BlockingTriggerOptions + + if kwargs["event_type"] == event_type_before_create or kwargs["event_type"] == event_type_before_sign_in: + blocking_trigger_options = _manifest.BlockingTriggerOptions( + idToken=self.id_token if self.id_token is not None else False, + accessToken=self.access_token if self.access_token is not None else False, + refreshToken=self.refresh_token if self.refresh_token is not None else False, + ) + else: + blocking_trigger_options = _manifest.BlockingTriggerOptions() + blocking_trigger = _manifest.BlockingTrigger( eventType=kwargs["event_type"], - options=_manifest.BlockingTriggerOptions( - idToken=self.id_token if self.id_token is not None else False, - accessToken=self.access_token - if self.access_token is not None else False, - refreshToken=self.refresh_token - if self.refresh_token is not None else False, - ), + options=blocking_trigger_options, ) kwargs_merged = { diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 2a8f516f..3130d342 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -167,6 +167,8 @@ def _additional_user_info_from_token_data(token_data: dict[str, _typing.Any]): username=username, is_new_user=is_new_user, recaptcha_score=token_data.get("recaptcha_score"), + email=token_data.get("email"), + phone_number=token_data.get("phone_number"), ) @@ -200,10 +202,15 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], ) -def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any]): - from firebase_functions.identity_fn import AuthBlockingEvent +def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any], event_type: str): + from firebase_functions.identity_fn import AuthBlockingEvent, AuthUserRecord + + data: AuthUserRecord | None = None + if event_type == event_type_before_create or event_type == event_type_before_sign_in: + data = _auth_user_record_from_token_data(token_data["user_record"]) + return AuthBlockingEvent( - data=_auth_user_record_from_token_data(token_data["user_record"]), + data=data, locale=token_data.get("locale"), event_id=token_data["event_id"], ip_address=token_data["ip_address"], @@ -211,11 +218,15 @@ def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any]): timestamp=_dt.datetime.fromtimestamp(token_data["iat"]), additional_user_info=_additional_user_info_from_token_data(token_data), credential=_credential_from_token_data(token_data, _time.time()), + email_type=token_data.get("email_type"), + sms_type=token_data.get("sms_type"), ) event_type_before_create = "providers/cloud.auth/eventTypes/user.beforeCreate" event_type_before_sign_in = "providers/cloud.auth/eventTypes/user.beforeSignIn" +event_type_before_email_sent = "providers/cloud.auth/eventTypes/user.beforeSendEmail" +event_type_before_sms_sent = "providers/cloud.auth/eventTypes/user.beforeSendSms" def _validate_auth_response( @@ -338,7 +349,7 @@ def before_operation_handler( event_type: str, request: _Request, ) -> _Response: - from firebase_functions.identity_fn import BeforeCreateResponse, BeforeSignInResponse + from firebase_functions.identity_fn import BeforeCreateResponse, BeforeSignInResponse, BeforeEmailSentResponse, BeforeSmsSentResponse try: if not _util.valid_on_call_request(request): _logging.error("Invalid request, unable to process.") @@ -351,8 +362,8 @@ def before_operation_handler( raise HttpsError(FunctionsErrorCode.INVALID_ARGUMENT, "Bad Request") jwt_token = request.json["data"]["jwt"] decoded_token = _token_verifier.verify_auth_blocking_token(jwt_token) - event = _auth_blocking_event_from_token_data(decoded_token) - auth_response: BeforeCreateResponse | BeforeSignInResponse | None = _with_init( + event = _auth_blocking_event_from_token_data(decoded_token, event_type) + auth_response: BeforeCreateResponse | BeforeSignInResponse | BeforeEmailSentResponse | BeforeSmsSentResponse | None = _with_init( func)(event) if not auth_response: return _jsonify({}) From 6ba3a046d1cf5e862f0f0b7535864fd613628490 Mon Sep 17 00:00:00 2001 From: exaby73 Date: Thu, 5 Dec 2024 15:43:15 +0530 Subject: [PATCH 2/6] feat: Add tests for email and sms blocking triggers, format code, solve cyclic import issue with event types --- samples/identity/functions/main.py | 4 +- src/firebase_functions/firestore_fn.py | 7 +- src/firebase_functions/identity_fn.py | 25 +++-- src/firebase_functions/options.py | 13 ++- .../private/_identity_fn.py | 16 ++- .../private/_identity_fn_event_types.py | 10 ++ tests/test_identity_fn.py | 101 ++++++++++++++++-- 7 files changed, 141 insertions(+), 35 deletions(-) create mode 100644 src/firebase_functions/private/_identity_fn_event_types.py diff --git a/samples/identity/functions/main.py b/samples/identity/functions/main.py index 9fa44f15..79f99415 100644 --- a/samples/identity/functions/main.py +++ b/samples/identity/functions/main.py @@ -11,7 +11,7 @@ def beforeusercreated( event: identity_fn.AuthBlockingEvent ) -> identity_fn.BeforeCreateResponse | None: print(event) - if not event.data.email: + if not event.data or not event.data.email: return None if "@cats.com" in event.data.email: return identity_fn.BeforeCreateResponse(display_name="Meow!",) @@ -29,7 +29,7 @@ def beforeusersignedin( event: identity_fn.AuthBlockingEvent ) -> identity_fn.BeforeSignInResponse | None: print(event) - if not event.data.email: + if not event.data or not event.data.email: return None if "@cats.com" in event.data.email: diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index a9d4f2a6..3aacf68b 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -218,8 +218,11 @@ def _firestore_endpoint_handler( auth_id=event_auth_id) func(database_event_with_auth_context) else: - # mypy cannot infer that the event type is correct, hence the cast - _typing.cast(_C1 | _C2, func)(database_event) + # Split the casting into two separate branches based on event type + if event_type in (_event_type_written, _event_type_updated): + _typing.cast(_C1, func)(_typing.cast(_E1, database_event)) + else: + _typing.cast(_C2, func)(_typing.cast(_E2, database_event)) @_util.copy_func_kwargs(FirestoreOptions) diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index f920df3c..eaa70fcb 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -243,8 +243,11 @@ class Credential: sign_in_method: str """The user's sign-in method.""" + EmailType = _typing.Literal["EMAIL_SIGN_IN", "PASSWORD_RESET"] -SmsType = _typing.Literal["SIGN_IN_OR_SIGN_UP", "MULTI_FACTOR_SIGN_IN", "MULTI_FACTOR_ENROLLMENT"] +SmsType = _typing.Literal["SIGN_IN_OR_SIGN_UP", "MULTI_FACTOR_SIGN_IN", + "MULTI_FACTOR_ENROLLMENT"] + @_dataclasses.dataclass(frozen=True) class AuthBlockingEvent: @@ -252,7 +255,7 @@ class AuthBlockingEvent: Defines an auth event for identitytoolkit v2 auth blocking events. """ - data: AuthUserRecord | None # This is None for beforeEmailSent and beforeSmsSent events + data: AuthUserRecord | None # This is None for beforeEmailSent and beforeSmsSent events """ The UserRecord passed to auth blocking functions from the identity platform. """ @@ -345,7 +348,7 @@ class BeforeEmailSentResponse(_typing.TypedDict, total=False): recaptcha_action_override: RecaptchaActionOptions | None -class BeforeSmsSentResponse(BeforeEmailSentResponse, total=False): +class BeforeSmsSentResponse(_typing.TypedDict, total=False): """ The handler response type for 'before_sms_sent' blocking events. """ @@ -372,7 +375,7 @@ class BeforeSmsSentResponse(BeforeEmailSentResponse, total=False): """ BeforeSmsSentCallable = _typing.Callable[[AuthBlockingEvent], - BeforeSmsSentResponse | None] + BeforeSmsSentResponse | None] """ The type of the callable for 'before_sms_sent' blocking events. """ @@ -485,6 +488,7 @@ def before_user_created_wrapped(request: _Request) -> _Response: return before_user_created_decorator + @_util.copy_func_kwargs(_options.BlockingOptions) def before_email_sent( **kwargs, @@ -499,7 +503,9 @@ def before_email_sent( from firebase_functions import identity_fn @identity_fn.before_email_sent() - def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeEmailSentResponse | None: + def example( + event: identity_fn.AuthBlockingEvent + ) -> identity_fn.BeforeEmailSentResponse | None: pass :param \\*\\*kwargs: Options. @@ -507,12 +513,13 @@ def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeEmailSent :rtype: :exc:`typing.Callable` \\[ \\[ :exc:`firebase_functions.identity_fn.AuthBlockingEvent` \\], :exc:`firebase_functions.identity_fn.BeforeEmailSentResponse` \\| `None` \\] - A function that takes a AuthBlockingEvent and optionally returns BeforeEmailSentResponse. + A function that takes a AuthBlockingEvent and optionally returns + BeforeEmailSentResponse. """ options = _options.BlockingOptions(**kwargs) def before_email_sent_decorator(func: BeforeEmailSentCallable): - from firebase_functions.private._identity_fn import event_type_before_email_sent + from firebase_functions.private._identity_fn_event_types import event_type_before_email_sent @_functools.wraps(func) def before_email_sent_wrapped(request: _Request) -> _Response: @@ -566,7 +573,7 @@ def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentRe options = _options.BlockingOptions(**kwargs) def before_sms_sent_decorator(func: BeforeSmsSentCallable): - from firebase_functions.private._identity_fn import event_type_before_sms_sent + from firebase_functions.private._identity_fn_event_types import event_type_before_sms_sent @_functools.wraps(func) def before_sms_sent_wrapped(request: _Request) -> _Response: @@ -590,4 +597,4 @@ def before_sms_sent_wrapped(request: _Request) -> _Response: ) return before_sms_sent_wrapped - return before_sms_sent_decorator \ No newline at end of file + return before_sms_sent_decorator diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 65a0aebd..86490ffc 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -994,17 +994,20 @@ def _endpoint( self, **kwargs, ) -> _manifest.ManifestEndpoint: - from firebase_functions.private._identity_fn import event_type_before_create, event_type_before_sign_in + from firebase_functions.private._identity_fn_event_types import event_type_before_create, event_type_before_sign_in assert kwargs["event_type"] is not None blocking_trigger_options: _manifest.BlockingTriggerOptions - - if kwargs["event_type"] == event_type_before_create or kwargs["event_type"] == event_type_before_sign_in: + + if kwargs["event_type"] == event_type_before_create or kwargs[ + "event_type"] == event_type_before_sign_in: blocking_trigger_options = _manifest.BlockingTriggerOptions( idToken=self.id_token if self.id_token is not None else False, - accessToken=self.access_token if self.access_token is not None else False, - refreshToken=self.refresh_token if self.refresh_token is not None else False, + accessToken=self.access_token + if self.access_token is not None else False, + refreshToken=self.refresh_token + if self.refresh_token is not None else False, ) else: blocking_trigger_options = _manifest.BlockingTriggerOptions() diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 3130d342..7dedc1ee 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -20,6 +20,7 @@ from firebase_functions.core import _with_init from firebase_functions.https_fn import HttpsError, FunctionsErrorCode +from firebase_functions.private._identity_fn_event_types import event_type_before_create, event_type_before_sign_in import firebase_functions.private.util as _util import firebase_functions.private.token_verifier as _token_verifier @@ -202,11 +203,12 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], ) -def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any], event_type: str): +def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any], + event_type: str): from firebase_functions.identity_fn import AuthBlockingEvent, AuthUserRecord data: AuthUserRecord | None = None - if event_type == event_type_before_create or event_type == event_type_before_sign_in: + if event_type in (event_type_before_create, event_type_before_sign_in): data = _auth_user_record_from_token_data(token_data["user_record"]) return AuthBlockingEvent( @@ -223,12 +225,6 @@ def _auth_blocking_event_from_token_data(token_data: dict[str, _typing.Any], eve ) -event_type_before_create = "providers/cloud.auth/eventTypes/user.beforeCreate" -event_type_before_sign_in = "providers/cloud.auth/eventTypes/user.beforeSignIn" -event_type_before_email_sent = "providers/cloud.auth/eventTypes/user.beforeSendEmail" -event_type_before_sms_sent = "providers/cloud.auth/eventTypes/user.beforeSendSms" - - def _validate_auth_response( event_type: str, auth_response, @@ -363,8 +359,8 @@ def before_operation_handler( jwt_token = request.json["data"]["jwt"] decoded_token = _token_verifier.verify_auth_blocking_token(jwt_token) event = _auth_blocking_event_from_token_data(decoded_token, event_type) - auth_response: BeforeCreateResponse | BeforeSignInResponse | BeforeEmailSentResponse | BeforeSmsSentResponse | None = _with_init( - func)(event) + auth_response: BeforeCreateResponse | BeforeSignInResponse | BeforeEmailSentResponse | \ + BeforeSmsSentResponse | None = _with_init(func)(event) if not auth_response: return _jsonify({}) auth_response_dict = _validate_auth_response(event_type, auth_response) diff --git a/src/firebase_functions/private/_identity_fn_event_types.py b/src/firebase_functions/private/_identity_fn_event_types.py new file mode 100644 index 00000000..b5117d1e --- /dev/null +++ b/src/firebase_functions/private/_identity_fn_event_types.py @@ -0,0 +1,10 @@ +""" +Identity function event types. +""" + +# We need to import these from the identity_fn module, but due to circular import +# issues, we need to define them here. +event_type_before_create = "providers/cloud.auth/eventTypes/user.beforeCreate" +event_type_before_sign_in = "providers/cloud.auth/eventTypes/user.beforeSignIn" +event_type_before_email_sent = "providers/cloud.auth/eventTypes/user.beforeSendEmail" +event_type_before_sms_sent = "providers/cloud.auth/eventTypes/user.beforeSendSms" diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index b71414bc..0c3dffc5 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -7,8 +7,6 @@ from flask import Flask, Request from werkzeug.test import EnvironBuilder -from firebase_functions import core, identity_fn - token_verifier_mock = MagicMock() token_verifier_mock.verify_auth_blocking_token = Mock( return_value={ @@ -24,8 +22,14 @@ "user_agent": "user_agent", "iat": 0 }) + +firebase_admin_mock = MagicMock() +firebase_admin_mock.initialize_app = Mock() +firebase_admin_mock.get_app = Mock() + mocked_modules = { "firebase_functions.private.token_verifier": token_verifier_mock, + "firebase_admin": firebase_admin_mock } @@ -37,12 +41,14 @@ class TestIdentity(unittest.TestCase): def test_calls_init_function(self): hello = None - @core.init - def init(): - nonlocal hello - hello = "world" - with patch.dict("sys.modules", mocked_modules): + from firebase_functions import core, identity_fn + + @core.init + def init(): + nonlocal hello + hello = "world" + app = Flask(__name__) func = Mock(__name__="example_func", @@ -62,3 +68,84 @@ def init(): decorated_func(request) self.assertEqual("world", hello) + + def test_auth_blocking_event_from_token_data_email(self): + """Test parsing a beforeSendEmail event.""" + # Mock token data for email event + token_data = { + "iss": "https://securetoken.google.com/project_id", + "aud": "https://us-east1-project_id.cloudfunctions.net/function-1", + "iat": 1, # Unix timestamp + "exp": 60 * 60 + 1, + "event_id": "EVENT_ID", + "event_type": "beforeSendEmail", + "user_agent": "USER_AGENT", + "ip_address": "1.2.3.4", + "locale": "en", + "recaptcha_score": 0.9, + "email_type": "PASSWORD_RESET", + "email": "johndoe@gmail.com" + } + + with patch.dict("sys.modules", mocked_modules): + from firebase_functions.private._identity_fn import _auth_blocking_event_from_token_data + from firebase_functions.private._identity_fn_event_types import event_type_before_email_sent + import datetime + + event = _auth_blocking_event_from_token_data( + token_data, event_type_before_email_sent) + + self.assertEqual(event.event_id, "EVENT_ID") + self.assertEqual(event.ip_address, "1.2.3.4") + self.assertEqual(event.user_agent, "USER_AGENT") + self.assertEqual(event.locale, "en") + self.assertEqual(event.email_type, "PASSWORD_RESET") + self.assertEqual(event.sms_type, None) + self.assertEqual(event.data, None) # No user record for email events + self.assertEqual(event.timestamp, datetime.datetime.fromtimestamp(1)) + + self.assertEqual(event.additional_user_info.email, "johndoe@gmail.com") + self.assertEqual(event.additional_user_info.recaptcha_score, 0.9) + self.assertEqual(event.additional_user_info.is_new_user, False) + self.assertEqual(event.additional_user_info.phone_number, None) + + def test_auth_blocking_event_from_token_data_sms(self): + """Test parsing a beforeSendSms event.""" + import datetime + + token_data = { + "iss": "https://securetoken.google.com/project_id", + "aud": "https://us-east1-project_id.cloudfunctions.net/function-1", + "iat": 1, # Unix timestamp + "exp": 60 * 60 + 1, + "event_id": "EVENT_ID", + "event_type": "beforeSendSms", + "user_agent": "USER_AGENT", + "ip_address": "1.2.3.4", + "locale": "en", + "recaptcha_score": 0.9, + "sms_type": "SIGN_IN_OR_SIGN_UP", + "phone_number": "+11234567890" + } + + with patch.dict("sys.modules", mocked_modules): + from firebase_functions.private._identity_fn import _auth_blocking_event_from_token_data + from firebase_functions.private._identity_fn_event_types import event_type_before_sms_sent + + event = _auth_blocking_event_from_token_data( + token_data, event_type_before_sms_sent) + + self.assertEqual(event.event_id, "EVENT_ID") + self.assertEqual(event.ip_address, "1.2.3.4") + self.assertEqual(event.user_agent, "USER_AGENT") + self.assertEqual(event.locale, "en") + self.assertEqual(event.email_type, None) + self.assertEqual(event.sms_type, "SIGN_IN_OR_SIGN_UP") + self.assertEqual(event.data, None) # No user record for SMS events + self.assertEqual(event.timestamp, datetime.datetime.fromtimestamp(1)) + + self.assertEqual(event.additional_user_info.phone_number, + "+11234567890") + self.assertEqual(event.additional_user_info.recaptcha_score, 0.9) + self.assertEqual(event.additional_user_info.is_new_user, False) + self.assertEqual(event.additional_user_info.email, None) From 12f51f70b186f209079bbbb084e5e3b02fec8d44 Mon Sep 17 00:00:00 2001 From: exaby73 Date: Thu, 5 Dec 2024 16:43:53 +0530 Subject: [PATCH 3/6] feat: Make BaseBlockingOptions and use it with new email and sms triggers --- samples/identity/functions/main.py | 14 +++++++ samples/identity/functions/requirements.txt | 4 +- src/firebase_functions/identity_fn.py | 12 +++--- src/firebase_functions/options.py | 42 ++++++++++++--------- 4 files changed, 47 insertions(+), 25 deletions(-) diff --git a/samples/identity/functions/main.py b/samples/identity/functions/main.py index 79f99415..c8bd98fa 100644 --- a/samples/identity/functions/main.py +++ b/samples/identity/functions/main.py @@ -39,3 +39,17 @@ def beforeusersignedin( return identity_fn.BeforeSignInResponse(session_claims={"emoji": "🐕"}) return None + + +@identity_fn.before_email_sent() +def beforeemailsent( + event: identity_fn.AuthBlockingEvent +) -> identity_fn.BeforeEmailSentResponse | None: + print(event) + return None + + +@identity_fn.before_sms_sent() +def beforesmssent(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentResponse | None: + print(event) + return None diff --git a/samples/identity/functions/requirements.txt b/samples/identity/functions/requirements.txt index 8977a411..2b5981d9 100644 --- a/samples/identity/functions/requirements.txt +++ b/samples/identity/functions/requirements.txt @@ -1,8 +1,8 @@ # Not published yet, # firebase-functions-python >= 0.0.1 # so we use a relative path during development: -./../../../ +# ./../../../ # Or switch to git ref for deployment testing: -# git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions +git+https://github.com/firebase/firebase-functions-python.git@feat/before_email_sms_sent_blocking_fn#egg=firebase-functions firebase-admin >= 6.0.1 diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index eaa70fcb..2bbd7c10 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -489,7 +489,7 @@ def before_user_created_wrapped(request: _Request) -> _Response: return before_user_created_decorator -@_util.copy_func_kwargs(_options.BlockingOptions) +@_util.copy_func_kwargs(_options.BaseBlockingOptions) def before_email_sent( **kwargs, ) -> _typing.Callable[[BeforeEmailSentCallable], BeforeEmailSentCallable]: @@ -509,14 +509,14 @@ def example( pass :param \\*\\*kwargs: Options. - :type \\*\\*kwargs: as :exc:`firebase_functions.options.BlockingOptions` + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BaseBlockingOptions` :rtype: :exc:`typing.Callable` \\[ \\[ :exc:`firebase_functions.identity_fn.AuthBlockingEvent` \\], :exc:`firebase_functions.identity_fn.BeforeEmailSentResponse` \\| `None` \\] A function that takes a AuthBlockingEvent and optionally returns BeforeEmailSentResponse. """ - options = _options.BlockingOptions(**kwargs) + options = _options.BaseBlockingOptions(**kwargs) def before_email_sent_decorator(func: BeforeEmailSentCallable): from firebase_functions.private._identity_fn_event_types import event_type_before_email_sent @@ -546,7 +546,7 @@ def before_email_sent_wrapped(request: _Request) -> _Response: return before_email_sent_decorator -@_util.copy_func_kwargs(_options.BlockingOptions) +@_util.copy_func_kwargs(_options.BaseBlockingOptions) def before_sms_sent( **kwargs, ) -> _typing.Callable[[BeforeSmsSentCallable], BeforeSmsSentCallable]: @@ -564,13 +564,13 @@ def example(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentRe pass :param \\*\\*kwargs: Options. - :type \\*\\*kwargs: as :exc:`firebase_functions.options.BlockingOptions` + :type \\*\\*kwargs: as :exc:`firebase_functions.options.BaseBlockingOptions` :rtype: :exc:`typing.Callable` \\[ \\[ :exc:`firebase_functions.identity_fn.AuthBlockingEvent` \\], :exc:`firebase_functions.identity_fn.BeforeSmsSentResponse` \\| `None` \\] A function that takes a AuthBlockingEvent and optionally returns BeforeSmsSentResponse. """ - options = _options.BlockingOptions(**kwargs) + options = _options.BaseBlockingOptions(**kwargs) def before_sms_sent_decorator(func: BeforeSmsSentCallable): from firebase_functions.private._identity_fn_event_types import event_type_before_sms_sent diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 86490ffc..72963753 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -969,27 +969,12 @@ def _endpoint( @_dataclasses.dataclass(frozen=True, kw_only=True) -class BlockingOptions(RuntimeOptions): +class BaseBlockingOptions(RuntimeOptions): """ - Options that can be set on an Auth Blocking trigger. + Base class for options that can be set on an Auth Blocking trigger. Internal use only. """ - id_token: bool | None = None - """ - Pass the ID Token credential to the function. - """ - - access_token: bool | None = None - """ - Pass the access token credential to the function. - """ - - refresh_token: bool | None = None - """ - Pass the refresh token credential to the function. - """ - def _endpoint( self, **kwargs, @@ -1034,6 +1019,29 @@ def _required_apis(self) -> list[_manifest.ManifestRequiredApi]: ] +@_dataclasses.dataclass(frozen=True, kw_only=True) +class BlockingOptions(BaseBlockingOptions): + """ + Options that can be set on an Auth Blocking trigger. + Internal use only. + """ + + id_token: bool | None = None + """ + Pass the ID Token credential to the function. + """ + + access_token: bool | None = None + """ + Pass the access token credential to the function. + """ + + refresh_token: bool | None = None + """ + Pass the refresh token credential to the function. + """ + + @_dataclasses.dataclass(frozen=True, kw_only=True) class FirestoreOptions(RuntimeOptions): """ From 2a5a25b9351e47669c08840b7f6083d1b11a4ad6 Mon Sep 17 00:00:00 2001 From: exaby73 Date: Thu, 5 Dec 2024 17:59:46 +0530 Subject: [PATCH 4/6] fix: Formatting and linting errors --- samples/identity/functions/main.py | 2 ++ src/firebase_functions/options.py | 12 +++++++----- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/samples/identity/functions/main.py b/samples/identity/functions/main.py index c8bd98fa..be981f91 100644 --- a/samples/identity/functions/main.py +++ b/samples/identity/functions/main.py @@ -42,6 +42,7 @@ def beforeusersignedin( @identity_fn.before_email_sent() +# pylint: disable=useless-return def beforeemailsent( event: identity_fn.AuthBlockingEvent ) -> identity_fn.BeforeEmailSentResponse | None: @@ -50,6 +51,7 @@ def beforeemailsent( @identity_fn.before_sms_sent() +# pylint: disable=useless-return def beforesmssent(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentResponse | None: print(event) return None diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index 72963753..0bdfaa0b 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -987,12 +987,14 @@ def _endpoint( if kwargs["event_type"] == event_type_before_create or kwargs[ "event_type"] == event_type_before_sign_in: + options = _typing.cast(BlockingOptions, self) blocking_trigger_options = _manifest.BlockingTriggerOptions( - idToken=self.id_token if self.id_token is not None else False, - accessToken=self.access_token - if self.access_token is not None else False, - refreshToken=self.refresh_token - if self.refresh_token is not None else False, + idToken=options.id_token + if options.id_token is not None else False, + accessToken=options.access_token + if options.access_token is not None else False, + refreshToken=options.refresh_token + if options.refresh_token is not None else False, ) else: blocking_trigger_options = _manifest.BlockingTriggerOptions() From 4bbcde9b360c292a300fef24f984ae3f08c883e5 Mon Sep 17 00:00:00 2001 From: exaby73 Date: Thu, 5 Dec 2024 18:01:42 +0530 Subject: [PATCH 5/6] fix: Formatting in sample --- samples/identity/functions/main.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/samples/identity/functions/main.py b/samples/identity/functions/main.py index be981f91..f84b2eeb 100644 --- a/samples/identity/functions/main.py +++ b/samples/identity/functions/main.py @@ -52,6 +52,8 @@ def beforeemailsent( @identity_fn.before_sms_sent() # pylint: disable=useless-return -def beforesmssent(event: identity_fn.AuthBlockingEvent) -> identity_fn.BeforeSmsSentResponse | None: +def beforesmssent( + event: identity_fn.AuthBlockingEvent +) -> identity_fn.BeforeSmsSentResponse | None: print(event) return None From 3ad297ea564338bdba9199e862913c0b3d64e073 Mon Sep 17 00:00:00 2001 From: exaby73 Date: Thu, 5 Dec 2024 22:33:38 +0530 Subject: [PATCH 6/6] fix(sample): Comment git url in requirement.txt --- samples/identity/functions/requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/identity/functions/requirements.txt b/samples/identity/functions/requirements.txt index 2b5981d9..8977a411 100644 --- a/samples/identity/functions/requirements.txt +++ b/samples/identity/functions/requirements.txt @@ -1,8 +1,8 @@ # Not published yet, # firebase-functions-python >= 0.0.1 # so we use a relative path during development: -# ./../../../ +./../../../ # Or switch to git ref for deployment testing: -git+https://github.com/firebase/firebase-functions-python.git@feat/before_email_sms_sent_blocking_fn#egg=firebase-functions +# git+https://github.com/firebase/firebase-functions-python.git@main#egg=firebase-functions firebase-admin >= 6.0.1