Compare commits

..

71 Commits

Author SHA1 Message Date
0f2ddb1997 Merge branch 'main' into providers/oauth2/conformance-automated-tests 2025-06-25 00:44:30 +02:00
ff966d763b providers/oauth2: add conformance tools (#15228)
add conformance tools

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-25 00:43:15 +02:00
e00b68cafe web: bump API Client version (#15227)
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-06-24 22:13:34 +00:00
bf4e8dbedc core: include more authenticator details when possible (#15224)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 23:52:06 +02:00
d09b7757b6 root: fix some cases of invalid data triggering exceptions (#14799)
* sentry: separate checker if exception should be ignored

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* use should_ignore_exception in flow executor (fix ParseError)

fix system exceptions for unsupported media type, json decode error

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* improve API validation

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 22:42:59 +02:00
ca2f0439f6 website/docs: add links to Customer Portal (#15223)
* add direct link to Portal, remove screenshot

* fix link

* Update website/docs/enterprise/get-started.md

Signed-off-by: Jens L. <jens@beryju.org>

---------

Signed-off-by: Jens L. <jens@beryju.org>
Co-authored-by: Tana M Berry <tana@goauthentik.io>
Co-authored-by: Jens L. <jens@goauthentik.io>
2025-06-24 15:34:37 -05:00
27b7b0b0e7 web/elements/empty-state: Fix issues with EmptyState and Loading Overlay (#15152)
* web: Add InvalidationFlow to Radius Provider dialogues

## What

- Bugfix: adds the InvalidationFlow to the Radius Provider dialogues
  - Repairs: `{"invalidation_flow":["This field is required."]}` message, which was *not* propagated
    to the Notification.
- Nitpick: Pretties `?foo=${true}` expressions: `s/\?([^=]+)=\$\{true\}/\1/`

## Note

Yes, I know I'm going to have to do more magic when we harmonize the forms, and no, I didn't add the
Property Mappings to the wizard, and yes, I know I'm going to have pain with the *new* version of
the wizard. But this is a serious bug; you can't make Radius servers with *either* of the current
dialogues at the moment.

* This (temporary) change is needed to prevent the unit tests from failing.

\# What

\# Why

\# How

\# Designs

\# Test Steps

\# Other Notes

* Revert "This (temporary) change is needed to prevent the unit tests from failing."

This reverts commit dddde09be5.

* web/element: empty-state should not have a default label when used as a loading indicator

* .

* web/bug/empty-state: Fix issues with EmptyState and Loading Overlay

- Add a method, `hasSlotted()`, to the Base component.
- Revise `EmptyState` to use `hasSlotted()`.
- Revise `LoadingOverlay` to use `hasSlotted()`.
- Provide (hopefully complete) Storybook stories for both
- Revise use of these components throughout the codebase.

The essential problem here was mine: I misunderstood what the Patternfly `SlotController` does (and,
yikes, how it does it). Slots aren't magical; they're just named containers, in which lightDOM
elements that appear between the opening and closing tags of a web component can be strategically
placed, shown or hidden, and to some extent styled, within the rendered and visible results of the
shadowDOM component that will fill the browser's RECT allocated to that component.

SlotController tries to associate the template with slots by creating the shadowDOM *first*, then
working backwards to see if there are lightDOM components to put into those slots.  That's not what
we want; we want to see if there are lightDOM components that meet our slot requirements and, if
there are, create corresponding slots for them.

That's what `hasSlotted()` does: it returns true or false to the question, "Is there currently in
the lightDOM for this component an entry requesting a known slot name?"  Components are free to do
what they want with that knowledge.

`<ak-empty-state>` now has several modes, all well-documented in the Storybook story.  But in short,
the Title is now a default slot; any HTML Element not sent to one of the named slots are put into
the Title.  The two named slots are `body` and `primary`.  The header is bold and large; body is
just text, and primary is boxed to indicate that one or more buttons should be placed there, to
allow interaction.

The extra modes are controlled by boolean attributes:

- `loading`: Shows the loading spinner, overriding the `icon` attribute
- `default`: Shows the loading spinner *and* the word "Loading" (i18n-aware).

The priority for all of these is:

- Has something in the default (header) slot: That text will be shown. Overrides both
- `default` overrides `loading`
- `loading`

q`<ak-loading-overlay>` is a specialized variant of `<ak-empty-state>` over what will become
`<ak-backdrop>`, but for now is just internal.  It allows only for the heading and primary slots,
forwarding them `<ak-empty-state>`.  Since this is literally the *Loading*Overlay, showing the
`loading` spinner is the default; to prevent it, pass `no-spinner` as an attribute.

* Grammatical error.

* Prettier had opinions that shouldn't have been aired in public.

* Prettier had opinions that shouldn't have been aired in public.

* Collapsing unnecessary boolean nest.

* fix typo

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* always render icon

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* missing default in flow exec

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated: fix loading interface

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* rename default attr

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix jsdoc

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 21:33:07 +02:00
88073305eb *: remove redundant user_logged_out signals (#15221) 2025-06-24 18:29:05 +02:00
37657e47a3 web: bump the eslint group across 2 directories with 3 updates (#15215)
Bumps the eslint group with 1 update in the /packages/eslint-config directory: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).
Bumps the eslint group with 1 update in the /web directory: [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

---
updated-dependencies:
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:51:10 +02:00
0d649a70c9 core: bump github.com/getsentry/sentry-go from 0.33.0 to 0.34.0 (#15213)
Bumps [github.com/getsentry/sentry-go](https://github.com/getsentry/sentry-go) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/getsentry/sentry-go/releases)
- [Changelog](https://github.com/getsentry/sentry-go/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-go/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: github.com/getsentry/sentry-go
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:51:00 +02:00
7ec3055018 web: bump @sentry/browser from 9.30.0 to 9.31.0 in /web in the sentry group across 1 directory (#15214)
web: bump @sentry/browser in /web in the sentry group across 1 directory

Bumps the sentry group with 1 update in the /web directory: [@sentry/browser](https://github.com/getsentry/sentry-javascript).


Updates `@sentry/browser` from 9.30.0 to 9.31.0
- [Release notes](https://github.com/getsentry/sentry-javascript/releases)
- [Changelog](https://github.com/getsentry/sentry-javascript/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-javascript/compare/9.30.0...9.31.0)

---
updated-dependencies:
- dependency-name: "@sentry/browser"
  dependency-version: 9.31.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: sentry
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:50:48 +02:00
50ffce87c4 website: bump the eslint group in /website with 3 updates (#15216)
Bumps the eslint group in /website with 3 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser) and [typescript-eslint](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/typescript-eslint).


Updates `@typescript-eslint/eslint-plugin` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/parser)

Updates `typescript-eslint` from 8.34.1 to 8.35.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/typescript-eslint/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.35.0/packages/typescript-eslint)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: typescript-eslint
  dependency-version: 8.35.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-24 16:50:33 +02:00
a4393ac9f0 core: bump astral-sh/uv from 0.7.13 to 0.7.14 (#15217)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.7.13 to 0.7.14.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.7.13...0.7.14)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-version: 0.7.14
  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-06-24 16:50:14 +02:00
e235c854a5 website: bump the build group in /website with 6 updates (#15218)
Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.5` | `1.12.6` |


Updates `@swc/core-darwin-arm64` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

Updates `@swc/core-linux-arm64-gnu` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

Updates `@swc/core-linux-x64-gnu` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

Updates `@swc/html-darwin-arm64` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

Updates `@swc/html-linux-arm64-gnu` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

Updates `@swc/html-linux-x64-gnu` from 1.12.5 to 1.12.6
- [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.12.5...v1.12.6)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.12.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.12.6
  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-06-24 16:50:04 +02:00
910b69f89d translate: Updates for file web/xliff/en.xlf in it (#15219)
Translate web/xliff/en.xlf in it

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

Co-authored-by: transifex-integration[bot] <43880903+transifex-integration[bot]@users.noreply.github.com>
2025-06-24 16:49:41 +02:00
f89cc98014 sources/scim: add group patch support (#15212)
* set auth_via

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* allow requests with json content type

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* fix group schema

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* start improving error handling

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add scim group patch for members

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated #1: fix debug check on startup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* unrelated fix #2: fix path for user page

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add group view tests

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add more user tests too

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 16:48:48 +02:00
91a675a5a1 web/admin: point create application to wizard (#15211)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-24 01:36:20 +02:00
71be3acd1a web/elements: typing error when variables are not converted to string (#15169)
fix: typing error when variables are not converted to string

Co-authored-by: leandro.saraiva <leandro.saraiva@adonite.com>
2025-06-23 23:40:36 +02:00
0b6ab171ce website: bump prettier from 3.5.3 to 3.6.0 in /website (#15199)
* website: bump prettier from 3.5.3 to 3.6.0 in /website

Bumps [prettier](https://github.com/prettier/prettier) from 3.5.3 to 3.6.0.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.5.3...3.6.0)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>

* format

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 21:29:14 +02:00
0c73572b0c ci: update daily tested versions (#15196)
* ci: update daily tested versions

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

* Update ci-main-daily.yml

Co-authored-by: Jens L. <jens@beryju.org>
Signed-off-by: Dominic R <dominic@sdko.org>

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Co-authored-by: Jens L. <jens@beryju.org>
2025-06-23 21:16:47 +02:00
03d0899a76 website/docs: sys mgmt: clean up certificates and add steps to download saml certs (#14497)
* website/docs: sys mgmt: clean up certificates and add steps to download
saml certs

* fix broken link

* Apply suggestions from code review

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

* Dewi review comments

* Update website/docs/sys-mgmt/certificates.md

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

* Apply suggestions from code review

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* Update website/docs/sys-mgmt/certificates.md

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

* lint fix following header rm

---------

Signed-off-by: Dominic R <dominic@sdko.org>
Signed-off-by: Tana M Berry <tanamarieberry@yahoo.com>
Co-authored-by: Dewi Roberts <dewi@goauthentik.io>
Co-authored-by: Tana M Berry <tanamarieberry@yahoo.com>
2025-06-23 18:56:50 +00:00
91f79c97d8 ci: fix CodeQL failing on cherry-pick PRs (#15205) 2025-06-23 16:55:08 +02:00
19324c61a3 root: add system check for database encoding (#15186)
* root: add system check for database encoding

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* oops

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 15:17:48 +02:00
d297733614 enterprise/stages/source: update outer flow with context from inner flow (#15177)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 15:13:27 +02:00
f201f41a1b website: bump the build group in /website with 6 updates (#15200)
Bumps the build group in /website with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [@swc/core-darwin-arm64](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/core-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/core-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-darwin-arm64](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-linux-arm64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |
| [@swc/html-linux-x64-gnu](https://github.com/swc-project/swc) | `1.12.4` | `1.12.5` |


Updates `@swc/core-darwin-arm64` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

Updates `@swc/core-linux-arm64-gnu` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

Updates `@swc/core-linux-x64-gnu` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

Updates `@swc/html-darwin-arm64` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

Updates `@swc/html-linux-arm64-gnu` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

Updates `@swc/html-linux-x64-gnu` from 1.12.4 to 1.12.5
- [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.12.4...v1.12.5)

---
updated-dependencies:
- dependency-name: "@swc/core-darwin-arm64"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-arm64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/core-linux-x64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-darwin-arm64"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-arm64-gnu"
  dependency-version: 1.12.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: build
- dependency-name: "@swc/html-linux-x64-gnu"
  dependency-version: 1.12.5
  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-06-23 13:54:27 +02:00
f58f679171 web: bump the eslint group across 2 directories with 1 update (#15198)
Bumps the eslint group with 1 update in the /packages/eslint-config directory: [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import).
Bumps the eslint group with 1 update in the /web directory: [eslint-plugin-import](https://github.com/import-js/eslint-plugin-import).


Updates `eslint-plugin-import` from 2.31.0 to 2.32.0
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0)

Updates `eslint-plugin-import` from 2.31.0 to 2.32.0
- [Release notes](https://github.com/import-js/eslint-plugin-import/releases)
- [Changelog](https://github.com/import-js/eslint-plugin-import/blob/main/CHANGELOG.md)
- [Commits](https://github.com/import-js/eslint-plugin-import/compare/v2.31.0...v2.32.0)

---
updated-dependencies:
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: eslint
- dependency-name: eslint-plugin-import
  dependency-version: 2.32.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: eslint
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:54:16 +02:00
1bea5e38a1 core: bump goauthentik.io/api/v3 from 3.2025062.3 to 3.2025062.4 (#15197)
Bumps [goauthentik.io/api/v3](https://github.com/goauthentik/client-go) from 3.2025062.3 to 3.2025062.4.
- [Release notes](https://github.com/goauthentik/client-go/releases)
- [Changelog](https://github.com/goauthentik/client-go/blob/main/model_version_history.go)
- [Commits](https://github.com/goauthentik/client-go/compare/v3.2025062.3...v3.2025062.4)

---
updated-dependencies:
- dependency-name: goauthentik.io/api/v3
  dependency-version: 3.2025062.4
  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-06-23 13:54:08 +02:00
4d1c63e7fa core: bump axllent/mailpit from v1.26.1 to v1.26.2 in /tests/e2e (#15201)
Bumps axllent/mailpit from v1.26.1 to v1.26.2.

---
updated-dependencies:
- dependency-name: axllent/mailpit
  dependency-version: v1.26.2
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-06-23 13:52:03 +02:00
e341032bf9 website/integrations: bitwarden: fix certificate download section (#15184)
* website/integrations: bitwarden: fix certificate download section

Fix whatever happened here

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

* keep it as it was before I copy-pasted from stripe

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

---------

Signed-off-by: Dominic R <dominic@sdko.org>
2025-06-23 10:28:38 +01:00
e3ff242956 core, web: update translations (#15194)
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-06-23 02:45:30 +02:00
c6756bf809 web: bump API Client version (#15193)
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-06-23 02:45:03 +02:00
cf9b7eaa64 web/user: fix infinite loop when no user settings flow is set (#15188)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 01:49:43 +02:00
53d8f9bd8c stages/authenticator_webauthn: add option to configure max attempts (#15041)
* house keeping - migrate to session part 1

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* cleanup v2

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add max_attempts

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* teeny tiny cleanup

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* add ui

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-23 01:49:07 +02:00
b6ab7d9db2 fix duplicate artifact
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-04 16:39:04 +02:00
691c60f4be fix waiting for compete on error
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 21:01:01 +02:00
56630b0b4c fix offline_access tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:24:12 +02:00
9503c8d2e2 split up
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
cecaea28c8 only run basic+implicit for now, fix other tests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
e4e2067c94 fix missing offline_access, use scoped issuer
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
b43b9b8682 use actual timestamp
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
0343999e52 add custom profile scope that includes standard scopes to return number of warnings
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
5c856d6d06 add implicit test
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:46 +02:00
3c92b0e137 another actual fix; don't return access_token when using response_type id_token
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:45 +02:00
1a39f1902b fix basic suite?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:45 +02:00
e3f5058cbe fix some
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:45 +02:00
ad872379e3 check authorize request param earlier
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:45 +02:00
005c2440de Revert "attempt to switch to generated"
This reverts commit 6f851e021d305a93be9cfbb4a9b6783231b6d7cf.

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:45 +02:00
49f306cd94 Revert "make it work generated?"
This reverts commit 4e29d2c5737ee9aaad6c0f4701caf7e0fb110e15.
2025-06-01 20:00:45 +02:00
9c394a03e6 Revert "fix teardown"
This reverts commit cb96b0cb988acedec1fe72ec437b68e2c38ed6b1.
2025-06-01 20:00:45 +02:00
f1755652eb Revert "re-add implicit and fix?"
This reverts commit 6a4d15fc22cf4b27ffa428be9ecc9a0e778961c6.
2025-06-01 20:00:45 +02:00
873dd95641 re-add implicit and fix?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
0ca4775600 fix teardown
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
7e4c9bed3d make it work generated?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
573c7a09e6 attempt to switch to generated
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
bf1d80d265 test
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
107e9b789d upload cert exports
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:44 +02:00
3e2c1a9501 fix network and cookie deletion
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
cefb99ec7d dont hardcode IP
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
35391dcd3b maybe run in CI?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
e76071b79f properly install subtests
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
0184f8cbba refactor conformance helper to requests (thanks chatgpt)
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
e42c6c6fea cleanup, restart loop when we finished an operation
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:43 +02:00
ef779294bf sleep a bit before checking again
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:42 +02:00
ad159d9a3f remove debugging
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:42 +02:00
125cfe938d smaller screenshots?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:42 +02:00
ad376de981 make tests mostly work
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:42 +02:00
9f1670f965 revert more debugging stuff
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:42 +02:00
8dd4709981 make it kinda work
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:41 +02:00
b25e68486a remove some previous debugging things
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:41 +02:00
80186fc625 somewhat working?
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:41 +02:00
ce19332450 some progress
Signed-off-by: Jens Langhammer <jens@goauthentik.io>
2025-06-01 20:00:41 +02:00
237 changed files with 3123 additions and 6698 deletions

View File

@ -15,8 +15,8 @@ jobs:
matrix:
version:
- docs
- version-2025-4
- version-2025-2
- version-2024-12
steps:
- uses: actions/checkout@v4
- run: |

View File

@ -226,6 +226,61 @@ jobs:
flags: e2e
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
test-conformance:
name: test-conformance (${{ matrix.job.name }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
job:
- name: basic
glob: tests/openid_conformance/test_basic.py
- name: implicit
glob: tests/openid_conformance/test_implicit.py
steps:
- uses: actions/checkout@v4
- name: Setup authentik env
uses: ./.github/actions/setup
- name: Setup e2e env (chrome, etc)
run: |
docker compose -f tests/e2e/docker-compose.yml up -d --quiet-pull
- name: Setup conformance suite
run: |
docker compose -f tests/openid_conformance/compose.yml up -d --quiet-pull
- id: cache-web
uses: actions/cache@v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true'
working-directory: web
run: |
npm ci
make -C .. gen-client-ts
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
uv run coverage xml
- if: ${{ always() }}
uses: codecov/codecov-action@v5
with:
flags: conformance
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
flags: conformance
file: unittest.xml
token: ${{ secrets.CODECOV_TOKEN }}
- if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
name: conformance-certification-${{ matrix.job.glob }}
path: tests/openid_conformance/exports/
ci-core-mark:
if: always()
needs:
@ -235,6 +290,7 @@ jobs:
- test-unittest
- test-integration
- test-e2e
- test-conformance
runs-on: ubuntu-latest
steps:
- uses: re-actors/alls-green@release/v1

View File

@ -2,7 +2,7 @@ name: "CodeQL"
on:
push:
branches: [main, "*", next, version*]
branches: [main, next, version*]
pull_request:
branches: [main]
schedule:

1
.gitignore vendored
View File

@ -217,3 +217,4 @@ source_docs/
### Docker ###
docker-compose.override.yml
tests/openid_conformance/exports/*.zip

View File

@ -75,7 +75,7 @@ RUN --mount=type=secret,id=GEOIPUPDATE_ACCOUNT_ID \
/bin/sh -c "GEOIPUPDATE_LICENSE_KEY_FILE=/run/secrets/GEOIPUPDATE_LICENSE_KEY /usr/bin/entry.sh || echo 'Failed to get GeoIP database, disabling'; exit 0"
# Stage 4: Download uv
FROM ghcr.io/astral-sh/uv:0.7.13 AS uv
FROM ghcr.io/astral-sh/uv:0.7.14 AS uv
# Stage 5: Base python image
FROM ghcr.io/goauthentik/fips-python:3.13.5-slim-bookworm-fips AS python-base

View File

@ -35,6 +35,6 @@ def blueprint_tester(file_name: Path) -> Callable:
for blueprint_file in Path("blueprints/").glob("**/*.yaml"):
if "local" in str(blueprint_file):
if "local" in str(blueprint_file) or "testing" in str(blueprint_file):
continue
setattr(TestPackaged, f"test_blueprint_{blueprint_file}", blueprint_tester(blueprint_file))

View File

@ -1,8 +1,6 @@
"""Authenticator Devices API Views"""
from django.utils.translation import gettext_lazy as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema
from drf_spectacular.utils import extend_schema
from guardian.shortcuts import get_objects_for_user
from rest_framework.fields import (
BooleanField,
@ -15,6 +13,7 @@ from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.viewsets import ViewSet
from authentik.core.api.users import ParamUserSerializer
from authentik.core.api.utils import MetaNameSerializer
from authentik.enterprise.stages.authenticator_endpoint_gdtc.models import EndpointDevice
from authentik.stages.authenticator import device_classes, devices_for_user
@ -23,7 +22,7 @@ from authentik.stages.authenticator_webauthn.models import WebAuthnDevice
class DeviceSerializer(MetaNameSerializer):
"""Serializer for Duo authenticator devices"""
"""Serializer for authenticator devices"""
pk = CharField()
name = CharField()
@ -33,22 +32,27 @@ class DeviceSerializer(MetaNameSerializer):
last_updated = DateTimeField(read_only=True)
last_used = DateTimeField(read_only=True, allow_null=True)
extra_description = SerializerMethodField()
external_id = SerializerMethodField()
def get_type(self, instance: Device) -> str:
"""Get type of device"""
return instance._meta.label
def get_extra_description(self, instance: Device) -> str:
def get_extra_description(self, instance: Device) -> str | None:
"""Get extra description"""
if isinstance(instance, WebAuthnDevice):
return (
instance.device_type.description
if instance.device_type
else _("Extra description not available")
)
return instance.device_type.description if instance.device_type else None
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return ""
return None
def get_external_id(self, instance: Device) -> str | None:
"""Get external Device ID"""
if isinstance(instance, WebAuthnDevice):
return instance.device_type.aaguid if instance.device_type else None
if isinstance(instance, EndpointDevice):
return instance.data.get("deviceSignals", {}).get("deviceModel")
return None
class DeviceViewSet(ViewSet):
@ -57,7 +61,6 @@ class DeviceViewSet(ViewSet):
serializer_class = DeviceSerializer
permission_classes = [IsAuthenticated]
@extend_schema(responses={200: DeviceSerializer(many=True)})
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
devices = devices_for_user(request.user)
@ -79,18 +82,11 @@ class AdminDeviceViewSet(ViewSet):
yield from device_set
@extend_schema(
parameters=[
OpenApiParameter(
name="user",
location=OpenApiParameter.QUERY,
type=OpenApiTypes.INT,
)
],
parameters=[ParamUserSerializer],
responses={200: DeviceSerializer(many=True)},
)
def list(self, request: Request) -> Response:
"""Get all devices for current user"""
kwargs = {}
if "user" in request.query_params:
kwargs = {"user": request.query_params["user"]}
return Response(DeviceSerializer(self.get_devices(**kwargs), many=True).data)
args = ParamUserSerializer(data=request.query_params)
args.is_valid(raise_exception=True)
return Response(DeviceSerializer(self.get_devices(**args.validated_data), many=True).data)

View File

@ -90,6 +90,12 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
user = PrimaryKeyRelatedField(queryset=User.objects.all().exclude_anonymous(), required=False)
class UserGroupSerializer(ModelSerializer):
"""Simplified Group Serializer for user's groups"""

View File

@ -13,7 +13,6 @@ class Command(TenantCommand):
parser.add_argument("usernames", nargs="*", type=str)
def handle_per_tenant(self, **options):
print(options)
new_type = UserTypes(options["type"])
qs = (
User.objects.exclude_anonymous()

View File

@ -1,10 +1,8 @@
from hashlib import sha256
from django.contrib.auth.signals import user_logged_out
from django.db.models import Model
from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from django.http.request import HttpRequest
from guardian.shortcuts import assign_perm
from authentik.core.models import (
@ -62,31 +60,6 @@ def ssf_providers_post_save(sender: type[Model], instance: SSFProvider, created:
instance.save()
@receiver(user_logged_out)
def ssf_user_logged_out_session_revoked(sender, request: HttpRequest, user: User, **_):
"""Session revoked trigger (user logged out)"""
if not request.session or not request.session.session_key or not user:
return
send_ssf_event(
EventTypes.CAEP_SESSION_REVOKED,
{
"initiating_entity": "user",
},
sub_id={
"format": "complex",
"session": {
"format": "opaque",
"id": sha256(request.session.session_key.encode("ascii")).hexdigest(),
},
"user": {
"format": "email",
"email": user.email,
},
},
request=request,
)
@receiver(pre_delete, sender=AuthenticatedSession)
def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSession, **_):
"""Session revoked trigger (users' session has been deleted)

View File

@ -97,6 +97,7 @@ class SourceStageFinal(StageView):
token: FlowToken = self.request.session.get(SESSION_KEY_OVERRIDE_FLOW_TOKEN)
self.logger.info("Replacing source flow with overridden flow", flow=token.flow.slug)
plan = token.plan
plan.context.update(self.executor.plan.context)
plan.context[PLAN_CONTEXT_IS_RESTORED] = token
response = plan.to_redirect(self.request, token.flow)
token.delete()

View File

@ -90,14 +90,17 @@ class TestSourceStage(FlowTestCase):
plan: FlowPlan = session[SESSION_KEY_PLAN]
plan.insert_stage(in_memory_stage(SourceStageFinal), index=0)
plan.context[PLAN_CONTEXT_IS_RESTORED] = flow_token
plan.context["foo"] = "bar"
session[SESSION_KEY_PLAN] = plan
session.save()
# Pretend we've just returned from the source
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
with self.assertFlowFinishes() as ff:
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug}), follow=True
)
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(
response, reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertEqual(ff().context["foo"], "bar")

View File

@ -19,7 +19,7 @@ from authentik.blueprints.v1.importer import excluded_models
from authentik.core.models import Group, User
from authentik.events.models import Event, EventAction, Notification
from authentik.events.utils import model_to_dict
from authentik.lib.sentry import before_send
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.errors import exception_to_string
from authentik.stages.authenticator_static.models import StaticToken
@ -173,7 +173,7 @@ class AuditMiddleware:
message=exception_to_string(exception),
)
thread.run()
elif before_send({}, {"exc_info": (None, exception, None)}) is not None:
elif not should_ignore_exception(exception):
thread = EventNewThread(
EventAction.SYSTEM_EXCEPTION,
request,

View File

@ -2,7 +2,9 @@
from django.test import TestCase
from authentik.events.context_processors.base import get_context_processors
from authentik.events.context_processors.geoip import GeoIPContextProcessor
from authentik.events.models import Event, EventAction
class TestGeoIP(TestCase):
@ -13,8 +15,7 @@ class TestGeoIP(TestCase):
def test_simple(self):
"""Test simple city wrapper"""
# IPs from
# https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
self.assertEqual(
self.reader.city_dict("2.125.160.216"),
{
@ -25,3 +26,12 @@ class TestGeoIP(TestCase):
"long": -1.25,
},
)
def test_special_chars(self):
"""Test city name with special characters"""
# IPs from https://github.com/maxmind/MaxMind-DB/blob/main/source-data/GeoLite2-City-Test.json
event = Event.new(EventAction.LOGIN)
event.client_ip = "89.160.20.112"
for processor in get_context_processors():
processor.enrich_event(event)
event.save()

View File

@ -4,8 +4,10 @@ from unittest.mock import MagicMock, PropertyMock, patch
from urllib.parse import urlencode
from django.http import HttpRequest, HttpResponse
from django.test import override_settings
from django.test.client import RequestFactory
from django.urls import reverse
from rest_framework.exceptions import ParseError
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_flow, create_test_user
@ -648,3 +650,25 @@ class TestFlowExecutor(FlowTestCase):
self.assertStageResponse(response, flow, component="ak-stage-identification")
response = self.client.post(exec_url, {"uid_field": user_other.username}, follow=True)
self.assertStageResponse(response, flow, component="ak-stage-access-denied")
@patch(
"authentik.flows.views.executor.to_stage_response",
TO_STAGE_RESPONSE_MOCK,
)
def test_invalid_json(self):
"""Test invalid JSON body"""
flow = create_test_flow()
FlowStageBinding.objects.create(
target=flow, stage=DummyStage.objects.create(name=generate_id()), order=0
)
url = reverse("authentik_api:flow-executor", kwargs={"flow_slug": flow.slug})
with override_settings(TEST=False, DEBUG=False):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)
with self.assertRaises(ParseError):
self.client.logout()
response = self.client.post(url, data="{", content_type="application/json")
self.assertEqual(response.status_code, 200)

View File

@ -55,7 +55,7 @@ from authentik.flows.planner import (
FlowPlanner,
)
from authentik.flows.stage import AccessDeniedStage, StageView
from authentik.lib.sentry import SentryIgnoredException
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
from authentik.lib.utils.errors import exception_to_string
from authentik.lib.utils.reflection import all_subclasses, class_to_path
from authentik.lib.utils.urls import is_url_absolute, redirect_with_qs
@ -234,12 +234,13 @@ class FlowExecutorView(APIView):
"""Handle exception in stage execution"""
if settings.DEBUG or settings.TEST:
raise exc
capture_exception(exc)
self._logger.warning(exc)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=exception_to_string(exc),
).from_http(self.request)
if not should_ignore_exception(exc):
capture_exception(exc)
Event.new(
action=EventAction.SYSTEM_EXCEPTION,
message=exception_to_string(exc),
).from_http(self.request)
challenge = FlowErrorChallenge(self.request, exc)
challenge.is_valid(raise_exception=True)
return to_stage_response(self.request, HttpChallengeResponse(challenge))

View File

@ -104,6 +104,7 @@ def get_logger_config():
"hpack": "WARNING",
"httpx": "WARNING",
"azure": "WARNING",
"httpcore": "WARNING",
}
for handler_name, level in handler_level_map.items():
base_config["loggers"][handler_name] = {

View File

@ -14,6 +14,7 @@ from django_redis.exceptions import ConnectionInterrupted
from docker.errors import DockerException
from h11 import LocalProtocolError
from ldap3.core.exceptions import LDAPException
from psycopg.errors import Error
from redis.exceptions import ConnectionError as RedisConnectionError
from redis.exceptions import RedisError, ResponseError
from rest_framework.exceptions import APIException
@ -44,6 +45,49 @@ class SentryIgnoredException(Exception):
"""Base Class for all errors that are suppressed, and not sent to sentry."""
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
class SentryTransport(HttpTransport):
"""Custom sentry transport with custom user-agent"""
@ -101,56 +145,17 @@ def traces_sampler(sampling_context: dict) -> float:
return float(CONFIG.get("error_reporting.sample_rate", 0.1))
def should_ignore_exception(exc: Exception) -> bool:
"""Check if an exception should be dropped"""
return isinstance(exc, ignored_classes)
def before_send(event: dict, hint: dict) -> dict | None:
"""Check if error is database error, and ignore if so"""
from psycopg.errors import Error
ignored_classes = (
# Inbuilt types
KeyboardInterrupt,
ConnectionResetError,
OSError,
PermissionError,
# Django Errors
Error,
ImproperlyConfigured,
DatabaseError,
OperationalError,
InternalError,
ProgrammingError,
SuspiciousOperation,
ValidationError,
# Redis errors
RedisConnectionError,
ConnectionInterrupted,
RedisError,
ResponseError,
# websocket errors
ChannelFull,
WebSocketException,
LocalProtocolError,
# rest_framework error
APIException,
# celery errors
WorkerLostError,
CeleryError,
SoftTimeLimitExceeded,
# custom baseclass
SentryIgnoredException,
# ldap errors
LDAPException,
# Docker errors
DockerException,
# End-user errors
Http404,
# AsyncIO
CancelledError,
)
exc_value = None
if "exc_info" in hint:
_, exc_value, _ = hint["exc_info"]
if isinstance(exc_value, ignored_classes):
if should_ignore_exception(exc_value):
LOGGER.debug("dropping exception", exc=exc_value)
return None
if "logger" in event:

View File

@ -2,7 +2,7 @@
from django.test import TestCase
from authentik.lib.sentry import SentryIgnoredException, before_send
from authentik.lib.sentry import SentryIgnoredException, should_ignore_exception
class TestSentry(TestCase):
@ -10,8 +10,8 @@ class TestSentry(TestCase):
def test_error_not_sent(self):
"""Test SentryIgnoredError not sent"""
self.assertIsNone(before_send({}, {"exc_info": (0, SentryIgnoredException(), 0)}))
self.assertTrue(should_ignore_exception(SentryIgnoredException()))
def test_error_sent(self):
"""Test error sent"""
self.assertEqual({}, before_send({}, {"exc_info": (0, ValueError(), 0)}))
self.assertFalse(should_ignore_exception(ValueError()))

View File

@ -1,15 +1,13 @@
"""authentik outpost signals"""
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache
from django.db.models import Model
from django.db.models.signals import m2m_changed, post_save, pre_delete, pre_save
from django.dispatch import receiver
from django.http import HttpRequest
from structlog.stdlib import get_logger
from authentik.brands.models import Brand
from authentik.core.models import AuthenticatedSession, Provider, User
from authentik.core.models import AuthenticatedSession, Provider
from authentik.crypto.models import CertificateKeyPair
from authentik.lib.utils.reflection import class_to_path
from authentik.outposts.models import Outpost, OutpostServiceConnection
@ -82,14 +80,6 @@ def pre_delete_cleanup(sender, instance: Outpost, **_):
outpost_controller.delay(instance.pk.hex, action="down", from_cache=True)
@receiver(user_logged_out)
def logout_revoke_direct(sender: type[User], request: HttpRequest, **_):
"""Catch logout by direct logout and forward to providers"""
if not request.session or not request.session.session_key:
return
outpost_session_end.delay(request.session.session_key)
@receiver(pre_delete, sender=AuthenticatedSession)
def logout_revoke(sender: type[AuthenticatedSession], instance: AuthenticatedSession, **_):
"""Catch logout by expiring sessions being deleted"""

View File

@ -1,23 +1,10 @@
from django.contrib.auth.signals import user_logged_out
from django.db.models.signals import post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User
from authentik.providers.oauth2.models import AccessToken, DeviceToken, RefreshToken
@receiver(user_logged_out)
def user_logged_out_oauth_tokens_removal(sender, request: HttpRequest, user: User, **_):
"""Revoke tokens upon user logout"""
if not request.session or not request.session.session_key:
return
AccessToken.objects.filter(
user=user,
session__session__session_key=request.session.session_key,
).delete()
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted_oauth_tokens_removal(sender, instance: AuthenticatedSession, **_):
"""Revoke tokens upon user logout"""

View File

@ -2,13 +2,11 @@
from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.contrib.auth.signals import user_logged_out
from django.core.cache import cache
from django.db.models.signals import post_delete, post_save, pre_delete
from django.dispatch import receiver
from django.http import HttpRequest
from authentik.core.models import AuthenticatedSession, User
from authentik.core.models import AuthenticatedSession
from authentik.providers.rac.api.endpoints import user_endpoint_cache_key
from authentik.providers.rac.consumer_client import (
RAC_CLIENT_GROUP_SESSION,
@ -17,21 +15,6 @@ from authentik.providers.rac.consumer_client import (
from authentik.providers.rac.models import ConnectionToken, Endpoint
@receiver(user_logged_out)
def user_logged_out_session(sender, request: HttpRequest, user: User, **_):
"""Disconnect any open RAC connections"""
if not request.session or not request.session.session_key:
return
layer = get_channel_layer()
async_to_sync(layer.group_send)(
RAC_CLIENT_GROUP_SESSION
% {
"session": request.session.session_key,
},
{"type": "event.disconnect", "reason": "session_logout"},
)
@receiver(pre_delete, sender=AuthenticatedSession)
def user_session_deleted(sender, instance: AuthenticatedSession, **_):
layer = get_channel_layer()

View File

@ -5,7 +5,6 @@ from itertools import batched
from django.db import transaction
from pydantic import ValidationError
from pydanticscim.group import GroupMember
from pydanticscim.responses import PatchOp
from authentik.core.models import Group
from authentik.lib.sync.mapper import PropertyMappingManager
@ -20,7 +19,12 @@ from authentik.providers.scim.clients.base import SCIMClient
from authentik.providers.scim.clients.exceptions import (
SCIMRequestException,
)
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOperation, PatchRequest
from authentik.providers.scim.clients.schema import (
SCIM_GROUP_SCHEMA,
PatchOp,
PatchOperation,
PatchRequest,
)
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.providers.scim.models import (
SCIMMapping,

View File

@ -1,5 +1,7 @@
"""Custom SCIM schemas"""
from enum import Enum
from pydantic import Field
from pydanticscim.group import Group as BaseGroup
from pydanticscim.responses import PatchOperation as BasePatchOperation
@ -65,6 +67,21 @@ class ServiceProviderConfiguration(BaseServiceProviderConfiguration):
)
class PatchOp(str, Enum):
replace = "replace"
remove = "remove"
add = "add"
@classmethod
def _missing_(cls, value):
value = value.lower()
for member in cls:
if member.lower() == value:
return member
return None
class PatchRequest(BasePatchRequest):
"""PatchRequest which correctly sets schemas"""
@ -74,6 +91,7 @@ class PatchRequest(BasePatchRequest):
class PatchOperation(BasePatchOperation):
"""PatchOperation with optional path"""
op: PatchOp
path: str | None

View File

@ -27,7 +27,7 @@ from structlog.stdlib import get_logger
from tenant_schemas_celery.app import CeleryApp as TenantAwareCeleryApp
from authentik import get_full_version
from authentik.lib.sentry import before_send
from authentik.lib.sentry import should_ignore_exception
from authentik.lib.utils.errors import exception_to_string
# set the default Django settings module for the 'celery' program.
@ -81,7 +81,7 @@ def task_error_hook(task_id: str, exception: Exception, traceback, *args, **kwar
LOGGER.warning("Task failure", task_id=task_id.replace("-", ""), exc=exception)
CTX_TASK_ID.set(...)
if before_send({}, {"exc_info": (None, exception, None)}) is not None:
if not should_ignore_exception(exception):
Event.new(
EventAction.SYSTEM_EXCEPTION, message=exception_to_string(exception), task_id=task_id
).save()

View File

@ -1,13 +1,49 @@
"""authentik database backend"""
from django.core.checks import Warning
from django.db.backends.base.validation import BaseDatabaseValidation
from django_tenants.postgresql_backend.base import DatabaseWrapper as BaseDatabaseWrapper
from authentik.lib.config import CONFIG
class DatabaseValidation(BaseDatabaseValidation):
def check(self, **kwargs):
return self._check_encoding()
def _check_encoding(self):
"""Throw a warning when the server_encoding is not UTF-8 or
server_encoding and client_encoding are mismatched"""
messages = []
with self.connection.cursor() as cursor:
cursor.execute("SHOW server_encoding;")
server_encoding = cursor.fetchone()[0]
cursor.execute("SHOW client_encoding;")
client_encoding = cursor.fetchone()[0]
if server_encoding != client_encoding:
messages.append(
Warning(
"PostgreSQL Server and Client encoding are mismatched: Server: "
f"{server_encoding}, Client: {client_encoding}",
id="ak.db.W001",
)
)
if server_encoding != "UTF8":
messages.append(
Warning(
f"PostgreSQL Server encoding is not UTF8: {server_encoding}",
id="ak.db.W002",
)
)
return messages
class DatabaseWrapper(BaseDatabaseWrapper):
"""database backend which supports rotating credentials"""
validation_class = DatabaseValidation
def get_connection_params(self):
"""Refresh DB credentials before getting connection params"""
conn_params = super().get_connection_params()

View File

@ -0,0 +1,277 @@
"""Test SCIM Group"""
from json import dumps
from uuid import uuid4
from django.urls import reverse
from rest_framework.test import APITestCase
from authentik.core.models import Group
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.lib.generators import generate_id
from authentik.providers.scim.clients.schema import Group as SCIMGroupSchema
from authentik.sources.scim.models import (
SCIMSource,
SCIMSourceGroup,
)
from authentik.sources.scim.views.v2.base import SCIM_CONTENT_TYPE
class TestSCIMGroups(APITestCase):
"""Test SCIM Group view"""
def setUp(self) -> None:
self.source = SCIMSource.objects.create(name=generate_id(), slug=generate_id())
def test_group_list(self):
"""Test full group list"""
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_group_list_single(self):
"""Test full group list (single group)"""
group = Group.objects.create(name=generate_id())
user = create_test_user()
group.users.add(user)
SCIMSourceGroup.objects.create(
source=self.source,
group=group,
id=str(uuid4()),
)
response = self.client.get(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(group.pk),
},
),
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
SCIMGroupSchema.model_validate_json(response.content, strict=True)
def test_group_create(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members(self):
"""Test group create"""
user = create_test_user()
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{
"displayName": generate_id(),
"externalId": ext_id,
"members": [{"value": str(user.uuid)}],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_members_empty(self):
"""Test group create"""
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "members": []}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 201)
self.assertTrue(SCIMSourceGroup.objects.filter(source=self.source, id=ext_id).exists())
self.assertTrue(
Event.objects.filter(
action=EventAction.MODEL_CREATED, user__username=self.source.token.user.username
).exists()
)
def test_group_create_duplicate(self):
"""Test group create (duplicate)"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.post(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.group.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 409)
self.assertJSONEqual(
response.content,
{
"detail": "Group with ID exists already.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"scimType": "uniqueness",
"status": 409,
},
)
def test_group_update(self):
"""Test group update"""
group = Group.objects.create(name=generate_id())
existing = SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{"displayName": generate_id(), "externalId": ext_id, "id": str(existing.pk)}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
def test_group_update_non_existent(self):
"""Test group update"""
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={
"source_slug": self.source.slug,
"group_id": str(uuid4()),
},
),
data=dumps({"displayName": generate_id(), "externalId": ext_id, "id": ""}),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=404)
self.assertJSONEqual(
response.content,
{
"detail": "Group not found.",
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": 404,
},
)
def test_group_patch_add(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "Add",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertTrue(group.users.filter(pk=user.pk).exists())
def test_group_patch_remove(self):
"""Test group patch"""
user = create_test_user()
group = Group.objects.create(name=generate_id())
group.users.add(user)
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.patch(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
data=dumps(
{
"Operations": [
{
"op": "remove",
"path": "members",
"value": {"value": str(user.uuid)},
}
]
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=200)
self.assertFalse(group.users.filter(pk=user.pk).exists())
def test_group_delete(self):
"""Test group delete"""
group = Group.objects.create(name=generate_id())
SCIMSourceGroup.objects.create(source=self.source, group=group, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-groups",
kwargs={"source_slug": self.source.slug, "group_id": group.pk},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, second=204)

View File

@ -177,3 +177,51 @@ class TestSCIMUsers(APITestCase):
SCIMSourceUser.objects.get(source=self.source, id=ext_id).user.attributes["phone"],
"0123456789",
)
def test_user_update(self):
"""Test user update"""
user = create_test_user()
existing = SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
ext_id = generate_id()
response = self.client.put(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
data=dumps(
{
"id": str(existing.pk),
"userName": generate_id(),
"externalId": ext_id,
"emails": [
{
"primary": True,
"value": user.email,
}
],
}
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 200)
def test_user_delete(self):
"""Test user delete"""
user = create_test_user()
SCIMSourceUser.objects.create(source=self.source, user=user, id=uuid4())
response = self.client.delete(
reverse(
"authentik_sources_scim:v2-users",
kwargs={
"source_slug": self.source.slug,
"user_id": str(user.uuid),
},
),
content_type=SCIM_CONTENT_TYPE,
HTTP_AUTHORIZATION=f"Bearer {self.source.token.key}",
)
self.assertEqual(response.status_code, 204)

View File

@ -8,6 +8,7 @@ from rest_framework.authentication import BaseAuthentication, get_authorization_
from rest_framework.request import Request
from rest_framework.views import APIView
from authentik.core.middleware import CTX_AUTH_VIA
from authentik.core.models import Token, TokenIntents, User
from authentik.sources.scim.models import SCIMSource
@ -26,6 +27,7 @@ class SCIMTokenAuth(BaseAuthentication):
_username, _, password = b64decode(key.encode()).decode().partition(":")
token = self.check_token(password, source_slug)
if token:
CTX_AUTH_VIA.set("scim_basic")
return (token.user, token)
return None
@ -52,4 +54,5 @@ class SCIMTokenAuth(BaseAuthentication):
token = self.check_token(key, source_slug)
if not token:
return None
CTX_AUTH_VIA.set("scim_token")
return (token.user, token)

View File

@ -1,13 +1,11 @@
"""SCIM Utils"""
from typing import Any
from urllib.parse import urlparse
from django.conf import settings
from django.core.paginator import Page, Paginator
from django.db.models import Q, QuerySet
from django.http import HttpRequest
from django.urls import resolve
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.renderers import JSONRenderer
@ -46,7 +44,7 @@ class SCIMView(APIView):
logger: BoundLogger
permission_classes = [IsAuthenticated]
parser_classes = [SCIMParser]
parser_classes = [SCIMParser, JSONParser]
renderer_classes = [SCIMRenderer]
def setup(self, request: HttpRequest, *args: Any, **kwargs: Any) -> None:
@ -56,28 +54,6 @@ class SCIMView(APIView):
def get_authenticators(self):
return [SCIMTokenAuth(self)]
def patch_resolve_value(self, raw_value: dict) -> User | Group | None:
"""Attempt to resolve a raw `value` attribute of a patch operation into
a database model"""
model = User
query = {}
if "$ref" in raw_value:
url = urlparse(raw_value["$ref"])
if match := resolve(url.path):
if match.url_name == "v2-users":
model = User
query = {"pk": int(match.kwargs["user_id"])}
elif "type" in raw_value:
match raw_value["type"]:
case "User":
model = User
query = {"pk": int(raw_value["value"])}
case "Group":
model = Group
else:
return None
return model.objects.filter(**query).first()
def filter_parse(self, request: Request):
"""Parse the path of a Patch Operation"""
path = request.query_params.get("filter")

View File

@ -0,0 +1,58 @@
from enum import Enum
from pydanticscim.responses import SCIMError as BaseSCIMError
from rest_framework.exceptions import ValidationError
class SCIMErrorTypes(Enum):
invalid_filter = "invalidFilter"
too_many = "tooMany"
uniqueness = "uniqueness"
mutability = "mutability"
invalid_syntax = "invalidSyntax"
invalid_path = "invalidPath"
no_target = "noTarget"
invalid_value = "invalidValue"
invalid_vers = "invalidVers"
sensitive = "sensitive"
class SCIMError(BaseSCIMError):
scimType: SCIMErrorTypes | None = None
detail: str | None = None
class SCIMValidationError(ValidationError):
status_code = 400
default_detail = SCIMError(scimType=SCIMErrorTypes.invalid_syntax, status=400)
def __init__(self, detail: SCIMError | None):
if detail is None:
detail = self.default_detail
detail.status = self.status_code
self.detail = detail.model_dump(mode="json", exclude_none=True)
class SCIMConflictError(SCIMValidationError):
status_code = 409
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
scimType=SCIMErrorTypes.uniqueness,
status=self.status_code,
)
)
class SCIMNotFoundError(SCIMValidationError):
status_code = 404
def __init__(self, detail: str):
super().__init__(
SCIMError(
detail=detail,
status=self.status_code,
)
)

View File

@ -4,19 +4,25 @@ from uuid import uuid4
from django.db.models import Q
from django.db.transaction import atomic
from django.http import Http404, QueryDict
from django.http import QueryDict
from django.urls import reverse
from pydantic import ValidationError as PydanticValidationError
from pydanticscim.group import GroupMember
from rest_framework.exceptions import ValidationError
from rest_framework.request import Request
from rest_framework.response import Response
from scim2_filter_parser.attr_paths import AttrPath
from authentik.core.models import Group, User
from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import SCIM_GROUP_SCHEMA, PatchOp, PatchOperation
from authentik.providers.scim.clients.schema import Group as SCIMGroupModel
from authentik.sources.scim.models import SCIMSourceGroup
from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import (
SCIMConflictError,
SCIMNotFoundError,
SCIMValidationError,
)
class GroupsView(SCIMObjectView):
@ -27,7 +33,7 @@ class GroupsView(SCIMObjectView):
def group_to_scim(self, scim_group: SCIMSourceGroup) -> dict:
"""Convert Group to SCIM data"""
payload = SCIMGroupModel(
schemas=[SCIM_USER_SCHEMA],
schemas=[SCIM_GROUP_SCHEMA],
id=str(scim_group.group.pk),
externalId=scim_group.id,
displayName=scim_group.group.name,
@ -58,7 +64,7 @@ class GroupsView(SCIMObjectView):
if group_id:
connection = base_query.filter(source=self.source, group__group_uuid=group_id).first()
if not connection:
raise Http404
raise SCIMNotFoundError("Group not found.")
return Response(self.group_to_scim(connection))
connections = (
base_query.filter(source=self.source).order_by("pk").filter(self.filter_parse(request))
@ -119,7 +125,7 @@ class GroupsView(SCIMObjectView):
).first()
if connection:
self.logger.debug("Found existing group")
return Response(status=409)
raise SCIMConflictError("Group with ID exists already.")
connection = self.update_group(None, request.data)
return Response(self.group_to_scim(connection), status=201)
@ -129,10 +135,44 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise Http404
raise SCIMNotFoundError("Group not found.")
connection = self.update_group(connection, request.data)
return Response(self.group_to_scim(connection), status=200)
@atomic
def patch(self, request: Request, group_id: str, **kwargs) -> Response:
"""Patch group handler"""
connection = SCIMSourceGroup.objects.filter(
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise SCIMNotFoundError("Group not found.")
for _op in request.data.get("Operations", []):
operation = PatchOperation.model_validate(_op)
if operation.op.lower() not in ["add", "remove", "replace"]:
raise SCIMValidationError()
attr_path = AttrPath(f'{operation.path} eq ""', {})
if attr_path.first_path == ("members", None, None):
# FIXME: this can probably be de-duplicated
if operation.op == PatchOp.add:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.add(*User.objects.filter(query))
elif operation.op == PatchOp.remove:
if not isinstance(operation.value, list):
operation.value = [operation.value]
query = Q()
for member in operation.value:
query |= Q(uuid=member["value"])
if query:
connection.group.users.remove(*User.objects.filter(query))
return Response(self.group_to_scim(connection), status=200)
@atomic
def delete(self, request: Request, group_id: str, **kwargs) -> Response:
"""Delete group handler"""
@ -140,7 +180,7 @@ class GroupsView(SCIMObjectView):
source=self.source, group__group_uuid=group_id
).first()
if not connection:
raise Http404
raise SCIMNotFoundError("Group not found.")
connection.group.delete()
connection.delete()
return Response(status=204)

View File

@ -1,11 +1,11 @@
"""SCIM Meta views"""
from django.http import Http404
from django.urls import reverse
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
class ResourceTypesView(SCIMView):
@ -138,7 +138,7 @@ class ResourceTypesView(SCIMView):
resource = [x for x in resource_types if x.get("id") == resource_type]
if resource:
return Response(resource[0])
raise Http404
raise SCIMNotFoundError("Resource not found.")
return Response(
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -3,12 +3,12 @@
from json import loads
from django.conf import settings
from django.http import Http404
from django.urls import reverse
from rest_framework.request import Request
from rest_framework.response import Response
from authentik.sources.scim.views.v2.base import SCIMView
from authentik.sources.scim.views.v2.exceptions import SCIMNotFoundError
with open(
settings.BASE_DIR / "authentik" / "sources" / "scim" / "schemas" / "schema.json",
@ -44,7 +44,7 @@ class SchemaView(SCIMView):
schema = [x for x in schemas if x.get("id") == schema_uri]
if schema:
return Response(schema[0])
raise Http404
raise SCIMNotFoundError("Schema not found.")
return Response(
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:ListResponse"],

View File

@ -33,6 +33,8 @@ class ServiceProviderConfigView(SCIMView):
{
"schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"],
"authenticationSchemes": auth_schemas,
# We only support patch for groups currently, so don't broadly advertise it.
# Implementations that require Group patch will use it regardless of this flag.
"patch": {"supported": False},
"bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0},
"filter": {

View File

@ -4,7 +4,7 @@ from uuid import uuid4
from django.db.models import Q
from django.db.transaction import atomic
from django.http import Http404, QueryDict
from django.http import QueryDict
from django.urls import reverse
from pydanticscim.user import Email, EmailKind, Name
from rest_framework.exceptions import ValidationError
@ -16,6 +16,7 @@ from authentik.providers.scim.clients.schema import SCIM_USER_SCHEMA
from authentik.providers.scim.clients.schema import User as SCIMUserModel
from authentik.sources.scim.models import SCIMSourceUser
from authentik.sources.scim.views.v2.base import SCIMObjectView
from authentik.sources.scim.views.v2.exceptions import SCIMConflictError, SCIMNotFoundError
class UsersView(SCIMObjectView):
@ -69,7 +70,7 @@ class UsersView(SCIMObjectView):
.first()
)
if not connection:
raise Http404
raise SCIMNotFoundError("User not found.")
return Response(self.user_to_scim(connection))
connections = (
SCIMSourceUser.objects.filter(source=self.source).select_related("user").order_by("pk")
@ -122,7 +123,7 @@ class UsersView(SCIMObjectView):
).first()
if connection:
self.logger.debug("Found existing user")
return Response(status=409)
raise SCIMConflictError("Group with ID exists already.")
connection = self.update_user(None, request.data)
return Response(self.user_to_scim(connection), status=201)
@ -130,7 +131,7 @@ class UsersView(SCIMObjectView):
"""Update user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection:
raise Http404
raise SCIMNotFoundError("User not found.")
self.update_user(connection, request.data)
return Response(self.user_to_scim(connection), status=200)
@ -139,7 +140,7 @@ class UsersView(SCIMObjectView):
"""Delete user handler"""
connection = SCIMSourceUser.objects.filter(source=self.source, user__uuid=user_id).first()
if not connection:
raise Http404
raise SCIMNotFoundError("User not found.")
connection.user.delete()
connection.delete()
return Response(status=204)

View File

@ -1,6 +1,7 @@
"""Validation stage challenge checking"""
from json import loads
from typing import TYPE_CHECKING
from urllib.parse import urlencode
from django.http import HttpRequest
@ -36,10 +37,12 @@ from authentik.stages.authenticator_email.models import EmailDevice
from authentik.stages.authenticator_sms.models import SMSDevice
from authentik.stages.authenticator_validate.models import AuthenticatorValidateStage, DeviceClasses
from authentik.stages.authenticator_webauthn.models import UserVerification, WebAuthnDevice
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
LOGGER = get_logger()
if TYPE_CHECKING:
from authentik.stages.authenticator_validate.stage import AuthenticatorValidateStageView
class DeviceChallenge(PassiveSerializer):
@ -52,11 +55,11 @@ class DeviceChallenge(PassiveSerializer):
def get_challenge_for_device(
request: HttpRequest, stage: AuthenticatorValidateStage, device: Device
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage, device: Device
) -> dict:
"""Generate challenge for a single device"""
if isinstance(device, WebAuthnDevice):
return get_webauthn_challenge(request, stage, device)
return get_webauthn_challenge(stage_view, stage, device)
if isinstance(device, EmailDevice):
return {"email": mask_email(device.email)}
# Code-based challenges have no hints
@ -64,26 +67,30 @@ def get_challenge_for_device(
def get_webauthn_challenge_without_user(
request: HttpRequest, stage: AuthenticatorValidateStage
stage_view: "AuthenticatorValidateStageView", stage: AuthenticatorValidateStage
) -> dict:
"""Same as `get_webauthn_challenge`, but allows any client device. We can then later check
who the device belongs to."""
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
authentication_options = generate_authentication_options(
rp_id=get_rp_id(request),
rp_id=get_rp_id(stage_view.request),
allow_credentials=[],
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
)
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
authentication_options.challenge
)
return loads(options_to_json(authentication_options))
def get_webauthn_challenge(
request: HttpRequest, stage: AuthenticatorValidateStage, device: WebAuthnDevice | None = None
stage_view: "AuthenticatorValidateStageView",
stage: AuthenticatorValidateStage,
device: WebAuthnDevice | None = None,
) -> dict:
"""Send the client a challenge that we'll check later"""
request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage_view.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
allowed_credentials = []
@ -94,12 +101,14 @@ def get_webauthn_challenge(
allowed_credentials.append(user_device.descriptor)
authentication_options = generate_authentication_options(
rp_id=get_rp_id(request),
rp_id=get_rp_id(stage_view.request),
allow_credentials=allowed_credentials,
user_verification=UserVerificationRequirement(stage.webauthn_user_verification),
)
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = authentication_options.challenge
stage_view.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = (
authentication_options.challenge
)
return loads(options_to_json(authentication_options))
@ -146,7 +155,7 @@ def validate_challenge_code(code: str, stage_view: StageView, user: User) -> Dev
def validate_challenge_webauthn(data: dict, stage_view: StageView, user: User) -> Device:
"""Validate WebAuthn Challenge"""
request = stage_view.request
challenge = request.session.get(SESSION_KEY_WEBAUTHN_CHALLENGE)
challenge = stage_view.executor.plan.context.get(PLAN_CONTEXT_WEBAUTHN_CHALLENGE)
stage: AuthenticatorValidateStage = stage_view.executor.current_stage
try:
credential = parse_authentication_credential_json(data)

View File

@ -224,7 +224,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
data={
"device_class": device_class,
"device_uid": device.pk,
"challenge": get_challenge_for_device(self.request, stage, device),
"challenge": get_challenge_for_device(self, stage, device),
"last_used": device.last_used,
}
)
@ -243,7 +243,7 @@ class AuthenticatorValidateStageView(ChallengeStageView):
"device_class": DeviceClasses.WEBAUTHN,
"device_uid": -1,
"challenge": get_webauthn_challenge_without_user(
self.request,
self,
self.executor.current_stage,
),
"last_used": None,

View File

@ -31,7 +31,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice,
WebAuthnDeviceType,
)
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
from authentik.stages.identification.models import IdentificationStage, UserFields
from authentik.stages.user_login.models import UserLoginStage
@ -103,7 +103,11 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
device_classes=[DeviceClasses.WEBAUTHN],
webauthn_user_verification=UserVerification.PREFERRED,
)
challenge = get_challenge_for_device(request, stage, webauthn_device)
plan = FlowPlan("")
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
del challenge["challenge"]
self.assertEqual(
challenge,
@ -122,7 +126,9 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
with self.assertRaises(ValidationError):
validate_challenge_webauthn(
{}, StageView(FlowExecutorView(current_stage=stage), request=request), self.user
{},
StageView(FlowExecutorView(current_stage=stage, plan=plan), request=request),
self.user,
)
def test_device_challenge_webauthn_restricted(self):
@ -193,22 +199,35 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0,
rp_id=generate_id(),
)
challenge = get_challenge_for_device(request, stage, webauthn_device)
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
plan = FlowPlan("")
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_challenge_for_device(stage_view, stage, webauthn_device)
self.assertEqual(
challenge,
{
"allowCredentials": [
{
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
"type": "public-key",
}
],
"challenge": bytes_to_base64url(webauthn_challenge),
"rpId": "testserver",
"timeout": 60000,
"userVerification": "preferred",
},
challenge["allowCredentials"],
[
{
"id": "QKZ97ASJAOIDyipAs6mKUxDUZgDrWrbAsUb5leL7-oU",
"type": "public-key",
}
],
)
self.assertIsNotNone(challenge["challenge"])
self.assertEqual(
challenge["rpId"],
"testserver",
)
self.assertEqual(
challenge["timeout"],
60000,
)
self.assertEqual(
challenge["userVerification"],
"preferred",
)
def test_get_challenge_userless(self):
@ -228,18 +247,16 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
sign_count=0,
rp_id=generate_id(),
)
challenge = get_webauthn_challenge_without_user(request, stage)
webauthn_challenge = request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
self.assertEqual(
challenge,
{
"allowCredentials": [],
"challenge": bytes_to_base64url(webauthn_challenge),
"rpId": "testserver",
"timeout": 60000,
"userVerification": "preferred",
},
plan = FlowPlan("")
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=None, current_stage=stage, plan=plan), request=request
)
challenge = get_webauthn_challenge_without_user(stage_view, stage)
self.assertEqual(challenge["allowCredentials"], [])
self.assertIsNotNone(challenge["challenge"])
self.assertEqual(challenge["rpId"], "testserver")
self.assertEqual(challenge["timeout"], 60000)
self.assertEqual(challenge["userVerification"], "preferred")
def test_validate_challenge_unrestricted(self):
"""Test webauthn authentication (unrestricted webauthn device)"""
@ -275,10 +292,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None,
}
]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
)
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
@ -352,10 +369,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None,
}
]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"aCC6ak_DP45xMH1qyxzUM5iC2xc4QthQb09v7m4qDBmY8FvWvhxFzSuFlDYQmclrh5fWS5q0TPxgJGF4vimcFQ"
)
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
@ -433,10 +450,10 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
"last_used": None,
}
]
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
@ -496,17 +513,14 @@ class AuthenticatorValidateStageWebAuthnTests(FlowTestCase):
not_configured_action=NotConfiguredAction.CONFIGURE,
device_classes=[DeviceClasses.WEBAUTHN],
)
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=flow, current_stage=stage), request=request
)
request = get_request("/")
request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
plan = FlowPlan(flow.pk.hex)
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = base64url_to_bytes(
"g98I51mQvZXo5lxLfhrD2zfolhZbLRyCgqkkYap1jwSaJ13BguoJWCF9_Lg3AgO4Wh-Bqa556JE20oKsYbl6RA"
)
request.session.save()
request = get_request("/")
stage_view = AuthenticatorValidateStageView(
FlowExecutorView(flow=flow, current_stage=stage), request=request
FlowExecutorView(flow=flow, current_stage=stage, plan=plan), request=request
)
request.META["SERVER_NAME"] = "localhost"
request.META["SERVER_PORT"] = "9000"

View File

@ -25,6 +25,7 @@ class AuthenticatorWebAuthnStageSerializer(StageSerializer):
"resident_key_requirement",
"device_type_restrictions",
"device_type_restrictions_obj",
"max_attempts",
]

View File

@ -0,0 +1,21 @@
# Generated by Django 5.1.11 on 2025-06-13 22:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"authentik_stages_authenticator_webauthn",
"0012_webauthndevice_created_webauthndevice_last_updated_and_more",
),
]
operations = [
migrations.AddField(
model_name="authenticatorwebauthnstage",
name="max_attempts",
field=models.PositiveIntegerField(default=0),
),
]

View File

@ -84,6 +84,8 @@ class AuthenticatorWebAuthnStage(ConfigurableStage, FriendlyNamedStage, Stage):
device_type_restrictions = models.ManyToManyField("WebAuthnDeviceType", blank=True)
max_attempts = models.PositiveIntegerField(default=0)
@property
def serializer(self) -> type[BaseSerializer]:
from authentik.stages.authenticator_webauthn.api.stages import (

View File

@ -5,12 +5,13 @@ from uuid import UUID
from django.http import HttpRequest, HttpResponse
from django.http.request import QueryDict
from django.utils.translation import gettext as __
from django.utils.translation import gettext_lazy as _
from rest_framework.fields import CharField
from rest_framework.serializers import ValidationError
from webauthn import options_to_json
from webauthn.helpers.bytes_to_base64url import bytes_to_base64url
from webauthn.helpers.exceptions import InvalidRegistrationResponse
from webauthn.helpers.exceptions import WebAuthnException
from webauthn.helpers.structs import (
AttestationConveyancePreference,
AuthenticatorAttachment,
@ -41,7 +42,8 @@ from authentik.stages.authenticator_webauthn.models import (
)
from authentik.stages.authenticator_webauthn.utils import get_origin, get_rp_id
SESSION_KEY_WEBAUTHN_CHALLENGE = "authentik/stages/authenticator_webauthn/challenge"
PLAN_CONTEXT_WEBAUTHN_CHALLENGE = "goauthentik.io/stages/authenticator_webauthn/challenge"
PLAN_CONTEXT_WEBAUTHN_ATTEMPT = "goauthentik.io/stages/authenticator_webauthn/attempt"
class AuthenticatorWebAuthnChallenge(WithUserInfoChallenge):
@ -62,7 +64,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
def validate_response(self, response: dict) -> dict:
"""Validate webauthn challenge response"""
challenge = self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE]
challenge = self.stage.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]
try:
registration: VerifiedRegistration = verify_registration_response(
@ -71,7 +73,7 @@ class AuthenticatorWebAuthnChallengeResponse(ChallengeResponse):
expected_rp_id=get_rp_id(self.request),
expected_origin=get_origin(self.request),
)
except InvalidRegistrationResponse as exc:
except WebAuthnException as exc:
self.stage.logger.warning("registration failed", exc=exc)
raise ValidationError(f"Registration failed. Error: {exc}") from None
@ -114,9 +116,10 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response_class = AuthenticatorWebAuthnChallengeResponse
def get_challenge(self, *args, **kwargs) -> Challenge:
# clear session variables prior to starting a new registration
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
# clear flow variables prior to starting a new registration
self.executor.plan.context.pop(PLAN_CONTEXT_WEBAUTHN_CHALLENGE, None)
user = self.get_pending_user()
# library accepts none so we store null in the database, but if there is a value
@ -139,8 +142,7 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
attestation=AttestationConveyancePreference.DIRECT,
)
self.request.session[SESSION_KEY_WEBAUTHN_CHALLENGE] = registration_options.challenge
self.request.session.save()
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = registration_options.challenge
return AuthenticatorWebAuthnChallenge(
data={
"registration": loads(options_to_json(registration_options)),
@ -153,6 +155,24 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
response.user = self.get_pending_user()
return response
def challenge_invalid(self, response):
stage: AuthenticatorWebAuthnStage = self.executor.current_stage
self.executor.plan.context.setdefault(PLAN_CONTEXT_WEBAUTHN_ATTEMPT, 0)
self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] += 1
if (
stage.max_attempts > 0
and self.executor.plan.context[PLAN_CONTEXT_WEBAUTHN_ATTEMPT] >= stage.max_attempts
):
return self.executor.stage_invalid(
__(
"Exceeded maximum attempts. "
"Contact your {brand} administrator for help.".format(
brand=self.request.brand.branding_title
)
)
)
return super().challenge_invalid(response)
def challenge_valid(self, response: ChallengeResponse) -> HttpResponse:
# Webauthn Challenge has already been validated
webauthn_credential: VerifiedRegistration = response.validated_data["response"]
@ -179,6 +199,3 @@ class AuthenticatorWebAuthnStageView(ChallengeStageView):
else:
return self.executor.stage_invalid("Device with Credential ID already exists.")
return self.executor.stage_ok()
def cleanup(self):
self.request.session.pop(SESSION_KEY_WEBAUTHN_CHALLENGE, None)

View File

@ -18,7 +18,7 @@ from authentik.stages.authenticator_webauthn.models import (
WebAuthnDevice,
WebAuthnDeviceType,
)
from authentik.stages.authenticator_webauthn.stage import SESSION_KEY_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.stage import PLAN_CONTEXT_WEBAUTHN_CHALLENGE
from authentik.stages.authenticator_webauthn.tasks import webauthn_mds_import
@ -57,6 +57,9 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
response = self.client.get(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
)
plan: FlowPlan = self.client.session[SESSION_KEY_PLAN]
self.assertEqual(response.status_code, 200)
session = self.client.session
self.assertStageResponse(
@ -70,7 +73,7 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"name": self.user.username,
"displayName": self.user.name,
},
"challenge": bytes_to_base64url(session[SESSION_KEY_WEBAUTHN_CHALLENGE]),
"challenge": bytes_to_base64url(plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE]),
"pubKeyCredParams": [
{"type": "public-key", "alg": -7},
{"type": "public-key", "alg": -8},
@ -97,11 +100,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
"""Test registration"""
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -146,11 +149,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -209,11 +212,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -259,11 +262,11 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session[SESSION_KEY_WEBAUTHN_CHALLENGE] = b64decode(
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
@ -298,3 +301,109 @@ class TestAuthenticatorWebAuthnStage(FlowTestCase):
self.assertEqual(response.status_code, 200)
self.assertStageRedirects(response, reverse("authentik_core:root-redirect"))
self.assertTrue(WebAuthnDevice.objects.filter(user=self.user).exists())
def test_register_max_retries(self):
"""Test registration (exceeding max retries)"""
self.stage.max_attempts = 2
self.stage.save()
plan = FlowPlan(flow_pk=self.flow.pk.hex, bindings=[self.binding], markers=[StageMarker()])
plan.context[PLAN_CONTEXT_PENDING_USER] = self.user
plan.context[PLAN_CONTEXT_WEBAUTHN_CHALLENGE] = b64decode(
b"03Xodi54gKsfnP5I9VFfhaGXVVE2NUyZpBBXns/JI+x6V9RY2Tw2QmxRJkhh7174EkRazUntIwjMVY9bFG60Lw=="
)
session = self.client.session
session[SESSION_KEY_PLAN] = plan
session.save()
# first failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-authenticator-webauthn",
response_errors={
"response": [
{
"string": (
"Registration failed. Error: Unable to decode "
"client_data_json bytes as JSON"
),
"code": "invalid",
}
]
},
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())
# Second failed request
response = self.client.post(
reverse("authentik_api:flow-executor", kwargs={"flow_slug": self.flow.slug}),
data={
"component": "ak-stage-authenticator-webauthn",
"response": {
"id": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"rawId": "kqnmrVLnDG-OwsSNHkihYZaNz5s",
"type": "public-key",
"registrationClientExtensions": "{}",
"response": {
"clientDataJSON": (
"eyJ0eXBlIjoid2ViYXV0aG4uY3JlYXRlIiwiY2hhbGxlbmd"
"lIjoiMDNYb2RpNTRnS3NmblA1STlWRmZoYUdYVlZFMk5VeV"
"pwQkJYbnNfSkkteDZWOVJZMlR3MlFteFJKa2hoNzE3NEVrU"
"mF6VW50SXdqTVZZOWJGRzYwTHciLCJvcmlnaW4iOiJodHRw"
"Oi8vbG9jYWxob3N0OjkwMDAiLCJjcm9zc09yaWdpbiI6ZmF"
),
"attestationObject": (
"o2NmbXRkbm9uZWdhdHRTdG10oGhhdXRoRGF0YViYSZYN5Yg"
"OjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2NdAAAAAPv8MA"
"cVTk7MjAtuAgVX170AFJKp5q1S5wxvjsLEjR5IoWGWjc-bp"
"QECAyYgASFYIKtcZHPumH37XHs0IM1v3pUBRIqHVV_SE-Lq"
"2zpJAOVXIlgg74Fg_WdB0kuLYqCKbxogkEPaVtR_iR3IyQFIJAXBzds"
),
},
},
},
SERVER_NAME="localhost",
SERVER_PORT="9000",
)
self.assertEqual(response.status_code, 200)
self.assertStageResponse(
response,
flow=self.flow,
component="ak-stage-access-denied",
error_message=(
"Exceeded maximum attempts. Contact your authentik administrator for help."
),
)
self.assertFalse(WebAuthnDevice.objects.filter(user=self.user).exists())

View File

@ -16,6 +16,7 @@ def check_embedded_outpost_disabled(app_configs, **kwargs):
"Embedded outpost must be disabled when tenants API is enabled.",
hint="Disable embedded outpost by setting outposts.disable_embedded_outpost to "
"True, or disable the tenants API by setting tenants.enabled to False",
id="ak.tenants.E001",
)
]
return []

View File

@ -13310,6 +13310,12 @@
"format": "uuid"
},
"title": "Device type restrictions"
},
"max_attempts": {
"type": "integer",
"minimum": 0,
"maximum": 2147483647,
"title": "Max attempts"
}
},
"required": []

View File

@ -1,6 +1,8 @@
version: 1
metadata:
name: OIDC conformance testing
name: OpenID Conformance testing
labels:
blueprints.goauthentik.io/instantiate: "false"
entries:
- identifiers:
managed: goauthentik.io/providers/oauth2/scope-address
@ -21,38 +23,72 @@ entries:
attrs:
name: "authentik default OAuth Mapping: OpenID 'phone'"
scope_name: phone
description: "General phone Information"
description: "General phone information"
expression: |
return {
"phone_number": "+1234",
"phone_number_verified": True,
}
- identifiers:
managed: goauthentik.io/providers/oauth2/scope-profile-oidc-standard
model: authentik_providers_oauth2.scopemapping
attrs:
name: "OIDC conformance profile"
scope_name: profile
description: "General profile information"
expression: |
return {
# Because authentik only saves the user's full name, and has no concept of first and last names,
# the full name is used as given name.
# You can override this behaviour in custom mappings, i.e. `request.user.name.split(" ")`
"name": request.user.name,
"given_name": request.user.name,
"preferred_username": request.user.username,
"nickname": request.user.username,
"groups": [group.name for group in request.user.ak_groups.all()],
"website" : "foo",
"zoneinfo" : "foo",
"birthdate" : "2000",
"gender" : "foo",
"profile" : "foo",
"middle_name" : "foo",
"locale" : "foo",
"picture" : "foo",
"updated_at" : 1748557810,
"family_name" : "foo",
}
- model: authentik_providers_oauth2.oauth2provider
id: provider
id: oidc-conformance-1
identifiers:
name: provider
name: oidc-conformance-1
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global
client_id: 4054d882aff59755f2f279968b97ce8806a926e1
client_secret: 4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867
redirect_uris: |
https://localhost:8443/test/a/authentik/callback
https://localhost.emobix.co.uk:8443/test/a/authentik/callback
redirect_uris:
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application
identifiers:
slug: conformance
slug: oidc-conformance-1
attrs:
provider: !KeyOf provider
name: Conformance
provider: !KeyOf oidc-conformance-1
name: OIDC Conformance (1)
- model: authentik_providers_oauth2.oauth2provider
id: oidc-conformance-2
@ -60,22 +96,27 @@ entries:
name: oidc-conformance-2
attrs:
authorization_flow: !Find [authentik_flows.flow, [slug, default-provider-authorization-implicit-consent]]
invalidation_flow: !Find [authentik_flows.flow, [slug, default-provider-invalidation-flow]]
# Required as OIDC Conformance test requires issues to be the same across multiple clients
issuer_mode: global
client_id: ad64aeaf1efe388ecf4d28fcc537e8de08bcae26
client_secret: ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789
redirect_uris: |
https://localhost:8443/test/a/authentik/callback
https://localhost.emobix.co.uk:8443/test/a/authentik/callback
redirect_uris:
- matching_mode: strict
url: https://localhost:8443/test/a/authentik/callback
- matching_mode: strict
url: https://host.docker.internal:8443/test/a/authentik/callback
property_mappings:
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-openid]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-email]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-profile-oidc-standard]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-address]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-phone]]
- !Find [authentik_providers_oauth2.scopemapping, [managed, goauthentik.io/providers/oauth2/scope-offline_access]]
signing_key: !Find [authentik_crypto.certificatekeypair, [name, authentik Self-signed Certificate]]
- model: authentik_core.application
identifiers:
slug: oidc-conformance-2
attrs:
provider: !KeyOf oidc-conformance-2
name: OIDC Conformance
name: OIDC Conformance (2)

4
go.mod
View File

@ -6,7 +6,7 @@ require (
beryju.io/ldap v0.1.0
github.com/avast/retry-go/v4 v4.6.1
github.com/coreos/go-oidc/v3 v3.14.1
github.com/getsentry/sentry-go v0.33.0
github.com/getsentry/sentry-go v0.34.0
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.11
github.com/go-openapi/runtime v0.28.0
@ -29,7 +29,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.2025062.3
goauthentik.io/api/v3 v3.2025062.4
golang.org/x/exp v0.0.0-20230210204819-062eb4c674ab
golang.org/x/oauth2 v0.30.0
golang.org/x/sync v0.15.0

8
go.sum
View File

@ -71,8 +71,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/getsentry/sentry-go v0.33.0 h1:YWyDii0KGVov3xOaamOnF0mjOrqSjBqwv48UEzn7QFg=
github.com/getsentry/sentry-go v0.33.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
github.com/getsentry/sentry-go v0.34.0 h1:1FCHBVp8TfSc8L10zqSwXUZNiOSF+10qw4czjarTiY4=
github.com/getsentry/sentry-go v0.34.0/go.mod h1:C55omcY9ChRQIUcVcGcs+Zdy4ZpQGvNJ7JYHIoSWOtE=
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=
@ -298,8 +298,8 @@ 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.2025062.3 h1:syBOKigaHyX/8Rwmh9kOSF+TzsxOzmP5i7rsFwbemzA=
goauthentik.io/api/v3 v3.2025062.3/go.mod h1:zz+mEZg8rY/7eEjkMGWJ2DnGqk+zqxuybGCGrR2O4Kw=
goauthentik.io/api/v3 v3.2025062.4 h1:HuyL12kKserXT2w+wCDUYNRSeyCCGX81wU9SRCPuxDo=
goauthentik.io/api/v3 v3.2025062.4/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=

View File

@ -10,7 +10,7 @@ from typing import Any
from psycopg import Connection, Cursor, connect
from structlog.stdlib import get_logger
from authentik.lib.config import CONFIG
from authentik.lib.config import CONFIG, django_db_config
LOGGER = get_logger()
ADV_LOCK_UID = 1000
@ -115,9 +115,13 @@ def run_migrations():
execute_from_command_line(["", "migrate_schemas"])
if CONFIG.get_bool("tenants.enabled", False):
execute_from_command_line(["", "migrate_schemas", "--schema", "template", "--tenant"])
execute_from_command_line(
["", "check"] + ([] if CONFIG.get_bool("debug") else ["--deploy"])
)
# Run django system checks for all databases
check_args = ["", "check"]
for label in django_db_config(CONFIG).keys():
check_args.append(f"--database={label}")
if not CONFIG.get_bool("debug"):
check_args.append("--deploy")
execute_from_command_line(check_args)
finally:
release_lock(curr)
curr.close()

View File

@ -576,17 +576,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
"integrity": "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
"integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/type-utils": "8.34.1",
"@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/type-utils": "8.35.0",
"@typescript-eslint/utils": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@ -600,7 +600,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.34.1",
"@typescript-eslint/parser": "^8.35.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
@ -616,16 +616,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.1.tgz",
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz",
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/typescript-estree": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"debug": "^4.3.4"
},
"engines": {
@ -641,14 +641,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.1.tgz",
"integrity": "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz",
"integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.34.1",
"@typescript-eslint/types": "^8.34.1",
"@typescript-eslint/tsconfig-utils": "^8.35.0",
"@typescript-eslint/types": "^8.35.0",
"debug": "^4.3.4"
},
"engines": {
@ -663,14 +663,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.1.tgz",
"integrity": "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz",
"integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1"
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -681,9 +681,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.1.tgz",
"integrity": "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz",
"integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==",
"dev": true,
"license": "MIT",
"engines": {
@ -698,14 +698,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.1.tgz",
"integrity": "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz",
"integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0",
"@typescript-eslint/utils": "8.35.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@ -722,9 +722,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz",
"integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz",
"integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -736,16 +736,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.1.tgz",
"integrity": "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz",
"integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.34.1",
"@typescript-eslint/tsconfig-utils": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/project-service": "8.35.0",
"@typescript-eslint/tsconfig-utils": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -804,16 +804,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.1.tgz",
"integrity": "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz",
"integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.34.1"
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/typescript-estree": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -828,13 +828,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.1.tgz",
"integrity": "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz",
"integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/types": "8.35.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -920,17 +920,19 @@
}
},
"node_modules/array-includes": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
"integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.4",
"is-string": "^1.0.7"
"es-abstract": "^1.24.0",
"es-object-atoms": "^1.1.1",
"get-intrinsic": "^1.3.0",
"is-string": "^1.1.1",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@ -1376,27 +1378,27 @@
}
},
"node_modules/es-abstract": {
"version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
"integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
"integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
"license": "MIT",
"dependencies": {
"array-buffer-byte-length": "^1.0.2",
"arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"call-bound": "^1.0.4",
"data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-object-atoms": "^1.1.1",
"es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8",
"get-intrinsic": "^1.2.7",
"get-proto": "^1.0.0",
"get-intrinsic": "^1.3.0",
"get-proto": "^1.0.1",
"get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
@ -1408,21 +1410,24 @@
"is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7",
"is-data-view": "^1.0.2",
"is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1",
"is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1",
"is-typed-array": "^1.1.15",
"is-weakref": "^1.1.0",
"is-weakref": "^1.1.1",
"math-intrinsics": "^1.1.0",
"object-inspect": "^1.13.3",
"object-inspect": "^1.13.4",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
"own-keys": "^1.0.1",
"regexp.prototype.flags": "^1.5.3",
"regexp.prototype.flags": "^1.5.4",
"safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0",
"stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8",
@ -1431,7 +1436,7 @@
"typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0",
"which-typed-array": "^1.1.18"
"which-typed-array": "^1.1.19"
},
"engines": {
"node": ">= 0.4"
@ -1634,9 +1639,9 @@
}
},
"node_modules/eslint-module-utils": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
"license": "MIT",
"dependencies": {
"debug": "^3.2.7"
@ -1660,29 +1665,29 @@
}
},
"node_modules/eslint-plugin-import": {
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"license": "MIT",
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
"array.prototype.findlastindex": "^1.2.5",
"array.prototype.flat": "^1.3.2",
"array.prototype.flatmap": "^1.3.2",
"array-includes": "^3.1.9",
"array.prototype.findlastindex": "^1.2.6",
"array.prototype.flat": "^1.3.3",
"array.prototype.flatmap": "^1.3.3",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.12.0",
"eslint-module-utils": "^2.12.1",
"hasown": "^2.0.2",
"is-core-module": "^2.15.1",
"is-core-module": "^2.16.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3",
"object.values": "^1.2.0",
"object.values": "^1.2.1",
"semver": "^6.3.1",
"string.prototype.trimend": "^1.0.8",
"string.prototype.trimend": "^1.0.9",
"tsconfig-paths": "^3.15.0"
},
"engines": {
@ -2501,6 +2506,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
"integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -3693,6 +3710,19 @@
"node": ">=10"
}
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"internal-slot": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/string.prototype.matchall": {
"version": "4.0.12",
"resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz",
@ -4035,15 +4065,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.1.tgz",
"integrity": "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz",
"integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/parser": "8.34.1",
"@typescript-eslint/utils": "8.34.1"
"@typescript-eslint/eslint-plugin": "8.35.0",
"@typescript-eslint/parser": "8.35.0",
"@typescript-eslint/utils": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -96,6 +96,7 @@ dev = [
"pytest-django==4.11.1",
"pytest-github-actions-annotate-failures==0.3.0",
"pytest-randomly==3.16.0",
"pytest-subtests>=0.14.1",
"pytest-timeout==2.4.0",
"requests-mock==1.12.1",
"ruff==0.11.9",

View File

@ -34963,6 +34963,10 @@ paths:
name: friendly_name
schema:
type: string
- in: query
name: max_attempts
schema:
type: integer
- in: query
name: name
schema:
@ -42633,6 +42637,10 @@ components:
items:
$ref: '#/components/schemas/WebAuthnDeviceType'
readOnly: true
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
required:
- component
- device_type_restrictions_obj
@ -42675,6 +42683,10 @@ components:
items:
type: string
format: uuid
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
required:
- name
AuthorizationCodeAuthMethodEnum:
@ -43941,7 +43953,7 @@ components:
- name
Device:
type: object
description: Serializer for Duo authenticator devices
description: Serializer for authenticator devices
properties:
verbose_name:
type: string
@ -43980,11 +43992,18 @@ components:
nullable: true
extra_description:
type: string
nullable: true
description: Get extra description
readOnly: true
external_id:
type: string
nullable: true
description: Get external Device ID
readOnly: true
required:
- confirmed
- created
- external_id
- extra_description
- last_updated
- last_used
@ -52625,6 +52644,10 @@ components:
items:
type: string
format: uuid
max_attempts:
type: integer
maximum: 2147483647
minimum: 0
PatchedBlueprintInstanceRequest:
type: object
description: Info about a single blueprint instance file

View File

@ -6,8 +6,10 @@ services:
- /dev/shm:/dev/shm
network_mode: host
restart: always
extra_hosts:
- "host.docker.internal:host-gateway"
mailpit:
image: docker.io/axllent/mailpit:v1.26.1
image: docker.io/axllent/mailpit:v1.26.2
ports:
- 1025:1025
- 8025:8025

View File

@ -165,6 +165,7 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
def _get_driver(self) -> WebDriver:
count = 0
opts = webdriver.ChromeOptions()
opts.accept_insecure_certs = True
opts.add_argument("--disable-search-engine-choice-screen")
# This breaks selenium when running remotely...?
# opts.set_capability("goog:loggingPrefs", {"browser": "ALL"})
@ -249,7 +250,6 @@ class SeleniumTestCase(DockerTestCase, StaticLiveServerTestCase):
def login(self, shadow_dom=True):
"""Do entire login flow"""
if shadow_dom:
flow_executor = self.get_shadow_root("ak-flow-executor")
identification_stage = self.get_shadow_root("ak-stage-identification", flow_executor)

View File

@ -1,8 +0,0 @@
# #Test files for OpenID Conformance testing.
These config files assume testing is being done using the [OpenID Conformance Suite
](https://openid.net/certification/about-conformance-suite/), locally.
See https://gitlab.com/openid/conformance-suite/-/wikis/Developers/Build-&-Run for running the conformance suite locally.
Requires docker containers to be able to access the host via `host.docker.internal` and an entry in the hosts file that maps `host.docker.internal` to localhost.

View File

@ -1,20 +0,0 @@
{
"alias": "authentik",
"description": "authentik",
"server": {
"discoveryUrl": "http://host.docker.internal:9000/application/o/conformance/.well-known/openid-configuration"
},
"client": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867"
},
"client_secret_post": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": "4c7e4933009437fb486b5389d15b173109a0555dc47e0cc0949104f1925bcc6565351cb1dffd7e6818cf074f5bd50c210b565121a7328ee8bd40107fc4bbd867"
},
"client2": {
"client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26",
"client_secret": "ff2e34a5b04c99acaf7241e25a950e7f6134c86936923d8c698d8f38bd57647750d661069612c0ee55045e29fe06aa101804bdae38e8360647d595e771fea789"
},
"consent": {}
}

View File

View File

@ -0,0 +1,156 @@
from json import dumps
from os import makedirs
from pathlib import Path
from time import sleep
from selenium.webdriver.common.by import By
from selenium.webdriver.support import expected_conditions as ec
from authentik.blueprints.tests import apply_blueprint, reconcile_app
from authentik.providers.oauth2.models import OAuth2Provider
from tests.e2e.utils import SeleniumTestCase
from tests.openid_conformance.conformance import Conformance
class TestOpenIDConformance(SeleniumTestCase):
conformance: Conformance
@apply_blueprint(
"default/flow-default-authentication-flow.yaml",
"default/flow-default-invalidation-flow.yaml",
)
@apply_blueprint(
"default/flow-default-provider-authorization-implicit-consent.yaml",
"default/flow-default-provider-invalidation.yaml",
)
@apply_blueprint("system/providers-oauth2.yaml")
@reconcile_app("authentik_crypto")
@apply_blueprint("testing/oidc-conformance.yaml")
def setUp(self):
super().setUp()
makedirs(Path(__file__).parent / "exports", exist_ok=True)
provider_a = OAuth2Provider.objects.get(
client_id="4054d882aff59755f2f279968b97ce8806a926e1"
)
provider_b = OAuth2Provider.objects.get(
client_id="ad64aeaf1efe388ecf4d28fcc537e8de08bcae26"
)
self.test_plan_config = {
"alias": "authentik",
"description": "authentik",
"server": {
"discoveryUrl": self.url(
"authentik_providers_oauth2:provider-info",
application_slug="oidc-conformance-1",
),
},
"client": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": provider_a.client_secret,
},
"client_secret_post": {
"client_id": "4054d882aff59755f2f279968b97ce8806a926e1",
"client_secret": provider_a.client_secret,
},
"client2": {
"client_id": "ad64aeaf1efe388ecf4d28fcc537e8de08bcae26",
"client_secret": provider_b.client_secret,
},
"consent": {},
}
self.test_variant = {
"server_metadata": "discovery",
"client_registration": "static_client",
}
def run_test(self, test_plan: str, test_plan_config: dict):
# Create a Conformance instance...
self.conformance = Conformance(f"https://{self.host}:8443/", None, verify_ssl=False)
test_plan = self.conformance.create_test_plan(
test_plan,
dumps(test_plan_config),
self.test_variant,
)
plan_id = test_plan["id"]
for test in test_plan["modules"]:
with self.subTest(test["testModule"], **test["variant"]):
# Fetch name and variant of the next test to run
module_name = test["testModule"]
variant = test["variant"]
module_instance = self.conformance.create_test_from_plan_with_variant(
plan_id, module_name, variant
)
module_id = module_instance["id"]
self.run_single_test(module_id)
self.conformance.wait_for_state(module_id, ["FINISHED"], timeout=self.wait_timeout)
sleep(2)
self.conformance.exporthtml(plan_id, Path(__file__).parent / "exports")
def run_single_test(self, module_id: str):
"""Process instructions for a single test, navigate to browser URLs and take screenshots"""
tested_browser_url = 0
uploaded_image = 0
cleared_cookies = False
while True:
# Fetch all info
test_status = self.conformance.get_test_status(module_id)
test_log = self.conformance.get_test_log(module_id)
test_info = self.conformance.get_module_info(module_id)
# Check status early, if we're finished already we don't want to do anything extra
if test_info["status"] in ["INTERRUPTED", "FINISHED"]:
return
# Check if we need to clear cookies - tests only indicates this in their written summary
# so this check is a bit brittle
if "cookies" in test_info["summary"] and not cleared_cookies:
# Navigate to our origin to delete cookies in the right context
self.driver.get(self.url("authentik_api:user-me") + "?format=json")
self.driver.delete_all_cookies()
cleared_cookies = True
# Check if we need deal with any browser URLs
browser_urls = test_status.get("browser", {}).get("urls", [])
if len(browser_urls) > tested_browser_url:
self.do_browser(browser_urls[tested_browser_url])
tested_browser_url += 1
continue
# Check if we need to upload any items
upload_items = [x for x in test_log if "upload" in x]
if len(upload_items) > uploaded_image:
screenshot = self.get_screenshot()
self.conformance.upload_image(
module_id, upload_items[uploaded_image]["upload"], screenshot
)
sleep(3)
uploaded_image += 1
continue
sleep(0.1)
def get_screenshot(self):
"""Get a screenshot, but resize the window first so we don't exceed 500kb"""
self.driver.set_window_size(800, 600)
screenshot = f"data:image/jpeg;base64,{self.driver.get_screenshot_as_base64()}"
self.driver.maximize_window()
return screenshot
def do_browser(self, url):
"""For any specific OpenID Conformance test, execute the operations required"""
self.driver.get(url)
should_expect_completion = False
if "if/flow/default-authentication-flow" in self.driver.current_url:
self.logger.debug("Logging in")
self.login()
should_expect_completion = True
if "prompt=consent" in url or "offline_access" in url:
self.logger.debug("Authorizing")
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "ak-flow-executor")))
sleep(1)
flow_executor = self.get_shadow_root("ak-flow-executor")
consent_stage = self.get_shadow_root("ak-stage-consent", flow_executor)
consent_stage.find_element(
By.CSS_SELECTOR,
"[type=submit]",
).click()
should_expect_completion = True
if should_expect_completion:
self.wait.until(ec.presence_of_element_located((By.CSS_SELECTOR, "#complete")))

View File

@ -0,0 +1,29 @@
services:
mongodb:
image: mongo:6.0.13
httpd:
image: ghcr.io/beryju/oidc-conformance-suite-httpd:v5.1.32
ports:
- "8443:8443"
- "8444:8444"
depends_on:
- server
server:
image: ghcr.io/beryju/oidc-conformance-suite-server:v5.1.32
ports:
- "9999:9999"
extra_hosts:
- "host.docker.internal:host-gateway"
command: >
java
-Xdebug -Xrunjdwp:transport=dt_socket,address=*:9999,server=y,suspend=n
-jar /server/fapi-test-suite.jar
-Djdk.tls.maxHandshakeMessageSize=65536
--fintechlabs.base_url=https://host.docker.internal:8443
--fintechlabs.base_mtls_url=https://host.docker.internal:8444
--fintechlabs.devmode=true
--fintechlabs.startredir=true
links:
- mongodb:mongodb
depends_on:
- mongodb

View File

@ -0,0 +1,192 @@
import json
import os
import re
import time
import zipfile
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
class Conformance:
HTTP_OK = 200
HTTP_CREATED = 201
def __init__(self, api_url_base, api_token, verify_ssl):
if not api_url_base.endswith("/"):
api_url_base += "/"
self.api_url_base = api_url_base
self.session = requests.Session()
self.session.verify = verify_ssl
retries = Retry(
total=5,
backoff_factor=1,
status_forcelist=[500, 502, 503, 504],
allowed_methods=["GET", "POST"],
)
self.session.mount("https://", HTTPAdapter(max_retries=retries))
self.session.mount("http://", HTTPAdapter(max_retries=retries))
self.session.headers.update({"Content-Type": "application/json"})
if api_token is not None:
self.session.headers.update({"Authorization": f"Bearer {api_token}"})
def get_all_test_modules(self):
url = f"{self.api_url_base}api/runner/available"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_all_test_modules failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def get_test_status(self, module_id):
url = f"{self.api_url_base}api/runner/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_test_status failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def exporthtml(self, plan_id, path):
for _ in range(5):
url = f"{self.api_url_base}api/plan/exporthtml/{plan_id}"
try:
with self.session.get(url, stream=True) as response:
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"exporthtml failed - HTTP {response.status_code} {response.content}"
)
cd = response.headers.get("content-disposition", "")
local_filename = re.findall('filename="(.+)"', cd)[0]
full_path = os.path.join(path, local_filename)
with open(full_path, "wb") as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
zip_file = zipfile.ZipFile(full_path)
ret = zip_file.testzip()
if ret is not None:
raise Exception(f"exporthtml returned corrupt zip file: {ret}")
return full_path
except Exception as e:
print(f"requests {url} exception {e} caught - retrying")
time.sleep(1)
raise Exception(f"exporthtml for {plan_id} failed even after retries")
def create_certification_package(
self, plan_id, conformance_pdf_path, rp_logs_zip_path=None, output_zip_directory="./"
):
with (
open(conformance_pdf_path, "rb") as cert_pdf,
open(rp_logs_zip_path, "rb") if rp_logs_zip_path else open(os.devnull, "rb") as rp_logs,
):
files = {
"certificationOfConformancePdf": cert_pdf,
"clientSideData": rp_logs,
}
headers = self.session.headers.copy()
headers.pop("Content-Type", None)
url = f"{self.api_url_base}api/plan/{plan_id}/certificationpackage"
response = self.session.post(url, files=files, headers=headers)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"certificationpackage failed - HTTP {response.status_code} {response.content}"
)
cd = response.headers.get("content-disposition", "")
local_filename = re.findall('filename="(.+)"', cd)[0]
full_path = os.path.join(output_zip_directory, local_filename)
with open(full_path, "wb") as f:
f.write(response.content)
print(f"Certification package zip for plan id {plan_id} written to {full_path}")
def create_test_plan(self, name, configuration, variant=None):
url = f"{self.api_url_base}api/plan"
payload = {"planName": name}
if variant is not None:
payload["variant"] = json.dumps(variant)
response = self.session.post(url, params=payload, data=configuration)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
f"create_test_plan failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def create_test(self, test_name, configuration):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name}
response = self.session.post(url, params=payload, data=configuration)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(f"create_test failed - HTTP {response.status_code} {response.content}")
return response.json()
def create_test_from_plan(self, plan_id, test_name):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name, "plan": plan_id}
response = self.session.post(url, params=payload)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
f"create_test_from_plan failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def create_test_from_plan_with_variant(self, plan_id, test_name, variant):
url = f"{self.api_url_base}api/runner"
payload = {"test": test_name, "plan": plan_id}
if variant is not None:
payload["variant"] = json.dumps(variant)
response = self.session.post(url, params=payload)
if response.status_code != Conformance.HTTP_CREATED:
raise Exception(
"create_test_from_plan_with_variant failed - "
f"HTTP {response.status_code} {response.content}"
)
return response.json()
def get_module_info(self, module_id):
url = f"{self.api_url_base}api/info/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(
f"get_module_info failed - HTTP {response.status_code} {response.content}"
)
return response.json()
def get_test_log(self, module_id):
url = f"{self.api_url_base}api/log/{module_id}"
response = self.session.get(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"get_test_log failed - HTTP {response.status_code} {response.content}")
return response.json()
def upload_image(self, log_id, placeholder, data):
url = f"{self.api_url_base}api/log/{log_id}/images/{placeholder}"
response = self.session.post(url, data=data, headers={"Content-Type": "text/plain"})
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"upload_image failed - HTTP {response.status_code} {response.content}")
def start_test(self, module_id):
url = f"{self.api_url_base}api/runner/{module_id}"
response = self.session.post(url)
if response.status_code != Conformance.HTTP_OK:
raise Exception(f"start_test failed - HTTP {response.status_code} {response.content}")
return response.json()
def wait_for_state(self, module_id, required_states, timeout=240):
timeout_at = time.time() + timeout
while time.time() < timeout_at:
info = self.get_module_info(module_id)
status = info.get("status")
if status in required_states:
return status
if status == "INTERRUPTED":
raise Exception(f"Test module {module_id} has moved to INTERRUPTED")
time.sleep(1)
raise Exception(
f"Timed out waiting for test module {module_id} "
f"to be in one of states: {required_states}"
)

View File

@ -0,0 +1,10 @@
from tests.e2e.utils import retry
from tests.openid_conformance.base import TestOpenIDConformance
class TestOpenIDConformanceBasic(TestOpenIDConformance):
@retry()
def test_oidcc_basic_certification_test(self):
test_plan_name = "oidcc-basic-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)

View File

@ -0,0 +1,10 @@
from tests.e2e.utils import retry
from tests.openid_conformance.base import TestOpenIDConformance
class TestOpenIDConformanceBasic(TestOpenIDConformance):
@retry()
def test_oidcc_basic_certification_test(self):
test_plan_name = "oidcc-basic-certification-test-plan"
self.run_test(test_plan_name, self.test_plan_config)

15
uv.lock generated
View File

@ -259,6 +259,7 @@ dev = [
{ name = "pytest-django" },
{ name = "pytest-github-actions-annotate-failures" },
{ name = "pytest-randomly" },
{ name = "pytest-subtests" },
{ name = "pytest-timeout" },
{ name = "requests-mock" },
{ name = "ruff" },
@ -358,6 +359,7 @@ dev = [
{ name = "pytest-django", specifier = "==4.11.1" },
{ name = "pytest-github-actions-annotate-failures", specifier = "==0.3.0" },
{ name = "pytest-randomly", specifier = "==3.16.0" },
{ name = "pytest-subtests", specifier = ">=0.14.1" },
{ name = "pytest-timeout", specifier = "==2.4.0" },
{ name = "requests-mock", specifier = "==1.12.1" },
{ name = "ruff", specifier = "==0.11.9" },
@ -2685,6 +2687,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/22/70/b31577d7c46d8e2f9baccfed5067dd8475262a2331ffb0bfdf19361c9bde/pytest_randomly-3.16.0-py3-none-any.whl", hash = "sha256:8633d332635a1a0983d3bba19342196807f6afb17c3eef78e02c2f85dade45d6", size = 8396, upload-time = "2024-10-25T15:45:32.78Z" },
]
[[package]]
name = "pytest-subtests"
version = "0.14.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c0/4c/ba9eab21a2250c2d46c06c0e3cd316850fde9a90da0ac8d0202f074c6817/pytest_subtests-0.14.1.tar.gz", hash = "sha256:350c00adc36c3aff676a66135c81aed9e2182e15f6c3ec8721366918bbbf7580", size = 17632, upload-time = "2024-12-10T00:21:04.856Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/b7/7ca948d35642ae72500efda6ba6fa61dcb6683feb596d19c4747c63c0789/pytest_subtests-0.14.1-py3-none-any.whl", hash = "sha256:e92a780d98b43118c28a16044ad9b841727bd7cb6a417073b38fd2d7ccdf052d", size = 8833, upload-time = "2024-12-10T00:20:58.873Z" },
]
[[package]]
name = "pytest-timeout"
version = "2.4.0"

304
web/package-lock.json generated
View File

@ -22,7 +22,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^6.7.2",
"@goauthentik/api": "^2025.6.2-1750246811",
"@goauthentik/api": "^2025.6.2-1750801939",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -34,7 +34,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^9.30.0",
"@sentry/browser": "^9.31.0",
"@spotlightjs/spotlight": "^3.0.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
@ -126,7 +126,7 @@
"storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1",
"typescript-eslint": "^8.35.0",
"vite-plugin-lit-css": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1",
"wireit": "^0.14.12"
@ -1731,9 +1731,9 @@
}
},
"node_modules/@goauthentik/api": {
"version": "2025.6.2-1750246811",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750246811.tgz",
"integrity": "sha512-ENHEi3kGAodf5tKQb5kziUrT1EcJw3z8tp2mU7LWqNlXr4eoAI15BjDfH5DW56l4jy3xKqTd+R2Ntnj4hiVhHw=="
"version": "2025.6.2-1750801939",
"resolved": "https://registry.npmjs.org/@goauthentik/api/-/api-2025.6.2-1750801939.tgz",
"integrity": "sha512-3s0pE6enhLEWVMD+zClORktBhUAw1vO/lCG0ATqm6xqbTfqGxPYWj5XMzYuX7+a2axxn1BFE134afWmdzDhThw=="
},
"node_modules/@goauthentik/core": {
"resolved": "packages/core",
@ -4561,75 +4561,75 @@
"dev": true
},
"node_modules/@sentry-internal/browser-utils": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.30.0.tgz",
"integrity": "sha512-e6ZlN8oWheCB0YJSGlBNUlh6UPnY5Ecj1P+/cgeKBhNm7c3bIx4J50485hB8LQsu+b7Q11L2o/wucZ//Pb6FCg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-9.31.0.tgz",
"integrity": "sha512-rviu/jUmeQbY4rSO8l4pubOtRIhFtH5Gu/ryRNMTlpJRdomp4uxddqthHUDH5g6xCXZsMTyJEIdx0aTqbgr/GQ==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.30.0"
"@sentry/core": "9.31.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.30.0.tgz",
"integrity": "sha512-qAZ7xxLqZM7GlEvmSUmTHnoueg+fc7esMQD4vH8pS7HI3n9C5MjGn3HHlndRpD8lL7iUUQ0TPZQgU6McbzMDyw==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-9.31.0.tgz",
"integrity": "sha512-Ygi/8UZ7p2B4DhXQjZDtOc45vNUHkfk2XETBTBGkByEQkE8vygzSiKhgRcnVpzwq+8xKFMRy+PxvpcCo+PNQew==",
"license": "MIT",
"dependencies": {
"@sentry/core": "9.30.0"
"@sentry/core": "9.31.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.30.0.tgz",
"integrity": "sha512-+6wkqQGLJuFUzvGRzbh3iIhFGyxQx/Oxc0ODDKmz9ag2xYRjCYb3UUQXmQX9navAF0HXUsq8ajoJPm2L1ZyWVg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-9.31.0.tgz",
"integrity": "sha512-V5rvcO/xSj8JMw4ZnZT2cBYC+UOuIiZ2Flj4EoIurxMrTgowE1uMXUBA32EBfuB5/vQSJXB6W5uAudhk7LjBPQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.30.0",
"@sentry/core": "9.30.0"
"@sentry-internal/browser-utils": "9.31.0",
"@sentry/core": "9.31.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.30.0.tgz",
"integrity": "sha512-I4MxS27rfV7vnOU29L80y4baZ4I1XqpnYvC/yLN7C17nA8eDCufQ8WVomli41y8JETnfcxlm68z7CS0sO4RCSA==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-9.31.0.tgz",
"integrity": "sha512-VGqfvQCIuXQZeecrBf8bd4sj8lYGzUA/2CffTAkad1nB1Onyz0Kzo54qLWemivCxA3ufHf6DCpNA3Loa/0ywFQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "9.30.0",
"@sentry/core": "9.30.0"
"@sentry-internal/replay": "9.31.0",
"@sentry/core": "9.31.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/browser": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.30.0.tgz",
"integrity": "sha512-sRyW6A9nIieTTI26MYXk1DmWEhmphTjZevusNWla+vvUigCmSjuH+xZw19w43OyvF3bu261Skypnm/mAalOTwg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-9.31.0.tgz",
"integrity": "sha512-DzG72JJTqHzE0Qo2fHeHm3xgFs97InaSQStmTMxOA59yPqvAXbweNPcsgCNu1q76+jZyaJcoy1qOwahnLuEVDg==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "9.30.0",
"@sentry-internal/feedback": "9.30.0",
"@sentry-internal/replay": "9.30.0",
"@sentry-internal/replay-canvas": "9.30.0",
"@sentry/core": "9.30.0"
"@sentry-internal/browser-utils": "9.31.0",
"@sentry-internal/feedback": "9.31.0",
"@sentry-internal/replay": "9.31.0",
"@sentry-internal/replay-canvas": "9.31.0",
"@sentry/core": "9.31.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/core": {
"version": "9.30.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.30.0.tgz",
"integrity": "sha512-JfEpeQ8a1qVJEb9DxpFTFy1J1gkNdlgKAPiqYGNnm4yQbnfl2Kb/iEo1if70FkiHc52H8fJwISEF90pzMm6lPg==",
"version": "9.31.0",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-9.31.0.tgz",
"integrity": "sha512-6JeoPGvBgT9m2YFIf2CrW+KrrOYzUqb9+Xwr/Dw25kPjVKy+WJjWqK8DKCNLgkBA22OCmSOmHuRwFR0YxGVdZQ==",
"license": "MIT",
"engines": {
"node": ">=18"
@ -7415,17 +7415,17 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.1.tgz",
"integrity": "sha512-STXcN6ebF6li4PxwNeFnqF8/2BNDvBupf2OPx2yWNzr6mKNGF7q49VM00Pz5FaomJyqvbXpY6PhO+T9w139YEQ==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.0.tgz",
"integrity": "sha512-ijItUYaiWuce0N1SoSMrEd0b6b6lYkYt99pqCPfybd+HKVXtEvYhICfLdwp42MhiI5mp0oq7PKEL+g1cNiz/Eg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.10.0",
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/type-utils": "8.34.1",
"@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/type-utils": "8.35.0",
"@typescript-eslint/utils": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"graphemer": "^1.4.0",
"ignore": "^7.0.0",
"natural-compare": "^1.4.0",
@ -7439,7 +7439,7 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.34.1",
"@typescript-eslint/parser": "^8.35.0",
"eslint": "^8.57.0 || ^9.0.0",
"typescript": ">=4.8.4 <5.9.0"
}
@ -7455,16 +7455,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.1.tgz",
"integrity": "sha512-4O3idHxhyzjClSMJ0a29AcoK0+YwnEqzI6oz3vlRf3xw0zbzt15MzXwItOlnr5nIth6zlY2RENLsOPvhyrKAQA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.0.tgz",
"integrity": "sha512-6sMvZePQrnZH2/cJkwRpkT7DxoAWh+g6+GFRK6bV3YQo7ogi3SX5rgF6099r5Q53Ma5qeT7LGmOmuIutF4t3lA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/typescript-estree": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"debug": "^4.3.4"
},
"engines": {
@ -7480,14 +7480,14 @@
}
},
"node_modules/@typescript-eslint/project-service": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.1.tgz",
"integrity": "sha512-nuHlOmFZfuRwLJKDGQOVc0xnQrAmuq1Mj/ISou5044y1ajGNp2BNliIqp7F2LPQ5sForz8lempMFCovfeS1XoA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.0.tgz",
"integrity": "sha512-41xatqRwWZuhUMF/aZm2fcUsOFKNcG28xqRSS6ZVr9BVJtGExosLAm5A1OxTjRMagx8nJqva+P5zNIGt8RIgbQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/tsconfig-utils": "^8.34.1",
"@typescript-eslint/types": "^8.34.1",
"@typescript-eslint/tsconfig-utils": "^8.35.0",
"@typescript-eslint/types": "^8.35.0",
"debug": "^4.3.4"
},
"engines": {
@ -7502,14 +7502,14 @@
}
},
"node_modules/@typescript-eslint/scope-manager": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.1.tgz",
"integrity": "sha512-beu6o6QY4hJAgL1E8RaXNC071G4Kso2MGmJskCFQhRhg8VOH/FDbC8soP8NHN7e/Hdphwp8G8cE6OBzC8o41ZA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.0.tgz",
"integrity": "sha512-+AgL5+mcoLxl1vGjwNfiWq5fLDZM1TmTPYs2UkyHfFhgERxBbqHlNjRzhThJqz+ktBqTChRYY6zwbMwy0591AA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1"
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7520,9 +7520,9 @@
}
},
"node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.1.tgz",
"integrity": "sha512-K4Sjdo4/xF9NEeA2khOb7Y5nY6NSXBnod87uniVYW9kHP+hNlDV8trUSFeynA2uxWam4gIWgWoygPrv9VMWrYg==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.0.tgz",
"integrity": "sha512-04k/7247kZzFraweuEirmvUj+W3bJLI9fX6fbo1Qm2YykuBvEhRTPl8tcxlYO8kZZW+HIXfkZNoasVb8EV4jpA==",
"dev": true,
"license": "MIT",
"engines": {
@ -7537,14 +7537,14 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.1.tgz",
"integrity": "sha512-Tv7tCCr6e5m8hP4+xFugcrwTOucB8lshffJ6zf1mF1TbU67R+ntCc6DzLNKM+s/uzDyv8gLq7tufaAhIBYeV8g==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.0.tgz",
"integrity": "sha512-ceNNttjfmSEoM9PW87bWLDEIaLAyR+E6BoYJQ5PfaDau37UGca9Nyq3lBk8Bw2ad0AKvYabz6wxc7DMTO2jnNA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/typescript-estree": "8.34.1",
"@typescript-eslint/utils": "8.34.1",
"@typescript-eslint/typescript-estree": "8.35.0",
"@typescript-eslint/utils": "8.35.0",
"debug": "^4.3.4",
"ts-api-utils": "^2.1.0"
},
@ -7561,9 +7561,9 @@
}
},
"node_modules/@typescript-eslint/types": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.1.tgz",
"integrity": "sha512-rjLVbmE7HR18kDsjNIZQHxmv9RZwlgzavryL5Lnj2ujIRTeXlKtILHgRNmQ3j4daw7zd+mQgy+uyt6Zo6I0IGA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.0.tgz",
"integrity": "sha512-0mYH3emanku0vHw2aRLNGqe7EXh9WHEhi7kZzscrMDf6IIRUQ5Jk4wp1QrledE/36KtdZrVfKnE32eZCf/vaVQ==",
"dev": true,
"license": "MIT",
"engines": {
@ -7575,16 +7575,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.1.tgz",
"integrity": "sha512-rjCNqqYPuMUF5ODD+hWBNmOitjBWghkGKJg6hiCHzUvXRy6rK22Jd3rwbP2Xi+R7oYVvIKhokHVhH41BxPV5mA==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.0.tgz",
"integrity": "sha512-F+BhnaBemgu1Qf8oHrxyw14wq6vbL8xwWKKMwTMwYIRmFFY/1n/9T/jpbobZL8vp7QyEUcC6xGrnAO4ua8Kp7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/project-service": "8.34.1",
"@typescript-eslint/tsconfig-utils": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/visitor-keys": "8.34.1",
"@typescript-eslint/project-service": "8.35.0",
"@typescript-eslint/tsconfig-utils": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/visitor-keys": "8.35.0",
"debug": "^4.3.4",
"fast-glob": "^3.3.2",
"is-glob": "^4.0.3",
@ -7604,16 +7604,16 @@
}
},
"node_modules/@typescript-eslint/utils": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.1.tgz",
"integrity": "sha512-mqOwUdZ3KjtGk7xJJnLbHxTuWVn3GO2WZZuM+Slhkun4+qthLdXx32C8xIXbO1kfCECb3jIs3eoxK3eryk7aoQ==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.0.tgz",
"integrity": "sha512-nqoMu7WWM7ki5tPgLVsmPM8CkqtoPUG6xXGeefM5t4x3XumOEKMoUZPdi+7F+/EotukN4R9OWdmDxN80fqoZeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.7.0",
"@typescript-eslint/scope-manager": "8.34.1",
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/typescript-estree": "8.34.1"
"@typescript-eslint/scope-manager": "8.35.0",
"@typescript-eslint/types": "8.35.0",
"@typescript-eslint/typescript-estree": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@ -7628,13 +7628,13 @@
}
},
"node_modules/@typescript-eslint/visitor-keys": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.1.tgz",
"integrity": "sha512-xoh5rJ+tgsRKoXnkBPFRLZ7rjKM0AfVbC68UZ/ECXoDbfggb9RbEySN359acY1vS3qZ0jVTVWzbtfapwm5ztxw==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.0.tgz",
"integrity": "sha512-zTh2+1Y8ZpmeQaQVIc/ZZxsx8UzgKJyNg1PTvjzC7WMhPSVS8bfDX34k1SrwOf016qd5RU3az2UxUNue3IfQ5g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.34.1",
"@typescript-eslint/types": "8.35.0",
"eslint-visitor-keys": "^4.2.1"
},
"engines": {
@ -10380,18 +10380,20 @@
}
},
"node_modules/array-includes": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz",
"integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==",
"version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
"integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.4",
"define-properties": "^1.2.1",
"es-abstract": "^1.23.2",
"es-object-atoms": "^1.0.0",
"get-intrinsic": "^1.2.4",
"is-string": "^1.0.7"
"es-abstract": "^1.24.0",
"es-object-atoms": "^1.1.1",
"get-intrinsic": "^1.3.0",
"is-string": "^1.1.1",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
@ -13642,9 +13644,9 @@
"license": "MIT"
},
"node_modules/es-abstract": {
"version": "1.23.9",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz",
"integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==",
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.0.tgz",
"integrity": "sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -13652,18 +13654,18 @@
"arraybuffer.prototype.slice": "^1.0.4",
"available-typed-arrays": "^1.0.7",
"call-bind": "^1.0.8",
"call-bound": "^1.0.3",
"call-bound": "^1.0.4",
"data-view-buffer": "^1.0.2",
"data-view-byte-length": "^1.0.2",
"data-view-byte-offset": "^1.0.1",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.0.0",
"es-object-atoms": "^1.1.1",
"es-set-tostringtag": "^2.1.0",
"es-to-primitive": "^1.3.0",
"function.prototype.name": "^1.1.8",
"get-intrinsic": "^1.2.7",
"get-proto": "^1.0.0",
"get-intrinsic": "^1.3.0",
"get-proto": "^1.0.1",
"get-symbol-description": "^1.1.0",
"globalthis": "^1.0.4",
"gopd": "^1.2.0",
@ -13675,21 +13677,24 @@
"is-array-buffer": "^3.0.5",
"is-callable": "^1.2.7",
"is-data-view": "^1.0.2",
"is-negative-zero": "^2.0.3",
"is-regex": "^1.2.1",
"is-set": "^2.0.3",
"is-shared-array-buffer": "^1.0.4",
"is-string": "^1.1.1",
"is-typed-array": "^1.1.15",
"is-weakref": "^1.1.0",
"is-weakref": "^1.1.1",
"math-intrinsics": "^1.1.0",
"object-inspect": "^1.13.3",
"object-inspect": "^1.13.4",
"object-keys": "^1.1.1",
"object.assign": "^4.1.7",
"own-keys": "^1.0.1",
"regexp.prototype.flags": "^1.5.3",
"regexp.prototype.flags": "^1.5.4",
"safe-array-concat": "^1.1.3",
"safe-push-apply": "^1.0.0",
"safe-regex-test": "^1.1.0",
"set-proto": "^1.0.0",
"stop-iteration-iterator": "^1.1.0",
"string.prototype.trim": "^1.2.10",
"string.prototype.trimend": "^1.0.9",
"string.prototype.trimstart": "^1.0.8",
@ -13698,7 +13703,7 @@
"typed-array-byte-offset": "^1.0.4",
"typed-array-length": "^1.0.7",
"unbox-primitive": "^1.1.0",
"which-typed-array": "^1.1.18"
"which-typed-array": "^1.1.19"
},
"engines": {
"node": ">= 0.4"
@ -14623,9 +14628,9 @@
}
},
"node_modules/eslint-module-utils": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz",
"integrity": "sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==",
"version": "2.12.1",
"resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz",
"integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -14651,30 +14656,30 @@
}
},
"node_modules/eslint-plugin-import": {
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz",
"integrity": "sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==",
"version": "2.32.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz",
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.8",
"array.prototype.findlastindex": "^1.2.5",
"array.prototype.flat": "^1.3.2",
"array.prototype.flatmap": "^1.3.2",
"array-includes": "^3.1.9",
"array.prototype.findlastindex": "^1.2.6",
"array.prototype.flat": "^1.3.3",
"array.prototype.flatmap": "^1.3.3",
"debug": "^3.2.7",
"doctrine": "^2.1.0",
"eslint-import-resolver-node": "^0.3.9",
"eslint-module-utils": "^2.12.0",
"eslint-module-utils": "^2.12.1",
"hasown": "^2.0.2",
"is-core-module": "^2.15.1",
"is-core-module": "^2.16.1",
"is-glob": "^4.0.3",
"minimatch": "^3.1.2",
"object.fromentries": "^2.0.8",
"object.groupby": "^1.0.3",
"object.values": "^1.2.0",
"object.values": "^1.2.1",
"semver": "^6.3.1",
"string.prototype.trimend": "^1.0.8",
"string.prototype.trimend": "^1.0.9",
"tsconfig-paths": "^3.15.0"
},
"engines": {
@ -17382,9 +17387,10 @@
}
},
"node_modules/is-core-module": {
"version": "2.15.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz",
"integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==",
"version": "2.16.1",
"resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz",
"integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==",
"license": "MIT",
"dependencies": {
"hasown": "^2.0.2"
},
@ -17563,6 +17569,19 @@
"integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==",
"dev": true
},
"node_modules/is-negative-zero": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz",
"integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
@ -24021,14 +24040,17 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz",
"integrity": "sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==",
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bind": "^1.0.7",
"call-bind": "^1.0.8",
"define-properties": "^1.2.1",
"es-errors": "^1.3.0",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"set-function-name": "^2.0.2"
},
"engines": {
@ -25530,6 +25552,20 @@
"dev": true,
"optional": true
},
"node_modules/stop-iteration-iterator": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz",
"integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"internal-slot": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/storybook": {
"version": "8.6.14",
"resolved": "https://registry.npmjs.org/storybook/-/storybook-8.6.14.tgz",
@ -27181,15 +27217,15 @@
}
},
"node_modules/typescript-eslint": {
"version": "8.34.1",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.1.tgz",
"integrity": "sha512-XjS+b6Vg9oT1BaIUfkW3M3LvqZE++rbzAMEHuccCfO/YkP43ha6w3jTEMilQxMF92nVOYCcdjv1ZUhAa1D/0ow==",
"version": "8.35.0",
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.0.tgz",
"integrity": "sha512-uEnz70b7kBz6eg/j0Czy6K5NivaYopgxRjsnAJ2Fx5oTLo3wefTHIbL7AkQr1+7tJCRVpTs/wiM8JR/11Loq9A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/eslint-plugin": "8.34.1",
"@typescript-eslint/parser": "8.34.1",
"@typescript-eslint/utils": "8.34.1"
"@typescript-eslint/eslint-plugin": "8.35.0",
"@typescript-eslint/parser": "8.35.0",
"@typescript-eslint/utils": "8.35.0"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"

View File

@ -93,7 +93,7 @@
"@floating-ui/dom": "^1.6.11",
"@formatjs/intl-listformat": "^7.7.11",
"@fortawesome/fontawesome-free": "^6.7.2",
"@goauthentik/api": "^2025.6.2-1750246811",
"@goauthentik/api": "^2025.6.2-1750801939",
"@lit/context": "^1.1.2",
"@lit/localize": "^0.12.2",
"@lit/reactive-element": "^2.0.4",
@ -105,7 +105,7 @@
"@openlayers-elements/maps": "^0.4.0",
"@patternfly/elements": "^4.1.0",
"@patternfly/patternfly": "^4.224.2",
"@sentry/browser": "^9.30.0",
"@sentry/browser": "^9.31.0",
"@spotlightjs/spotlight": "^3.0.1",
"@webcomponents/webcomponentsjs": "^2.8.0",
"base64-js": "^1.5.1",
@ -197,7 +197,7 @@
"storybook-addon-mock": "^5.0.0",
"turnstile-types": "^1.2.3",
"typescript": "^5.8.3",
"typescript-eslint": "^8.34.1",
"typescript-eslint": "^8.35.0",
"vite-plugin-lit-css": "^2.0.0",
"vite-tsconfig-paths": "^5.0.1",
"wireit": "^0.14.12"

View File

@ -64,7 +64,7 @@ export const EntryPoint = /** @type {const} */ ({
in: resolve(PackageRoot, "src", "flow", "index.entrypoint.ts"),
out: resolve(DistDirectory, "flow", "FlowInterface"),
},
Standalone: {
StandaloneAPI: {
in: resolve(PackageRoot, "src", "standalone", "api-browser/index.entrypoint.ts"),
out: resolve(DistDirectory, "standalone", "api-browser", "index"),
},

View File

@ -64,7 +64,7 @@ export class AdminOverviewPage extends AdminOverviewBase {
}
quickActions: QuickAction[] = [
[msg("Create a new application"), paramURL("/core/applications", { createForm: true })],
[msg("Create a new application"), paramURL("/core/applications", { createWizard: true })],
[msg("Check the logs"), paramURL("/events/log")],
[msg("Explore integrations"), "https://goauthentik.io/integrations/", true],
[msg("Manage users"), paramURL("/identity/users")],

View File

@ -89,7 +89,7 @@ export class RecentEventsCard extends Table<Event> {
return super.renderEmpty(
html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span>
><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`,
);

View File

@ -112,7 +112,7 @@ export class ApplicationViewPage extends AKElement {
renderApp(): TemplateResult {
if (!this.application) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`;
return html`<ak-empty-state default-label></ak-empty-state>`;
}
return html`<ak-tabs>
${this.missingOutpost

View File

@ -118,13 +118,12 @@ export class ApplicationEntitlementsPage extends Table<ApplicationEntitlement> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state
header=${msg("No app entitlements created.")}
icon="pf-icon-module"
>
html`<ak-empty-state icon="pf-icon-module"
><span>${msg("No app entitlements created.")}</span>
<div slot="body">
${msg(
"This application does currently not have any application entitlement defined.",
"This application does currently not have any application entitlements defined.",
)}
</div>
<div slot="primary"></div>

View File

@ -116,7 +116,7 @@ export class ApplicationWizardBindingsStep extends ApplicationWizardStep {
.content=${[]}
></ak-select-table>
<ak-empty-state icon="pf-icon-module"
><span slot="header">${msg("No bound policies.")} </span>
><span>${msg("No bound policies.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary">
<button

View File

@ -83,7 +83,7 @@ export class ApplicationWizardProviderChoiceStep extends WithLicenseSummary(Appl
}}
></ak-wizard-page-type-create>
</form>`
: html`<ak-empty-state loading header=${msg("Loading")}></ak-empty-state>`;
: html`<ak-empty-state default-label></ak-empty-state>`;
}
}

View File

@ -109,10 +109,8 @@ export class EnterpriseLicenseListPage extends TablePage<License> {
return super.renderEmpty(html`
${inner
? inner
: html`<ak-empty-state
icon=${this.pageIcon()}
header="${msg("No licenses found.")}"
>
: html`<ak-empty-state icon=${this.pageIcon()}
><span>${msg("No licenses found.")}</span>
<div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div>

View File

@ -136,7 +136,7 @@ export class BoundStagesList extends Table<FlowStageBinding> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module">
<span slot="header">${msg("No Stages bound")}</span>
<span>${msg("No Stages bound")}</span>
<div slot="body">${msg("No stages are currently bound to this flow.")}</div>
<div slot="primary">
<ak-stage-wizard

View File

@ -199,7 +199,7 @@ export class BoundPoliciesList extends Table<PolicyBinding> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state icon="pf-icon-module"
><span slot="header">${msg("No Policies bound.")}</span>
><span>${msg("No Policies bound.")}</span>
<div slot="body">${msg("No policies are currently bound to this object.")}</div>
<div slot="primary">
<ak-policy-wizard

View File

@ -42,7 +42,7 @@ export class ProviderViewPage extends AKElement {
renderProvider(): TemplateResult {
if (!this.provider) {
return html`<ak-empty-state loading fullHeight></ak-empty-state>`;
return html`<ak-empty-state loading full-height></ak-empty-state>`;
}
switch (this.provider?.component) {
case "ak-provider-saml-form":

View File

@ -34,7 +34,7 @@ export class SourceViewPage extends AKElement {
renderSource(): TemplateResult {
if (!this.source) {
return html`<ak-empty-state loading fullHeight></ak-empty-state>`;
return html`<ak-empty-state loading full-height></ak-empty-state>`;
}
switch (this.source?.component) {
case "ak-source-kerberos-form":

View File

@ -2,6 +2,7 @@ import { RenderFlowOption } from "@goauthentik/admin/flows/utils";
import { BaseStageForm } from "@goauthentik/admin/stages/BaseStageForm";
import { deviceTypeRestrictionPair } from "@goauthentik/admin/stages/authenticator_webauthn/utils";
import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
import "@goauthentik/components/ak-number-input";
import "@goauthentik/elements/ak-dual-select/ak-dual-select-provider";
import { DataProvision } from "@goauthentik/elements/ak-dual-select/types";
import "@goauthentik/elements/forms/HorizontalFormElement";
@ -165,6 +166,15 @@ export class AuthenticatorWebAuthnStageForm extends BaseStageForm<AuthenticatorW
>
</ak-radio>
</ak-form-element-horizontal>
<ak-number-input
label=${msg("Maximum registration attempts")}
required
name="maxAttempts"
value="${this.instance?.maxAttempts || 0}"
help=${msg(
"Maximum allowed registration attempts. When set to 0 attempts, attempts are not limited.",
)}
></ak-number-input>
<ak-form-element-horizontal
label=${msg("Device type restrictions")}
name="deviceTypeRestrictions"

View File

@ -7,7 +7,7 @@ import { PaginatedResponse } from "@goauthentik/elements/table/Table";
import { Table, TableColumn } from "@goauthentik/elements/table/Table";
import { msg, str } from "@lit/localize";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { AuthenticatorsApi, Device } from "@goauthentik/api";
@ -104,8 +104,11 @@ export class UserDeviceTable extends Table<Device> {
row(item: Device): TemplateResult[] {
return [
html`${item.name}`,
html`${deviceTypeName(item)}
${item.extraDescription ? ` - ${item.extraDescription}` : ""}`,
html`<div>
${deviceTypeName(item)}
${item.extraDescription ? ` - ${item.extraDescription}` : ""}
</div>
${item.externalId ? html` <small>${item.externalId}</small> ` : nothing} `,
html`${item.confirmed ? msg("Yes") : msg("No")}`,
html`${item.created.getTime() > 0
? html`<div>${formatElapsedTime(item.created)}</div>

View File

@ -133,7 +133,7 @@ export class UserListPage extends WithBrandConfig(WithCapabilitiesConfig(TablePa
async apiEndpoint(): Promise<PaginatedResponse<User>> {
const users = await new CoreApi(DEFAULT_CONFIG).coreUsersList({
...(await this.defaultEndpointConfig()),
pathStartswith: getURLParam("path", ""),
pathStartswith: this.activePath,
isActive: this.hideDeactivated ? true : undefined,
includeGroups: false,
});

View File

@ -94,7 +94,7 @@ export class ObjectChangelog extends Table<Event> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span>
><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`,
);

View File

@ -67,7 +67,7 @@ export class UserEvents extends Table<Event> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state
><span slot="header">${msg("No Events found.")}</span>
><span>${msg("No Events found.")}</span>
<div slot="body">${msg("No matching events could be found.")}</div>
</ak-empty-state>`,
);

View File

@ -148,5 +148,31 @@ export class AKElement extends LitElement implements AKElementProps {
return this.#styleRoot;
}
protected hasSlotted(name: string | null) {
const isNotNestedSlot = (start: Element) => {
let node = start.parentNode;
while (node && node !== this) {
if (node instanceof Element && node.hasAttribute("slot")) {
return false;
}
node = node.parentNode;
}
return true;
};
// All child slots accessible from the component's LightDOM that match the request
const allChildSlotRequests =
typeof name === "string"
? [...this.querySelectorAll(`[slot="${name}"]`)]
: [...this.children].filter((child) => {
const slotAttr = child.getAttribute("slot");
return !slotAttr || slotAttr === "";
});
// All child slots accessible from the LightDom that match the request *and* are not nested
// within another slotted element.
return allChildSlotRequests.filter((node) => isNotNestedSlot(node)).length > 0;
}
//#endregion
}

View File

@ -3,38 +3,63 @@ import { AKElement } from "@goauthentik/elements/Base";
import "@goauthentik/elements/Spinner";
import { type SlottedTemplateResult, type Spread } from "@goauthentik/elements/types";
import { spread } from "@open-wc/lit-helpers";
import { SlotController } from "@patternfly/pfe-core/controllers/slot-controller.js";
import { msg } from "@lit/localize";
import { css, html, nothing } from "lit";
import { css, html, nothing, render } from "lit";
import { customElement, property } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
/**
* Props for the EmptyState component
*/
export interface IEmptyState {
/** Font Awesome icon class (e.g., "fa-user", "fa-folder") to display */
icon?: string;
/** When true, will automatically show the loading spinner. Overrides `icon`. */
loading?: boolean;
/**
* When true, will automatically fill the header with the "Loading" message and show the loading
* spinner. Overrides 'loading'.
*/
defaultLabel?: boolean;
/** Whether the empty state should take up the full height of its container */
fullHeight?: boolean;
header?: string;
}
/**
* @element ak-empty-state
* @class EmptyState
*
* A component for displaying empty states with optional icons, headings, body text, and actions.
* Follows PatternFly design patterns for empty state presentations.
*
* ## Slots
*
* @slot - The main heading text for the empty state
* @slot body - Descriptive text explaining the empty state or what the user can do
* @slot primary - Primary action buttons or other interactive elements
*
*/
@customElement("ak-empty-state")
export class EmptyState extends AKElement implements IEmptyState {
@property({ type: String })
icon = "";
public icon = "";
@property({ type: Boolean })
loading = false;
@property({ type: Boolean, reflect: true })
public loading = false;
@property({ type: Boolean })
fullHeight = false;
@property({ type: Boolean, reflect: true, attribute: "default-label" })
public defaultLabel = false;
@property()
header?: string;
slots = new SlotController(this, "header", "body", "primary");
@property({ type: Boolean, attribute: "full-height" })
public fullHeight = false;
static get styles() {
return [
@ -50,32 +75,49 @@ export class EmptyState extends AKElement implements IEmptyState {
];
}
render() {
const showHeader = this.loading || this.slots.hasSlotted("header");
const header = () =>
this.slots.hasSlotted("header")
? html`<slot name="header"></slot>`
: html`<span>${msg("Loading")}</span>`;
willUpdate() {
if (this.defaultLabel && this.querySelector("span:not([slot])") === null) {
render(html`<span>${msg("Loading")}</span>`, this);
}
}
return html`<div class="pf-c-empty-state ${this.fullHeight && "pf-m-full-height"}">
<div class="pf-c-empty-state__content">
${this.loading
? html`<div class="pf-c-empty-state__icon">
get localAriaLabel() {
const result = this.querySelector("span:not([slot])");
return result instanceof HTMLElement ? result.innerText || undefined : undefined;
}
render() {
const hasHeading = this.hasSlotted(null);
const loading = this.loading || this.defaultLabel;
const classes = {
"pf-c-empty-state": true,
"pf-m-full-height": this.fullHeight,
};
return html`<div aria-label=${this.localAriaLabel ?? nothing} class="${classMap(classes)}">
<div class="pf-c-empty-state__content" role="progressbar">
${loading
? html`<div part="spinner" class="pf-c-empty-state__icon">
<ak-spinner size=${PFSize.XLarge}></ak-spinner>
</div>`
: html`<i
part="icon"
class="pf-icon fa ${this.icon ||
"fa-question-circle"} pf-c-empty-state__icon"
aria-hidden="true"
></i>`}
${showHeader ? html` <h1 class="pf-c-title pf-m-lg">${header()}</h1>` : nothing}
${this.slots.hasSlotted("body")
? html` <div class="pf-c-empty-state__body">
${hasHeading
? html` <h1 part="heading" class="pf-c-title pf-m-lg" id="empty-state-heading">
<slot></slot>
</h1>`
: nothing}
${this.hasSlotted("body")
? html` <div part="body" class="pf-c-empty-state__body">
<slot name="body"></slot>
</div>`
: nothing}
${this.slots.hasSlotted("primary")
? html` <div class="pf-c-empty-state__primary">
${this.hasSlotted("primary")
? html` <div part="primary" class="pf-c-empty-state__primary">
<slot name="primary"></slot>
</div>`
: nothing}
@ -84,10 +126,37 @@ export class EmptyState extends AKElement implements IEmptyState {
}
}
export function akEmptyState(properties: IEmptyState, content: SlottedTemplateResult = nothing) {
const message =
typeof content === "string" ? html`<span slot="body">${content}</span>` : content;
return html`<ak-empty-state ${spread(properties as Spread)}>${message}</ak-empty-state>`;
interface IEmptyStateContent {
heading?: SlottedTemplateResult;
body?: SlottedTemplateResult;
primary?: SlottedTemplateResult;
}
type ContentKey = keyof IEmptyStateContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Generate `<ak-empty-state>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-empty-state>`
* @returns TemplateResult for the ak-empty-state element
*
*/
export function akEmptyState(properties: IEmptyState = {}, content: IEmptyStateContent = {}) {
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.
const stringToSlot = (name: string, c: ContentValue) =>
name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-empty-state ${spread(properties as Spread)}>${items}</ak-empty-state>`;
}
declare global {

View File

@ -5,30 +5,59 @@ import { spread } from "@open-wc/lit-helpers";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
export interface ILoadingOverlay {
/**
* Whether this overlay should appear above all other overlays (z-index: 999)
*/
topmost?: boolean;
/**
* Whether to show the loading spinner animation
*/
noSpinner?: boolean;
/**
* Icon name to display instead of the default loading spinner
*/
icon?: string;
}
/**
* @element ak-loading-overlay
* @class LoadingOverlay
*
* A component for for showing a loading message above a darkening background, in order
* to pause interaction while dynamically importing a major component.
*
* ## Slots
*
* @slot - The main heading text for the loading state
* @slot body - Descriptive text explaining the loading state
*
*/
@customElement("ak-loading-overlay")
export class LoadingOverlay extends AKElement implements ILoadingOverlay {
// Do not camelize: https://www.merriam-webster.com/dictionary/topmost
@property({ type: Boolean, attribute: "topmost" })
topmost = false;
@property({ type: Boolean })
loading = true;
@property({ type: Boolean, attribute: "no-spinner" })
noSpinner = false;
@property({ type: String })
icon = "";
icon?: string;
static get styles() {
return [
PFBase,
css`
:host {
top: 0;
left: 0;
display: flex;
height: 100%;
width: 100%;
@ -46,20 +75,49 @@ export class LoadingOverlay extends AKElement implements ILoadingOverlay {
}
render() {
return html`<ak-empty-state ?loading=${this.loading} header="" icon=${this.icon}>
<span slot="body"><slot></slot></span>
// Nested slots. Can get a little cognitively heavy, so be careful if you're editing here...
return html`<ak-empty-state ?loading=${!this.noSpinner} icon=${ifDefined(this.icon)}>
${this.hasSlotted(null) ? html`<span><slot></slot></span>` : nothing}
${this.hasSlotted("body")
? html`<span slot="body"><slot name="body"></slot></span>`
: nothing}
</ak-empty-state>`;
}
}
interface ILoadingOverlayContent {
heading?: SlottedTemplateResult;
body?: SlottedTemplateResult;
}
type ContentKey = keyof ILoadingOverlayContent;
type ContentValue = SlottedTemplateResult | undefined;
/**
* Function to create `<ak-loading-overlay>` programmatically
*
* @param properties - properties to apply to the component.
* @param content - strings or TemplateResults for the slots in `<ak-loading-overlay>`
* @returns TemplateResult for the ak-loading-overlay element
*
*/
export function akLoadingOverlay(
properties: ILoadingOverlay,
content: SlottedTemplateResult = nothing,
properties: ILoadingOverlay = {},
content: ILoadingOverlayContent = {},
) {
const message = typeof content === "string" ? html`<span>${content}</span>` : content;
return html`<ak-loading-overlay ${spread(properties as Spread)}
>${message}</ak-loading-overlay
>`;
// `heading` here is an Object.key of ILoadingOverlayContent, not the obsolete
// slot-name.
const stringToSlot = (name: string, c: ContentValue) =>
name === "heading" ? html`<span>${c}</span>` : html`<span slot=${name}>${c}</span>`;
const stringToTemplate = (name: string, c: ContentValue) =>
typeof c === "string" ? stringToSlot(name, c) : c;
const items = Object.entries(content)
.map(([name, content]) => stringToTemplate(name, content))
.filter(Boolean);
return html`<ak-loading-overlay ${spread(properties as Spread)}>${items}</ak-loading-overlay>`;
}
declare global {

View File

@ -32,8 +32,8 @@ import {
} from "./types.js";
function localeComparator(a: DualSelectPair, b: DualSelectPair) {
const aSortBy = a[2] || a[0];
const bSortBy = b[2] || b[0];
const aSortBy = String(a[2] || a[0]);
const bSortBy = String(b[2] || b[0]);
return aSortBy.localeCompare(bSortBy);
}

View File

@ -201,7 +201,7 @@ export abstract class AKChart<T> extends AKElement {
${this.error
? html`
<ak-empty-state icon="fa-times"
><span slot="header">${msg("Failed to fetch data.")}</span>
><span>${msg("Failed to fetch data.")}</span>
<p slot="body">${pluckErrorDetail(this.error)}</p>
</ak-empty-state>
`

View File

@ -40,9 +40,7 @@ export class LogViewer extends Table<LogEvent> {
renderEmpty(): TemplateResult {
return super.renderEmpty(
html`<ak-empty-state
><span slot="header">${msg("No log messages.")}</span>
</ak-empty-state>`,
html`<ak-empty-state><span>${msg("No log messages.")}</span> </ak-empty-state>`,
);
}

View File

@ -164,7 +164,7 @@ export class NotificationDrawer extends AKElement {
renderEmpty() {
return html`<ak-empty-state
><span slot="header">${msg("No notifications found.")}</span>
><span>${msg("No notifications found.")}</span>
<div slot="body">${msg("You don't have any notifications currently.")}</div>
</ak-empty-state>`;
}

View File

@ -1,59 +0,0 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as EmptyStateStories from "./EmptyState.stories";
<Meta of={EmptyStateStories} />
# EmptyState
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has two named slots, `body` and `primary`, to communicate further details about the current state
this element is meant to display.
## Usage
```Typescript
import "@goauthentik/elements/EmptyState.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-empty-state icon="fa-eject"
><span slot="primary">This would display in the "primary" slot</span></ak-empty-state
>
```
## Demo
### Default: Loading
The default state is _loading_
<Story of={EmptyStateStories.DefaultStory} />
### Done
<Story of={EmptyStateStories.DefaultAndLoadingDone} />
### Alternative "Done" Icon
This also shows the "header" attribute filled, which is rendered in a large, dark typeface.
<Story of={EmptyStateStories.DoneWithAlternativeIcon} />
### The Body Slot Filled
The body content slot is rendered in a lighter typeface at default size.
<Story of={EmptyStateStories.WithBodySlotFilled} />
### The Body and Primary Slot Filled
The primary content is rendered in the normal dark typeface at default size. It is also spaced
significantly below the spinner itself.
<Story of={EmptyStateStories.WithBodyAndPrimarySlotsFilled} />

View File

@ -1,108 +1,254 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { TemplateResult, html } from "lit";
import { TemplateResult, html, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { EmptyState, type IEmptyState } from "../EmptyState.js";
import "../EmptyState.js";
import { type EmptyState, type IEmptyState, akEmptyState } from "../EmptyState.js";
const metadata: Meta<EmptyState> = {
title: "Elements/<ak-empty-state>",
type StoryArgs = IEmptyState & {
headingText?: string | TemplateResult;
bodyText?: string | TemplateResult;
primaryButtonText?: string | TemplateResult;
};
const metadata: Meta<StoryArgs> = {
title: "Elements / <ak-empty-state>",
component: "ak-empty-state",
tags: ["autodocs"],
parameters: {
docs: {
description: "Our empty state spinner",
description: {
component: `
# Empty State Component
The EmptyState is an in-page element to indicate that something is either loading or unavailable.
When "loading" is true it displays a spinner, otherwise it displays a static icon. The default
icon is a question mark in a circle.
It has three named slots:
- The default slot: The heading (renders larger and more bold)
- **body**: Any text to describe the state
- **primary**: Action buttons or other interactive elements
For the loading attributes:
- The attribute \`loading\` will show the spinner
- The attribute \`default\` will show the spinner and the default header of "Loading"
If either of these attributes is active and the element contains content not assigned to one of the
named slots, it will be shown in the header. This overrides the default text of \`default\`. You
do not need both attributes for \`default\` to work; it assumes loading.
`,
},
},
layout: "padded",
},
argTypes: {
icon: { control: "text" },
loading: { control: "boolean" },
fullHeight: { control: "boolean" },
header: { control: "text" },
icon: {
control: "text",
description: "Font Awesome icon class (without 'fa-' prefix)",
},
loading: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
defaultLabel: {
control: "boolean",
description: "Show loading spinner instead of icon",
},
fullHeight: {
control: "boolean",
description: "Fill the full height of container",
},
headingText: {
control: "text",
description: "Text for heading slot (for demo purposes)",
},
bodyText: {
control: "text",
description: "Text for body slot (for demo purposes)",
},
primaryButtonText: {
control: "text",
description: "Text for primary button (for demo purposes)",
},
},
};
export default metadata;
const container = (content: TemplateResult) =>
html` <div style="background-color: #f0f0f0; padding: 1rem;">
<style>
ak-divider {
display: inline-block;
width: 32rem;
max-width: 32rem;
}</style
>${content}
</div>`;
type Story = StoryObj<StoryArgs>;
export const DefaultStory: StoryObj = {
const Template: Story = {
args: {
icon: undefined,
loading: true,
icon: "fa-circle-radiation",
loading: false,
defaultLabel: false,
fullHeight: false,
header: undefined,
},
render: (args) => html`
<ak-empty-state
icon=${ifDefined(args.icon)}
?loading=${args.loading}
?default=${args.defaultLabel}
?full-height=${args.fullHeight}
>
${args.headingText ? html`<span>${args.headingText}</span>` : nothing}
${args.bodyText ? html`<span slot="body">${args.bodyText}</span>` : nothing}
${args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: nothing}
</ak-empty-state>
`,
};
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
</ak-empty-state>`,
export const Basic: Story = {
...Template,
args: {
icon: "fa-folder-open",
headingText: "No files found",
bodyText: "This folder is empty. Upload some files to get started.",
},
};
export const Empty: Story = {
...Template,
args: {
icon: "",
},
render: () =>
html`<p>Note that a completely empty &lt;ak-empty-state&gt; is just that: empty.</p>
<ak-empty-state></ak-empty-state>`,
};
export const WithAction: Story = {
...Template,
args: {
icon: "fa-users",
headingText: "No users yet",
bodyText: "Get started by creating your first user account.",
primaryButtonText: html`<button>Create User</button>`,
},
};
export const Loading: Story = {
...Template,
args: {
loading: true,
},
};
export const LoadingWithCustomMessage: Story = {
...Template,
args: {
loading: true,
headingText: html`<span>I <em>know</em> it's here, somewhere...</span>`,
},
};
export const LoadingWithDefaultMessage: Story = {
...Template,
args: {
defaultLabel: true,
},
};
export const LoadingDefaultWithOverride: Story = {
...Template,
args: {
defaultLabel: true,
headingText: html`<span>Have they got a chance? Eh. It would take a miracle.</span>`,
},
};
export const LoadingDefaultWithButton: Story = {
...Template,
args: {
defaultLabel: true,
primaryButtonText: html`<button>Cancel</button>`,
},
};
export const FullHeight: Story = {
...Template,
args: {
icon: "fa-search",
headingText: "No search results",
bodyText: "Try adjusting your search criteria or browse our categories.",
fullHeight: true,
primaryButtonText: html`<button>Go back</button>`,
},
};
export const ProgrammaticUsage: Story = {
...Template,
args: {
icon: "fa-beer",
headingText: "Hold My Beer",
bodyText: "I saw this in a cartoon once. I'm sure I can pull it off.",
primaryButtonText: html`<button>Leave The Scene Immediately</button>`,
},
render: (args) =>
akEmptyState(
{
icon: args.icon,
},
{
heading: args.headingText,
body: args.bodyText,
primary: args.primaryButtonText
? html`
<button slot="primary" class="pf-c-button pf-m-primary">
${args.primaryButtonText}
</button>
`
: undefined,
},
),
};
export const DefaultAndLoadingDone = {
...DefaultStory,
args: { ...DefaultStory, ...{ loading: false } },
};
export const DoneWithAlternativeIcon = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
};
export const WithBodySlotFilled = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(html`
<ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content</span>
export const IconShowcase: Story = {
args: {},
render: () => html`
<div
style="display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 1rem;"
>
<ak-empty-state icon="fa-users">
<span>Users</span>
<span slot="body">No users found</span>
</ak-empty-state>
`),
};
export const WithBodyAndPrimarySlotsFilled = {
...DefaultStory,
args: {
...DefaultStory,
...{ loading: false, icon: "fa-space-shuttle", header: "The final frontier" },
},
render: ({ icon, loading, fullHeight, header }: IEmptyState) =>
container(
html` <ak-empty-state
?loading=${loading}
?fullHeight=${fullHeight}
icon=${ifDefined(icon)}
header=${ifDefined(header)}
>
<span slot="body">This is the body content slot</span>
<span slot="primary">This is the primary content slot</span>
</ak-empty-state>`,
),
<ak-empty-state icon="fa-database">
<span>Database</span>
<span slot="body">No records</span>
</ak-empty-state>
<ak-empty-state icon="fa-envelope">
<span>Messages</span>
<span slot="body">No messages</span>
</ak-empty-state>
<ak-empty-state icon="fa-chart-bar">
<span>Analytics</span>
<span slot="body">No data to display</span>
</ak-empty-state>
<ak-empty-state icon="fa-cog">
<span>Settings</span>
<span slot="body">No configuration</span>
</ak-empty-state>
<ak-empty-state icon="fa-shield-alt">
<span>Security</span>
<span slot="body">No alerts</span>
</ak-empty-state>
</div>
`,
};

View File

@ -1,36 +0,0 @@
import { Canvas, Description, Meta, Story, Title } from "@storybook/blocks";
import * as LoadingOverlayStories from "./LoadingOverlay.stories";
<Meta of={LoadingOverlayStories} />
# LoadingOverlay
The LoadingOverlay is meant to cover the container element completely, hiding the content behind a
dimming filter, while content loads.
It has a single named slot, "body" into which messages about the loading process can be included.
## Usage
```Typescript
import "@goauthentik/elements/LoadingOverlay.js";
```
Note that the content of an alert _must_ be a valid HTML component; plain text does not work here.
```html
<ak-loading-overlay topmost>
<span>This would display below the loading spinner</span>
</ak-loading-overlay>
```
## Demo
### Default
<Story of={LoadingOverlayStories.DefaultStory} />
### With a message
<Story of={LoadingOverlayStories.WithAMessage} />

View File

@ -1,74 +1,154 @@
import type { Meta, StoryObj } from "@storybook/web-components";
import { LitElement, TemplateResult, css, html } from "lit";
import { customElement, property } from "lit/decorators.js";
import { html } from "lit";
import { ifDefined } from "lit/directives/if-defined.js";
import { type ILoadingOverlay, LoadingOverlay } from "../LoadingOverlay.js";
import "../LoadingOverlay.js";
import { type ILoadingOverlay, LoadingOverlay, akLoadingOverlay } from "../LoadingOverlay.js";
const metadata: Meta<LoadingOverlay> = {
title: "Elements/<ak-loading-overlay>",
type StoryArgs = ILoadingOverlay & {
headingText?: string;
bodyText?: string;
noSpinner: boolean;
};
const metadata: Meta<StoryArgs> = {
title: "Elements/ <ak-loading-overlay>",
component: "ak-loading-overlay",
tags: ["autodocs"],
parameters: {
docs: {
description: "Our empty state spinner",
description: {
component: `
# Loading Overlay Component
A full-screen overlay component that displays a loading state with optional heading and body content.
A variant of the EmptyState component that includes a protective background for load or import
operations during which the user should be prevented from interacting with the page.
It has two named slots, both optional:
- **heading**: Main title (renders in an \`<h1>\`)
- **body**: Any text to describe the state
`,
},
},
},
argTypes: {
topmost: { control: "boolean" },
// @ts-ignore
message: { control: "text" },
topmost: {
control: "boolean",
description:
"Whether this overlay should appear above all other overlays (z-index: 999)",
defaultValue: false,
},
noSpinner: {
control: "boolean",
description: "Disable the loading spinner animation",
defaultValue: false,
},
icon: {
control: "text",
description: "Icon name to display instead of the default loading spinner",
},
headingText: {
control: "text",
description: "Heading text displayed above the loading indicator",
},
bodyText: {
control: "text",
description: "Body text displayed below the loading indicator",
},
},
decorators: [
(story) => html`
<div
style="position: relative; height: 400px; width: 100%; border: 1px solid #ccc; background: #f5f5f5;"
>
<div style="padding: 20px;">
<h3>Content Behind Overlay</h3>
<p>authentik is awesome (or will be if something were actually loading)</p>
<button>Sample Button</button>
</div>
${story()}
</div>
`,
],
};
export default metadata;
@customElement("ak-storybook-demo-container")
export class Container extends LitElement {
static get styles() {
return css`
:host {
display: block;
position: relative;
height: 25vh;
width: 75vw;
}
#main-container {
position: relative;
width: 100%;
height: 100%;
}
`;
}
type Story = StoryObj<StoryArgs>;
@property({ type: Object, attribute: false })
content!: TemplateResult;
export const Default: Story = {
render: () => html`<ak-loading-overlay></ak-loading-overlay>`,
};
render() {
return html` <div id="main-container">${this.content}</div>`;
}
}
export const DefaultStory: StoryObj = {
export const WithHeading: Story = {
args: {
topmost: undefined,
// @ts-ignore
message: undefined,
},
// @ts-ignore
render: ({ topmost, message }: ILoadingOverlay) => {
message = typeof message === "string" ? html`<span>${message}</span>` : message;
const content = html` <ak-loading-overlay ?topmost=${topmost}
>${message ?? ""}
</ak-loading-overlay>`;
return html`<ak-storybook-demo-container
.content=${content}
></ak-storybook-demo-container>`;
headingText: "Loading Data",
},
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
</ak-loading-overlay>`,
};
export const WithAMessage: StoryObj = {
...DefaultStory,
args: { ...DefaultStory.args, message: html`<p>Overlay with a message</p>` },
export const WithHeadingAndBody: Story = {
args: {
headingText: "Loading Data",
bodyText: "Please wait while we fetch your information...",
},
render: (args) =>
html`<ak-loading-overlay>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const NoSpinner: Story = {
args: {
headingText: "Static Message",
bodyText: "This overlay shows without a spinner animation.",
},
render: (args) =>
html`<ak-loading-overlay no-spinner>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const WithCustomIcon: Story = {
args: {
icon: "fa-info-circle",
headingText: "Processing",
bodyText: "Your request is being processed...",
},
render: (args) =>
html`<ak-loading-overlay no-spinner icon=${ifDefined(args.icon)}>
<span>${args.headingText}</span>
<span slot="body">${args.bodyText}</span>
</ak-loading-overlay>`,
};
export const ProgrammaticUsage: Story = {
args: {
topmost: false,
noSpinner: false,
icon: "",
headingText: "Programmatic Loading",
bodyText: "This overlay was created using the akLoadingOverlay function.",
},
render: (args) =>
akLoadingOverlay(
{
topmost: args.topmost,
noSpinner: args.noSpinner,
icon: args.icon || undefined,
},
{
heading: args.headingText,
body: args.bodyText,
},
),
};

View File

@ -299,9 +299,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
return html`<tr role="row">
<td role="cell" colspan="25">
<div class="pf-l-bullseye">
<ak-empty-state loading
><span slot="header">${msg("Loading")}</span></ak-empty-state
>
<ak-empty-state default-label></ak-empty-state>
</div>
</td>
</tr>`;
@ -314,7 +312,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
<div class="pf-l-bullseye">
${inner ??
html`<ak-empty-state
><span slot="header">${msg("No objects found.")}</span> >
><span>${msg("No objects found.")}</span>
<div slot="primary">${this.renderObjectCreate()}</div>
</ak-empty-state>`}
</div>
@ -331,7 +329,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
if (!this.error) return nothing;
return html`<ak-empty-state icon="fa-ban"
><span slot="header">${msg("Failed to fetch objects.")}</span>
><span>${msg("Failed to fetch objects.")}</span>
<div slot="body">${pluckErrorDetail(this.error)}</div>
</ak-empty-state>`;
}

View File

@ -42,7 +42,8 @@ export abstract class TablePage<T> extends Table<T> {
return super.renderEmpty(html`
${inner
? inner
: html`<ak-empty-state icon=${this.pageIcon()} header="${msg("No objects found.")}">
: html`<ak-empty-state icon=${this.pageIcon()}
><span>${msg("No objects found.")}</span>
<div slot="body">
${this.searchEnabled() ? this.renderEmptyClearSearch() : html``}
</div>

View File

@ -19,11 +19,7 @@ describe("ak-empty-state", () => {
});
it("should render the default loader", async () => {
render(
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
render(html`<ak-empty-state default-label></ak-empty-state>`);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist();
@ -33,25 +29,17 @@ describe("ak-empty-state", () => {
});
it("should handle standard boolean", async () => {
render(
html`<ak-empty-state loading
><span slot="header">${msg("Loading")}</span>
</ak-empty-state>`,
);
render(html`<ak-empty-state loading>Waiting</ak-empty-state>`);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist();
const header = await $("ak-empty-state").$(">>>.pf-c-title");
await expect(header).toHaveText("Loading");
await expect(header).toHaveText("Waiting");
});
it("should render a static empty state", async () => {
render(
html`<ak-empty-state
><span slot="header">${msg("No messages found")}</span>
</ak-empty-state>`,
);
render(html`<ak-empty-state><span>${msg("No messages found")}</span> </ak-empty-state>`);
const empty = await $("ak-empty-state").$(">>>.pf-c-empty-state__icon");
await expect(empty).toExist();
@ -64,7 +52,7 @@ describe("ak-empty-state", () => {
it("should render a slotted message", async () => {
render(
html`<ak-empty-state
><span slot="header">${msg("No messages found")}</span>
><span>${msg("No messages found")}</span>
<p slot="body">Try again with a different filter</p>
</ak-empty-state>`,
);

View File

@ -115,9 +115,9 @@ export class UserSourceSettingsPage extends AKElement {
${this.sourceSettings
? html`
${this.sourceSettings.length < 1
? html`<ak-empty-state
header=${msg("No services available.")}
></ak-empty-state>`
? html`<ak-empty-state>
<span>${msg("No services available.")}</span></ak-empty-state
>`
: html`
${this.sourceSettings.map((source) => {
return html`<li class="pf-c-data-list__item">
@ -139,7 +139,7 @@ export class UserSourceSettingsPage extends AKElement {
})}
`}
`
: html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`}
: html`<ak-empty-state default-label></ak-empty-state>`}
</ul>`;
}
}

View File

@ -304,7 +304,7 @@ export class FlowExecutor
async renderChallenge(): Promise<TemplateResult> {
if (!this.challenge) {
return html`<ak-empty-state loading> </ak-empty-state>`;
return html`<ak-empty-state loading default-label> </ak-empty-state>`;
}
switch (this.challenge?.component) {
case "ak-stage-access-denied":

View File

@ -24,7 +24,7 @@ export class SessionEnd extends BaseStage<SessionEndChallenge, unknown> {
render(): TemplateResult {
if (!this.challenge) {
return html`<ak-empty-state loading header=${msg("Loading")}> </ak-empty-state>`;
return html`<ak-empty-state default-label></ak-empty-state>`;
}
return html`<header class="pf-c-login__main-header">
<h1 class="pf-c-title pf-m-3xl">${this.challenge.flowInfo?.title}</h1>

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