diff --git a/src/firebase_functions/identity_fn.py b/src/firebase_functions/identity_fn.py index aad9bf2..051e8e2 100644 --- a/src/firebase_functions/identity_fn.py +++ b/src/firebase_functions/identity_fn.py @@ -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: """ @@ -311,6 +336,12 @@ class AuthBlockingEvent: sms_type: SmsType | None """The type of SMS event.""" + auth_type: AuthType + """Whether the event was triggered by a user or by an unauthenticated request.""" + + resource: AuthBlockingResource + """The resource that emitted the event.""" + timestamp: _dt.datetime """ The time the event was triggered.""" diff --git a/src/firebase_functions/private/_identity_fn.py b/src/firebase_functions/private/_identity_fn.py index ec8ebca..9b14d7d 100644 --- a/src/firebase_functions/private/_identity_fn.py +++ b/src/firebase_functions/private/_identity_fn.py @@ -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 @@ -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"]), @@ -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", + resource=AuthBlockingResource( + service="identitytoolkit.googleapis.com", + name=resource_name, + ), ) @@ -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({}) diff --git a/tests/test_identity_fn.py b/tests/test_identity_fn.py index 38a3600..1cab9b8 100644 --- a/tests/test_identity_fn.py +++ b/tests/test_identity_fn.py @@ -3,6 +3,7 @@ """ import datetime as dt +import os import unittest from unittest.mock import MagicMock, Mock, patch @@ -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()) @@ -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, + )