Git Product home page Git Product logo

Comments (7)

BeryJu avatar BeryJu commented on July 21, 2024

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

from authentik.

cadeParade avatar cadeParade commented on July 21, 2024

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)

from authentik.

BeryJu avatar BeryJu commented on July 21, 2024

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?

from authentik.

cadeParade avatar cadeParade commented on July 21, 2024

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 <module>
    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.

from authentik.

ekoyle avatar ekoyle commented on July 21, 2024

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.

from authentik.

ekoyle avatar ekoyle commented on July 21, 2024

(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

from authentik.

ekoyle avatar ekoyle commented on July 21, 2024

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 encode/django-rest-framework#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: tfranzel/drf-spectacular#383

from authentik.

Related Issues (20)

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.