Skip to content
Merged
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
130 changes: 125 additions & 5 deletions src/firebase_functions/identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cloud functions to handle Eventarc events."""
"""Cloud functions to handle Firebase Authentication and Identity Platform events."""

# pylint: disable=protected-access,cyclic-import
import dataclasses as _dataclasses
Expand All @@ -20,15 +20,18 @@
import typing as _typing
from enum import Enum

import cloudevents.http as _ce
from flask import (
Request as _Request,
)
from flask import (
Response as _Response,
)

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


@_dataclasses.dataclass(frozen=True)
Expand Down Expand Up @@ -62,10 +65,10 @@ class AuthUserMetadata:
Additional metadata about the user.
"""

creation_time: _dt.datetime
"""The date the user was created."""
creation_time: _dt.datetime | None = None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Note on Breaking Changes: AuthUserMetadata Timestamps

Updating AuthUserMetadata for 2nd-Gen triggers introduces two subtle breaking changes for existing 1st-Gen blocking function users:

  1. Type Nullability (creation_time: datetime -> datetime | None):
    None is the right representation because deletion events may omit creation metadata. However, existing blocking function code using strict type checking (mypy/pyright) may raise errors when accessing event.data.metadata.creation_time without a None guard.

  2. Timezone Awareness (offset-naive -> offset-aware UTC):
    1st-Gen blocking functions emitted offset-naive datetimes via utcfromtimestamp() (tzinfo is None). 2nd-Gen triggers emit offset-aware UTC datetimes via _util.timestamp_conversion() (tzinfo=timezone.utc). In Python, subtracting or comparing naive and aware datetimes (e.g. datetime.utcnow() - metadata.creation_time) raises TypeError: can't compare offset-naive and offset-aware datetimes.

Let's make sure both are called out in the PR description under ### ⚠️ Breaking Changes and documented with a BREAKING CHANGE: git footer in the squash commit message. Something like:

BREAKING CHANGE: `AuthUserMetadata.creation_time` is now typed as `datetime | None = None` (previously `datetime`). In addition, 2nd-Gen Auth triggers emit timezone-aware UTC datetimes for metadata timestamps (unlike 1st-Gen blocking functions which emitted offset-naive datetimes); arithmetic and comparisons must use timezone-aware datetimes (e.g. `datetime.now(timezone.utc)`).

"""The date the user was created, or None if omitted."""

last_sign_in_time: _dt.datetime | None
last_sign_in_time: _dt.datetime | None = None
"""The date the user last signed in."""


Expand Down Expand Up @@ -116,7 +119,7 @@ class AuthMultiFactorSettings:
@_dataclasses.dataclass(frozen=True)
class AuthUserRecord:
"""
The UserRecord passed to auth blocking functions from the identity platform.
The UserRecord passed to auth blocking and event functions from the identity platform.
"""

uid: str
Expand Down Expand Up @@ -472,3 +475,120 @@ def before_user_created_wrapped(request: _Request) -> _Response:
return before_user_created_wrapped

return before_user_created_decorator


@_dataclasses.dataclass(frozen=True)
class AuthEvent(_core.CloudEvent[AuthUserRecord]):
"""
A CloudEvent that contains an AuthUserRecord when a user is created or deleted.
"""

project: str | None = None
"""
The project identifier.
"""

tenant_id: str | None = None
"""
The ID of the Identity Platform tenant associated with the event, if applicable.
"""


AuthEventCallable = _typing.Callable[[AuthEvent], None]
"""
The type of the callable for 'on_user_created' and 'on_user_deleted' Auth events.
"""


@_util.copy_func_kwargs(AuthOptions)
def on_user_created(
**kwargs,
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
"""
Handles an event that is triggered when a user is created in Firebase Authentication.

Example:

.. code-block:: python

from firebase_functions import identity_fn

@identity_fn.on_user_created()
def example(event: identity_fn.AuthEvent) -> None:
pass

:param \\*\\*kwargs: Options.
:type \\*\\*kwargs: as :exc:`firebase_functions.options.AuthOptions`
:rtype: :exc:`typing.Callable`
\\[ \\[ :exc:`firebase_functions.identity_fn.AuthEvent` \\], `None` \\]
A function that takes an AuthEvent and returns ``None``.
"""
options = AuthOptions(**kwargs)

def on_user_created_decorator(func: AuthEventCallable):
from firebase_functions.private._identity_fn import (
event_type_user_created,
user_event_handler,
)

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

_util.set_func_endpoint_attr(
on_user_created_wrapped,
options._endpoint(
func_name=func.__name__,
event_type=event_type_user_created,
),
)
return on_user_created_wrapped

return on_user_created_decorator


@_util.copy_func_kwargs(AuthOptions)
def on_user_deleted(
**kwargs,
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
"""
Handles an event that is triggered when a user is deleted in Firebase Authentication.

Example:

.. code-block:: python

from firebase_functions import identity_fn

@identity_fn.on_user_deleted()
def example(event: identity_fn.AuthEvent) -> None:
pass

:param \\*\\*kwargs: Options.
:type \\*\\*kwargs: as :exc:`firebase_functions.options.AuthOptions`
:rtype: :exc:`typing.Callable`
\\[ \\[ :exc:`firebase_functions.identity_fn.AuthEvent` \\], `None` \\]
A function that takes an AuthEvent and returns ``None``.
"""
options = AuthOptions(**kwargs)

def on_user_deleted_decorator(func: AuthEventCallable):
from firebase_functions.private._identity_fn import (
event_type_user_deleted,
user_event_handler,
)

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

_util.set_func_endpoint_attr(
on_user_deleted_wrapped,
options._endpoint(
func_name=func.__name__,
event_type=event_type_user_deleted,
),
)
return on_user_deleted_wrapped

return on_user_deleted_decorator
34 changes: 34 additions & 0 deletions src/firebase_functions/options.py
Original file line number Diff line number Diff line change
Expand Up @@ -1055,6 +1055,40 @@ def _required_apis(self) -> list[_manifest.ManifestRequiredApi]:
]


@_dataclasses.dataclass(frozen=True, kw_only=True)
class AuthOptions(EventHandlerOptions):
"""
Options that can be set on an Auth event trigger.
Internal use only.
Comment thread
shettyvarun268 marked this conversation as resolved.
"""

tenant_id: str | Expression[str] | None = None
"""
An optional tenant ID to scope the trigger to.
"""

def _endpoint(
self,
**kwargs,
) -> _manifest.ManifestEndpoint:
assert kwargs["event_type"] is not None
event_filters: dict[str, str | Expression[str]] = {}
if self.tenant_id is not None:
if self.tenant_id == "":
raise ValueError("AuthOptions: tenant_id cannot be an empty string.")
event_filters["tenantid"] = self.tenant_id

endpoint = super()._endpoint(
**kwargs,
event_filters=event_filters,
)
assert endpoint.eventTrigger is not None
# Firebase Auth event triggers are global by nature, so Eventarc requires
# the trigger region to be "global" even when the Cloud Function is regional.
endpoint.eventTrigger["region"] = "global"
return endpoint


@_dataclasses.dataclass(frozen=True, kw_only=True)
class FirestoreOptions(RuntimeOptions):
"""
Expand Down
149 changes: 148 additions & 1 deletion src/firebase_functions/private/_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Cloud functions to handle Eventarc events."""
"""Cloud functions to handle Firebase Authentication and Identity Platform events."""

# pylint: disable=protected-access
import datetime as _dt
import json as _json
import re as _re
import time as _time
import typing as _typing

import cloudevents.http as _ce
from flask import (
Request as _Request,
)
Expand Down Expand Up @@ -369,3 +371,148 @@ def before_operation_handler(
exception = HttpsError(FunctionsErrorCode.INTERNAL, "INTERNAL")
status = exception._http_error_code.status
return _make_response(_jsonify(error=exception._as_dict()), status)


event_type_user_created = "google.firebase.auth.user.v2.created"
event_type_user_deleted = "google.firebase.auth.user.v2.deleted"
# Matches both absolute (/projects/...) and relative (projects/...) CloudEvent source paths.
_PROJECT_ID_REGEX = _re.compile(r"(?:^|/)projects/([^/]+)")


def _auth_user_info_from_ce_data(info_data: dict[str, _typing.Any]):
"""
Converts a single entry from the CloudEvent 'providerData' array into an AuthUserInfo.
"""
from firebase_functions.identity_fn import AuthUserInfo

return AuthUserInfo(
uid=info_data.get("uid") or "",
provider_id=info_data.get("providerId") or "",
display_name=info_data.get("displayName"),
email=info_data.get("email"),
photo_url=info_data.get("photoURL"),
)


def _auth_user_metadata_from_ce_data(meta_data: dict[str, _typing.Any] | None):
"""
Converts the CloudEvent 'metadata' dict into an AuthUserMetadata object.
"""
from firebase_functions.identity_fn import AuthUserMetadata

if not meta_data or not isinstance(meta_data, dict):
return AuthUserMetadata()

raw_creation = meta_data.get("createTime")
creation_time = _util.timestamp_conversion(raw_creation) if raw_creation else None

raw_last_sign_in = meta_data.get("lastSignInTime")
last_sign_in_time = _util.timestamp_conversion(raw_last_sign_in) if raw_last_sign_in else None

return AuthUserMetadata(
creation_time=creation_time,
last_sign_in_time=last_sign_in_time,
)


def _unwrap_auth_event_data(raw_data: _typing.Any) -> dict[str, _typing.Any]:
"""
Extracts the inner user dictionary from an Eventarc AuthEventData payload.
Raises ValueError if the payload is missing, null, or does not contain a user dictionary.
"""
if not raw_data or not isinstance(raw_data, dict):
raise ValueError("Auth event data payload must be a non-empty dictionary.")
# 'on_user_created' wraps the user in 'value'; 'on_user_deleted' wraps it in 'oldValue'.
user_data = raw_data.get("value") or raw_data.get("oldValue")
if not isinstance(user_data, dict) or not user_data:
raise ValueError("Auth event data envelope contains an empty or null user record.")
return user_data


def _auth_user_record_from_ce_data(raw_data: _typing.Any, tenant_id: str | None = None):
"""
Unpacks and normalizes an AuthUserRecord from a v2 Eventarc CloudEvent data payload.
"""
from firebase_functions.identity_fn import AuthUserRecord

user_data = _unwrap_auth_event_data(raw_data)
uid = user_data.get("uid")
if not isinstance(uid, str) or not uid:
raise ValueError("Auth event user record is missing a valid 'uid'.")

raw_provider_data = user_data.get("providerData")
if not isinstance(raw_provider_data, list):
raw_provider_data = []
provider_data = [
_auth_user_info_from_ce_data(info) for info in raw_provider_data if isinstance(info, dict)
]

raw_claims = user_data.get("customClaims")
custom_claims = raw_claims if isinstance(raw_claims, dict) else None

return AuthUserRecord(
uid=uid,
email=user_data.get("email"),
email_verified=bool(user_data.get("emailVerified", False)),
display_name=user_data.get("displayName"),
photo_url=user_data.get("photoURL"),
phone_number=user_data.get("phoneNumber"),
disabled=bool(user_data.get("disabled", False)),
metadata=_auth_user_metadata_from_ce_data(user_data.get("metadata")),
provider_data=provider_data,
custom_claims=custom_claims,
tenant_id=tenant_id,
# The following fields on AuthUserRecord are populated only by Auth Blocking
# JWTs and are not part of the v2 Eventarc User proto (google.events.firebase.auth.v2).
password_hash=None,
password_salt=None,
tokens_valid_after_time=None,
multi_factor=None,
)


def _auth_event_from_ce(raw: _ce.CloudEvent):
"""
Constructs a strongly-typed AuthEvent from a raw Eventarc CloudEvent,
extracting the project ID, tenant ID, and unpacked AuthUserRecord.
"""
from firebase_functions.identity_fn import AuthEvent

event_attributes = raw._get_attributes()
event_data: _typing.Any = raw.get_data()
user_record = _auth_user_record_from_ce_data(
event_data,
tenant_id=event_attributes.get("tenantid"),
)

# Extract project ID from the CloudEvent source attribute
# (e.g. "//identitytoolkit.googleapis.com/projects/my-project").
source: str = event_attributes["source"]
match = _PROJECT_ID_REGEX.search(source)
project: str | None = match.group(1) if match else None

event_time = _util.timestamp_conversion(event_attributes["time"])

return AuthEvent(
specversion=event_attributes["specversion"],
id=event_attributes["id"],
source=source,
type=event_attributes["type"],
time=event_time,
data=user_record,
subject=event_attributes.get("subject"),
project=project,
tenant_id=user_record.tenant_id,
)
Comment thread
shettyvarun268 marked this conversation as resolved.


def user_event_handler(
func: _typing.Callable,
raw: _ce.CloudEvent,
) -> None:
"""
Runtime handler invoked by functions-framework for v2 Auth triggers
(on_user_created and on_user_deleted).
"""
event = _auth_event_from_ce(raw)
_with_init(func)(event)
1 change: 1 addition & 0 deletions src/firebase_functions/private/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class EventTrigger(_typing.TypedDict):
channel: _typing_extensions.NotRequired[str]
eventType: _typing_extensions.Required[str]
retry: _typing_extensions.Required[bool | _params.Expression[bool] | _util.Sentinel]
region: _typing_extensions.NotRequired[str]


class RetryConfigBase(_typing.TypedDict):
Expand Down
Loading
Loading