Skip to content
31 changes: 31 additions & 0 deletions src/firebase_functions/identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,31 @@ class SmsType(str, Enum):
MULTI_FACTOR_ENROLLMENT = "MULTI_FACTOR_ENROLLMENT"


AuthType = _typing.Literal["USER", "UNAUTHENTICATED"]
"""
Whether the event was triggered by a user or by an unauthenticated request.
"""


@_dataclasses.dataclass(frozen=True)
class AuthBlockingResource:
"""
The resource that emitted an auth blocking event.
"""

service: str
"""
The service that emitted the event.
Example: 'identitytoolkit.googleapis.com'
"""

name: str
"""
The project, or the tenant within it, the event belongs to.
Example: 'projects/my-project' or 'projects/my-project/tenants/my-tenant'
"""


@_dataclasses.dataclass(frozen=True)
class AuthBlockingEvent:
"""
Expand Down Expand Up @@ -311,6 +336,12 @@ class AuthBlockingEvent:
sms_type: SmsType | None
"""The type of SMS event."""

auth_type: AuthType
Comment thread
IzaakGough marked this conversation as resolved.
Resolved
"""Whether the event was triggered by a user or by an unauthenticated request."""
Comment thread
IzaakGough marked this conversation as resolved.
Resolved

resource: AuthBlockingResource
"""The resource that emitted the event."""

timestamp: _dt.datetime
"""
The time the event was triggered."""
Expand Down
23 changes: 20 additions & 3 deletions src/firebase_functions/private/_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
# pylint: disable=protected-access
import datetime as _dt
import json as _json
import os as _os
import re as _re
import time as _time
import typing as _typing
Expand Down Expand Up @@ -229,8 +230,17 @@ def _credential_from_token_data(token_data: dict[str, _typing.Any], time: float)
)


def _auth_blocking_event_from_token_data(event_type: str, token_data: dict[str, _typing.Any]):
from firebase_functions.identity_fn import AuthBlockingEvent
def _auth_blocking_event_from_token_data(
event_type: str,
token_data: dict[str, _typing.Any],
project_id: str | None,
):
from firebase_functions.identity_fn import AuthBlockingEvent, AuthBlockingResource

tenant_id = token_data.get("tenant_id")
resource_name = (
f"projects/{project_id}/tenants/{tenant_id}" if tenant_id else f"projects/{project_id}"
)

return AuthBlockingEvent(
data=_auth_user_record_from_token_data(token_data["user_record"]),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 [Logic & Guarding] Validate user_record Explicitly

Rationale:
Line 254 evaluates:

auth_type="USER" if token_data.get("user_record") else "UNAUTHENTICATED",

However, line 243 unconditionally accesses token_data["user_record"]:

data=_auth_user_record_from_token_data(token_data["user_record"]),

If an unauthenticated payload arrives without a user record, line 243 raises KeyError: 'user_record' before line 254 is reached.

Since before_user_created and before_user_signed_in currently require a user record, explicitly validate it so missing records produce a clear error rather than an unhandled KeyError:

    user_record_data = token_data.get("user_record")
    if not isinstance(user_record_data, dict) or not user_record_data:
        raise ValueError(f"Auth blocking event '{event_type}' requires a valid 'user_record'.")

    return AuthBlockingEvent(
        data=_auth_user_record_from_token_data(user_record_data),
        ...
        auth_type="USER",
        resource=AuthBlockingResource(
            service="identitytoolkit.googleapis.com",
            name=resource_name,
        ),
    )

This keeps AuthType = Literal["USER", "UNAUTHENTICATED"] ready in public types for when unauthenticated triggers (beforeSendEmail, beforeSendSms) are added in a future PR.

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.

Adding this check would move away from what Node does. I deployed this branch as before_user_created and before_user_signed_in functions, and every token from email sign-up and password sign-in included user_record. If it were ever missing, the handler would log Unhandled error 'user_record' and return a 500, as Node does for these triggers. AuthType already has both "USER" and "UNAUTHENTICATED", and auth_type is set the same way as in Node, so the email and SMS triggers will only need data to become optional.

Would you still prefer to add this validation, or should we keep it as is?

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I was conflicted myself on this, I'm fine keeping it as is and improving in the future if needed as we add email and SMS triggers.

Expand All @@ -244,6 +254,11 @@ def _auth_blocking_event_from_token_data(event_type: str, token_data: dict[str,
credential=_credential_from_token_data(token_data, _time.time()),
email_type=token_data.get("email_type"),
sms_type=token_data.get("sms_type"),
auth_type="USER" if token_data.get("user_record") else "UNAUTHENTICATED",
Comment thread
IzaakGough marked this conversation as resolved.
Resolved
resource=AuthBlockingResource(
service="identitytoolkit.googleapis.com",
name=resource_name,
),
)


Expand Down Expand Up @@ -363,7 +378,9 @@ def before_operation_handler(
raise HttpsError(FunctionsErrorCode.INVALID_ARGUMENT, "Bad Request")
jwt_token = request.json["data"]["jwt"]
decoded_token = _token_verifier.verify_auth_blocking_token(jwt_token)
event = _auth_blocking_event_from_token_data(event_type, decoded_token)
event = _auth_blocking_event_from_token_data(
event_type, decoded_token, _os.environ.get("GCLOUD_PROJECT")
)
auth_response: BeforeCreateResponse | BeforeSignInResponse | None = _with_init(func)(event)
if not auth_response:
return _jsonify({})
Expand Down
70 changes: 69 additions & 1 deletion tests/test_identity_fn.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"""

import datetime as dt
import os
import unittest
from unittest.mock import MagicMock, Mock, patch

Expand Down Expand Up @@ -43,7 +44,9 @@ def init():
nonlocal hello
hello = "world"

with patch.object(_identity_fn, "_token_verifier", token_verifier_mock):
with (
patch.object(_identity_fn, "_token_verifier", token_verifier_mock),
):
app = Flask(__name__)

func = Mock(__name__="example_func", return_value=identity_fn.BeforeSignInResponse())
Expand Down Expand Up @@ -667,3 +670,68 @@ def on_before_create(
self.assertIsNone(created_events[1].credential.expiration_time)
assert created_events[2].credential is not None
self.assertIsNone(created_events[2].credential.expiration_time)

def test_handler_receives_auth_type_and_resource(self):
received = None

def func(event):
nonlocal received
received = event

with (
patch.object(_identity_fn, "_token_verifier", token_verifier_mock),
patch.dict(os.environ, {"GCLOUD_PROJECT": "project-id"}),
):
app = Flask(__name__)
with app.test_request_context("/"):
environ = EnvironBuilder(
method="POST",
json={"data": {"jwt": "jwt"}},
).get_environ()
response = identity_fn.before_user_signed_in()(func)(Request(environ))

self.assertEqual(200, response.status_code)
self.assertEqual("USER", received.auth_type)
self.assertEqual(
identity_fn.AuthBlockingResource(
service="identitytoolkit.googleapis.com",
name="projects/project-id",
),
received.resource,
)


class TestAuthBlockingEventFromTokenData(unittest.TestCase):
"""
Tests for building an AuthBlockingEvent from a decoded blocking token.
"""

def _event(self, **token_fields):
token_data = {
**token_verifier_mock.verify_auth_blocking_token.return_value,
**token_fields,
}
return _identity_fn._auth_blocking_event_from_token_data(
_identity_fn.event_type_before_sign_in, token_data, "project-id"
)

def test_auth_type_is_user_when_token_has_user_record(self):
self.assertEqual("USER", self._event().auth_type)

def test_resource_names_project_when_token_has_no_tenant(self):
self.assertEqual(
identity_fn.AuthBlockingResource(
service="identitytoolkit.googleapis.com",
name="projects/project-id",
),
self._event().resource,
)

def test_resource_names_tenant_when_token_has_tenant_id(self):
self.assertEqual(
identity_fn.AuthBlockingResource(
service="identitytoolkit.googleapis.com",
name="projects/project-id/tenants/tenant-1",
),
self._event(tenant_id="tenant-1").resource,
)
Loading