goauthentik / authentik

The authentication glue you need.
https://goauthentik.io
Other
7.8k stars 598 forks source link

Creating application or provider via core API causes validation errors #9787

Open cadeParade opened 1 month ago

cadeParade commented 1 month ago

Describe the bug I am trying to set up a clean authentik install with a script in my FastAPI app, including an application and an oauth2/OIDC provider. I am using authentik-client. So I need to create both an application and a provider object, but creating either one causes validation errors. It seems like the create call does work, ultimately, but the terminal output shows errors and I'm not sure it should. Additionally, the properties it complains about are not listed as required (or even mentioned at all?) on the core API doc pages (application and provider)

To Reproduce

I run this:

    import authentik_client
    from authentik_client.rest import ApiException

    authentik_admin_access_token = settings.AUTHENTIK_BOOTSTRAP_TOKEN
    authentik_configuration = authentik_client.Configuration(
        host="http://localhost:9000/api/v3",
        access_token=authentik_admin_access_token,
    )

    with authentik_client.ApiClient(authentik_configuration) as api_client:
        application_api_instance = authentik_client.CoreApi(api_client)
        try:
            application_data = {
                "name": "Foo",
                "slug": "foo",
                "meta_launch_url": "http://127.0.0.1:3000",
            }
            api_application_response = application_api_instance.core_applications_create(
                application_data
            )
            print("The response of ApplicationsAPI->core_applications_create:\n")
            pprint(api_application_response)
            return api_application_response
        except ApiException as e:
            print("Exception when calling ApplicationsAPI->core_applications_create: %s\n" % e)

I get

pydantic_core._pydantic_core.ValidationError: 1 validation error for Application provider_obj Input should be a valid dictionary or instance of Provider [type=model_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/model_type

Stack trace ``` Traceback (most recent call last): File "/Users/lc/projects/foo/api/bin/first_time_setup", line 38, in setup_authentik.do_it() File "/Users/lc/projects/foo/api/bin/setup_authentik.py", line 43, in do_it create_foo_application() File "/Users/lc/projects/foo/api/bin/setup_authentik.py", line 56, in create_foo_application api_application_response = application_api_instance.core_applications_create( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/validate_call_decorator.py", line 59, in wrapper_function return validate_call_wrapper(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/_internal/_validate_call.py", line 81, in __call__ res = self.__pydantic_validator__.validate_python(pydantic_core.ArgsKwargs(args, kwargs)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api/core_api.py", line 428, in core_applications_create return self.api_client.response_deserialize( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 316, in response_deserialize return_data = self.deserialize(response_text, response_type) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 392, in deserialize return self.__deserialize(data, response_type) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 439, in __deserialize return self.__deserialize_model(data, klass) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 761, in __deserialize_model return klass.from_dict(data) ^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/models/application.py", line 148, in from_dict _obj = cls.model_validate({ ^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/main.py", line 551, in model_validate return cls.__pydantic_validator__.validate_python( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ pydantic_core._pydantic_core.ValidationError: 1 validation error for Application provider_obj Input should be a valid dictionary or instance of Provider [type=model_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/model_type ```

Ok fine so maybe I need a provider first. So I run (after deleting the application):

    with authentik_client.ApiClient(authentik_configuration) as api_client:
        provider_api_instance = authentik_client.ProvidersApi(api_client)
        flows_api_instance = authentik_client.FlowsApi(api_client)

        try:
            authorization_flow = flows_api_instance.flows_instances_retrieve(
                "default-provider-authorization-implicit-consent"
            )
            authetication_flow = flows_api_instance.flows_instances_retrieve(
                "default-authentication-flow"
            )
            provider_data = {
                "name": "FooProvider",
                "authorization_flow": authorization_flow.pk,
                "authentication_flow": authetication_flow.pk,
                "client_id": "authentik_client_id2",
                "client_secret": "authentik_client_secret",
                "redirect_uris": "http://127.0.0.1:9090/auth/authentik/callback",
                "assigned_application_slug": "foo",
                "assigned_application_name": "Foo",
                "assigned_backchannel_application_slug": "foo",
                "assigned_backchannel_application_name": "foo",
            }

            api_provider_response = provider_api_instance.providers_oauth2_create(provider_data)
            print("The response of ProvidersApi->core_providers_create:\n")
            pprint(api_provider_response)
            return api_provider_response
        except ApiException as e:
            print("Exception when calling ProvidersApi->core_providers_create: %s\n" % e)

And again I get validation errors:

pydantic_core._pydantic_core.ValidationError: 4 validation errors for OAuth2Provider
assigned_application_slug
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type
assigned_application_name
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type
assigned_backchannel_application_slug
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type
assigned_backchannel_application_name
  Input should be a valid string [type=string_type, input_value=None, input_type=NoneType]
    For further information visit https://errors.pydantic.dev/2.7/v/string_type

First of all -- all the validation error properties have a string provided, so I'm pretty confused about this error. Second, none of these properties are mentioned in the request_body section of the providers_oauth2_create documentation.

It seems like there are two issues:

  1. Some type validation is acting weird or incorrect -- I am providing strings for properties but it is saying it is wrong because it expects a string
  2. Creating a provider requires an application. Creating an application requires a provider. So I can't create either.

Expected behavior Ability to create an application or a provider.

Version and Deployment (please complete the following information):

Thanks for any advice or help!

BeryJu commented 1 month ago

The intended use for the generated API client would be using this

from authentik_client.models.application import Application
from authentik_client.models.o_auth2_provider import OAuth2Provider

Application()

instead of just passing a dictionary to the API calls

cadeParade commented 1 month ago

Thanks for your reply. I have been continuing to run into this problem after using authentik_client provided models. I think I have narrowed down the problem. When I create an OAuth Provider via the API, it throws validation errors for assigned_application_slug, assigned_application_name, assigned_backchannel_application_slug, assigned_backchannel_application_name.

I am using this package, as linked from the authentik documentation. FYI, the links to the documentation on the pypi page go to 404s (ex: https://pypi.org/project/authentik-client/docs/ProvidersApi.md#providers_oauth2_create). I cannot find a python version of authentik_client open source on github, although maybe it is all generated by this schema.yml?)

To create a new provider, we call: provider_api_instance.providers_oauth2_create([Instance of OAuth2ProviderRequest]).

providers_oauth2_create expects an argument of type OAuth2ProviderRequest (maybe defined here).

An OAuth2ProviderRequest has these properties, which does not include assigned_application_slug, assigned_application_name, assigned_backchannel_application_slug, assigned_backchannel_application_name. So to me, it seems impossible to send the properties an OAuth2Provider model is expecting since even if you put the assigned_application_slug etc in the data object to construct OAuth2ProviderRequest, properties that aren't in the OAuth2ProviderRequest schema are ignored.

Please let me know if I am misunderstanding something and how I can successfully create an OAuth provider with authentik-client

Here is the actual code I am using if it helpful:

expand... ``` with ApiClient(authentik_configuration) as api_client: provider_api_instance = ProvidersApi(api_client) flows_api_instance = FlowsApi(api_client) property_mappings_api_instance = PropertymappingsApi(api_client) try: print("[authentik_setup] Creating oauth provider...") authorization_flow = flows_api_instance.flows_instances_retrieve( "default-provider-authorization-implicit-consent" ) authetication_flow = flows_api_instance.flows_instances_retrieve( "default-authentication-flow" ) mappings = property_mappings_api_instance.propertymappings_scope_list() scope_mappings = [ mapping.pk for mapping in mappings.results if mapping.managed in [ "goauthentik.io/providers/oauth2/scope-openid", "goauthentik.io/providers/oauth2/scope-profile", "goauthentik.io/providers/oauth2/scope-email", ] ] provider_data = { "name": PROVIDER_NAME, "authorization_flow": authorization_flow.pk, "authentication_flow": authetication_flow.pk, "client_id": settings.AUTHENTIK_CLIENT_ID, "client_secret": settings.AUTHENTIK_CLIENT_SECRET, "redirect_uris": "http://127.0.0.1:9090/auth/authentik/callback", "property_mappings": scope_mappings, } provider_request = OAuth2ProviderRequest.model_construct(**provider_data) api_provider_response = provider_api_instance.providers_oauth2_create(provider_request) ```
BeryJu commented 1 month ago

The assigned_* properties are read_only and are mainly used by the frontend when the provider is connected with an application. In the backend this is defined correctly (and in theory so is it in the schema), however some client generators don't interpret this correctly

The python client is indeed generated from that schema (https://github.com/goauthentik/authentik/blob/main/.github/workflows/api-py-publish.yml) hence there currently isn't a source for it available.

Which line exactly is throwing the exception you posted above?

cadeParade commented 1 month ago

Thanks for your reply.

The lines are hard to share since I can't link to the generated python code, but here's some more details.

Backtrace ``` File "/Users/lc/projects/foo/api/bin/first_time_setup", line 307, in setup_authentik.do_it() File "/Users/lc/projects/foo/api/bin/setup_authentik.py", line 30, in do_it create_oauth_provider() File "/Users/lc/projects/foo/api/bin/setup_authentik.py", line 110, in create_oauth_provider api_provider_response = provider_api_instance.providers_oauth2_create(provider_request) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/validate_call_decorator.py", line 59, in wrapper_function return validate_call_wrapper(*args, **kwargs) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/_internal/_validate_call.py", line 81, in __call__ res = self.__pydantic_validator__.validate_python(pydantic_core.ArgsKwargs(args, kwargs)) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api/providers_api.py", line 16090, in providers_oauth2_create foo = self.api_client.response_deserialize( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 316, in response_deserialize return_data = self.deserialize(response_text, response_type) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 392, in deserialize return self.__deserialize(data, response_type) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 437, in __deserialize return self.__deserialize_model(data, klass) ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/api_client.py", line 761, in __deserialize_model return klass.from_dict(data) ^^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/authentik_client/models/o_auth2_provider.py", line 163, in from_dict _obj = cls.model_validate({ ^^^^^^^^^^^^^^^^^^^^ File "/Users/lc/projects/foo/api/.venv/lib/python3.12/site-packages/pydantic/main.py", line 551, in model_validate return cls.__pydantic_validator__.validate_python( ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ pydantic_core._pydantic_core.ValidationError: 4 validation errors for OAuth2Provider assigned_application_slug Input should be a valid string [type=string_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/string_type assigned_application_name Input should be a valid string [type=string_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/string_type assigned_backchannel_application_slug Input should be a valid string [type=string_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/string_type assigned_backchannel_application_name Input should be a valid string [type=string_type, input_value=None, input_type=NoneType] For further information visit https://errors.pydantic.dev/2.7/v/string_type ```

So what is happening as far as I understand is:

I call `providers_oauth2_create` ``` # definition from generated authentik-client -> `providers_api.py:16027` @validate_call def providers_oauth2_create( self, o_auth2_provider_request: OAuth2ProviderRequest, _request_timeout: Union[ None, Annotated[StrictFloat, Field(gt=0)], Tuple[ Annotated[StrictFloat, Field(gt=0)], Annotated[StrictFloat, Field(gt=0)] ] ] = None, _request_auth: Optional[Dict[StrictStr, Any]] = None, _content_type: Optional[StrictStr] = None, _headers: Optional[Dict[StrictStr, Any]] = None, _host_index: Annotated[StrictInt, Field(ge=0, le=0)] = 0, ) -> OAuth2Provider: """providers_oauth2_create OAuth2Provider Viewset :param o_auth2_provider_request: (required) :type o_auth2_provider_request: OAuth2ProviderRequest :param _request_timeout: timeout setting for this request. If one number provided, it will be total request timeout. It can also be a pair (tuple) of (connection, read) timeouts. :type _request_timeout: int, tuple(int, int), optional :param _request_auth: set to override the auth_settings for an a single request; this effectively ignores the authentication in the spec for a single request. :type _request_auth: dict, optional :param _content_type: force content-type for the request. :type _content_type: str, Optional :param _headers: set to override the headers for a single request; this effectively ignores the headers in the spec for a single request. :type _headers: dict, optional :param _host_index: set to override the host_index for a single request; this effectively ignores the host_index in the spec for a single request. :type _host_index: int, optional :return: Returns the result object. """ # noqa: E501 _param = self._providers_oauth2_create_serialize( o_auth2_provider_request=o_auth2_provider_request, _request_auth=_request_auth, _content_type=_content_type, _headers=_headers, _host_index=_host_index ) _response_types_map: Dict[str, Optional[str]] = { '201': "OAuth2Provider", '400': "ValidationError", '403': "GenericError", } response_data = self.api_client.call_api( *_param, _request_timeout=_request_timeout ) response_data.read() return self.api_client.response_deserialize( response_data=response_data, response_types_map=_response_types_map, ).data ```
We get some response_data back at the end of `providers_oauth2_create`. ``` # `response_data` content { "pk": 1, "name": "Foo OAuth/OIDC provider", "authentication_flow": "feb4c4d7-14fe-44e4-8036-c43c9af69a93", "authorization_flow": "5dbc2d24-2417-43f3-ac98-16336fba7968", "property_mappings": [ "89cb76b9-3874-4bbe-9d04-4fedd482a787", "bbc36495-5293-436e-960e-f0745a4ee542", "db7d0b95-2d85-4894-a0dc-4d398f9076b6" ], "component": "ak-provider-oauth2-form", "assigned_application_slug": null, "assigned_application_name": null, "verbose_name": "OAuth2/OpenID Provider", "verbose_name_plural": "OAuth2/OpenID Providers", "meta_model_name": "authentik_providers_oauth2.oauth2provider", "client_type": "confidential", "client_id": "foobarbaz", "client_secret": "blablabla", "access_code_validity": "minutes=1", "access_token_validity": "hours=1", "refresh_token_validity": "days=30", "include_claims_in_id_token": true, "signing_key": null, "redirect_uris": "http://127.0.0.1:9090/auth/authentik/callback", "sub_mode": "hashed_user_id", "issuer_mode": "per_provider", "jwks_sources": [] } ```

As you can see, there are no assigned_backchannel_application_name, assigned_backchannel_application_slug, assigned_application_name, or assigned_application_slug in this response. We get this response back, and then providers_oauth2_create calls api_client.response_deserialize which then calls api_client.__deserialize, which eventually calls api_client.__deserialize_model.

return self.api_client.response_deserialize(
            response_data=response_data,
            response_types_map=_response_types_map,
        ).data
definition of `api_client.response_deserialize`, `api_client.__deserialize`, and `api_client.__deserialize_model` -> api_client.py: 376 - 437, api_client.py:751 ``` def deserialize(self, response_text, response_type): """Deserializes response into an object. :param response: RESTResponse object to be deserialized. :param response_type: class literal for deserialized object, or string of class name. :return: deserialized object. """ # fetch data from response object try: data = json.loads(response_text) except ValueError: data = response_text return self.__deserialize(data, response_type) def __deserialize(self, data, klass): """Deserializes dict, list, str into an object. :param data: dict, list or str. :param klass: class literal, or string of class name. :return: object. """ if data is None: return None if isinstance(klass, str): if klass.startswith('List['): m = re.match(r'List\[(.*)]', klass) assert m is not None, "Malformed List type definition" sub_kls = m.group(1) return [self.__deserialize(sub_data, sub_kls) for sub_data in data] if klass.startswith('Dict['): m = re.match(r'Dict\[([^,]*), (.*)]', klass) assert m is not None, "Malformed Dict type definition" sub_kls = m.group(2) return {k: self.__deserialize(v, sub_kls) for k, v in data.items()} # convert str to class if klass in self.NATIVE_TYPES_MAPPING: klass = self.NATIVE_TYPES_MAPPING[klass] else: klass = getattr(authentik_client.models, klass) if klass in self.PRIMITIVE_TYPES: return self.__deserialize_primitive(data, klass) elif klass == object: return self.__deserialize_object(data) elif klass == datetime.date: return self.__deserialize_date(data) elif klass == datetime.datetime: return self.__deserialize_datetime(data) elif issubclass(klass, Enum): return self.__deserialize_enum(data, klass) else: return self.__deserialize_model(data, klass) ... def __deserialize_model(self, data, klass): """Deserializes list or dict to model. :param data: dict, list. :param klass: class literal. :return: model object. """ return klass.from_dict(data) ```
`__deserialize_model` calls `klass.from_dict` which in our case is `OAuth2Provider` class. So it calls `from_dict` method on `OAuth2Provider` model which calls `model_validate` requiring `assigned_application_slug`, etc. which do not exist, and therefore fails validation ``` models/o_auth2_provider.py:151 @classmethod def from_dict(cls, obj: Optional[Dict[str, Any]]) -> Optional[Self]: """Create an instance of OAuth2Provider from a dict""" if obj is None: return None if not isinstance(obj, dict): return cls.model_validate(obj) _obj = cls.model_validate({ "pk": obj.get("pk"), "name": obj.get("name"), "authentication_flow": obj.get("authentication_flow"), "authorization_flow": obj.get("authorization_flow"), "property_mappings": obj.get("property_mappings"), "component": obj.get("component"), "assigned_application_slug": obj.get("assigned_application_slug"), "assigned_application_name": obj.get("assigned_application_name"), "assigned_backchannel_application_slug": obj.get("assigned_backchannel_application_slug"), "assigned_backchannel_application_name": obj.get("assigned_backchannel_application_name"), "verbose_name": obj.get("verbose_name"), "verbose_name_plural": obj.get("verbose_name_plural"), "meta_model_name": obj.get("meta_model_name"), "client_type": obj.get("client_type"), "client_id": obj.get("client_id"), "client_secret": obj.get("client_secret"), "access_code_validity": obj.get("access_code_validity"), "access_token_validity": obj.get("access_token_validity"), "refresh_token_validity": obj.get("refresh_token_validity"), "include_claims_in_id_token": obj.get("include_claims_in_id_token"), "signing_key": obj.get("signing_key"), "redirect_uris": obj.get("redirect_uris"), "sub_mode": obj.get("sub_mode"), "issuer_mode": obj.get("issuer_mode"), "jwks_sources": obj.get("jwks_sources") }) return _obj ```

So it seems like

  1. The authentik API is not sending back required properties assigned_application_slug, assigned_application_name, assigned_backchannel_application_name, and assigned_backchannel_application_slug from a create call and therefore failing the de-serialization step.
  2. It is impossible for me to create a provider with those properties because the request object to create a provider does not accept those properties.
ekoyle commented 1 week ago

The generated API docs show assigned_application_name, assigned_application_slug, etc as "required" in the response, so I think the problem may actually be with the schema.yml file (or whatever generates it) rather than the openapi generator.

ekoyle commented 1 week ago

(edit: these were for the provider creation endpoint rather than the application creation endpoint)

For reference, adding nullable to these fields in schema.yml and regenerating the python client bindings was enough to get past this (not sure whether that is correct, I didn't look at the returned json to determine whether these were actually null values or just not present):

diff --git a/schema.yml b/schema.yml
index baa970150..8b301609b 100644
--- a/schema.yml
+++ b/schema.yml
@@ -45767,18 +45767,22 @@ components:
         assigned_application_slug:
           type: string
           description: Internal application name, used in URLs.
+          nullable: true
           readOnly: true
         assigned_application_name:
           type: string
           description: Application's display Name.
+          nullable: true
           readOnly: true
         assigned_backchannel_application_slug:
           type: string
           description: Internal application name, used in URLs.
+          nullable: true
           readOnly: true
         assigned_backchannel_application_name:
           type: string
           description: Application's display Name.
+          nullable: true
           readOnly: true
         verbose_name:
           type: string
ekoyle commented 6 days ago

I am also seeing similar issues trying to create a provider via the API.

It looks like DRF doesn't honor required=False for ReadOnlyFields. Even though these fields have required=False in their ModelSerializer class, they are still showing up under the required: field list for the response object in schema.yml . There was a similar problem with allow_null which appears to have been resolved by https://github.com/encode/django-rest-framework/pull/8536 . It doesn't seem like adding "required": False for the field in extra_kwargs in the serializer Meta makes any difference on these. I think changes may be needed in DRF for required so that drf_spectacular gets the metadata it needs.

A possible workaround would be to set allow_null=True for these fields. That solves the python openapi binding issue, at least (not sure whether other libraries/bindings validate the responses the same way... it still seems like having the field not be required in the spec would be ideal).

See also: https://github.com/tfranzel/drf-spectacular/issues/383