Repository navigation
feat(identity): add v2 Auth triggers (on_user_created and on_user_deleted) - #318
Conversation
There was a problem hiding this comment.
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.
23f6a65 to
6c1969b
Compare
6c1969b to
9a13f3c
Compare
IzaakGough
left a comment
There was a problem hiding this comment.
Left some comments inline :)
| _EPOCH_START_UTC = _dt.datetime.fromtimestamp(0, tz=_dt.timezone.utc) | ||
|
|
||
|
|
||
| def _ensure_utc(value: _dt.datetime) -> _dt.datetime: |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
…ull/unrecognized payloads
| 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")), |
There was a problem hiding this comment.
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").
There was a problem hiding this comment.
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.
ajperel
left a comment
There was a problem hiding this comment.
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.
ajperel
left a comment
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
⚠️ Note on Breaking Changes: AuthUserMetadata Timestamps
Updating AuthUserMetadata for 2nd-Gen triggers introduces two subtle breaking changes for existing 1st-Gen blocking function users:
-
Type Nullability (
creation_time:datetime->datetime | None):
Noneis 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 accessingevent.data.metadata.creation_timewithout aNoneguard. -
Timezone Awareness (
offset-naive->offset-aware UTC):
1st-Gen blocking functions emitted offset-naive datetimes viautcfromtimestamp()(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) raisesTypeError: 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)`).
Summary
Adds 2nd Gen Firebase Authentication Eventarc triggers (
on_user_createdandon_user_deleted) tofirebase_functions.identity_fn, bringing feature parity with the Node.js SDK (firebase-functions/v2/identity).Fixes #94
Usage
Architectural & Implementation Details
1. Global Eventarc Trigger Region (
manifest.py,options.py)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).region: NotRequired[str]tomanifest.EventTriggerand setendpoint.eventTrigger["region"] = "global"insideAuthOptions._endpoint().Expressioninstances:AuthOptions._endpoint()mutatesendpoint.eventTriggerdirectly rather than wrappingsuper()._endpoint()indataclasses.asdict(), preservingStringParam(Expression)instances insideeventFilters["tenantid"]somanifest._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 viaAuthOptions(EventHandlerOptions), registeringgoogle.firebase.auth.user.v2.createdandgoogle.firebase.auth.user.v2.deletedendpoints respectively. Also re-exportsAuthOptionsfor convenience.AuthEvent(CloudEvent[AuthUserRecord]):_core.CloudEvent[AuthUserRecord]soevent.datais a non-optionalAuthUserRecord(matchingAuthBlockingEventin Python andAuthEvent<User>in the Node SDK).project: str | Noneandtenant_id: str | Nonefields extracted from the CloudEvent attributes.AuthUserMetadata.creation_time: Updated to_dt.datetime | None = Noneso omittedcreateTimefields (e.g., on minimal deletion payloads) are cleanly represented asNone.3. Runtime CloudEvent Unpacking (
private/_identity_fn.py)AuthEventDataEnvelope Unpacking (_unwrap_auth_event_data):data["value"]foron_user_createdevents anddata["oldValue"]foron_user_deletedevents (google.events.firebase.auth.v2.AuthEventData), raisingValueErroron missing, empty, or malformed payloads.google.events.firebase.auth.v2protobuf JSON fields (uid,email,emailVerified,displayName,photoURL,phoneNumber,disabled,metadata.createTime,metadata.lastSignInTime,providerData, andcustomClaims) intoAuthUserRecord,AuthUserMetadata, andAuthUserInfo(aligned with the Auth Emulator in fix(emulator): align v2 Auth CloudEvent payload with Eventarc wire format firebase-tools#11323)._util.timestamp_conversiondirectly formetadata.createTime,metadata.lastSignInTime, and the CloudEventtimeattribute.password_hash,password_salt,tokens_valid_after_time, andmulti_factortoNoneonAuthUserRecord, as those fields are only populated by Auth Blocking JWTs and are not part of the v2 EventarcUserproto.projectfromsource(//identitytoolkit.googleapis.com/projects/{project}) and populatestenant_idon bothevent.tenant_idandevent.data.tenant_idfrom the top-level CloudEventtenantidattribute.AuthUserMetadata.creation_time: datetime -> datetime | None):AuthUserMetadata.creation_timeis now typed asdatetime | None = Nonebecause deletion events may omit creation metadata. Existing blocking function code using strict type checking (mypy/pyright) may require aNonecheck before accessingevent.data.metadata.creation_time.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
pytest(all unit tests passing, includingTestIdentitysuite covering endpoint metadata,functions_as_yamlStringParamrendering,value/oldValueunpacking, and payload/timestamp validation)mypy&ruff check/ruff format --check./docs/generate.sh --out=./docs/build/ --pypath=src/) with-W(zero warnings)firebase emulators:execwith fix(emulator): align v2 Auth CloudEvent payload with Eventarc wire format firebase-tools#11323).on_user_createdandon_user_deletedexecution for both default-project users and tenant-scoped users.PR Stack: