Skip to content

Commit 4691ce3

Browse files
feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted) (#318)
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)`).
1 parent 26ee370 commit 4691ce3

6 files changed

Lines changed: 636 additions & 8 deletions

File tree

‎src/firebase_functions/identity_fn.py‎

Lines changed: 125 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
"""Cloud functions to handle Eventarc events."""
14+
"""Cloud functions to handle Firebase Authentication and Identity Platform events."""
1515

1616
# pylint: disable=protected-access,cyclic-import
1717
import dataclasses as _dataclasses
@@ -20,15 +20,18 @@
2020
import typing as _typing
2121
from enum import Enum
2222

23+
import cloudevents.http as _ce
2324
from flask import (
2425
Request as _Request,
2526
)
2627
from flask import (
2728
Response as _Response,
2829
)
2930

31+
import firebase_functions.core as _core
3032
import firebase_functions.options as _options
3133
import firebase_functions.private.util as _util
34+
from firebase_functions.options import AuthOptions
3235

3336

3437
@_dataclasses.dataclass(frozen=True)
@@ -62,10 +65,10 @@ class AuthUserMetadata:
6265
Additional metadata about the user.
6366
"""
6467

65-
creation_time: _dt.datetime
66-
"""The date the user was created."""
68+
creation_time: _dt.datetime | None = None
69+
"""The date the user was created, or None if omitted."""
6770

68-
last_sign_in_time: _dt.datetime | None
71+
last_sign_in_time: _dt.datetime | None = None
6972
"""The date the user last signed in."""
7073

7174

@@ -116,7 +119,7 @@ class AuthMultiFactorSettings:
116119
@_dataclasses.dataclass(frozen=True)
117120
class AuthUserRecord:
118121
"""
119-
The UserRecord passed to auth blocking functions from the identity platform.
122+
The UserRecord passed to auth blocking and event functions from the identity platform.
120123
"""
121124

122125
uid: str
@@ -472,3 +475,120 @@ def before_user_created_wrapped(request: _Request) -> _Response:
472475
return before_user_created_wrapped
473476

474477
return before_user_created_decorator
478+
479+
480+
@_dataclasses.dataclass(frozen=True)
481+
class AuthEvent(_core.CloudEvent[AuthUserRecord]):
482+
"""
483+
A CloudEvent that contains an AuthUserRecord when a user is created or deleted.
484+
"""
485+
486+
project: str | None = None
487+
"""
488+
The project identifier.
489+
"""
490+
491+
tenant_id: str | None = None
492+
"""
493+
The ID of the Identity Platform tenant associated with the event, if applicable.
494+
"""
495+
496+
497+
AuthEventCallable = _typing.Callable[[AuthEvent], None]
498+
"""
499+
The type of the callable for 'on_user_created' and 'on_user_deleted' Auth events.
500+
"""
501+
502+
503+
@_util.copy_func_kwargs(AuthOptions)
504+
def on_user_created(
505+
**kwargs,
506+
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
507+
"""
508+
Handles an event that is triggered when a user is created in Firebase Authentication.
509+
510+
Example:
511+
512+
.. code-block:: python
513+
514+
from firebase_functions import identity_fn
515+
516+
@identity_fn.on_user_created()
517+
def example(event: identity_fn.AuthEvent) -> None:
518+
pass
519+
520+
:param \\*\\*kwargs: Options.
521+
:type \\*\\*kwargs: as :exc:`firebase_functions.options.AuthOptions`
522+
:rtype: :exc:`typing.Callable`
523+
\\[ \\[ :exc:`firebase_functions.identity_fn.AuthEvent` \\], `None` \\]
524+
A function that takes an AuthEvent and returns ``None``.
525+
"""
526+
options = AuthOptions(**kwargs)
527+
528+
def on_user_created_decorator(func: AuthEventCallable):
529+
from firebase_functions.private._identity_fn import (
530+
event_type_user_created,
531+
user_event_handler,
532+
)
533+
534+
@_functools.wraps(func)
535+
def on_user_created_wrapped(raw: _ce.CloudEvent):
536+
return user_event_handler(func, raw)
537+
538+
_util.set_func_endpoint_attr(
539+
on_user_created_wrapped,
540+
options._endpoint(
541+
func_name=func.__name__,
542+
event_type=event_type_user_created,
543+
),
544+
)
545+
return on_user_created_wrapped
546+
547+
return on_user_created_decorator
548+
549+
550+
@_util.copy_func_kwargs(AuthOptions)
551+
def on_user_deleted(
552+
**kwargs,
553+
) -> _typing.Callable[[AuthEventCallable], AuthEventCallable]:
554+
"""
555+
Handles an event that is triggered when a user is deleted in Firebase Authentication.
556+
557+
Example:
558+
559+
.. code-block:: python
560+
561+
from firebase_functions import identity_fn
562+
563+
@identity_fn.on_user_deleted()
564+
def example(event: identity_fn.AuthEvent) -> None:
565+
pass
566+
567+
:param \\*\\*kwargs: Options.
568+
:type \\*\\*kwargs: as :exc:`firebase_functions.options.AuthOptions`
569+
:rtype: :exc:`typing.Callable`
570+
\\[ \\[ :exc:`firebase_functions.identity_fn.AuthEvent` \\], `None` \\]
571+
A function that takes an AuthEvent and returns ``None``.
572+
"""
573+
options = AuthOptions(**kwargs)
574+
575+
def on_user_deleted_decorator(func: AuthEventCallable):
576+
from firebase_functions.private._identity_fn import (
577+
event_type_user_deleted,
578+
user_event_handler,
579+
)
580+
581+
@_functools.wraps(func)
582+
def on_user_deleted_wrapped(raw: _ce.CloudEvent):
583+
return user_event_handler(func, raw)
584+
585+
_util.set_func_endpoint_attr(
586+
on_user_deleted_wrapped,
587+
options._endpoint(
588+
func_name=func.__name__,
589+
event_type=event_type_user_deleted,
590+
),
591+
)
592+
return on_user_deleted_wrapped
593+
594+
return on_user_deleted_decorator

‎src/firebase_functions/options.py‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1055,6 +1055,40 @@ def _required_apis(self) -> list[_manifest.ManifestRequiredApi]:
10551055
]
10561056

10571057

1058+
@_dataclasses.dataclass(frozen=True, kw_only=True)
1059+
class AuthOptions(EventHandlerOptions):
1060+
"""
1061+
Options that can be set on an Auth event trigger.
1062+
Internal use only.
1063+
"""
1064+
1065+
tenant_id: str | Expression[str] | None = None
1066+
"""
1067+
An optional tenant ID to scope the trigger to.
1068+
"""
1069+
1070+
def _endpoint(
1071+
self,
1072+
**kwargs,
1073+
) -> _manifest.ManifestEndpoint:
1074+
assert kwargs["event_type"] is not None
1075+
event_filters: dict[str, str | Expression[str]] = {}
1076+
if self.tenant_id is not None:
1077+
if self.tenant_id == "":
1078+
raise ValueError("AuthOptions: tenant_id cannot be an empty string.")
1079+
event_filters["tenantid"] = self.tenant_id
1080+
1081+
endpoint = super()._endpoint(
1082+
**kwargs,
1083+
event_filters=event_filters,
1084+
)
1085+
assert endpoint.eventTrigger is not None
1086+
# Firebase Auth event triggers are global by nature, so Eventarc requires
1087+
# the trigger region to be "global" even when the Cloud Function is regional.
1088+
endpoint.eventTrigger["region"] = "global"
1089+
return endpoint
1090+
1091+
10581092
@_dataclasses.dataclass(frozen=True, kw_only=True)
10591093
class FirestoreOptions(RuntimeOptions):
10601094
"""

‎src/firebase_functions/private/_identity_fn.py‎

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
"""Cloud functions to handle Eventarc events."""
14+
"""Cloud functions to handle Firebase Authentication and Identity Platform events."""
1515

1616
# pylint: disable=protected-access
1717
import datetime as _dt
1818
import json as _json
19+
import re as _re
1920
import time as _time
2021
import typing as _typing
2122

23+
import cloudevents.http as _ce
2224
from flask import (
2325
Request as _Request,
2426
)
@@ -369,3 +371,148 @@ def before_operation_handler(
369371
exception = HttpsError(FunctionsErrorCode.INTERNAL, "INTERNAL")
370372
status = exception._http_error_code.status
371373
return _make_response(_jsonify(error=exception._as_dict()), status)
374+
375+
376+
event_type_user_created = "google.firebase.auth.user.v2.created"
377+
event_type_user_deleted = "google.firebase.auth.user.v2.deleted"
378+
# Matches both absolute (/projects/...) and relative (projects/...) CloudEvent source paths.
379+
_PROJECT_ID_REGEX = _re.compile(r"(?:^|/)projects/([^/]+)")
380+
381+
382+
def _auth_user_info_from_ce_data(info_data: dict[str, _typing.Any]):
383+
"""
384+
Converts a single entry from the CloudEvent 'providerData' array into an AuthUserInfo.
385+
"""
386+
from firebase_functions.identity_fn import AuthUserInfo
387+
388+
return AuthUserInfo(
389+
uid=info_data.get("uid") or "",
390+
provider_id=info_data.get("providerId") or "",
391+
display_name=info_data.get("displayName"),
392+
email=info_data.get("email"),
393+
photo_url=info_data.get("photoURL"),
394+
)
395+
396+
397+
def _auth_user_metadata_from_ce_data(meta_data: dict[str, _typing.Any] | None):
398+
"""
399+
Converts the CloudEvent 'metadata' dict into an AuthUserMetadata object.
400+
"""
401+
from firebase_functions.identity_fn import AuthUserMetadata
402+
403+
if not meta_data or not isinstance(meta_data, dict):
404+
return AuthUserMetadata()
405+
406+
raw_creation = meta_data.get("createTime")
407+
creation_time = _util.timestamp_conversion(raw_creation) if raw_creation else None
408+
409+
raw_last_sign_in = meta_data.get("lastSignInTime")
410+
last_sign_in_time = _util.timestamp_conversion(raw_last_sign_in) if raw_last_sign_in else None
411+
412+
return AuthUserMetadata(
413+
creation_time=creation_time,
414+
last_sign_in_time=last_sign_in_time,
415+
)
416+
417+
418+
def _unwrap_auth_event_data(raw_data: _typing.Any) -> dict[str, _typing.Any]:
419+
"""
420+
Extracts the inner user dictionary from an Eventarc AuthEventData payload.
421+
Raises ValueError if the payload is missing, null, or does not contain a user dictionary.
422+
"""
423+
if not raw_data or not isinstance(raw_data, dict):
424+
raise ValueError("Auth event data payload must be a non-empty dictionary.")
425+
# 'on_user_created' wraps the user in 'value'; 'on_user_deleted' wraps it in 'oldValue'.
426+
user_data = raw_data.get("value") or raw_data.get("oldValue")
427+
if not isinstance(user_data, dict) or not user_data:
428+
raise ValueError("Auth event data envelope contains an empty or null user record.")
429+
return user_data
430+
431+
432+
def _auth_user_record_from_ce_data(raw_data: _typing.Any, tenant_id: str | None = None):
433+
"""
434+
Unpacks and normalizes an AuthUserRecord from a v2 Eventarc CloudEvent data payload.
435+
"""
436+
from firebase_functions.identity_fn import AuthUserRecord
437+
438+
user_data = _unwrap_auth_event_data(raw_data)
439+
uid = user_data.get("uid")
440+
if not isinstance(uid, str) or not uid:
441+
raise ValueError("Auth event user record is missing a valid 'uid'.")
442+
443+
raw_provider_data = user_data.get("providerData")
444+
if not isinstance(raw_provider_data, list):
445+
raw_provider_data = []
446+
provider_data = [
447+
_auth_user_info_from_ce_data(info) for info in raw_provider_data if isinstance(info, dict)
448+
]
449+
450+
raw_claims = user_data.get("customClaims")
451+
custom_claims = raw_claims if isinstance(raw_claims, dict) else None
452+
453+
return AuthUserRecord(
454+
uid=uid,
455+
email=user_data.get("email"),
456+
email_verified=bool(user_data.get("emailVerified", False)),
457+
display_name=user_data.get("displayName"),
458+
photo_url=user_data.get("photoURL"),
459+
phone_number=user_data.get("phoneNumber"),
460+
disabled=bool(user_data.get("disabled", False)),
461+
metadata=_auth_user_metadata_from_ce_data(user_data.get("metadata")),
462+
provider_data=provider_data,
463+
custom_claims=custom_claims,
464+
tenant_id=tenant_id,
465+
# The following fields on AuthUserRecord are populated only by Auth Blocking
466+
# JWTs and are not part of the v2 Eventarc User proto (google.events.firebase.auth.v2).
467+
password_hash=None,
468+
password_salt=None,
469+
tokens_valid_after_time=None,
470+
multi_factor=None,
471+
)
472+
473+
474+
def _auth_event_from_ce(raw: _ce.CloudEvent):
475+
"""
476+
Constructs a strongly-typed AuthEvent from a raw Eventarc CloudEvent,
477+
extracting the project ID, tenant ID, and unpacked AuthUserRecord.
478+
"""
479+
from firebase_functions.identity_fn import AuthEvent
480+
481+
event_attributes = raw._get_attributes()
482+
event_data: _typing.Any = raw.get_data()
483+
user_record = _auth_user_record_from_ce_data(
484+
event_data,
485+
tenant_id=event_attributes.get("tenantid"),
486+
)
487+
488+
# Extract project ID from the CloudEvent source attribute
489+
# (e.g. "//identitytoolkit.googleapis.com/projects/my-project").
490+
source: str = event_attributes["source"]
491+
match = _PROJECT_ID_REGEX.search(source)
492+
project: str | None = match.group(1) if match else None
493+
494+
event_time = _util.timestamp_conversion(event_attributes["time"])
495+
496+
return AuthEvent(
497+
specversion=event_attributes["specversion"],
498+
id=event_attributes["id"],
499+
source=source,
500+
type=event_attributes["type"],
501+
time=event_time,
502+
data=user_record,
503+
subject=event_attributes.get("subject"),
504+
project=project,
505+
tenant_id=user_record.tenant_id,
506+
)
507+
508+
509+
def user_event_handler(
510+
func: _typing.Callable,
511+
raw: _ce.CloudEvent,
512+
) -> None:
513+
"""
514+
Runtime handler invoked by functions-framework for v2 Auth triggers
515+
(on_user_created and on_user_deleted).
516+
"""
517+
event = _auth_event_from_ce(raw)
518+
_with_init(func)(event)

‎src/firebase_functions/private/manifest.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class EventTrigger(_typing.TypedDict):
6464
channel: _typing_extensions.NotRequired[str]
6565
eventType: _typing_extensions.Required[str]
6666
retry: _typing_extensions.Required[bool | _params.Expression[bool] | _util.Sentinel]
67+
region: _typing_extensions.NotRequired[str]
6768

6869

6970
class RetryConfigBase(_typing.TypedDict):

0 commit comments

Comments
 (0)