authentikate 2.2.0__tar.gz → 2.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {authentikate-2.2.0 → authentikate-2.2.1}/PKG-INFO +1 -1
  2. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/base_models.py +13 -13
  3. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/expand.py +52 -39
  4. authentikate-2.2.1/authentikate/migrations/0006_alter_app_identifier_alter_release_unique_together.py +22 -0
  5. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/models.py +3 -1
  6. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/provenance/models.py +7 -10
  7. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/strawberry/extension.py +4 -1
  8. {authentikate-2.2.0 → authentikate-2.2.1}/pyproject.toml +14 -11
  9. {authentikate-2.2.0 → authentikate-2.2.1}/.gitignore +0 -0
  10. {authentikate-2.2.0 → authentikate-2.2.1}/LICENSE +0 -0
  11. {authentikate-2.2.0 → authentikate-2.2.1}/README.md +0 -0
  12. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/__init__.py +0 -0
  13. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/admin.py +0 -0
  14. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/apps.py +0 -0
  15. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/decode.py +0 -0
  16. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/errors.py +0 -0
  17. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/0001_initial.py +0 -0
  18. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/0002_membership.py +0 -0
  19. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/0003_app_release_client_release.py +0 -0
  20. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/0004_device_client_device.py +0 -0
  21. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/0005_alter_client_client_id.py +0 -0
  22. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/migrations/__init__.py +0 -0
  23. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/protocols.py +0 -0
  24. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/provenance/__init__.py +0 -0
  25. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/provenance/canonical.py +0 -0
  26. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/provenance/decode.py +0 -0
  27. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/provenance/verify.py +0 -0
  28. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/settings.py +0 -0
  29. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/strawberry/__init__.py +0 -0
  30. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/strawberry/directives.py +0 -0
  31. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/strawberry/info.py +0 -0
  32. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/strawberry/types.py +0 -0
  33. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/utils.py +0 -0
  34. {authentikate-2.2.0 → authentikate-2.2.1}/authentikate/vars.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: authentikate
3
- Version: 2.2.0
3
+ Version: 2.2.1
4
4
  Author-email: jhnnsrs <jhnnsrs@gmail.com>
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -1,7 +1,7 @@
1
1
  import hashlib
2
2
  import logging
3
3
  import asyncio
4
- from typing import Literal, Type, Union, Annotated, cast
4
+ from typing import Literal, Union, Annotated, cast
5
5
  import httpx
6
6
  from pydantic import (
7
7
  BaseModel,
@@ -94,30 +94,28 @@ class JWTToken(BaseModel):
94
94
  """ The client device identifier """
95
95
 
96
96
  @field_validator("aud", mode="before")
97
- def aud_to_list(
98
- cls: Type["JWTToken"], v: str | list[str] | None
99
- ) -> list[str] | None:
97
+ @classmethod
98
+ def aud_to_list(cls, v: str | list[str] | None) -> list[str] | None:
100
99
  """Convert the aud to a list"""
101
100
  return coerce_aud_to_list(v)
102
101
 
103
102
  @field_validator("sub", mode="before")
104
- def sub_to_username(cls: Type["JWTToken"], v: str) -> str:
103
+ @classmethod
104
+ def sub_to_username(cls, v: str) -> str:
105
105
  """Convert the sub to a username compatible string"""
106
106
  if isinstance(v, int):
107
107
  return str(v)
108
108
  return v
109
109
 
110
110
  @field_validator("iat", mode="before")
111
- def iat_to_datetime(
112
- cls: Type["JWTToken"], v: int
113
- ) -> datetime.datetime | None:
111
+ @classmethod
112
+ def iat_to_datetime(cls, v: int) -> datetime.datetime | None:
114
113
  """Convert the iat to a datetime object"""
115
114
  return coerce_unix_to_datetime(v)
116
115
 
117
116
  @field_validator("exp", mode="before")
118
- def exp_to_datetime(
119
- cls: Type["JWTToken"], v: int
120
- ) -> datetime.datetime | None:
117
+ @classmethod
118
+ def exp_to_datetime(cls, v: int) -> datetime.datetime | None:
121
119
  """Convert the exp to a datetime object"""
122
120
  return coerce_unix_to_datetime(v)
123
121
 
@@ -266,7 +264,8 @@ class JWKIssuer(Issuer):
266
264
  """The JWKS document of the issuer (a dict with a "keys" list)"""
267
265
 
268
266
  @field_validator("jwks", mode="before")
269
- def validate_jwks_dict(cls: Type["JWKIssuer"], v: Dict[str, Any]) -> Dict[str, Any]:
267
+ @classmethod
268
+ def validate_jwks_dict(cls, v: Dict[str, Any]) -> Dict[str, Any]:
270
269
  """Validate the jwks dict"""
271
270
  if not isinstance(v, dict):
272
271
  raise ValueError("jwks_dict must be a dict")
@@ -531,7 +530,8 @@ class ProvenanceSettings(BaseModel):
531
530
  """The signature algorithms allowed for provenance tokens (alg is pinned)."""
532
531
 
533
532
  @field_validator("algorithms")
534
- def reject_unsafe_algorithms(cls: Type["ProvenanceSettings"], v: list[str]) -> list[str]:
533
+ @classmethod
534
+ def reject_unsafe_algorithms(cls, v: list[str]) -> list[str]:
535
535
  """Pin the alg per RFC 8725: forbid an empty list and the ``none`` alg.
536
536
 
537
537
  An empty allow-list or ``alg: none`` would let an attacker present an
@@ -1,5 +1,6 @@
1
1
  from dataclasses import dataclass
2
2
  from django.contrib.auth.models import Group
3
+ from django.db import IntegrityError
3
4
  from authentikate import base_models, models
4
5
  import logging
5
6
  from typing import cast
@@ -167,26 +168,7 @@ async def aexpand_user_from_token(
167
168
 
168
169
  try:
169
170
  user = await models.User.objects.aget(sub=token.sub, iss=token.iss)
170
- if user.changed_hash != token.changed_hash:
171
- # User has changed, update the user object
172
- user.first_name = token.preferred_username
173
- user.changed_hash = token.changed_hash
174
-
175
- if organization is not None:
176
- user.active_organization = organization
177
- elif token.active_org:
178
- current_org, _ = await models.Organization.objects.aget_or_create(
179
- slug=token.active_org,
180
- )
181
- user.active_organization = current_org
182
-
183
- await user.asave()
184
- await aset_user_groups(user, token.roles)
185
-
186
- return user
187
-
188
171
  except models.User.DoesNotExist:
189
-
190
172
  user = models.User(
191
173
  sub=token.sub,
192
174
  username=token_to_username(token),
@@ -196,6 +178,30 @@ async def aexpand_user_from_token(
196
178
  user.first_name = token.preferred_username
197
179
  user.changed_hash = token.changed_hash
198
180
 
181
+ if organization is not None:
182
+ user.active_organization = organization
183
+ elif token.active_org:
184
+ current_org, _ = await models.Organization.objects.aget_or_create(
185
+ slug=token.active_org,
186
+ )
187
+ user.active_organization = current_org
188
+
189
+ try:
190
+ await user.asave()
191
+ await aset_user_groups(user, token.roles)
192
+ return user
193
+ except IntegrityError:
194
+ # Lost a concurrent create race: another request authenticating the
195
+ # same token already inserted this (sub, iss) user. Fall through to
196
+ # treat the winner's row as an existing user instead of propagating
197
+ # the IntegrityError.
198
+ user = await models.User.objects.aget(sub=token.sub, iss=token.iss)
199
+
200
+ if user.changed_hash != token.changed_hash:
201
+ # The token's user metadata changed since we last saw it: sync it across.
202
+ user.first_name = token.preferred_username
203
+ user.changed_hash = token.changed_hash
204
+
199
205
  if organization is not None:
200
206
  user.active_organization = organization
201
207
  elif token.active_org:
@@ -206,7 +212,8 @@ async def aexpand_user_from_token(
206
212
 
207
213
  await user.asave()
208
214
  await aset_user_groups(user, token.roles)
209
- return user
215
+
216
+ return user
210
217
 
211
218
 
212
219
  async def aexpand_token_context(
@@ -240,25 +247,7 @@ def expand_user_from_token(
240
247
 
241
248
  try:
242
249
  user = models.User.objects.get(sub=token.sub, iss=token.iss)
243
- if user.changed_hash != token.changed_hash:
244
- # User has changed, update the user object
245
- user.first_name = token.preferred_username
246
- user.changed_hash = token.changed_hash
247
- set_user_groups(user, token.roles)
248
-
249
- if token.active_org:
250
- current_org, _ = models.Organization.objects.get_or_create(
251
- slug=token.active_org
252
- )
253
-
254
- user.active_organization = current_org
255
-
256
- user.save()
257
-
258
- return user
259
-
260
250
  except models.User.DoesNotExist:
261
-
262
251
  user = models.User(
263
252
  sub=token.sub,
264
253
  username=(token_to_username(token)),
@@ -268,6 +257,29 @@ def expand_user_from_token(
268
257
  user.first_name = token.preferred_username
269
258
  user.changed_hash = token.changed_hash
270
259
 
260
+ if token.active_org:
261
+ current_org, _ = models.Organization.objects.get_or_create(
262
+ slug=token.active_org
263
+ )
264
+
265
+ user.active_organization = current_org
266
+
267
+ try:
268
+ user.save()
269
+ set_user_groups(user, token.roles)
270
+ return user
271
+ except IntegrityError:
272
+ # Lost a concurrent create race: another request authenticating the
273
+ # same token already inserted this (sub, iss) user. Fall through to
274
+ # treat the winner's row as an existing user instead of propagating
275
+ # the IntegrityError.
276
+ user = models.User.objects.get(sub=token.sub, iss=token.iss)
277
+
278
+ if user.changed_hash != token.changed_hash:
279
+ # The token's user metadata changed since we last saw it: sync it across.
280
+ user.first_name = token.preferred_username
281
+ user.changed_hash = token.changed_hash
282
+
271
283
  if token.active_org:
272
284
  current_org, _ = models.Organization.objects.get_or_create(
273
285
  slug=token.active_org
@@ -277,7 +289,8 @@ def expand_user_from_token(
277
289
 
278
290
  user.save()
279
291
  set_user_groups(user, token.roles)
280
- return user
292
+
293
+ return user
281
294
 
282
295
 
283
296
  async def aexpand_client_from_token(
@@ -0,0 +1,22 @@
1
+ # Generated by Django 6.0.5 on 2026-06-29 15:00
2
+
3
+ from django.db import migrations, models
4
+
5
+
6
+ class Migration(migrations.Migration):
7
+
8
+ dependencies = [
9
+ ("authentikate", "0005_alter_client_client_id"),
10
+ ]
11
+
12
+ operations = [
13
+ migrations.AlterField(
14
+ model_name="app",
15
+ name="identifier",
16
+ field=models.CharField(max_length=2000, unique=True),
17
+ ),
18
+ migrations.AlterUniqueTogether(
19
+ name="release",
20
+ unique_together={("app", "version")},
21
+ ),
22
+ ]
@@ -82,7 +82,7 @@ class Device(models.Model):
82
82
  class App(models.Model):
83
83
  """An App model to represent an application in the system"""
84
84
 
85
- identifier = models.CharField(max_length=2000)
85
+ identifier = models.CharField(max_length=2000, unique=True)
86
86
  """The application identifier (from the token's client_app claim)"""
87
87
 
88
88
  def __str__(self) -> str:
@@ -101,6 +101,8 @@ class Release(models.Model):
101
101
  class Meta:
102
102
  """Meta class for Release"""
103
103
 
104
+ unique_together = ("app", "version")
105
+
104
106
 
105
107
  class Client(models.Model):
106
108
  """An Oauth2 Client
@@ -7,7 +7,7 @@ verification entrypoint.
7
7
  """
8
8
 
9
9
  import datetime
10
- from typing import Any, Type
10
+ from typing import Any
11
11
 
12
12
  from pydantic import BaseModel, ConfigDict, field_validator
13
13
 
@@ -73,23 +73,20 @@ class ProvenanceToken(BaseModel):
73
73
  """The raw original token string."""
74
74
 
75
75
  @field_validator("aud", mode="before")
76
- def aud_to_list(
77
- cls: Type["ProvenanceToken"], v: str | list[str] | None
78
- ) -> list[str] | None:
76
+ @classmethod
77
+ def aud_to_list(cls, v: str | list[str] | None) -> list[str] | None:
79
78
  """Convert the aud to a list"""
80
79
  return coerce_aud_to_list(v)
81
80
 
82
81
  @field_validator("iat", mode="before")
83
- def iat_to_datetime(
84
- cls: Type["ProvenanceToken"], v: int
85
- ) -> datetime.datetime | None:
82
+ @classmethod
83
+ def iat_to_datetime(cls, v: int) -> datetime.datetime | None:
86
84
  """Convert the iat to a datetime object"""
87
85
  return coerce_unix_to_datetime(v)
88
86
 
89
87
  @field_validator("exp", mode="before")
90
- def exp_to_datetime(
91
- cls: Type["ProvenanceToken"], v: int
92
- ) -> datetime.datetime | None:
88
+ @classmethod
89
+ def exp_to_datetime(cls, v: int) -> datetime.datetime | None:
93
90
  """Convert the exp to a datetime object"""
94
91
  return coerce_unix_to_datetime(v)
95
92
 
@@ -144,7 +144,10 @@ class AuthentikateExtension(SchemaExtension):
144
144
  dict(context.headers), settings
145
145
  )
146
146
  if provenance is not None:
147
- context.request.set_provenance(provenance)
147
+ # ProvenanceToken structurally satisfies kante's
148
+ # Provenance protocol at runtime; the nested Actor
149
+ # types differ only by protocol invariance.
150
+ context.request.set_provenance(provenance) # pyright: ignore[reportArgumentType]
148
151
  context.request.set_extension("provenance", provenance)
149
152
  else:
150
153
  raise ValueError(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "authentikate"
3
- version = "2.2.0"
3
+ version = "2.2.1"
4
4
  description = ""
5
5
  authors = [{ name = "jhnnsrs", email = "jhnnsrs@gmail.com" }]
6
6
  requires-python = ">=3.12, <4.0"
@@ -23,7 +23,8 @@ dev-dependencies = [
23
23
  "cryptography>=45.0.1",
24
24
  "ruff>=0.0.282,<0.0.283",
25
25
  "black>=22",
26
- "django-stubs>=4.2.7,<5",
26
+ "basedpyright>=1.10.0",
27
+ "django-types>=0.19.1",
27
28
  "python-semantic-release>=9.21.1",
28
29
  "daphne>=4.1.2",
29
30
  "pytest-asyncio>=0.23.8",
@@ -34,11 +35,16 @@ omit = [
34
35
  "test_project/*"
35
36
  ]
36
37
 
37
- [tool.mypy]
38
- exclude = ["venv/", "tests/", "examples/"]
39
- plugins = ["mypy_django_plugin.main","pydantic.mypy"]
40
- ignore_missing_imports = true
41
- strict = true
38
+ [tool.basedpyright]
39
+ include = ["authentikate"]
40
+ exclude = ["**/migrations", "venv", ".venv", "tests", "examples"]
41
+ pythonVersion = "3.12"
42
+ typeCheckingMode = "standard"
43
+ reportMissingTypeStubs = false
44
+ # Pydantic models intentionally narrow inherited fields (e.g. a discriminator
45
+ # `kind: str` -> `Literal[...]`, or an optional base field made required in a
46
+ # subclass). Pyright flags these because mutable attributes are invariant.
47
+ reportIncompatibleVariableOverride = false
42
48
 
43
49
  [tool.hatch.build.targets.sdist]
44
50
  include = ["authentikate"]
@@ -50,9 +56,6 @@ include = ["authentikate"]
50
56
  requires = ["hatchling"]
51
57
  build-backend = "hatchling.build"
52
58
 
53
- [tool.django-stubs]
54
- django_settings_module = "test_project.settings"
55
-
56
59
  [tool.pytest.ini_options]
57
60
  DJANGO_SETTINGS_MODULE = "test_project.settings"
58
61
 
@@ -61,7 +64,7 @@ DJANGO_SETTINGS_MODULE = "test_project.settings"
61
64
  # Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
62
65
  # McCabe complexity (`C901`) by default.
63
66
  extend-select = ["ANN", "D1"]
64
- extend-ignore = [ "ANN002", "ANN003", "D100", "ANN401", "ANN101"]
67
+ extend-ignore = [ "ANN002", "ANN003", "D100", "ANN401", "ANN101", "ANN102"]
65
68
 
66
69
  # Exclude a variety of commonly ignored directories.
67
70
  exclude = [
File without changes
File without changes
File without changes