Compare commits

..

9 Commits

Author SHA1 Message Date
Connor Peshek
ef3d795cc2 clean up 2026-04-29 04:09:54 -05:00
Connor Peshek
2cd89b0ab0 update to main 2026-04-28 20:52:24 -05:00
Connor Peshek
1e68fc887a Merge branch 'main' into saml-endpoints 2026-03-12 21:44:21 -05:00
Connor Peshek
f60a441435 fix for tests when sp init login 2026-02-25 17:53:38 -06:00
Connor Peshek
f207fdfed0 Merge branch 'main' into saml-endpoints 2026-02-25 17:23:28 -06:00
Connor Peshek
1137924e49 Merge branch 'main' into saml-endpoints 2026-02-06 23:06:49 -06:00
Connor Peshek
4cb40fee4b fix saml parsing 2026-02-05 17:42:12 -06:00
Connor Peshek
649a4e57c2 Merge branch 'main' into saml-endpoints 2026-02-04 16:27:45 -06:00
Connor Peshek
ca03d81bd9 providers/saml: make unified saml endpoint 2026-02-04 15:57:12 -06:00
131 changed files with 2222 additions and 4955 deletions

View File

@@ -1,81 +0,0 @@
name: "Setup Node.js and NPM"
description: "Sets up Node.js with a specific NPM version via Corepack"
inputs:
working-directory:
description: "Path to the working directory containing the package.json file"
required: false
default: "."
dependencies:
required: false
description: "List of dependencies to setup"
default: "monorepo,working-directory"
node-version-file:
description: "Path to file containing the Node.js version"
required: false
default: "package.json"
cache-dependency-path:
description: "Path to dependency lock file for caching"
required: false
default: "package-lock.json"
cache:
description: "Package manager to cache"
default: "npm"
registry-url:
description: "npm registry URL"
default: "https://registry.npmjs.org"
runs:
using: "composite"
steps:
- name: Setup Node.js (Corepack bootstrap)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.node-version-file }}
registry-url: ${{ inputs.registry-url }}
# The setup-node action will attempt to create a cache using a version of
# npm that may not be compatible with the range specified in package.json.
# This can be enabled **after** corepack is installed and the correct npm version is available.
package-manager-cache: false
- name: Install Corepack
working-directory: ${{ github.workspace}}
shell: bash
run: | #shell
node ./scripts/node/lint-runtime.mjs
node ./scripts/node/setup-corepack.mjs --force
corepack enable
- name: Lint Node.js and NPM versions
shell: bash
run: node ./scripts/node/lint-runtime.mjs
- name: Setup Node.js (Monorepo Root)
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.node-version-file }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.cache-dependency-path }}
registry-url: ${{ inputs.registry-url }}
- name: Install monorepo dependencies
if: ${{ contains(inputs.dependencies, 'monorepo') }}
shell: bash
run: | #shell
node ./scripts/node/lint-lockfile.mjs
corepack npm ci
- name: Setup Node.js (Working Directory)
if: ${{ contains(inputs.dependencies, 'working-directory') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: ${{ inputs.working-directory }}/${{ inputs.node-version-file }}
cache: ${{ inputs.cache }}
cache-dependency-path: ${{ inputs.working-directory }}/${{ inputs.cache-dependency-path }}
registry-url: ${{ inputs.registry-url }}
- name: Install working directory dependencies
if: ${{ contains(inputs.dependencies, 'working-directory') }}
shell: bash
run: | # shell
corepack install
echo "node version: $(node --version)"
echo "npm version: $(corepack npm --version)"
node ./scripts/node/lint-lockfile.mjs ${{ inputs.working-directory }}
corepack npm ci --prefix ${{ inputs.working-directory }}

View File

@@ -18,24 +18,19 @@ runs:
using: "composite"
steps:
- name: Cleanup apt
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
'python') }}
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: sudo apt-get remove --purge man-db
- name: Install apt deps
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
'python') }}
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
uses: gerlero/apt-install@f4fa5265092af9e750549565d28c99aec7189639
with:
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev
libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user
krb5-admin-server
packages: libpq-dev openssl libxmlsec1-dev pkg-config gettext krb5-multidev libkrb5-dev heimdal-multidev libclang-dev krb5-kdc krb5-user krb5-admin-server
update: true
upgrade: false
install-recommends: false
- name: Make space on disk
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies,
'python') }}
if: ${{ contains(inputs.dependencies, 'system') || contains(inputs.dependencies, 'python') }}
shell: bash
run: |
sudo mkdir -p /tmp/empty/
@@ -56,8 +51,7 @@ runs:
working-directory: ${{ inputs.working-directory }}
run: uv sync --all-extras --dev --frozen
- name: Setup rust (stable)
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies,
'rust-nightly') }}
if: ${{ contains(inputs.dependencies, 'rust') && !contains(inputs.dependencies, 'rust-nightly') }}
uses: actions-rust-lang/setup-rust-toolchain@2b1f5e9b395427c92ee4e3331786ca3c37afe2d7 # v1
with:
rustflags: ""
@@ -70,14 +64,30 @@ runs:
rustflags: ""
- name: Setup rust dependencies
if: ${{ contains(inputs.dependencies, 'rust') }}
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
uses: taiki-e/install-action@cf525cb33f51aca27cd6fa02034117ab963ff9f1 # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (root, web)
- name: Setup node (web)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: ./.github/actions/setup-node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
working-directory: web
node-version-file: "${{ inputs.working-directory }}web/package.json"
cache: "npm"
cache-dependency-path: "${{ inputs.working-directory }}web/package-lock.json"
registry-url: "https://registry.npmjs.org"
- name: Setup node (root)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
with:
node-version-file: "${{ inputs.working-directory }}package.json"
cache: "npm"
cache-dependency-path: "${{ inputs.working-directory }}package-lock.json"
registry-url: "https://registry.npmjs.org"
- name: Install Node deps
if: ${{ contains(inputs.dependencies, 'node') }}
shell: bash
working-directory: ${{ inputs.working-directory }}
run: npm ci
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5
@@ -87,9 +97,7 @@ runs:
if: ${{ contains(inputs.dependencies, 'runtime') }}
uses: AndreKurait/docker-cache@0fe76702a40db986d9663c24954fc14c6a6031b7
with:
key: docker-images-${{ runner.os }}-${{
hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{
inputs.postgresql_version }}
key: docker-images-${{ runner.os }}-${{ hashFiles('.github/actions/setup/compose.yml', 'Makefile') }}-${{ inputs.postgresql_version }}
- name: Setup dependencies
if: ${{ contains(inputs.dependencies, 'runtime') }}
shell: bash
@@ -97,7 +105,7 @@ runs:
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d
corepack npm ci --prefix web
cd web && npm ci
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
shell: uv run python {0}

View File

@@ -67,16 +67,6 @@ jobs:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: ./.github/actions/setup-node
with:
working-directory: web
dependencies: "monorepo"
- uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6
with:
go-version-file: "go.mod"
- name: Generate API Clients
run: |
make gen-client-ts
- name: Build Docker Image
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
id: push
@@ -91,8 +81,7 @@ jobs:
${{ steps.ev.outputs.imageBuildArgs }}
tags: ${{ steps.ev.outputs.imageTags }}
platforms: linux/${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames
}}:buildcache-${{ inputs.image_arch }}
cache-from: type=registry,ref=${{ steps.ev.outputs.attestImageNames }}:buildcache-${{ inputs.image_arch }}
cache-to: ${{ steps.ev.outputs.cacheTo }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
id: attest

View File

@@ -1,65 +0,0 @@
---
name: API - Publish Typescript client
on:
push:
branches: [main]
paths:
- "schema.yml"
workflow_dispatch:
permissions:
# Required for NPM OIDC trusted publisher
id-token: write
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- id: generate_token
uses: actions/create-github-app-token@f8d387b68d61c58ab83c6c016672934102569859 # v2
with:
app-id: ${{ secrets.GH_APP_ID }}
private-key: ${{ secrets.GH_APP_PRIV_KEY }}
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
token: ${{ steps.generate_token.outputs.token }}
- uses: ./.github/actions/setup-node
with:
working-directory: web
- name: Generate API Client
run: make gen-client-ts
- name: Publish package
working-directory: gen-ts-api/
run: |
npm i
npm publish --tag generated
- name: Upgrade /web
working-directory: web
run: |
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
npm i @goauthentik/api@$VERSION
- name: Upgrade /web/packages/sfe
working-directory: web/packages/sfe
run: |
export VERSION=`node -e 'import mod from "./gen-ts-api/package.json" with { type: "json" };console.log(mod.version);'`
npm i @goauthentik/api@$VERSION
- uses: peter-evans/create-pull-request@c0f553fe549906ede9cf27b5156039d195d2ece0 # v7
id: cpr
with:
token: ${{ steps.generate_token.outputs.token }}
branch: update-web-api-client
commit-message: "web: bump API Client version"
title: "web: bump API Client version"
body: "web: bump API Client version"
delete-branch: true
signoff: true
# ID from https://api.github.com/users/authentik-automation[bot]
author: authentik-automation[bot] <135050075+authentik-automation[bot]@users.noreply.github.com>
labels: dependencies
- uses: peter-evans/enable-pull-request-automerge@a660677d5469627102a1c1e11409dd063606628d # v3
with:
token: ${{ steps.generate_token.outputs.token }}
pull-request-number: ${{ steps.cpr.outputs.pull-request-number }}
merge-method: squash

View File

@@ -22,19 +22,25 @@ jobs:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
with:
working-directory: website
- name: Install Dependencies
working-directory: website/
run: npm ci
- name: Lint
run: corepack npm run ${{ matrix.command }} --prefix website
working-directory: website/
run: npm run ${{ matrix.command }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: |
@@ -48,7 +54,7 @@ jobs:
working-directory: website
env:
NODE_ENV: production
run: corepack npm run build -w api
run: npm run build -w api
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v4
with:
name: api-docs
@@ -65,9 +71,11 @@ jobs:
with:
name: api-docs
path: website/api/build
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- name: Deploy Netlify (Production)
working-directory: website/api
if: github.event_name == 'push' && github.ref == 'refs/heads/main'

View File

@@ -24,9 +24,14 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: lifecycle/aws
node-version-file: lifecycle/aws/package.json
cache: "npm"
cache-dependency-path: lifecycle/aws/package-lock.json
- working-directory: lifecycle/aws/
run: |
npm ci
- name: Check changes have been applied
run: |
uv run make aws-cfn

View File

@@ -24,34 +24,46 @@ jobs:
- prettier-check
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
with:
working-directory: website
- name: Install dependencies
working-directory: website/
run: npm ci
- name: Lint
run: corepack npm run ${{ matrix.command }} --prefix website
working-directory: website/
run: npm run ${{ matrix.command }}
build-docs:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
name: Setup Node.js
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- name: Build Documentation via Docusaurus
run: corepack npm run build --prefix website
working-directory: website/
run: npm run build
build-integrations:
runs-on: ubuntu-latest
env:
NODE_ENV: production
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: website
node-version-file: website/package.json
cache: "npm"
cache-dependency-path: website/package-lock.json
- working-directory: website/
name: Install Dependencies
run: npm ci
- name: Build Integrations via Docusaurus
run: corepack npm run build -w integrations --prefix website
working-directory: website/
run: npm run build -w integrations
build-container:
runs-on: ubuntu-latest
permissions:
@@ -92,9 +104,7 @@ jobs:
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' &&
'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max'
|| '' }}
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && 'type=registry,ref=ghcr.io/goauthentik/dev-docs:buildcache,mode=max' || '' }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}

View File

@@ -73,8 +73,7 @@ jobs:
- name: generate API clients
run: make gen-clients
- name: ensure schema is up-to-date
run: git diff --exit-code -- schema.yml blueprints/schema.json
packages/client-go packages/client-rust packages/client-ts
run: git diff --exit-code -- schema.yml blueprints/schema.json packages/client-go packages/client-rust packages/client-ts
test-migrations:
runs-on: ubuntu-latest
steps:
@@ -92,8 +91,7 @@ jobs:
outputs:
seed: ${{ steps.seed.outputs.seed }}
test-migrations-from-stable:
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{
matrix.run_id }}/5
name: test-migrations-from-stable - PostgreSQL ${{ matrix.psql }} - Run ${{ matrix.run_id }}/5
runs-on: ubuntu-latest
timeout-minutes: 30
needs: test-make-seed
@@ -103,7 +101,7 @@ jobs:
psql:
- 14-alpine
- 18-alpine
run_id: [ 1, 2, 3, 4, 5 ]
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -111,13 +109,8 @@ jobs:
- name: checkout stable
run: |
set -e -o pipefail
cp -R .github ..
cp -R scripts ..
mkdir -p ../packages
cp -R packages/logger-js ../packages/logger-js
# Previous stable tag
prev_stable=$(git tag --sort=version:refname | grep '^version/' | grep -vE -- '-rc[0-9]+$' | tail -n1)
# Current version family based on
@@ -125,13 +118,10 @@ jobs:
if [[ -n $current_version_family ]]; then
prev_stable="version/${current_version_family}"
fi
echo "::notice::Checking out ${prev_stable} as stable version..."
git checkout ${prev_stable}
rm -rf .github/ scripts/ packages/logger-js/
rm -rf .github/ scripts/
mv ../.github ../scripts .
mv ../packages/logger-js ./packages/
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
@@ -179,7 +169,7 @@ jobs:
psql:
- 14-alpine
- 18-alpine
run_id: [ 1, 2, 3, 4, 5 ]
run_id: [1, 2, 3, 4, 5]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
@@ -262,22 +252,19 @@ jobs:
COMPOSE_PROFILES: ${{ matrix.job.profiles }}
run: |
docker compose -f tests/e2e/compose.yml up -d --quiet-pull
- uses: ./.github/actions/setup-node
- id: cache-web
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
if: contains(matrix.job.profiles, 'selenium')
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json',
'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'package-lock.json', 'web/src/**', 'web/packages/sfe/src/**') }}-b
- name: prepare web ui
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles,
'selenium')
if: steps.cache-web.outputs.cache-hit != 'true' && contains(matrix.job.profiles, 'selenium')
working-directory: web
run: |
corepack npm ci
corepack npm run build
corepack npm run build:sfe
npm ci
npm run build
npm run build:sfe
- name: run e2e
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -315,14 +302,14 @@ jobs:
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: web/dist
key: ${{ runner.os }}-web-${{ hashFiles('web/package-lock.json', 'web/src/**',
'web/packages/sfe/src/**') }}-b
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: |
corepack npm ci --prefix web
corepack npm run build --prefix web
corepack npm run build:sfe --prefix web
npm ci
npm run build
npm run build:sfe
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -388,9 +375,7 @@ jobs:
uses: ./.github/workflows/_reusable-docker-build.yml
secrets: inherit
with:
image_name: ${{ github.repository == 'goauthentik/authentik-internal' &&
'ghcr.io/goauthentik/internal-server' ||
'ghcr.io/goauthentik/dev-server' }}
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
release: false
pr-comment:
needs:

View File

@@ -114,11 +114,8 @@ jobs:
GIT_BUILD_HASH=${{ steps.ev.outputs.sha }}
platforms: linux/amd64,linux/arm64
context: .
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type
}}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' &&
format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max',
matrix.type) || '' }}
cache-from: type=registry,ref=ghcr.io/goauthentik/dev-${{ matrix.type }}:buildcache
cache-to: ${{ steps.ev.outputs.shouldPush == 'true' && format('type=registry,ref=ghcr.io/goauthentik/dev-{0}:buildcache,mode=max', matrix.type) || '' }}
- uses: actions/attest-build-provenance@a2bbfa25375fe432b6a289bc6b6cd05ecd0c4c32 # v3
id: attest
if: ${{ steps.ev.outputs.shouldPush == 'true' }}
@@ -139,8 +136,8 @@ jobs:
- ldap
- radius
- rac
goos: [ linux ]
goarch: [ amd64, arm64 ]
goos: [linux]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -148,11 +145,16 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Build web
run: corepack npm run build-proxy --prefix web
working-directory: web/
run: |
npm ci
npm run build-proxy
- name: Build outpost
run: |
set -x

View File

@@ -15,30 +15,48 @@ on:
jobs:
lint:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
command:
- lint
- lint:lockfile
- tsc
- prettier-check
project:
- web
include:
- command: tsc
project: web
- command: lit-analyse
project: web
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: |
npm ci
- name: Lint
run: corepack npm run lint --prefix web
- name: Check types
run: corepack npm run tsc --prefix web
- name: Check formatting
run: corepack npm run prettier-check --prefix web
- name: Lit analyse
run: corepack npm run lit-analyse --prefix web
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: build
working-directory: web/
run: corepack npm run build
run: npm run build
ci-web-mark:
if: always()
needs:
@@ -55,9 +73,13 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
- name: test
working-directory: web/
run: corepack npm run test || exit 0
run: npm run test || exit 0

View File

@@ -3,7 +3,7 @@ name: Packages - Publish NPM packages
on:
push:
branches: [ main ]
branches: [main]
paths:
- packages/tsconfig/**
- packages/eslint-config/**
@@ -35,19 +35,22 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 2
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: ${{ matrix.package }}
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
- name: Get changed files
id: changed-files
uses: tj-actions/changed-files@9426d40962ed5378910ee2e21d5f8c6fcbf2dd96 # 24d32ffd492484c1d75e0c0b894501ddb9d30d62
with:
files: |
${{ matrix.package }}/package.json
- name: Install Dependencies
run: npm ci
- name: Publish package
if: steps.changed-files.outputs.any_changed == 'true'
working-directory: ${{ matrix.package }}
run: |
corepack npm ci
corepack npm run build
corepack npm publish
npm ci
npm run build
npm publish

View File

@@ -3,7 +3,7 @@ name: Release - On publish
on:
release:
types: [ published, created ]
types: [published, created]
jobs:
build-server:
@@ -87,9 +87,11 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Set up QEMU
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a # v4.0.0
- name: Set up Docker Buildx
@@ -142,16 +144,22 @@ jobs:
- proxy
- ldap
- radius
goos: [ linux, darwin ]
goarch: [ amd64, arm64 ]
goos: [linux, darwin]
goarch: [amd64, arm64]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: ./.github/actions/setup-node
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
with:
working-directory: web
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- name: Install web dependencies
working-directory: web/
run: |
npm ci
- name: Build web
working-directory: web/
run: |
@@ -167,10 +175,8 @@ jobs:
uses: svenstaro/upload-release-action@29e53e917877a24fad85510ded594ab3c9ca12de # v2
with:
repo_token: ${{ secrets.GITHUB_TOKEN }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{
matrix.goarch }}
file: ./authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
asset_name: authentik-outpost-${{ matrix.type }}_${{ matrix.goos }}_${{ matrix.goarch }}
tag: ${{ github.ref }}
upload-aws-cfn-template:
permissions:

2
.gitignore vendored
View File

@@ -14,8 +14,6 @@ media
# Node
node_modules
corepack.tgz
.corepack
.cspellcache
cspell-report.*

4
Cargo.lock generated
View File

@@ -3000,9 +3000,9 @@ dependencies = [
[[package]]
name = "rustls"
version = "0.23.40"
version = "0.23.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b"
checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e"
dependencies = [
"aws-lc-rs",
"log",

View File

@@ -66,7 +66,7 @@ reqwest-middleware = { version = "= 0.5.1", features = [
"query",
"rustls",
] }
rustls = { version = "= 0.23.40", features = ["fips"] }
rustls = { version = "= 0.23.39", features = ["fips"] }
sentry = { version = "= 0.47.0", default-features = false, features = [
"backtrace",
"contexts",

View File

@@ -106,9 +106,8 @@ migrate: ## Run the Authentik Django server's migrations
i18n-extract: core-i18n-extract web-i18n-extract ## Extract strings that require translation into files to send to a translation service
aws-cfn: node-install
corepack npm install --prefix lifecycle/aws
$(UV) run corepack npm run aws-cfn --prefix lifecycle/aws
aws-cfn:
cd lifecycle/aws && npm i && $(UV) run npm run aws-cfn
run-server: ## Run the main authentik server process
$(UV) run ak server
@@ -129,7 +128,7 @@ core-i18n-extract:
--ignore website \
-l en
install: node-install web-install core-install ## Install all requires dependencies for `node`, `web` and `core`
install: node-install docs-install core-install ## Install all requires dependencies for `node`, `docs` and `core`
dev-drop-db:
$(eval pg_user := $(shell $(UV) run python -m authentik.lib.config postgresql.user 2>/dev/null))
@@ -233,46 +232,38 @@ gen-dev-config: ## Generate a local development config file
#########################
node-install: ## Install the necessary libraries to build Node.js packages
node ./scripts/node/setup-corepack.mjs
node ./scripts/node/lint-runtime.mjs
node ./scripts/node/lint-runtime.mjs
npm ci
npm ci --prefix web
#########################
## Web
#########################
web-install: ## Install the necessary libraries to build the Authentik UI
node ./scripts/node/lint-runtime.mjs web
corepack npm ci
corepack npm ci --prefix web
web-build: ## Build the Authentik UI
corepack npm run --prefix web build
web-build: node-install ## Build the Authentik UI
npm run --prefix web build
web: web-lint-fix web-lint web-check-compile ## Automatically fix formatting issues in the Authentik UI source code, lint the code, and compile it
web-test: ## Run tests for the Authentik UI
corepack npm run --prefix web test
npm run --prefix web test
web-watch: ## Build and watch the Authentik UI for changes, updating automatically
corepack npm run --prefix web watch
npm run --prefix web watch
web-storybook-watch: ## Build and run the storybook documentation server
corepack npm run --prefix web storybook
npm run --prefix web storybook
web-lint-fix:
corepack npm run --prefix web prettier
npm run --prefix web prettier
web-lint:
corepack npm run --prefix web lint
corepack npm run --prefix web lit-analyse
npm run --prefix web lint
npm run --prefix web lit-analyse
web-check-compile:
corepack npm run --prefix web tsc
npm run --prefix web tsc
web-i18n-extract:
corepack npm run --prefix web extract-locales
npm run --prefix web extract-locales
#########################
## Docs
@@ -280,40 +271,35 @@ web-i18n-extract:
docs: docs-lint-fix docs-build ## Automatically fix formatting issues in the Authentik docs source code, lint the code, and compile it
docs-install: node-install ## Install the necessary libraries to build the Authentik documentation
node ./scripts/node/lint-runtime.mjs
corepack npm ci
corepack npm ci --prefix website
docs-install:
npm ci --prefix website
docs-lint-fix: lint-spellcheck
corepack npm run --prefix website prettier
npm run --prefix website prettier
docs-build:
node ./scripts/node/lint-runtime.mjs website
corepack npm run --prefix website build
npm run --prefix website build
docs-watch: ## Build and watch the topics documentation
corepack npm run --prefix website start
npm run --prefix website start
integrations: docs-lint-fix integrations-build ## Fix formatting issues in the integrations source code, lint the code, and compile it
integrations-build:
corepack npm run --prefix website -w integrations build
npm run --prefix website -w integrations build
integrations-watch: ## Build and watch the Integrations documentation
corepack npm run --prefix website -w integrations start
npm run --prefix website -w integrations start
docs-api-build:
corepack npm run --prefix website -w api build
npm run --prefix website -w api build
docs-api-watch: ## Build and watch the API documentation
corepack npm run --prefix website -w api generate
corepack npm run --prefix website -w api start
npm run --prefix website -w api generate
npm run --prefix website -w api start
docs-api-clean: ## Clean generated API documentation
corepack npm run --prefix website -w api build:api:clean
npm run --prefix website -w api build:api:clean
#########################
## Docker

View File

@@ -1,73 +1,31 @@
"""authentik API Modelviewset tests"""
from collections.abc import Callable
from urllib.parse import urlencode
from django.test import TestCase
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
from authentik.admin.api.version_history import VersionHistoryViewSet
from authentik.api.v3.urls import router
from authentik.core.tests.utils import RequestFactory, create_test_admin_user
from authentik.lib.generators import generate_id
from authentik.tenants.api.domains import DomainViewSet
from authentik.tenants.api.tenants import TenantViewSet
from authentik.tenants.utils import get_current_tenant
class TestModelViewSets(TestCase):
"""Test Viewset"""
def setUp(self):
self.user = create_test_admin_user()
self.factory = RequestFactory()
def viewset_tester_factory(test_viewset: type[ModelViewSet], full=True) -> dict[str, Callable]:
def viewset_tester_factory(test_viewset: type[ModelViewSet]) -> Callable:
"""Test Viewset"""
def test_attrs(self: TestModelViewSets) -> None:
"""Test attributes we require on all viewsets"""
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
def tester(self: TestModelViewSets):
self.assertIsNotNone(getattr(test_viewset, "search_fields", None))
self.assertIsNotNone(getattr(test_viewset, "ordering", None))
filterset_class = getattr(test_viewset, "filterset_class", None)
if not filterset_class:
self.assertIsNotNone(getattr(test_viewset, "filterset_fields", None))
def test_ordering(self: TestModelViewSets) -> None:
"""Test that all ordering fields are correct"""
view = test_viewset.as_view({"get": "list"})
for ordering_field in test_viewset.ordering:
with self.subTest(ordering_field):
req = self.factory.get(
f"/?{urlencode({'ordering': ordering_field}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
def test_search(self: TestModelViewSets) -> None:
"""Test that search fields are correct"""
view = test_viewset.as_view({"get": "list"})
req = self.factory.get(
f"/?{urlencode({'search': generate_id()}, doseq=True)}", user=self.user
)
req.tenant = get_current_tenant()
res = view(req)
self.assertEqual(res.status_code, 200)
cases = {
"attrs": test_attrs,
}
if full:
cases["ordering"] = test_ordering
cases["search"] = test_search
return cases
return tester
for _, viewset, _ in router.registry:
if not issubclass(viewset, ModelViewSet | ReadOnlyModelViewSet):
continue
full = viewset not in [VersionHistoryViewSet, DomainViewSet, TenantViewSet]
for test, case in viewset_tester_factory(viewset, full=full).items():
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}_{test}", case)
setattr(TestModelViewSets, f"test_viewset_{viewset.__name__}", viewset_tester_factory(viewset))

View File

@@ -20,16 +20,11 @@ class TestBrands(APITestCase):
def setUp(self):
super().setUp()
self.default_flags = {}
for flag in Flag.available(visibility="public"):
self.default_flags[flag().key] = flag.get()
Brand.objects.all().delete()
@property
def default_flags(self) -> dict[str, object]:
"""Get current public flags.
Some tests define temporary Flag subclasses, so this can't be cached in setUp.
"""
return {flag().key: flag.get() for flag in Flag.available(visibility="public")}
def test_current_brand(self):
"""Test Current brand API"""
brand = create_test_brand()

View File

@@ -47,8 +47,7 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
search_fields = [
"pbm_uuid",
"name",
"app__name",
"app__slug",
"app",
"attributes",
]
filterset_fields = [

View File

@@ -14,7 +14,6 @@ from django.utils.http import urlencode
from django.utils.text import slugify
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy
from django_filters.filters import (
BooleanFilter,
CharFilter,
@@ -107,10 +106,6 @@ from authentik.stages.email.utils import TemplateEmailMessage
LOGGER = get_logger()
INVALID_PASSWORD_HASH_MESSAGE = gettext_lazy(
"Invalid password hash format. Must be a valid Django password hash."
)
class ParamUserSerializer(PassiveSerializer):
"""Partial serializer for query parameters to select a user"""
@@ -195,79 +190,47 @@ class UserSerializer(ModelSerializer):
return RoleSerializer(instance.roles, many=True).data
def __init__(self, *args, **kwargs):
"""Setting password and permissions directly is allowed only in blueprints."""
super().__init__(*args, **kwargs)
if SERIALIZER_CONTEXT_BLUEPRINT in self.context:
self.fields["password"] = CharField(required=False, allow_null=True)
self.fields["password_hash"] = CharField(required=False, allow_null=True)
self.fields["permissions"] = ListField(
required=False,
child=ChoiceField(choices=get_permission_choices()),
)
def create(self, validated_data: dict) -> User:
"""Create a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
"""If this serializer is used in the blueprint context, we allow for
directly setting a password. However should be done via the `set_password`
method instead of directly setting it like rest_framework."""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance: User = super().create(validated_data)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def update(self, instance: User, validated_data: dict) -> User:
"""Update a user, with blueprint-only password and permission writes."""
is_blueprint = SERIALIZER_CONTEXT_BLUEPRINT in self.context
if is_blueprint:
password = validated_data.pop("password", None)
password_hash = validated_data.pop("password_hash", None)
permissions = validated_data.pop("permissions", [])
self._validate_password_inputs(password, password_hash)
"""Same as `create` above, set the password directly if we're in a blueprint
context"""
password = validated_data.pop("password", None)
perms_qs = Permission.objects.filter(
codename__in=[x.split(".")[1] for x in validated_data.pop("permissions", [])]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in list(perms_qs)]
instance = super().update(instance, validated_data)
if is_blueprint:
self._set_password(instance, password, password_hash)
perms_qs = Permission.objects.filter(
codename__in=[permission.split(".")[1] for permission in permissions]
).values_list("content_type__app_label", "codename")
perms_list = [f"{ct}.{name}" for ct, name in perms_qs]
instance.assign_perms_to_managed_role(perms_list)
self._ensure_password_not_empty(instance)
self._set_password(instance, password)
instance.assign_perms_to_managed_role(perms_list)
return instance
def _validate_password_inputs(self, password: str | None, password_hash: str | None):
"""Validate mutually-exclusive password inputs before any model mutation."""
if password is not None and password_hash is not None:
raise ValidationError(_("Cannot set both password and password_hash. Use only one."))
if password_hash is None:
return
try:
User.validate_password_hash(password_hash)
except ValueError as exc:
LOGGER.warning("Failed to identify password hash format", exc_info=exc)
raise ValidationError(INVALID_PASSWORD_HASH_MESSAGE) from exc
def _set_password(self, instance: User, password: str | None, password_hash: str | None = None):
"""Set password from plain text or hash."""
if password_hash is not None:
instance.set_password_from_hash(password_hash)
instance.save()
elif password:
def _set_password(self, instance: User, password: str | None):
"""Set password of user if we're in a blueprint context, and if it's an empty
string then use an unusable password"""
if SERIALIZER_CONTEXT_BLUEPRINT in self.context and password:
instance.set_password(password)
instance.save()
def _ensure_password_not_empty(self, instance: User):
"""Store an explicit unusable password instead of an empty password field."""
if len(instance.password) == 0:
instance.set_unusable_password()
instance.save()
@@ -436,12 +399,6 @@ class UserPasswordSetSerializer(PassiveSerializer):
password = CharField(required=True)
class UserPasswordHashSetSerializer(PassiveSerializer):
"""Payload to set a users' password hash directly"""
password = CharField(required=True)
class UserServiceAccountSerializer(PassiveSerializer):
"""Payload to create a service account"""
@@ -785,11 +742,6 @@ class UserViewSet(
self.request.session.modified = True
return Response(serializer.initial_data)
def _update_session_hash_after_password_change(self, request: Request, user: User):
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordSetSerializer,
@@ -813,45 +765,9 @@ class UserViewSet(
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password", exc=exc)
return Response(status=400)
self._update_session_hash_after_password_change(request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")
@extend_schema(
request=UserPasswordHashSetSerializer,
responses={
204: OpenApiResponse(description="Successfully changed password"),
400: OpenApiResponse(description="Bad request"),
},
)
@action(
detail=True,
methods=["POST"],
permission_classes=[IsAuthenticated],
)
@validate(UserPasswordHashSetSerializer)
def set_password_hash(
self, request: Request, pk: int, body: UserPasswordHashSetSerializer
) -> Response:
"""Set a user's password from a pre-hashed Django password value.
Submit the Django password hash in the shared ``password`` request field.
This updates authentik's local password verifier only. It does not attempt
to propagate the password change to LDAP or Kerberos because no raw password
is available from the request payload.
"""
user: User = self.get_object()
try:
user.set_password_from_hash(body.validated_data["password"], request=request)
user.save()
except ValueError as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(data={"password": [INVALID_PASSWORD_HASH_MESSAGE]}, status=400)
except (ValidationError, IntegrityError) as exc:
LOGGER.debug("Failed to set password hash", exc=exc)
return Response(status=400)
self._update_session_hash_after_password_change(request, user)
if user.pk == request.user.pk and SESSION_KEY_IMPERSONATE_USER not in self.request.session:
LOGGER.debug("Updating session hash after password change")
update_session_auth_hash(self.request, user)
return Response(status=204)
@permission_required("authentik_core.reset_user_password")

View File

@@ -1,28 +0,0 @@
"""Hash password using Django's password hashers"""
from django.contrib.auth.hashers import make_password
from django.core.management.base import BaseCommand, CommandError
class Command(BaseCommand):
"""Hash a password using Django's password hashers"""
help = "Hash a password for use with AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"
def add_arguments(self, parser):
parser.add_argument(
"password",
type=str,
help="Password to hash",
)
def handle(self, *args, **options):
password = options["password"]
if not password:
raise CommandError("Password cannot be empty")
try:
hashed = make_password(password)
self.stdout.write(hashed)
except ValueError as exc:
raise CommandError(f"Error hashing password: {exc}") from exc

View File

@@ -10,7 +10,7 @@ from uuid import uuid4
import pgtrigger
from deepmerge import always_merger
from django.contrib.auth.hashers import check_password, identify_hasher
from django.contrib.auth.hashers import check_password
from django.contrib.auth.models import AbstractUser, Permission
from django.contrib.auth.models import UserManager as DjangoUserManager
from django.contrib.sessions.base_session import AbstractBaseSession
@@ -560,33 +560,6 @@ class User(SerializerModel, AttributesMixin, AbstractUser):
self.password_change_date = now()
return super().set_password(raw_password)
@staticmethod
def validate_password_hash(password_hash: str):
"""Validate that the value is a recognized Django password hash."""
identify_hasher(password_hash) # Raises ValueError if invalid
def set_password_from_hash(self, password_hash: str, signal=True, sender=None, request=None):
"""Set password directly from a pre-hashed value.
Unlike set_password(), this does not hash the input again. The provided value
must already be a valid Django password hash, and it is stored directly on the
user after validation.
Because no raw password is available, downstream password sync integrations
such as LDAP and Kerberos cannot be updated from this code path.
Raises ValueError if the hash format is not recognized.
"""
self.validate_password_hash(password_hash)
if self.pk and signal:
from authentik.core.signals import password_hash_changed
if not sender:
sender = self
password_hash_changed.send(sender=sender, user=self, request=request)
self.password = password_hash
self.password_change_date = now()
def check_password(self, raw_password: str) -> bool:
"""
Return a boolean of whether the raw_password was correct. Handles

View File

@@ -16,11 +16,7 @@ LOGGER = get_logger()
@receiver(post_startup)
def post_startup_setup_bootstrap(sender, **_):
if (
not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD")
and not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH")
and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN")
):
if not getenv("AUTHENTIK_BOOTSTRAP_PASSWORD") and not getenv("AUTHENTIK_BOOTSTRAP_TOKEN"):
return
LOGGER.info("Configuring authentik through bootstrap environment variables")
content = BlueprintInstance(path=BOOTSTRAP_BLUEPRINT).retrieve()

View File

@@ -24,8 +24,6 @@ from authentik.root.ws.consumer import build_device_group
# Arguments: user: User, password: str
password_changed = Signal()
# Arguments: user: User, request: HttpRequest | None
password_hash_changed = Signal()
# Arguments: credentials: dict[str, any], request: HttpRequest,
# stage: Stage, context: dict[str, any]
login_failed = Signal()

View File

@@ -12,7 +12,7 @@
{% block head %}
<style data-id="static-styles">
:root {
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url|iriencode|safe }}");
--ak-global--background-image: url("{{ request.brand.branding_default_flow_background_url }}");
}
</style>

View File

@@ -1,28 +0,0 @@
"""Tests for hash_password management command."""
from io import StringIO
from django.contrib.auth.hashers import check_password
from django.core.management import call_command
from django.core.management.base import CommandError
from django.test import TestCase
class TestHashPasswordCommand(TestCase):
"""Test hash_password management command."""
def test_hash_password(self):
"""Test hashing a password."""
out = StringIO()
call_command("hash_password", "test123", stdout=out)
hashed = out.getvalue().strip()
self.assertTrue(hashed.startswith("pbkdf2_sha256$"))
self.assertTrue(check_password("test123", hashed))
def test_hash_password_empty_fails(self):
"""Test that empty password raises error."""
with self.assertRaises(CommandError) as ctx:
call_command("hash_password", "")
self.assertIn("Password cannot be empty", str(ctx.exception))

View File

@@ -1,7 +1,6 @@
from http import HTTPStatus
from os import environ
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from authentik.blueprints.tests import apply_blueprint
@@ -17,7 +16,6 @@ from authentik.tenants.flags import patch_flag
class TestSetup(FlowTestCase):
def tearDown(self):
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD", None)
environ.pop("AUTHENTIK_BOOTSTRAP_PASSWORD_HASH", None)
environ.pop("AUTHENTIK_BOOTSTRAP_TOKEN", None)
@patch_flag(Setup, True)
@@ -156,19 +154,3 @@ class TestSetup(FlowTestCase):
token = Token.objects.filter(identifier="authentik-bootstrap-token").first()
self.assertEqual(token.intent, TokenIntents.INTENT_API)
self.assertEqual(token.key, environ["AUTHENTIK_BOOTSTRAP_TOKEN"])
def test_setup_bootstrap_env_password_hash(self):
"""Test setup with password hash env var"""
User.objects.filter(username="akadmin").delete()
Setup.set(False)
password = generate_id()
password_hash = make_password(password)
environ["AUTHENTIK_BOOTSTRAP_PASSWORD_HASH"] = password_hash
pre_startup.send(sender=self)
post_startup.send(sender=self)
self.assertTrue(Setup.get())
user = User.objects.get(username="akadmin")
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))

View File

@@ -1,15 +1,8 @@
"""user tests"""
from unittest.mock import patch
from django.contrib.auth.hashers import make_password
from django.test.testcases import TestCase
from rest_framework.exceptions import ValidationError
from authentik.blueprints.v1.importer import SERIALIZER_CONTEXT_BLUEPRINT
from authentik.core.api.users import UserSerializer
from authentik.core.models import User
from authentik.core.signals import password_changed, password_hash_changed
from authentik.events.models import Event
from authentik.lib.generators import generate_id
@@ -40,99 +33,3 @@ class TestUsers(TestCase):
self.assertEqual(Event.objects.count(), 1)
user.ak_groups.all()
self.assertEqual(Event.objects.count(), 1)
def test_set_password_from_hash_signal_skips_source_sync_receivers(self):
"""Test hash password updates do not expose a raw password to sync receivers."""
user = User.objects.create(
username=generate_id(),
attributes={"distinguishedName": "cn=test,ou=users,dc=example,dc=com"},
)
password_changed_captured = []
password_hash_changed_captured = []
dispatch_uid = generate_id()
hash_dispatch_uid = generate_id()
def password_changed_receiver(sender, **kwargs):
password_changed_captured.append(kwargs)
def password_hash_changed_receiver(sender, **kwargs):
password_hash_changed_captured.append(kwargs)
password_changed.connect(password_changed_receiver, dispatch_uid=dispatch_uid)
password_hash_changed.connect(
password_hash_changed_receiver, dispatch_uid=hash_dispatch_uid
)
try:
with (
patch(
"authentik.sources.ldap.signals.LDAPSource.objects.filter"
) as ldap_sources_filter,
patch(
"authentik.sources.kerberos.signals."
"UserKerberosSourceConnection.objects.select_related"
) as kerberos_connections_select,
):
user.set_password_from_hash(make_password("new-password")) # nosec
user.save()
finally:
password_changed.disconnect(dispatch_uid=dispatch_uid)
password_hash_changed.disconnect(dispatch_uid=hash_dispatch_uid)
self.assertEqual(password_changed_captured, [])
self.assertEqual(len(password_hash_changed_captured), 1)
ldap_sources_filter.assert_not_called()
kerberos_connections_select.assert_not_called()
class TestUserSerializerPasswordHash(TestCase):
"""Test UserSerializer password_hash support in blueprint context."""
def test_password_hash_sets_password_directly(self):
"""Test a valid password hash is stored without re-hashing."""
password = "test-password-123" # nosec
password_hash = make_password(password)
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": password_hash,
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
user = serializer.save()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
self.assertIsNotNone(user.password_change_date)
def test_password_hash_rejects_invalid_format(self):
"""Test invalid password hash values are rejected."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": "not-a-valid-hash",
},
context={SERIALIZER_CONTEXT_BLUEPRINT: True},
)
self.assertTrue(serializer.is_valid(), serializer.errors)
with self.assertRaises(ValidationError) as ctx:
serializer.save()
self.assertIn("Invalid password hash format", str(ctx.exception))
def test_password_hash_ignored_outside_blueprint_context(self):
"""Test password_hash is not accepted by the regular serializer."""
serializer = UserSerializer(
data={
"username": generate_id(),
"name": "Test User",
"password_hash": make_password("test"), # nosec
}
)
self.assertTrue(serializer.is_valid(), serializer.errors)
self.assertNotIn("password_hash", serializer.validated_data)

View File

@@ -3,7 +3,6 @@
from datetime import datetime, timedelta
from json import loads
from django.contrib.auth.hashers import make_password
from django.urls.base import reverse
from django.utils.timezone import now
from rest_framework.test import APITestCase
@@ -27,9 +26,6 @@ from authentik.flows.models import FlowAuthenticationRequirement, FlowDesignatio
from authentik.lib.generators import generate_id, generate_key
from authentik.stages.email.models import EmailStage
INVALID_PASSWORD_HASH = "not-a-valid-hash"
INVALID_PASSWORD_HASH_ERROR = "Invalid password hash format. Must be a valid Django password hash."
class TestUsersAPI(APITestCase):
"""Test Users API"""
@@ -38,20 +34,6 @@ class TestUsersAPI(APITestCase):
self.admin = create_test_admin_user()
self.user = create_test_user()
def _set_password_hash(self, user: User, password_hash: str, client=None):
return (client or self.client).post(
reverse("authentik_api:user-set-password-hash", kwargs={"pk": user.pk}),
data={"password": password_hash},
)
def _assert_password_hash_set(
self, user: User, password: str, password_hash: str, response
) -> None:
self.assertEqual(response.status_code, 204, response.data)
user.refresh_from_db()
self.assertEqual(user.password, password_hash)
self.assertTrue(user.check_password(password))
def test_filter_type(self):
"""Test API filtering by type"""
self.client.force_login(self.admin)
@@ -131,26 +113,6 @@ class TestUsersAPI(APITestCase):
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(response.content, {"password": ["This field may not be blank."]})
def test_set_password_hash(self):
"""Test setting a user's password from a hash."""
self.client.force_login(self.admin)
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(self.user, password_hash)
self._assert_password_hash_set(self.user, password, password_hash, response)
def test_set_password_hash_invalid(self):
"""Test invalid password hashes are rejected."""
self.client.force_login(self.admin)
response = self._set_password_hash(self.user, INVALID_PASSWORD_HASH)
self.assertEqual(response.status_code, 400)
self.assertJSONEqual(
response.content,
{"password": [INVALID_PASSWORD_HASH_ERROR]},
)
def test_recovery(self):
"""Test user recovery link"""
flow = create_test_flow(
@@ -299,29 +261,6 @@ class TestUsersAPI(APITestCase):
self.assertTrue(token_filter.exists())
self.assertTrue(token_filter.first().expiring)
def test_service_account_set_password_hash(self):
"""Service account password hash can be set through the API."""
self.client.force_login(self.admin)
response = self.client.post(
reverse("authentik_api:user-service-account"),
data={
"name": "test-sa",
"create_group": False,
},
)
self.assertEqual(response.status_code, 200, response.data)
body = loads(response.content)
user = User.objects.get(pk=body["user_pk"])
self.assertEqual(user.type, UserTypes.SERVICE_ACCOUNT)
self.assertFalse(user.has_usable_password())
password = generate_key()
password_hash = make_password(password)
response = self._set_password_hash(user, password_hash)
self._assert_password_hash_set(user, password, password_hash, response)
def test_service_account_no_expire(self):
"""Service account creation without token expiration"""
self.client.force_login(self.admin)

View File

@@ -12,7 +12,7 @@ from authentik.core.models import (
User,
UserTypes,
)
from authentik.core.signals import password_changed, password_hash_changed
from authentik.core.signals import password_changed
from authentik.enterprise.providers.ssf.models import (
EventTypes,
SSFProvider,
@@ -84,13 +84,14 @@ def ssf_user_session_delete_session_revoked(sender, instance: AuthenticatedSessi
)
def _send_password_credential_change(user: User, change_type: str):
@receiver(password_changed)
def ssf_password_changed_cred_change(sender, user: User, password: str | None, **_):
"""Credential change trigger (password changed)"""
send_ssf_events(
EventTypes.CAEP_CREDENTIAL_CHANGE,
{
"credential_type": "password",
"change_type": change_type,
"change_type": "revoke" if password is None else "update",
},
sub_id={
"format": "complex",
@@ -102,16 +103,6 @@ def _send_password_credential_change(user: User, change_type: str):
)
@receiver(password_hash_changed)
@receiver(password_changed)
def ssf_password_changed_cred_change(signal, sender, user: User, password: str | None = None, **_):
"""Credential change trigger (password changed)"""
if signal is password_hash_changed:
_send_password_credential_change(user, "update")
return
_send_password_credential_change(user, "revoke" if password is None else "update")
device_type_map = {
StaticDevice: "pin",
TOTPDevice: "pin",

View File

@@ -1,6 +1,5 @@
from uuid import uuid4
from django.contrib.auth.hashers import make_password
from django.urls import reverse
from rest_framework.test import APITestCase
@@ -53,21 +52,6 @@ class TestSignals(APITestCase):
)
self.assertEqual(res.status_code, 201, res.content)
def _assert_password_credential_change(self, user, change_type: str):
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], change_type)
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
def test_signal_logout(self):
"""Test user logout"""
user = create_test_user()
@@ -95,25 +79,19 @@ class TestSignals(APITestCase):
user.set_password(generate_id())
user.save()
self._assert_password_credential_change(user, "update")
def test_signal_password_change_from_hash(self):
"""Test user password change from a pre-hashed password."""
user = create_test_user()
self.client.force_login(user)
user.set_password_from_hash(make_password(generate_id()))
user.save()
self._assert_password_credential_change(user, "update")
def test_signal_password_revoke(self):
"""Test explicit password revoke."""
user = create_test_user()
self.client.force_login(user)
user.set_password(None)
user.save()
self._assert_password_credential_change(user, "revoke")
stream = Stream.objects.filter(provider=self.provider).first()
self.assertIsNotNone(stream)
event = StreamEvent.objects.filter(stream=stream).first()
self.assertIsNotNone(event)
self.assertEqual(event.status, SSFEventStatus.PENDING_FAILED)
event_payload = event.payload["events"][
"https://schemas.openid.net/secevent/caep/event-type/credential-change"
]
self.assertEqual(event_payload["change_type"], "update")
self.assertEqual(event_payload["credential_type"], "password")
self.assertEqual(event.payload["sub_id"]["format"], "complex")
self.assertEqual(event.payload["sub_id"]["user"]["format"], "email")
self.assertEqual(event.payload["sub_id"]["user"]["email"], user.email)
def test_signal_authenticator_added(self):
"""Test authenticator creation signal"""

View File

@@ -11,7 +11,7 @@ from django.http import HttpRequest
from rest_framework.request import Request
from authentik.core.models import AuthenticatedSession, User
from authentik.core.signals import login_failed, password_changed, password_hash_changed
from authentik.core.signals import login_failed, password_changed
from authentik.events.models import Event, EventAction
from authentik.flows.models import Stage
from authentik.flows.planner import (
@@ -112,15 +112,8 @@ def on_invitation_used(sender, request: HttpRequest, invitation: Invitation, **_
)
@receiver(password_hash_changed)
@receiver(password_changed)
def on_password_changed(
sender,
user: User,
password: str | None = None,
request: HttpRequest | None = None,
**_,
):
def on_password_changed(sender, user: User, password: str, request: HttpRequest | None, **_):
"""Log password change"""
Event.new(EventAction.PASSWORD_SET).from_http(request, user=user)

View File

@@ -2,7 +2,6 @@
from urllib.parse import urlencode
from django.contrib.auth.hashers import make_password
from django.contrib.contenttypes.models import ContentType
from django.test import RequestFactory, TestCase
from django.views.debug import SafeExceptionReporterFilter
@@ -11,7 +10,7 @@ from guardian.shortcuts import get_anonymous_user
from authentik.brands.models import Brand
from authentik.core.models import Group, User
from authentik.core.tests.utils import create_test_user
from authentik.events.models import Event, EventAction
from authentik.events.models import Event
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, FlowPlan
from authentik.flows.views.executor import QS_QUERY, SESSION_KEY_PLAN
from authentik.lib.generators import generate_id
@@ -214,14 +213,3 @@ class TestEvents(TestCase):
event = Event.new("unittest", foo="foo bar \u0000 baz")
event.save()
self.assertEqual(event.context["foo"], "foo bar baz")
def test_password_set_signal_on_set_password_from_hash(self):
"""Changing password from hash should still emit an audit event."""
user = create_test_user()
old_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
user.set_password_from_hash(make_password(generate_id()))
user.save()
new_count = Event.objects.filter(action=EventAction.PASSWORD_SET, user__pk=user.pk).count()
self.assertEqual(new_count, old_count + 1)

View File

@@ -23,7 +23,7 @@
height: 100%;
}
body {
background-image: url("{{ flow_background_url|iriencode|safe }}");
background-image: url("{{ flow_background_url }}");
background-repeat: no-repeat;
background-size: cover;
}

View File

@@ -39,7 +39,7 @@
<script src="{% versioned_script 'dist/flow/FlowInterface-%v.js' %}" type="module"></script>
<style data-id="flow-css">
:root {
--ak-global--background-image: url("{{ flow_background_url|iriencode|safe }}");
--ak-global--background-image: url("{{ flow_background_url }}");
}
</style>
{% endblock %}

View File

@@ -1,14 +1,12 @@
"""stage view tests"""
from collections.abc import Callable
from unittest.mock import patch
from django.test import RequestFactory, TestCase
from django.urls import reverse
from authentik.core.tests.utils import RequestFactory as AuthentikRequestFactory
from authentik.core.tests.utils import create_test_flow
from authentik.flows.models import Flow, FlowStageBinding
from authentik.flows.models import FlowStageBinding
from authentik.flows.stage import StageView
from authentik.flows.views.executor import FlowExecutorView
from authentik.lib.utils.reflection import all_subclasses
@@ -44,46 +42,6 @@ class TestViews(TestCase):
"/static/dist/assets/images/flow_background.jpg",
)
def test_flow_interface_css_background_preserves_presigned_url_query(self):
"""Test flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug})
)
self.assertContains(
response,
f'--ak-global--background-image: url("{background_url}");',
html=False,
)
def test_flow_sfe_css_background_preserves_presigned_url_query(self):
"""Test SFE flow CSS keeps signed URL query separators intact."""
flow = create_test_flow()
background_url = (
"https://s3.ca-central-1.amazonaws.com/example/media/public/background.png"
"?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=credential"
"&X-Amz-Signature=signature"
)
with patch.object(Flow, "background_url", return_value=background_url):
response = self.client.get(
reverse("authentik_core:if-flow", kwargs={"flow_slug": flow.slug}) + "?sfe"
)
self.assertContains(
response,
f'background-image: url("{background_url}");',
html=False,
)
def view_tester_factory(view_class: type[StageView]) -> Callable:
"""Test a form"""

View File

@@ -61,6 +61,11 @@ class SAMLProviderSerializer(ProviderSerializer):
url_download_metadata = SerializerMethodField()
url_issuer = SerializerMethodField()
# Unified SAML endpoint (primary)
url_unified = SerializerMethodField()
url_unified_init = SerializerMethodField()
# Legacy endpoints (for backward compatibility)
url_sso_post = SerializerMethodField()
url_sso_redirect = SerializerMethodField()
url_sso_init = SerializerMethodField()
@@ -107,6 +112,36 @@ class SAMLProviderSerializer(ProviderSerializer):
except Provider.application.RelatedObjectDoesNotExist:
return DEFAULT_ISSUER
def get_url_unified(self, instance: SAMLProvider) -> str:
"""Get unified SAML endpoint URL (handles SSO and SLO)"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_unified_init(self, instance: SAMLProvider) -> str:
"""Get IdP-initiated SAML URL"""
if "request" not in self._context:
return ""
request: HttpRequest = self._context["request"]._request
try:
return request.build_absolute_uri(
reverse(
"authentik_providers_saml:init",
kwargs={"application_slug": instance.application.slug},
)
)
except Provider.application.RelatedObjectDoesNotExist:
return "-"
def get_url_sso_post(self, instance: SAMLProvider) -> str:
"""Get SSO Post URL"""
if "request" not in self._context:
@@ -243,6 +278,8 @@ class SAMLProviderSerializer(ProviderSerializer):
"default_name_id_policy",
"url_download_metadata",
"url_issuer",
"url_unified",
"url_unified_init",
"url_sso_post",
"url_sso_redirect",
"url_sso_init",

View File

@@ -241,7 +241,7 @@ class SAMLProvider(Provider):
"""Use IDP-Initiated SAML flow as launch URL"""
try:
return reverse(
"authentik_providers_saml:sso-init",
"authentik_providers_saml:init",
kwargs={"application_slug": self.application.slug},
)
except Provider.application.RelatedObjectDoesNotExist:

View File

@@ -81,54 +81,35 @@ class MetadataProcessor:
element.text = name_id_format
yield element
def _get_unified_url(self) -> str:
"""Get the unified SAML endpoint URL"""
return self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:base",
kwargs={"application_slug": self.provider.application.slug},
)
)
def get_sso_bindings(self) -> Iterator[Element]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleSignOnService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:sso-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
"""Get all SSO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element = Element(f"{{{NS_SAML_METADATA}}}SingleSignOnService")
element.attrib["Binding"] = binding
element.attrib["Location"] = url
element.attrib["Location"] = unified_url
yield element
def get_slo_bindings(self) -> Iterator[Element]:
"""Get all Bindings supported"""
binding_url_map = {
(SAML_BINDING_REDIRECT, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-redirect",
kwargs={"application_slug": self.provider.application.slug},
)
),
(SAML_BINDING_POST, "SingleLogoutService"): self.http_request.build_absolute_uri(
reverse(
"authentik_providers_saml:slo-post",
kwargs={"application_slug": self.provider.application.slug},
)
),
}
for binding_svc, url in binding_url_map.items():
binding, svc = binding_svc
"""Get all SLO Bindings - both point to unified endpoint"""
unified_url = self._get_unified_url()
for binding in [SAML_BINDING_REDIRECT, SAML_BINDING_POST]:
if self.force_binding and self.force_binding != binding:
continue
element = Element(f"{{{NS_SAML_METADATA}}}{svc}")
element = Element(f"{{{NS_SAML_METADATA}}}SingleLogoutService")
element.attrib["Binding"] = binding
element.attrib["Location"] = url
element.attrib["Location"] = unified_url
yield element
def _prepare_signature(self, entity_descriptor: _Element):

View File

@@ -4,19 +4,26 @@ from django.urls import path
from authentik.providers.saml.api.property_mappings import SAMLPropertyMappingViewSet
from authentik.providers.saml.api.providers import SAMLProviderViewSet
from authentik.providers.saml.views import metadata, sso
from authentik.providers.saml.views import metadata, sso, unified
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
urlpatterns = [
# Base path for Issuer/Entity ID
# Unified Endpoint - handles SSO and SLO based on message type
path(
"<slug:application_slug>/",
sso.SAMLSSOBindingRedirectView.as_view(),
unified.SAMLUnifiedView.as_view(),
name="base",
),
# IdP-initiated
path(
"<slug:application_slug>/init/",
sso.SAMLSSOBindingInitView.as_view(),
name="init",
),
# LEGACY Endpoints (backward compatibility)
# SSO Bindings
path(
"<slug:application_slug>/sso/binding/redirect/",

View File

@@ -0,0 +1,118 @@
"""Unified SAML endpoint - handles SSO and SLO based on message type"""
from base64 import b64decode
from defusedxml.lxml import fromstring
from django.http import HttpRequest, HttpResponse
from django.utils.decorators import method_decorator
from django.views import View
from django.views.decorators.clickjacking import xframe_options_sameorigin
from django.views.decorators.csrf import csrf_exempt
from structlog.stdlib import get_logger
from authentik.common.saml.constants import NS_MAP
from authentik.flows.views.executor import SESSION_KEY_POST
from authentik.lib.views import bad_request_message
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
from authentik.providers.saml.views.flows import (
REQUEST_KEY_SAML_REQUEST,
REQUEST_KEY_SAML_RESPONSE,
)
from authentik.providers.saml.views.sp_slo import (
SPInitiatedSLOBindingPOSTView,
SPInitiatedSLOBindingRedirectView,
)
from authentik.providers.saml.views.sso import (
SAMLSSOBindingPOSTView,
SAMLSSOBindingRedirectView,
)
LOGGER = get_logger()
# SAML message type constants
SAML_MESSAGE_TYPE_AUTHN_REQUEST = "AuthnRequest"
SAML_MESSAGE_TYPE_LOGOUT_REQUEST = "LogoutRequest"
def detect_saml_message_type(saml_request: str, is_post_binding: bool) -> str | None:
"""Parse SAML request to determine if AuthnRequest or LogoutRequest."""
try:
if is_post_binding:
decoded_xml = b64decode(saml_request.encode())
else:
decoded_xml = decode_base64_and_inflate(saml_request)
root = fromstring(decoded_xml)
if len(root.xpath("//samlp:AuthnRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_AUTHN_REQUEST
if len(root.xpath("//samlp:LogoutRequest", namespaces=NS_MAP)):
return SAML_MESSAGE_TYPE_LOGOUT_REQUEST
return None
except Exception: # noqa: BLE001
return None
@method_decorator(xframe_options_sameorigin, name="dispatch")
@method_decorator(csrf_exempt, name="dispatch")
class SAMLUnifiedView(View):
"""Unified SAML endpoint - handles SSO and SLO based on message type.
The operation type is determined by parsing
the incoming SAML message:
- AuthnRequest -> SSO flow (delegates to SAMLSSOBindingRedirectView/POSTView)
- LogoutRequest -> SLO flow (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
- LogoutResponse -> SLO completion (delegates to SPInitiatedSLOBindingRedirectView/POSTView)
"""
def dispatch(self, request: HttpRequest, application_slug: str) -> HttpResponse:
"""Route the request based on SAML message type."""
# ak user was not logged in, redirected to login, and is back w POST payload in session
if SESSION_KEY_POST in request.session:
return self._delegate_to_sso(request, application_slug, is_post_binding=True)
# Determine binding from HTTP method
is_post_binding = request.method == "POST"
data = request.POST if is_post_binding else request.GET
# LogoutResponse - delegate to SLO view (handles it in dispatch)
if REQUEST_KEY_SAML_RESPONSE in data:
return self._delegate_to_slo(request, application_slug, is_post_binding)
# Check for SAML request
if REQUEST_KEY_SAML_REQUEST not in data:
LOGGER.info("SAML payload missing")
return bad_request_message(request, "The SAML request payload is missing.")
# Detect message type and delegate
saml_request = data[REQUEST_KEY_SAML_REQUEST]
message_type = detect_saml_message_type(saml_request, is_post_binding)
if message_type == SAML_MESSAGE_TYPE_AUTHN_REQUEST:
return self._delegate_to_sso(request, application_slug, is_post_binding)
elif message_type == SAML_MESSAGE_TYPE_LOGOUT_REQUEST:
return self._delegate_to_slo(request, application_slug, is_post_binding)
else:
LOGGER.warning("Unknown SAML message type", message_type=message_type)
return bad_request_message(
request, f"Unsupported SAML message type: {message_type or 'unknown'}"
)
def _delegate_to_sso(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SSO view."""
if is_post_binding:
view = SAMLSSOBindingPOSTView.as_view()
else:
view = SAMLSSOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)
def _delegate_to_slo(
self, request: HttpRequest, application_slug: str, is_post_binding: bool
) -> HttpResponse:
"""Delegate to the appropriate SLO view."""
if is_post_binding:
view = SPInitiatedSLOBindingPOSTView.as_view()
else:
view = SPInitiatedSLOBindingRedirectView.as_view()
return view(request, application_slug=application_slug)

View File

@@ -5537,14 +5537,6 @@
"minLength": 1,
"title": "Password"
},
"password_hash": {
"type": [
"string",
"null"
],
"minLength": 1,
"title": "Password hash"
},
"permissions": {
"type": "array",
"items": {

View File

@@ -11,7 +11,6 @@ context:
group_name: authentik Admins
email: !Env [AUTHENTIK_BOOTSTRAP_EMAIL, "root@example.com"]
password: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD, null]
password_hash: !Env [AUTHENTIK_BOOTSTRAP_PASSWORD_HASH, null]
token: !Env [AUTHENTIK_BOOTSTRAP_TOKEN, null]
entries:
- model: authentik_core.group
@@ -32,7 +31,6 @@ entries:
groups:
- !KeyOf admin-group
password: !Context password
password_hash: !Context password_hash
- model: authentik_core.token
state: created
conditions:

2
go.mod
View File

@@ -7,7 +7,7 @@ require (
beryju.io/radius-eap v0.1.0
github.com/avast/retry-go/v4 v4.7.0
github.com/coreos/go-oidc/v3 v3.18.0
github.com/getsentry/sentry-go v0.46.0
github.com/getsentry/sentry-go v0.45.1
github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1
github.com/go-ldap/ldap/v3 v3.4.13
github.com/go-openapi/runtime v0.29.4

4
go.sum
View File

@@ -20,8 +20,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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.46.0 h1:mbdDaarbUdOt9X+dx6kDdntkShLEX3/+KyOsVDTPDj0=
github.com/getsentry/sentry-go v0.46.0/go.mod h1:evVbw2qotNUdYG8KxXbAdjOQWWvWIwKxpjdZZIvcIPw=
github.com/getsentry/sentry-go v0.45.1 h1:9rfzJtGiJG+MGIaWZXidDGHcH5GU1Z5y0WVJGf9nysw=
github.com/getsentry/sentry-go v0.45.1/go.mod h1:XDotiNZbgf5U8bPDUAfvcFmOnMQQceESxyKaObSssW0=
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=

View File

@@ -13,8 +13,8 @@
"cross-env": "^10.1.0"
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
"node": ">=20",
"npm": ">=11.6.2"
}
},
"node_modules/@epic-web/invariant": {

View File

@@ -11,20 +11,7 @@
"cross-env": "^10.1.0"
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
},
"devEngines": {
"runtime": {
"name": "node",
"onFail": "warn",
"version": ">=24"
},
"packageManager": {
"name": "npm",
"version": ">=11.10.1",
"onFail": "warn"
}
},
"packageManager": "npm@11.11.0+sha512.f36811c4aae1fde639527368ae44c571d050006a608d67a191f195a801a52637a312d259186254aa3a3799b05335b7390539cf28656d18f0591a1125ba35f973"
"node": ">=20",
"npm": ">=11.6.2"
}
}

View File

@@ -7,17 +7,6 @@ ARG GIT_BUILD_HASH
ENV GIT_BUILD_HASH=$GIT_BUILD_HASH
ENV NODE_ENV=production
WORKDIR /work
RUN --mount=type=bind,target=/work/package.json,src=./package.json \
--mount=type=bind,target=/work/package-lock.json,src=./package-lock.json \
--mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/work/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/work/packages/logger-js/,src=./packages/logger-js/ \
node ./scripts/node/setup-corepack.mjs --force && \
node ./scripts/node/lint-runtime.mjs ./web
WORKDIR /work/web
# These files need to be copied and cannot be mounted as `npm ci` will build the client's typescript
@@ -29,7 +18,7 @@ RUN --mount=type=bind,target=/work/web/package.json,src=./web/package.json \
--mount=type=bind,target=/work/web/packages/sfe/package.json,src=./web/packages/sfe/package.json \
--mount=type=bind,target=/work/web/scripts,src=./web/scripts \
--mount=type=cache,id=npm-ak,sharing=shared,target=/root/.npm \
corepack npm ci
npm ci
COPY ./package.json /work
COPY ./web /work/web/

View File

@@ -10,22 +10,12 @@ WORKDIR /static
COPY ./packages /packages
COPY ./web/packages /static/packages
RUN --mount=type=bind,target=/static/package.json,src=./package.json \
--mount=type=bind,target=/static/package-lock.json,src=./package-lock.json \
--mount=type=bind,target=/static/web/package.json,src=./web/package.json \
--mount=type=bind,target=/static/web/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/static/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/static/packages/logger-js/,src=./packages/logger-js/ \
node ./scripts/node/setup-corepack.mjs --force && \
node ./scripts/node/lint-runtime.mjs ./web
COPY package.json /
RUN --mount=type=bind,target=/static/package.json,src=./web/package.json \
--mount=type=bind,target=/static/package-lock.json,src=./web/package-lock.json \
--mount=type=bind,target=/static/scripts,src=./web/scripts \
--mount=type=cache,target=/root/.npm \
corepack npm ci
npm ci
COPY web .
RUN npm run build-proxy

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-04-30 00:27+0000\n"
"POT-Creation-Date: 2026-04-28 00:30+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -224,14 +224,6 @@ msgid ""
"providers are returned. When set to false, backchannel providers are excluded"
msgstr ""
#: authentik/core/api/users.py
msgid "Invalid password hash format. Must be a valid Django password hash."
msgstr ""
#: authentik/core/api/users.py
msgid "Cannot set both password and password_hash. Use only one."
msgstr ""
#: authentik/core/api/users.py
msgid "No leading or trailing slashes allowed."
msgstr ""
@@ -400,10 +392,6 @@ msgstr ""
msgid "Open launch URL in a new browser tab or window."
msgstr ""
#: authentik/core/models.py
msgid "Hide this application from the user's My applications page."
msgstr ""
#: authentik/core/models.py
msgid "Application"
msgstr ""
@@ -2503,9 +2491,7 @@ msgid ""
msgstr ""
#: authentik/providers/saml/models.py
msgid ""
"Also known as EntityID. Providing a value overrides the default issuer "
"generated by authentik."
msgid "Also known as EntityID"
msgstr ""
#: authentik/providers/saml/models.py
@@ -2699,10 +2685,6 @@ msgstr ""
msgid "SAML NameID format"
msgstr ""
#: authentik/providers/saml/models.py
msgid "SAML Issuer used for this session"
msgstr ""
#: authentik/providers/saml/models.py
msgid "SAML Session"
msgstr ""

View File

@@ -19,7 +19,6 @@ Forti
Fortigate
Gatus
Gestionnaire
ghec
Gitea
Gravitee
Homarr

Binary file not shown.

View File

@@ -54,7 +54,6 @@ import type {
User,
UserAccountRequest,
UserConsent,
UserPasswordHashSetRequest,
UserPasswordSetRequest,
UserPath,
UserRecoveryEmailRequest,
@@ -105,7 +104,6 @@ import {
UserAccountRequestToJSON,
UserConsentFromJSON,
UserFromJSON,
UserPasswordHashSetRequestToJSON,
UserPasswordSetRequestToJSON,
UserPathFromJSON,
UserRecoveryEmailRequestToJSON,
@@ -510,11 +508,6 @@ export interface CoreUsersSetPasswordCreateRequest {
userPasswordSetRequest: UserPasswordSetRequest;
}
export interface CoreUsersSetPasswordHashCreateRequest {
id: number;
userPasswordHashSetRequest: UserPasswordHashSetRequest;
}
export interface CoreUsersUpdateRequest {
id: number;
userRequest: UserRequest;
@@ -5295,77 +5288,6 @@ export class CoreApi extends runtime.BaseAPI {
await this.coreUsersSetPasswordCreateRaw(requestParameters, initOverrides);
}
/**
* Creates request options for coreUsersSetPasswordHashCreate without sending the request
*/
async coreUsersSetPasswordHashCreateRequestOpts(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
): Promise<runtime.RequestOpts> {
if (requestParameters["id"] == null) {
throw new runtime.RequiredError(
"id",
'Required parameter "id" was null or undefined when calling coreUsersSetPasswordHashCreate().',
);
}
if (requestParameters["userPasswordHashSetRequest"] == null) {
throw new runtime.RequiredError(
"userPasswordHashSetRequest",
'Required parameter "userPasswordHashSetRequest" was null or undefined when calling coreUsersSetPasswordHashCreate().',
);
}
const queryParameters: any = {};
const headerParameters: runtime.HTTPHeaders = {};
headerParameters["Content-Type"] = "application/json";
if (this.configuration && this.configuration.accessToken) {
const token = this.configuration.accessToken;
const tokenString = await token("authentik", []);
if (tokenString) {
headerParameters["Authorization"] = `Bearer ${tokenString}`;
}
}
let urlPath = `/core/users/{id}/set_password_hash/`;
urlPath = urlPath.replace(`{${"id"}}`, encodeURIComponent(String(requestParameters["id"])));
return {
path: urlPath,
method: "POST",
headers: headerParameters,
query: queryParameters,
body: UserPasswordHashSetRequestToJSON(requestParameters["userPasswordHashSetRequest"]),
};
}
/**
* Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload.
*/
async coreUsersSetPasswordHashCreateRaw(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<runtime.ApiResponse<void>> {
const requestOptions =
await this.coreUsersSetPasswordHashCreateRequestOpts(requestParameters);
const response = await this.request(requestOptions, initOverrides);
return new runtime.VoidApiResponse(response);
}
/**
* Set a user\'s password from a pre-hashed Django password value. Submit the Django password hash in the shared ``password`` request field. This updates authentik\'s local password verifier only. It does not attempt to propagate the password change to LDAP or Kerberos because no raw password is available from the request payload.
*/
async coreUsersSetPasswordHashCreate(
requestParameters: CoreUsersSetPasswordHashCreateRequest,
initOverrides?: RequestInit | runtime.InitOverrideFunction,
): Promise<void> {
await this.coreUsersSetPasswordHashCreateRaw(requestParameters, initOverrides);
}
/**
* Creates request options for coreUsersUpdate without sending the request
*/

View File

@@ -266,6 +266,18 @@ export interface SAMLProvider {
* @memberof SAMLProvider
*/
readonly urlIssuer: string;
/**
* Get unified SAML endpoint URL (handles SSO and SLO)
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnified: string;
/**
* Get IdP-initiated SAML URL
* @type {string}
* @memberof SAMLProvider
*/
readonly urlUnifiedInit: string;
/**
* Get SSO Post URL
* @type {string}
@@ -328,6 +340,8 @@ export function instanceOfSAMLProvider(value: object): value is SAMLProvider {
if (!("urlDownloadMetadata" in value) || value["urlDownloadMetadata"] === undefined)
return false;
if (!("urlIssuer" in value) || value["urlIssuer"] === undefined) return false;
if (!("urlUnified" in value) || value["urlUnified"] === undefined) return false;
if (!("urlUnifiedInit" in value) || value["urlUnifiedInit"] === undefined) return false;
if (!("urlSsoPost" in value) || value["urlSsoPost"] === undefined) return false;
if (!("urlSsoRedirect" in value) || value["urlSsoRedirect"] === undefined) return false;
if (!("urlSsoInit" in value) || value["urlSsoInit"] === undefined) return false;
@@ -414,6 +428,8 @@ export function SAMLProviderFromJSONTyped(json: any, ignoreDiscriminator: boolea
: SAMLNameIDPolicyEnumFromJSON(json["default_name_id_policy"]),
urlDownloadMetadata: json["url_download_metadata"],
urlIssuer: json["url_issuer"],
urlUnified: json["url_unified"],
urlUnifiedInit: json["url_unified_init"],
urlSsoPost: json["url_sso_post"],
urlSsoRedirect: json["url_sso_redirect"],
urlSsoInit: json["url_sso_init"],
@@ -440,6 +456,8 @@ export function SAMLProviderToJSONTyped(
| "meta_model_name"
| "url_download_metadata"
| "url_issuer"
| "url_unified"
| "url_unified_init"
| "url_sso_post"
| "url_sso_redirect"
| "url_sso_init"

View File

@@ -1,70 +0,0 @@
/* tslint:disable */
/* eslint-disable */
/**
* authentik
* Making authentication simple.
*
* The version of the OpenAPI document: 2026.5.0-rc1
* Contact: hello@goauthentik.io
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
* Payload to set a users' password hash directly
* @export
* @interface UserPasswordHashSetRequest
*/
export interface UserPasswordHashSetRequest {
/**
*
* @type {string}
* @memberof UserPasswordHashSetRequest
*/
password: string;
}
/**
* Check if a given object implements the UserPasswordHashSetRequest interface.
*/
export function instanceOfUserPasswordHashSetRequest(
value: object,
): value is UserPasswordHashSetRequest {
if (!("password" in value) || value["password"] === undefined) return false;
return true;
}
export function UserPasswordHashSetRequestFromJSON(json: any): UserPasswordHashSetRequest {
return UserPasswordHashSetRequestFromJSONTyped(json, false);
}
export function UserPasswordHashSetRequestFromJSONTyped(
json: any,
ignoreDiscriminator: boolean,
): UserPasswordHashSetRequest {
if (json == null) {
return json;
}
return {
password: json["password"],
};
}
export function UserPasswordHashSetRequestToJSON(json: any): UserPasswordHashSetRequest {
return UserPasswordHashSetRequestToJSONTyped(json, false);
}
export function UserPasswordHashSetRequestToJSONTyped(
value?: UserPasswordHashSetRequest | null,
ignoreDiscriminator: boolean = false,
): any {
if (value == null) {
return value;
}
return {
password: value["password"],
};
}

View File

@@ -842,7 +842,6 @@ export * from "./UserLogoutStageRequest";
export * from "./UserMatchingModeEnum";
export * from "./UserOAuthSourceConnection";
export * from "./UserOAuthSourceConnectionRequest";
export * from "./UserPasswordHashSetRequest";
export * from "./UserPasswordSetRequest";
export * from "./UserPath";
export * from "./UserPlexSourceConnection";

View File

@@ -66,7 +66,7 @@ dependencies = [
"ua-parser==1.0.2",
"unidecode==1.4.0",
"urllib3<3",
"uvicorn[standard]==0.45.0",
"uvicorn[standard]==0.44.0",
"watchdog==6.0.0",
"webauthn==2.7.1",
"wsproto==1.3.2",

View File

@@ -4522,41 +4522,6 @@ paths:
description: Bad request
'403':
$ref: '#/components/responses/GenericErrorResponse'
/core/users/{id}/set_password_hash/:
post:
operationId: core_users_set_password_hash_create
description: |-
Set a user's password from a pre-hashed Django password value.
Submit the Django password hash in the shared ``password`` request field.
This updates authentik's local password verifier only. It does not attempt
to propagate the password change to LDAP or Kerberos because no raw password
is available from the request payload.
parameters:
- in: path
name: id
schema:
type: integer
description: A unique integer value identifying this User.
required: true
tags:
- core
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/UserPasswordHashSetRequest'
required: true
security:
- authentik: []
responses:
'204':
description: Successfully changed password
'400':
description: Bad request
'403':
$ref: '#/components/responses/GenericErrorResponse'
/core/users/{id}/used_by/:
get:
operationId: core_users_used_by_list
@@ -53856,6 +53821,14 @@ components:
type: string
description: Get Issuer/EntityID URL
readOnly: true
url_unified:
type: string
description: Get unified SAML endpoint URL (handles SSO and SLO)
readOnly: true
url_unified_init:
type: string
description: Get IdP-initiated SAML URL
readOnly: true
url_sso_post:
type: string
description: Get SSO Post URL
@@ -53895,6 +53868,8 @@ components:
- url_sso_init
- url_sso_post
- url_sso_redirect
- url_unified
- url_unified_init
- verbose_name
- verbose_name_plural
SAMLProviderImportRequest:
@@ -57623,15 +57598,6 @@ components:
- identifier
- source
- user
UserPasswordHashSetRequest:
type: object
description: Payload to set a users' password hash directly
properties:
password:
type: string
minLength: 1
required:
- password
UserPasswordSetRequest:
type: object
description: Payload to set a users' password directly

View File

@@ -1,278 +0,0 @@
#!/usr/bin/env node
/**
* @file Lints the package-lock.json file to ensure it is in sync with package.json.
*
* Usage:
* lint-lockfile [options] [directory]
*
* Options:
* --warn Report issues as warnings instead of failing. The lockfile is
* still regenerated on disk, but the process exits 0.
*
* Exit codes:
* 0 Lockfile is in sync (or --warn was passed)
* 1 Unexpected error
* 2 Lockfile drift detected
*/
/// <reference lib="esnext" />
import * as assert from "node:assert/strict";
import { findPackageJSON } from "node:module";
import { dirname } from "node:path";
import { isDeepStrictEqual, parseArgs } from "node:util";
import { ConsoleLogger } from "../../packages/logger-js/lib/node.js";
import { parseCWD, reportAndExit } from "./utils/commands.mjs";
import { corepack } from "./utils/corepack.mjs";
import { gitStatus } from "./utils/git.mjs";
import { findNPMPackage, loadJSON, npm, pluckDependencyFields } from "./utils/node.mjs";
//#region Constants
const logger = ConsoleLogger.prefix("lint:lockfile");
const { values: options, positionals } = parseArgs({
options: {
"warn": {
type: "boolean",
default: false,
description: "Report issues as warnings instead of failing",
},
"skip-git": {
type: "boolean",
default: !!process.env.CI,
description:
"Skip checking for uncommitted changes (use with --warn to ignore drift without reporting)",
},
},
allowPositionals: true,
});
const cwd = parseCWD(positionals);
const ignoredProperties = new Set([
// ---
"peer",
"engines",
"optional",
]);
//#region Utilities
/**
* @param {Record<string, unknown>} actual
* @param {Record<string, unknown>} expected
* @param {string[]} [prefix]
* @returns {Set<string>[]}
*/
function extractDiffedProperties(actual, expected, prefix = []) {
const a = actual ?? {};
const b = expected ?? {};
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
/** @type {Set<string>[]} */
const diffs = [];
for (const key of keys) {
const path = [...prefix, key];
const valA = a[key];
const valB = b[key];
if (
valA !== null &&
valB !== null &&
typeof valA === "object" &&
typeof valB === "object" &&
!Array.isArray(valA) &&
!Array.isArray(valB)
) {
// @ts-ignore
diffs.push(...extractDiffedProperties(valA, valB, path));
} else if (!isDeepStrictEqual(valA, valB)) {
diffs.push(new Set(path));
}
}
return diffs;
}
//#endregion
/**
* Exit code when lockfile drift is detected (distinct from general errors)
*/
const EXIT_DRIFT = 2;
/**
* @returns {Promise<string[]>} The list of issues detected.
*/
async function run() {
/** @type {string[]} */
const issues = [];
/**
* Records an issue. In strict mode, throws immediately.
* In warn mode, collects the message for later reporting.
*
* @param {boolean} ok
* @param {string} message
*/
const check = (ok, message) => {
if (ok) return;
if (options.warn) {
issues.push(message);
return;
}
assert.fail(message);
};
/**
* Checks deep equality of two values. In strict mode, throws if they are not equal.
* In warn mode, records an issue instead.
*
* @param {unknown} actual
* @param {unknown} expected
* @param {string} message
*/
const checkDeep = (actual, expected, message) => {
if (options.warn) {
if (!isDeepStrictEqual(actual, expected)) {
issues.push(message);
}
return;
}
assert.deepStrictEqual(actual, expected, message);
};
logger.info(`Linting lockfile integrity in: ${cwd}`);
// MARK: Locate files
const resolvedPath = import.meta.resolve(cwd);
const packageJSONPath = findPackageJSON(resolvedPath);
assert.ok(
packageJSONPath,
"Could not find package.json in the current directory or any parent directories",
);
const packageDir = dirname(packageJSONPath);
const { packageLockPath } = await findNPMPackage(packageDir);
const lockfileDir = dirname(packageLockPath);
const isWorkspace = lockfileDir !== packageDir;
const corepackVersion = await corepack`--version`().catch(() => null);
const useCorepack = !!corepackVersion;
logger.info(`corepack: ${corepackVersion || "disabled"}`);
const expected = {
lockfile: await loadJSON(packageLockPath),
package: await loadJSON(packageJSONPath).then(pluckDependencyFields),
};
logger.info(`package.json: ${packageJSONPath} (${expected.package.name})`);
logger.info(`package-lock.json: ${packageLockPath}${isWorkspace ? " (workspace root)" : ""}`);
// MARK: Uncommitted changes
if (options["skip-git"]) {
logger.warn("Skipping git status check");
} else {
const packageStatus = await gitStatus(packageJSONPath);
const lockfileStatus = await gitStatus(packageLockPath);
if (!packageStatus.available || !lockfileStatus.available) {
logger.warn("Git is not available; skipping uncommitted change detection.");
} else {
check(packageStatus.clean, `package.json has uncommitted changes: ${packageJSONPath}`);
check(
lockfileStatus.clean,
`package-lock.json has uncommitted changes: ${packageLockPath}`,
);
}
}
// MARK: Regenerate
const npmVersion = await npm`--version`({ useCorepack });
logger.info(`Detected npm version: ${npmVersion}`);
await npm`install --package-lock-only`({
cwd: lockfileDir,
useCorepack,
});
logger.info("npm install complete.");
const actual = {
lockfile: await loadJSON(packageLockPath),
package: await loadJSON(packageJSONPath).then(pluckDependencyFields),
};
// MARK: Compare
assert.deepStrictEqual(
actual.package,
expected.package,
`package.json was unexpectedly modified during lockfile check: ${packageJSONPath}`,
);
try {
checkDeep(
actual.lockfile,
expected.lockfile,
`package-lock.json is out of sync with package.json`,
);
} catch (error) {
if (!(error instanceof assert.AssertionError)) {
throw error;
}
// NPM versions <=11.10 has issues with deterministic lockfile generation,
// especially around optional peer dependencies.
const diffedProperties = extractDiffedProperties(actual.lockfile, expected.lockfile).filter(
(segments) => segments.isDisjointFrom(ignoredProperties),
);
if (diffedProperties.length) {
const formatted = diffedProperties
.map((segments) => Array.from(segments).join("."))
.join("\n");
throw new Error(`Lockfile drift detected:\n${formatted}`, { cause: error });
}
logger.warn(
"Permissible dependency differences detected. Run `npm install` to update the lockfile.",
);
}
return issues;
}
run()
.then((issues) => {
if (issues.length) {
logger.warn(`⚠️ ${issues.length} issue(s) detected:`);
for (const issue of issues) {
logger.warn(` - ${issue}`);
}
if (options.warn) {
logger.warn(
"The lockfile on disk has been regenerated. Review and commit the changes.",
);
process.exit(EXIT_DRIFT);
}
} else {
logger.info("✅ Lockfile is in sync.");
}
})
.catch((error) => reportAndExit(error, logger));

View File

@@ -1,114 +0,0 @@
#!/usr/bin/env node
/**
* @file Lints the installed Node.js and npm versions against the requirements specified in package.json.
*
* Usage:
* lint-node [options] [directory]
*
* Exit codes:
* 0 Versions are in sync
* 1 Version mismatch detected
*/
import * as assert from "node:assert/strict";
import { parseArgs } from "node:util";
import { ConsoleLogger } from "../../packages/logger-js/lib/node.js";
import { CommandError, parseCWD, reportAndExit } from "./utils/commands.mjs";
import { corepack } from "./utils/corepack.mjs";
import { resolveRepoRoot } from "./utils/git.mjs";
import { compareVersions, findNPMPackage, loadJSON, node, npm, parseRange } from "./utils/node.mjs";
const logger = ConsoleLogger.prefix("lint-runtime");
/**
* @param {string} start
*/
async function readRequirements(start) {
const { packageJSONPath } = await findNPMPackage(start);
logger.info(`Checking versions in ${packageJSONPath}`);
const packageJSONData = await loadJSON(packageJSONPath);
const nodeVersion = await node`--version`().then((output) => output.replace(/^v/, ""));
const requiredNpmVersion = packageJSONData.engines?.npm;
const requiredNodeVersion = packageJSONData.engines?.node;
return { nodeVersion, requiredNpmVersion, requiredNodeVersion };
}
async function main() {
const parsedArgs = parseArgs({
allowPositionals: true,
});
const cwd = parseCWD(parsedArgs.positionals);
const repoRoot = await resolveRepoRoot(cwd).catch(() => null);
logger.info(`cwd ${cwd}`);
logger.info(`repository ${repoRoot || "not found"}`);
const corepackVersion = await corepack`--version`().catch(() => null);
const useCorepack = !!corepackVersion;
logger.info(`corepack ${corepackVersion || "disabled"}`);
const npmVersion = await npm`--version`({ cwd, useCorepack })
.then((version) => {
logger.info(`npm${corepackVersion ? " (via Corepack)" : ""} ${version}`);
return version;
})
.catch((error) => {
if (error instanceof CommandError && corepackVersion) {
logger.warn(`Failed to read npm version via Corepack ${error.message}`);
logger.info(`Attempting to read npm version directly without Corepack...`);
// Corepack might be misconfigured or outdated.
// Attempting a second read without Corepack can help us distinguish
// between a general npm issue and a Corepack-specific one.
return npm`--version`({ cwd }).then((version) => {
logger.info(`npm (direct) ${version}`);
return version;
});
}
throw error;
});
const { nodeVersion, requiredNpmVersion, requiredNodeVersion } = await readRequirements(cwd);
logger.info(`node ${nodeVersion}`);
if (requiredNpmVersion) {
logger.info(`package.json npm ${requiredNpmVersion}`);
const { operator, version: required } = parseRange(requiredNpmVersion);
const result = compareVersions(npmVersion, required);
assert.ok(
operator === ">=" ? result >= 0 : result === 0,
`npm version ${npmVersion} does not satisfy required version ${requiredNpmVersion}`,
);
}
if (requiredNodeVersion) {
logger.info(`package.json node ${requiredNodeVersion}`);
const { operator, version: required } = parseRange(requiredNodeVersion);
const result = compareVersions(nodeVersion, required);
assert.ok(
operator === ">=" ? result >= 0 : result === 0,
`Node.js version ${nodeVersion} does not satisfy required version ${requiredNodeVersion}`,
);
}
}
main()
.then(() => {
logger.info("✅ Node.js and npm versions are in sync.");
})
.catch((error) => reportAndExit(error, logger));

View File

@@ -1,94 +0,0 @@
#!/usr/bin/env node
/**
* @file Downloads the latest corepack tarball from the npm registry.
*/
import * as fs from "node:fs/promises";
import { parseArgs } from "node:util";
import { ConsoleLogger } from "../../packages/logger-js/lib/node.js";
import { $, parseCWD, reportAndExit } from "./utils/commands.mjs";
import { corepack, pullLatestCorepack } from "./utils/corepack.mjs";
import { resolveRepoRoot } from "./utils/git.mjs";
import { findNPMPackage, loadJSON, npm } from "./utils/node.mjs";
const FALLBACK_NPM_VERSION = "11.11.0";
const logger = ConsoleLogger.prefix("setup-corepack");
async function main() {
const parsedArgs = parseArgs({
options: {
force: {
type: "boolean",
default: false,
description: "Force re-download of corepack even if a version is already installed",
},
},
allowPositionals: true,
});
const cwdArg = parseCWD(parsedArgs.positionals);
const repoRoot = await resolveRepoRoot(cwdArg).catch(() => null);
const cwd = repoRoot || cwdArg;
const npmVersion = await npm`--version`({ cwd });
logger.info(`npm ${npmVersion}`);
const corepackVersion = await corepack`--version`({ cwd }).catch(() => null);
logger.info(`corepack ${corepackVersion || "not found"}`);
if (corepackVersion && !parsedArgs.values.force) {
logger.info("Corepack is already installed, skipping download (use --force to override)");
return;
}
await pullLatestCorepack(cwd);
await npm`install --force -g corepack@latest`({ cwd });
logger.info("Corepack installed successfully");
const { packageJSONPath } = await findNPMPackage(cwd);
logger.info(`Checking versions in ${packageJSONPath}`);
const packageJSONData = await loadJSON(packageJSONPath);
const packageManager = packageJSONData.packageManager || `npm@${FALLBACK_NPM_VERSION}`;
await $`corepack install -g ${packageManager}`({ cwd });
logger.info(`Setting up Corepack to use ${packageManager}...`);
const writablePackageJSON = await fs.access(packageJSONPath, fs.constants.W_OK).then(
() => true,
() => false,
);
/**
* @type {string}
*/
let subcommand;
if (!writablePackageJSON) {
if (!packageJSONData.packageManager) {
throw new Error(
`package.json is not writable and does not specify a packageManager field. Was the package.json file mounted via Docker?`,
);
}
subcommand = "install -g";
} else {
logger.info("package.json is writable");
subcommand = "use";
}
await $`corepack ${subcommand} ${packageManager}`({ cwd });
logger.info("Corepack installed npm successfully");
}
main().catch((error) => reportAndExit(error, logger));

View File

@@ -1,116 +0,0 @@
/**
* Utility functions for running shell commands and handling their results.
*
* @import { ExecOptions } from "node:child_process"
*/
import { exec } from "node:child_process";
import { resolve, sep } from "node:path";
import { promisify } from "node:util";
import { ConsoleLogger } from "../../../packages/logger-js/lib/node.js";
const logger = ConsoleLogger.prefix("commands");
export class CommandError extends Error {
name = "CommandError";
/**
* @param {string} command
* @param {ErrorOptions & ExecOptions} options
*/
constructor(command, { cause, cwd, shell } = {}) {
const cwdInfo = cwd ? ` in directory ${cwd}` : "";
const shellInfo = shell ? ` using shell ${shell}` : "";
super(`Command failed: ${command}${cwdInfo}${shellInfo}`, { cause });
}
}
/**
* @param {string[]} positionals
* @returns {string} The resolved current working directory for the script
*/
export function parseCWD(positionals) {
// `INIT_CWD` is present only if the script is run via npm.
const initCWD = process.env.INIT_CWD || process.cwd();
const cwd = (positionals.length ? resolve(initCWD, positionals[0]) : initCWD) + sep;
return cwd;
}
const execAsync = promisify(exec);
/**
* @param {Awaited<ReturnType<typeof execAsync>>} result
*/
export const trimResult = (result) => String(result.stdout).trim();
/**
* @typedef {(strings: TemplateStringsArray, ...expressions: unknown[]) =>
* (options?: ExecOptions) => Promise<string>
* } CommandTag
*/
function createTag(prefix = "") {
/** @type {CommandTag} */
return (strings, ...expressions) => {
const command = (prefix ? prefix + " " : "") + String.raw(strings, ...expressions);
logger.debug(command);
return (options) =>
execAsync(command, options)
.then(trimResult)
.catch((cause) => {
throw new CommandError(command, { ...options, cause });
});
};
}
/**
* A tagged template function for running shell commands.
* @type {CommandTag & { bind(prefix: string): CommandTag }}
*/
export const $ = createTag();
/**
* @param {string} prefix
* @returns {CommandTag}
*/
$.bind = (prefix) => createTag(prefix);
/**
* Promisified version of {@linkcode exec} for easier async/await usage.
*
* @param {string} command The command to run, with space-separated arguments.
* @param {ExecOptions} [options] Optional execution options.
* @throws {CommandError} If the command fails to execute.
*/
export function $2(command, options) {
return execAsync(command, options)
.then(trimResult)
.catch((cause) => {
throw new CommandError(command, { ...options, cause });
});
}
/**
* Logs the given error and its cause (if any) and exits the process with a failure code.
* @param {unknown} error
* @param {typeof ConsoleLogger} logger
* @returns {never}
*/
export function reportAndExit(error, logger = ConsoleLogger) {
const message = error instanceof Error ? error.message : String(error);
const cause = error instanceof Error && error.cause instanceof Error ? error.cause : null;
logger.error(`${message}`);
if (cause) {
logger.error(`Caused by: ${cause.message}`);
}
process.exit(1);
}

View File

@@ -1,84 +0,0 @@
import * as crypto from "node:crypto";
import * as fs from "node:fs/promises";
import { join, relative } from "node:path";
import { ConsoleLogger } from "../../../packages/logger-js/lib/node.js";
import { $ } from "./commands.mjs";
const REGISTRY_URL = "https://registry.npmjs.org/corepack";
const OUTPUT_DIR = join(".corepack", "releases");
const OUTPUT_FILENAME = "latest.tgz";
export const corepack = $.bind("corepack");
/**
* Reads the installed Corepack version.
*
* @param {string} [cwd] The directory to run the command in.
* @returns {Promise<string | null>} The installed Corepack version
*/
export function readCorepackVersion(cwd = process.cwd()) {
return $`corepack --version`({ cwd });
}
const logger = ConsoleLogger.prefix("setup-corepack");
/**
* @param {string} baseDirectory
*/
export async function pullLatestCorepack(baseDirectory = process.cwd()) {
logger.info("Fetching corepack metadata from registry...");
const outputDir = join(baseDirectory, OUTPUT_DIR);
const outputPath = join(outputDir, OUTPUT_FILENAME);
const res = await fetch(REGISTRY_URL, { signal: AbortSignal.timeout(1000 * 60) });
if (!res.ok) {
throw new Error(`Failed to fetch registry metadata: ${res.status} ${res.statusText}`);
}
const metadata = await res.json();
const latestVersion = metadata["dist-tags"].latest;
const versionData = metadata.versions[latestVersion];
const tarballUrl = versionData.dist.tarball;
const expectedIntegrity = versionData.dist.integrity;
logger.info(`Latest corepack version: ${latestVersion}`);
logger.info(`Tarball URL: ${tarballUrl}`);
logger.info(`Expected integrity: ${expectedIntegrity}`);
logger.info({ url: tarballUrl }, "Downloading tarball...");
const tarballRes = await fetch(tarballUrl, {
signal: AbortSignal.timeout(1000 * 60),
});
if (!tarballRes.ok) {
throw new Error(
`Failed to download tarball: ${tarballRes.status} ${tarballRes.statusText}`,
);
}
const tarballBuffer = Buffer.from(await tarballRes.arrayBuffer());
logger.info("Verifying integrity...");
const [algorithm, expectedHash] = expectedIntegrity.split("-");
const actualHash = crypto.createHash(algorithm).update(tarballBuffer).digest("base64");
if (actualHash !== expectedHash) {
throw new Error(
`Integrity mismatch!\n Expected: ${expectedHash}\n Actual: ${actualHash}`,
);
}
logger.info("Integrity verified.");
await fs.mkdir(outputDir, { recursive: true });
await fs.writeFile(outputPath, tarballBuffer);
logger.info(`Saved to ${relative(baseDirectory, outputPath)}`);
logger.info(`corepack@${latestVersion} (${expectedIntegrity})`);
}

View File

@@ -1,25 +0,0 @@
import { $ } from "./commands.mjs";
/**
* Checks whether the given file has uncommitted changes in git.
*
* @param {string} filePath
* @param {string} [cwd]
* @returns {Promise<{ clean: boolean, available: boolean }>}
*/
export async function gitStatus(filePath, cwd = process.cwd()) {
return $`git status --porcelain ${filePath}`({ cwd })
.then((output) => ({ clean: !output, available: true }))
.catch(() => ({ clean: false, available: false }));
}
/**
* Finds the root directory of the git repository containing the given directory.
*
* @param {string} cwd
* @returns {Promise<string>} The path to the git repository root.
* @throws {Error} If the command fails (e.g., not a git repository).
*/
export function resolveRepoRoot(cwd = process.cwd()) {
return $`git rev-parse --show-toplevel`({ cwd });
}

View File

@@ -1,175 +0,0 @@
/**
* Utility functions for working with npm packages and versions.
*
* @import { ExecOptions } from "node:child_process"
*/
import * as fs from "node:fs/promises";
import { dirname, join } from "node:path";
import { $ } from "./commands.mjs";
/**
* Find the nearest directory containing both package.json and package-lock.json,
* starting from the given directory and walking upward.
*
* @param {string} start The directory to start searching from.
* @returns {Promise<{ packageJSONPath: string, packageLockPath: string }>}
* @throws {Error} If no co-located package.json and package-lock.json are found.
*/
export async function findNPMPackage(start) {
let currentDir = start;
while (currentDir !== dirname(currentDir)) {
const packageJSONPath = join(currentDir, "package.json");
const packageLockPath = join(currentDir, "package-lock.json");
try {
await Promise.all([fs.access(packageJSONPath), fs.access(packageLockPath)]);
return {
packageJSONPath,
packageLockPath,
};
} catch {
// Continue searching up the directory tree
}
currentDir = dirname(currentDir);
}
throw new Error(`No co-located package.json and package-lock.json found above ${start}`);
}
/**
* @typedef {object} PackageJSON
* @property {string} name
* @property {string} version
* @property {Record<string, string>} [dependencies]
* @property {Record<string, string>} [devDependencies]
* @property {Record<string, string>} [peerDependencies]
* @property {Record<string, string>} [optionalDependencies]
* @property {Record<string, string>} [peerDependenciesMeta]
* @property {Record<string, string>} [engines]
* @property {Record<string, string>} [devEngines]
* @property {string} [packageManager]
*/
/**
* @param {string} jsonPath
* @returns {Promise<PackageJSON>}
*/
export function loadJSON(jsonPath) {
return fs
.readFile(jsonPath, "utf-8")
.then(JSON.parse)
.catch((cause) => {
throw new Error(`Failed to load JSON file at ${jsonPath}`, { cause });
});
}
const PackageJSONComparisionFields = /** @type {const} */ ([
"name",
"dependencies",
"devDependencies",
"optionalDependencies",
"peerDependencies",
"peerDependenciesMeta",
]);
/**
* @typedef {typeof PackageJSONComparisionFields[number]} PackageJSONComparisionField
*/
/**
* Extracts only the dependency fields from a package.json object for comparison purposes.
*
* @param {PackageJSON} data
* @returns {Pick<PackageJSON, PackageJSONComparisionField>}
*/
export function pluckDependencyFields(data) {
/**
* @type {Record<string, unknown>}
*/
const result = {};
for (const field of PackageJSONComparisionFields) {
if (data[field]) {
result[field] = data[field];
}
}
return /** @type {Pick<PackageJSON, PackageJSONComparisionField>} */ (result);
}
//#region Versioning
/**
* Compares two semantic version strings (e.g., "14.17.0").
*
* @param {string} a The first version string.
* @param {string} b The second version string.
* @returns {number}
*/
export function compareVersions(a, b) {
const pa = a.split(".").map(Number);
const pb = b.split(".").map(Number);
for (let i = 0; i < 3; i++) {
if (pa[i] > pb[i]) return 1;
if (pa[i] < pb[i]) return -1;
}
return 0;
}
/**
* Runs a Node.js command and returns its stdout output as a string.
*
* @param {TemplateStringsArray} strings
* @param {...unknown} expressions
* @returns {(options?: ExecOptions) => Promise<string>}
*/
export const node = $.bind("node");
/**
* @typedef {object} NPMCommandOptions
* @property {boolean} [useCorepack] Whether to prefix the command with "corepack " to use Corepack's shims.
* @returns {Promise<string>}
*/
/**
* Runs an npm command and returns its stdout output as a string.
*
* @param {TemplateStringsArray} strings
* @param {...unknown} expressions
* @returns {(options?: ExecOptions & NPMCommandOptions) => Promise<string>}
*/
export function npm(strings, ...expressions) {
const subcommand = String.raw(strings, ...expressions);
return ({ useCorepack, ...options } = {}) => {
const command = [useCorepack ? "corepack" : "", "npm", subcommand]
.filter(Boolean)
.join(" ");
return $`${command}`(options);
};
}
/**
* Parses a version range string, stripping any leading >= and normalizing to three parts.
* @param {string} range
* @returns {{ operator: ">=" | "=", version: string }}
*/
export function parseRange(range) {
const hasGte = range.startsWith(">=");
const raw = hasGte ? range.slice(2) : range;
const parts = raw.split(".").map(Number);
while (parts.length < 3) parts.push(0);
return {
operator: hasGte ? ">=" : "=",
version: parts.join("."),
};
}
//#endregion

View File

@@ -83,8 +83,7 @@ pub(crate) fn start(tasks: &mut Tasks) -> Result<Arc<Metrics>> {
"metrics",
router.clone(),
addr,
config::get().debug, /* Allow failure in case the server is running on the same
* machine, like in dev */
true, // Allow failure in case the server is running on the same machine, like in dev
)?;
}
@@ -93,8 +92,7 @@ pub(crate) fn start(tasks: &mut Tasks) -> Result<Arc<Metrics>> {
"metrics",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
config::get().debug, /* Allow failure in case the server is running on the same machine,
* like in dev */
true, // Allow failure in case the server is running on the same machine, like in dev
)?;
Ok(metrics)

View File

@@ -328,8 +328,8 @@ pub(crate) fn start(_cli: Cli, tasks: &mut Tasks) -> Result<Arc<Workers>> {
"worker",
router.clone(),
addr,
config::get().debug, /* Allow failure in case the server is running on the same
* machine, like in dev. */
true, /* Allow failure in case the server is running on the same machine, like
* in dev. */
)?;
}
@@ -338,8 +338,7 @@ pub(crate) fn start(_cli: Cli, tasks: &mut Tasks) -> Result<Arc<Workers>> {
"worker",
router,
unix::net::SocketAddr::from_pathname(socket_path())?,
config::get().debug, /* Allow failure in case the server is running on the same
* machine, like in dev. */
true, // Allow failure in case the server is running on the same machine, like in dev.
)?;
}

8
uv.lock generated
View File

@@ -375,7 +375,7 @@ requires-dist = [
{ name = "ua-parser", specifier = "==1.0.2" },
{ name = "unidecode", specifier = "==1.4.0" },
{ name = "urllib3", specifier = "<3" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.45.0" },
{ name = "uvicorn", extras = ["standard"], specifier = "==0.44.0" },
{ name = "watchdog", specifier = "==6.0.0" },
{ name = "webauthn", specifier = "==2.7.1" },
{ name = "wsproto", specifier = "==1.3.2" },
@@ -3808,15 +3808,15 @@ socks = [
[[package]]
name = "uvicorn"
version = "0.45.0"
version = "0.44.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/62b0d9a2cfc8b4de6771322dae30f2db76c66dae9ec32e94e176a44ad563/uvicorn-0.45.0.tar.gz", hash = "sha256:3fe650df136c5bd2b9b06efc5980636344a2fbb840e9ddd86437d53144fa335d", size = 87818, upload-time = "2026-04-21T10:43:46.815Z" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/da/6eee1ff8b6cbeed47eeb5229749168e81eb4b7b999a1a15a7176e51410c9/uvicorn-0.44.0.tar.gz", hash = "sha256:6c942071b68f07e178264b9152f1f16dfac5da85880c4ce06366a96d70d4f31e", size = 86947, upload-time = "2026-04-06T09:23:22.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/88/d0f7512465b166a4e931ccf7e77792be60fb88466a43964c7566cbaff752/uvicorn-0.45.0-py3-none-any.whl", hash = "sha256:2db26f588131aeec7439de00f2dd52d5f210710c1f01e407a52c90b880d1fd4f", size = 69838, upload-time = "2026-04-21T10:43:45.029Z" },
{ url = "https://files.pythonhosted.org/packages/b7/23/a5bbd9600dd607411fa644c06ff4951bec3a4d82c4b852374024359c19c0/uvicorn-0.44.0-py3-none-any.whl", hash = "sha256:ce937c99a2cc70279556967274414c087888e8cec9f9c94644dfca11bd3ced89", size = 69425, upload-time = "2026-04-06T09:23:21.524Z" },
]
[package.optional-dependencies]

View File

@@ -77,8 +77,6 @@ export class FormFixture extends PageFixture {
/**
* Search for a row containing the given text.
*
* @returns A locator for the row entry matching the query.
*/
public search = async (
query: string,

View File

@@ -1,8 +1,6 @@
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { PageFixture, PageFixtureInit } from "#e2e/fixtures/PageFixture";
import { expect, Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
@@ -13,8 +11,6 @@ export interface LoginInit {
username?: string;
password?: string;
to?: URL | string;
rememberMe?: boolean;
page?: Page;
}
export interface SessionFixtureInit extends PageFixtureInit {
@@ -40,10 +36,6 @@ export class SessionFixture extends PageFixture {
public $passwordStage = this.page.locator("ak-stage-password");
public $passwordField = this.page.getByLabel("Password");
public $rememberMeCheckbox = this.page.getByRole("checkbox", {
name: "Remember me on this device",
});
/**
* The button to submit the the login flow,
* typically redirecting to the authenticated interface.
@@ -74,45 +66,19 @@ export class SessionFixture extends PageFixture {
/**
* Log into the application.
*/
public async login(
{
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
rememberMe,
}: LoginInit = {},
page = this.page,
): Promise<void> {
public async login({
username = GOOD_USERNAME,
password = GOOD_PASSWORD,
to = SessionFixture.pathname,
}: LoginInit = {}) {
this.logger.info("Logging in...");
const initialURL = new URL(page.url());
const initialURL = new URL(this.page.url());
if (initialURL.pathname === SessionFixture.pathname) {
this.logger.info("Skipping navigation because we're already in a authentication flow");
} else {
await page.goto(to.toString());
}
if (typeof rememberMe === "boolean") {
const rememberMeCheckboxVisible = await this.$rememberMeCheckbox.isVisible();
if (rememberMeCheckboxVisible) {
if (rememberMe) {
await this.$rememberMeCheckbox.check();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is checked",
).toBeChecked();
} else {
await this.$rememberMeCheckbox.uncheck();
await expect(
this.$rememberMeCheckbox,
"Remember me checkbox is unchecked",
).not.toBeChecked();
}
}
await this.page.goto(to.toString());
}
await this.$usernameField.fill(username);
@@ -136,7 +102,7 @@ export class SessionFixture extends PageFixture {
//#region Navigation
public async toLoginPage(page: Page = this.page) {
await page.goto(SessionFixture.pathname);
public async toLoginPage() {
await this.page.goto(SessionFixture.pathname);
}
}

2109
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,6 +13,7 @@
"format": "wireit",
"lint": "eslint --fix .",
"lint:imports": "knip --config scripts/knip.config.ts",
"lint:lockfile": "wireit",
"lint:types": "wireit",
"lint-check": "eslint --max-warnings 0 .",
"lit-analyse": "wireit",
@@ -153,7 +154,7 @@
"globals": "^17.5.0",
"guacamole-common-js": "^1.5.0",
"hastscript": "^9.0.1",
"knip": "^6.6.0",
"knip": "^6.4.1",
"lex": "^2025.11.0",
"lit": "^3.3.2",
"lit-analyzer": "^2.0.3",
@@ -267,6 +268,11 @@
"build-locales"
]
},
"lint:lockfile": {
"__comment": "The lockfile-lint package does not have an option to ensure resolved hashes are set everywhere",
"shell": true,
"command": "sh ./scripts/lint-lockfile.sh package-lock.json"
},
"lit-analyse": {
"command": "lit-analyzer src"
},
@@ -275,7 +281,8 @@
"dependencies": [
"lint",
"lint:types",
"lint:components"
"lint:components",
"lint:lockfile"
]
},
"storybook:build": {
@@ -296,7 +303,7 @@
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
"npm": ">=11.6.2"
},
"devEngines": {
"runtime": {
@@ -306,11 +313,10 @@
},
"packageManager": {
"name": "npm",
"version": ">=11.10.1",
"version": "11.10.1",
"onFail": "warn"
}
},
"packageManager": "npm@11.11.0+sha512.f36811c4aae1fde639527368ae44c571d050006a608d67a191f195a801a52637a312d259186254aa3a3799b05335b7390539cf28656d18f0591a1125ba35f973",
"prettier": "@goauthentik/prettier-config",
"overrides": {
"@goauthentik/esbuild-plugin-live-reload": {
@@ -346,7 +352,6 @@
"rapidoc": {
"@apitools/openapi-parser": "0.0.37"
},
"tree-sitter": false,
"typescript-eslint": {
"typescript": "$typescript"
}

View File

@@ -52,6 +52,6 @@
},
"engines": {
"node": ">=24",
"npm": ">=11.10.1"
"npm": ">=11.6.2"
}
}

21
web/scripts/lint-lockfile.sh Executable file
View File

@@ -0,0 +1,21 @@
#!/usr/bin/env bash
if ! command -v jq >/dev/null 2>&1 ; then
echo "This check requires the jq program be installed."
echo "To install jq, visit"
echo " https://jqlang.github.io/jq/"
exit 1
fi
CMD=$(jq -r '.packages | to_entries[] | select((.key | contains("node_modules")) and (.value | has("resolved") | not)) | .key' < "$1")
if [ -n "$CMD" ]; then
echo "ERROR package-lock.json entries missing 'resolved' field:"
echo ""
# Shellcheck erroneously believes that shell string substitution can be used here, but that
# feature lacks a "start of line" discriminator.
# shellcheck disable=SC2001
echo "$CMD" | sed 's/^/ /g'
echo ""
exit 1
fi

View File

@@ -3,7 +3,6 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -76,7 +75,7 @@ export class GoogleWorkspaceProviderUserList extends Table<GoogleWorkspaceProvid
}
protected override rowLabel(item: GoogleWorkspaceProviderUser): string {
return formatUserDisplayName(item.userObj);
return item.userObj.name || item.userObj.username;
}
protected columns: TableColumn[] = [

View File

@@ -3,7 +3,6 @@ import "#elements/forms/ModalForm";
import "#elements/sync/SyncObjectForm";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -76,7 +75,7 @@ export class MicrosoftEntraProviderUserList extends Table<MicrosoftEntraProvider
}
protected override rowLabel(item: MicrosoftEntraProviderUser): string {
return formatUserDisplayName(item.userObj);
return item.userObj.name || item.userObj.username;
}
protected columns: TableColumn[] = [

View File

@@ -391,28 +391,20 @@ export class SAMLProviderViewPage extends AKElement {
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SSO URL (Post)")}</span
>${msg("SAML Endpoint")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SSO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoRedirect)}"
value="${ifDefined(this.provider.urlUnified)}"
/>
<p class="pf-c-form__helper-text">
${msg(
"SAML provider endpoint. Use this URL for SP configuration.",
)}
</p>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
@@ -424,33 +416,7 @@ export class SAMLProviderViewPage extends AKElement {
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSsoInit)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Post)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloPost)}"
/>
</div>
<div class="pf-c-form__group">
<label class="pf-c-form__label">
<span class="pf-c-form__label-text"
>${msg("SLO URL (Redirect)")}</span
>
</label>
<input
class="pf-c-form-control"
readonly
type="text"
value="${ifDefined(this.provider.urlSloRedirect)}"
value="${ifDefined(this.provider.urlUnifiedInit)}"
/>
</div>
</form>

View File

@@ -4,7 +4,6 @@ import "#elements/sync/SyncObjectForm";
import "#admin/common/ak-flow-search/ak-flow-search-no-default";
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -74,7 +73,7 @@ export class SCIMProviderUserList extends Table<SCIMProviderUser> {
}
protected override rowLabel(item: SCIMProviderUser): string {
return formatUserDisplayName(item.userObj);
return item.userObj.name || item.userObj.username;
}
protected columns: TableColumn[] = [

View File

@@ -1,5 +1,4 @@
import { DEFAULT_CONFIG } from "#common/api/config";
import { formatUserDisplayName } from "#common/users";
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
import { SlottedTemplateResult } from "#elements/types";
@@ -26,7 +25,7 @@ export class SCIMSourceUserList extends Table<SCIMSourceUser> {
}
protected override rowLabel(item: SCIMSourceUser): string {
return formatUserDisplayName(item.userObj);
return item.userObj.name || item.userObj.username;
}
protected columns: TableColumn[] = [

View File

@@ -2,30 +2,20 @@ import "#elements/buttons/SpinnerButton/index";
import "#elements/forms/HorizontalFormElement";
import { DEFAULT_CONFIG } from "#common/api/config";
import { PFSize } from "#common/enums";
import { Form } from "#elements/forms/Form";
import { ifPresent } from "#elements/utils/attributes";
import { FocusTarget } from "#elements/utils/focus";
import { AKLabel } from "#components/ak-label";
import { CoreApi, UserPasswordSetRequest } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { html, nothing, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
@customElement("ak-user-password-form")
export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public static shadowRootOptions: ShadowRootInit = {
...Form.shadowRootOptions,
delegatesFocus: true,
};
public static override verboseName = msg("Password");
public static override verboseNamePlural = msg("Passwords");
public static override submittingVerb = msg("Setting");
public override submitLabel = msg("Set Password");
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
@@ -33,9 +23,6 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#region Properties
public override submitLabel = msg("Set Password");
public override successMessage = msg("Successfully updated password.");
@property({ type: Number })
public instancePk?: number;
@@ -43,15 +30,13 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
public label = msg("New Password");
@property({ type: String })
public placeholder = msg("Type a new password...");
public placeholder = msg("New Password");
@property({ type: String, useDefault: true })
public username: string | null = null;
@property({ type: String })
public username?: string;
@property({ type: String, useDefault: true })
public email: string | null = null;
public override size = PFSize.Medium;
@property({ type: String })
public email?: string;
/**
* The autocomplete attribute to use for the password field.
@@ -65,15 +50,17 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
//#endregion
public override getSuccessMessage(): string {
return msg("Successfully updated password.");
}
public override connectedCallback(): void {
super.connectedCallback();
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override firstUpdated(): void {
requestAnimationFrame(() => {
this.focus();
});
this.focus();
}
protected override async send(data: UserPasswordSetRequest): Promise<void> {
@@ -107,26 +94,17 @@ export class UserPasswordForm extends Form<UserPasswordSetRequest> {
/>`
: nothing}
<ak-form-element-horizontal required name="password">
${AKLabel(
{
slot: "label",
className: "pf-c-form__group-label",
htmlFor: "password",
required: true,
},
this.label,
)}
<ak-form-element-horizontal label=${this.label} required name="password">
<input
autofocus
${this.autofocusTarget.toRef()}
id="password"
type="password"
value=""
class="pf-c-form-control"
required
placeholder=${ifPresent(this.placeholder || this.label)}
autocomplete=${ifPresent(this.autocomplete)}
placeholder=${ifDefined(this.placeholder || this.label)}
aria-label=${this.label}
autocomplete=${ifDefined(this.autocomplete)}
/>
</ak-form-element-horizontal>`;
}

View File

@@ -1,5 +1,3 @@
import { formatUserDisplayName } from "#common/users";
import { modalInvoker } from "#elements/dialogs";
import { LitFC } from "#elements/types";
@@ -54,7 +52,7 @@ export const RecoveryButtons: LitFC<RecoveryButtonsProps> = ({
class="pf-c-button pf-m-secondary ${buttonClasses || ""}"
type="button"
${modalInvoker(UserPasswordForm, {
headline: msg(str`Update ${formatUserDisplayName(user)}'s password`),
headline: msg(str`Update ${user.name || user.username}'s password`),
username: user.username,
email: user.email,
instancePk: user.pk,

View File

@@ -1,140 +0,0 @@
/**
* @file Storage utilities.
*/
import { ConsoleLogger } from "#logger/browser";
/**
* A utility class for safely accessing web storage (localStorage or sessionStorage) with error handling.
*/
export class StorageAccessor {
constructor(
/**
* The key under which the value is stored in the storage backend.
*/
public readonly key: string,
/**
* The storage backend to use, e.g. `window.localStorage` or `window.sessionStorage`.
*/
protected readonly storage: Storage,
protected logger = ConsoleLogger.prefix("storage-accessor"),
) {
if (typeof key !== "string") {
throw new TypeError("Storage key must be a string");
}
if (!key) {
throw new TypeError("Storage key must be a non-empty string");
}
}
/**
* Create a {@link StorageAccessor} for local storage.
*
* @param key The key under which the value is stored in localStorage.
*/
public static local = (key: string) => new StorageAccessor(key, self.localStorage);
/**
* Create a {@link StorageAccessor} for session storage.
*
* @param key The key under which the value is stored in sessionStorage.
*/
public static session = (key: string) => new StorageAccessor(key, self.sessionStorage);
/**
* Read the value from storage.
*
* @param fallback An optional value to return if the key does not exist or an error occurs. Defaults to `null`.
*
* @returns The stored value, or `null` if the key does not exist or an error occurs.
*/
public read<T extends string>(fallback?: T): T | null {
try {
const value = this.storage.getItem(this.key);
return value !== null ? (value as T) : (fallback ?? null);
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public write(value: string | null): boolean {
if (!value) {
if (this.read()) {
return this.delete();
}
return true;
}
try {
this.storage.setItem(this.key, value);
return true;
} catch (_error: unknown) {
return false;
}
}
/**
* Read the value from storage and parse it as JSON.
*
* @param fallback An optional value to return if the key does not exist, the value is not valid JSON, or an error occurs. Defaults to `null`.
*
* @returns The parsed value, or `null` if the key does not exist, the value is not valid JSON, or an error occurs.
*/
public readJSON<T>(fallback?: T): T | null {
const value = this.read<string>();
if (value === null) {
return fallback ?? null;
}
try {
return JSON.parse(value) as T;
} catch (_error: unknown) {
return fallback ?? null;
}
}
/**
* Write a value to storage after stringifying it as JSON.
*
* @param value The value to store.
*
* @returns `true` if the value was successfully stored, or `false` if an error occurred.
*/
public writeJSON(value: unknown): boolean {
try {
const stringified = JSON.stringify(value);
return this.write(stringified);
} catch (error: unknown) {
this.logger.error("Failed to write JSON value to storage", error);
return false;
}
}
/**
* Delete the value from storage.
*
* @returns `true` if the value was successfully deleted, or `false` if an error occurred.
*/
public delete(): boolean {
this.logger.debug("Deleting value from storage");
try {
this.storage.removeItem(this.key);
return true;
} catch (error: unknown) {
this.logger.error("Failed to delete value from storage", error);
return false;
}
}
}

View File

@@ -207,7 +207,6 @@ export class NavigationButtons extends WithNotifications(WithSession(AKElement))
<a
href="${globalAK().api.base}flows/-/default/invalidation/"
class="pf-c-button pf-m-plain"
aria-label=${msg("Sign out")}
>
<pf-tooltip position="top" content=${msg("Sign out")}>
<i class="fas fa-sign-out-alt" aria-hidden="true"></i>

View File

@@ -34,7 +34,6 @@ export const MDXAnchor = ({
const nextURL = new URL(nextPathname, import.meta.env.AK_DOCS_URL);
// Remove trailing .md and .mdx, and trailing "index".
nextURL.pathname = nextURL.pathname.replace(/(index)?\.mdx?$/, "");
// eslint-disable-next-line react-hooks/immutability
href = nextURL.toString();
}

View File

@@ -414,13 +414,6 @@ export class Form<T = Record<string, unknown>, D = T>
const { submittingVerb, verboseName } = this.constructor as typeof Form;
if (!verboseName) {
return msg(str`${submittingVerb}...`, {
id: "form.submitting.no-entity",
desc: "The message shown while a form is being submitted, when no entity name is provided.",
});
}
return msg(str`${submittingVerb} ${verboseName}...`, {
id: "form.submitting",
desc: "The message shown while a form is being submitted.",
@@ -622,7 +615,6 @@ export class Form<T = Record<string, unknown>, D = T>
protected doSubmit = (event: SubmitEvent): void => {
if (this.submitting) {
this.logger.info("Skipping submit. Already submitting!");
return;
}
this.submitting = true;

View File

@@ -4,44 +4,6 @@
import { createRef, ref, Ref } from "lit/directives/ref.js";
export interface FocusErrorOptions extends ErrorOptions {
target: Element | null;
}
export class FocusAssertionError extends Error {
public override name = "FocusAssertionError";
public readonly target: Element | null;
constructor(message: string, { target, ...options }: FocusErrorOptions) {
super(message, options);
this.target = target;
}
}
export function assertFocusable(target: Element | null | undefined): asserts target is HTMLElement {
if (!target) {
throw new FocusAssertionError("Skipping focus, no target", { target: null });
}
if (!(target instanceof HTMLElement)) {
throw new FocusAssertionError("Skipping focus, target is not an HTMLElement", { target });
}
if (document.activeElement === target) {
throw new FocusAssertionError("Target is already focused", { target });
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
throw new FocusAssertionError("Skipping focus, target is not visible", { target });
}
if (typeof target.focus !== "function") {
throw new FocusAssertionError("Skipping focus, target has no focus method", { target });
}
}
/**
* Recursively check if the target element or any of its children are active (i.e. "focused").
*
@@ -74,17 +36,35 @@ export function isActiveElement(
* @category DOM
*/
export function isFocusable(target: Element | null | undefined): target is HTMLElement {
try {
assertFocusable(target);
return true;
} catch (error) {
if (error instanceof FocusAssertionError) {
console.debug(error.message, error.target);
} else {
console.error("Unexpected error during focus assertion", error);
}
if (!target) {
console.debug("FocusTarget: Skipping focus, no target", target);
return false;
}
if (!(target instanceof HTMLElement)) {
console.debug("FocusTarget: Skipping focus, target is not an HTMLElement", target);
return false;
}
if (document.activeElement === target) {
console.debug("FocusTarget: Target is already focused", target);
return false;
}
// Despite our type definitions, this method isn't available in all browsers,
// so we fallback to assuming the element is visible.
const visible = target.checkVisibility?.() ?? true;
if (!visible) {
console.debug("FocusTarget: Skipping focus, target is not visible", target);
return false;
}
if (typeof target.focus !== "function") {
console.debug("FocusTarget: Skipping focus, target has no focus method", target);
return false;
}
return true;
}
/**

View File

@@ -4,7 +4,6 @@ import { ifPresent } from "#elements/utils/attributes";
import { isDefaultAvatar } from "#elements/utils/images";
import Styles from "#flow/FormStatic.css";
import { RememberMeStorage } from "#flow/stages/identification/controllers/RememberMeController";
import { StageChallengeLike } from "#flow/types";
import { msg, str } from "@lit/localize";
@@ -70,9 +69,7 @@ export const FlowUserDetails: LitFC<FlowUserDetailsProps> = ({ challenge }) => {
${flowInfo?.cancelUrl
? html`
<div slot="link">
<a href=${flowInfo.cancelUrl} @click=${RememberMeStorage.reset}
>${msg("Not you?")}</a
>
<a href=${flowInfo.cancelUrl}>${msg("Not you?")}</a>
</div>
`
: nothing}

View File

@@ -121,10 +121,9 @@ export class InputPassword extends AKElement {
//#region Refs
@property({ attribute: false, useDefault: true })
public inputRef: Ref<HTMLInputElement> = createRef();
inputRef: Ref<HTMLInputElement> = createRef();
public toggleVisibilityRef = createRef<HTMLButtonElement>();
toggleVisibilityRef: Ref<HTMLButtonElement> = createRef();
//#endregion

View File

@@ -55,7 +55,7 @@ export abstract class BaseStage<Tin extends StageChallengeLike, Tout = unknown>
@intersectionObserver()
public visible = false;
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
protected autofocusTarget = new FocusTarget();
focus = this.autofocusTarget.focus;
#visibilityListener = () => {

View File

@@ -12,7 +12,7 @@ import { AKLabel } from "#components/ak-label";
import { BaseStage } from "#flow/stages/base";
import AutoRedirect from "#flow/stages/identification/controllers/AutoRedirectController";
import CaptchaDisplayController from "#flow/stages/identification/controllers/CaptchaDisplayController";
import RememberMeController from "#flow/stages/identification/controllers/RememberMeController";
import RememberMe from "#flow/stages/identification/controllers/RememberMeController";
import WebauthnController from "#flow/stages/identification/controllers/WebauthnController";
import Styles from "#flow/stages/identification/styles.css";
@@ -30,7 +30,6 @@ import { match } from "ts-pattern";
import { msg, str } from "@lit/localize";
import { html, nothing, PropertyValues, ReactiveControllerHost } from "lit";
import { createRef, ref } from "lit-html/directives/ref.js";
import { customElement, property } from "lit/decorators.js";
import { repeat } from "lit/directives/repeat.js";
@@ -46,6 +45,8 @@ type IdentificationFooter = Partial<Pick<IdentificationChallenge, "enrollUrl" |
export type IdentificationHost = IdentificationStage & ReactiveControllerHost;
type EmptyString = string | null | undefined;
export const PasswordManagerPrefill: {
password?: string;
totp?: string;
@@ -81,26 +82,21 @@ export class IdentificationStage extends BaseStage<
PFFormControl,
PFTitle,
PFButton,
...RememberMeController.styles,
...RememberMe.styles,
Styles,
];
/**
* The ID of the identifier input field, used for accessibility and focus management.
* The ID of the input field.
*
* @attr
*/
@property({ type: String, attribute: "input-id" })
public inputID = "ak-identifier-input";
protected passwordFieldRef = createRef<HTMLInputElement>();
#form?: HTMLFormElement;
public defaultUserIdentification: string | null = null;
protected rememberMeController: RememberMeController | null = null;
private rememberMe = new RememberMe(this);
#autoRedirect = new AutoRedirect(this);
#captcha = new CaptchaDisplayController(this);
#webauthn = new WebauthnController(this);
@@ -113,23 +109,15 @@ export class IdentificationStage extends BaseStage<
super();
// We _define and instantiate_ these fields above, then _read_ them here, and that satisfies
// the lint pass that there are no unused private fields.
this.addController(this.rememberMe);
this.addController(this.#autoRedirect);
this.addController(this.#captcha);
this.addController(this.#webauthn);
}
#prepareRememberMeFrame = -1;
public override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has("challenge") && this.challenge) {
cancelAnimationFrame(this.#prepareRememberMeFrame);
this.#prepareRememberMeFrame = requestAnimationFrame(() => {
this.prepareRememberMeController();
});
this.#createHelperForm();
}
}
@@ -139,46 +127,10 @@ export class IdentificationStage extends BaseStage<
this.addEventListener("focus", this.autofocusTarget.toEventListener());
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
cancelAnimationFrame(this.#prepareRememberMeFrame);
}
public override firstUpdated(): void {
this.focus();
}
protected prepareRememberMeController(): void {
if (!this.challenge) return;
const { enableRememberMe, pendingUserIdentifier = null } = this.challenge;
if (!enableRememberMe) {
this.defaultUserIdentification = pendingUserIdentifier;
if (this.rememberMeController) {
this.removeController(this.rememberMeController);
this.rememberMeController = null;
}
return;
}
if (!this.rememberMeController) {
this.rememberMeController = new RememberMeController(this, {
identificationFieldID: this.inputID,
identificationFieldRef: this.autofocusTarget.reference,
passwordFieldRef: this.passwordFieldRef,
pendingUserIdentifier,
});
this.addController(this.rememberMeController);
}
this.defaultUserIdentification = this.rememberMeController.defaultUserIdentification;
}
//#endregion
//#region Helper Form
@@ -295,11 +247,11 @@ export class IdentificationStage extends BaseStage<
id: string,
type: string,
label: string,
initialUserIdentification: string | null,
username: EmptyString,
autocomplete: string,
) {
return html`<input
${ref(this.autofocusTarget.reference)}
${this.autofocusTarget.toRef()}
id=${id}
type=${type}
name="uidField"
@@ -308,57 +260,56 @@ export class IdentificationStage extends BaseStage<
autocomplete=${autocomplete}
spellcheck="false"
class="pf-c-form-control"
value=${initialUserIdentification ?? ""}
value=${username ?? ""}
required
/>`;
}
protected renderPasswordFields(challenge: IdentificationChallenge) {
const { allowShowPassword } = challenge;
return html`<ak-flow-input-password
.inputRef=${this.passwordFieldRef}
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password> `;
return html`
<ak-flow-input-password
label=${msg("Password")}
input-id="ak-stage-identification-password"
class="pf-c-form__group"
.errors=${challenge.responseErrors?.password}
?allow-show-password=${allowShowPassword}
prefill=${PasswordManagerPrefill.password ?? ""}
></ak-flow-input-password>
`;
}
protected renderInput(challenge: IdentificationChallenge) {
const { flowDesignation, passwordFields, passwordlessUrl, primaryAction, userFields } =
challenge;
const {
flowDesignation,
passwordFields,
passwordlessUrl,
pendingUserIdentifier,
primaryAction,
userFields,
} = challenge;
const fields = (userFields || []).sort();
if (fields.length === 0) {
return html`<p>${msg("Select one of the options below to continue.")}</p>`;
}
const {
inputID,
defaultUserIdentification: initialUserIdentification,
rememberMeController,
} = this;
const { inputID, rememberMe } = this;
const offerRecovery = flowDesignation === FlowDesignationEnum.Recovery;
const type = fields.length === 1 && fields[0] === UserFieldsEnum.Email ? "email" : "text";
const label = OR_LIST_FORMATTERS.format(fields.map((f) => UI_FIELDS[f]));
const username = rememberMe.username ?? pendingUserIdentifier;
// When webauthn is enabled, add "webauthn" to autocomplete to enable passkey autofill
const autocomplete: AutoFill = this.#webauthn.live ? "username webauthn" : "username";
console.debug(
"Rendering identification stage with fields:",
fields,
initialUserIdentification,
);
// prettier-ignore
return html`${offerRecovery ? this.renderRecoveryMessage() : nothing}
<div class="pf-c-form__group">
${AKLabel({ required: true, htmlFor: inputID }, label)}
${this.renderUidField(inputID, type, label, initialUserIdentification, autocomplete)}
${rememberMeController?.renderToggleInput() ?? null}
${this.renderUidField(inputID, type, label, username, autocomplete)}
${rememberMe.render()}
${AKFormErrors({ errors: challenge.responseErrors?.uid_field })}
</div>
${passwordFields ? this.renderPasswordFields(challenge) : nothing}

View File

@@ -1,35 +1,11 @@
import { StorageAccessor } from "#common/storage";
import { getCookie } from "#common/utils";
import { ReactiveElementHost } from "#elements/types";
import type { IdentificationStage } from "#flow/stages/identification/IdentificationStage";
import { ConsoleLogger } from "#logger/browser";
import { msg } from "@lit/localize";
import { css, html, ReactiveController } from "lit";
import { createRef, Ref } from "lit-html/directives/ref.js";
import { css, html, nothing, ReactiveController, ReactiveControllerHost } from "lit";
export class RememberMeStorage {
static readonly user = StorageAccessor.local("authentik-remember-me-user");
static readonly session = StorageAccessor.local("authentik-remember-me-session");
static reset = () => {
this.user.delete();
this.session.delete();
};
}
function readSessionID() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
export interface RememberMeControllerInit {
pendingUserIdentifier: string | null;
identificationFieldRef: Ref<HTMLInputElement>;
passwordFieldRef: Ref<HTMLInputElement> | null;
identificationFieldID: string;
}
type RememberMeHost = ReactiveControllerHost & IdentificationStage;
/**
* Remember the user's `username` "on this device."
@@ -48,7 +24,7 @@ export interface RememberMeControllerInit {
* came back to this view after reaching the identity proof phase, indicating they pressed the "not
* you?" link, at which point it begins again to record the username as it is typed in.
*/
export class RememberMeController implements ReactiveController {
export class RememberMe implements ReactiveController {
static readonly styles = [
css`
.remember-me-switch {
@@ -59,178 +35,121 @@ export class RememberMeController implements ReactiveController {
`,
];
//#region Lifecycle
public username?: string;
public readonly identificationFieldRef: Ref<HTMLInputElement>;
public readonly passwordFieldRef: Ref<HTMLInputElement> | null;
public readonly defaultChecked: boolean;
public readonly defaultUserIdentification: string | null;
public readonly identificationFieldID: string;
#trackRememberMe = () => {
if (!this.#usernameField || this.#usernameField.value === undefined) {
return;
}
this.username = this.#usernameField.value;
localStorage?.setItem("authentik-remember-me-user", this.username);
};
protected logger = ConsoleLogger.prefix("controller/remember-me");
protected autoSubmitAttempts = 0;
protected currentSessionID = readSessionID();
// When active, save current details and record every keystroke to the username.
// When inactive, clear all fields and remove keystroke recorder.
#toggleRememberMe = () => {
if (!this.#rememberMeToggle || !this.#rememberMeToggle.checked) {
localStorage?.removeItem("authentik-remember-me-user");
localStorage?.removeItem("authentik-remember-me-session");
this.username = undefined;
this.#usernameField?.removeEventListener("keyup", this.#trackRememberMe);
return;
}
if (!this.#usernameField) {
return;
}
localStorage?.setItem("authentik-remember-me-user", this.#usernameField.value);
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
this.#usernameField.addEventListener("keyup", this.#trackRememberMe);
};
constructor(
protected host: ReactiveElementHost<IdentificationStage>,
{
identificationFieldRef,
passwordFieldRef,
identificationFieldID,
}: RememberMeControllerInit,
) {
this.identificationFieldRef = identificationFieldRef;
this.passwordFieldRef = passwordFieldRef || null;
this.identificationFieldID = identificationFieldID;
constructor(private host: RememberMeHost) {}
const persistedSessionID = RememberMeStorage.session.read();
// Record a stable token that we can use between requests to track if we've
// been here before. If we can't, clear out the username.
public hostConnected() {
try {
const sessionId = localStorage.getItem("authentik-remember-me-session");
if (!!this.#localSession && sessionId === this.#localSession) {
this.username = undefined;
localStorage?.removeItem("authentik-remember-me-user");
}
localStorage?.setItem("authentik-remember-me-session", this.#localSession);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
if (persistedSessionID && persistedSessionID !== this.currentSessionID) {
this.logger.debug("Session ID mismatch, clearing remembered username");
RememberMeStorage.user.delete();
get #localSession() {
return (getCookie("authentik_csrf") ?? "").substring(0, 8);
}
get #usernameField() {
return this.host.renderRoot.querySelector(
'input[name="uidField"]',
) as HTMLInputElement | null;
}
get #rememberMeToggle() {
return this.host.renderRoot.querySelector(
"#authentik-remember-me",
) as HTMLInputElement | null;
}
get #submitButton() {
return this.host.renderRoot.querySelector('button[type="submit"]') as HTMLButtonElement;
}
get #isEnabled() {
return this.host.challenge?.enableRememberMe && typeof localStorage !== "undefined";
}
get #canAutoSubmit() {
return (
!!this.host.challenge &&
!!this.username &&
!!this.#usernameField?.value &&
!this.host.challenge.passwordFields &&
!this.host.challenge.passwordlessUrl
);
}
// Before the page is updated, try to extract the username from localstorage.
public hostUpdate() {
if (!this.#isEnabled) {
return;
}
const persistedUserIdentifier = RememberMeStorage.user.read();
this.defaultUserIdentification =
persistedUserIdentifier || this.host.challenge?.pendingUserIdentifier || null;
this.defaultChecked = !!persistedUserIdentifier;
try {
this.username = localStorage.getItem("authentik-remember-me-user") || undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (_e: any) {
this.username = undefined;
}
}
// After the page is updated, if everything is ready to go, do the autosubmit.
public hostUpdated() {
if (this.canAutoSubmit() && this.autoSubmitAttempts === 0) {
this.autoSubmitAttempts++;
this.host.submitForm?.();
if (this.#isEnabled && this.#canAutoSubmit) {
this.#submitButton?.click();
}
}
//#region Event Listeners
#writeFrameID = -1;
public inputListener = (event: InputEvent) => {
cancelAnimationFrame(this.#writeFrameID);
const { value } = event.target as HTMLInputElement;
this.#writeFrameID = requestAnimationFrame(() => {
RememberMeStorage.user.write(value);
});
};
//#endregion
//#region Public API
/**
* Toggle the "remember me" feature on or off.
*
* When toggled on, the current username is saved to localStorage and will be automatically
* submitted on future visits. Additionally, every keystroke in the username field will update
* the stored username.
*
* When toggled off, any stored username is cleared from localStorage, and the keystroke listener
* is removed to stop updating the stored username.
*/
public toggleChangeListener = (event: Event) => {
const checkbox = event.target as HTMLInputElement;
const { usernameField, passwordField } = this;
if (!checkbox.checked) {
this.logger.debug("Disabling remember me");
RememberMeStorage.reset();
if (usernameField) {
usernameField.removeEventListener("input", this.inputListener);
usernameField.focus();
usernameField.select();
}
return;
}
if (!usernameField) {
this.logger.warn("Cannot enable remember me: no username field found");
return;
}
const focusTarget = passwordField && usernameField?.value ? passwordField : usernameField;
if (focusTarget) {
focusTarget.focus();
focusTarget.select();
}
this.logger.debug("Enabling remember me for user");
RememberMeStorage.user.write(usernameField.value);
RememberMeStorage.session.write(this.currentSessionID);
usernameField.addEventListener("input", this.inputListener, {
passive: true,
});
};
/**
* Determines if the "remember me" feature can be automatically submitted, which requires:
*
* - An active challenge.
* - A stored username from a previous session.
* - The identifier input field to be present in the DOM.
* - No password fields or passwordless URL, indicating we can skip directly to the next step.
*/
public canAutoSubmit(): boolean {
const { challenge } = this.host;
if (!challenge) return false;
if (!challenge.enableRememberMe) return false;
if (challenge.passwordFields) return false;
if (challenge.passwordlessUrl) return false;
if (!this.defaultChecked) return false;
return !!this.usernameField?.value;
public render() {
return this.#isEnabled
? html` <label class="pf-c-switch remember-me-switch">
<input
class="pf-c-switch__input"
id="authentik-remember-me"
@click=${this.#toggleRememberMe}
type="checkbox"
?checked=${!!this.username}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`
: nothing;
}
//#endregion
//#region Rendering
protected readonly checkboxRef = createRef<HTMLInputElement>();
protected get usernameField() {
return this.identificationFieldRef.value || null;
}
protected get passwordField() {
return this.passwordFieldRef?.value || null;
}
protected get checkboxToggle() {
return this.checkboxRef.value || null;
}
public renderToggleInput = () => {
return html`<label
class="pf-c-switch remember-me-switch"
for="authentik-remember-me"
aria-description=${msg(
"When enabled, your username will be remembered on this device for future logins.",
)}
>
<input
class="pf-c-switch__input"
type="checkbox"
id="authentik-remember-me"
@change=${this.toggleChangeListener}
?checked=${this.defaultChecked}
/>
<span class="pf-c-form__label">${msg("Remember me on this device")}</span>
</label>`;
};
//#endregion
}
export default RememberMeController;
export default RememberMe;

View File

@@ -179,7 +179,7 @@ test.describe("Groups", () => {
});
});
test("Edit group from view page", async ({ form, pointer, page }, testInfo) => {
test("Edit group from view page", async ({ navigator, form, pointer, page }, testInfo) => {
const groupName = groupNames.get(testInfo.testId)!;
const { fill, search } = form;

View File

@@ -17,7 +17,11 @@ test.describe("Provider Wizard", () => {
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
await test.step("Authenticate", async () => session.login());
await test.step("Authenticate", async () => {
await session.login({
to: "/if/admin/#/core/providers",
});
});
await test.step("Navigate to provider wizard", async () => {
await expect(dialog, "Dialog is initially closed").toBeHidden();

View File

@@ -1,119 +0,0 @@
import { expect, test } from "#e2e";
import { FormFixture } from "#e2e/fixtures/FormFixture";
import { NavigatorFixture } from "#e2e/fixtures/NavigatorFixture";
import { GOOD_USERNAME, SessionFixture } from "#e2e/fixtures/SessionFixture";
import type { Page } from "@playwright/test";
const REMEMBER_ME_USER_KEY = "authentik-remember-me-user";
const REMEMBER_ME_SESSION_KEY = "authentik-remember-me-session";
const IDENTIFICATION_STAGE_NAME = "default-authentication-identification";
const readStoredUserIdentifier = (page: Page) =>
page.evaluate((k) => localStorage.getItem(k), REMEMBER_ME_USER_KEY);
test.describe("Session Lifecycle", () => {
test.beforeAll(
'Ensure "Enable Remember me on this device" is on for the default identification stage',
async ({ browser }, { title: testName }) => {
if (Date.now()) return;
const context = await browser.newContext();
const page = await context.newPage();
const navigator = new NavigatorFixture(page, testName);
const form = new FormFixture(page, testName);
const session = new SessionFixture({ page, testName, navigator });
await test.step("Authenticate", async () =>
session.login({
to: "/if/admin/#/flow/stages",
page,
}));
const $stage = await test.step("Find stage via search", () =>
form.search(IDENTIFICATION_STAGE_NAME, page));
await $stage.getByRole("button", { name: "Edit Stage" }).click();
const dialog = page.getByRole("dialog", { name: "Edit Identification Stage" });
await expect(dialog, "Edit modal opens after clicking edit").toBeVisible();
await form.setInputCheck(`Enable "Remember me on this device"`, true, dialog);
await dialog.getByRole("button", { name: "Save Changes" }).click();
await expect(dialog, "Edit modal closes after save").toBeHidden();
await context.close();
},
);
test.beforeEach(async ({ session, page }) => {
await session.toLoginPage();
await page.evaluate(
([userKey, sessionKey]) => {
localStorage.removeItem(userKey);
localStorage.removeItem(sessionKey);
},
[REMEMBER_ME_USER_KEY, REMEMBER_ME_SESSION_KEY],
);
await page.reload();
await session.$identificationStage.waitFor({ state: "visible" });
});
test("Remember me persists username", async ({ navigator, session, page }) => {
await test.step("Verify identification stage", async () => {
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is visible",
).toBeVisible();
await expect(
session.$rememberMeCheckbox,
"Remember me checkbox is not checked by default",
).not.toBeChecked();
});
await test.step("Identify with remember-me enabled", async () => {
await session.login(
{
rememberMe: true,
to: "if/user/#/library",
},
page,
);
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(
storedUserIdentifier,
"username persists to localStorage when remember-me is checked",
).toBe(GOOD_USERNAME);
});
await test.step("Sign out and verify username is remembered", async () => {
const signOutLink = page.getByRole("link", { name: "Sign out" });
await expect(signOutLink, "Sign out link is visible").toBeVisible();
await signOutLink.click();
await navigator.waitForPathname("/if/flow/default-authentication-flow/?next=%2F");
const notYouLink = page.getByRole("link", { name: "Not you?" });
await expect(notYouLink, "Not you? link is visible after sign out").toBeVisible();
await notYouLink.click();
await expect(
session.$identificationStage,
"Identification stage is visible after clicking not you link",
).toBeVisible();
const storedUserIdentifier = await readStoredUserIdentifier(page);
expect(storedUserIdentifier, "Removed after clicking not you link").toBeNull();
});
});
});

View File

@@ -2198,6 +2198,9 @@
<source>Issuer</source>
<target>Vydavatel</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Publikum</target>
@@ -5270,7 +5273,7 @@ neprojde, když jedna nebo obě z vybraných možností jsou rovny nebo nad prah
<target>Aktivovat</target>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
<target>Aktualizovat heslo uživatele <x id="0" equiv-text="${item.name || item.username}"/></target>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
@@ -10995,34 +10998,6 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2213,6 +2213,9 @@
<source>Issuer</source>
<target>Aussteller</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Zielgruppe</target>
@@ -5295,7 +5298,7 @@ Hier können nur Policies verwendet werden, da der Zugriff geprüft wird, bevor
<target>Aktivieren</target>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
<target><x id="0" equiv-text="${item.name || item.username}"/> - Passwort ändern.</target>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
@@ -11028,34 +11031,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -1688,6 +1688,9 @@
<trans-unit id="sb7a30abc1dcf6c36">
<source>Issuer</source>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
</trans-unit>
@@ -4089,7 +4092,7 @@ doesn't pass when either or both of the selected options are equal or above the
<source>Activate</source>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
<source>Set password</source>
@@ -8998,34 +9001,6 @@ Bindings to groups/users are checked against the user of the event.</source>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2176,6 +2176,9 @@
<source>Issuer</source>
<target>Emisor</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audiencia</target>
@@ -5235,7 +5238,7 @@ El valor de este campo se compara con el atributo de pertenencia del usuario.</t
<target>Activar</target>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
<target>Actualizar la contraseña de <x id="0" equiv-text="${item.name || item.username}"/></target>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
@@ -10953,34 +10956,6 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2255,6 +2255,10 @@
<source>Issuer</source>
<target>Myöntäjä</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
<target>Tunnetaan myös nimellä Entity ID.</target>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Yleisö</target>
@@ -5398,7 +5402,7 @@ läpäisy estyy kun jompi kumpi tai molemmat vaihtoehdot ylittävät raja-arvon.
<target>Aktivoi</target>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
<target>Päivitä käyttäjän <x id="0" equiv-text="${item.name || item.username}"/> salasana</target>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
@@ -11194,34 +11198,6 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -2252,6 +2252,9 @@
<source>Issuer</source>
<target>Émetteur</target>
</trans-unit>
<trans-unit id="sd4fd64791f73d37b">
<source>Also known as Entity ID.</source>
</trans-unit>
<trans-unit id="sd5a4b41c6c883b03">
<source>Audience</source>
<target>Audience</target>
@@ -5388,7 +5391,7 @@ doesn't pass when either or both of the selected options are equal or above the
<target>Activer</target>
</trans-unit>
<trans-unit id="s547b687213f48489">
<source>Update <x id="0" equiv-text="${formatUserDisplayName(user)}"/>'s password</source>
<source>Update <x id="0" equiv-text="${user.name || user.username}"/>'s password</source>
<target>Mettre à jour le mot de passe de <x id="0" equiv-text="${item.name || item.username}"/></target>
</trans-unit>
<trans-unit id="sce8d867ca5f35304">
@@ -11183,34 +11186,6 @@ Les liaisons avec les groupes/utilisateurs sont vérifiées par rapport à l'uti
<trans-unit id="s71724d2ff6637203">
<source>Altered behavior for usage with VMware vCenter.</source>
</trans-unit>
<trans-unit id="s9eda7101f63a8652">
<source>Hide from My applications</source>
</trans-unit>
<trans-unit id="s30f30e9c42594a33">
<source>If checked, this application will not be shown on the user's My applications page.</source>
</trans-unit>
<trans-unit id="s2ea2e39e4b470249">
<source>EntityID/Issuer override</source>
</trans-unit>
<trans-unit id="sf7aba95a8c43b7b1">
<source>Sets a custom EntityID/Issuer to override the authentik generated default.</source>
</trans-unit>
<trans-unit id="sa3a27a128ad87f31">
<source>Passwords</source>
</trans-unit>
<trans-unit id="s16d13ea527d7fe6b">
<source>Setting</source>
</trans-unit>
<trans-unit id="sfef81bb4077a56fd">
<source>Type a new password...</source>
</trans-unit>
<trans-unit id="sf9ec917e3e986bc1">
<source>When enabled, your username will be remembered on this device for future logins.</source>
</trans-unit>
<trans-unit id="form.submitting.no-entity">
<source><x id="0" equiv-text="${submittingVerb}"/>...</source>
<note from="lit-localize">The message shown while a form is being submitted, when no entity name is provided.</note>
</trans-unit>
</body>
</file>
</xliff>

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