Add company welcome and verified NetKingdom sign-in
Some checks failed
CI Smoke / host-smoke (push) Waiting to run
CI Smoke / container-smoke (push) Waiting to run
Application acceptance / application-tests (push) Has been cancelled
Build and Publish Container Image / build-and-push (push) Successful in 40s

Assistant: codex
Assistant-Model: gpt-6-astra
Assistant-Session: 01a092fe-13b1-7f12-ac74-7d258af4d79c
This commit is contained in:
tegwick 2026-09-12 02:43:31 +02:00
parent 4175709024
commit 8be281025b
17 changed files with 1044 additions and 23 deletions

View file

@ -119,4 +119,5 @@ CMD ["gunicorn", "vergabe_teilnahme.wsgi:application", \
"--bind", "0.0.0.0:8000", \
"--workers", "3", \
"--access-logfile", "-", \
"--access-logformat", "%(h)s %(m)s %(U)s %(s)s %(L)s", \
"--error-logfile", "-"]

View file

@ -0,0 +1,74 @@
# Company welcome and NetKingdom sign-in
VERGABE-WP-0019-T06. The isolated company instance consumes Authorization Code
with S256 PKCE and a browser-bound state and nonce. It discovers endpoints from
the configured HTTPS issuer, pins RS256, verifies signature/issuer/audience,
time and nonce, and uses a unique `(issuer, subject)` record for the local user.
It never merges accounts by email or directory display name.
Admission requires the exact configured tenant, `tenant_source: directory`, a
human principal and the signed `<tenant>:users` membership group. Suspended,
platform and emergency identities fail. This is the existing invited-company
pilot rule: admitted members collaborate on all company tenders. Tenant-admin
claims do not grant Django staff or superuser rights. New local users have no
usable local password. Local deactivation immediately blocks existing sessions;
provider-side revocation is bounded by the product's absolute five-minute
session maximum (or the token expiry, whichever comes first).
The welcome page never consumes password-setup tokens. A CSRF-protected sign-in
action clears the current product session and requests `prompt=login`. A valid
callback displays the verified account and requires another CSRF-protected
confirmation before creating a product session. External `next` and `return_to`
parameters are not used. The callback redirects to a clean URL before displaying
identity. Gunicorn access logging records the URL path without query strings.
Passwords and bearer tokens are not persisted in the product.
## Exact demo binding
Enable only with the registered client and the verified KeyCape fresh-login
release. The currently deployed `dcebd46` issuer lacks `tenant_source` and does
not propagate fresh-login requests to Authelia; this product fails closed on it.
```
NETKINGDOM_ENABLED=true
NETKINGDOM_ISSUER=https://kc.coulomb.social
NETKINGDOM_CLIENT_ID=vergabe-demo-company
NETKINGDOM_CALLBACK=https://vergabe-teilnahme.coulomb.social/demo-company/accounts/oidc/callback/
NETKINGDOM_TENANT=tenant:trial:demo-company
COMPANY_DISPLAY_NAME=Demo Company
```
Use a public client with only `openid profile groups`, `authorization_code`,
and the exact callback above. Do not add a client-declared tenant or a weaker
MFA override. Keep the existing provider MFA policy. The application namespace
requires narrowly scoped HTTPS egress to the issuer; its existing policy only
allows database traffic and DNS.
NetKingdom's password setup maps `tenant:trial:demo-company` to the fixed HTTPS
company entry in `PASSWORD_SETUP_TENANT_RETURNS`. That return is stored inside
the setup grant at issuance and released only after successful consumption.
There is no browser-supplied return address, recipient credential transfer,
automatic product login or modification to the operator's portal session.
Existing setup grants have no new destination; issue a fresh link if needed.
## Release and acceptance
Run application tests, assets build and migration drift checks, publish the
exact source, and pin its digest in the existing Railiance pilot values.
Migration 0003 adds only the unique identity mapping table. Keep the accepted
60m CPU / 256Mi memory request and both data claims. Disabling
NETKINGDOM_ENABLED restores the interim local login; OIDC-created users still
have unusable local passwords, so rollback does not manufacture credentials.
Keep the added table when reverting an application image.
KeyCape's current main also contains previously accepted startup and token
issuance changes after deployed source dcebd46; validate the live registration
configuration before upgrading (key-cape/docs/operations.md). Existing login
attempts are process-local and are lost on its single-replica Recreate rollout.
Password-setup links are likewise process-local and expire on provisioner restart.
Native acceptance still requires the actual invited user to complete setup,
fresh sign-in and any provider-required MFA, confirm their account, and enter
the company workflow. Do not substitute an operator session. Recovery and
two-user workflow acceptance remain the existing RAPPS-WP-0014-T03 and
VERGABE-WP-0019-T04 tasks.

View file

@ -12,6 +12,7 @@ dependencies = [
"dj-database-url>=2.1",
"issue-core>=0.2,<0.3",
"gunicorn>=22.0",
"PyJWT[crypto]>=2.10,<3",
]
[tool.uv.sources]

160
uv.lock generated
View file

@ -61,6 +61,91 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/30/7cd8fdcdfbc5b869528b079bfb76dcdf6056b1a2097a662e5e8c04f42965/certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a", size = 135707 },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821 },
{ url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719 },
{ url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799 },
{ url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389 },
{ url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249 },
{ url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775 },
{ url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822 },
{ url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232 },
{ url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597 },
{ url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292 },
{ url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919 },
{ url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093 },
{ url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248 },
{ url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908 },
{ url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805 },
{ url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764 },
{ url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722 },
{ url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369 },
{ url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175 },
{ url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670 },
{ url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824 },
{ url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148 },
{ url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564 },
{ url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263 },
{ url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688 },
{ url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078 },
{ url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064 },
{ url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720 },
{ url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964 },
{ url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962 },
{ url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328 },
{ url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985 },
{ url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530 },
{ url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525 },
{ url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053 },
{ url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213 },
{ url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682 },
{ url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949 },
{ url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947 },
{ url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504 },
{ url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259 },
{ url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864 },
{ url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538 },
{ url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688 },
{ url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803 },
{ url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763 },
{ url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688 },
{ url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868 },
{ url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104 },
{ url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402 },
{ url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043 },
{ url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737 },
{ url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933 },
{ url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002 },
{ url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271 },
{ url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919 },
{ url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529 },
{ url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630 },
{ url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134 },
{ url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197 },
{ url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683 },
{ url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897 },
{ url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935 },
{ url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464 },
{ url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262 },
{ url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779 },
{ url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520 },
{ url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673 },
{ url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835 },
{ url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705 },
{ url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539 },
{ url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707 },
{ url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772 },
{ url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.7"
@ -239,6 +324,56 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346 },
]
[[package]]
name = "cryptography"
version = "50.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153 },
{ url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133 },
{ url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478 },
{ url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726 },
{ url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524 },
{ url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720 },
{ url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866 },
{ url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028 },
{ url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405 },
{ url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230 },
{ url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596 },
{ url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082 },
{ url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826 },
{ url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525 },
{ url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817 },
{ url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300 },
{ url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039 },
{ url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388 },
{ url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293 },
{ url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031 },
{ url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344 },
{ url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201 },
{ url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023 },
{ url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362 },
{ url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247 },
{ url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806 },
{ url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307 },
{ url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900 },
{ url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357 },
{ url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474 },
{ url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862 },
{ url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942 },
{ url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347 },
{ url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050 },
{ url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955 },
{ url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694 },
{ url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413 },
{ url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355 },
{ url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429 },
]
[[package]]
name = "dj-database-url"
version = "3.1.2"
@ -571,6 +706,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/e6/5fff07a70d1f945ed90ae131c3bd76cab32beff7c58c6db15ad5820b6d1f/psycopg_binary-3.3.4-cp314-cp314-win_amd64.whl", hash = "sha256:c37e024c07308cd06cf3ec51bfd0e7f6157585a4d84d1bce4a7f5f7913719bf8", size = 3666849 },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 },
]
[[package]]
name = "pygments"
version = "2.20.0"
@ -580,6 +724,20 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 },
]
[[package]]
name = "pyjwt"
version = "2.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896 },
]
[package.optional-dependencies]
crypto = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.0.3"
@ -748,6 +906,7 @@ dependencies = [
{ name = "gunicorn" },
{ name = "issue-core" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pyjwt", extra = ["crypto"] },
{ name = "python-decouple" },
{ name = "whitenoise" },
]
@ -770,6 +929,7 @@ requires-dist = [
{ name = "gunicorn", specifier = ">=22.0" },
{ name = "issue-core", specifier = ">=0.2,<0.3", index = "https://forgejo.coulomb.social/api/packages/coulomb/pypi/simple/" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" },
{ name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" },
{ name = "python-decouple", specifier = ">=3.8" },
{ name = "whitenoise", specifier = ">=6.7" },
]

View file

@ -0,0 +1,7 @@
from django.conf import settings
def company_context(request):
if settings.NETKINGDOM_ENABLED:
return {'company_name': settings.COMPANY_DISPLAY_NAME}
return {}

View file

@ -1,3 +1,7 @@
import time
from django.conf import settings
from django.contrib.auth import logout
from django.contrib.auth.middleware import LoginRequiredMiddleware
from django.contrib.auth.views import redirect_to_login
from django.http import HttpResponse
@ -8,15 +12,31 @@ from django.utils.cache import add_never_cache_headers
class PilotLoginRequiredMiddleware(LoginRequiredMiddleware):
"""Use Django's default-deny gate, including full-page HTMX reauthentication."""
def process_request(self, request):
expiry = request.session.get("oidc_expires")
if expiry is not None and (
expiry <= time.time()
or request.user.is_staff
or request.user.is_superuser
or not settings.NETKINGDOM_ENABLED
or request.session.get("oidc_binding")
!= [
settings.NETKINGDOM_ISSUER,
settings.NETKINGDOM_TENANT,
settings.NETKINGDOM_CLIENT_ID,
]
):
logout(request)
def handle_no_permission(self, request, view_func):
if request.headers.get('HX-Request') == 'true':
if request.headers.get("HX-Request") == "true":
# The original URL may render only a fragment or accept only POST.
login = redirect_to_login(reverse('home'), self.get_login_url(view_func))
return HttpResponse(status=401, headers={'HX-Redirect': login.url})
login = redirect_to_login(reverse("home"), self.get_login_url(view_func))
return HttpResponse(status=401, headers={"HX-Redirect": login.url})
return super().handle_no_permission(request, view_func)
def process_response(self, request, response):
# Company records and account pages must not survive in shared caches.
if request.path_info != '/health/':
if request.path_info != "/health/":
add_never_cache_headers(response)
return response

View file

@ -0,0 +1,45 @@
# Generated by Django 6.0.5 on 2026-09-12 00:31
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0002_alter_mitarbeiter_mobilnummer_and_more"),
]
operations = [
migrations.CreateModel(
name="OIDCIdentity",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("issuer", models.CharField(max_length=512)),
("subject", models.CharField(max_length=512)),
(
"user",
models.OneToOneField(
on_delete=django.db.models.deletion.PROTECT,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"constraints": [
models.UniqueConstraint(
fields=("issuer", "subject"), name="unique_oidc_identity"
)
],
},
),
]

View file

@ -23,3 +23,13 @@ class Mitarbeiter(AbstractUser):
class Meta:
verbose_name = 'Mitarbeiter'
verbose_name_plural = 'Mitarbeiter'
class OIDCIdentity(models.Model):
issuer = models.CharField(max_length=512)
subject = models.CharField(max_length=512)
user = models.OneToOneField(Mitarbeiter, on_delete=models.PROTECT)
class Meta:
constraints = [models.UniqueConstraint(fields=['issuer', 'subject'],
name='unique_oidc_identity')]

View file

@ -0,0 +1,210 @@
"""Bounded NetKingdom OIDC relying party for one isolated company instance."""
import base64
import hashlib
import json
import secrets
import time
from functools import lru_cache
from urllib.parse import urlencode, urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
import jwt
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class LoginRejectedError(ValueError):
pass
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
def configuration():
issuer = settings.NETKINGDOM_ISSUER
callback = settings.NETKINGDOM_CALLBACK
for value in (issuer, callback):
parts = urlsplit(value)
if (
parts.scheme != "https"
or not parts.hostname
or parts.username
or parts.password
or parts.query
or parts.fragment
or parts.hostname in {"localhost", "127.0.0.1"}
):
raise ImproperlyConfigured("NetKingdom requires fixed HTTPS issuer and callback URLs")
if not settings.NETKINGDOM_CLIENT_ID or not settings.NETKINGDOM_TENANT:
raise ImproperlyConfigured("NetKingdom client and company binding are required")
expected = settings.APP_BASE_PATH + "/accounts/oidc/callback/"
if urlsplit(callback).path != expected:
raise ImproperlyConfigured("NetKingdom callback must match the fixed company path")
if settings.SESSION_ENGINE == "django.contrib.sessions.backends.signed_cookies":
raise ImproperlyConfigured("OIDC requires server-side sessions")
return issuer, settings.NETKINGDOM_CLIENT_ID, callback
def read_json(url, data=None):
request = Request(
url,
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
},
)
with build_opener(NoRedirect).open(request, timeout=10) as response:
body = response.read(262145)
if len(body) > 262144:
raise LoginRejectedError("Oversized provider response")
result = json.loads(body)
if not isinstance(result, dict):
raise LoginRejectedError("Invalid provider response")
return result
@lru_cache(maxsize=8)
def discovery(issuer, period):
metadata = read_json(issuer.rstrip("/") + "/.well-known/openid-configuration")
if metadata.get("issuer") != issuer:
raise LoginRejectedError("Issuer mismatch")
for field in ("authorization_endpoint", "token_endpoint", "jwks_uri"):
parts = urlsplit(metadata.get(field, ""))
if (
parts.scheme != "https"
or parts.netloc != urlsplit(issuer).netloc
or parts.username
or parts.password
or parts.fragment
or parts.query
):
raise LoginRejectedError("Unapproved provider endpoint")
if "S256" not in metadata.get(
"code_challenge_methods_supported", []
) or "RS256" not in metadata.get("id_token_signing_alg_values_supported", []):
raise LoginRejectedError("Provider does not support the admitted flow")
return metadata
@lru_cache(maxsize=8)
def key_client(uri):
return jwt.PyJWKClient(uri, timeout=10, lifespan=300)
def begin():
issuer, client, callback = configuration()
metadata = discovery(issuer, int(time.time() // 300))
pending = {
"state": secrets.token_urlsafe(32),
"nonce": secrets.token_urlsafe(32),
"verifier": secrets.token_urlsafe(64),
"created": time.time(),
}
challenge = (
base64.urlsafe_b64encode(
hashlib.sha256(pending["verifier"].encode("ascii")).digest(),
)
.decode("ascii")
.rstrip("=")
)
parameters = {
"response_type": "code",
"client_id": client,
"redirect_uri": callback,
"scope": "openid profile groups",
"state": pending["state"],
"nonce": pending["nonce"],
"code_challenge": challenge,
"code_challenge_method": "S256",
"prompt": "login",
"tenant_hint": settings.NETKINGDOM_TENANT,
}
return pending, metadata["authorization_endpoint"] + "?" + urlencode(parameters)
def verify_claims(claims):
"""Identity evidence plus the invited pilot's explicit company admission rule."""
tenant = settings.NETKINGDOM_TENANT
groups, roles = claims.get("groups"), claims.get("roles")
if (
claims.get("tenant") != tenant
or claims.get("tenant_source") != "directory"
or claims.get("principal_type") != "human"
or not isinstance(groups, list)
or not all(isinstance(g, str) for g in groups)
or not isinstance(roles, list)
or not all(isinstance(r, str) for r in roles)
or tenant + ":users" not in groups
or {"netkingdom-suspended", "net-kingdom-admins"} & set(groups)
or {"platform-operator", "platform-root", "emergency"} & set(roles)
):
raise LoginRejectedError("Company membership is not established")
assurance = claims.get("assurance")
if not isinstance(assurance, dict) or assurance.get("level") not in {"aal1", "aal2", "aal3"}:
raise LoginRejectedError("Missing authentication assurance")
if not isinstance(claims.get("sub"), str) or not 0 < len(claims["sub"]) <= 512:
raise LoginRejectedError("Invalid subject")
def complete(pending, state, code):
if (
not isinstance(pending, dict)
or not state
or not code
or len(code) > 8192
or not secrets.compare_digest(pending.get("state", ""), state)
or not 0 <= time.time() - pending.get("created", 0) <= 600
):
raise LoginRejectedError("Invalid or expired sign-in")
issuer, client, callback = configuration()
metadata = discovery(issuer, int(time.time() // 300))
tokens = read_json(
metadata["token_endpoint"],
urlencode(
{
"grant_type": "authorization_code",
"client_id": client,
"redirect_uri": callback,
"code": code,
"code_verifier": pending["verifier"],
}
).encode("ascii"),
)
token = tokens.get("id_token")
if not isinstance(token, str) or len(token) > 32768:
raise LoginRejectedError("Missing ID token")
key = key_client(metadata["jwks_uri"]).get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
key.key,
algorithms=["RS256"],
issuer=issuer,
audience=client,
options={"require": ["iss", "sub", "aud", "exp", "iat", "nonce"]},
)
audience = claims["aud"]
if (
(isinstance(audience, list) and len(audience) > 1 and claims.get("azp") != client)
or ("azp" in claims and claims["azp"] != client)
or not isinstance(claims["nonce"], str)
or not secrets.compare_digest(claims["nonce"], pending["nonce"])
):
raise LoginRejectedError("Token binding mismatch")
if any(type(claims[field]) is not int for field in ("iat", "exp")):
raise LoginRejectedError("Invalid token timestamps")
if claims["iat"] < pending["created"] - 60 or claims["exp"] <= claims["iat"]:
raise LoginRejectedError("Token predates this sign-in")
verify_claims(claims)
# Store neither bearer tokens nor a provider password in the product session.
return {
"issuer": issuer,
"tenant": settings.NETKINGDOM_TENANT,
"client": client,
"subject": claims["sub"],
"label": str(claims.get("preferred_username") or "Ihr Benutzerkonto")[:150],
"expires": min(int(claims["exp"]), int(time.time()) + 300),
}

View file

@ -0,0 +1,265 @@
"""Exercise the actual signed-token and browser-session admission boundaries."""
import time
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from django.test import Client
from django.urls import get_script_prefix, set_script_prefix
from . import oidc
from .models import Mitarbeiter, OIDCIdentity
pytestmark = pytest.mark.django_db
ISSUER = "https://kc.example.test"
TENANT = "tenant:trial:demo-company"
CLIENT = "vergabe-demo-company"
@pytest.fixture
def configured(settings, monkeypatch):
settings.NETKINGDOM_ENABLED = True
settings.NETKINGDOM_ISSUER = ISSUER
settings.NETKINGDOM_CLIENT_ID = CLIENT
settings.NETKINGDOM_TENANT = TENANT
settings.NETKINGDOM_CALLBACK = "https://vergabe.example.test/accounts/oidc/callback/"
settings.COMPANY_DISPLAY_NAME = "Demo Company"
settings.APP_BASE_PATH = ""
metadata = {
"issuer": ISSUER,
"authorization_endpoint": ISSUER + "/authorize",
"token_endpoint": ISSUER + "/token",
"jwks_uri": ISSUER + "/jwks",
"code_challenge_methods_supported": ["S256"],
"id_token_signing_alg_values_supported": ["RS256"],
}
monkeypatch.setattr(oidc, "discovery", lambda *args: metadata)
return settings
@pytest.fixture
def signed_flow(configured, monkeypatch):
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
monkeypatch.setattr(
oidc,
"key_client",
lambda uri: SimpleNamespace(
get_signing_key_from_jwt=lambda token: SimpleNamespace(key=key.public_key()),
),
)
def exchange(client, changes=None, token_override=None):
pending = client.session["oidc_pending"]
claims = {
"iss": ISSUER,
"sub": "subject-1",
"aud": CLIENT,
"iat": int(time.time()),
"exp": int(time.time()) + 600,
"nonce": pending["nonce"],
"tenant": TENANT,
"tenant_source": "directory",
"principal_type": "human",
"groups": [TENANT + ":users"],
"roles": ["user"],
"assurance": {"level": "aal1"},
"preferred_username": "Demo member",
}
claims.update(changes or {})
token = token_override or jwt.encode(
claims, key, algorithm="RS256", headers={"kid": "test"}
)
monkeypatch.setattr(oidc, "read_json", lambda *args: {"id_token": token})
return client.get(
"/accounts/oidc/callback/", {"code": "single-code", "state": pending["state"]}
)
return exchange
def test_welcome_does_not_log_in_and_start_requires_csrf(configured):
client = Client(enforce_csrf_checks=True)
response = client.get("/accounts/login/?next=https://evil.test&token=untrusted")
assert b"Demo Company" in response.content
assert b"Mit NetKingdom anmelden" in response.content
assert "_auth_user_id" not in client.session
assert client.get("/accounts/oidc/start/").status_code == 405
assert client.post("/accounts/oidc/start/").status_code == 403
response = client.post(
"/accounts/oidc/start/",
{
"csrfmiddlewaretoken": client.cookies["csrftoken"].value,
},
)
query = parse_qs(urlsplit(response.url).query)
assert query["prompt"] == ["login"]
assert query["code_challenge_method"] == ["S256"]
assert query["tenant_hint"] == [TENANT]
assert len(query["nonce"][0]) >= 32
assert "evil.test" not in response.url
def test_verified_login_needs_explicit_confirmation_and_stable_mapping(client, signed_flow):
assert client.post("/accounts/oidc/start/").status_code == 302
assert signed_flow(client).url == "/accounts/oidc/confirm/"
assert not Mitarbeiter.objects.exists()
assert client.get("/ausschreibungen/").status_code == 302
assert b"Demo member" in client.get("/accounts/oidc/confirm/").content
assert client.post("/accounts/oidc/confirm/").url == "/"
user = Mitarbeiter.objects.get()
assert not user.is_staff and not user.is_superuser and not user.has_usable_password()
assert OIDCIdentity.objects.get().subject == "subject-1"
dashboard = client.get("/ausschreibungen/")
assert dashboard.status_code == 200
assert b"Demo Company" in dashboard.content
assert "access_token" not in repr(dict(client.session))
assert client.post("/accounts/oidc/start/").status_code == 302
assert "_auth_user_id" not in client.session
assert signed_flow(client, {"preferred_username": "New display name"}).status_code == 302
assert client.post("/accounts/oidc/confirm/").status_code == 302
assert Mitarbeiter.objects.count() == 1
@pytest.mark.parametrize(
"changes",
[
{"iss": "https://evil.test"},
{"aud": "other-client"},
{"nonce": "wrong"},
{"exp": 1},
{"iat": int(time.time()) + 3600},
{"nbf": int(time.time()) + 3600},
{"tenant": "tenant:platform"},
{"tenant_source": "registration"},
{"principal_type": "service"},
{"groups": []},
{"groups": [TENANT + ":users", "netkingdom-suspended"]},
{"roles": ["platform-operator"]},
{"groups": [TENANT + ":users", "net-kingdom-admins"]},
{"assurance": {"level": "aal0"}},
{"sub": ""},
{"roles": "user"},
{"aud": [CLIENT, "other"], "azp": "other"},
],
)
def test_invalid_tokens_never_create_users(client, signed_flow, changes):
client.post("/accounts/oidc/start/")
response = signed_flow(client, changes)
assert response.status_code == 403
assert not Mitarbeiter.objects.exists()
assert "_auth_user_id" not in client.session
def test_wrong_algorithm_denied(client, signed_flow):
client.post("/accounts/oidc/start/")
assert (
signed_flow(
client, token_override=jwt.encode({"sub": "forged"}, "x" * 32, algorithm="HS256")
).status_code
== 403
)
def test_state_is_browser_bound_expired_and_one_use(client, signed_flow):
client.post("/accounts/oidc/start/")
original = client.session["oidc_pending"]
other = Client()
response = other.get("/accounts/oidc/callback/", {"state": original["state"], "code": "x"})
assert response.status_code == 403
assert signed_flow(client).status_code == 302
assert (
client.get(
"/accounts/oidc/callback/", {"state": original["state"], "code": "x"}
).status_code
== 403
)
assert client.post("/accounts/oidc/confirm/").status_code == 403
client.post("/accounts/oidc/start/")
session = client.session
session["oidc_pending"]["created"] -= 601
session.save()
assert signed_flow(client).status_code == 403
def test_confirmation_requires_csrf(client, signed_flow):
client.post("/accounts/oidc/start/")
signed_flow(client)
guarded = Client(enforce_csrf_checks=True)
guarded.cookies = client.cookies
assert guarded.post("/accounts/oidc/confirm/").status_code == 403
assert not Mitarbeiter.objects.exists()
def test_no_email_merge_and_inactive_or_staff_accounts_stay_denied(client, signed_flow):
existing = Mitarbeiter.objects.create_user(username="Demo member", email="same@example.test")
client.post("/accounts/oidc/start/")
signed_flow(client, {"email": existing.email})
client.post("/accounts/oidc/confirm/")
linked = OIDCIdentity.objects.get().user
assert linked.pk != existing.pk
linked.is_active = False
linked.save()
assert client.get("/ausschreibungen/").status_code == 302
client.post("/accounts/oidc/start/")
signed_flow(client)
assert client.post("/accounts/oidc/confirm/").status_code == 403
linked.is_active = True
linked.is_staff = True
linked.save()
client.post("/accounts/oidc/start/")
signed_flow(client)
assert client.post("/accounts/oidc/confirm/").status_code == 403
def test_absolute_session_expiry_and_wrong_identity_do_not_reuse_operator(client, signed_flow):
operator = Mitarbeiter.objects.create_superuser(username="operator", password="test-only")
client.force_login(operator)
client.post("/accounts/oidc/start/")
assert "_auth_user_id" not in client.session
assert signed_flow(client, {"tenant": "tenant:platform"}).status_code == 403
client.post("/accounts/oidc/start/")
signed_flow(client)
client.post("/accounts/oidc/confirm/")
session = client.session
session["oidc_expires"] = time.time() - 1
session.save()
assert client.get("/ausschreibungen/").status_code == 302
def test_exact_company_callback_and_prefixed_links(configured, client):
old = get_script_prefix()
configured.APP_BASE_PATH = "/demo-company"
configured.FORCE_SCRIPT_NAME = "/demo-company"
configured.NETKINGDOM_CALLBACK = (
"https://vergabe.example.test/demo-company/accounts/oidc/callback/"
)
set_script_prefix("/demo-company")
try:
page = client.get("/accounts/login/")
assert b"/demo-company/accounts/oidc/start/" in page.content
target = client.post("/accounts/oidc/start/").url
assert parse_qs(urlsplit(target).query)["redirect_uri"] == [configured.NETKINGDOM_CALLBACK]
configured.NETKINGDOM_CALLBACK = (
"https://vergabe.example.test/other/accounts/oidc/callback/"
)
assert client.post("/accounts/oidc/start/").status_code == 503
finally:
set_script_prefix(old)
def test_disabled_oidc_preserves_existing_pilot_login(settings, client):
settings.NETKINGDOM_ENABLED = False
assert b"Benutzername" in client.get("/accounts/login/").content
assert client.post("/accounts/oidc/start/").status_code == 404
assert client.get("/accounts/oidc/callback/").status_code == 404
def test_wrong_signature_denied(client, signed_flow):
client.post("/accounts/oidc/start/")
another = rsa.generate_private_key(public_exponent=65537, key_size=2048)
token = jwt.encode({"sub": "forged"}, another, algorithm="RS256")
assert signed_flow(client, token_override=token).status_code == 403
assert not Mitarbeiter.objects.exists()

View file

@ -1,21 +1,32 @@
from django.contrib.auth import views
from django.urls import path, reverse_lazy
from .forms import AuthenticationForm, PasswordChangeForm
from . import views as company_views
from .forms import PasswordChangeForm
app_name = 'accounts'
app_name = "accounts"
urlpatterns = [
path('login/', views.LoginView.as_view(
template_name='accounts/login.html', authentication_form=AuthenticationForm,
), name='login'),
path('logout/', views.LogoutView.as_view(), name='logout'),
path('password-change/', views.PasswordChangeView.as_view(
template_name='accounts/password_change.html',
form_class=PasswordChangeForm,
success_url=reverse_lazy('accounts:password_change_done'),
), name='password_change'),
path('password-change/done/', views.PasswordChangeDoneView.as_view(
template_name='accounts/password_change_done.html',
), name='password_change_done'),
path("login/", company_views.company_login, name="login"),
path("welcome/", company_views.welcome, name="welcome"),
path("oidc/start/", company_views.oidc_start, name="oidc_start"),
path("oidc/callback/", company_views.oidc_callback, name="oidc_callback"),
path("oidc/confirm/", company_views.oidc_confirm, name="oidc_confirm"),
path("logout/", views.LogoutView.as_view(), name="logout"),
path(
"password-change/",
views.PasswordChangeView.as_view(
template_name="accounts/password_change.html",
form_class=PasswordChangeForm,
success_url=reverse_lazy("accounts:password_change_done"),
),
name="password_change",
),
path(
"password-change/done/",
views.PasswordChangeDoneView.as_view(
template_name="accounts/password_change_done.html",
),
name="password_change_done",
),
]

View file

@ -1,3 +1,137 @@
from django.shortcuts import render
import hashlib
import time
from urllib.error import URLError
# Create your views here.
import jwt
from django.conf import settings
from django.contrib.auth import login, logout
from django.contrib.auth.decorators import login_not_required
from django.contrib.auth.views import LoginView
from django.core.exceptions import ImproperlyConfigured
from django.db import IntegrityError, transaction
from django.http import Http404
from django.shortcuts import redirect, render
from django.views.decorators.debug import sensitive_variables
from django.views.decorators.http import require_GET, require_POST
from . import oidc
from .forms import AuthenticationForm
from .models import Mitarbeiter, OIDCIdentity
def context(**extra):
return {"company_name": settings.COMPANY_DISPLAY_NAME, **extra}
@login_not_required
def company_login(request):
if settings.NETKINGDOM_ENABLED:
return welcome(request)
return LoginView.as_view(
template_name="accounts/login.html", authentication_form=AuthenticationForm
)(request)
@login_not_required
@require_GET
def welcome(request):
if not settings.NETKINGDOM_ENABLED:
return redirect("accounts:login")
# A setup return never establishes or changes an application session.
return render(request, "accounts/welcome.html", context())
@login_not_required
@require_POST
@sensitive_variables()
def oidc_start(request):
if not settings.NETKINGDOM_ENABLED:
raise Http404
# Explicit CSRF-protected action clears only this product's previous login.
logout(request)
try:
pending, target = oidc.begin()
except (ValueError, URLError, TimeoutError, OSError, ImproperlyConfigured):
return render(request, "accounts/welcome.html", context(login_error=True), status=503)
request.session["oidc_pending"] = pending
request.session.set_expiry(600)
return redirect(target)
@login_not_required
@require_GET
@sensitive_variables()
def oidc_callback(request):
if not settings.NETKINGDOM_ENABLED:
raise Http404
pending = request.session.pop("oidc_pending", None)
request.session.pop("oidc_confirm", None)
try:
if request.GET.get("error") or len(request.GET.getlist("state")) != 1:
raise oidc.LoginRejectedError("Authorization was not completed")
verified = oidc.complete(pending, request.GET.get("state", ""), request.GET.get("code", ""))
except (ValueError, jwt.PyJWTError, URLError, TimeoutError, OSError, ImproperlyConfigured):
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
request.session["oidc_confirm"] = verified
# Remove the authorization code from the address bar before displaying identity.
return redirect("accounts:oidc_confirm")
@login_not_required
@sensitive_variables()
def oidc_confirm(request):
if not settings.NETKINGDOM_ENABLED:
raise Http404
verified = request.session.get("oidc_confirm")
if (
not verified
or verified["expires"] <= time.time()
or verified.get("tenant") != settings.NETKINGDOM_TENANT
or verified.get("issuer") != settings.NETKINGDOM_ISSUER
or verified.get("client") != settings.NETKINGDOM_CLIENT_ID
):
request.session.pop("oidc_confirm", None)
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
if request.method == "GET":
return render(request, "accounts/confirm.html", context(identity_label=verified["label"]))
if request.method != "POST":
from django.http import HttpResponseNotAllowed
return HttpResponseNotAllowed(["GET", "POST"])
request.session.pop("oidc_confirm", None)
try:
with transaction.atomic():
identity = (
OIDCIdentity.objects.select_related("user")
.filter(
issuer=verified["issuer"],
subject=verified["subject"],
)
.first()
)
if identity is None:
# Never merge by email, display name or directory username.
username = (
"nk_"
+ hashlib.sha256(
(verified["issuer"] + "\0" + verified["subject"]).encode(),
).hexdigest()
)
user = Mitarbeiter(username=username, first_name=verified["label"][:150])
user.set_unusable_password()
user.save()
identity = OIDCIdentity.objects.create(
issuer=verified["issuer"],
subject=verified["subject"],
user=user,
)
user = identity.user
if not user.is_active or user.is_staff or user.is_superuser:
raise oidc.LoginRejectedError("Product account is not admitted")
except (IntegrityError, oidc.LoginRejectedError):
return render(request, "accounts/welcome.html", context(login_error=True), status=403)
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
request.session["oidc_expires"] = verified["expires"]
request.session["oidc_binding"] = [verified["issuer"], verified["tenant"], verified["client"]]
request.session.set_expiry(max(1, int(verified["expires"] - time.time())))
return redirect("home")

View file

@ -56,6 +56,7 @@ TEMPLATES = [
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
'vergabe_teilnahme.apps.core.context_processors.vergabe_context',
'vergabe_teilnahme.apps.accounts.context_processors.company_context',
],
},
},
@ -120,3 +121,11 @@ ISSUE_FACADE_GITEA: dict | None = None
# 'owner': 'org',
# 'repo': 'vergabe',
# }
# Enabled only after the exact client, company and provider release are admitted.
NETKINGDOM_ENABLED = config('NETKINGDOM_ENABLED', default=False, cast=bool)
NETKINGDOM_ISSUER = config('NETKINGDOM_ISSUER', default='https://kc.coulomb.social')
NETKINGDOM_CLIENT_ID = config('NETKINGDOM_CLIENT_ID', default='')
NETKINGDOM_CALLBACK = config('NETKINGDOM_CALLBACK', default='')
NETKINGDOM_TENANT = config('NETKINGDOM_TENANT', default='')
COMPANY_DISPLAY_NAME = config('COMPANY_DISPLAY_NAME', default='Ihrem Unternehmen')

View file

@ -0,0 +1,12 @@
{% extends "accounts/base.html" %}
{% block title %}Anmeldung bestätigen{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Anmelden bei {{ company_name }}</h1>
<p class="mb-6">Sie fahren mit <strong>{{ identity_label }}</strong> fort.</p>
<form method="post" action="{% url 'accounts:oidc_confirm' %}">
{% csrf_token %}<button type="submit" class="btn-primary w-full">Mit diesem Konto fortfahren</button>
</form>
<form method="post" action="{% url 'accounts:oidc_start' %}" class="mt-4">
{% csrf_token %}<button type="submit">Mit einem anderen Konto anmelden</button>
</form>
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends "accounts/base.html" %}
{% block title %}Willkommen bei {{ company_name }}{% endblock %}
{% block content %}
<h1 class="text-xl font-semibold mb-2">Willkommen bei {{ company_name }}</h1>
<p class="text-sm text-slate-600 mb-6">Hier bearbeiten Sie die Ausschreibungen Ihres Unternehmens gemeinsam.</p>
{% if login_error %}<p role="alert" class="text-red-700 mb-4">Die Anmeldung konnte nicht abgeschlossen werden. Melden Sie sich mit Ihrem persönlichen Unternehmenskonto erneut an.</p>{% endif %}
<form method="post" action="{% url 'accounts:oidc_start' %}">
{% csrf_token %}
<button type="submit" class="btn-primary w-full">Mit NetKingdom anmelden</button>
</form>
<p class="text-sm text-slate-500 mt-6">Verwenden Sie Ihr eigenes Benutzerkonto und das von Ihnen eingerichtete Passwort. Wenn Sie den Zugang für eine andere Person vorbereitet haben, geben Sie ihr den Unternehmenslink weiter.</p>
{% endblock %}

View file

@ -7,7 +7,7 @@
</svg>
</button>
<a href="{% url 'home' %}" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme</a>
<a href="{% url 'home' %}" class="text-brand-700 font-semibold text-lg shrink-0">Vergabe Teilnahme{% if company_name %}<span class="block text-xs text-slate-500">{{ company_name }}</span>{% endif %}</a>
<form hx-get="{% url 'suche' %}" hx-target="#search-results" hx-trigger="input changed delay:300ms"
class="relative flex-1 max-w-lg mx-auto">
@ -39,8 +39,10 @@
<div class="px-3 py-2 text-xs text-slate-500 border-b border-slate-100">
{{ request.user.get_rolle_display|default:"Mitarbeiter" }}
</div>
{% if request.user.has_usable_password %}
<a href="{% url 'accounts:password_change' %}"
class="block px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Passwort ändern</a>
{% endif %}
<form method="post" action="{% url 'accounts:logout' %}">
{% csrf_token %}
<button type="submit" class="block w-full text-left px-3 py-2 text-sm text-slate-700 hover:bg-slate-50">Abmelden</button>

View file

@ -100,6 +100,41 @@ vergabe-teilnahme namespace nor any Deployment whose name/image contains
vergabe/teilnahme. Treat the old runbook as historical intent, not a live return.
Native placement and existing data inventory remain required before cutover.
2026-09-12 deployment evidence: the operator added A records for
vergabe-teilnahme.coulomb.social and users.coulomb.social. Authoritative IONOS
and recursive readback both return 92.205.62.239. Both cert-manager certificates
are Ready. The portal now uses https://users.coulomb.social/login; its legacy
nip.io address redirects to the canonical hostname. The exact new callback is
registered alongside the rollback callback; scopes, public client type and PKCE
remain unchanged. Canonical authorization succeeds; unapproved callback and
missing PKCE fail. This supersedes earlier DNS and portal-hostname blockers.
Helm release vergabe-teilnahme revision 1 is deployed in vergabe-demo-company,
chart 0.2.1, pinned product digest cebe8ca6218cc89f903e8a69e06ac50d3812526d4119cf4da485cc173f674b68.
Deployment UID 2152014d-a020-4c5e-a3b0-9575e9f21c44 is Ready 1/1. Its init
migration completed before the web process; both phases share the same 60m CPU /
256Mi memory request. Node requests now total 3965m of 4000m; the 35m remainder
matches the accepted prototype allocation. No unrelated resource requests changed.
RPF-WP-0039 delivered fresh vergabe_demo_company database/role on apps-pg and
runtime Secret vergabe-demo-company/vergabe-demo-env. The app's own connection
confirms that exact database/role. The role is non-superuser, cannot create roles
or databases, has a 20-connection ceiling and 15-second timeouts, and cannot
CONNECT to historical vergabe_db, coulomb_social_db or apps_meta. Both dedicated
PVCs are Bound: 5Gi media and 1Gi issue state. Historical data was not selected
or overwritten; no credentials are recorded here.
Thirteen live Chromium/HTTP checks pass: page and assets, secure tenant-scoped
CSRF cookie, anonymous login gate and media refusal, private operational path
refusal, neighboring/root path refusal, canonical slash, HTTPS redirect and
missing-CSRF POST denial. Migration/app initialization also proves consumer
connectivity. The empty product has zero accounts, including zero staff accounts.
The current login is still the interim Django login, not NetKingdom SSO. Native
recipient login, company welcome and account mapping remain VERGABE-WP-0019-T06.
RAPPS-WP-0014-T03 retains restart and coherent off-host backup/isolated restore;
the latest existing apps-pg base backup predates this new database. No pilot-user
acceptance, shared tenancy, MFA completion or natural factory-worker trace is claimed.
## Accept onboarding, collaboration, recovery and support with pilot users
```task
@ -108,7 +143,7 @@ status: wait
priority: high
assignee: the-custodian
depends_on: [VERGABE-WP-0019-T03]
blocking_reason: "Native tenant/user/password setup is verified. Await admitted application placement, tenant welcome/sign-in connection and demonstrated restore."
blocking_reason: "Application placement and native tenant/user/password setup are verified. Await tenant welcome/SSO connection, two-user acceptance and demonstrated restore."
state_hub_task_id: "db807f96-f5b8-528d-bc33-75a33fbbf1e4"
```
@ -166,7 +201,7 @@ preceding service-login failure. It does not create a Django account or session.
```task
id: VERGABE-WP-0019-T06
status: todo
status: progress
priority: high
assignee: the-custodian
state_hub_task_id: "455b33f9-163a-5389-86a3-04000c32d176"
@ -199,3 +234,16 @@ invalid/expired setup returns, unapproved external return URLs, wrong-tenant
identities and operator-session confusion fail safely. Keep setup tokens,
credentials and personal addresses out of work records. The earlier manual
Django login gate remains an interim product capability, not SSO proof.
2026-09-12 continuation: implemented company welcome, OIDC code+PKCE/nonce
verification, explicit account confirmation and stable issuer/subject mapping.
New accounts are ordinary users with unusable local passwords; signed directory
membership, exact tenant and bounded session expiry are required. Added callback,
wrong-tenant/platform, signature, replay, CSRF, local deactivation and prefix tests.
Source/deployment contract: docs/netkingdom-sign-in.md. The current live issuer
predates tenant_source and drops prompt=login before Authelia, so enabling the
product depends on KEY-WP-0033 and NK-WP-0037's exact registration/provider rollout.
The password-setup company return is grant-bound in NK-WP-0037. Native recipient
login/MFA, product publication and live acceptance remain open; no completed SSO
or pilot acceptance is claimed from source tests.