Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions src/firebase_functions/identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
import firebase_functions.core as _core
import firebase_functions.options as _options
import firebase_functions.private.util as _util
from firebase_functions.options import AuthOptions # noqa: F401
from firebase_functions.options import IS_NOT_TENANT, AuthOptions # noqa: F401


@_dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -506,6 +506,8 @@ def on_user_created(
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
"""
Handles an event that is triggered when a user is created in Firebase Authentication.
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
default project (not scoped to any Identity Platform tenant).

Example:

Expand Down Expand Up @@ -533,7 +535,7 @@ def on_user_created_decorator(func: AuthEventCallable):

@_functools.wraps(func)
def on_user_created_wrapped(raw: _ce.CloudEvent):
return user_event_handler(func, raw)
return user_event_handler(func, raw, options)

_util.set_func_endpoint_attr(
on_user_created_wrapped,
Expand All @@ -553,6 +555,8 @@ def on_user_deleted(
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
"""
Handles an event that is triggered when a user is deleted in Firebase Authentication.
Pass ``tenant_id=identity_fn.IS_NOT_TENANT`` to only trigger on users in the
default project (not scoped to any Identity Platform tenant).

Example:

Expand Down Expand Up @@ -580,7 +584,7 @@ def on_user_deleted_decorator(func: AuthEventCallable):

@_functools.wraps(func)
def on_user_deleted_wrapped(raw: _ce.CloudEvent):
return user_event_handler(func, raw)
return user_event_handler(func, raw, options)

_util.set_func_endpoint_attr(
on_user_deleted_wrapped,
Expand Down
15 changes: 13 additions & 2 deletions src/firebase_functions/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
)
"""Special configuration value to reset configuration to platform default."""

IS_NOT_TENANT = _util.Sentinel(
"Constant to represent the absence of a tenant ID in Auth event triggers."
)
"""Constant to represent the absence of a tenant ID in Auth event triggers."""


class VpcEgressSetting(str, _enum.Enum):
"""Valid settings for VPC egress."""
Expand Down Expand Up @@ -1062,9 +1067,12 @@ class AuthOptions(EventHandlerOptions):
Internal use only.
"""

tenant_id: str | Expression[str] | None = None
tenant_id: str | Expression[str] | _util.Sentinel | None = None
"""
An optional tenant ID to scope the trigger to.
If not set, the function triggers on users across all tenants.
Set to ``IS_NOT_TENANT`` to only trigger on users in the default
project (no tenant).
"""

def _endpoint(
Expand All @@ -1073,7 +1081,10 @@ def _endpoint(
) -> _manifest.ManifestEndpoint:
assert kwargs["event_type"] is not None
event_filters: dict[str, str | Expression[str]] = {}
if self.tenant_id is not None:
# Eventarc does not support empty-string filters to match "no tenant". When
# tenant_id is IS_NOT_TENANT (a Sentinel), we omit the Eventarc filter so all
# events are delivered and filter out tenant-scoped events at runtime instead.
if self.tenant_id is not None and not isinstance(self.tenant_id, _util.Sentinel):
event_filters["tenantid"] = self.tenant_id

endpoint = super()._endpoint(
Expand Down
22 changes: 19 additions & 3 deletions src/firebase_functions/private/_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

# pylint: disable=protected-access
import datetime as _dt
import email.utils as _email_utils
import json as _json
import re as _re
import time as _time
Expand All @@ -35,6 +36,7 @@
)
from functions_framework import logging as _logging

import firebase_functions.options as _options
import firebase_functions.private.token_verifier as _token_verifier
import firebase_functions.private.util as _util
from firebase_functions.core import _with_init
Expand Down Expand Up @@ -390,7 +392,8 @@ def _ensure_utc(value: _dt.datetime) -> _dt.datetime:
def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
"""
Normalizes Auth event timestamps to timezone-aware UTC datetimes.
Supports epoch milliseconds (int, float, or numeric str) and ISO-8601 strings.
Supports epoch milliseconds (int, float, or numeric str), ISO-8601 strings
(production Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator).
"""
if isinstance(value, _dt.datetime):
return _ensure_utc(value)
Expand All @@ -403,10 +406,14 @@ def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
except (OverflowError, OSError, ValueError):
return None
if isinstance(value, str) and value:
for parser in (_util.timestamp_conversion, _dt.datetime.fromisoformat):
for parser in (
_util.timestamp_conversion,
_dt.datetime.fromisoformat,
_email_utils.parsedate_to_datetime,
):
try:
return _ensure_utc(parser(value))
except ValueError:
except (ValueError, TypeError):
continue
return None
Comment on lines 392 to 418

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure consistency and correctness across all parsing paths, any parsed or provided datetime object should be normalized to a timezone-aware UTC datetime. Currently, _email_utils.parsedate_to_datetime or _dt.datetime.fromisoformat can return naive datetimes or datetimes with non-UTC offsets. Normalizing them to UTC guarantees that the returned value always matches the docstring's contract.

def _parse_auth_timestamp(value: _typing.Any) -> _dt.datetime | None:
    """
    Normalizes Auth event timestamps to UTC datetimes.
    Supports epoch milliseconds (int/float/numeric str), ISO-8601 strings (production
    Eventarc), and RFC-2822 strings (emitted by the Firebase Auth Emulator).
    """
    if isinstance(value, _dt.datetime):
        if value.tzinfo is None:
            return value.replace(tzinfo=_dt.timezone.utc)
        return value.astimezone(_dt.timezone.utc)
    if isinstance(value, int | float) and not isinstance(value, bool):
        return _dt.datetime.fromtimestamp(value / 1000.0, tz=_dt.timezone.utc)
    if isinstance(value, str) and value:
        if value.isdigit():
            return _dt.datetime.fromtimestamp(int(value) / 1000.0, tz=_dt.timezone.utc)
        for parser in (
            _util.timestamp_conversion,
            _dt.datetime.fromisoformat,
            _email_utils.parsedate_to_datetime,
        ):
            try:
                dt = parser(value)
                if dt.tzinfo is None:
                    return dt.replace(tzinfo=_dt.timezone.utc)
                return dt.astimezone(_dt.timezone.utc)
            except (ValueError, TypeError):
                continue
    return None


Expand Down Expand Up @@ -611,10 +618,19 @@ def _auth_event_from_ce(raw: _ce.CloudEvent):
def user_event_handler(
func: _typing.Callable,
raw: _ce.CloudEvent,
options: _options.AuthOptions | None = None,
) -> None:
"""
Runtime handler invoked by functions-framework for v2 Auth triggers
(on_user_created and on_user_deleted).
"""
event = _auth_event_from_ce(raw)
# Eventarc cannot filter for the absence of a tenant attribute, so when
# tenant_id=IS_NOT_TENANT we drop events belonging to any tenant at runtime.
if (
options is not None
and options.tenant_id == _options.IS_NOT_TENANT
and event.tenant_id is not None
):
return
_with_init(func)(event)
121 changes: 121 additions & 0 deletions tests/test_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -326,3 +326,124 @@ def example(event: identity_fn.AuthEvent) -> None:
dt.datetime(2023, 1, 1, 0, 0, 0, tzinfo=dt.timezone.utc),
)
self.assertEqual(factors[1].factor_id, "totp")

def test_on_user_created_is_not_tenant(self) -> None:
called_uids: list[str] = []
init_calls = 0

@core.init
def init_fn() -> None:
nonlocal init_calls
init_calls += 1

@identity_fn.on_user_created(tenant_id=identity_fn.IS_NOT_TENANT)
def example(event: identity_fn.AuthEvent) -> None:
called_uids.append(event.data.uid)

endpoint = getattr(example, "__firebase_endpoint__", None)
assert endpoint is not None
self.assertEqual(endpoint.eventTrigger["eventFilters"], {})

rendered_yaml = yaml.safe_load(serving.functions_as_yaml({"example": example}))
self.assertEqual(
rendered_yaml["endpoints"]["example"]["eventTrigger"]["eventFilters"],
{},
)

# Should ignore events with top-level tenantid CloudEvent attribute (and not run @core.init)
tenant_attr_ce = CloudEvent(
attributes={
"specversion": "1.0",
"id": "event-tenant-attr",
"source": "//identitytoolkit.googleapis.com/projects/my-project",
"type": "google.firebase.auth.user.v2.created",
"time": "2023-01-01T00:00:00Z",
"tenantid": "some-tenant",
},
data={"value": {"uid": "user-with-tenant-attr"}},
)
example(tenant_attr_ce) # type: ignore[arg-type]
self.assertEqual(called_uids, [])
self.assertEqual(init_calls, 0)

# Should ignore events with tenantId inside the user payload
tenant_data_ce = CloudEvent(
attributes={
"specversion": "1.0",
"id": "event-tenant-data",
"source": "//identitytoolkit.googleapis.com/projects/my-project",
"type": "google.firebase.auth.user.v2.created",
"time": "2023-01-01T00:00:00Z",
},
data={"value": {"uid": "user-with-tenant-data", "tenantId": "some-tenant"}},
)
example(tenant_data_ce) # type: ignore[arg-type]
self.assertEqual(called_uids, [])
self.assertEqual(init_calls, 0)

# Should execute for non-tenant events (and run @core.init)
non_tenant_ce = CloudEvent(
attributes={
"specversion": "1.0",
"id": "event-no-tenant",
"source": "//identitytoolkit.googleapis.com/projects/my-project",
"type": "google.firebase.auth.user.v2.created",
"time": "2023-01-01T00:00:00Z",
},
data={"value": {"uid": "user-no-tenant"}},
)
example(non_tenant_ce) # type: ignore[arg-type]
self.assertEqual(called_uids, ["user-no-tenant"])
self.assertEqual(init_calls, 1)

# Also verify on_user_deleted with IS_NOT_TENANT
deleted_uids: list[str] = []

@identity_fn.on_user_deleted(tenant_id=identity_fn.IS_NOT_TENANT)
def example_deleted(event: identity_fn.AuthEvent) -> None:
deleted_uids.append(event.data.uid)

delete_endpoint = getattr(example_deleted, "__firebase_endpoint__", None)
assert delete_endpoint is not None
self.assertEqual(delete_endpoint.eventTrigger["eventFilters"], {})

example_deleted(non_tenant_ce) # type: ignore[arg-type]
example_deleted(tenant_attr_ce) # type: ignore[arg-type]
self.assertEqual(deleted_uids, ["user-no-tenant"])

def test_auth_emulator_rfc2822_timestamps(self) -> None:
captured_event: identity_fn.AuthEvent | None = None

@identity_fn.on_user_created()
def example(event: identity_fn.AuthEvent) -> None:
nonlocal captured_event
captured_event = event

emulator_ce = CloudEvent(
attributes={
"specversion": "1.0",
"id": "emu-event-1",
"source": "//identitytoolkit.googleapis.com/projects/my-project",
"type": "google.firebase.auth.user.v2.created",
"time": "2023-01-01T00:00:00.000Z",
},
data={
"value": {
"uid": "emu-user-1",
"metadata": {
"creationTime": "Sun, 01 Jan 2023 00:00:00 GMT",
"lastSignInTime": "1672662600000",
},
}
},
)
example(emulator_ce) # type: ignore[arg-type]
assert captured_event 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.last_sign_in_time,
dt.datetime(2023, 1, 2, 12, 30, 0, tzinfo=dt.timezone.utc),
)
Loading