Skip to content

[BUG] Django Ninja JWT Token Validation Issue #117

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
subham1099 opened this issue Feb 14, 2025 · 9 comments
Open

[BUG] Django Ninja JWT Token Validation Issue #117

subham1099 opened this issue Feb 14, 2025 · 9 comments

Comments

@subham1099
Copy link

subham1099 commented Feb 14, 2025

Description

When using Django Ninja JWT with a custom token obtain pair schema, the validation is being bypassed due to input type mismatch, leading to authentication errors.

Environment

  • Python: 3.12
  • Django: 5.1.6
  • django-ninja: 1.3.0
  • django-ninja-jwt: 5.3.5
  • pydantic: 2.9.2

Issue

The TokenObtainInputSchemaBase.validate_inputs method expects the input to be a dictionary, but in the current version of Django Ninja, the input is wrapped in a DjangoGetter object. This causes the validation to be bypassed, leading to a NoneType error when trying to authenticate.

Code

class TokenObtainPairInputSchema(TokenObtainInputSchemaBase):
      """Custom schema for token obtain pair."""
      @pyd.model_validator(mode="before")
      def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:
          input_values = values.obj
          request = values.context.get("request")
          # This condition is never true because input_values is a DjangoGetter
          if isinstance(input_values, dict):  # <--
              values.obj.update(
                  cls.validate_values(request=request, values=input_values)
              )
              return values
          return values

    @classmethod
    def get_response_schema(cls) -> type[Schema]:
        return TokenObtainPairOutputSchema

    @classmethod
    def get_token(cls, user: AbstractUser) -> dict[str, t.Any]:
        values = {}
        refresh = RefreshToken.for_user(user)
        values["refresh"] = str(refresh)
        values["access"] = str(refresh.access_token)
        values.update(
            user=UserSchema.from_orm(user)
        )  # this will be needed when creating output schema
        return values

Request

curl -X 'POST' \
'http://localhost:8001/api/v1/auth/token/pair' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"username": "string",
"password": "string"
}'

Error Log

[debug ] Input validation - values type: <class 'ninja.schema.DjangoGetter'>
[debug ] Input validation - input_values type: <class 'ninja.schema.DjangoGetter'>
[debug ] Input validation - input_values: <DjangoGetter: {'password': 'string', 'username': 'string'}>
[error ] 'NoneType' object has no attribute 'id'

Traceback (most recent call last):
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/operation.py", line 119, in run
    values = self._get_values(request, kw, temporal_response)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/operation.py", line 288, in _get_values
    data = model.resolve(request, self.api, path_params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/params/models.py", line 57, in resolve
    return cls.model_validate(data, context={"request": request})
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/pydantic/main.py", line 641, in model_validate
    return cls.__pydantic_validator__.validate_python(
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja/schema.py", line 228, in _run_root_validator
    return handler(values)
           ^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/schema.py", line 117, in post_validate
    return cls.post_validate_schema(values)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/schema.py", line 128, in post_validate_schema
    data = cls.get_token(cls._user)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/src/app/app/api/auth/token_obtain.py", line 93, in get_token
    refresh = RefreshToken.for_user(user)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninja_jwt/tokens.py", line 189, in for_user
    user_id = getattr(user, api_settings.USER_ID_FIELD)
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
AttributeError: 'NoneType' object has no attribute 'id'

Expected Behavior

The validation should handle both dictionary and DjangoGetter inputs, ensuring proper validation before authentication attempts.

Current Workaround

We've implemented a workaround by explicitly handling the DjangoGetter case:

@pyd.model_validator(mode="before")
def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:
    input_values = values.obj
    request = values.context.get("request")
    # Handle both dict and DjangoGetter inputs
    if isinstance(input_values, dict):
        values_dict = input_values
    else:
        # Convert DjangoGetter to dict
        values_dict = input_values._obj

    validated_values = cls.validate_values(request=request, values=values_dict)
    values.obj = validated_values
    return values

Questions

  1. Is this the intended behavior of the input validation?
  2. Should the base implementation be updated to handle DjangoGetter inputs?
  3. Is there a better way to handle this validation in custom schemas?
@subham1099
Copy link
Author

subham1099 commented Feb 14, 2025

For more context, I have also tried to use the original ninja-jwt's implementation without any customization, same issue.

following is how I add the router to ninja api:

from ninja import Router
from ninja_jwt.routers.obtain import obtain_pair_router


router = Router(tags=["auth"])

# This ninja_jwt router contains two endpoints:
#   - /pair: Obtain a pair of access and refresh tokens
#   - /refresh: Refresh an access token
router.add_router("/token", obtain_pair_router, auth=None, tags=["token"])

@subham1099
Copy link
Author

I also found out that setting model_config = pydantic.ConfigDict(...) also breaks the implementation. Example schema:

class TokenObtainPairInputSchema(TokenObtainInputSchemaBase):
    """Custom schema for token obtain pair.

    NOTE: this schema is used to customize the output schema of the token obtain pair.
    This is set in the project's settings.py file.
    """

    model_config = pyd.ConfigDict(extra="forbid")

    @classmethod
    def get_response_schema(cls) -> type[SchemaOut]:
        return TokenObtainPairOutputSchema

    @classmethod
    def get_token(cls, user: AbstractUser) -> dict[str, t.Any]:
        values = {}
        refresh = RefreshToken.for_user(user)
        values["refresh"] = str(refresh)
        values["access"] = str(refresh.access_token)
        values.update(
            user=UserSchema.from_orm(user)
        )  # this will be needed when creating output schema
        return values

results in - AttributeError: 'dict' object has no attribute '_obj' :

╭─────────────────────────────── Traceback (most recent call last) ────────────────────────────────╮
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/operation.py:119 in run                                                                        │
│                                                                                                  │
│   116 │   │   │   return error                                                                   │
│   117 │   │   try:                                                                               │
│   118 │   │   │   temporal_response = self.api.create_temporal_response(request)                 │
│ ❱ 119 │   │   │   values = self._get_values(request, kw, temporal_response)                      │
│   120 │   │   │   result = self.view_func(request, **values)                                     │
│   121 │   │   │   return self._result_to_response(request, result, temporal_response)            │
│   122 │   │   except Exception as e:                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────── locals ────────────────────────────────────────╮        │
│ │                 e = AttributeError("'dict' object has no attribute '_obj'")           │        │
│ │             error = None                                                              │        │
│ │                kw = {}                                                                │        │
│ │           request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                     │        │
│ │              self = <ninja.operation.Operation object at 0xffffa30d6db0>              │        │
│ │ temporal_response = <HttpResponse status_code=200, "application/json; charset=utf-8"> │        │
│ ╰───────────────────────────────────────────────────────────────────────────────────────╯        │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/operation.py:288 in _get_values                                                                │
│                                                                                                  │
│   285 │   │   values, errors = {}, []                                                            │
│   286 │   │   for model in self.models:                                                          │
│   287 │   │   │   try:                                                                           │
│ ❱ 288 │   │   │   │   data = model.resolve(request, self.api, path_params)                       │
│   289 │   │   │   │   values.update(data)                                                        │
│   290 │   │   │   except pydantic.ValidationError as e:                                          │
│   291 │   │   │   │   items = []                                                                 │
│                                                                                                  │
│ ╭─────────────────────────────────────── locals ────────────────────────────────────────╮        │
│ │            errors = []                                                                │        │
│ │       path_params = {}                                                                │        │
│ │           request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                     │        │
│ │              self = <ninja.operation.Operation object at 0xffffa30d6db0>              │        │
│ │ temporal_response = <HttpResponse status_code=200, "application/json; charset=utf-8"> │        │
│ │            values = {}                                                                │        │
│ ╰───────────────────────────────────────────────────────────────────────────────────────╯        │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/params/models.py:57 in resolve                                                                 │
│                                                                                                  │
│    54 │   │   │   return cls()                                                                   │
│    55 │   │                                                                                      │
│    56 │   │   data = cls._map_data_paths(data)                                                   │
│ ❱  57 │   │   return cls.model_validate(data, context={"request": request})                      │
│    58 │                                                                                          │
│    59 │   @classmethod                                                                           │
│    60 │   def _map_data_paths(cls, data: DictStrAny) -> DictStrAny:                              │
│                                                                                                  │
│ ╭────────────────────────────────────────── locals ──────────────────────────────────────────╮   │
│ │         api = <ninja_extra.main.NinjaExtraAPI object at 0xffffa32d7f50>                    │   │
│ │        data = {'user_token': {'username': 'user0@example.com', 'password': 'testpass123'}} │   │
│ │ path_params = {}                                                                           │   │
│ │     request = <WSGIRequest: POST '/api/v1/auth/token/pair'>                                │   │
│ ╰────────────────────────────────────────────────────────────────────────────────────────────╯   │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/pyda │
│ ntic/main.py:641 in model_validate                                                               │
│                                                                                                  │
│    638 │   │   """                                                                               │
│    639 │   │   # `__tracebackhide__` tells pytest and some other tools to omit this function fr  │
│    640 │   │   __tracebackhide__ = True                                                          │
│ ❱  641 │   │   return cls.__pydantic_validator__.validate_python(                                │
│    642 │   │   │   obj, strict=strict, from_attributes=from_attributes, context=context          │
│    643 │   │   )                                                                                 │
│    644                                                                                           │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │         context = {'request': <WSGIRequest: POST '/api/v1/auth/token/pair'>}                 │ │
│ │ from_attributes = None                                                                       │ │
│ │             obj = {                                                                          │ │
│ │                   │   'user_token': {                                                        │ │
│ │                   │   │   'username': 'user0@example.com',                                   │ │
│ │                   │   │   'password': 'testpass123'                                          │ │
│ │                   │   }                                                                      │ │
│ │                   }                                                                          │ │
│ │          strict = None                                                                       │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/schema.py:225 in _run_root_validator                                                           │
│                                                                                                  │
│   222 │   │   forbids_extra = cls.model_config.get("extra") == "forbid"                          │
│   223 │   │   should_validate_assignment = cls.model_config.get("validate_assignment", False)    │
│   224 │   │   if forbids_extra or should_validate_assignment:                                    │
│ ❱ 225 │   │   │   handler(values)                                                                │
│   226 │   │                                                                                      │
│   227 │   │   values = DjangoGetter(values, cls, info.context)                                   │
│   228 │   │   return handler(values)                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │              forbids_extra = True                                                            │ │
│ │                    handler = ValidatorCallable(Prebuilt(PrebuiltValidator {                  │ │
│ │                              schema_validator: Py(0xaaaafd71c3d0) }))                        │ │
│ │                       info = ValidationInfo(config={'title': 'BodyParams'},                  │ │
│ │                              context={'request': <WSGIRequest: POST                          │ │
│ │                              '/api/v1/auth/token/pair'>}, data={}, field_name=None)          │ │
│ │ should_validate_assignment = False                                                           │ │
│ │                     values = {'username': 'user0@example.com', 'password': 'testpass123'}    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a/schema.py:225 in _run_root_validator                                                           │
│                                                                                                  │
│   222 │   │   forbids_extra = cls.model_config.get("extra") == "forbid"                          │
│   223 │   │   should_validate_assignment = cls.model_config.get("validate_assignment", False)    │
│   224 │   │   if forbids_extra or should_validate_assignment:                                    │
│ ❱ 225 │   │   │   handler(values)                                                                │
│   226 │   │                                                                                      │
│   227 │   │   values = DjangoGetter(values, cls, info.context)                                   │
│   228 │   │   return handler(values)                                                             │
│                                                                                                  │
│ ╭─────────────────────────────────────────── locals ───────────────────────────────────────────╮ │
│ │              forbids_extra = True                                                            │ │
│ │                    handler = ValidatorCallable(Model(ModelValidator { revalidate: Never,     │ │
│ │                              validator: FunctionBefore(FunctionBeforeValidator { validator:  │ │
│ │                              ModelFields(ModelFieldsValidator { fields: [Field { name:       │ │
│ │                              "password", lookup_key: Simple { key: "password", py_key:       │ │
│ │                              Py(0xffffa2cddf30), path: LookupPath([S("password",             │ │
│ │                              Py(0xffffa2cde0f0))]) }, name_py: Py(0xffffbbb8a308),           │ │
│ │                              validator: StrConstrained(StrConstrainedValidator { strict:     │ │
│ │                              false, pattern: None, max_length: Some(128), min_length: None,  │ │
│ │                              strip_whitespace: false, to_lower: false, to_upper: false,      │ │
│ │                              coerce_numbers_to_str: false }), frozen: false }, Field { name: │ │
│ │                              "username", lookup_key: Simple { key: "username", py_key:       │ │
│ │                              Py(0xffffa2cddef0), path: LookupPath([S("username",             │ │
│ │                              Py(0xffffa2cdde30))]) }, name_py: Py(0xffffbafd23f0),           │ │
│ │                              validator: StrConstrained(StrConstrainedValidator { strict:     │ │
│ │                              false, pattern: None, max_length: Some(150), min_length: None,  │ │
│ │                              strip_whitespace: false, to_lower: false, to_upper: false,      │ │
│ │                              coerce_numbers_to_str: false }), frozen: false }], model_name:  │ │
│ │                              "TokenObtainPairInputSchema", extra_behavior: Forbid,           │ │
│ │                              extras_validator: None, strict: false, from_attributes: true,   │ │
│ │                              loc_by_alias: true }), func: Py(0xffffa2cdd500), config:        │ │
│ │                              Py(0xffffa2cde180), name: "function-before[validate_inputs(),   │ │
│ │                              model-fields]", field_name: None, info_arg: false }), class:    │ │
│ │                              Py(0xaaaafd71b480), generic_origin: None, post_init: None,      │ │
│ │                              frozen: false, custom_init: false, root_model: false,           │ │
│ │                              undefined: Py(0xffffb6b35c60), name:                            │ │
│ │                              "TokenObtainPairInputSchema" }))                                │ │
│ │                       info = ValidationInfo(config={'title': 'TokenObtainPairInputSchema',   │ │
│ │                              'extra_fields_behavior': 'forbid', 'from_attributes': True},    │ │
│ │                              context={'request': <WSGIRequest: POST                          │ │
│ │                              '/api/v1/auth/token/pair'>}, data={}, field_name=None)          │ │
│ │ should_validate_assignment = False                                                           │ │
│ │                     values = {'username': 'user0@example.com', 'password': 'testpass123'}    │ │
│ ╰──────────────────────────────────────────────────────────────────────────────────────────────╯ │
│                                                                                                  │
│ /home/ubuntu/.cache/pypoetry/virtualenvs/app-VA82Wl8V-py3.12/lib/python3.12/site-packages/ninj │
│ a_jwt/schema.py:106 in validate_inputs                                                           │
│                                                                                                  │
│   103 │                                                                                          │
│   104 │   @model_validator(mode="before")                                                        │
│   105 │   def validate_inputs(cls, values: DjangoGetter) -> DjangoGetter:                        │
│ ❱ 106 │   │   input_values = values._obj                                                         │
│   107 │   │   request = values._context.get("request")                                           │
│   108 │   │   if isinstance(input_values, dict):                                                 │
│   109 │   │   │   values._obj.update(                                                            │
│                                                                                                  │
│ ╭─────────────────────────────── locals ────────────────────────────────╮                        │
│ │ values = {'username': 'user0@example.com', 'password': 'testpass123'} │                        │
│ ╰───────────────────────────────────────────────────────────────────────╯                        │
╰──────────────────────────────────────────────────────────────────────────────────────────────────╯
AttributeError: 'dict' object has no attribute '_obj'

@subham1099 subham1099 changed the title Django Ninja JWT Token Validation Issue ( [BUG] Django Ninja JWT Token Validation Issue Feb 14, 2025
@eadwinCode
Copy link
Owner

@subham1099 So sorry for the late response. Reason #123

I know, if you set, extra to forbid, we wont be able to set _obj. That strategy was adopted as a workaround.
I will look at this over the weekend

@subham1099
Copy link
Author

@eadwinCode No problem. I will make sure to include a cc: in the future. The problem here is that the schema validation doesn't work as intended.

  1. TokenObtainInputSchemaBase expects that values param be a DjangoGetter. but if extra=forbid is set, values param is a dict not a DjangoGetter.

  2. My original issue was that input_values._obj is also a DjangoGetter object. So there should be proper way to validate the nested DjangoGetter objects

@eadwinCode
Copy link
Owner

@subham1099 Alright I will look into thanks for the summary

@eadwinCode
Copy link
Owner

@subham1099 Sorry this took so long to fix, I have been very busy with work. If you are still using this on your project please let me know if the new release fixes the issue. Thanks

@subham1099
Copy link
Author

Okay, I looked at the changes and it correctly handles the extra=forbid part. My original issue still persists, values.obj is also a DjangoGetter

@eadwinCode
Copy link
Owner

I am beginning to understand the issue here. First, authentication should happen after schema validation, not before. That way, Pydantic, and Ninja DjangoGetter will have resolved everything about the schema, and then we can proceed to authentication and other things.
Custom schemas don't have to override before schema validation unless needed.

Is this the expected behavior for you too?

@subham1099
Copy link
Author

I will test the changes out today and get back to you by EOD with a reproducible example if the issue still persists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants