From 5f7fc5e63f2cb8b413f8f6f52bcc3ea19ada9550 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Mon, 26 Oct 2026 20:19:47 +0000 Subject: [PATCH 1/4] fix(identity)!: emit timezone-aware UTC datetimes for Auth blocking events BREAKING CHANGE: Auth blocking functions (before_user_created and before_user_signed_in) now emit timezone-aware UTC datetimes (tzinfo=timezone.utc) instead of offset-naive datetimes for all timestamp fields (AuthBlockingEvent.timestamp, AuthUserMetadata.creation_time, AuthUserMetadata.last_sign_in_time, AuthUserRecord.tokens_valid_after_time, AuthMultiFactorInfo.enrollment_time, and Credential.expiration_time), matching 2nd-Gen Auth triggers and the rest of the SDK. --- .../private/_identity_fn.py | 20 +++-- tests/test_identity_fn.py | 77 +++++++++++++++++++ 2 files changed, 90 insertions(+), 7 deletions(-) diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 6054c93..16f5490 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -77,11 +77,13 @@ def _auth_user_info_from_token_data(token_data: dict[str, _typing.Any]): def _auth_user_metadata_from_token_data(token_data: dict[str, _typing.Any]): from firebase_functions.identity_fn import AuthUserMetadata - creation_time = _dt.datetime.utcfromtimestamp(int(token_data["creation_time"]) / 1000.0) + creation_time = _dt.datetime.fromtimestamp( + int(token_data["creation_time"]) / 1000.0, tz=_dt.timezone.utc + ) last_sign_in_time = None if "last_sign_in_time" in token_data: - last_sign_in_time = _dt.datetime.utcfromtimestamp( - int(token_data["last_sign_in_time"]) / 1000.0 + last_sign_in_time = _dt.datetime.fromtimestamp( + int(token_data["last_sign_in_time"]) / 1000.0, tz=_dt.timezone.utc ) return AuthUserMetadata(creation_time=creation_time, last_sign_in_time=last_sign_in_time) @@ -92,7 +94,7 @@ def _auth_multi_factor_info_from_token_data(token_data: dict[str, _typing.Any]): enrollment_time = token_data.get("enrollment_time") if enrollment_time: - enrollment_time = _dt.datetime.fromisoformat(enrollment_time) + enrollment_time = _util.timestamp_conversion(enrollment_time) factor_id = token_data["factor_id"] if not token_data.get("phone_number") else "phone" return AuthMultiFactorInfo( uid=token_data["uid"], @@ -139,7 +141,9 @@ def _auth_user_record_from_token_data(token_data: dict[str, _typing.Any]): password_salt=token_data.get("password_salt"), custom_claims=token_data.get("custom_claims"), tenant_id=token_data.get("tenant_id"), - tokens_valid_after_time=_dt.datetime.utcfromtimestamp(token_data["tokens_valid_after_time"]) + tokens_valid_after_time=_dt.datetime.fromtimestamp( + token_data["tokens_valid_after_time"], tz=_dt.timezone.utc + ) if token_data.get("tokens_valid_after_time") else None, multi_factor=_auth_multi_factor_settings_from_token_data(token_data["multi_factor"]) @@ -196,7 +200,9 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], time: float) oauth_expires_in = token_data.get("oauth_expires_in") expiration_time = ( - _dt.datetime.utcfromtimestamp(time + oauth_expires_in) if oauth_expires_in else None + _dt.datetime.fromtimestamp(time + oauth_expires_in, tz=_dt.timezone.utc) + if oauth_expires_in + else None ) provider_id: str = ( @@ -227,7 +233,7 @@ def _auth_blocking_event_from_token_data(event_type: str, token_data: dict[str, event_id=token_data["event_id"], ip_address=token_data["ip_address"], user_agent=token_data["user_agent"], - timestamp=_dt.datetime.fromtimestamp(token_data["iat"]), + timestamp=_dt.datetime.fromtimestamp(token_data["iat"], tz=_dt.timezone.utc), 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"), diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 09ea834..72fc90e 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -454,3 +454,80 @@ def example_deleted(event: identity_fn.AuthEvent) -> None: @identity_fn.on_user_created(tenant_id=options.RESET_VALUE) # type: ignore[arg-type] def _invalid_sentinel(_event: identity_fn.AuthEvent) -> None: pass + + def test_blocking_event_timestamps_are_timezone_aware_utc(self) -> None: + captured_event: identity_fn.AuthBlockingEvent | None = None + + @identity_fn.before_user_signed_in() + def example( + event: identity_fn.AuthBlockingEvent, + ) -> identity_fn.BeforeSignInResponse | None: + nonlocal captured_event + captured_event = event + return None + + mock_verifier = MagicMock() + mock_verifier.verify_auth_blocking_token = Mock( + return_value={ + "user_record": { + "uid": "user-123", + "metadata": { + "creation_time": 1672531200000, + "last_sign_in_time": 1672662600000, + }, + "provider_data": [], + "tokens_valid_after_time": 1672531200, + "multi_factor": { + "enrolled_factors": [ + { + "uid": "mfa-1", + "factor_id": "phone", + "enrollment_time": "2023-01-01T00:00:00.000Z", + "phone_number": "+15555550100", + } + ] + }, + }, + "event_id": "event-id", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "iat": 1672531200, + "sign_in_method": "google.com", + "oauth_access_token": "token-abc", + "oauth_expires_in": 3600, + } + ) + + with ( + patch.object(_identity_fn, "_token_verifier", mock_verifier), + patch.object(_identity_fn._time, "time", return_value=1672531200.0), + ): + app = Flask(__name__) + with app.test_request_context("/"): + environ = EnvironBuilder( + method="POST", + json={"data": {"jwt": "jwt"}}, + ).get_environ() + request = Request(environ) + example(request) # type: ignore[arg-type] + + self.assertIsNotNone(captured_event) + assert captured_event is not None + expected_epoch = dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc) + self.assertEqual(captured_event.timestamp, expected_epoch) + self.assertEqual(captured_event.data.metadata.creation_time, expected_epoch) + self.assertEqual( + captured_event.data.metadata.last_sign_in_time, + dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc), + ) + self.assertEqual(captured_event.data.tokens_valid_after_time, expected_epoch) + assert captured_event.data.multi_factor is not None + self.assertEqual( + captured_event.data.multi_factor.enrolled_factors[0].enrollment_time, + expected_epoch, + ) + assert captured_event.credential is not None + self.assertEqual( + captured_event.credential.expiration_time, + dt.datetime(2023, 1, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + ) From ea8012779a7da780e8f03228cbb325417d8d0593 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Mon, 26 Oct 2026 22:44:40 +0000 Subject: [PATCH 2/4] fix(identity): guard against None metadata timestamps in blocking token data --- src/firebase_functions/private/_identity_fn.py | 10 ++++++---- tests/test_identity_fn.py | 6 ++++++ 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 16f5490..6d96d7e 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -77,11 +77,13 @@ def _auth_user_info_from_token_data(token_data: dict[str, _typing.Any]): def _auth_user_metadata_from_token_data(token_data: dict[str, _typing.Any]): from firebase_functions.identity_fn import AuthUserMetadata - creation_time = _dt.datetime.fromtimestamp( - int(token_data["creation_time"]) / 1000.0, tz=_dt.timezone.utc - ) + creation_time = None + if token_data.get("creation_time") is not None: + creation_time = _dt.datetime.fromtimestamp( + int(token_data["creation_time"]) / 1000.0, tz=_dt.timezone.utc + ) last_sign_in_time = None - if "last_sign_in_time" in token_data: + if token_data.get("last_sign_in_time") is not None: last_sign_in_time = _dt.datetime.fromtimestamp( int(token_data["last_sign_in_time"]) / 1000.0, tz=_dt.timezone.utc ) diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 72fc90e..e54680b 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -531,3 +531,9 @@ def example( captured_event.credential.expiration_time, dt.datetime(2023, 1, 1, 1, 0, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual( + _identity_fn._auth_user_metadata_from_token_data( + {"creation_time": None, "last_sign_in_time": None} + ), + identity_fn.AuthUserMetadata(creation_time=None, last_sign_in_time=None), + ) From b6d24b7338ce684967a8b499ff0a22f16690b538 Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Tue, 27 Oct 2026 17:45:55 +0000 Subject: [PATCH 3/4] fix(identity): coerce string oauth_expires_in and normalize empty enrollment_time --- .../private/_identity_fn.py | 8 +- tests/test_identity_fn.py | 113 +++++++++++++++--- 2 files changed, 103 insertions(+), 18 deletions(-) diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 6d96d7e..dd4ef9b 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -94,9 +94,8 @@ def _auth_user_metadata_from_token_data(token_data: dict[str, _typing.Any]): def _auth_multi_factor_info_from_token_data(token_data: dict[str, _typing.Any]): from firebase_functions.identity_fn import AuthMultiFactorInfo - enrollment_time = token_data.get("enrollment_time") - if enrollment_time: - enrollment_time = _util.timestamp_conversion(enrollment_time) + raw_enrollment = token_data.get("enrollment_time") + enrollment_time = _util.timestamp_conversion(raw_enrollment) if raw_enrollment else None factor_id = token_data["factor_id"] if not token_data.get("phone_number") else "phone" return AuthMultiFactorInfo( uid=token_data["uid"], @@ -200,7 +199,8 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], time: float) from firebase_functions.identity_fn import Credential - oauth_expires_in = token_data.get("oauth_expires_in") + raw_expires_in = token_data.get("oauth_expires_in") + oauth_expires_in = float(raw_expires_in) if raw_expires_in else 0.0 expiration_time = ( _dt.datetime.fromtimestamp(time + oauth_expires_in, tz=_dt.timezone.utc) if oauth_expires_in diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index e54680b..6f0e93d 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -476,22 +476,28 @@ def example( "last_sign_in_time": 1672662600000, }, "provider_data": [], - "tokens_valid_after_time": 1672531200, + "tokens_valid_after_time": 1672704000, "multi_factor": { "enrolled_factors": [ { "uid": "mfa-1", "factor_id": "phone", - "enrollment_time": "2023-01-01T00:00:00.000Z", + "enrollment_time": "2023-01-04T06:15:30.123456789Z", "phone_number": "+15555550100", - } + }, + { + "uid": "mfa-2", + "factor_id": "phone", + "enrollment_time": "", + "phone_number": "+15555550101", + }, ] }, }, "event_id": "event-id", "ip_address": "127.0.0.1", "user_agent": "test-agent", - "iat": 1672531200, + "iat": 1672876800, "sign_in_method": "google.com", "oauth_access_token": "token-abc", "oauth_expires_in": 3600, @@ -500,7 +506,7 @@ def example( with ( patch.object(_identity_fn, "_token_verifier", mock_verifier), - patch.object(_identity_fn._time, "time", return_value=1672531200.0), + patch.object(_identity_fn._time, "time", return_value=1672963200.0), ): app = Flask(__name__) with app.test_request_context("/"): @@ -513,27 +519,106 @@ def example( self.assertIsNotNone(captured_event) assert captured_event is not None - expected_epoch = dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc) - self.assertEqual(captured_event.timestamp, expected_epoch) - self.assertEqual(captured_event.data.metadata.creation_time, expected_epoch) + self.assertEqual( + captured_event.data.metadata.creation_time, + dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc), + ) self.assertEqual( captured_event.data.metadata.last_sign_in_time, dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc), ) - self.assertEqual(captured_event.data.tokens_valid_after_time, expected_epoch) + self.assertEqual( + captured_event.data.tokens_valid_after_time, + dt.datetime(2023, 1, 3, 0, 0, 0, tzinfo=dt.timezone.utc), + ) assert captured_event.data.multi_factor is not None self.assertEqual( captured_event.data.multi_factor.enrolled_factors[0].enrollment_time, - expected_epoch, + dt.datetime(2023, 1, 4, 6, 15, 30, 123456, tzinfo=dt.timezone.utc), + ) + self.assertIsNone( + captured_event.data.multi_factor.enrolled_factors[1].enrollment_time, + ) + self.assertEqual( + captured_event.timestamp, + dt.datetime(2023, 1, 5, 0, 0, 0, tzinfo=dt.timezone.utc), ) assert captured_event.credential is not None self.assertEqual( captured_event.credential.expiration_time, - dt.datetime(2023, 1, 1, 1, 0, 0, tzinfo=dt.timezone.utc), + dt.datetime(2023, 1, 6, 1, 0, 0, tzinfo=dt.timezone.utc), + ) + + # Verify None/omitted metadata timestamps and Auth Emulator string oauth_expires_in + # ("3600" and "0") via the public @before_user_created() decorator. + created_events: list[identity_fn.AuthBlockingEvent] = [] + + @identity_fn.before_user_created() + def on_before_create( + event: identity_fn.AuthBlockingEvent, + ) -> identity_fn.BeforeCreateResponse | None: + created_events.append(event) + return None + + mock_verifier.verify_auth_blocking_token = Mock( + side_effect=[ + { + "user_record": { + "uid": "user-emulated-1", + "metadata": {"creation_time": None, "last_sign_in_time": None}, + "provider_data": [], + }, + "event_id": "event-emulated-1", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "iat": 1672876800, + "sign_in_method": "google.com", + "oauth_access_token": "token-abc", + "oauth_expires_in": "3600", + }, + { + "user_record": { + "uid": "user-emulated-2", + "metadata": {}, + "provider_data": [], + }, + "event_id": "event-emulated-2", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "iat": 1672876800, + "sign_in_method": "google.com", + "oauth_access_token": "token-abc", + "oauth_expires_in": "0", + }, + ] + ) + + with ( + patch.object(_identity_fn, "_token_verifier", mock_verifier), + patch.object(_identity_fn._time, "time", return_value=1672963200.0), + ): + app = Flask(__name__) + with app.test_request_context("/"): + for _ in range(2): + environ = EnvironBuilder( + method="POST", + json={"data": {"jwt": "jwt"}}, + ).get_environ() + on_before_create(Request(environ)) # type: ignore[arg-type] + + self.assertEqual(len(created_events), 2) + self.assertEqual( + created_events[0].data.metadata, + identity_fn.AuthUserMetadata(creation_time=None, last_sign_in_time=None), + ) + assert created_events[0].credential is not None + self.assertEqual( + created_events[0].credential.expiration_time, + dt.datetime(2023, 1, 6, 1, 0, 0, tzinfo=dt.timezone.utc), ) self.assertEqual( - _identity_fn._auth_user_metadata_from_token_data( - {"creation_time": None, "last_sign_in_time": None} - ), + created_events[1].data.metadata, identity_fn.AuthUserMetadata(creation_time=None, last_sign_in_time=None), ) + assert created_events[1].credential is not None + self.assertIsNone(created_events[1].credential.expiration_time) From cbad1a20a7e519dcbfa71bdc4ffa7a21d07065bd Mon Sep 17 00:00:00 2001 From: Varun Shetty Date: Tue, 27 Oct 2026 23:19:23 +0000 Subject: [PATCH 4/4] fix(identity): normalize enrollment_time tz, default missing metadata, and bound oauth_expires_in --- .../private/_identity_fn.py | 10 ++- tests/test_identity_fn.py | 61 ++++++++++++++++--- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index dd4ef9b..99e3e41 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -95,7 +95,11 @@ def _auth_multi_factor_info_from_token_data(token_data: dict[str, _typing.Any]): from firebase_functions.identity_fn import AuthMultiFactorInfo raw_enrollment = token_data.get("enrollment_time") - enrollment_time = _util.timestamp_conversion(raw_enrollment) if raw_enrollment else None + enrollment_time = ( + _util.timestamp_conversion(raw_enrollment).astimezone(_dt.timezone.utc) + if raw_enrollment + else None + ) factor_id = token_data["factor_id"] if not token_data.get("phone_number") else "phone" return AuthMultiFactorInfo( uid=token_data["uid"], @@ -134,7 +138,7 @@ def _auth_user_record_from_token_data(token_data: dict[str, _typing.Any]): photo_url=token_data.get("photo_url"), phone_number=token_data.get("phone_number"), disabled=token_data.get("disabled", False), - metadata=_auth_user_metadata_from_token_data(token_data["metadata"]), + metadata=_auth_user_metadata_from_token_data(token_data.get("metadata") or {}), provider_data=[ _auth_user_info_from_token_data(info) for info in token_data["provider_data"] ], @@ -203,7 +207,7 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], time: float) oauth_expires_in = float(raw_expires_in) if raw_expires_in else 0.0 expiration_time = ( _dt.datetime.fromtimestamp(time + oauth_expires_in, tz=_dt.timezone.utc) - if oauth_expires_in + if 0 < oauth_expires_in < float("inf") else None ) diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 6f0e93d..38a3600 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -488,9 +488,15 @@ def example( { "uid": "mfa-2", "factor_id": "phone", - "enrollment_time": "", + "enrollment_time": "2023-01-04T08:15:30+02:00", "phone_number": "+15555550101", }, + { + "uid": "mfa-3", + "factor_id": "phone", + "enrollment_time": "", + "phone_number": "+15555550102", + }, ] }, }, @@ -519,38 +525,64 @@ def example( self.assertIsNotNone(captured_event) assert captured_event is not None + assert captured_event.data.metadata.creation_time is not None self.assertEqual( captured_event.data.metadata.creation_time, dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual(captured_event.data.metadata.creation_time.tzinfo, dt.timezone.utc) + + assert captured_event.data.metadata.last_sign_in_time is not None self.assertEqual( captured_event.data.metadata.last_sign_in_time, dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual(captured_event.data.metadata.last_sign_in_time.tzinfo, dt.timezone.utc) + + assert captured_event.data.tokens_valid_after_time is not None self.assertEqual( captured_event.data.tokens_valid_after_time, dt.datetime(2023, 1, 3, 0, 0, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual(captured_event.data.tokens_valid_after_time.tzinfo, dt.timezone.utc) + assert captured_event.data.multi_factor is not None + factor_0_time = captured_event.data.multi_factor.enrolled_factors[0].enrollment_time + assert factor_0_time is not None self.assertEqual( - captured_event.data.multi_factor.enrolled_factors[0].enrollment_time, + factor_0_time, dt.datetime(2023, 1, 4, 6, 15, 30, 123456, tzinfo=dt.timezone.utc), ) + self.assertEqual(factor_0_time.tzinfo, dt.timezone.utc) + + factor_1_time = captured_event.data.multi_factor.enrolled_factors[1].enrollment_time + assert factor_1_time is not None + self.assertEqual( + factor_1_time, + dt.datetime(2023, 1, 4, 6, 15, 30, tzinfo=dt.timezone.utc), + ) + self.assertEqual(factor_1_time.tzinfo, dt.timezone.utc) + self.assertIsNone( - captured_event.data.multi_factor.enrolled_factors[1].enrollment_time, + captured_event.data.multi_factor.enrolled_factors[2].enrollment_time, ) + self.assertEqual( captured_event.timestamp, dt.datetime(2023, 1, 5, 0, 0, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual(captured_event.timestamp.tzinfo, dt.timezone.utc) + assert captured_event.credential is not None + assert captured_event.credential.expiration_time is not None self.assertEqual( captured_event.credential.expiration_time, dt.datetime(2023, 1, 6, 1, 0, 0, tzinfo=dt.timezone.utc), ) + self.assertEqual(captured_event.credential.expiration_time.tzinfo, dt.timezone.utc) - # Verify None/omitted metadata timestamps and Auth Emulator string oauth_expires_in - # ("3600" and "0") via the public @before_user_created() decorator. + # Verify None/omitted metadata timestamps and Auth Emulator string/omitted oauth_expires_in + # ("3600", "0", and omitted) via the public @before_user_created() decorator. created_events: list[identity_fn.AuthBlockingEvent] = [] @identity_fn.before_user_created() @@ -579,7 +611,6 @@ def on_before_create( { "user_record": { "uid": "user-emulated-2", - "metadata": {}, "provider_data": [], }, "event_id": "event-emulated-2", @@ -590,6 +621,18 @@ def on_before_create( "oauth_access_token": "token-abc", "oauth_expires_in": "0", }, + { + "user_record": { + "uid": "user-emulated-3", + "provider_data": [], + }, + "event_id": "event-emulated-3", + "ip_address": "127.0.0.1", + "user_agent": "test-agent", + "iat": 1672876800, + "sign_in_method": "google.com", + "oauth_access_token": "token-abc", + }, ] ) @@ -599,14 +642,14 @@ def on_before_create( ): app = Flask(__name__) with app.test_request_context("/"): - for _ in range(2): + for _ in range(3): environ = EnvironBuilder( method="POST", json={"data": {"jwt": "jwt"}}, ).get_environ() on_before_create(Request(environ)) # type: ignore[arg-type] - self.assertEqual(len(created_events), 2) + self.assertEqual(len(created_events), 3) self.assertEqual( created_events[0].data.metadata, identity_fn.AuthUserMetadata(creation_time=None, last_sign_in_time=None), @@ -622,3 +665,5 @@ def on_before_create( ) assert created_events[1].credential is not None self.assertIsNone(created_events[1].credential.expiration_time) + assert created_events[2].credential is not None + self.assertIsNone(created_events[2].credential.expiration_time)