diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index 6054c93..99e3e41 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -77,11 +77,15 @@ 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 = 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: - last_sign_in_time = _dt.datetime.utcfromtimestamp( - int(token_data["last_sign_in_time"]) / 1000.0 + 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 ) return AuthUserMetadata(creation_time=creation_time, last_sign_in_time=last_sign_in_time) @@ -90,9 +94,12 @@ 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 = _dt.datetime.fromisoformat(enrollment_time) + raw_enrollment = token_data.get("enrollment_time") + 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"], @@ -131,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"] ], @@ -139,7 +146,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"]) @@ -194,9 +203,12 @@ 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.utcfromtimestamp(time + oauth_expires_in) if oauth_expires_in else None + _dt.datetime.fromtimestamp(time + oauth_expires_in, tz=_dt.timezone.utc) + if 0 < oauth_expires_in < float("inf") + else None ) provider_id: str = ( @@ -227,7 +239,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..38a3600 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -454,3 +454,216 @@ 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": 1672704000, + "multi_factor": { + "enrolled_factors": [ + { + "uid": "mfa-1", + "factor_id": "phone", + "enrollment_time": "2023-01-04T06:15:30.123456789Z", + "phone_number": "+15555550100", + }, + { + "uid": "mfa-2", + "factor_id": "phone", + "enrollment_time": "2023-01-04T08:15:30+02:00", + "phone_number": "+15555550101", + }, + { + "uid": "mfa-3", + "factor_id": "phone", + "enrollment_time": "", + "phone_number": "+15555550102", + }, + ] + }, + }, + "event_id": "event-id", + "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, + } + ) + + 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("/"): + 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 + 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( + 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[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/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() + 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", + "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", + }, + { + "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", + }, + ] + ) + + 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(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), 3) + 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( + 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) + assert created_events[2].credential is not None + self.assertIsNone(created_events[2].credential.expiration_time)