Skip to content

feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted) - #318

Merged
shettyvarun268 merged 12 commits into
mainfrom
shettyvarun268/auth-v2-triggers
Oct 22, 2026
Merged

shettyvarun268 merged 12 commits into
mainfrom
shettyvarun268/auth-v2-triggers

Conversation

@shettyvarun268

@shettyvarun268 shettyvarun268 commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds 2nd Gen Firebase Authentication Eventarc triggers (on_user_created and on_user_deleted) to firebase_functions.identity_fn, bringing feature parity with the Node.js SDK (firebase-functions/v2/identity).

Fixes #94


Usage

from firebase_functions import identity_fn
from firebase_functions.params import StringParam

TENANT_PARAM = StringParam("TENANT_ID")

# 1. Trigger on user creation across the project (and all tenants)
@identity_fn.on_user_created()
def handle_user_created(event: identity_fn.AuthEvent) -> None:
    print(f"User created: uid={event.data.uid}, email={event.data.email}")

# 2. Trigger on user deletion scoped to a specific Identity Platform tenant
@identity_fn.on_user_deleted(tenant_id="my-tenant-id")
def handle_tenant_user_deleted(event: identity_fn.AuthEvent) -> None:
    print(f"Deleted user {event.data.uid} from tenant {event.tenant_id}")

# 3. Parameterized tenant_id using StringParam
@identity_fn.on_user_created(tenant_id=TENANT_PARAM)
def handle_param_tenant_created(event: identity_fn.AuthEvent) -> None:
    print(f"Created user {event.data.uid} in tenant {event.tenant_id}")

Architectural & Implementation Details

1. Global Eventarc Trigger Region (manifest.py, options.py)

  • Why eventTrigger["region"] = "global" is required: Unlike regional event sources (such as Firestore or Cloud Storage), Firebase Authentication / Identity Platform is a global service and only publishes Eventarc events in the "global" Eventarc location—even when the Cloud Run function itself is deployed to a specific region (e.g., us-central1).
  • Added region: NotRequired[str] to manifest.EventTrigger and set endpoint.eventTrigger["region"] = "global" inside AuthOptions._endpoint().
  • Preserving Expression instances: AuthOptions._endpoint() mutates endpoint.eventTrigger directly rather than wrapping super()._endpoint() in dataclasses.asdict(), preserving StringParam(Expression) instances inside eventFilters["tenantid"] so manifest._dict_to_spec() serializes {{ params.TENANT_ID }} expressions properly.

2. Public API & Event Model (identity_fn.py)

  • @on_user_created() and @on_user_deleted(): Decorators configured via AuthOptions(EventHandlerOptions), registering google.firebase.auth.user.v2.created and google.firebase.auth.user.v2.deleted endpoints respectively. Also re-exports AuthOptions for convenience.
  • AuthEvent(CloudEvent[AuthUserRecord]):
    • Subclasses _core.CloudEvent[AuthUserRecord] so event.data is a non-optional AuthUserRecord (matching AuthBlockingEvent in Python and AuthEvent<User> in the Node SDK).
    • Adds top-level project: str | None and tenant_id: str | None fields extracted from the CloudEvent attributes.
  • AuthUserMetadata.creation_time: Updated to _dt.datetime | None = None so omitted createTime fields (e.g., on minimal deletion payloads) are cleanly represented as None.

3. Runtime CloudEvent Unpacking (private/_identity_fn.py)

  • Eventarc AuthEventData Envelope Unpacking (_unwrap_auth_event_data):
    • Unpacks data["value"] for on_user_created events and data["oldValue"] for on_user_deleted events (google.events.firebase.auth.v2.AuthEventData), raising ValueError on missing, empty, or malformed payloads.
  • Canonical Wire Schema Parsing:
    • Maps the google.events.firebase.auth.v2 protobuf JSON fields (uid, email, emailVerified, displayName, photoURL, phoneNumber, disabled, metadata.createTime, metadata.lastSignInTime, providerData, and customClaims) into AuthUserRecord, AuthUserMetadata, and AuthUserInfo (aligned with the Auth Emulator in fix(emulator): align v2 Auth CloudEvent payload with Eventarc wire format firebase-tools#11323).
    • Reuses _util.timestamp_conversion directly for metadata.createTime, metadata.lastSignInTime, and the CloudEvent time attribute.
    • Explicitly sets password_hash, password_salt, tokens_valid_after_time, and multi_factor to None on AuthUserRecord, as those fields are only populated by Auth Blocking JWTs and are not part of the v2 Eventarc User proto.
    • Extracts project from source (//identitytoolkit.googleapis.com/projects/{project}) and populates tenant_id on both event.tenant_id and event.data.tenant_id from the top-level CloudEvent tenantid attribute.

⚠️ Breaking Changes

  • Type Nullability (AuthUserMetadata.creation_time: datetime -> datetime | None): AuthUserMetadata.creation_time is now typed as datetime | None = None because deletion events may omit creation metadata. Existing blocking function code using strict type checking (mypy / pyright) may require a None check before accessing event.data.metadata.creation_time.
  • Timezone Awareness (offset-naive -> offset-aware UTC): 2nd-Gen Auth triggers emit timezone-aware UTC datetimes (tzinfo=timezone.utc) via _util.timestamp_conversion(), whereas 1st-Gen blocking functions emit offset-naive datetimes (tzinfo=None). Arithmetic and comparisons on 2nd-Gen Auth metadata timestamps must use timezone-aware datetimes (e.g., datetime.now(timezone.utc)).

Verification

  • Automated Checks:
    • pytest (all unit tests passing, including TestIdentity suite covering endpoint metadata, functions_as_yaml StringParam rendering, value/oldValue unpacking, and payload/timestamp validation)
    • mypy & ruff check / ruff format --check
    • Sphinx reference documentation build (./docs/generate.sh --out=./docs/build/ --pypath=src/) with -W (zero warnings)
  • End-to-End Production & Emulator Testing:

PR Stack:

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces support for Firebase Authentication event triggers (on_user_created and on_user_deleted) by adding the AuthEvent class, AuthOptions configuration, and the necessary Eventarc CloudEvent parsing logic. It also includes comprehensive unit tests to verify the functionality. The feedback suggests avoiding the use of the private _get_attributes() method on CloudEvent in favor of its public dictionary-like interface, and adding safety checks to ensure that raw_factors and raw_provider_data are lists before iterating over them to prevent potential TypeError exceptions on malformed payloads.

Comment thread src/firebase_functions/private/_identity_fn.py
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
@shettyvarun268
shettyvarun268 force-pushed the shettyvarun268/auth-v2-triggers branch from 23f6a65 to 6c1969b Compare October 13, 2026 19:57
@shettyvarun268
shettyvarun268 force-pushed the shettyvarun268/auth-v2-triggers branch from 6c1969b to 9a13f3c Compare October 13, 2026 20:42
@shettyvarun268
shettyvarun268 marked this pull request as ready for review October 13, 2026 21:21
@shettyvarun268 shettyvarun268 changed the title feat(identity): add v2 Auth triggers (on_user_created and on_user_del… feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted Oct 13, 2026
@shettyvarun268 shettyvarun268 changed the title feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted) Oct 13, 2026
@shettyvarun268
shettyvarun268 added this pull request to stack #320 October 14, 2026 14:57

@IzaakGough IzaakGough left a comment

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.

Left some comments inline :)

_EPOCH_START_UTC = _dt.datetime.fromtimestamp(0, tz=_dt.timezone.utc)


def _ensure_utc(value: _dt.datetime) -> _dt.datetime:

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.

This path gives tz-aware datetimes but the blocking path (utcfromtimestamp at 79, 82, 141) gives naive ones, into the same AuthUserMetadata fields. Comparing a creation_time from each raises TypeError: can't compare offset-naive and offset-aware datetimes. Do we want these to be consistent?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Great call-out Izaak. Some additional context, Auth blocking events use timezone-naive timestamps while the rest of the SDK uses timezone aware ones. Plus, tz-naive timestamps have been deprecated as of Python 3.12 due to the number of potential bugs it can cause.

So all things considered, I think it would make sense for us to be consistent and bring the Auth Blocking events to tz-aware timestamps to keep things consistent with the rest of the SDK.

But since its a breaking change(and kinda out of scope for this particular PR stack), I am leaning towards landing this as is and then fixing the tz issue for the blocking path in a follow-up PR in time for a breaking change release.

Thanks again for the review.

Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
custom_claims=_parse_custom_claims(user_data),
tenant_id=user_data.get("tenantId") or None,
tokens_valid_after_time=_parse_auth_timestamp(user_data.get("tokensValidAfterTime")),
multi_factor=_auth_multi_factor_settings_from_ce_data(user_data.get("multiFactor")),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Production Eventarc (data.proto) does not include MFA fields, and the Firebase Auth Emulator sends "mfaInfo": [{"mfaEnrollmentId": ..., "displayName": ..., "enrolledAt": ..., "phoneInfo": ...}] at the top level of the user dict rather than "multiFactor": {"enrolledFactors": [...]}.

As written, event.data.multi_factor will be None in both production Eventarc and the Auth Emulator. If MFA parsing here is meant to work with the Auth Emulator (or Identity Toolkit UserInfo payloads), we should also handle user_data.get("mfaInfo").

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch! AuthUserRecord is shared with the Auth Blocking functions (before_user_created / before_user_signed_in), which is why it includes multi_factor, password_hash, password_salt, and tokens_valid_after_time from the Blocking JWT. However, as you noted, none of those 4 fields exist in the v2 Eventarc User proto (data.proto) or in the Node v2 SDK's User type.

Rather than adding mfaInfo parsing just for the Auth Emulator (which would cause event.data.multi_factor to be populated in the emulator on on_user_deleted but always be None in production), I removed the v2 MFA parsing helpers and explicitly set multi_factor, password_hash, password_salt, and tokens_valid_after_time to None for v2 Eventarc events.

Comment thread src/firebase_functions/options.py
Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread tests/test_identity_fn.py Outdated
Comment thread src/firebase_functions/identity_fn.py Outdated

@wandamora wandamora left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Ty!

@ajperel ajperel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

It seems like we re-used emulator code for gen1 blocking events that don't match the wire protocol for gen2 auth events. We should verify those claims 100% and if real, fix the emulator and then simplify this python implementation as a result.

Comment thread src/firebase_functions/private/_identity_fn.py Outdated
Comment thread src/firebase_functions/private/_identity_fn.py Outdated

@ajperel ajperel left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

We need to make sure this is documented on this PR and in the commit message properly as a breaking change. But other than that, LG


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)`).

@shettyvarun268
shettyvarun268 merged commit 4691ce3 into main Oct 22, 2026
21 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add triggers for onCreate and onDelete

5 participants