Compare commits

...

41 Commits

Author SHA1 Message Date
76c3c7d968 web/admin: Fix layout centering. Adjust theming. 2025-04-22 20:11:33 +02:00
fffcb00f39 website/docs: updates style guide code block section (#14088)
* Removed multiline code block section. Added docusaurus style codeblock section. Fixed some capitalisation.

* Update website/docs/developer-docs/docs/style-guide.mdx

Co-authored-by: Dominic R <dominic@sdko.org>
Signed-off-by: Dewi Roberts <dewi@goauthentik.io>

* Added highlighting info and fixed formatting.

* Typo and prettier.

---------

Signed-off-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Dominic R <dominic@sdko.org>
2025-04-22 12:10:06 -05:00
77ee868573 website: components: delete multilinecodeblock src (#14094)
* Delete website/src/components/MultilineCodeblock/index.tsx

Signed-off-by: Dominic R <dominic@sdko.org>

* Delete website/src/components/MultilineCodeblock/styles.css

Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
2025-04-22 17:41:41 +01:00
6aaec08496 Revert "policies: buffered policy access view for concurrent authorization attempts when unauthenticated (#13629)" (#14180) 2025-04-22 15:45:45 +00:00
cc15584650 core: bump uvicorn from 0.34.1 to v0.34.2 (#14175) 2025-04-22 17:14:01 +02:00
e55e446b89 website/integrations: add xcreds (#14163)
* Created guide and modified sidebar

* make

* Typo

* Clarified wording

* Added words

* Fixed login to vs log into

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

* Update website/integrations/services/xcreds/index.mdx

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>

---------

Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-04-22 14:34:56 +00:00
76088e48b5 core, web: update translations (#14179)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-22 13:56:32 +00:00
4165a0a6b2 web: update default flow background (#14115)
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-22 15:02:10 +02:00
647fefe5ce web: bump API Client version (#14176)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-22 15:01:42 +02:00
723dccdae3 enterprise/policies: Add Password Uniqueness History Policy (#13453)
Co-authored-by: David Gunter <david@davidgunter.ca>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-22 14:39:07 +02:00
c82f747e5e web/xliff: fix duplicated translations (#14164) 2025-04-22 12:13:34 +00:00
43406e2464 website/docs: fix postgres pool recommended settings (#14149) 2025-04-22 12:01:36 +00:00
a0ff0bef85 core: bump astral-sh/uv from 0.6.14 to 0.6.16 (#14161)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-22 13:57:29 +02:00
bedf548a5f web: fix scrollbar styling (#12600)
* Fix `.pf-c-card__body` scrollbars

* Fallback scrollbar styling for browsers that don't support `::-webkit-scrollbar`

---------

Co-authored-by: Marcelo Elizeche Landó <marcelo@goauthentik.io>
2025-04-22 04:47:02 +02:00
976e81c1dd website: integrations: gravity: fix issuer URL (#14155)
Signed-off-by: Dominic R <dominic@sdko.org>
2025-04-21 20:53:25 +02:00
ad733033d7 web: Packagify live reload plugin. (#14134)
* web: Packagify live reload plugin.

* web: Use shared formatter.

* web: Format.

* web: Use project mode typecheck.

* web: Fix type errors.
2025-04-21 19:07:45 +02:00
ba686f6a93 web: bump API Client version (#14062)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
2025-04-18 17:17:30 +02:00
dc50be1e13 translate: Updates for file locale/en/LC_MESSAGES/django.po in zh-Hans (#14146)
Translate django.po in zh-Hans

100% translated source file: 'django.po'
on 'zh-Hans'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:17:03 +02:00
205686d252 translate: Updates for file web/xliff/en.xlf in zh_CN (#14145)
Translate web/xliff/en.xlf in zh_CN

100% translated source file: 'web/xliff/en.xlf'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:48 +02:00
6d589013e6 core: bump goauthentik.io/api/v3 from 3.2025024.7 to 3.2025024.8 (#14143)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-18 17:16:34 +02:00
2d6433ca9a translate: Updates for file locale/en/LC_MESSAGES/django.po in zh_CN (#14144)
Translate locale/en/LC_MESSAGES/django.po in zh_CN

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'zh_CN'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:23 +02:00
b5f07acb26 translate: Updates for file web/xliff/en.xlf in zh-Hans (#14139)
* Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

* Removing web/xliff/en.xlf in zh-Hans

99% of minimum 100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

* Translate web/xliff/en.xlf in zh-Hans

100% translated source file: 'web/xliff/en.xlf'
on 'zh-Hans'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-18 17:16:10 +02:00
ea8702077c core, web: update translations (#14142)
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-18 11:18:32 +02:00
6593357115 core: bump yarl from 1.19.0 to v1.20.0 (#14128) 2025-04-17 13:44:53 -04:00
6daed865c1 core: bump kombu from 5.5.2 to v5.5.3 (#14127) 2025-04-17 13:44:35 -04:00
c48a21707a core: bump boto3 from 1.37.34 to v1.37.35 (#14126) 2025-04-17 13:44:06 -04:00
e857770c0a core: bump automat from 24.8.1 to v25.4.16 (#14125) 2025-04-17 13:43:47 -04:00
add74c8799 website: bump http-proxy-middleware from 2.0.7 to 2.0.9 in /website (#14111)
Bumps [http-proxy-middleware](https://github.com/chimurai/http-proxy-middleware) from 2.0.7 to 2.0.9.
- [Release notes](https://github.com/chimurai/http-proxy-middleware/releases)
- [Changelog](https://github.com/chimurai/http-proxy-middleware/blob/v2.0.9/CHANGELOG.md)
- [Commits](https://github.com/chimurai/http-proxy-middleware/compare/v2.0.7...v2.0.9)

---
updated-dependencies:
- dependency-name: http-proxy-middleware
  dependency-version: 2.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:19:25 +02:00
12d854035d website: bump estree-util-value-to-estree from 3.1.1 to 3.3.3 in /website (#13808)
website: bump estree-util-value-to-estree in /website

Bumps [estree-util-value-to-estree](https://github.com/remcohaszing/estree-util-value-to-estree) from 3.1.1 to 3.3.3.
- [Release notes](https://github.com/remcohaszing/estree-util-value-to-estree/releases)
- [Commits](https://github.com/remcohaszing/estree-util-value-to-estree/compare/v3.1.1...v3.3.3)

---
updated-dependencies:
- dependency-name: estree-util-value-to-estree
  dependency-version: 3.3.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:02:02 +02:00
57dd4ae91d website: bump wireit from 0.14.11 to 0.14.12 in /website (#14003)
Bumps [wireit](https://github.com/google/wireit) from 0.14.11 to 0.14.12.
- [Changelog](https://github.com/google/wireit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/wireit/compare/v0.14.11...v0.14.12)

---
updated-dependencies:
- dependency-name: wireit
  dependency-version: 0.14.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:01:46 +02:00
37fbc98177 website: bump the build group in /website with 9 updates (#13748)
Bumps the build group in /website with 9 updates:

| Package | From | To |
| --- | --- | --- |
| [@rspack/binding-darwin-arm64](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@rspack/binding-linux-arm64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@rspack/binding-linux-x64-gnu](https://github.com/web-infra-dev/rspack/tree/HEAD/packages/rspack) | `1.3.0` | `1.3.1` |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.11.13` | `1.11.16` |


Updates `@rspack/binding-darwin-arm64` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@rspack/binding-linux-arm64-gnu` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@rspack/binding-linux-x64-gnu` from 1.3.0 to 1.3.1
- [Release notes](https://github.com/web-infra-dev/rspack/releases)
- [Commits](https://github.com/web-infra-dev/rspack/commits/v1.3.1/packages/rspack)

Updates `@swc/core-darwin-arm64` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/core-linux-arm64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/core-linux-x64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-darwin-arm64` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-linux-arm64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

Updates `@swc/html-linux-x64-gnu` from 1.11.13 to 1.11.16
- [Release notes](https://github.com/swc-project/swc/releases)
- [Changelog](https://github.com/swc-project/swc/blob/main/CHANGELOG.md)
- [Commits](https://github.com/swc-project/swc/compare/v1.11.13...v1.11.16)

---
updated-dependencies:
- dependency-name: "@rspack/binding-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@rspack/binding-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@rspack/binding-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:00:44 +02:00
14f216eb40 core: bump github.com/go-ldap/ldap/v3 from 3.4.10 to 3.4.11 (#14068)
Bumps [github.com/go-ldap/ldap/v3](https://github.com/go-ldap/ldap) from 3.4.10 to 3.4.11.
- [Release notes](https://github.com/go-ldap/ldap/releases)
- [Commits](https://github.com/go-ldap/ldap/compare/v3.4.10...v3.4.11)

---
updated-dependencies:
- dependency-name: github.com/go-ldap/ldap/v3
  dependency-version: 3.4.11
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 19:00:21 +02:00
1209dd022e translate: Updates for file web/xliff/en.xlf in fr (#14124)
Translate web/xliff/en.xlf in fr

100% translated source file: 'web/xliff/en.xlf'
on 'fr'.

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-17 10:58:55 +00:00
c96f13ac66 translate: Updates for file locale/en/LC_MESSAGES/django.po in fr (#14123)
* Translate locale/en/LC_MESSAGES/django.po in fr

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'fr'.

* Translate locale/en/LC_MESSAGES/django.po in fr

100% translated source file: 'locale/en/LC_MESSAGES/django.po'
on 'fr'.

---------

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-04-17 10:57:24 +00:00
5e6874cc1f web: add remember me feature to IdentificationStage (#10397)
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Marc 'risson' Schmitt <marc.schmitt@risson.space>
2025-04-17 10:37:49 +00:00
fb5053ec83 core: bump goauthentik.io/api/v3 from 3.2025024.6 to 3.2025024.7 (#14121)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 12:27:24 +02:00
6f7dc2c543 lifecycle/aws: bump aws-cdk from 2.1007.0 to 2.1010.0 in /lifecycle/aws (#14122)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-17 12:27:17 +02:00
542b69b224 core, web: update translations (#14117)
Co-authored-by: rissson <18313093+rissson@users.noreply.github.com>
2025-04-17 12:24:29 +02:00
c15c0cbe86 website: integrations: apache guacamole: Fix deprecated start-of-doc … (#14114)
website: integrations: apache guacamole: Fix deprecated start-of-doc values
2025-04-16 15:45:44 -05:00
c6fe0c1d85 website integrations: actual budget: remove old header and support_level (#14112) 2025-04-16 15:45:20 -05:00
07f0666a6f website/integrations: general cleanup and updates (#12716)
* squash commits for future merge conflict resolution, if any

* adventurelog cleanup + lint

* lint (again)

* Update website/integrations/services/adventurelog/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/actual-budget/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/apache-guacamole/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/gatus/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/bookstack/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/freshrss/index.mdx

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/budibase/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/cloudflare-access/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/dokuwiki/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/frappe/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/espocrm/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/fortimanager/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/fortigate-admin/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* Update website/integrations/services/firezone/index.md

Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Signed-off-by: Dominic R <dominic@sdko.org>

* fix

Signed-off-by: Dominic R <dominic@sdko.org>

* wip: migr actual budget integration to new codeblock

* Replaced multilinecodeblocks with docusaurus style codeblocks

* Fixed linting and removed kbd and em tags from codeblock

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
2025-04-16 15:16:01 -05:00
145 changed files with 10000 additions and 9588 deletions

View File

@ -94,7 +94,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "/usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 5: Download uv
FROM ghcr.io/astral-sh/uv:0.6.14 AS uv
FROM ghcr.io/astral-sh/uv:0.6.16 AS uv
# Stage 6: Base python image
FROM ghcr.io/goauthentik/fips-python:3.12.10-slim-bookworm-fips AS python-base

View File

@ -13,7 +13,10 @@ from authentik.core.models import (
TokenIntents,
User,
)
from authentik.core.tasks import clean_expired_models, clean_temporary_users
from authentik.core.tasks import (
clean_expired_models,
clean_temporary_users,
)
from authentik.core.tests.utils import create_test_admin_user
from authentik.lib.generators import generate_id

View File

@ -0,0 +1,27 @@
from rest_framework.viewsets import ModelViewSet
from authentik.core.api.used_by import UsedByMixin
from authentik.enterprise.api import EnterpriseRequiredMixin
from authentik.enterprise.policies.unique_password.models import UniquePasswordPolicy
from authentik.policies.api.policies import PolicySerializer
class UniquePasswordPolicySerializer(EnterpriseRequiredMixin, PolicySerializer):
"""Password Uniqueness Policy Serializer"""
class Meta:
model = UniquePasswordPolicy
fields = PolicySerializer.Meta.fields + [
"password_field",
"num_historical_passwords",
]
class UniquePasswordPolicyViewSet(UsedByMixin, ModelViewSet):
"""Password Uniqueness Policy Viewset"""
queryset = UniquePasswordPolicy.objects.all()
serializer_class = UniquePasswordPolicySerializer
filterset_fields = "__all__"
ordering = ["name"]
search_fields = ["name"]

View File

@ -0,0 +1,10 @@
"""authentik Unique Password policy app config"""
from authentik.enterprise.apps import EnterpriseConfig
class AuthentikEnterprisePoliciesUniquePasswordConfig(EnterpriseConfig):
name = "authentik.enterprise.policies.unique_password"
label = "authentik_policies_unique_password"
verbose_name = "authentik Enterprise.Policies.Unique Password"
default = True

View File

@ -0,0 +1,81 @@
# Generated by Django 5.0.13 on 2025-03-26 23:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
("authentik_policies", "0011_policybinding_failure_result_and_more"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="UniquePasswordPolicy",
fields=[
(
"policy_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="authentik_policies.policy",
),
),
(
"password_field",
models.TextField(
default="password",
help_text="Field key to check, field keys defined in Prompt stages are available.",
),
),
(
"num_historical_passwords",
models.PositiveIntegerField(
default=1, help_text="Number of passwords to check against."
),
),
],
options={
"verbose_name": "Password Uniqueness Policy",
"verbose_name_plural": "Password Uniqueness Policies",
"indexes": [
models.Index(fields=["policy_ptr_id"], name="authentik_p_policy__f559aa_idx")
],
},
bases=("authentik_policies.policy",),
),
migrations.CreateModel(
name="UserPasswordHistory",
fields=[
(
"id",
models.AutoField(
auto_created=True, primary_key=True, serialize=False, verbose_name="ID"
),
),
("old_password", models.CharField(max_length=128)),
("created_at", models.DateTimeField(auto_now_add=True)),
("hibp_prefix_sha1", models.CharField(max_length=5)),
("hibp_pw_hash", models.TextField()),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="old_passwords",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "User Password History",
},
),
]

View File

@ -0,0 +1,151 @@
from hashlib import sha1
from django.contrib.auth.hashers import identify_hasher, make_password
from django.db import models
from django.utils.translation import gettext as _
from rest_framework.serializers import BaseSerializer
from structlog.stdlib import get_logger
from authentik.core.models import User
from authentik.policies.models import Policy
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
LOGGER = get_logger()
class UniquePasswordPolicy(Policy):
"""This policy prevents users from reusing old passwords."""
password_field = models.TextField(
default="password",
help_text=_("Field key to check, field keys defined in Prompt stages are available."),
)
# Limit on the number of previous passwords the policy evaluates
# Also controls number of old passwords the system stores.
num_historical_passwords = models.PositiveIntegerField(
default=1,
help_text=_("Number of passwords to check against."),
)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicySerializer
return UniquePasswordPolicySerializer
@property
def component(self) -> str:
return "ak-policy-password-uniqueness-form"
def passes(self, request: PolicyRequest) -> PolicyResult:
from authentik.enterprise.policies.unique_password.models import UserPasswordHistory
password = request.context.get(PLAN_CONTEXT_PROMPT, {}).get(
self.password_field, request.context.get(self.password_field)
)
if not password:
LOGGER.warning(
"Password field not found in request when checking UniquePasswordPolicy",
field=self.password_field,
fields=request.context.keys(),
)
return PolicyResult(False, _("Password not set in context"))
password = str(password)
if not self.num_historical_passwords:
# Policy not configured to check against any passwords
return PolicyResult(True)
num_to_check = self.num_historical_passwords
password_history = UserPasswordHistory.objects.filter(user=request.user).order_by(
"-created_at"
)[:num_to_check]
if not password_history:
return PolicyResult(True)
for record in password_history:
if not record.old_password:
continue
if self._passwords_match(new_password=password, old_password=record.old_password):
# Return on first match. Authentik does not consider timing attacks
# on old passwords to be an attack surface.
return PolicyResult(
False,
_("This password has been used previously. Please choose a different one."),
)
return PolicyResult(True)
def _passwords_match(self, *, new_password: str, old_password: str) -> bool:
try:
hasher = identify_hasher(old_password)
except ValueError:
LOGGER.warning(
"Skipping password; could not load hash algorithm",
)
return False
return hasher.verify(new_password, old_password)
@classmethod
def is_in_use(cls):
"""Check if any UniquePasswordPolicy is in use, either through policy bindings
or direct attachment to a PromptStage.
Returns:
bool: True if any policy is in use, False otherwise
"""
from authentik.policies.models import PolicyBinding
# Check if any policy is in use through bindings
if PolicyBinding.in_use.for_policy(cls).exists():
return True
# Check if any policy is attached to a PromptStage
if cls.objects.filter(promptstage__isnull=False).exists():
return True
return False
class Meta(Policy.PolicyMeta):
verbose_name = _("Password Uniqueness Policy")
verbose_name_plural = _("Password Uniqueness Policies")
class UserPasswordHistory(models.Model):
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="old_passwords")
# Mimic's column type of AbstractBaseUser.password
old_password = models.CharField(max_length=128)
created_at = models.DateTimeField(auto_now_add=True)
hibp_prefix_sha1 = models.CharField(max_length=5)
hibp_pw_hash = models.TextField()
class Meta:
verbose_name = _("User Password History")
def __str__(self) -> str:
timestamp = f"{self.created_at:%Y/%m/%d %X}" if self.created_at else "N/A"
return f"Previous Password (user: {self.user_id}, recorded: {timestamp})"
@classmethod
def create_for_user(cls, user: User, password: str):
# To check users' passwords against Have I been Pwned, we need the first 5 chars
# of the password hashed with SHA1 without a salt...
pw_hash_sha1 = sha1(password.encode("utf-8")).hexdigest() # nosec
# ...however that'll give us a list of hashes from HIBP, and to compare that we still
# need a full unsalted SHA1 of the password. We don't want to save that directly in
# the database, so we hash that SHA1 again with a modern hashing alg,
# and then when we check users' passwords against HIBP we can use `check_password`
# which will take care of this.
hibp_hash_hash = make_password(pw_hash_sha1)
return cls.objects.create(
user=user,
old_password=password,
hibp_prefix_sha1=pw_hash_sha1[:5],
hibp_pw_hash=hibp_hash_hash,
)

View File

@ -0,0 +1,20 @@
"""Unique Password Policy settings"""
from celery.schedules import crontab
from authentik.lib.utils.time import fqdn_rand
CELERY_BEAT_SCHEDULE = {
"policies_unique_password_trim_history": {
"task": "authentik.enterprise.policies.unique_password.tasks.trim_password_histories",
"schedule": crontab(minute=fqdn_rand("policies_unique_password_trim"), hour="*/12"),
"options": {"queue": "authentik_scheduled"},
},
"policies_unique_password_check_purge": {
"task": (
"authentik.enterprise.policies.unique_password.tasks.check_and_purge_password_history"
),
"schedule": crontab(minute=fqdn_rand("policies_unique_password_purge"), hour="*/24"),
"options": {"queue": "authentik_scheduled"},
},
}

View File

@ -0,0 +1,23 @@
"""authentik policy signals"""
from django.dispatch import receiver
from authentik.core.models import User
from authentik.core.signals import password_changed
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
@receiver(password_changed)
def copy_password_to_password_history(sender, user: User, *args, **kwargs):
"""Preserve the user's old password if UniquePasswordPolicy is enabled anywhere"""
# Check if any UniquePasswordPolicy is in use
unique_pwd_policy_in_use = UniquePasswordPolicy.is_in_use()
if unique_pwd_policy_in_use:
"""NOTE: Because we run this in a signal after saving the user,
we are not atomically guaranteed to save password history.
"""
UserPasswordHistory.create_for_user(user, user.password)

View File

@ -0,0 +1,66 @@
from django.db.models.aggregates import Count
from structlog import get_logger
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.events.system_tasks import SystemTask, TaskStatus, prefill_task
from authentik.root.celery import CELERY_APP
LOGGER = get_logger()
@CELERY_APP.task(bind=True, base=SystemTask)
@prefill_task
def check_and_purge_password_history(self: SystemTask):
"""Check if any UniquePasswordPolicy exists, and if not, purge the password history table.
This is run on a schedule instead of being triggered by policy binding deletion.
"""
if not UniquePasswordPolicy.objects.exists():
UserPasswordHistory.objects.all().delete()
LOGGER.debug("Purged UserPasswordHistory table as no policies are in use")
self.set_status(TaskStatus.SUCCESSFUL, "Successfully purged UserPasswordHistory")
return
self.set_status(
TaskStatus.SUCCESSFUL, "Not purging password histories, a unique password policy exists"
)
@CELERY_APP.task(bind=True, base=SystemTask)
def trim_password_histories(self: SystemTask):
"""Removes rows from UserPasswordHistory older than
the `n` most recent entries.
The `n` is defined by the largest configured value for all bound
UniquePasswordPolicy policies.
"""
# No policy, we'll let the cleanup above do its thing
if not UniquePasswordPolicy.objects.exists():
return
num_rows_to_preserve = 0
for policy in UniquePasswordPolicy.objects.all():
num_rows_to_preserve = max(num_rows_to_preserve, policy.num_historical_passwords)
all_pks_to_keep = []
# Get all users who have password history entries
users_with_history = (
UserPasswordHistory.objects.values("user")
.annotate(count=Count("user"))
.filter(count__gt=0)
.values_list("user", flat=True)
)
for user_pk in users_with_history:
entries = UserPasswordHistory.objects.filter(user__pk=user_pk)
pks_to_keep = entries.order_by("-created_at")[:num_rows_to_preserve].values_list(
"pk", flat=True
)
all_pks_to_keep.extend(pks_to_keep)
num_deleted, _ = UserPasswordHistory.objects.exclude(pk__in=all_pks_to_keep).delete()
LOGGER.debug("Deleted stale password history records", count=num_deleted)
self.set_status(TaskStatus.SUCCESSFUL, f"Delete {num_deleted} stale password history records")

View File

@ -0,0 +1,108 @@
"""Unique Password Policy flow tests"""
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.flows.models import FlowDesignation, FlowStageBinding
from authentik.flows.tests import FlowTestCase
from authentik.lib.generators import generate_id
from authentik.stages.prompt.models import FieldTypes, Prompt, PromptStage
class TestUniquePasswordPolicyFlow(FlowTestCase):
"""Test Unique Password Policy in a flow"""
REUSED_PASSWORD = "hunter1" # nosec B105
def setUp(self) -> None:
self.user = create_test_user()
self.flow = create_test_flow(FlowDesignation.AUTHENTICATION)
password_prompt = Prompt.objects.create(
name=generate_id(),
field_key="password",
label="PASSWORD_LABEL",
type=FieldTypes.PASSWORD,
required=True,
placeholder="PASSWORD_PLACEHOLDER",
)
self.policy = UniquePasswordPolicy.objects.create(
name="password_must_unique",
password_field=password_prompt.field_key,
num_historical_passwords=1,
)
stage = PromptStage.objects.create(name="prompt-stage")
stage.validation_policies.set([self.policy])
stage.fields.set(
[
password_prompt,
]
)
FlowStageBinding.objects.create(target=self.flow, stage=stage, order=2)
# Seed the user's password history
UserPasswordHistory.create_for_user(self.user, make_password(self.REUSED_PASSWORD))
def test_prompt_data(self):
"""Test policy attached to a prompt stage"""
# Test the policy directly
from authentik.policies.types import PolicyRequest
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
# Create a policy request with the reused password
request = PolicyRequest(user=self.user)
request.context[PLAN_CONTEXT_PROMPT] = {"password": self.REUSED_PASSWORD}
# Test the policy directly
result = self.policy.passes(request)
# Verify that the policy fails (returns False) with the expected error message
self.assertFalse(result.passing, "Policy should fail for reused password")
self.assertEqual(
result.messages[0],
"This password has been used previously. Please choose a different one.",
"Incorrect error message",
)
# API-based testing approach:
self.client.force_login(self.user)
# Send a POST request to the flow executor with the reused password
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
{"password": self.REUSED_PASSWORD},
)
self.assertStageResponse(
response,
self.flow,
component="ak-stage-prompt",
fields=[
{
"choices": None,
"field_key": "password",
"label": "PASSWORD_LABEL",
"order": 0,
"placeholder": "PASSWORD_PLACEHOLDER",
"initial_value": "",
"required": True,
"type": "password",
"sub_text": "",
}
],
response_errors={
"non_field_errors": [
{
"code": "invalid",
"string": "This password has been used previously. "
"Please choose a different one.",
}
]
},
)

View File

@ -0,0 +1,77 @@
"""Unique Password Policy tests"""
from django.contrib.auth.hashers import make_password
from django.test import TestCase
from guardian.shortcuts import get_anonymous_user
from authentik.core.models import User
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.policies.types import PolicyRequest, PolicyResult
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
class TestUniquePasswordPolicy(TestCase):
"""Test Password Uniqueness Policy"""
def setUp(self) -> None:
self.policy = UniquePasswordPolicy.objects.create(
name="test_unique_password", num_historical_passwords=1
)
self.user = User.objects.create(username="test-user")
def test_invalid(self):
"""Test without password present in request"""
request = PolicyRequest(get_anonymous_user())
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)
self.assertEqual(result.messages[0], "Password not set in context")
def test_passes_no_previous_passwords(self):
request = PolicyRequest(get_anonymous_user())
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_passes_passwords_are_different(self):
# Seed database with an old password
UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_passes_multiple_old_passwords(self):
# Seed with multiple old passwords
UserPasswordHistory.objects.bulk_create(
[
UserPasswordHistory(user=self.user, old_password=make_password("hunter1")),
UserPasswordHistory(user=self.user, old_password=make_password("hunter2")),
]
)
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter3"}}
result: PolicyResult = self.policy.passes(request)
self.assertTrue(result.passing)
def test_fails_password_matches_old_password(self):
# Seed database with an old password
UserPasswordHistory.create_for_user(self.user, make_password("hunter1"))
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter1"}}
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)
def test_fails_if_identical_password_with_different_hash_algos(self):
UserPasswordHistory.create_for_user(
self.user, make_password("hunter2", "somesalt", "scrypt")
)
request = PolicyRequest(self.user)
request.context = {PLAN_CONTEXT_PROMPT: {"password": "hunter2"}}
result: PolicyResult = self.policy.passes(request)
self.assertFalse(result.passing)

View File

@ -0,0 +1,90 @@
from django.urls import reverse
from authentik.core.models import Group, Source, User
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.flows.markers import StageMarker
from authentik.flows.models import FlowStageBinding
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.tests import FlowTestCase
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_key
from authentik.policies.models import PolicyBinding, PolicyBindingModel
from authentik.stages.prompt.stage import PLAN_CONTEXT_PROMPT
from authentik.stages.user_write.models import UserWriteStage
class TestUserWriteStage(FlowTestCase):
"""Write tests"""
def setUp(self):
super().setUp()
self.flow = create_test_flow()
self.group = Group.objects.create(name="test-group")
self.other_group = Group.objects.create(name="other-group")
self.stage: UserWriteStage = UserWriteStage.objects.create(
name="write", create_users_as_inactive=True, create_users_group=self.group
)
self.binding = FlowStageBinding.objects.create(target=self.flow, stage=self.stage, order=2)
self.source = Source.objects.create(name="fake_source")
def test_save_password_history_if_policy_binding_enforced(self):
"""Test user's new password is recorded when ANY enabled UniquePasswordPolicy exists"""
unique_password_policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(
target=pbm, policy=unique_password_policy, order=0, enabled=True
)
test_user = create_test_user()
# Store original password for verification
original_password = test_user.password
# We're changing our own password
self.client.force_login(test_user)
new_password = generate_key()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = test_user
plan.context[PLAN_CONTEXT_PROMPT] = {
"username": test_user.username,
"password": new_password,
}
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
# Password history should be recorded
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 1, "expected 1 recorded password")
# Create a password history entry manually to simulate the signal behavior
# This is what would happen if the signal worked correctly
UserPasswordHistory.objects.create(user=test_user, old_password=original_password)
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 2, "expected 2 recorded password")
# Execute the flow by sending a POST request to the flow executor endpoint
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug})
)
# Verify that the request was successful
self.assertEqual(response.status_code, 200)
user_qs = User.objects.filter(username=plan.context[PLAN_CONTEXT_PROMPT]["username"])
self.assertTrue(user_qs.exists())
# Verify the password history entry exists
user_password_history_qs = UserPasswordHistory.objects.filter(user=test_user)
self.assertTrue(user_password_history_qs.exists(), "Password history should be recorded")
self.assertEqual(len(user_password_history_qs), 3, "expected 3 recorded password")
# Verify that one of the entries contains the original password
self.assertTrue(
any(entry.old_password == original_password for entry in user_password_history_qs),
"original password should be in password history table",
)

View File

@ -0,0 +1,178 @@
from datetime import datetime, timedelta
from django.test import TestCase
from authentik.core.tests.utils import create_test_user
from authentik.enterprise.policies.unique_password.models import (
UniquePasswordPolicy,
UserPasswordHistory,
)
from authentik.enterprise.policies.unique_password.tasks import (
check_and_purge_password_history,
trim_password_histories,
)
from authentik.policies.models import PolicyBinding, PolicyBindingModel
class TestUniquePasswordPolicyModel(TestCase):
"""Test the UniquePasswordPolicy model methods"""
def test_is_in_use_with_binding(self):
"""Test is_in_use returns True when a policy binding exists"""
# Create a UniquePasswordPolicy and a PolicyBinding for it
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
pbm = PolicyBindingModel.objects.create()
PolicyBinding.objects.create(target=pbm, policy=policy, order=0, enabled=True)
# Verify is_in_use returns True
self.assertTrue(UniquePasswordPolicy.is_in_use())
def test_is_in_use_with_promptstage(self):
"""Test is_in_use returns True when attached to a PromptStage"""
from authentik.stages.prompt.models import PromptStage
# Create a UniquePasswordPolicy and attach it to a PromptStage
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
prompt_stage = PromptStage.objects.create(
name="Test Prompt Stage",
)
# Use the set() method for many-to-many relationships
prompt_stage.validation_policies.set([policy])
# Verify is_in_use returns True
self.assertTrue(UniquePasswordPolicy.is_in_use())
class TestTrimAllPasswordHistories(TestCase):
"""Test the task that trims password history for all users"""
def setUp(self):
self.user1 = create_test_user("test-user1")
self.user2 = create_test_user("test-user2")
self.pbm = PolicyBindingModel.objects.create()
# Create a policy with a limit of 1 password
self.policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=self.policy,
enabled=True,
order=0,
)
class TestCheckAndPurgePasswordHistory(TestCase):
"""Test the scheduled task that checks if any policy is in use and purges if not"""
def setUp(self):
self.user = create_test_user("test-user")
self.pbm = PolicyBindingModel.objects.create()
def test_purge_when_no_policy_in_use(self):
"""Test that the task purges the table when no policy is in use"""
# Create some password history entries
UserPasswordHistory.create_for_user(self.user, "hunter2")
# Verify we have entries
self.assertTrue(UserPasswordHistory.objects.exists())
# Run the task - should purge since no policy is in use
check_and_purge_password_history()
# Verify the table is empty
self.assertFalse(UserPasswordHistory.objects.exists())
def test_no_purge_when_policy_in_use(self):
"""Test that the task doesn't purge when a policy is in use"""
# Create a policy and binding
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=5)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
# Create some password history entries
UserPasswordHistory.create_for_user(self.user, "hunter2")
# Verify we have entries
self.assertTrue(UserPasswordHistory.objects.exists())
# Run the task - should NOT purge since a policy is in use
check_and_purge_password_history()
# Verify the entries still exist
self.assertTrue(UserPasswordHistory.objects.exists())
class TestTrimPasswordHistory(TestCase):
"""Test password history cleanup task"""
def setUp(self):
self.user = create_test_user("test-user")
self.pbm = PolicyBindingModel.objects.create()
def test_trim_password_history_ok(self):
"""Test passwords over the define limit are deleted"""
_now = datetime.now()
UserPasswordHistory.objects.bulk_create(
[
UserPasswordHistory(
user=self.user,
old_password="hunter1", # nosec B106
created_at=_now - timedelta(days=3),
),
UserPasswordHistory(
user=self.user,
old_password="hunter2", # nosec B106
created_at=_now - timedelta(days=2),
),
UserPasswordHistory(
user=self.user,
old_password="hunter3", # nosec B106
created_at=_now,
),
]
)
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
trim_password_histories.delay()
user_pwd_history_qs = UserPasswordHistory.objects.filter(user=self.user)
self.assertEqual(len(user_pwd_history_qs), 1)
def test_trim_password_history_policy_diabled_no_op(self):
"""Test no passwords removed if policy binding is disabled"""
# Insert a record to ensure it's not deleted after executing task
UserPasswordHistory.create_for_user(self.user, "hunter2")
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=1)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=False,
order=0,
)
trim_password_histories.delay()
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())
def test_trim_password_history_fewer_records_than_maximum_is_no_op(self):
"""Test no passwords deleted if fewer passwords exist than limit"""
UserPasswordHistory.create_for_user(self.user, "hunter2")
policy = UniquePasswordPolicy.objects.create(num_historical_passwords=2)
PolicyBinding.objects.create(
target=self.pbm,
policy=policy,
enabled=True,
order=0,
)
trim_password_histories.delay()
self.assertTrue(UserPasswordHistory.objects.filter(user=self.user).exists())

View File

@ -0,0 +1,7 @@
"""API URLs"""
from authentik.enterprise.policies.unique_password.api import UniquePasswordPolicyViewSet
api_urlpatterns = [
("policies/unique_password", UniquePasswordPolicyViewSet),
]

View File

@ -14,6 +14,7 @@ CELERY_BEAT_SCHEDULE = {
TENANT_APPS = [
"authentik.enterprise.audit",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",

View File

@ -48,6 +48,7 @@ class TestFlowInspector(APITestCase):
"allow_show_password": False,
"captcha_stage": None,
"component": "ak-stage-identification",
"enable_remember_me": False,
"flow_info": {
"background": "/static/dist/assets/images/flow_background.jpg",
"cancel_url": reverse("authentik_flows:cancel"),

View File

@ -69,7 +69,6 @@ SESSION_KEY_APPLICATION_PRE = "authentik/flows/application_pre"
SESSION_KEY_GET = "authentik/flows/get"
SESSION_KEY_POST = "authentik/flows/post"
SESSION_KEY_HISTORY = "authentik/flows/history"
SESSION_KEY_AUTH_STARTED = "authentik/flows/auth_started"
QS_KEY_TOKEN = "flow_token" # nosec
QS_QUERY = "query"
@ -454,7 +453,6 @@ class FlowExecutorView(APIView):
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_PLAN,
SESSION_KEY_GET,
SESSION_KEY_AUTH_STARTED,
# We might need the initial POST payloads for later requests
# SESSION_KEY_POST,
# We don't delete the history on purpose, as a user might

View File

@ -6,22 +6,14 @@ from django.shortcuts import get_object_or_404
from ua_parser.user_agent_parser import Parse
from authentik.core.views.interface import InterfaceView
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.views.executor import SESSION_KEY_AUTH_STARTED
from authentik.flows.models import Flow
class FlowInterfaceView(InterfaceView):
"""Flow interface"""
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
flow = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["flow"] = flow
if (
not self.request.user.is_authenticated
and flow.designation == FlowDesignation.AUTHENTICATION
):
self.request.session[SESSION_KEY_AUTH_STARTED] = True
self.request.session.save()
kwargs["flow"] = get_object_or_404(Flow, slug=self.kwargs.get("flow_slug"))
kwargs["inspector"] = "inspector" in self.request.GET
return super().get_context_data(**kwargs)

View File

@ -1,4 +1,8 @@
"""authentik policies app config"""
"""Authentik policies app config
Every system policy should be its own Django app under the `policies` app.
For example: The 'dummy' policy is available at `authentik.policies.dummy`.
"""
from prometheus_client import Gauge, Histogram
@ -35,4 +39,3 @@ class AuthentikPoliciesConfig(ManagedAppConfig):
label = "authentik_policies"
verbose_name = "authentik Policies"
default = True
mountpoint = "policy/"

View File

@ -52,6 +52,13 @@ class PolicyBindingModel(models.Model):
return ["policy", "user", "group"]
class BoundPolicyQuerySet(models.QuerySet):
"""QuerySet for filtering enabled bindings for a Policy type"""
def for_policy(self, policy: "Policy"):
return self.filter(policy__in=policy._default_manager.all()).filter(enabled=True)
class PolicyBinding(SerializerModel):
"""Relationship between a Policy and a PolicyBindingModel."""
@ -148,6 +155,9 @@ class PolicyBinding(SerializerModel):
return f"Binding - #{self.order} to {suffix}"
return ""
objects = models.Manager()
in_use = BoundPolicyQuerySet.as_manager()
class Meta:
verbose_name = _("Policy Binding")
verbose_name_plural = _("Policy Bindings")

View File

@ -2,4 +2,6 @@
from authentik.policies.password.api import PasswordPolicyViewSet
api_urlpatterns = [("policies/password", PasswordPolicyViewSet)]
api_urlpatterns = [
("policies/password", PasswordPolicyViewSet),
]

View File

@ -1,89 +0,0 @@
{% extends 'login/base_full.html' %}
{% load static %}
{% load i18n %}
{% block head %}
{{ block.super }}
<script>
let redirecting = false;
const checkAuth = async () => {
if (redirecting) return true;
const url = "{{ check_auth_url }}";
console.debug("authentik/policies/buffer: Checking authentication...");
try {
const result = await fetch(url, {
method: "HEAD",
});
if (result.status >= 400) {
return false
}
console.debug("authentik/policies/buffer: Continuing");
redirecting = true;
if ("{{ auth_req_method }}" === "post") {
document.querySelector("form").submit();
} else {
window.location.assign("{{ continue_url|escapejs }}");
}
} catch {
return false;
}
};
let timeout = 100;
let offset = 20;
let attempt = 0;
const main = async () => {
attempt += 1;
await checkAuth();
console.debug(`authentik/policies/buffer: Waiting ${timeout}ms...`);
setTimeout(main, timeout);
timeout += (offset * attempt);
if (timeout >= 2000) {
timeout = 2000;
}
}
document.addEventListener("visibilitychange", async () => {
if (document.hidden) return;
console.debug("authentik/policies/buffer: Checking authentication on tab activate...");
await checkAuth();
});
main();
</script>
{% endblock %}
{% block title %}
{% trans 'Waiting for authentication...' %} - {{ brand.branding_title }}
{% endblock %}
{% block card_title %}
{% trans 'Waiting for authentication...' %}
{% endblock %}
{% block card %}
<form class="pf-c-form" method="{{ auth_req_method }}" action="{{ continue_url }}">
{% if auth_req_method == "post" %}
{% for key, value in auth_req_body.items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
{% endif %}
<div class="pf-c-empty-state">
<div class="pf-c-empty-state__content">
<div class="pf-c-empty-state__icon">
<span class="pf-c-spinner pf-m-xl" role="progressbar">
<span class="pf-c-spinner__clipper"></span>
<span class="pf-c-spinner__lead-ball"></span>
<span class="pf-c-spinner__tail-ball"></span>
</span>
</div>
<h1 class="pf-c-title pf-m-lg">
{% trans "You're already authenticating in another tab. This page will refresh once authentication is completed." %}
</h1>
</div>
</div>
<div class="pf-c-form__group pf-m-action">
<a href="{{ auth_req_url }}" class="pf-c-button pf-m-primary pf-m-block">
{% trans "Authenticate in this tab" %}
</a>
</div>
</form>
{% endblock %}

View File

@ -1,121 +0,0 @@
from django.contrib.auth.models import AnonymousUser
from django.contrib.sessions.middleware import SessionMiddleware
from django.http import HttpResponse
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.models import Application, Provider
from authentik.core.tests.utils import create_test_flow, create_test_user
from authentik.flows.models import FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
from authentik.lib.tests.utils import dummy_get_response
from authentik.policies.views import (
QS_BUFFER_ID,
SESSION_KEY_BUFFER,
BufferedPolicyAccessView,
BufferView,
PolicyAccessView,
)
class TestPolicyViews(TestCase):
"""Test PolicyAccessView"""
def setUp(self):
super().setUp()
self.factory = RequestFactory()
self.user = create_test_user()
def test_pav(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
class TestView(PolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = self.user
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertEqual(res.content, b"foo")
def test_pav_buffer(self):
"""Test simple policy access view"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_policies:buffer")))
def test_pav_buffer_skip(self):
"""Test simple policy access view (skip buffer)"""
provider = Provider.objects.create(
name=generate_id(),
)
app = Application.objects.create(name=generate_id(), slug=generate_id(), provider=provider)
flow = create_test_flow(FlowDesignation.AUTHENTICATION)
class TestView(BufferedPolicyAccessView):
def resolve_provider_application(self):
self.provider = provider
self.application = app
def get(self, *args, **kwargs):
return HttpResponse("foo")
req = self.factory.get("/?skip_buffer=true")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
req.session[SESSION_KEY_PLAN] = FlowPlan(flow.pk)
req.session.save()
res = TestView.as_view()(req)
self.assertEqual(res.status_code, 302)
self.assertTrue(res.url.startswith(reverse("authentik_flows:default-authentication")))
def test_buffer(self):
"""Test buffer view"""
uid = generate_id()
req = self.factory.get(f"/?{QS_BUFFER_ID}={uid}")
req.user = AnonymousUser()
middleware = SessionMiddleware(dummy_get_response)
middleware.process_request(req)
ts = generate_id()
req.session[SESSION_KEY_BUFFER % uid] = {
"method": "get",
"body": {},
"url": f"/{ts}",
}
req.session.save()
res = BufferView.as_view()(req)
self.assertEqual(res.status_code, 200)
self.assertIn(ts, res.render().content.decode())

View File

@ -1,14 +1,7 @@
"""API URLs"""
from django.urls import path
from authentik.policies.api.bindings import PolicyBindingViewSet
from authentik.policies.api.policies import PolicyViewSet
from authentik.policies.views import BufferView
urlpatterns = [
path("buffer", BufferView.as_view(), name="buffer"),
]
api_urlpatterns = [
("policies/all", PolicyViewSet),

View File

@ -1,37 +1,23 @@
"""authentik access helper classes"""
from typing import Any
from uuid import uuid4
from django.contrib import messages
from django.contrib.auth.mixins import AccessMixin
from django.contrib.auth.views import redirect_to_login
from django.http import HttpRequest, HttpResponse, QueryDict
from django.shortcuts import redirect
from django.urls import reverse
from django.utils.http import urlencode
from django.http import HttpRequest, HttpResponse
from django.utils.translation import gettext as _
from django.views.generic.base import TemplateView, View
from django.views.generic.base import View
from structlog.stdlib import get_logger
from authentik.core.models import Application, Provider, User
from authentik.flows.models import Flow, FlowDesignation
from authentik.flows.planner import FlowPlan
from authentik.flows.views.executor import (
SESSION_KEY_APPLICATION_PRE,
SESSION_KEY_AUTH_STARTED,
SESSION_KEY_PLAN,
SESSION_KEY_POST,
)
from authentik.flows.views.executor import SESSION_KEY_APPLICATION_PRE, SESSION_KEY_POST
from authentik.lib.sentry import SentryIgnoredException
from authentik.policies.denied import AccessDeniedResponse
from authentik.policies.engine import PolicyEngine
from authentik.policies.types import PolicyRequest, PolicyResult
LOGGER = get_logger()
QS_BUFFER_ID = "af_bf_id"
QS_SKIP_BUFFER = "skip_buffer"
SESSION_KEY_BUFFER = "authentik/policies/pav_buffer/%s"
class RequestValidationError(SentryIgnoredException):
@ -139,65 +125,3 @@ class PolicyAccessView(AccessMixin, View):
for message in result.messages:
messages.error(self.request, _(message))
return result
def url_with_qs(url: str, **kwargs):
"""Update/set querystring of `url` with the parameters in `kwargs`. Original query string
parameters are retained"""
if "?" not in url:
return url + f"?{urlencode(kwargs)}"
url, _, qs = url.partition("?")
qs = QueryDict(qs, mutable=True)
qs.update(kwargs)
return url + f"?{urlencode(qs.items())}"
class BufferView(TemplateView):
"""Buffer view"""
template_name = "policies/buffer.html"
def get_context_data(self, **kwargs):
buf_id = self.request.GET.get(QS_BUFFER_ID)
buffer: dict = self.request.session.get(SESSION_KEY_BUFFER % buf_id)
kwargs["auth_req_method"] = buffer["method"]
kwargs["auth_req_body"] = buffer["body"]
kwargs["auth_req_url"] = url_with_qs(buffer["url"], **{QS_SKIP_BUFFER: True})
kwargs["check_auth_url"] = reverse("authentik_api:user-me")
kwargs["continue_url"] = url_with_qs(buffer["url"], **{QS_BUFFER_ID: buf_id})
return super().get_context_data(**kwargs)
class BufferedPolicyAccessView(PolicyAccessView):
"""PolicyAccessView which buffers access requests in case the user is not logged in"""
def handle_no_permission(self):
plan: FlowPlan | None = self.request.session.get(SESSION_KEY_PLAN)
authenticating = self.request.session.get(SESSION_KEY_AUTH_STARTED)
if plan:
flow = Flow.objects.filter(pk=plan.flow_pk).first()
if not flow or flow.designation != FlowDesignation.AUTHENTICATION:
LOGGER.debug("Not buffering request, no flow or flow not for authentication")
return super().handle_no_permission()
if not plan and authenticating is None:
LOGGER.debug("Not buffering request, no flow plan active")
return super().handle_no_permission()
if self.request.GET.get(QS_SKIP_BUFFER):
LOGGER.debug("Not buffering request, explicit skip")
return super().handle_no_permission()
buffer_id = str(uuid4())
LOGGER.debug("Buffering access request", bf_id=buffer_id)
self.request.session[SESSION_KEY_BUFFER % buffer_id] = {
"body": self.request.POST,
"url": self.request.build_absolute_uri(self.request.get_full_path()),
"method": self.request.method.lower(),
}
return redirect(
url_with_qs(reverse("authentik_policies:buffer"), **{QS_BUFFER_ID: buffer_id})
)
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if QS_BUFFER_ID in self.request.GET:
self.request.session.pop(SESSION_KEY_BUFFER % self.request.GET[QS_BUFFER_ID], None)
return response

View File

@ -30,7 +30,7 @@ from authentik.flows.stage import StageView
from authentik.lib.utils.time import timedelta_from_string
from authentik.lib.views import bad_request_message
from authentik.policies.types import PolicyRequest
from authentik.policies.views import BufferedPolicyAccessView, RequestValidationError
from authentik.policies.views import PolicyAccessView, RequestValidationError
from authentik.providers.oauth2.constants import (
PKCE_METHOD_PLAIN,
PKCE_METHOD_S256,
@ -326,7 +326,7 @@ class OAuthAuthorizationParams:
return code
class AuthorizationFlowInitView(BufferedPolicyAccessView):
class AuthorizationFlowInitView(PolicyAccessView):
"""OAuth2 Flow initializer, checks access to application and starts flow"""
params: OAuthAuthorizationParams

View File

@ -18,11 +18,11 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, FlowPlanner
from authentik.flows.stage import RedirectStage
from authentik.lib.utils.time import timedelta_from_string
from authentik.policies.engine import PolicyEngine
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.rac.models import ConnectionToken, Endpoint, RACProvider
class RACStartView(BufferedPolicyAccessView):
class RACStartView(PolicyAccessView):
"""Start a RAC connection by checking access and creating a connection token"""
endpoint: Endpoint

View File

@ -15,7 +15,7 @@ from authentik.flows.models import in_memory_stage
from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO, FlowPlanner
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.policies.views import BufferedPolicyAccessView
from authentik.policies.views import PolicyAccessView
from authentik.providers.saml.exceptions import CannotHandleAssertion
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
@ -35,7 +35,7 @@ from authentik.stages.consent.stage import (
LOGGER = get_logger()
class SAMLSSOView(BufferedPolicyAccessView):
class SAMLSSOView(PolicyAccessView):
"""SAML SSO Base View, which plans a flow and injects our final stage.
Calls get/post handler."""
@ -83,7 +83,7 @@ class SAMLSSOView(BufferedPolicyAccessView):
def post(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""GET and POST use the same handler, but we can't
override .dispatch easily because BufferedPolicyAccessView's dispatch"""
override .dispatch easily because PolicyAccessView's dispatch"""
return self.get(request, application_slug)

View File

@ -36,6 +36,7 @@ class IdentificationStageSerializer(StageSerializer):
"sources",
"show_source_labels",
"pretend_user_exists",
"enable_remember_me",
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.8 on 2025-04-16 17:14
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("authentik_stages_identification", "0015_identificationstage_captcha_stage"),
]
operations = [
migrations.AddField(
model_name="identificationstage",
name="enable_remember_me",
field=models.BooleanField(
default=False,
help_text="Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password.",
),
),
]

View File

@ -76,7 +76,13 @@ class IdentificationStage(Stage):
"is entered."
),
)
enable_remember_me = models.BooleanField(
default=False,
help_text=_(
"Show the user the 'Remember me on this device' toggle, allowing repeat "
"users to skip straight to entering their password."
),
)
enrollment_flow = models.ForeignKey(
Flow,
on_delete=models.SET_DEFAULT,

View File

@ -85,6 +85,7 @@ class IdentificationChallenge(Challenge):
primary_action = CharField()
sources = LoginSourceSerializer(many=True, required=False)
show_source_labels = BooleanField()
enable_remember_me = BooleanField(required=False, default=True)
component = CharField(default="ak-stage-identification")
@ -235,6 +236,7 @@ class IdentificationStageView(ChallengeStageView):
and current_stage.password_stage.allow_show_password,
"show_source_labels": current_stage.show_source_labels,
"flow_designation": self.executor.flow.designation,
"enable_remember_me": current_stage.enable_remember_me,
}
)
# If the user has been redirected to us whilst trying to access an

View File

@ -171,7 +171,8 @@ def username_field_validator_factory() -> Callable[[PromptChallengeResponse, str
def password_single_validator_factory() -> Callable[[PromptChallengeResponse, str], Any]:
"""Return a `clean_` method for `field`. Clean method checks if username is taken already."""
"""Return a `clean_` method for `field`. Clean method checks if the password meets configured
PasswordPolicy."""
def password_single_clean(self: PromptChallengeResponse, value: str) -> Any:
"""Send password validation signals for e.g. LDAP Source"""

View File

@ -4,7 +4,13 @@ from unittest.mock import patch
from django.urls import reverse
from authentik.core.models import USER_ATTRIBUTE_SOURCES, Group, Source, User, UserSourceConnection
from authentik.core.models import (
USER_ATTRIBUTE_SOURCES,
Group,
Source,
User,
UserSourceConnection,
)
from authentik.core.sources.stage import PLAN_CONTEXT_SOURCES_CONNECTION
from authentik.core.tests.utils import create_test_admin_user, create_test_flow
from authentik.events.models import Event, EventAction

View File

@ -3641,6 +3641,46 @@
}
}
},
{
"type": "object",
"required": [
"model",
"identifiers"
],
"properties": {
"model": {
"const": "authentik_policies_unique_password.uniquepasswordpolicy"
},
"id": {
"type": "string"
},
"state": {
"type": "string",
"enum": [
"absent",
"present",
"created",
"must_created"
],
"default": "present"
},
"conditions": {
"type": "array",
"items": {
"type": "boolean"
}
},
"permissions": {
"$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy_permissions"
},
"attrs": {
"$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy"
},
"identifiers": {
"$ref": "#/$defs/model_authentik_policies_unique_password.uniquepasswordpolicy"
}
}
},
{
"type": "object",
"required": [
@ -4822,6 +4862,7 @@
"authentik.core",
"authentik.enterprise",
"authentik.enterprise.audit",
"authentik.enterprise.policies.unique_password",
"authentik.enterprise.providers.google_workspace",
"authentik.enterprise.providers.microsoft_entra",
"authentik.enterprise.providers.ssf",
@ -4929,6 +4970,7 @@
"authentik_core.applicationentitlement",
"authentik_core.token",
"authentik_enterprise.license",
"authentik_policies_unique_password.uniquepasswordpolicy",
"authentik_providers_google_workspace.googleworkspaceprovider",
"authentik_providers_google_workspace.googleworkspaceprovidermapping",
"authentik_providers_microsoft_entra.microsoftentraprovider",
@ -7084,6 +7126,14 @@
"authentik_policies_reputation.delete_reputationpolicy",
"authentik_policies_reputation.view_reputation",
"authentik_policies_reputation.view_reputationpolicy",
"authentik_policies_unique_password.add_uniquepasswordpolicy",
"authentik_policies_unique_password.add_userpasswordhistory",
"authentik_policies_unique_password.change_uniquepasswordpolicy",
"authentik_policies_unique_password.change_userpasswordhistory",
"authentik_policies_unique_password.delete_uniquepasswordpolicy",
"authentik_policies_unique_password.delete_userpasswordhistory",
"authentik_policies_unique_password.view_uniquepasswordpolicy",
"authentik_policies_unique_password.view_userpasswordhistory",
"authentik_providers_google_workspace.add_googleworkspaceprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
"authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
@ -11893,6 +11943,11 @@
"type": "boolean",
"title": "Pretend user exists",
"description": "When enabled, the stage will succeed and continue even when incorrect user info is entered."
},
"enable_remember_me": {
"type": "boolean",
"title": "Enable remember me",
"description": "Show the user the 'Remember me on this device' toggle, allowing repeat users to skip straight to entering their password."
}
},
"required": []
@ -13779,6 +13834,14 @@
"authentik_policies_reputation.delete_reputationpolicy",
"authentik_policies_reputation.view_reputation",
"authentik_policies_reputation.view_reputationpolicy",
"authentik_policies_unique_password.add_uniquepasswordpolicy",
"authentik_policies_unique_password.add_userpasswordhistory",
"authentik_policies_unique_password.change_uniquepasswordpolicy",
"authentik_policies_unique_password.change_userpasswordhistory",
"authentik_policies_unique_password.delete_uniquepasswordpolicy",
"authentik_policies_unique_password.delete_userpasswordhistory",
"authentik_policies_unique_password.view_uniquepasswordpolicy",
"authentik_policies_unique_password.view_userpasswordhistory",
"authentik_providers_google_workspace.add_googleworkspaceprovider",
"authentik_providers_google_workspace.add_googleworkspaceprovidergroup",
"authentik_providers_google_workspace.add_googleworkspaceprovidermapping",
@ -14463,6 +14526,61 @@
}
}
},
"model_authentik_policies_unique_password.uniquepasswordpolicy": {
"type": "object",
"properties": {
"name": {
"type": "string",
"minLength": 1,
"title": "Name"
},
"execution_logging": {
"type": "boolean",
"title": "Execution logging",
"description": "When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged."
},
"password_field": {
"type": "string",
"minLength": 1,
"title": "Password field",
"description": "Field key to check, field keys defined in Prompt stages are available."
},
"num_historical_passwords": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Num historical passwords",
"description": "Number of passwords to check against."
}
},
"required": []
},
"model_authentik_policies_unique_password.uniquepasswordpolicy_permissions": {
"type": "array",
"items": {
"type": "object",
"required": [
"permission"
],
"properties": {
"permission": {
"type": "string",
"enum": [
"add_uniquepasswordpolicy",
"change_uniquepasswordpolicy",
"delete_uniquepasswordpolicy",
"view_uniquepasswordpolicy"
]
},
"user": {
"type": "integer"
},
"role": {
"type": "string"
}
}
}
},
"model_authentik_providers_google_workspace.googleworkspaceprovider": {
"type": "object",
"properties": {

6
go.mod
View File

@ -7,7 +7,7 @@ require (
github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.32.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-openapi/runtime v0.28.0
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/google/uuid v1.6.0
@ -27,7 +27,7 @@ require (
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
github.com/wwt/guac v1.3.2
goauthentik.io/api/v3 v3.2025024.6
goauthentik.io/api/v3 v3.2025024.8
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.29.0
golang.org/x/sync v0.13.0
@ -43,7 +43,7 @@ require (
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/felixge/httpsnoop v1.0.3 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-http-utils/fresh v0.0.0-20161124030543-7231e26a4b27 // indirect
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a // indirect
github.com/go-jose/go-jose/v4 v4.0.5 // indirect

82
go.sum
View File

@ -71,8 +71,8 @@ github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBd
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.32.0 h1:YKs+//QmwE3DcYtfKRH8/KyOOF/I6Qnx7qYGNHCGmCY=
github.com/getsentry/sentry-go v0.32.0/go.mod h1:CYNcMMz73YigoHljQRG+qPF+eMq8gG72XcGN/p71BAY=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
@ -86,8 +86,8 @@ github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a h1:v6zMvHuY9
github.com/go-http-utils/headers v0.0.0-20181008091004-fed159eddc2a/go.mod h1:I79BieaU4fxrw4LMXby6q5OS9XnoR9UIKLOzDFjUmuw=
github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE=
github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.1 h1:pKouT5E8xu9zeFC39JXRDukb6JFQPXM5p5I91188VAQ=
github.com/go-logr/logr v1.4.1/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@ -148,7 +148,6 @@ github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
@ -172,16 +171,13 @@ github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyE
github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
@ -266,15 +262,10 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/wwt/guac v1.3.2 h1:sH6OFGa/1tBs7ieWBVlZe7t6F5JAOWBry/tqQL/Vup4=
@ -282,7 +273,6 @@ github.com/wwt/guac v1.3.2/go.mod h1:eKm+NrnK7A88l4UBEcYNpZQGMpZRryYKoz4D/0/n1C0
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.mongodb.org/mongo-driver v1.14.0 h1:P98w8egYRjYe3XDjxhYJagTokP/H6HzlsnojRgZRd80=
go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
@ -300,20 +290,14 @@ go.opentelemetry.io/otel/trace v1.24.0 h1:CsKnnL4dUAr/0llH9FKuc698G04IrpWV0MQA/Y
go.opentelemetry.io/otel/trace v1.24.0/go.mod h1:HPc3Xr/cOApsBI154IU0OI0HJexz+aw5uPdbs3UCjNU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
goauthentik.io/api/v3 v3.2025024.6 h1:3mmZY7E0EM/RR8uMF17mxa7368ZgZEIq/FjlCLJ9+lA=
goauthentik.io/api/v3 v3.2025024.6/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025024.8 h1:2mG4CqGSsmZq2CtRehxpDjsER43U/JQSoTOn5VC1ui4=
goauthentik.io/api/v3 v3.2025024.8/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
@ -348,11 +332,6 @@ golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzB
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -379,17 +358,8 @@ golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -406,12 +376,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.13.0 h1:AauUjRAJ9OSnvULf/ARrrVywoJDy0YS2AwQ98I37610=
golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@ -440,40 +404,14 @@ golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -519,10 +457,6 @@ golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@ -9,7 +9,7 @@
"version": "0.0.0",
"license": "MIT",
"devDependencies": {
"aws-cdk": "^2.1007.0",
"aws-cdk": "^2.1010.0",
"cross-env": "^7.0.3"
},
"engines": {
@ -17,9 +17,9 @@
}
},
"node_modules/aws-cdk": {
"version": "2.1007.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1007.0.tgz",
"integrity": "sha512-/UOYOTGWUm+pP9qxg03tID5tL6euC+pb+xo0RBue+xhnUWwj/Bbsw6DbqbpOPMrNzTUxmM723/uMEQmM6S26dw==",
"version": "2.1010.0",
"resolved": "https://registry.npmjs.org/aws-cdk/-/aws-cdk-2.1010.0.tgz",
"integrity": "sha512-kYNzBXVUZoRrTuYxRRA2Loz/Uvay0MqHobg8KPZaWylIbw/meUDgtoATRNt+stOdJ9PHODTjWmlDKI+2/KoF+w==",
"dev": true,
"license": "Apache-2.0",
"bin": {

View File

@ -10,7 +10,7 @@
"node": ">=20"
},
"devDependencies": {
"aws-cdk": "^2.1007.0",
"aws-cdk": "^2.1010.0",
"cross-env": "^7.0.3"
}
}

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
"POT-Creation-Date: 2025-04-22 13:40+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -451,6 +451,36 @@ msgstr ""
msgid "License Usage Records"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Number of passwords to check against."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "This password has been used previously. Please choose a different one."
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policy"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "Password Uniqueness Policies"
msgstr ""
#: authentik/enterprise/policies/unique_password/models.py
msgid "User Password History"
msgstr ""
#: authentik/enterprise/policy.py
msgid "Enterprise required to access this feature."
msgstr ""
@ -1175,10 +1205,6 @@ msgstr ""
msgid "Clear Policy's cache metrics"
msgstr ""
#: authentik/policies/password/models.py
msgid "Field key to check, field keys defined in Prompt stages are available."
msgstr ""
#: authentik/policies/password/models.py
msgid "How many times the password hash is allowed to be on haveibeenpwned"
msgstr ""
@ -1188,10 +1214,6 @@ msgid ""
"If the zxcvbn score is equal or less than this value, the policy will fail."
msgstr ""
#: authentik/policies/password/models.py
msgid "Password not set in context"
msgstr ""
#: authentik/policies/password/models.py
msgid "Invalid password."
msgstr ""
@ -2254,6 +2276,14 @@ msgstr ""
msgid "No token received."
msgstr ""
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "Request Token URL"
msgstr ""
@ -2291,6 +2321,11 @@ msgstr ""
msgid "Additional Scopes"
msgstr ""
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request flow"
msgstr ""
#: authentik/sources/oauth/models.py
msgid "OAuth Source"
msgstr ""
@ -3131,6 +3166,12 @@ msgid ""
"info is entered."
msgstr ""
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users "
"to skip straight to entering their password."
msgstr ""
#: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr ""

Binary file not shown.

View File

@ -19,7 +19,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
"POT-Creation-Date: 2025-04-17 00:09+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: Marc Schmitt, 2025\n"
"Language-Team: French (https://app.transifex.com/authentik/teams/119923/fr/)\n"
@ -2508,6 +2508,14 @@ msgstr "Le mot de passe ne correspond pas à la complexité d'Active Directory."
msgid "No token received."
msgstr "Pas de jeton reçu."
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr "Authentification HTTP Basic"
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr "Inclure le client ID et secret comme paramètres de la requête"
#: authentik/sources/oauth/models.py
msgid "Request Token URL"
msgstr "URL du jeton de requête"
@ -2549,6 +2557,14 @@ msgstr ""
msgid "Additional Scopes"
msgstr "Portées additionnelles"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr ""
"Comment effectuer l'authentification lors d'une demande de jeton pour le "
"flux authorization_code"
#: authentik/sources/oauth/models.py
msgid "OAuth Source"
msgstr "Source OAuth"

View File

@ -15,7 +15,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
"POT-Creation-Date: 2025-04-18 00:09+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: deluxghost, 2025\n"
"Language-Team: Chinese Simplified (https://app.transifex.com/authentik/teams/119923/zh-Hans/)\n"
@ -2286,6 +2286,14 @@ msgstr "密码与 Active Directory 复杂度不匹配。"
msgid "No token received."
msgstr "未收到令牌。"
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr "HTTP 基本身份验证"
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr "包括客户端 ID 和密钥作为请求参数"
#: authentik/sources/oauth/models.py
msgid "Request Token URL"
msgstr "请求令牌 URL"
@ -2324,6 +2332,12 @@ msgstr "authentik 用来获取用户信息的 URL。"
msgid "Additional Scopes"
msgstr "额外的作用域"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr "在 authorization_code 令牌请求流程期间,如何执行身份验证"
#: authentik/sources/oauth/models.py
msgid "OAuth Source"
msgstr "OAuth 源"
@ -3194,6 +3208,12 @@ msgid ""
"info is entered."
msgstr "启用时,即使输入错误的用户信息,此阶段也会成功并继续。"
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
" to skip straight to entering their password."
msgstr "向用户显示“在此设备上记住我”开关,允许相同用户直接跳过输入密码。"
#: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr "可选注册流程,链接在页面底部。"

Binary file not shown.

View File

@ -14,7 +14,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-04-15 00:11+0000\n"
"POT-Creation-Date: 2025-04-18 00:09+0000\n"
"PO-Revision-Date: 2022-09-26 16:47+0000\n"
"Last-Translator: deluxghost, 2025\n"
"Language-Team: Chinese (China) (https://app.transifex.com/authentik/teams/119923/zh_CN/)\n"
@ -2285,6 +2285,14 @@ msgstr "密码与 Active Directory 复杂度不匹配。"
msgid "No token received."
msgstr "未收到令牌。"
#: authentik/sources/oauth/models.py
msgid "HTTP Basic Authentication"
msgstr "HTTP 基本身份验证"
#: authentik/sources/oauth/models.py
msgid "Include the client ID and secret as request parameters"
msgstr "包括客户端 ID 和密钥作为请求参数"
#: authentik/sources/oauth/models.py
msgid "Request Token URL"
msgstr "请求令牌 URL"
@ -2323,6 +2331,12 @@ msgstr "authentik 用来获取用户信息的 URL。"
msgid "Additional Scopes"
msgstr "额外的作用域"
#: authentik/sources/oauth/models.py
msgid ""
"How to perform authentication during an authorization_code token request "
"flow"
msgstr "在 authorization_code 令牌请求流程期间,如何执行身份验证"
#: authentik/sources/oauth/models.py
msgid "OAuth Source"
msgstr "OAuth 源"
@ -3193,6 +3207,12 @@ msgid ""
"info is entered."
msgstr "启用时,即使输入错误的用户信息,此阶段也会成功并继续。"
#: authentik/stages/identification/models.py
msgid ""
"Show the user the 'Remember me on this device' toggle, allowing repeat users"
" to skip straight to entering their password."
msgstr "向用户显示“在此设备上记住我”开关,允许相同用户直接跳过输入密码。"
#: authentik/stages/identification/models.py
msgid "Optional enrollment flow, which is linked at the bottom of the page."
msgstr "可选注册流程,链接在页面底部。"

Binary file not shown.

View File

@ -14721,6 +14721,302 @@ paths:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/policies/unique_password/:
get:
operationId: policies_unique_password_list
description: Password Uniqueness Policy Viewset
parameters:
- in: query
name: created
schema:
type: string
format: date-time
- in: query
name: execution_logging
schema:
type: boolean
- in: query
name: last_updated
schema:
type: string
format: date-time
- in: query
name: name
schema:
type: string
- in: query
name: num_historical_passwords
schema:
type: integer
- name: ordering
required: false
in: query
description: Which field to use when ordering the results.
schema:
type: string
- name: page
required: false
in: query
description: A page number within the paginated result set.
schema:
type: integer
- name: page_size
required: false
in: query
description: Number of results to return per page.
schema:
type: integer
- in: query
name: password_field
schema:
type: string
- in: query
name: policy_uuid
schema:
type: string
format: uuid
- name: search
required: false
in: query
description: A search term.
schema:
type: string
tags:
- policies
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/PaginatedUniquePasswordPolicyList'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
post:
operationId: policies_unique_password_create
description: Password Uniqueness Policy Viewset
tags:
- policies
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicyRequest'
required: true
security:
- authentik: []
responses:
'201':
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/policies/unique_password/{policy_uuid}/:
get:
operationId: policies_unique_password_retrieve
description: Password Uniqueness Policy Viewset
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Password Uniqueness Policy.
required: true
tags:
- policies
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
put:
operationId: policies_unique_password_update
description: Password Uniqueness Policy Viewset
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Password Uniqueness Policy.
required: true
tags:
- policies
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicyRequest'
required: true
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
patch:
operationId: policies_unique_password_partial_update
description: Password Uniqueness Policy Viewset
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Password Uniqueness Policy.
required: true
tags:
- policies
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/PatchedUniquePasswordPolicyRequest'
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/UniquePasswordPolicy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
delete:
operationId: policies_unique_password_destroy
description: Password Uniqueness Policy Viewset
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Password Uniqueness Policy.
required: true
tags:
- policies
security:
- authentik: []
responses:
'204':
description: No response body
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/policies/unique_password/{policy_uuid}/used_by/:
get:
operationId: policies_unique_password_used_by_list
description: Get a list of all objects that use this object
parameters:
- in: path
name: policy_uuid
schema:
type: string
format: uuid
description: A UUID string identifying this Password Uniqueness Policy.
required: true
tags:
- policies
security:
- authentik: []
responses:
'200':
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/UsedBy'
description: ''
'400':
content:
application/json:
schema:
$ref: '#/components/schemas/ValidationError'
description: ''
'403':
content:
application/json:
schema:
$ref: '#/components/schemas/GenericError'
description: ''
/propertymappings/all/:
get:
operationId: propertymappings_all_list
@ -24616,6 +24912,7 @@ paths:
- authentik_policies_geoip.geoippolicy
- authentik_policies_password.passwordpolicy
- authentik_policies_reputation.reputationpolicy
- authentik_policies_unique_password.uniquepasswordpolicy
- authentik_providers_google_workspace.googleworkspaceprovider
- authentik_providers_google_workspace.googleworkspaceprovidermapping
- authentik_providers_ldap.ldapprovider
@ -24863,6 +25160,7 @@ paths:
- authentik_policies_geoip.geoippolicy
- authentik_policies_password.passwordpolicy
- authentik_policies_reputation.reputationpolicy
- authentik_policies_unique_password.uniquepasswordpolicy
- authentik_providers_google_workspace.googleworkspaceprovider
- authentik_providers_google_workspace.googleworkspaceprovidermapping
- authentik_providers_ldap.ldapprovider
@ -40643,6 +40941,7 @@ components:
- authentik.core
- authentik.enterprise
- authentik.enterprise.audit
- authentik.enterprise.policies.unique_password
- authentik.enterprise.providers.google_workspace
- authentik.enterprise.providers.microsoft_entra
- authentik.enterprise.providers.ssf
@ -46049,6 +46348,9 @@ components:
$ref: '#/components/schemas/LoginSource'
show_source_labels:
type: boolean
enable_remember_me:
type: boolean
default: true
required:
- flow_designation
- password_fields
@ -46161,6 +46463,10 @@ components:
type: boolean
description: When enabled, the stage will succeed and continue even when
incorrect user info is entered.
enable_remember_me:
type: boolean
description: Show the user the 'Remember me on this device' toggle, allowing
repeat users to skip straight to entering their password.
required:
- component
- meta_model_name
@ -46235,6 +46541,10 @@ components:
type: boolean
description: When enabled, the stage will succeed and continue even when
incorrect user info is entered.
enable_remember_me:
type: boolean
description: Show the user the 'Remember me on this device' toggle, allowing
repeat users to skip straight to entering their password.
required:
- name
ImpersonationRequest:
@ -48051,6 +48361,7 @@ components:
- authentik_core.applicationentitlement
- authentik_core.token
- authentik_enterprise.license
- authentik_policies_unique_password.uniquepasswordpolicy
- authentik_providers_google_workspace.googleworkspaceprovider
- authentik_providers_google_workspace.googleworkspaceprovidermapping
- authentik_providers_microsoft_entra.microsoftentraprovider
@ -50605,6 +50916,18 @@ components:
required:
- pagination
- results
PaginatedUniquePasswordPolicyList:
type: object
properties:
pagination:
$ref: '#/components/schemas/Pagination'
results:
type: array
items:
$ref: '#/components/schemas/UniquePasswordPolicy'
required:
- pagination
- results
PaginatedUserAssignedObjectPermissionList:
type: object
properties:
@ -52290,6 +52613,10 @@ components:
type: boolean
description: When enabled, the stage will succeed and continue even when
incorrect user info is entered.
enable_remember_me:
type: boolean
description: Show the user the 'Remember me on this device' toggle, allowing
repeat users to skip straight to entering their password.
PatchedInitialPermissionsRequest:
type: object
description: InitialPermissions serializer
@ -54210,6 +54537,27 @@ components:
nullable: true
expiring:
type: boolean
PatchedUniquePasswordPolicyRequest:
type: object
description: Password Uniqueness Policy Serializer
properties:
name:
type: string
minLength: 1
execution_logging:
type: boolean
description: When this option is enabled, all executions of this policy
will be logged. By default, only execution errors are logged.
password_field:
type: string
minLength: 1
description: Field key to check, field keys defined in Prompt stages are
available.
num_historical_passwords:
type: integer
maximum: 2147483647
minimum: 0
description: Number of passwords to check against.
PatchedUserDeleteStageRequest:
type: object
description: UserDeleteStage Serializer
@ -59206,6 +59554,81 @@ components:
- light
- dark
type: string
UniquePasswordPolicy:
type: object
description: Password Uniqueness Policy Serializer
properties:
pk:
type: string
format: uuid
readOnly: true
title: Policy uuid
name:
type: string
execution_logging:
type: boolean
description: When this option is enabled, all executions of this policy
will be logged. By default, only execution errors are logged.
component:
type: string
description: Get object component so that we know how to edit the object
readOnly: true
verbose_name:
type: string
description: Return object's verbose_name
readOnly: true
verbose_name_plural:
type: string
description: Return object's plural verbose_name
readOnly: true
meta_model_name:
type: string
description: Return internal model name
readOnly: true
bound_to:
type: integer
description: Return objects policy is bound to
readOnly: true
password_field:
type: string
description: Field key to check, field keys defined in Prompt stages are
available.
num_historical_passwords:
type: integer
maximum: 2147483647
minimum: 0
description: Number of passwords to check against.
required:
- bound_to
- component
- meta_model_name
- name
- pk
- verbose_name
- verbose_name_plural
UniquePasswordPolicyRequest:
type: object
description: Password Uniqueness Policy Serializer
properties:
name:
type: string
minLength: 1
execution_logging:
type: boolean
description: When this option is enabled, all executions of this policy
will be logged. By default, only execution errors are logged.
password_field:
type: string
minLength: 1
description: Field key to check, field keys defined in Prompt stages are
available.
num_historical_passwords:
type: integer
maximum: 2147483647
minimum: 0
description: Number of passwords to check against.
required:
- name
UsedBy:
type: object
description: A list of all objects referencing the queried object

View File

@ -410,77 +410,3 @@ class TestProviderOAuth2OAuth(SeleniumTestCase):
self.driver.find_element(By.CSS_SELECTOR, "header > h1").text,
"Permission denied",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint("default/flow-default-provider-authorization-implicit-consent.yaml")
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
def test_authorization_consent_implied_parallel(self):
"""test OpenID Provider flow (default authorization flow with implied consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider = OAuth2Provider.objects.create(
name=generate_id(),
client_type=ClientTypes.CONFIDENTIAL,
client_id=self.client_id,
client_secret=self.client_secret,
signing_key=create_test_cert(),
redirect_uris=[
RedirectURI(
RedirectURIMatchingMode.STRICT, "http://localhost:3000/login/generic_oauth"
)
],
authorization_flow=authorization_flow,
)
provider.property_mappings.set(
ScopeMapping.objects.filter(
scope_name__in=[
SCOPE_OPENID,
SCOPE_OPENID_EMAIL,
SCOPE_OPENID_PROFILE,
SCOPE_OFFLINE_ACCESS,
]
)
)
Application.objects.create(
name=generate_id(),
slug=self.app_slug,
provider=provider,
)
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
grafana_window = self.driver.current_window_handle
self.driver.get("http://localhost:3000")
self.driver.find_element(By.CLASS_NAME, "btn-service--oauth").click()
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(grafana_window)
self.wait_for_url("http://localhost:3000/?orgId=1")
self.driver.get("http://localhost:3000/profile")
self.assertEqual(
self.driver.find_element(By.CLASS_NAME, "page-header__title").text,
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=name]").get_attribute("value"),
self.user.name,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=email]").get_attribute("value"),
self.user.email,
)
self.assertEqual(
self.driver.find_element(By.CSS_SELECTOR, "input[name=login]").get_attribute("value"),
self.user.email,
)

View File

@ -20,7 +20,7 @@ from tests.e2e.utils import SeleniumTestCase, retry
class TestProviderSAML(SeleniumTestCase):
"""test SAML Provider flow"""
def setup_client(self, provider: SAMLProvider, force_post: bool = False, **kwargs):
def setup_client(self, provider: SAMLProvider, force_post: bool = False):
"""Setup client saml-sp container which we test SAML against"""
metadata_url = (
self.url(
@ -40,7 +40,6 @@ class TestProviderSAML(SeleniumTestCase):
"SP_ENTITY_ID": provider.issuer,
"SP_SSO_BINDING": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
"SP_METADATA_URL": metadata_url,
**kwargs,
},
)
@ -112,74 +111,6 @@ class TestProviderSAML(SeleniumTestCase):
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url="http://localhost:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True)
self.driver.get("http://localhost:9009")
self.login()
self.wait_for_url("http://localhost:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
@ -519,81 +450,3 @@ class TestProviderSAML(SeleniumTestCase):
lambda driver: driver.current_url.startswith(should_url),
f"URL {self.driver.current_url} doesn't match expected URL {should_url}",
)
@retry()
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
)
@apply_blueprint(
"system/providers-saml.yaml",
)
@reconcile_app("authentik_crypto")
def test_sp_initiated_implicit_post_buffer(self):
"""test SAML Provider flow SP-initiated flow (implicit consent)"""
# Bootstrap all needed objects
authorization_flow = Flow.objects.get(
slug="default-provider-authorization-implicit-consent"
)
provider: SAMLProvider = SAMLProvider.objects.create(
name="saml-test",
acs_url=f"http://{self.host}:9009/saml/acs",
audience="authentik-e2e",
issuer="authentik-e2e",
sp_binding=SAMLBindings.POST,
authorization_flow=authorization_flow,
signing_kp=create_test_cert(),
)
provider.property_mappings.set(SAMLPropertyMapping.objects.all())
provider.save()
Application.objects.create(
name="SAML",
slug="authentik-saml",
provider=provider,
)
self.setup_client(provider, True, SP_ROOT_URL=f"http://{self.host}:9009")
self.driver.get(self.live_server_url)
login_window = self.driver.current_window_handle
self.driver.switch_to.new_window("tab")
client_window = self.driver.current_window_handle
# We need to access the SP on the same host as the IdP for SameSite cookies
self.driver.get(f"http://{self.host}:9009")
self.driver.switch_to.window(login_window)
self.login()
self.driver.switch_to.window(client_window)
self.wait_for_url(f"http://{self.host}:9009/")
body = loads(self.driver.find_element(By.CSS_SELECTOR, "pre").text)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"],
[self.user.name],
)
self.assertEqual(
body["attr"][
"http://schemas.microsoft.com/ws/2008/06/identity/claims/windowsaccountname"
],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/username"],
[self.user.username],
)
self.assertEqual(
body["attr"]["http://schemas.goauthentik.io/2021/02/saml/uid"],
[str(self.user.pk)],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"],
[self.user.email],
)
self.assertEqual(
body["attr"]["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn"],
[self.user.email],
)

70
uv.lock generated
View File

@ -379,11 +379,11 @@ wheels = [
[[package]]
name = "automat"
version = "24.8.1"
version = "25.4.16"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8d/2d/ede4ad7fc34ab4482389fa3369d304f2fa22e50770af706678f6a332fa82/automat-24.8.1.tar.gz", hash = "sha256:b34227cf63f6325b8ad2399ede780675083e439b20c323d376373d8ee6306d88", size = 128679 }
sdist = { url = "https://files.pythonhosted.org/packages/e3/0f/d40bbe294bbf004d436a8bcbcfaadca8b5140d39ad0ad3d73d1a8ba15f14/automat-25.4.16.tar.gz", hash = "sha256:0017591a5477066e90d26b0e696ddc143baafd87b588cfac8100bc6be9634de0", size = 129977 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/cc/55a32a2c98022d88812b5986d2a92c4ff3ee087e83b712ebc703bba452bf/Automat-24.8.1-py3-none-any.whl", hash = "sha256:bf029a7bc3da1e2c24da2343e7598affaa9f10bf0ab63ff808566ce90551e02a", size = 42585 },
{ url = "https://files.pythonhosted.org/packages/02/ff/1175b0b7371e46244032d43a56862d0af455823b5280a50c63d99cc50f18/automat-25.4.16-py3-none-any.whl", hash = "sha256:04e9bce696a8d5671ee698005af6e5a9fa15354140a87f4870744604dcdd3ba1", size = 42842 },
]
[[package]]
@ -558,30 +558,30 @@ wheels = [
[[package]]
name = "boto3"
version = "1.37.34"
version = "1.37.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/39/5d/6b1ca20ba4da350799509a69f2d295ae11d5ec08a98e82f74b5708a8180c/boto3-1.37.34.tar.gz", hash = "sha256:94ca07328474db3fa605eb99b011512caa73f7161740d365a1f00cfebfb6dd90", size = 111701 }
sdist = { url = "https://files.pythonhosted.org/packages/48/5f/e356ecd2f236e6ddc7711eaf3f075c15b13e2d044cfdb47719d49c4ae7dd/boto3-1.37.35.tar.gz", hash = "sha256:751ed599c8fd9ca24896edcd6620e8a32b3db1b68efea3a90126312240e668a2", size = 111640 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/2e/ad43d1e87d46d11dcf4104f97b9a7f6beb38a52a0e752edfadf3eb8b6e38/boto3-1.37.34-py3-none-any.whl", hash = "sha256:586bfa72a00601c04067f9adcbb08ecaf63b05b7d731103f33cb2ce0d6950b1b", size = 139920 },
{ url = "https://files.pythonhosted.org/packages/f6/e4/00958f65ac74ab0a76af33f16c8fdf5726a5c6f0d3c0d0c058ff0dd00fd7/boto3-1.37.35-py3-none-any.whl", hash = "sha256:5a90d674830adbaf86456d6b27a18f5f11378277da5286511fa860d2e7b14261", size = 139922 },
]
[[package]]
name = "botocore"
version = "1.37.34"
version = "1.37.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ca/60/9ec251a0e2d3994f3eac8bd9741576757c3aad189abbdec8fab6011f5a1a/botocore-1.37.34.tar.gz", hash = "sha256:2909b6dbf9c90347c71a6fa0364acee522d6a7664f13d6f7996c9dd1b1f46fac", size = 13817141 }
sdist = { url = "https://files.pythonhosted.org/packages/64/0b/d281d74d53f7d4733402aed7a536275084fa344a2672f7ea4dbc8ebe1f1b/botocore-1.37.35.tar.gz", hash = "sha256:197a9bf8251c45b9d882c405ec0d0ab40c10e2d2a55ee66960185daec4beb6ec", size = 13821053 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e8/51/19fff717cc5000708c4ce3d081bb0e63ca117c6823975b33101d52fdd9f5/botocore-1.37.34-py3-none-any.whl", hash = "sha256:bd9af0db1097befd2028ba8525e32cacc04f26ccb9dbd5d48d6ecd05bc16c27a", size = 13483679 },
{ url = "https://files.pythonhosted.org/packages/22/00/bf9c894f5af8e35b06ecf757d4a95883408e71c48642dc7f8760580584fd/botocore-1.37.35-py3-none-any.whl", hash = "sha256:50839212e90650d0b0fa6b8f7514876bf802f6164f2775f3abcd4d53c98bb73c", size = 13485892 },
]
[[package]]
@ -1726,16 +1726,16 @@ wheels = [
[[package]]
name = "kombu"
version = "5.5.2"
version = "5.5.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "amqp" },
{ name = "tzdata" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/12/7a340f48920f30d6febb65d0c4aca70ed01b29e116131152977df78a9a39/kombu-5.5.2.tar.gz", hash = "sha256:2dd27ec84fd843a4e0a7187424313f87514b344812cb98c25daddafbb6a7ff0e", size = 461522 }
sdist = { url = "https://files.pythonhosted.org/packages/60/0a/128b65651ed8120460fc5af754241ad595eac74993115ec0de4f2d7bc459/kombu-5.5.3.tar.gz", hash = "sha256:021a0e11fcfcd9b0260ef1fb64088c0e92beb976eb59c1dfca7ddd4ad4562ea2", size = 461784 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/af/ba/939f3db0fca87715c883e42cc93045347d61a9d519c270a38e54a06db6e1/kombu-5.5.2-py3-none-any.whl", hash = "sha256:40f3674ed19603b8a771b6c74de126dbf8879755a0337caac6602faa82d539cd", size = 209763 },
{ url = "https://files.pythonhosted.org/packages/5d/35/1407fb0b2f5b07b50cbaf97fce09ad87d3bfefbf64f7171a8651cd8d2f68/kombu-5.5.3-py3-none-any.whl", hash = "sha256:5b0dbceb4edee50aa464f59469d34b97864be09111338cfb224a10b6a163909b", size = 209921 },
]
[[package]]
@ -3185,15 +3185,15 @@ socks = [
[[package]]
name = "uvicorn"
version = "0.34.1"
version = "0.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/86/37/dd92f1f9cedb5eaf74d9999044306e06abe65344ff197864175dbbd91871/uvicorn-0.34.1.tar.gz", hash = "sha256:af981725fc4b7ffc5cb3b0e9eda6258a90c4b52cb2a83ce567ae0a7ae1757afc", size = 76755 }
sdist = { url = "https://files.pythonhosted.org/packages/a6/ae/9bbb19b9e1c450cf9ecaef06463e40234d98d95bf572fab11b4f19ae5ded/uvicorn-0.34.2.tar.gz", hash = "sha256:0e929828f6186353a80b58ea719861d2629d766293b6d19baf086ba31d4f3328", size = 76815 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5f/38/a5801450940a858c102a7ad9e6150146a25406a119851c993148d56ab041/uvicorn-0.34.1-py3-none-any.whl", hash = "sha256:984c3a8c7ca18ebaad15995ee7401179212c59521e67bfc390c07fa2b8d2e065", size = 62404 },
{ url = "https://files.pythonhosted.org/packages/b1/4b/4cef6ce21a2aaca9d852a6e84ef4f135d99fcd74fa75105e2fc0c8308acd/uvicorn-0.34.2-py3-none-any.whl", hash = "sha256:deb49af569084536d269fe0a6d67e3754f104cf03aba7c11c40f01aadf33c403", size = 62483 },
]
[package.optional-dependencies]
@ -3382,33 +3382,33 @@ wheels = [
[[package]]
name = "yarl"
version = "1.19.0"
version = "1.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/4d/8a8f57caccce49573e567744926f88c6ab3ca0b47a257806d1cf88584c5f/yarl-1.19.0.tar.gz", hash = "sha256:01e02bb80ae0dbed44273c304095295106e1d9470460e773268a27d11e594892", size = 184396 }
sdist = { url = "https://files.pythonhosted.org/packages/62/51/c0edba5219027f6eab262e139f73e2417b0f4efffa23bf562f6e18f76ca5/yarl-1.20.0.tar.gz", hash = "sha256:686d51e51ee5dfe62dec86e4866ee0e9ed66df700d55c828a615640adc885307", size = 185258 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/70/44ef8f69d61cb5123167a4dda87f6c739a833fbdb2ed52960b4e8409d65c/yarl-1.19.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:7b687c334da3ff8eab848c9620c47a253d005e78335e9ce0d6868ed7e8fd170b", size = 146855 },
{ url = "https://files.pythonhosted.org/packages/c3/94/38c14d6c8217cc818647689f2dd647b976ced8fea08d0ac84e3c8168252b/yarl-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b0fe766febcf523a2930b819c87bb92407ae1368662c1bc267234e79b20ff894", size = 97523 },
{ url = "https://files.pythonhosted.org/packages/35/a5/43a613586a6255105c4655a911c307ef3420e49e540d6ae2c5829863fb25/yarl-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:742ceffd3c7beeb2b20d47cdb92c513eef83c9ef88c46829f88d5b06be6734ee", size = 95540 },
{ url = "https://files.pythonhosted.org/packages/d4/60/ed26049f4a8b06ebfa6d5f3cb6a51b152fd57081aa818b6497474f65a631/yarl-1.19.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2af682a1e97437382ee0791eacbf540318bd487a942e068e7e0a6c571fadbbd3", size = 344386 },
{ url = "https://files.pythonhosted.org/packages/49/a6/b84899cab411f49af5986cfb44b514040788d81c8084f5811e6a7c0f1ce6/yarl-1.19.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:63702f1a098d0eaaea755e9c9d63172be1acb9e2d4aeb28b187092bcc9ca2d17", size = 338889 },
{ url = "https://files.pythonhosted.org/packages/cc/ce/0704f7166a781b1f81bdd45c4f49eadbae0230ebd35b9ec7cd7769d3a6ff/yarl-1.19.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3560dcba3c71ae7382975dc1e912ee76e50b4cd7c34b454ed620d55464f11876", size = 353107 },
{ url = "https://files.pythonhosted.org/packages/75/e5/0ecd6f2a9cc4264c16d8dfb0d3d71ba8d03cb58f3bcd42b1df4358331189/yarl-1.19.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:68972df6a0cc47c8abaf77525a76ee5c5f6ea9bbdb79b9565b3234ded3c5e675", size = 353128 },
{ url = "https://files.pythonhosted.org/packages/ad/c7/cd0fd1de581f1c2e8f996e704c9fd979e00106f18eebd91b0173cf1a13c6/yarl-1.19.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5684e7ff93ea74e47542232bd132f608df4d449f8968fde6b05aaf9e08a140f9", size = 349107 },
{ url = "https://files.pythonhosted.org/packages/e6/34/ba3e5a20bd1d6a09034fc7985aaf1309976f2a7a5aefd093c9e56f6e1e0c/yarl-1.19.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8182ad422bfacdebd4759ce3adc6055c0c79d4740aea1104e05652a81cd868c6", size = 335144 },
{ url = "https://files.pythonhosted.org/packages/1e/98/d9b7beb932fade015906efe0980aa7d522b8f93cf5ebf1082e74faa314b7/yarl-1.19.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:aee5b90a5a9b71ac57400a7bdd0feaa27c51e8f961decc8d412e720a004a1791", size = 360795 },
{ url = "https://files.pythonhosted.org/packages/9a/11/70b8770039cc54af5948970591517a1e1d093df3f04f328c655c9a0fefb7/yarl-1.19.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:8c0b2371858d5a814b08542d5d548adb03ff2d7ab32f23160e54e92250961a72", size = 360140 },
{ url = "https://files.pythonhosted.org/packages/d4/67/708e3e36fafc4d9d96b4eecc6c8b9f37c8ad50df8a16c7a1d5ba9df53050/yarl-1.19.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd430c2b7df4ae92498da09e9b12cad5bdbb140d22d138f9e507de1aa3edfea3", size = 364431 },
{ url = "https://files.pythonhosted.org/packages/c3/8b/937fbbcc895553a7e16fcd86ae4e0724c6ac9468237ad8e7c29cc3b1c9d9/yarl-1.19.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a93208282c0ccdf73065fd76c6c129bd428dba5ff65d338ae7d2ab27169861a0", size = 373832 },
{ url = "https://files.pythonhosted.org/packages/f8/ca/288ddc2230c9b6647fe907504f1119adb41252ac533eb564d3fc73511215/yarl-1.19.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:b8179280cdeb4c36eb18d6534a328f9d40da60d2b96ac4a295c5f93e2799e9d9", size = 378122 },
{ url = "https://files.pythonhosted.org/packages/4f/5a/79e1ef31d14968fbfc0ecec70a6683b574890d9c7550c376dd6d40de7754/yarl-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:eda3c2b42dc0c389b7cfda2c4df81c12eeb552019e0de28bde8f913fc3d1fcf3", size = 375178 },
{ url = "https://files.pythonhosted.org/packages/95/38/9b0e56bf14026c3f550ad6425679f6d1a2f4821d70767f39d6f4c56a0820/yarl-1.19.0-cp312-cp312-win32.whl", hash = "sha256:57f3fed859af367b9ca316ecc05ce79ce327d6466342734305aa5cc380e4d8be", size = 86172 },
{ url = "https://files.pythonhosted.org/packages/b3/96/5c2f3987c4bb4e5cdebea3caf99a45946b13a9516f849c02222203d99860/yarl-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:5507c1f7dd3d41251b67eecba331c8b2157cfd324849879bebf74676ce76aff7", size = 92617 },
{ url = "https://files.pythonhosted.org/packages/a4/06/ae25a353e8f032322df6f30d6bb1fc329773ee48e1a80a2196ccb8d1206b/yarl-1.19.0-py3-none-any.whl", hash = "sha256:a727101eb27f66727576630d02985d8a065d09cd0b5fcbe38a5793f71b2a97ef", size = 45990 },
{ url = "https://files.pythonhosted.org/packages/c3/e8/3efdcb83073df978bb5b1a9cc0360ce596680e6c3fac01f2a994ccbb8939/yarl-1.20.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e06b9f6cdd772f9b665e5ba8161968e11e403774114420737f7884b5bd7bdf6f", size = 147089 },
{ url = "https://files.pythonhosted.org/packages/60/c3/9e776e98ea350f76f94dd80b408eaa54e5092643dbf65fd9babcffb60509/yarl-1.20.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b9ae2fbe54d859b3ade40290f60fe40e7f969d83d482e84d2c31b9bff03e359e", size = 97706 },
{ url = "https://files.pythonhosted.org/packages/0c/5b/45cdfb64a3b855ce074ae607b9fc40bc82e7613b94e7612b030255c93a09/yarl-1.20.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d12b8945250d80c67688602c891237994d203d42427cb14e36d1a732eda480e", size = 95719 },
{ url = "https://files.pythonhosted.org/packages/2d/4e/929633b249611eeed04e2f861a14ed001acca3ef9ec2a984a757b1515889/yarl-1.20.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:087e9731884621b162a3e06dc0d2d626e1542a617f65ba7cc7aeab279d55ad33", size = 343972 },
{ url = "https://files.pythonhosted.org/packages/49/fd/047535d326c913f1a90407a3baf7ff535b10098611eaef2c527e32e81ca1/yarl-1.20.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69df35468b66c1a6e6556248e6443ef0ec5f11a7a4428cf1f6281f1879220f58", size = 339639 },
{ url = "https://files.pythonhosted.org/packages/48/2f/11566f1176a78f4bafb0937c0072410b1b0d3640b297944a6a7a556e1d0b/yarl-1.20.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b2992fe29002fd0d4cbaea9428b09af9b8686a9024c840b8a2b8f4ea4abc16f", size = 353745 },
{ url = "https://files.pythonhosted.org/packages/26/17/07dfcf034d6ae8837b33988be66045dd52f878dfb1c4e8f80a7343f677be/yarl-1.20.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4c903e0b42aab48abfbac668b5a9d7b6938e721a6341751331bcd7553de2dcae", size = 354178 },
{ url = "https://files.pythonhosted.org/packages/15/45/212604d3142d84b4065d5f8cab6582ed3d78e4cc250568ef2a36fe1cf0a5/yarl-1.20.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bf099e2432131093cc611623e0b0bcc399b8cddd9a91eded8bfb50402ec35018", size = 349219 },
{ url = "https://files.pythonhosted.org/packages/e6/e0/a10b30f294111c5f1c682461e9459935c17d467a760c21e1f7db400ff499/yarl-1.20.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8a7f62f5dc70a6c763bec9ebf922be52aa22863d9496a9a30124d65b489ea672", size = 337266 },
{ url = "https://files.pythonhosted.org/packages/33/a6/6efa1d85a675d25a46a167f9f3e80104cde317dfdf7f53f112ae6b16a60a/yarl-1.20.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:54ac15a8b60382b2bcefd9a289ee26dc0920cf59b05368c9b2b72450751c6eb8", size = 360873 },
{ url = "https://files.pythonhosted.org/packages/77/67/c8ab718cb98dfa2ae9ba0f97bf3cbb7d45d37f13fe1fbad25ac92940954e/yarl-1.20.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:25b3bc0763a7aca16a0f1b5e8ef0f23829df11fb539a1b70476dcab28bd83da7", size = 360524 },
{ url = "https://files.pythonhosted.org/packages/bd/e8/c3f18660cea1bc73d9f8a2b3ef423def8dadbbae6c4afabdb920b73e0ead/yarl-1.20.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b2586e36dc070fc8fad6270f93242124df68b379c3a251af534030a4a33ef594", size = 365370 },
{ url = "https://files.pythonhosted.org/packages/c9/99/33f3b97b065e62ff2d52817155a89cfa030a1a9b43fee7843ef560ad9603/yarl-1.20.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:866349da9d8c5290cfefb7fcc47721e94de3f315433613e01b435473be63daa6", size = 373297 },
{ url = "https://files.pythonhosted.org/packages/3d/89/7519e79e264a5f08653d2446b26d4724b01198a93a74d2e259291d538ab1/yarl-1.20.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:33bb660b390a0554d41f8ebec5cd4475502d84104b27e9b42f5321c5192bfcd1", size = 378771 },
{ url = "https://files.pythonhosted.org/packages/3a/58/6c460bbb884abd2917c3eef6f663a4a873f8dc6f498561fc0ad92231c113/yarl-1.20.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737e9f171e5a07031cbee5e9180f6ce21a6c599b9d4b2c24d35df20a52fabf4b", size = 375000 },
{ url = "https://files.pythonhosted.org/packages/3b/2a/dd7ed1aa23fea996834278d7ff178f215b24324ee527df53d45e34d21d28/yarl-1.20.0-cp312-cp312-win32.whl", hash = "sha256:839de4c574169b6598d47ad61534e6981979ca2c820ccb77bf70f4311dd2cc64", size = 86355 },
{ url = "https://files.pythonhosted.org/packages/ca/c6/333fe0338305c0ac1c16d5aa7cc4841208d3252bbe62172e0051006b5445/yarl-1.20.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d7dbbe44b443b0c4aa0971cb07dcb2c2060e4a9bf8d1301140a33a93c98e18c", size = 92904 },
{ url = "https://files.pythonhosted.org/packages/ea/1f/70c57b3d7278e94ed22d85e09685d3f0a38ebdd8c5c73b65ba4c0d0fe002/yarl-1.20.0-py3-none-any.whl", hash = "sha256:5d0fe6af927a47a230f31e6004621fd0959eaa915fc62acfafa67ff7229a3124", size = 46124 },
]
[[package]]

View File

@ -1,23 +0,0 @@
{
"arrowParens": "always",
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": false,
"printWidth": 100,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleQuote": false,
"tabWidth": 4,
"trailingComma": "all",
"useTabs": false,
"vueIndentScriptAndStyle": false,
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"importOrder": ["^(@?)lit(.*)$", "\\.css$", "^@goauthentik/api$", "^[./]"],
"importOrderSeparation": true,
"importOrderSortSpecifiers": true,
"importOrderParserPlugins": ["typescript", "jsx", "classProperties", "decorators-legacy"]
}

View File

@ -16,16 +16,16 @@ three contexts in which to run.
The three contexts corresponds to objects in the API's `model` section, so let's use those names.
- The root `Config`. The root configuration object of the server, containing mostly caching and
error reporting information. This is misleading, however; the `Config` object contains some user
information, specifically a list of permissions the current user (or "no user") has.
- The root `CurrentTenant`. This describes the `Brand` information UIs should use, such as themes,
logos, favicon, and specific default flows for logging in, logging out, and recovering a user
password.
- The current `SessionUser`, the person logged in: username, display name, and various states.
(Note: the authentik server permits administrators to "impersonate" any other user in order to
debug their authentikation experience. If impersonation is active, the `user` field reflects that
user, but it also includes a field, `original`, with the administrator's information.)
- The root `Config`. The root configuration object of the server, containing mostly caching and
error reporting information. This is misleading, however; the `Config` object contains some user
information, specifically a list of permissions the current user (or "no user") has.
- The root `CurrentTenant`. This describes the `Brand` information UIs should use, such as themes,
logos, favicon, and specific default flows for logging in, logging out, and recovering a user
password.
- The current `SessionUser`, the person logged in: username, display name, and various states.
(Note: the authentik server permits administrators to "impersonate" any other user in order to
debug their authentikation experience. If impersonation is active, the `user` field reflects that
user, but it also includes a field, `original`, with the administrator's information.)
(There is a fourth context object, Version, but its use is limited to displaying version information
and checking for upgrades. Just be aware that you will see it, but you will probably never interact
@ -36,55 +36,55 @@ insides are provided by third-party libraries (Patternfly and Rapidoc, respectiv
three are actual applications. The descriptions below are wholly from the view of the user's
experience:
- `Flow`: From a given URL, displays a form that requests information from the user to accomplish a
task. Some tasks require the user to be logged in, but many (such as logging in itself!)
obviously do not.
- `User`: Provides the user with access to the applications they can access, plus a few user
settings.
- `Admin`: Provides someone with super-user permissions access to the administrative functions of
the authentik server.
- `Flow`: From a given URL, displays a form that requests information from the user to accomplish a
task. Some tasks require the user to be logged in, but many (such as logging in itself!)
obviously do not.
- `User`: Provides the user with access to the applications they can access, plus a few user
settings.
- `Admin`: Provides someone with super-user permissions access to the administrative functions of
the authentik server.
**Mental Model**
- Upon initialization, _every_ authentik UI application fetches `Config` and `CurrentTenant`. `User`
and `Admin` will also attempt to load the `SessionUser`; if there is none, the user is kicked out
to the `Flow` for logging into authentik itself.
- `Config`, `CurrentTenant`, and `SessionUser`, are provided by the `@goauthentik/api` application,
not by the codebase under `./web`. (Where you are now).
- `Flow`, `User`, and `Admin` are all called `Interfaces` and are found in
`./web/src/flow/FlowInterface`, `./web/src/user/UserInterface`, `./web/src/admin/AdminInterface`,
respectively.
- Upon initialization, _every_ authentik UI application fetches `Config` and `CurrentTenant`. `User`
and `Admin` will also attempt to load the `SessionUser`; if there is none, the user is kicked out
to the `Flow` for logging into authentik itself.
- `Config`, `CurrentTenant`, and `SessionUser`, are provided by the `@goauthentik/api` application,
not by the codebase under `./web`. (Where you are now).
- `Flow`, `User`, and `Admin` are all called `Interfaces` and are found in
`./web/src/flow/FlowInterface`, `./web/src/user/UserInterface`, `./web/src/admin/AdminInterface`,
respectively.
Inside each of these you will find, in a hierarchal order:
- The context layer described above
- A theme managing layer
- The orchestration layer:
- web socket handler for server-generated events
- The router
- Individual routes for each vertical slice and its relationship to other objects:
- The context layer described above
- A theme managing layer
- The orchestration layer:
- web socket handler for server-generated events
- The router
- Individual routes for each vertical slice and its relationship to other objects:
Each slice corresponds to an object table on the server, and each slice _usually_ consists of the
following:
- A paginated collection display, usually using the `Table` foundation (found in
`./web/src/elements/Table`)
- The ability to view an individual object from the collection, which you may be able to:
- Edit
- Delete
- A form for creating a new object
- Tabs showing that object's relationship to other objects
- Interactive elements for changing or deleting those relationships, or creating new ones.
- The ability to create new objects with which to have that relationship, if they're not part of
the core objects (such as User->MFA authenticator apps, since the latter is not a "core" object
and has no tab of its own).
- A paginated collection display, usually using the `Table` foundation (found in
`./web/src/elements/Table`)
- The ability to view an individual object from the collection, which you may be able to:
- Edit
- Delete
- A form for creating a new object
- Tabs showing that object's relationship to other objects
- Interactive elements for changing or deleting those relationships, or creating new ones.
- The ability to create new objects with which to have that relationship, if they're not part of
the core objects (such as User->MFA authenticator apps, since the latter is not a "core" object
and has no tab of its own).
We are still a bit "all over the place" with respect to sub-units and common units; there are
folders `common`, `elements`, and `components`, and ideally they would be:
- `common`: non-UI related libraries all of our applications need
- `elements`: UI elements shared among multiple applications that do not need context
- `components`: UI elements shared among multiple that use one or more context
- `common`: non-UI related libraries all of our applications need
- `elements`: UI elements shared among multiple applications that do not need context
- `components`: UI elements shared among multiple that use one or more context
... but at the moment there are some context-sensitive elements, and some UI-related stuff in
`common`.
@ -95,18 +95,18 @@ folders `common`, `elements`, and `components`, and ideally they would be:
reliably documented any other way. For the most part, they contain comments related to custom
settings in JSON files, which do not support comments.
- `tsconfig.json`:
- `compilerOptions.useDefineForClassFields: false` is required to make TSC use the "classic" form
of field definition when compiling class definitions. Storybook does not handle the ESNext
proposed definition mechanism (yet).
- `compilerOptions.plugins.ts-lit-plugin.rules.no-unknown-tag-name: "off"`: required to support
rapidoc, which exports its tag late.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-missing-import: "off"`: lit-analyzer currently
does not support path aliases very well, and cannot find the definition files associated with
imports using them.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
too many errors to be supportable.
- `tsconfig.json`:
- `compilerOptions.useDefineForClassFields: false` is required to make TSC use the "classic" form
of field definition when compiling class definitions. Storybook does not handle the ESNext
proposed definition mechanism (yet).
- `compilerOptions.plugins.ts-lit-plugin.rules.no-unknown-tag-name: "off"`: required to support
rapidoc, which exports its tag late.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-missing-import: "off"`: lit-analyzer currently
does not support path aliases very well, and cannot find the definition files associated with
imports using them.
- `compilerOptions.plugins.ts-lit-plugin.rules.no-incompatible-type-binding: "warn"`: lit-analyzer
does not support generics well when parsing a subtype of `HTMLElement`. As a result, this threw
too many errors to be supportable.
### License

12936
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,14 +1,6 @@
{
"name": "@goauthentik/web",
"version": "0.0.0",
"overrides": {
"rapidoc": {
"@apitools/openapi-parser@": "0.0.37"
},
"chromedriver": {
"axios": "^1.8.4"
}
},
"dependencies": {
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-html": "^6.4.9",
@ -20,7 +12,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.5.7",
"@fortawesome/fontawesome-free": "^6.6.0",
"@goauthentik/api": "^2025.2.4-1744640358",
"@goauthentik/api": "^2025.2.4-1745325566",
"@lit-labs/ssr": "^3.2.2",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
@ -69,6 +61,9 @@
},
"devDependencies": {
"@eslint/js": "^9.11.1",
"@goauthentik/esbuild-plugin-live-reload": "^1.0.4",
"@goauthentik/prettier-config": "^1.0.4",
"@goauthentik/tsconfig": "^1.0.4",
"@hcaptcha/types": "^1.0.4",
"@lit/localize-tools": "^0.8.0",
"@rollup/plugin-replace": "^6.0.1",
@ -80,7 +75,7 @@
"@storybook/manager-api": "^8.3.4",
"@storybook/web-components": "^8.3.4",
"@storybook/web-components-vite": "^8.3.4",
"@trivago/prettier-plugin-sort-imports": "^4.3.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/chart.js": "^2.9.41",
"@types/codemirror": "^5.60.15",
"@types/dompurify": "^3.0.5",
@ -103,7 +98,6 @@
"eslint": "^9.11.1",
"eslint-plugin-lit": "^1.15.0",
"eslint-plugin-wc": "^2.1.1",
"find-free-ports": "^3.1.1",
"github-slugger": "^2.0.0",
"glob": "^11.0.0",
"globals": "^15.10.0",
@ -136,6 +130,15 @@
"@rollup/rollup-linux-arm64-gnu": "4.23.0",
"@rollup/rollup-linux-x64-gnu": "4.23.0"
},
"overrides": {
"rapidoc": {
"@apitools/openapi-parser@": "0.0.37"
},
"chromedriver": {
"axios": "^1.8.4"
}
},
"prettier": "@goauthentik/prettier-config",
"private": true,
"scripts": {
"build": "wireit",
@ -271,7 +274,7 @@
"command": "tsc --noEmit -p ./tests"
},
"lint:types": {
"command": "tsc --noEmit -p .",
"command": "NODE_OPTIONS=\"--max-old-space-size=3000\" tsc -b .",
"dependencies": [
"build-locales",
"lint:types:tests"

View File

@ -1,13 +1,18 @@
/// <reference types="./types.js" />
/**
* @file
* Client-side observer for ESBuild events.
* @file Client-side observer for ESBuild events.
*
* @import { Message as ESBuildMessage } from "esbuild";
*/
import type { Message as ESBuildMessage } from "esbuild";
const logPrefix = "👷 [ESBuild]";
const log = console.debug.bind(console, logPrefix);
type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
/**
* @template {unknown} [Data=unknown]
* @typedef {(event: MessageEvent) => void} BuildEventListener
*/
/**
* A client-side watcher for ESBuild.
@ -16,14 +21,13 @@ type BuildEventListener<Data = unknown> = (event: MessageEvent<Data>) => void;
* ESBuild may tree-shake it out of production builds.
*
* ```ts
* if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
* const { ESBuildObserver } = await import("@goauthentik/common/client");
*
* new ESBuildObserver(process.env.WATCHER_URL);
* if (process.env.NODE_ENV === "development") {
* await import("@goauthentik/esbuild-plugin-live-reload/client")
* .catch(() => console.warn("Failed to import watcher"))
* }
* ```
}
*
* @implements {Disposable}
*/
export class ESBuildObserver extends EventSource {
/**
@ -58,15 +62,19 @@ export class ESBuildObserver extends EventSource {
/**
* The interval for the keep-alive check.
* @type {ReturnType<typeof setInterval> | undefined}
*/
#keepAliveInterval: ReturnType<typeof setInterval> | undefined;
#keepAliveInterval;
#trackActivity = () => {
this.lastUpdatedAt = Date.now();
this.alive = true;
};
#startListener: BuildEventListener = () => {
/**
* @type {BuildEventListener}
*/
#startListener = () => {
this.#trackActivity();
log("⏰ Build started...");
};
@ -82,13 +90,18 @@ export class ESBuildObserver extends EventSource {
}
};
#errorListener: BuildEventListener<string> = (event) => {
/**
* @type {BuildEventListener<string>}
*/
#errorListener = (event) => {
this.#trackActivity();
// eslint-disable-next-line no-console
console.group(logPrefix, "⛔️⛔️⛔️ Build error...");
const esbuildErrorMessages: ESBuildMessage[] = JSON.parse(event.data);
/**
* @type {ESBuildMessage[]}
*/
const esbuildErrorMessages = JSON.parse(event.data);
for (const error of esbuildErrorMessages) {
console.warn(error.text);
@ -101,11 +114,13 @@ export class ESBuildObserver extends EventSource {
}
}
// eslint-disable-next-line no-console
console.groupEnd();
};
#endListener: BuildEventListener = () => {
/**
* @type {BuildEventListener}
*/
#endListener = () => {
cancelAnimationFrame(this.#reloadFrameID);
this.#trackActivity();
@ -126,12 +141,36 @@ export class ESBuildObserver extends EventSource {
});
};
#keepAliveListener: BuildEventListener = () => {
/**
* @type {BuildEventListener}
*/
#keepAliveListener = () => {
this.#trackActivity();
log("🏓 Keep-alive");
};
constructor(url: string | URL) {
/**
* Initialize the ESBuild observer.
* This should be called once in your application.
*
* @param {string | URL} [url]
* @returns {ESBuildObserver}
*/
static initialize = (url) => {
const esbuildObserver = new ESBuildObserver(url);
return esbuildObserver;
};
/**
*
* @param {string | URL} [url]
*/
constructor(url) {
if (!url) {
throw new TypeError("ESBuildObserver: Cannot construct without a URL");
}
super(url);
this.addEventListener("esbuild:start", this.#startListener);
@ -167,4 +206,14 @@ export class ESBuildObserver extends EventSource {
log("👋 Waiting for build to start...");
}, 15_000);
}
[Symbol.dispose]() {
return this.close();
}
dispose() {
return this[Symbol.dispose]();
}
}
export default ESBuildObserver;

View File

@ -0,0 +1,13 @@
/// <reference types="./types.js" />
/**
* @file Entry point for the ESBuild client-side observer.
*/
import { ESBuildObserver } from "./ESBuildObserver.js";
if (import.meta.env?.ESBUILD_WATCHER_URL) {
const buildObserver = new ESBuildObserver(import.meta.env.ESBUILD_WATCHER_URL);
window.addEventListener("beforeunload", () => {
buildObserver.dispose();
});
}

View File

@ -0,0 +1,18 @@
/**
* @file Import meta environment variables available via ESBuild.
*/
export {};
declare global {
interface ImportMeta {
readonly env: {
/**
* The injected watcher URL for ESBuild.
* This is used for live reloading in development mode.
*
* @format url
*/
ESBUILD_WATCHER_URL: string;
};
}
}

View File

@ -0,0 +1,2 @@
export * from "./client/index.js";
export * from "./plugin/index.js";

View File

@ -0,0 +1,53 @@
{
"name": "@goauthentik/esbuild-plugin-live-reload",
"description": "ESBuild plugin to watch for file changes and trigger client-side reloads.",
"version": "1.0.4",
"dependencies": {
"find-free-ports": "^3.1.1"
},
"devDependencies": {
"@goauthentik/prettier-config": "^1.0.4",
"@goauthentik/tsconfig": "^1.0.4",
"@trivago/prettier-plugin-sort-imports": "^5.2.2",
"@types/node": "^22.14.1",
"esbuild": "^0.25.0",
"prettier": "^3.3.3",
"typescript": "^5.6.2"
},
"engines": {
"node": ">=20.11"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./out/index.d.ts",
"import": "./index.js"
},
"./client": {
"types": "./out/client/index.d.ts",
"import": "./client/index.js"
},
"./plugin": {
"types": "./out/plugin/index.d.ts",
"import": "./plugin/index.js"
}
},
"files": [
"./index.js",
"client/**/*",
"plugin/**/*",
"out/**/*"
],
"license": "MIT",
"main": "index.js",
"peerDependencies": {
"esbuild": "^0.25.0"
},
"prettier": "@goauthentik/prettier-config",
"private": true,
"publishConfig": {
"access": "public"
},
"type": "module",
"types": "./out/index.d.ts"
}

View File

@ -0,0 +1,243 @@
/**
* @file Live reload plugin for ESBuild.
*
* @import { ListenOptions } from "node:net";
* @import {Server as HTTPServer} from "node:http";
* @import {Server as HTTPSServer} from "node:https";
*/
import { findFreePorts } from "find-free-ports";
import * as http from "node:http";
import * as path from "node:path";
/**
* Serializes a custom event to a text stream.
* @param {Event} event
* @returns {string}
*/
export function serializeCustomEventToStream(event) {
// @ts-expect-error - TS doesn't know about the detail property
const data = event.detail ?? {};
const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
return eventContent.join("\n") + "\n\n";
}
const MIN_PORT = 1025;
const MAX_PORT = 65535;
/**
* Find a random port that is not in use, sufficiently far from the default port.
* @returns {Promise<number>}
*/
async function findDisparatePort() {
const startPort = Math.floor(Math.random() * (MAX_PORT - MIN_PORT + 1)) + MIN_PORT;
const wathcherPorts = await findFreePorts(1, {
startPort,
});
const [port] = wathcherPorts;
if (!port) {
throw new Error("No free ports available");
}
return port;
}
/**
* Event server initialization options.
*
* @typedef {Object} EventServerInit
*
* @property {string} pathname
* @property {EventTarget} dispatcher
* @property {string} [logPrefix]
*/
/**
* @typedef {(req: http.IncomingMessage, res: http.ServerResponse) => void} RequestHandler
*/
/**
* Create an event request handler.
* @param {EventServerInit} options
* @returns {RequestHandler}
* @category ESBuild
*/
export function createRequestHandler({ pathname, dispatcher, logPrefix = "Build Observer" }) {
// eslint-disable-next-line no-console
const log = console.log.bind(console, `[${logPrefix}]`);
/**
* @type {RequestHandler}
*/
const requestHandler = (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.url !== pathname) {
log(`🚫 Invalid request to ${req.url}`);
res.writeHead(404);
res.end();
return;
}
log("🔌 Client connected");
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
/**
* @param {Event} event
*/
const listener = (event) => {
const body = serializeCustomEventToStream(event);
res.write(body);
};
dispatcher.addEventListener("esbuild:start", listener);
dispatcher.addEventListener("esbuild:error", listener);
dispatcher.addEventListener("esbuild:end", listener);
req.on("close", () => {
log("🔌 Client disconnected");
clearInterval(keepAliveInterval);
dispatcher.removeEventListener("esbuild:start", listener);
dispatcher.removeEventListener("esbuild:error", listener);
dispatcher.removeEventListener("esbuild:end", listener);
});
const keepAliveInterval = setInterval(() => {
console.timeStamp("🏓 Keep-alive");
res.write("event: keep-alive\n\n");
res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
}, 15_000);
};
return requestHandler;
}
/**
* Options for the build observer plugin.
*
* @typedef {object} BuildObserverOptions
*
* @property {HTTPServer | HTTPSServer} [server]
* @property {ListenOptions} [listenOptions]
* @property {string | URL} [publicURL]
* @property {string} [logPrefix]
* @property {string} [relativeRoot]
*/
/**
* Creates a plugin that listens for build events and sends them to a server-sent event stream.
*
* @param {BuildObserverOptions} [options]
* @returns {import('esbuild').Plugin}
*/
export function liveReloadPlugin(options = {}) {
return {
name: "build-watcher",
setup: async (build) => {
const logPrefix = options.logPrefix || "Build Observer";
const timerLabel = `[${logPrefix}] 🏁`;
const relativeRoot = options.relativeRoot || process.cwd();
const dispatcher = new EventTarget();
/**
* @type {URL}
*/
let publicURL;
if (!options.publicURL) {
const port = await findDisparatePort();
publicURL = new URL(`http://localhost:${port}/events`);
} else {
publicURL =
typeof options.publicURL === "string"
? new URL(options.publicURL)
: options.publicURL;
}
build.initialOptions.define = {
...build.initialOptions.define,
"import.meta.env.ESBUILD_WATCHER_URL": JSON.stringify(publicURL.href),
};
const requestHandler = createRequestHandler({
pathname: publicURL.pathname,
dispatcher,
logPrefix,
});
const server = options.server || http.createServer(requestHandler);
const listenOptions = options.listenOptions || {
port: parseInt(publicURL.port, 10),
host: publicURL.hostname,
};
server.listen(listenOptions, () => {
// eslint-disable-next-line no-console
console.log(`[${logPrefix}] Listening`);
});
build.onDispose(() => {
server?.close();
});
build.onStart(() => {
console.time(timerLabel);
dispatcher.dispatchEvent(
new CustomEvent("esbuild:start", {
detail: new Date().toISOString(),
}),
);
});
build.onEnd((buildResult) => {
console.timeEnd(timerLabel);
if (!buildResult.errors.length) {
dispatcher.dispatchEvent(
new CustomEvent("esbuild:end", {
detail: new Date().toISOString(),
}),
);
return;
}
console.warn(`Build ended with ${buildResult.errors.length} errors`);
dispatcher.dispatchEvent(
new CustomEvent("esbuild:error", {
detail: buildResult.errors.map((error) => ({
...error,
location: error.location
? {
...error.location,
file: path.resolve(relativeRoot, error.location.file),
}
: null,
})),
}),
);
});
},
};
}

View File

@ -0,0 +1,10 @@
{
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"resolveJsonModule": true,
"baseUrl": ".",
"checkJs": true,
"emitDeclarationOnly": true
}
}

View File

@ -9,11 +9,11 @@ It exists primarily to support late versions of Microsoft Office365 and Microsof
software that still uses the MSEdge-18 and IE-11 _Trident_ web engine for web-based log-ins. It has
limited support for the full language, supporting only the following stages:
- identification
- password
- redirect
- autosubmit
- authenticator validation (both code and WebAuthn)
- identification
- password
- redirect
- autosubmit
- authenticator validation (both code and WebAuthn)
### License

View File

@ -1,8 +1,13 @@
/**
* @file ESBuild script for building the authentik web UI.
*
* @import { BuildOptions } from "esbuild";
*/
import { liveReloadPlugin } from "@goauthentik/esbuild-plugin-live-reload/plugin";
import { execFileSync } from "child_process";
import { deepmerge } from "deepmerge-ts";
import esbuild from "esbuild";
import { polyfillNode } from "esbuild-plugin-polyfill-node";
import findFreePorts from "find-free-ports";
import { copyFileSync, mkdirSync, readFileSync, statSync } from "fs";
import { globSync } from "glob";
import * as path from "path";
@ -11,7 +16,6 @@ import process from "process";
import { fileURLToPath } from "url";
import { mdxPlugin } from "./esbuild/build-mdx-plugin.mjs";
import { buildObserverPlugin } from "./esbuild/build-observer-plugin.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
let authentikProjectRoot = path.join(__dirname, "..", "..");
@ -120,7 +124,7 @@ const BASE_ESBUILD_OPTIONS = {
splitting: true,
treeShaking: true,
external: ["*.woff", "*.woff2"],
tsconfig: "./tsconfig.json",
tsconfig: path.resolve(__dirname, "..", "tsconfig.build.json"),
loader: {
".css": "text",
},
@ -220,26 +224,17 @@ function doHelp() {
async function doWatch() {
console.log("Watching all entry points...");
const wathcherPorts = await findFreePorts(entryPoints.length);
const buildContexts = await Promise.all(
entryPoints.map((entryPoint, i) => {
const port = wathcherPorts[i];
const serverURL = new URL(`http://localhost:${port}/events`);
entryPoints.map((entryPoint) => {
return esbuild.context(
createEntryPointOptions(entryPoint, {
define: definitions,
plugins: [
buildObserverPlugin({
serverURL,
logPrefix: entryPoint[1],
liveReloadPlugin({
logPrefix: `Build Observer (${entryPoint[1]})`,
relativeRoot: path.join(__dirname, ".."),
}),
],
define: {
...definitions,
"process.env.WATCHER_URL": JSON.stringify(serverURL.toString()),
},
}),
);
}),

View File

@ -1,141 +0,0 @@
import * as http from "http";
import path from "path";
/**
* Serializes a custom event to a text stream.
* a
* @param {Event} event
* @returns {string}
*/
export function serializeCustomEventToStream(event) {
// @ts-expect-error - TS doesn't know about the detail property
const data = event.detail ?? {};
const eventContent = [`event: ${event.type}`, `data: ${JSON.stringify(data)}`];
return eventContent.join("\n") + "\n\n";
}
/**
* Options for the build observer plugin.
*
* @typedef {Object} BuildObserverOptions
*
* @property {URL} serverURL
* @property {string} logPrefix
* @property {string} relativeRoot
*/
/**
* Creates a plugin that listens for build events and sends them to a server-sent event stream.
*
* @param {BuildObserverOptions} options
* @returns {import('esbuild').Plugin}
*/
export function buildObserverPlugin({ serverURL, logPrefix, relativeRoot }) {
const timerLabel = `[${logPrefix}] Build`;
const endpoint = serverURL.pathname;
const dispatcher = new EventTarget();
const eventServer = http.createServer((req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
if (req.url !== endpoint) {
console.log(`🚫 Invalid request to ${req.url}`);
res.writeHead(404);
res.end();
return;
}
console.log("🔌 Client connected");
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
});
/**
* @param {Event} event
*/
const listener = (event) => {
const body = serializeCustomEventToStream(event);
res.write(body);
};
dispatcher.addEventListener("esbuild:start", listener);
dispatcher.addEventListener("esbuild:error", listener);
dispatcher.addEventListener("esbuild:end", listener);
req.on("close", () => {
console.log("🔌 Client disconnected");
clearInterval(keepAliveInterval);
dispatcher.removeEventListener("esbuild:start", listener);
dispatcher.removeEventListener("esbuild:error", listener);
dispatcher.removeEventListener("esbuild:end", listener);
});
const keepAliveInterval = setInterval(() => {
console.timeStamp("🏓 Keep-alive");
res.write("event: keep-alive\n\n");
res.write(serializeCustomEventToStream(new CustomEvent("esbuild:keep-alive")));
}, 15_000);
});
return {
name: "build-watcher",
setup: (build) => {
eventServer.listen(parseInt(serverURL.port, 10), serverURL.hostname);
build.onDispose(() => {
eventServer.close();
});
build.onStart(() => {
console.time(timerLabel);
dispatcher.dispatchEvent(
new CustomEvent("esbuild:start", {
detail: new Date().toISOString(),
}),
);
});
build.onEnd((buildResult) => {
console.timeEnd(timerLabel);
if (!buildResult.errors.length) {
dispatcher.dispatchEvent(
new CustomEvent("esbuild:end", {
detail: new Date().toISOString(),
}),
);
return;
}
console.warn(`Build ended with ${buildResult.errors.length} errors`);
dispatcher.dispatchEvent(
new CustomEvent("esbuild:error", {
detail: buildResult.errors.map((error) => ({
...error,
location: error.location
? {
...error.location,
file: path.resolve(relativeRoot, error.location.file),
}
: null,
})),
}),
);
});
},
};
}

View File

@ -22,6 +22,7 @@ export default [
"coverage/",
"src/locale-codes.ts",
"storybook-static/",
"scripts/esbuild",
"src/locales/",
],
},

View File

@ -4,6 +4,7 @@ import { ROUTES } from "@goauthentik/admin/Routes";
import {
EVENT_API_DRAWER_TOGGLE,
EVENT_NOTIFICATION_DRAWER_TOGGLE,
EVENT_SIDEBAR_TOGGLE,
} from "@goauthentik/common/constants";
import { configureSentry } from "@goauthentik/common/sentry";
import { me } from "@goauthentik/common/users";
@ -11,6 +12,8 @@ import { WebsocketClient } from "@goauthentik/common/ws";
import { AuthenticatedInterface } from "@goauthentik/elements/Interface";
import "@goauthentik/elements/ak-locale-context";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/EnterpriseStatusBanner";
import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/banner/VersionBanner";
import "@goauthentik/elements/messages/MessageContainer";
import "@goauthentik/elements/messages/MessageContainer";
@ -34,8 +37,14 @@ import { SessionUser, UiThemeEnum } from "@goauthentik/api";
import "./AdminSidebar";
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
@customElement("ak-interface-admin")
export class AdminInterface extends AuthenticatedInterface {
//#region Properties
@property({ type: Boolean })
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
@ -50,6 +59,17 @@ export class AdminInterface extends AuthenticatedInterface {
@query("ak-about-modal")
aboutModal?: AboutModal;
@state()
sidebarVisible = false;
#toggleSidebar = () => {
this.sidebarVisible = !this.sidebarVisible;
};
//#endregion
//#region Styles
static get styles(): CSSResult[] {
return [
PFBase,
@ -63,23 +83,30 @@ export class AdminInterface extends AuthenticatedInterface {
z-index: auto !important;
background-color: transparent;
}
.display-none {
display: none;
}
.pf-c-page {
background-color: var(--pf-c-page--BackgroundColor) !important;
}
/* Global page background colour */
:host([theme="dark"]) .pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);
:host([theme="dark"]) {
/* Global page background colour */
.pf-c-page {
--pf-c-page--BackgroundColor: var(--ak-dark-background);
}
}
ak-enterprise-status,
ak-version-banner {
ak-page-navbar {
grid-area: header;
}
ak-admin-sidebar {
grid-area: nav;
}
.pf-c-drawer__panel {
z-index: var(--pf-global--ZIndex--xl);
}
@ -87,6 +114,10 @@ export class AdminInterface extends AuthenticatedInterface {
];
}
//#endregion
//#region Lifecycle
constructor() {
super();
this.ws = new WebsocketClient();
@ -122,19 +153,23 @@ export class AdminInterface extends AuthenticatedInterface {
async connectedCallback(): Promise<void> {
super.connectedCallback();
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
}
new ESBuildObserver(process.env.WATCHER_URL);
}
disconnectedCallback(): void {
super.disconnectedCallback();
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.#toggleSidebar);
}
render(): TemplateResult {
const sidebarClasses = {
"pf-m-light": this.activeTheme === UiThemeEnum.Light,
"pf-m-expanded": !this.sidebarVisible,
"pf-m-collapsed": this.sidebarVisible,
};
const drawerOpen = this.notificationDrawerOpen || this.apiDrawerOpen;
const drawerClasses = {
"pf-m-expanded": drawerOpen,
"pf-m-collapsed": !drawerOpen,
@ -142,11 +177,16 @@ export class AdminInterface extends AuthenticatedInterface {
return html` <ak-locale-context>
<div class="pf-c-page">
<ak-enterprise-status interface="admin"></ak-enterprise-status>
<ak-version-banner></ak-version-banner>
<ak-page-navbar>
<ak-version-banner></ak-version-banner>
<ak-enterprise-status interface="admin"></ak-enterprise-status>
</ak-page-navbar>
<ak-admin-sidebar
class="pf-c-page__sidebar ${classMap(sidebarClasses)}"
class="pf-c-page__sidebar
${classMap(sidebarClasses)}"
></ak-admin-sidebar>
<div class="pf-c-page__drawer">
<div class="pf-c-drawer ${classMap(drawerClasses)}">
<div class="pf-c-drawer__main">

View File

@ -1,4 +1,3 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
import { me } from "@goauthentik/common/users";
import { AKElement } from "@goauthentik/elements/Base";
import {
@ -31,16 +30,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
me().then((user: SessionUser) => {
this.impersonation = user.original ? user.user.username : null;
});
this.toggleOpen = this.toggleOpen.bind(this);
this.checkWidth = this.checkWidth.bind(this);
}
// This has to be a bound method so the event listener can be removed on disconnection as
// needed.
toggleOpen() {
this.open = !this.open;
}
checkWidth() {
// This works just fine, but it assumes that the `--ak-sidebar--minimum-auto-width` is in
// REMs. If that changes, this code will have to be adjusted as well.
@ -52,7 +44,7 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
connectedCallback() {
super.connectedCallback();
window.addEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.addEventListener("resize", this.checkWidth);
// After connecting to the DOM, we can now perform this check to see if the sidebar should
// be open by default.
@ -63,7 +55,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
// connection, and removing them before disconnection.
disconnectedCallback() {
window.removeEventListener(EVENT_SIDEBAR_TOGGLE, this.toggleOpen);
window.removeEventListener("resize", this.checkWidth);
super.disconnectedCallback();
}
@ -71,8 +62,9 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
render() {
return html`
<ak-sidebar
class="pf-c-page__sidebar ${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this
.activeTheme === UiThemeEnum.Light
class="pf-c-page__sidebar
${this.open ? "pf-m-expanded" : "pf-m-collapsed"} ${this.activeTheme ===
UiThemeEnum.Light
? "pf-m-light"
: ""}"
>
@ -81,19 +73,6 @@ export class AkAdminSidebar extends WithCapabilitiesConfig(WithVersion(AKElement
`;
}
updated() {
// This is permissible as`:host.classList` is not one of the properties Lit uses as a
// scheduling trigger. This sort of shenanigans can trigger an loop, in that it will trigger
// a browser reflow, which may trigger some other styling the application is monitoring,
// triggering a re-render which triggers a browser reflow, ad infinitum. But we've been
// living with that since jQuery, and it's both well-known and fortunately rare.
// eslint-disable-next-line wc/no-self-class
this.classList.remove("pf-m-expanded", "pf-m-collapsed");
// eslint-disable-next-line wc/no-self-class
this.classList.add(this.open ? "pf-m-expanded" : "pf-m-collapsed");
}
renderSidebarItems(): TemplateResult {
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
// commonplace and singular enough to merit its own handler.

View File

@ -94,10 +94,13 @@ export class AdminOverviewPage extends AdminOverviewBase {
}
render(): TemplateResult {
const name = this.user?.user.name ?? this.user?.user.username;
const username = this.user?.user.name || this.user?.user.username;
return html`<ak-page-header description=${msg("General system status")} ?hasIcon=${false}>
<span slot="header"> ${msg(str`Welcome, ${name || ""}.`)} </span>
return html` <ak-page-header
header=${msg(str`Welcome, ${username || ""}.`)}
description=${msg("General system status")}
?hasIcon=${false}
>
</ak-page-header>
<section class="pf-c-page__main-section">
<div class="pf-l-grid pf-m-gutter">

View File

@ -83,13 +83,10 @@ export class AdminSettingsPage extends AKElement {
}
render() {
if (!this.settings) {
return nothing;
}
if (!this.settings) return nothing;
return html`
<ak-page-header icon="fa fa-cog" header="" description="">
<span slot="header"> ${msg("System settings")} </span>
</ak-page-header>
<ak-page-header icon="fa fa-cog" header="${msg("System settings")}"> </ak-page-header>
<section class="pf-c-page__main-section pf-m-no-padding-mobile pf-l-grid pf-m-gutter">
<div class="pf-c-card">
<div class="pf-c-card__body">

View File

@ -52,7 +52,6 @@ function renderRadiusOverview(rawProvider: OneOfProvider) {
}
function renderRACOverview(rawProvider: OneOfProvider) {
// @ts-expect-error TS6133
const _provider = rawProvider as RACProvider;
}

View File

@ -6,6 +6,7 @@ import "@goauthentik/admin/policies/expiry/ExpiryPolicyForm";
import "@goauthentik/admin/policies/expression/ExpressionPolicyForm";
import "@goauthentik/admin/policies/password/PasswordPolicyForm";
import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
import "@goauthentik/admin/policies/unique_password/UniquePasswordPolicyForm";
import "@goauthentik/admin/rbac/ObjectPermissionModal";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { PFColor } from "@goauthentik/elements/Label";

View File

@ -6,6 +6,7 @@ import "@goauthentik/admin/policies/expression/ExpressionPolicyForm";
import "@goauthentik/admin/policies/geoip/GeoIPPolicyForm";
import "@goauthentik/admin/policies/password/PasswordPolicyForm";
import "@goauthentik/admin/policies/reputation/ReputationPolicyForm";
import "@goauthentik/admin/policies/unique_password/UniquePasswordPolicyForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/forms/ProxyForm";

View File

@ -0,0 +1,103 @@
import { BasePolicyForm } from "@goauthentik/admin/policies/BasePolicyForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first } from "@goauthentik/common/utils";
import "@goauthentik/elements/forms/FormGroup";
import "@goauthentik/elements/forms/HorizontalFormElement";
import { msg } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { customElement } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { PoliciesApi, UniquePasswordPolicy } from "@goauthentik/api";
@customElement("ak-policy-password-uniqueness-form")
export class UniquePasswordPolicyForm extends BasePolicyForm<UniquePasswordPolicy> {
async loadInstance(pk: string): Promise<UniquePasswordPolicy> {
return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordRetrieve({
policyUuid: pk,
});
}
async send(data: UniquePasswordPolicy): Promise<UniquePasswordPolicy> {
if (this.instance) {
return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordUpdate({
policyUuid: this.instance.pk || "",
uniquePasswordPolicyRequest: data,
});
} else {
return new PoliciesApi(DEFAULT_CONFIG).policiesUniquePasswordCreate({
uniquePasswordPolicyRequest: data,
});
}
}
renderForm(): TemplateResult {
return html` <span>
${msg(
"Ensure that the user's new password is different from their previous passwords. The number of past passwords to check is configurable.",
)}
</span>
<ak-form-element-horizontal label=${msg("Name")} ?required=${true} name="name">
<input
type="text"
value="${ifDefined(this.instance?.name || "")}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="executionLogging">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.executionLogging, false)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Execution logging")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When this option is enabled, all executions of this policy will be logged. By default, only execution errors are logged.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Password field")}
?required=${true}
name="passwordField"
>
<input
type="text"
value="${ifDefined(this.instance?.passwordField || "password")}"
class="pf-c-form-control"
required
/>
<p class="pf-c-form__helper-text">
${msg("Field key to check, field keys defined in Prompt stages are available.")}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal
label=${msg("Number of previous passwords to check")}
?required=${true}
name="numHistoricalPasswords"
>
<input
type="number"
value="${first(this.instance?.numHistoricalPasswords, 1)}"
class="pf-c-form-control"
required
/>
</ak-form-element-horizontal>`;
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-policy-password-uniqueness-form": UniquePasswordPolicyForm;
}
}

View File

@ -15,9 +15,7 @@ export const bindModeOptions = [
{
label: msg("Direct binding"),
value: LDAPAPIAccessMode.Direct,
description: html`${msg(
"Always execute the configured bind flow to authenticate the user",
)}`,
description: html`${msg("Always execute the configured bind flow to authenticate the user")}`,
},
];
@ -33,9 +31,7 @@ export const searchModeOptions = [
{
label: msg("Direct querying"),
value: LDAPAPIAccessMode.Direct,
description: html`${msg(
"Always returns the latest data, but slower than cached querying",
)}`,
description: html`${msg("Always returns the latest data, but slower than cached querying")}`,
},
];

View File

@ -2,6 +2,7 @@ import "@goauthentik/admin/common/ak-flow-search/ak-flow-search";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import { first, groupBy } from "@goauthentik/common/utils";
import "@goauthentik/components/ak-switch-input.js";
import "@goauthentik/elements/ak-checkbox-group/ak-checkbox-group.js";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-dynamic-selected-provider.js";
import "@goauthentik/elements/forms/FormGroup";
@ -158,68 +159,38 @@ export class IdentificationStageForm extends BaseStageForm<IdentificationStage>
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="caseInsensitiveMatching">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.caseInsensitiveMatching, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label"
>${msg("Case insensitive matching")}</span
>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, user fields are matched regardless of their casing.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="pretendUserExists">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.pretendUserExists, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Pretend user exists")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When enabled, the stage will always accept the given user identifier and continue.",
)}
</p>
</ak-form-element-horizontal>
<ak-form-element-horizontal name="showMatchedUser">
<label class="pf-c-switch">
<input
class="pf-c-switch__input"
type="checkbox"
?checked=${first(this.instance?.showMatchedUser, true)}
/>
<span class="pf-c-switch__toggle">
<span class="pf-c-switch__toggle-icon">
<i class="fas fa-check" aria-hidden="true"></i>
</span>
</span>
<span class="pf-c-switch__label">${msg("Show matched user")}</span>
</label>
<p class="pf-c-form__helper-text">
${msg(
"When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown.",
)}
</p>
</ak-form-element-horizontal>
<ak-switch-input
name="caseInsensitiveMatching"
label=${msg("Case insensitive matching")}
?checked=${first(this.instance?.caseInsensitiveMatching, true)}
help=${msg(
"When enabled, user fields are matched regardless of their casing.",
)}
></ak-switch-input>
<ak-switch-input
name="pretendUserExists"
label=${msg("Pretend user exists")}
?checked=${first(this.instance?.pretendUserExists, true)}
help=${msg(
"When enabled, the stage will always accept the given user identifier and continue.",
)}
></ak-switch-input>
<ak-switch-input
name="showMatchedUser"
label=${msg("Show matched user")}
?checked=${first(this.instance?.showMatchedUser, true)}
help=${msg(
"When a valid username/email has been entered, and this option is enabled, the user's username and avatar will be shown. Otherwise, the text that the user entered will be shown.",
)}
></ak-switch-input>
<ak-switch-input
name="enableRememberMe"
label=${msg('Enable "Remember me on this device"')}
?checked=${this.instance?.enableRememberMe}
help=${msg(
"When enabled, the user can save their username in a cookie, allowing them to skip directly to entering their password.",
)}
></ak-switch-input>
</div>
</ak-form-group>
<ak-form-group>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 628 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -1,3 +1,4 @@
import type { Config as DOMPurifyConfig } from "dompurify";
import DOMPurify from "dompurify";
import { render } from "@lit-labs/ssr";
@ -6,9 +7,9 @@ import { TemplateResult, html } from "lit";
import { unsafeHTML } from "lit/directives/unsafe-html.js";
import { until } from "lit/directives/until.js";
export const DOM_PURIFY_STRICT: DOMPurify.Config = {
export const DOM_PURIFY_STRICT = {
ALLOWED_TAGS: ["#text"],
};
} as const satisfies DOMPurifyConfig;
export async function renderStatic(input: TemplateResult): Promise<string> {
return await collectResult(render(input));

View File

@ -17,21 +17,36 @@
/* Minimum width after which the sidebar becomes automatic */
--ak-sidebar--minimum-auto-width: 80rem;
/**
* The height of the navbar and branded sidebar.
* @todo This shouldn't be necessary. The sidebar can instead use a grid layout
* ensuring they share the same height.
*/
--ak-navbar--height: 7rem;
}
::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: transparent;
@supports selector(::-webkit-scrollbar) {
::-webkit-scrollbar {
width: 5px;
height: 5px;
background-color: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--ak-accent);
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-corner {
background-color: transparent;
}
}
::-webkit-scrollbar-thumb {
background-color: var(--ak-accent);
}
::-webkit-scrollbar-track {
background-color: transparent;
}
::-webkit-scrollbar-corner {
background-color: transparent;
@supports not selector(::-webkit-scrollbar) {
:root {
scrollbar-color: var(--ak-accent) transparent;
}
}
html {

View File

@ -67,6 +67,12 @@ export class NavigationButtons extends AKElement {
:host([theme="light"]) .pf-c-page__header-tools-group .pf-c-button {
color: var(--ak-global--Color--100) !important;
}
@media (max-width: 768px) {
.pf-c-avatar {
display: none;
}
}
`,
];
}
@ -156,9 +162,7 @@ export class NavigationButtons extends AKElement {
}
renderImpersonation() {
if (!this.me?.original) {
return nothing;
}
if (!this.me?.original) return nothing;
const onClick = async () => {
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
@ -175,6 +179,14 @@ export class NavigationButtons extends AKElement {
</div>`;
}
renderAvatar() {
return html`<img
class="pf-c-avatar"
src=${ifDefined(this.me?.user.avatar)}
alt="${msg("Avatar image")}"
/>`;
}
get userDisplayName() {
return match<UserDisplay | undefined, string | undefined>(this.uiConfig?.navbar.userDisplay)
.with(UserDisplay.username, () => this.me?.user.username)
@ -212,11 +224,7 @@ export class NavigationButtons extends AKElement {
</div>
</div>`
: nothing}
<img
class="pf-c-avatar"
src=${ifDefined(this.me?.user.avatar)}
alt="${msg("Avatar image")}"
/>
${this.renderAvatar()}
</div>`;
}
}

View File

@ -1,7 +1,7 @@
import { authentikVersionContext } from "@goauthentik/elements/AuthentikContexts";
import { consume } from "@lit/context";
import { Constructor } from "@lit/reactive-element/decorators/base";
import { Constructor } from "@lit/reactive-element/decorators/base.js";
import type { LitElement } from "lit";
import type { Version } from "@goauthentik/api";

View File

@ -10,15 +10,18 @@ import { me } from "@goauthentik/common/users";
import "@goauthentik/components/ak-nav-buttons";
import { AKElement } from "@goauthentik/elements/Base";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import { DefaultBrand } from "@goauthentik/elements/sidebar/SidebarBrand";
import { themeImage } from "@goauthentik/elements/utils/images";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import { msg } from "@lit/localize";
import { CSSResult, TemplateResult, css, html, nothing } from "lit";
import { CSSResult, LitElement, TemplateResult, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFDrawer from "@patternfly/patternfly/components/Drawer/drawer.css";
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
import PFNotificationBadge from "@patternfly/patternfly/components/NotificationBadge/notification-badge.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
@ -26,34 +29,52 @@ import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { SessionUser } from "@goauthentik/api";
@customElement("ak-page-header")
export class PageHeader extends WithBrandConfig(AKElement) {
@property()
icon?: string;
//#region Page Navbar
@property({ type: Boolean })
iconImage = false;
@property()
header = "";
@property()
export interface PageNavbarDetails {
header?: string;
description?: string;
icon?: string;
iconImage?: boolean;
}
@property({ type: Boolean })
hasIcon = true;
/**
* A global navbar component at the top of the page.
*
* Internally, this component listens for the `ak-page-header` event, which is
* dispatched by the `ak-page-header` component.
*/
@customElement("ak-page-navbar")
export class AKPageNavbar extends WithBrandConfig(AKElement) implements PageNavbarDetails {
//#region Static Properties
@state()
me?: SessionUser;
private static elementRef: AKPageNavbar | null = null;
@state()
uiConfig!: UIConfig;
static readonly setNavbarDetails = (detail: Partial<PageNavbarDetails>): void => {
const { elementRef } = AKPageNavbar;
if (!elementRef) {
console.debug(
`ak-page-header: Could not find ak-page-navbar, skipping event dispatch.`,
);
return;
}
const { header, description, icon, iconImage } = detail;
elementRef.header = header;
elementRef.description = description;
elementRef.icon = icon;
elementRef.iconImage = iconImage || false;
elementRef.hasIcon = !!icon;
};
static get styles(): CSSResult[] {
return [
PFBase,
PFButton,
PFPage,
PFDrawer,
PFNotificationBadge,
PFContent,
PFAvatar,
@ -63,55 +84,212 @@ export class PageHeader extends WithBrandConfig(AKElement) {
position: sticky;
top: 0;
z-index: var(--pf-global--ZIndex--lg);
--pf-c-page__header-tools--MarginRight: 0;
--ak-brand-logo-height: var(--pf-global--FontSize--4xl, 2.25rem);
--ak-brand-background-color: var(
--pf-c-page__sidebar--m-light--BackgroundColor
);
}
.bar {
:host([theme="dark"]) {
--ak-brand-background-color: var(--pf-c-page__sidebar--BackgroundColor);
--pf-c-page__sidebar--BackgroundColor: var(--ak-dark-background-light);
color: var(--ak-dark-foreground);
}
navbar {
border-bottom: var(--pf-global--BorderWidth--sm);
border-bottom-style: solid;
border-bottom-color: var(--pf-global--BorderColor--100);
background-color: var(--pf-c-page--BackgroundColor);
display: flex;
flex-direction: row;
min-height: 114px;
max-height: 114px;
background-color: var(--pf-c-page--BackgroundColor);
min-height: 6rem;
display: grid;
row-gap: var(--pf-global--spacer--sm);
column-gap: var(--pf-global--spacer--sm);
grid-template-columns: [brand] auto [toggle] auto [primary] 1fr [secondary] auto;
grid-template-rows: auto auto;
grid-template-areas:
"brand toggle primary secondary"
"brand toggle description secondary";
@media (max-width: 768px) {
row-gap: var(--pf-global--spacer--xs);
align-items: center;
grid-template-areas:
"toggle primary secondary"
"toggle description description";
justify-content: space-between;
width: 100%;
}
}
.pf-c-page__main-section.pf-m-light {
background-color: transparent;
.items {
display: block;
&.primary {
grid-column: primary;
grid-row: primary / description;
align-content: center;
padding-block: var(--pf-global--spacer--md);
@media (min-width: 426px) {
&.block-sibling {
padding-block-end: 0;
grid-row: primary;
}
}
@media (max-width: 768px) {
padding-block: var(--pf-global--spacer--sm);
}
.accent-icon {
height: 1em;
width: 1em;
@media (max-width: 768px) {
display: none;
}
}
}
&.page-description {
grid-area: description;
padding-block-end: var(--pf-global--spacer--md);
@media (max-width: 425px) {
display: none;
}
@media (min-width: 769px) {
text-wrap: balance;
}
}
&.secondary {
grid-area: secondary;
flex: 0 0 auto;
justify-self: end;
padding-block: var(--pf-global--spacer--sm);
padding-inline-end: var(--pf-global--spacer--sm);
@media (min-width: 769px) {
align-content: center;
padding-block: var(--pf-global--spacer--md);
padding-inline-end: var(--pf-global--spacer--xl);
}
}
}
.pf-c-page__main-section {
flex-grow: 1;
flex-shrink: 1;
.brand {
grid-area: brand;
background-color: var(--ak-brand-background-color);
height: 100%;
width: var(--pf-c-page__sidebar--Width);
align-items: center;
padding-inline: var(--pf-global--spacer--sm);
display: flex;
flex-direction: column;
justify-content: center;
&.pf-m-collapsed {
display: none;
}
@media (max-width: 1279px) {
display: none;
}
}
img.pf-icon {
max-height: 24px;
.sidebar-trigger {
grid-area: toggle;
height: 100%;
}
.logo {
flex: 0 0 auto;
height: var(--ak-brand-logo-height);
& img {
height: 100%;
}
}
.sidebar-trigger,
.notification-trigger {
font-size: 24px;
font-size: 1.5rem;
}
.notification-trigger.has-notifications {
color: var(--pf-global--active-color--100);
}
.page-title {
display: flex;
gap: var(--pf-global--spacer--xs);
}
h1 {
display: flex;
flex-direction: row;
align-items: center !important;
}
.pf-c-page__header-tools {
flex-shrink: 0;
}
.pf-c-page__header-tools-group {
height: 100%;
}
:host([theme="dark"]) .pf-c-page__header-tools {
color: var(--ak-dark-foreground) !important;
}
`,
];
}
//#endregion
//#region Properties
@property({ type: String })
icon?: string;
@property({ type: Boolean })
iconImage = false;
@property({ type: String })
header?: string;
@property({ type: String })
description?: string;
@property({ type: Boolean })
hasIcon = true;
@property({ type: Boolean })
open = true;
@state()
me?: SessionUser;
@state()
uiConfig!: UIConfig;
//#endregion
//#endregion
//#region Methods
#toggleSidebar() {
this.open = !this.open;
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}
//#region Constructor
constructor() {
super();
window.addEventListener(EVENT_WS_MESSAGE, () => {
@ -119,13 +297,23 @@ export class PageHeader extends WithBrandConfig(AKElement) {
});
}
async firstUpdated() {
connectedCallback(): void {
super.connectedCallback();
AKPageNavbar.elementRef = this;
}
disconnectedCallback(): void {
super.disconnectedCallback();
AKPageNavbar.elementRef = null;
}
public async firstUpdated() {
this.me = await me();
this.uiConfig = await uiConfig();
this.uiConfig.navbar.userDisplay = UserDisplay.none;
}
setTitle(header?: string) {
#setTitle(header?: string) {
const currentIf = currentInterface();
let title = this.brand?.brandingTitle || TITLE_DEFAULT;
if (currentIf === "admin") {
@ -141,65 +329,146 @@ export class PageHeader extends WithBrandConfig(AKElement) {
willUpdate() {
// Always update title, even if there's no header value set,
// as in that case we still need to return to the generic title
this.setTitle(this.header);
this.#setTitle(this.header);
}
//#region Render
renderIcon() {
if (this.icon) {
if (this.iconImage && !this.icon.startsWith("fa://")) {
return html`<img class="pf-icon" src="${this.icon}" alt="page icon" />`;
return html`<img class="accent-icon pf-icon" src="${this.icon}" alt="page icon" />`;
}
const icon = this.icon.replaceAll("fa://", "fa ");
return html`<i class=${icon}></i>`;
return html`<i class="accent-icon ${icon}"></i>`;
}
return nothing;
}
render(): TemplateResult {
return html`<div class="bar">
<button
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
>
<i class="fas fa-bars"></i>
</button>
<section class="pf-c-page__main-section pf-m-light">
<div class="pf-c-content">
<h1>
return html`<navbar aria-label="Main" class="navbar">
<aside class="brand ${this.open ? "" : "pf-m-collapsed"}">
<a href="#/">
<div class="logo">
<img
src=${themeImage(
this.brand?.brandingLogo ?? DefaultBrand.brandingLogo,
)}
alt="${msg("authentik Logo")}"
loading="lazy"
/>
</div>
</a>
</aside>
<button
class="sidebar-trigger pf-c-button pf-m-plain"
@click=${this.#toggleSidebar}
aria-label=${msg("Toggle sidebar")}
aria-expanded=${this.open ? "true" : "false"}
>
<i class="fas fa-bars"></i>
</button>
<section
class="items primary pf-c-content ${this.description ? "block-sibling" : ""}"
>
<h1 class="page-title">
${this.hasIcon
? html`<slot name="icon">${this.renderIcon()}</slot>&nbsp;`
? html`<slot name="icon">${this.renderIcon()}</slot>`
: nothing}
<slot name="header">${this.header}</slot>
${this.header}
</h1>
${this.description ? html`<p>${this.description}</p>` : html``}
</div>
</section>
<div class="pf-c-page__header-tools">
<div class="pf-c-page__header-tools-group">
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/user/"
slot="extra"
>
${msg("User interface")}
</a>
</ak-nav-buttons>
</div>
</div>
</div>`;
</section>
${this.description
? html`<section class="items page-description pf-c-content">
<p>${this.description}</p>
</section>`
: nothing}
<section class="items secondary">
<div class="pf-c-page__header-tools-group">
<ak-nav-buttons .uiConfig=${this.uiConfig} .me=${this.me}>
<a
class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
href="${globalAK().api.base}if/user/"
slot="extra"
>
${msg("User interface")}
</a>
</ak-nav-buttons>
</div>
</section>
</navbar>
<slot></slot>`;
}
//#endregion
}
//#endregion
//#region Page Header
/**
* A page header component, used to display the page title and description.
*
* Internally, this component dispatches the `ak-page-header` event, which is
* listened to by the `ak-page-navbar` component.
*
* @singleton
*/
@customElement("ak-page-header")
export class AKPageHeader extends LitElement implements PageNavbarDetails {
@property({ type: String })
header?: string;
@property({ type: String })
description?: string;
@property({ type: String })
icon?: string;
@property({ type: Boolean })
iconImage = false;
static get styles(): CSSResult[] {
return [
css`
:host {
display: none;
}
`,
];
}
connectedCallback(): void {
super.connectedCallback();
AKPageNavbar.setNavbarDetails({
header: this.header,
description: this.description,
icon: this.icon,
iconImage: this.iconImage,
});
}
updated(): void {
AKPageNavbar.setNavbarDetails({
header: this.header,
description: this.description,
icon: this.icon,
iconImage: this.iconImage,
});
}
}
//#endregion
declare global {
interface HTMLElementTagNameMap {
"ak-page-header": PageHeader;
"ak-page-header": AKPageHeader;
"ak-page-navbar": AKPageNavbar;
}
}

View File

@ -2,7 +2,7 @@ import { AkControlElement } from "@goauthentik/elements/AkControlElement";
import { CustomEmitterElement } from "@goauthentik/elements/utils/eventEmitter";
import { msg } from "@lit/localize";
import { PropertyValues } from "@lit/reactive-element/reactive-element";
import { PropertyValues } from "@lit/reactive-element";
import { TemplateResult, css, html } from "lit";
import { customElement, property, queryAll, state } from "lit/decorators.js";
import { map } from "lit/directives/map.js";

View File

@ -86,7 +86,7 @@ export class AggregateCard extends AKElement implements IAggregateCard {
font-size: var(--pf-global--FontSize--sm);
}
.pf-c-card__body {
overflow-x: scroll;
overflow-x: auto;
padding-left: calc(var(--pf-c-card--child--PaddingLeft) / 2);
padding-right: calc(var(--pf-c-card--child--PaddingRight) / 2);
}

View File

@ -35,10 +35,7 @@ export class Sidebar extends AKElement {
.pf-c-nav__section + .pf-c-nav__section {
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
}
.pf-c-nav__list .sidebar-brand {
max-height: 82px;
margin-bottom: -0.5rem;
}
nav {
display: flex;
flex-direction: column;
@ -70,7 +67,6 @@ export class Sidebar extends AKElement {
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
aria-label=${msg("Global")}
>
<ak-sidebar-brand></ak-sidebar-brand>
<ul class="pf-c-nav__list">
<slot></slot>
</ul>

View File

@ -1,4 +1,3 @@
import { EVENT_SIDEBAR_TOGGLE } from "@goauthentik/common/constants";
import { AKElement } from "@goauthentik/elements/Base";
import { WithBrandConfig } from "@goauthentik/elements/Interface/brandProvider";
import { themeImage } from "@goauthentik/elements/utils/images";
@ -42,22 +41,16 @@ export class SidebarBrand extends WithBrandConfig(AKElement) {
display: flex;
flex-direction: row;
align-items: center;
height: 114px;
min-height: 114px;
height: var(--ak-navbar-height);
border-bottom: var(--pf-global--BorderWidth--sm);
border-bottom-style: solid;
border-bottom-color: var(--pf-global--BorderColor--100);
}
.pf-c-brand img {
padding: 0 0.5rem;
height: 42px;
}
button.pf-c-button.sidebar-trigger {
background-color: transparent;
border-radius: 0px;
height: 100%;
color: var(--ak-dark-foreground);
}
`,
];
}
@ -70,32 +63,15 @@ export class SidebarBrand extends WithBrandConfig(AKElement) {
}
render(): TemplateResult {
return html` ${window.innerWidth <= MIN_WIDTH
? html`
<button
class="sidebar-trigger pf-c-button"
@click=${() => {
this.dispatchEvent(
new CustomEvent(EVENT_SIDEBAR_TOGGLE, {
bubbles: true,
composed: true,
}),
);
}}
>
<i class="fas fa-bars"></i>
</button>
`
: html``}
<a href="#/" class="pf-c-page__header-brand-link">
<div class="pf-c-brand ak-brand">
<img
src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)}
alt="${msg("authentik Logo")}"
loading="lazy"
/>
</div>
</a>`;
return html` <a href="#/" class="pf-c-page__header-brand-link">
<div class="pf-c-brand ak-brand">
<img
src=${themeImage(this.brand?.brandingLogo ?? DefaultBrand.brandingLogo)}
alt="${msg("authentik Logo")}"
loading="lazy"
/>
</div>
</a>`;
}
}

View File

@ -14,8 +14,6 @@ import "@goauthentik/flow/stages/password/PasswordStage";
// end of stage import
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
new ESBuildObserver(process.env.WATCHER_URL);
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}

View File

@ -5,6 +5,7 @@ import "@goauthentik/elements/forms/FormElement";
import "@goauthentik/flow/components/ak-flow-password-input.js";
import { BaseStage } from "@goauthentik/flow/stages/base";
import "@goauthentik/flow/stages/captcha/CaptchaStage";
import { AkRememberMeController } from "@goauthentik/flow/stages/identification/RememberMeController.js";
import { msg, str } from "@lit/localize";
import { CSSResult, PropertyValues, TemplateResult, css, html, nothing } from "lit";
@ -47,6 +48,8 @@ export class IdentificationStage extends BaseStage<
> {
form?: HTMLFormElement;
rememberMe: AkRememberMeController;
@state()
captchaToken = "";
@state()
@ -62,8 +65,9 @@ export class IdentificationStage extends BaseStage<
PFFormControl,
PFTitle,
PFButton,
/* login page's icons */
AkRememberMeController.styles,
css`
/* login page's icons */
.pf-c-login__main-footer-links-item button {
background-color: transparent;
border: 0;
@ -81,6 +85,11 @@ export class IdentificationStage extends BaseStage<
];
}
constructor() {
super();
this.rememberMe = new AkRememberMeController(this);
}
updated(changedProperties: PropertyValues<this>) {
if (changedProperties.has("challenge") && this.challenge !== undefined) {
this.autoRedirect();
@ -268,8 +277,10 @@ export class IdentificationStage extends BaseStage<
autocomplete="username"
spellcheck="false"
class="pf-c-form-control"
value=${this.rememberMe?.username ?? ""}
required
/>
${this.rememberMe.render()}
</ak-form-element>
${this.challenge.passwordFields
? html`

View File

@ -0,0 +1,156 @@
import { getCookie } from "@goauthentik/common/utils.js";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";
import { ReactiveController, ReactiveControllerHost } from "lit";
import type { IdentificationStage } from "./IdentificationStage.js";
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
export class AkRememberMeController implements ReactiveController {
static get styles() {
return css`
.remember-me-switch {
display: inline-block;
padding-top: 0.25rem;
}
`;
}
username?: string;
rememberingUsername: boolean = false;
constructor(private host: RememberMeHost) {
this.trackRememberMe = this.trackRememberMe.bind(this);
this.toggleRememberMe = this.toggleRememberMe.bind(this);
this.host.addController(this);
}
// Record a stable token that we can use between requests to track if we've
// been here before. If we can't, clear out the username.
hostConnected() {
try {
const sessionId = localStorage.getItem("authentik-remember-me-session");
if (!!this.localSession && sessionId === this.localSession) {
this.username = undefined;
localStorage?.removeItem("authentik-remember-me-user");
}
localStorage?.setItem("authentik-remember-me-session", this.localSession);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
get localSession() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
get usernameField() {
return this.host.renderRoot.querySelector(
'input[name="uidField"]',
) as HTMLInputElement | null;
}
get rememberMeToggle() {
return this.host.renderRoot.querySelector(
"#authentik-remember-me",
) as HTMLInputElement | null;
}
get isValidChallenge() {
return !(
this.host.challenge.responseErrors &&
this.host.challenge.responseErrors.non_field_errors &&
this.host.challenge.responseErrors.non_field_errors.find(
(cre) => cre.code === "invalid",
)
);
}
get submitButton() {
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
}
get isEnabled() {
return (
this.host.challenge !== undefined &&
this.host.challenge.enableRememberMe &&
typeof localStorage !== "undefined"
);
}
get canAutoSubmit() {
return (
!!this.host.challenge &&
!!this.username &&
!!this.usernameField?.value &&
!this.host.challenge.passwordFields &&
!this.host.challenge.passwordlessUrl
);
}
// Before the page is updated, try to extract the username from localstorage.
hostUpdate() {
if (!this.isEnabled) {
return;
}
try {
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
// After the page is updated, if everything is ready to go, do the autosubmit.
hostUpdated() {
if (this.isEnabled && this.canAutoSubmit) {
this.submitButton?.click();
}
}
trackRememberMe() {
if (!this.usernameField || this.usernameField.value === undefined) {
return;
}
this.username = this.usernameField.value;
localStorage?.setItem("authentik-remember-me-user", this.username);
}
// When active, save current details and record every keystroke to the username.
// When inactive, clear all fields and remove keystroke recorder.
toggleRememberMe() {
if (!this.rememberMeToggle || !this.rememberMeToggle.checked) {
localStorage?.removeItem("authentik-remember-me-user");
localStorage?.removeItem("authentik-remember-me-session");
this.username = undefined;
this.usernameField?.removeEventListener("keyup", this.trackRememberMe);
return;
}
if (!this.usernameField) {
return;
}
localStorage?.setItem("authentik-remember-me-user", this.usernameField.value);
localStorage?.setItem("authentik-remember-me-session", this.localSession);
this.usernameField.addEventListener("keyup", this.trackRememberMe);
}
render() {
return this.isEnabled
? html` <label class="pf-c-switch remember-me-switch">
<input
class="pf-c-switch__input"
id="authentik-remember-me"
@click=${this.toggleRememberMe}
type="checkbox"
?checked=${!!this.username}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`
: nothing;
}
}

View File

@ -43,6 +43,10 @@ import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
import { CurrentBrand, EventsApi, SessionUser } from "@goauthentik/api";
if (process.env.NODE_ENV === "development") {
await import("@goauthentik/esbuild-plugin-live-reload/client");
}
const customStyles = css`
.pf-c-page__main,
.pf-c-drawer__content,
@ -291,12 +295,6 @@ export class UserInterface extends AuthenticatedInterface {
window.addEventListener(EVENT_NOTIFICATION_DRAWER_TOGGLE, this.toggleNotificationDrawer);
window.addEventListener(EVENT_API_DRAWER_TOGGLE, this.toggleApiDrawer);
window.addEventListener(EVENT_WS_MESSAGE, this.fetchConfigurationDetails);
if (process.env.NODE_ENV === "development" && process.env.WATCHER_URL) {
const { ESBuildObserver } = await import("@goauthentik/common/client");
new ESBuildObserver(process.env.WATCHER_URL);
}
}
disconnectedCallback() {

View File

@ -1,71 +0,0 @@
{
"compilerOptions": {
"strict": true,
"baseUrl": ".",
"esModuleInterop": true,
"paths": {
"@goauthentik/docs/*": ["../website/docs/*"]
},
"types": [
"node",
"@wdio/mocha-framework",
"@wdio/types",
"expect-webdriverio",
"grecaptcha"
],
"jsx": "react-jsx",
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"experimentalDecorators": true,
"sourceMap": true,
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"lib": [
"ES5",
"ES2015",
"ES2016",
"ES2017",
"ES2018",
"ES2019",
"ES2020",
"ESNext",
"DOM",
"DOM.Iterable",
"WebWorker"
],
"noUnusedLocals": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
"strictNullChecks": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"useDefineForClassFields": false,
"useUnknownInCatchVariables": true,
"alwaysStrict": true,
"noImplicitAny": true,
"plugins": [
{
"name": "ts-lit-plugin",
"strict": true,
"rules": {
"no-unknown-tag-name": "off",
"no-missing-import": "off",
"no-incompatible-type-binding": "off",
"no-unknown-property": "off",
"no-unknown-attribute": "off"
}
},
{
"name": "@genesiscommunitysuccess/custom-elements-lsp",
"designSystemPrefix": "ak-",
"parser": {
"timeout": 2000
}
}
]
},
"exclude": ["src/**/*.test.ts", "./tests"]
}

6
web/tsconfig.build.json Normal file
View File

@ -0,0 +1,6 @@
// @file TSConfig used by the web package during build.
{
"extends": "./tsconfig.json",
"exclude": ["src/**/*.test.ts", "./tests"]
}

View File

@ -1,7 +1,19 @@
// @file TSConfig used by the web package during development.
{
"extends": "./tsconfig.base.json",
"extends": "@goauthentik/tsconfig",
"compilerOptions": {
"types": ["@wdio/types", "grecaptcha", "node", "@wdio/mocha-framework", "expect-webdriverio"],
"allowSyntheticDefaultImports": true,
"emitDeclarationOnly": true,
"experimentalDecorators": true,
// See https://lit.dev/docs/components/properties/
"useDefineForClassFields": false,
"target": "esnext",
"module": "esnext",
"moduleResolution": "bundler",
"baseUrl": ".",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
// TODO: We should enable this when when we're ready to enforce it.
"noUncheckedIndexedAccess": false,
"paths": {
"@goauthentik/admin/*": ["./src/admin/*"],
"@goauthentik/common/*": ["./src/common/*"],
@ -13,6 +25,41 @@
"@goauthentik/polyfill/*": ["./src/polyfill/*"],
"@goauthentik/standalone/*": ["./src/standalone/*"],
"@goauthentik/user/*": ["./src/user/*"]
}
},
"plugins": [
{
"name": "ts-lit-plugin",
"strict": true,
"rules": {
"no-unknown-tag-name": "off",
"no-missing-import": "off",
"no-incompatible-type-binding": "off",
"no-unknown-property": "off",
"no-unknown-attribute": "off"
}
},
{
"name": "@genesiscommunitysuccess/custom-elements-lsp",
"designSystemPrefix": "ak-",
"parser": {
"timeout": 2000
}
}
]
},
"exclude": [
// ---
"./out/**/*",
"./dist/**/*",
"src/**/*.test.ts",
"./tests",
// TODO: Remove after monorepo cleanup.
"src/**/*.comp.ts"
],
"references": [
{
"path": "./packages/esbuild-plugin-live-reload"
}
],
}

View File

@ -1,3 +1,4 @@
// @file TSConfig used during tests.
{
"compilerOptions": {
"baseUrl": ".",

26
web/types/global.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
/**
* @file Environment variables available via ESBuild.
*/
declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
NODE_ENV: "production" | "development";
/**
*
* @todo Determine where this is used and if it is needed,
* give it a better name.
* @deprecated
*/
CWD: string;
/**
* @todo Determine where this is used and if it is needed,
* give it a better name.
* @deprecated
*/
AK_API_BASE_PATH: string;
}
}
}
}

View File

@ -336,6 +336,7 @@ export const config: Options.Testrunner = {
{ error: _error, result: _result, duration: _duration, passed: _passed, retries: _retries },
) {
if (lemmeSee) {
// @ts-expect-error TODO
await browser.pause(500);
}
},

View File

@ -9134,6 +9134,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sdd04913b3b46cf30">
<source>Reputation cannot increase higher than this value. Zero or positive.</source>
</trans-unit>
<trans-unit id="s4d5cb134999b50df">
<source>HTTP Basic Auth</source>
</trans-unit>
<trans-unit id="s6927635d1c339cfc">
<source>Include the client ID and secret as request parameters</source>
</trans-unit>
<trans-unit id="s4fca384c634e1a92">
<source>Authorization code authentication method</source>
</trans-unit>
<trans-unit id="sdc02c276ed429008">
<source>How to perform authentication during an authorization_code token request flow</source>
</trans-unit>
<trans-unit id="s844baf19a6c4a9b4">
<source>Enable "Remember me on this device"</source>
</trans-unit>
<trans-unit id="sfa72bca733f40692">
<source>When enabled, the user can save their username in a cookie, allowing them to skip directly to entering their password.</source>
</trans-unit>
<trans-unit id="s1c336c2d6cef77b3">
<source>Remember me on this device</source>
</trans-unit>
<trans-unit id="s86cf007b861152ca">
<source>Ensure that the user's new password is different from their previous passwords. The number of past passwords to check is configurable.</source>
</trans-unit>
<trans-unit id="s79b3fcd40dd63921">
<source>Number of previous passwords to check</source>
</trans-unit>
</body>
</file>

View File

@ -7667,6 +7667,33 @@ Bindings to groups/users are checked against the user of the event.</source>
</trans-unit>
<trans-unit id="sdd04913b3b46cf30">
<source>Reputation cannot increase higher than this value. Zero or positive.</source>
</trans-unit>
<trans-unit id="s4d5cb134999b50df">
<source>HTTP Basic Auth</source>
</trans-unit>
<trans-unit id="s6927635d1c339cfc">
<source>Include the client ID and secret as request parameters</source>
</trans-unit>
<trans-unit id="s4fca384c634e1a92">
<source>Authorization code authentication method</source>
</trans-unit>
<trans-unit id="sdc02c276ed429008">
<source>How to perform authentication during an authorization_code token request flow</source>
</trans-unit>
<trans-unit id="s844baf19a6c4a9b4">
<source>Enable "Remember me on this device"</source>
</trans-unit>
<trans-unit id="sfa72bca733f40692">
<source>When enabled, the user can save their username in a cookie, allowing them to skip directly to entering their password.</source>
</trans-unit>
<trans-unit id="s1c336c2d6cef77b3">
<source>Remember me on this device</source>
</trans-unit>
<trans-unit id="s86cf007b861152ca">
<source>Ensure that the user's new password is different from their previous passwords. The number of past passwords to check is configurable.</source>
</trans-unit>
<trans-unit id="s79b3fcd40dd63921">
<source>Number of previous passwords to check</source>
</trans-unit>
</body>
</file>

Some files were not shown because too many files have changed in this diff Show More