mirror of
https://github.com/goauthentik/authentik
synced 2026-05-07 07:32:23 +02:00
Compare commits
16 Commits
github-ci-
...
saml-sourc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1392a89e | ||
|
|
69a86cf258 | ||
|
|
f79b1ba41e | ||
|
|
772db03b4b | ||
|
|
724f3cc59c | ||
|
|
99bf2ac131 | ||
|
|
dc9b302628 | ||
|
|
764e7a520c | ||
|
|
02e3baa84d | ||
|
|
46f17d23e9 | ||
|
|
3f832913dc | ||
|
|
f449335ad1 | ||
|
|
4215e76b74 | ||
|
|
ca63ee0142 | ||
|
|
63326b22bd | ||
|
|
8e3cff2769 |
81
.github/actions/setup-node/action.yml
vendored
81
.github/actions/setup-node/action.yml
vendored
@@ -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 }}
|
||||
46
.github/actions/setup/action.yml
vendored
46
.github/actions/setup/action.yml
vendored
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
65
.github/workflows/api-ts-publish.yml
vendored
65
.github/workflows/api-ts-publish.yml
vendored
@@ -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
|
||||
26
.github/workflows/ci-api-docs.yml
vendored
26
.github/workflows/ci-api-docs.yml
vendored
@@ -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'
|
||||
|
||||
9
.github/workflows/ci-aws-cfn.yml
vendored
9
.github/workflows/ci-aws-cfn.yml
vendored
@@ -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
|
||||
|
||||
38
.github/workflows/ci-docs.yml
vendored
38
.github/workflows/ci-docs.yml
vendored
@@ -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' }}
|
||||
|
||||
47
.github/workflows/ci-main.yml
vendored
47
.github/workflows/ci-main.yml
vendored
@@ -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:
|
||||
|
||||
22
.github/workflows/ci-outpost.yml
vendored
22
.github/workflows/ci-outpost.yml
vendored
@@ -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
|
||||
|
||||
54
.github/workflows/ci-web.yml
vendored
54
.github/workflows/ci-web.yml
vendored
@@ -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
|
||||
|
||||
15
.github/workflows/packages-npm-publish.yml
vendored
15
.github/workflows/packages-npm-publish.yml
vendored
@@ -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
|
||||
|
||||
28
.github/workflows/release-publish.yml
vendored
28
.github/workflows/release-publish.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -14,8 +14,6 @@ media
|
||||
# Node
|
||||
|
||||
node_modules
|
||||
corepack.tgz
|
||||
.corepack
|
||||
|
||||
.cspellcache
|
||||
cspell-report.*
|
||||
|
||||
4
Cargo.lock
generated
4
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
66
Makefile
66
Makefile
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""authentik SAML IDP Exceptions"""
|
||||
"""Common SAML Exceptions"""
|
||||
|
||||
from authentik.lib.sentry import SentryIgnoredException
|
||||
|
||||
0
authentik/common/saml/parsers/__init__.py
Normal file
0
authentik/common/saml/parsers/__init__.py
Normal file
@@ -1,4 +1,4 @@
|
||||
"""LogoutRequest parser"""
|
||||
"""Shared SAML LogoutRequest parser"""
|
||||
|
||||
from base64 import b64decode
|
||||
from dataclasses import dataclass
|
||||
@@ -6,41 +6,29 @@ from dataclasses import dataclass
|
||||
from defusedxml import ElementTree
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_ASSERTION, NS_SAML_PROTOCOL
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import ERROR_CANNOT_DECODE_REQUEST
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LogoutRequest:
|
||||
"""Logout Request"""
|
||||
"""Parsed SAML LogoutRequest"""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
issuer: str | None = None
|
||||
|
||||
name_id: str | None = None
|
||||
|
||||
name_id_format: str | None = None
|
||||
|
||||
session_index: str | None = None
|
||||
|
||||
relay_state: str | None = None
|
||||
|
||||
|
||||
class LogoutRequestParser:
|
||||
"""LogoutRequest Parser"""
|
||||
|
||||
provider: SAMLProvider
|
||||
|
||||
def __init__(self, provider: SAMLProvider):
|
||||
self.provider = provider
|
||||
"""Parse incoming SAML LogoutRequest messages"""
|
||||
|
||||
def _parse_xml(self, decoded_xml: str | bytes, relay_state: str | None = None) -> LogoutRequest:
|
||||
root = ElementTree.fromstring(decoded_xml)
|
||||
request = LogoutRequest(
|
||||
id=root.attrib["ID"],
|
||||
id=root.attrib.get("ID"),
|
||||
)
|
||||
# Try both namespaces for Issuer
|
||||
issuers = root.findall(f"{{{NS_SAML_PROTOCOL}}}Issuer")
|
||||
@@ -55,7 +43,6 @@ class LogoutRequestParser:
|
||||
name_ids = root.findall(f"{{{NS_SAML_PROTOCOL}}}NameID")
|
||||
if len(name_ids) > 0:
|
||||
request.name_id = name_ids[0].text
|
||||
# Extract NameID Format if present
|
||||
if "Format" in name_ids[0].attrib:
|
||||
request.name_id_format = name_ids[0].attrib["Format"]
|
||||
|
||||
@@ -70,22 +57,17 @@ class LogoutRequestParser:
|
||||
return request
|
||||
|
||||
def parse(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Validate and parse raw request with enveloped signautre."""
|
||||
"""Parse a POST-binding LogoutRequest (base64 encoded)."""
|
||||
try:
|
||||
decoded_xml = b64decode(saml_request.encode())
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
|
||||
def parse_detached(
|
||||
self,
|
||||
saml_request: str,
|
||||
relay_state: str | None = None,
|
||||
) -> LogoutRequest:
|
||||
"""Validate and parse raw request with detached signature"""
|
||||
def parse_detached(self, saml_request: str, relay_state: str | None = None) -> LogoutRequest:
|
||||
"""Parse a Redirect-binding LogoutRequest (deflate + base64 encoded)."""
|
||||
try:
|
||||
decoded_xml = decode_base64_and_inflate(saml_request)
|
||||
except UnicodeDecodeError:
|
||||
raise CannotHandleAssertion(ERROR_CANNOT_DECODE_REQUEST) from None
|
||||
|
||||
raise CannotHandleAssertion("Cannot decode SAML request") from None
|
||||
return self._parse_xml(decoded_xml, relay_state)
|
||||
43
authentik/common/saml/parsers/logout_response.py
Normal file
43
authentik/common/saml/parsers/logout_response.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Shared SAML LogoutResponse parser"""
|
||||
|
||||
from defusedxml.lxml import fromstring
|
||||
from lxml.etree import _Element # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.constants import NS_SAML_PROTOCOL, SAML_STATUS_SUCCESS
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
|
||||
class LogoutResponseParser:
|
||||
"""Parse and validate SAML LogoutResponse messages"""
|
||||
|
||||
_root: _Element
|
||||
|
||||
def __init__(self, raw_response: str):
|
||||
self._raw_response = raw_response
|
||||
|
||||
def parse(self):
|
||||
"""Decode and parse the LogoutResponse XML."""
|
||||
# decode_base64_and_inflate handles both deflate-compressed (Redirect binding)
|
||||
# and plain base64 (POST binding) responses
|
||||
response_xml = decode_base64_and_inflate(self._raw_response)
|
||||
self._root = fromstring(response_xml.encode())
|
||||
|
||||
def verify_status(self) -> bool:
|
||||
"""Check LogoutResponse status. Returns True if status is Success."""
|
||||
status = self._root.find(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
if status is None:
|
||||
return True
|
||||
status_code = status.find(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
if status_code is None:
|
||||
return True
|
||||
status_value = status_code.attrib.get("Value", "")
|
||||
if status_value != SAML_STATUS_SUCCESS:
|
||||
LOGGER.warning(
|
||||
"LogoutResponse status is not Success",
|
||||
status=status_value,
|
||||
)
|
||||
return False
|
||||
return True
|
||||
@@ -47,8 +47,7 @@ class ApplicationEntitlementViewSet(UsedByMixin, ModelViewSet):
|
||||
search_fields = [
|
||||
"pbm_uuid",
|
||||
"name",
|
||||
"app__name",
|
||||
"app__slug",
|
||||
"app",
|
||||
"attributes",
|
||||
]
|
||||
filterset_fields = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -19,8 +19,8 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA512,
|
||||
SAML_NAME_ID_FORMAT_UNSPECIFIED,
|
||||
)
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.lib.xml import lxml_from_string
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.utils.encoding import decode_base64_and_inflate
|
||||
from authentik.sources.saml.models import SAMLNameIDPolicy
|
||||
|
||||
@@ -15,8 +15,8 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
|
||||
@@ -5,10 +5,10 @@ from django.contrib.auth import get_user_model
|
||||
from dramatiq.actor import actor
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
@@ -8,10 +8,10 @@ from authentik.common.saml.constants import (
|
||||
RSA_SHA256,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
|
||||
|
||||
class TestLogoutIntegration(TestCase):
|
||||
@@ -46,7 +46,7 @@ class TestLogoutIntegration(TestCase):
|
||||
)
|
||||
|
||||
# Create parser for validation
|
||||
self.parser = LogoutRequestParser(self.provider)
|
||||
self.parser = LogoutRequestParser()
|
||||
|
||||
def test_post_binding_roundtrip(self):
|
||||
"""Test that a POST-encoded request can be parsed correctly"""
|
||||
@@ -100,7 +100,7 @@ class TestLogoutIntegration(TestCase):
|
||||
encoded = processor.encode_post()
|
||||
|
||||
# Create parser with verification enabled
|
||||
parser = LogoutRequestParser(self.provider)
|
||||
parser = LogoutRequestParser()
|
||||
|
||||
# Parse it - this would validate signature if verification is enabled
|
||||
parsed = parser.parse(encoded)
|
||||
|
||||
@@ -4,9 +4,9 @@ from django.test import TestCase
|
||||
|
||||
from authentik.blueprints.tests import apply_blueprint
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_TRANSIENT
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
GET_LOGOUT_REQUEST = (
|
||||
@@ -51,7 +51,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_get(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser(self.provider).parse_detached(GET_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser().parse_detached(GET_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-2ea1b01f69363ac95e3da4a15409b9d8ec525944")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The GET request has an empty NameID element with transient format
|
||||
@@ -60,7 +60,7 @@ class TestLogoutRequest(TestCase):
|
||||
|
||||
def test_static_post(self):
|
||||
"""Test static LogoutRequest"""
|
||||
request = LogoutRequestParser(self.provider).parse(POST_LOGOUT_REQUEST)
|
||||
request = LogoutRequestParser().parse(POST_LOGOUT_REQUEST)
|
||||
self.assertEqual(request.id, "id-b8f4fd51ed4106f1e782b95d51d9ad3f385e5816")
|
||||
self.assertEqual(request.issuer, "saml-test-sp")
|
||||
# The POST request has an empty NameID element with transient format
|
||||
|
||||
@@ -9,11 +9,11 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_PROTOCOL,
|
||||
NS_SIGNATURE,
|
||||
)
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequest
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_cert, create_test_flow
|
||||
from authentik.lib.generators import generate_id
|
||||
from authentik.providers.saml.models import SAMLPropertyMapping, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequest
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.processors.metadata import MetadataProcessor
|
||||
|
||||
|
||||
@@ -7,11 +7,11 @@ from django.test import RequestFactory, TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from authentik.common.saml.constants import SAML_NAME_ID_FORMAT_EMAIL
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.core.tests.utils import create_test_brand, create_test_cert, create_test_flow
|
||||
from authentik.flows.planner import FlowPlan
|
||||
from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLLogoutMethods, SAMLProvider
|
||||
from authentik.providers.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -7,6 +7,8 @@ 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.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.core.models import Application, AuthenticatedSession
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.models import Flow, in_memory_stage
|
||||
@@ -16,7 +18,6 @@ from authentik.flows.views.executor import SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.iframe_logout import IframeLogoutStageView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import (
|
||||
SAMLBindings,
|
||||
SAMLLogoutMethods,
|
||||
@@ -24,7 +25,6 @@ from authentik.providers.saml.models import (
|
||||
SAMLSession,
|
||||
)
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.providers.saml.processors.logout_request_parser import LogoutRequestParser
|
||||
from authentik.providers.saml.processors.logout_response_processor import LogoutResponseProcessor
|
||||
from authentik.providers.saml.tasks import send_saml_logout_response
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -251,7 +251,7 @@ class SPInitiatedSLOBindingRedirectView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser(self.provider).parse_detached(
|
||||
logout_request = LogoutRequestParser().parse_detached(
|
||||
self.request.GET[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=self.request.GET.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
@@ -295,7 +295,7 @@ class SPInitiatedSLOBindingPOSTView(SPInitiatedSLOView):
|
||||
return bad_request_message(self.request, "The SAML request payload is missing.")
|
||||
|
||||
try:
|
||||
logout_request = LogoutRequestParser(self.provider).parse(
|
||||
logout_request = LogoutRequestParser().parse(
|
||||
payload[REQUEST_KEY_SAML_REQUEST],
|
||||
relay_state=payload.get(REQUEST_KEY_RELAY_STATE, None),
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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.exceptions import CannotHandleAssertion
|
||||
from authentik.core.models import Application
|
||||
from authentik.events.models import Event, EventAction
|
||||
from authentik.flows.exceptions import FlowNonApplicableException
|
||||
@@ -16,7 +17,6 @@ from authentik.flows.planner import PLAN_CONTEXT_APPLICATION, PLAN_CONTEXT_SSO,
|
||||
from authentik.flows.views.executor import SESSION_KEY_POST
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.policies.views import PolicyAccessView
|
||||
from authentik.providers.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.providers.saml.models import SAMLBindings, SAMLProvider
|
||||
from authentik.providers.saml.processors.authn_request_parser import AuthNRequestParser
|
||||
from authentik.providers.saml.views.flows import (
|
||||
|
||||
@@ -43,6 +43,7 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -51,6 +52,8 @@ class SAMLSourceSerializer(SourceSerializer):
|
||||
"encryption_kp",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
|
||||
|
||||
@@ -78,6 +81,7 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"force_authn",
|
||||
"name_id_policy",
|
||||
"binding_type",
|
||||
"slo_binding",
|
||||
"verification_kp",
|
||||
"signing_kp",
|
||||
"digest_algorithm",
|
||||
@@ -85,6 +89,8 @@ class SAMLSourceViewSet(UsedByMixin, ModelViewSet):
|
||||
"temporary_user_delete_after",
|
||||
"signed_assertion",
|
||||
"signed_response",
|
||||
"sign_authn_request",
|
||||
"sign_logout_request",
|
||||
]
|
||||
search_fields = ["name", "slug"]
|
||||
ordering = ["name"]
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Generated by Django 5.2.11 on 2026-02-09 22:34
|
||||
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("authentik_core", "0057_remove_user_groups_remove_user_user_permissions_and_more"),
|
||||
("authentik_sources_saml", "0021_samlsource_signed_assertion_and_more"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_authn_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign AuthnRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="sign_logout_request",
|
||||
field=models.BooleanField(
|
||||
default=True,
|
||||
help_text="Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
|
||||
verbose_name="Sign LogoutRequest",
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name="samlsource",
|
||||
name="slo_binding",
|
||||
field=models.CharField(
|
||||
choices=[("REDIRECT", "Redirect Binding"), ("POST", "POST Binding")],
|
||||
default="REDIRECT",
|
||||
help_text="Binding type for Single Logout requests to the IdP.",
|
||||
max_length=100,
|
||||
verbose_name="SLO Binding",
|
||||
),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="SAMLSourceSession",
|
||||
fields=[
|
||||
(
|
||||
"saml_session_id",
|
||||
models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
|
||||
),
|
||||
(
|
||||
"session_index",
|
||||
models.TextField(
|
||||
blank=True,
|
||||
default="",
|
||||
help_text="SAML SessionIndex from the IdP's AuthnStatement",
|
||||
),
|
||||
),
|
||||
("name_id", models.TextField(help_text="SAML NameID value for this session")),
|
||||
(
|
||||
"name_id_format",
|
||||
models.TextField(blank=True, default="", help_text="SAML NameID format"),
|
||||
),
|
||||
("created", models.DateTimeField(auto_now_add=True)),
|
||||
(
|
||||
"session",
|
||||
models.ForeignKey(
|
||||
help_text="Link to the user's authenticated session",
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_core.authenticatedsession",
|
||||
),
|
||||
),
|
||||
(
|
||||
"source",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to="authentik_sources_saml.samlsource",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="User",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "SAML Source Session",
|
||||
"verbose_name_plural": "SAML Source Sessions",
|
||||
"indexes": [
|
||||
models.Index(fields=["source", "user"], name="authentik_s_source__abd088_idx"),
|
||||
models.Index(fields=["session"], name="authentik_s_session_054d2d_idx"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,7 @@
|
||||
"""saml sp models"""
|
||||
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
from django.db import models
|
||||
from django.http import HttpRequest
|
||||
@@ -36,9 +37,11 @@ from authentik.common.saml.constants import (
|
||||
SHA512,
|
||||
)
|
||||
from authentik.core.models import (
|
||||
AuthenticatedSession,
|
||||
GroupSourceConnection,
|
||||
PropertyMapping,
|
||||
Source,
|
||||
User,
|
||||
UserSourceConnection,
|
||||
)
|
||||
from authentik.core.types import UILoginButton, UserSettingSerializer
|
||||
@@ -78,6 +81,13 @@ class SAMLNameIDPolicy(models.TextChoices):
|
||||
UNSPECIFIED = SAML_NAME_ID_FORMAT_UNSPECIFIED
|
||||
|
||||
|
||||
class SAMLSLOBindingTypes(models.TextChoices):
|
||||
"""SAML SLO Binding types"""
|
||||
|
||||
REDIRECT = "REDIRECT", _("Redirect Binding")
|
||||
POST = "POST", _("POST Binding")
|
||||
|
||||
|
||||
class SAMLSource(Source):
|
||||
"""Authenticate using an external SAML Identity Provider."""
|
||||
|
||||
@@ -134,6 +144,28 @@ class SAMLSource(Source):
|
||||
choices=SAMLBindingTypes.choices,
|
||||
default=SAMLBindingTypes.REDIRECT,
|
||||
)
|
||||
slo_binding = models.CharField(
|
||||
max_length=100,
|
||||
choices=SAMLSLOBindingTypes.choices,
|
||||
default=SAMLSLOBindingTypes.REDIRECT,
|
||||
verbose_name=_("SLO Binding"),
|
||||
help_text=_("Binding type for Single Logout requests to the IdP."),
|
||||
)
|
||||
|
||||
sign_authn_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign AuthnRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
sign_logout_request = models.BooleanField(
|
||||
default=True,
|
||||
verbose_name=_("Sign LogoutRequest"),
|
||||
help_text=_(
|
||||
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
|
||||
),
|
||||
)
|
||||
|
||||
temporary_user_delete_after = models.TextField(
|
||||
default="days=1",
|
||||
@@ -355,3 +387,39 @@ class GroupSAMLSourceConnection(GroupSourceConnection):
|
||||
class Meta:
|
||||
verbose_name = _("Group SAML Source Connection")
|
||||
verbose_name_plural = _("Group SAML Source Connections")
|
||||
|
||||
|
||||
class SAMLSourceSession(models.Model):
|
||||
"""Track active SAML source sessions for Single Logout support"""
|
||||
|
||||
saml_session_id = models.UUIDField(default=uuid4, primary_key=True)
|
||||
source = models.ForeignKey(SAMLSource, on_delete=models.CASCADE)
|
||||
user = models.ForeignKey(User, verbose_name=_("User"), on_delete=models.CASCADE)
|
||||
session = models.ForeignKey(
|
||||
AuthenticatedSession,
|
||||
on_delete=models.CASCADE,
|
||||
help_text=_("Link to the user's authenticated session"),
|
||||
)
|
||||
session_index = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML SessionIndex from the IdP's AuthnStatement"),
|
||||
)
|
||||
name_id = models.TextField(help_text=_("SAML NameID value for this session"))
|
||||
name_id_format = models.TextField(
|
||||
default="",
|
||||
blank=True,
|
||||
help_text=_("SAML NameID format"),
|
||||
)
|
||||
created = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = _("SAML Source Session")
|
||||
verbose_name_plural = _("SAML Source Sessions")
|
||||
indexes = [
|
||||
models.Index(fields=["source", "user"]),
|
||||
models.Index(fields=["session"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"SAML Source Session for source {self.source_id} and user {self.user_id}"
|
||||
|
||||
210
authentik/sources/saml/processors/logout_request.py
Normal file
210
authentik/sources/saml/processors/logout_request.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""SAML Source LogoutRequest Processor"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import quote, urlencode
|
||||
|
||||
import xmlsec
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element, _Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
DIGEST_ALGORITHM_TRANSLATION_MAP,
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_NAME_ID_FORMAT_EMAIL,
|
||||
SIGN_ALGORITHM_TRANSFORM_MAP,
|
||||
)
|
||||
from authentik.lib.xml import remove_xml_newlines
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutRequestProcessor:
|
||||
"""Generate SAML LogoutRequest messages for SP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
name_id: str
|
||||
name_id_format: str
|
||||
session_index: str
|
||||
relay_state: str | None
|
||||
|
||||
_issue_instant: str
|
||||
_request_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
name_id: str,
|
||||
name_id_format: str = SAML_NAME_ID_FORMAT_EMAIL,
|
||||
session_index: str = "",
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.name_id = name_id
|
||||
self.name_id_format = name_id_format
|
||||
self.session_index = session_index
|
||||
self.relay_state = relay_state
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._request_id = get_random_id()
|
||||
|
||||
def get_issuer(self) -> Element:
|
||||
"""Get Issuer element"""
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
return issuer
|
||||
|
||||
def get_name_id(self) -> Element:
|
||||
"""Get NameID element"""
|
||||
name_id = Element(f"{{{NS_SAML_ASSERTION}}}NameID")
|
||||
name_id.attrib["Format"] = self.name_id_format
|
||||
name_id.text = self.name_id
|
||||
return name_id
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutRequest as etree Element"""
|
||||
logout_request = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutRequest", nsmap=NS_MAP)
|
||||
logout_request.attrib["ID"] = self._request_id
|
||||
logout_request.attrib["Version"] = "2.0"
|
||||
logout_request.attrib["IssueInstant"] = self._issue_instant
|
||||
logout_request.attrib["Destination"] = self.destination
|
||||
|
||||
logout_request.append(self.get_issuer())
|
||||
logout_request.append(self.get_name_id())
|
||||
|
||||
if self.session_index:
|
||||
session_index_element = Element(f"{{{NS_SAML_PROTOCOL}}}SessionIndex")
|
||||
session_index_element.text = self.session_index
|
||||
logout_request.append(session_index_element)
|
||||
|
||||
return logout_request
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutRequest for POST binding"""
|
||||
logout_request = self.build()
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
self._sign_logout_request(logout_request)
|
||||
return base64.b64encode(etree.tostring(logout_request)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutRequest for Redirect binding"""
|
||||
logout_request = self.build()
|
||||
xml_str = etree.tostring(logout_request, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self) -> str:
|
||||
"""Build complete logout URL for redirect binding with signature if needed"""
|
||||
encoded_request = self.encode_redirect()
|
||||
params = {
|
||||
"SAMLRequest": encoded_request,
|
||||
}
|
||||
|
||||
if self.relay_state:
|
||||
params["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp and self.source.sign_logout_request:
|
||||
sig_alg = self.source.signature_algorithm
|
||||
params["SigAlg"] = sig_alg
|
||||
|
||||
query_string = self._build_signable_query_string(params)
|
||||
signature = self._sign_query_string(query_string)
|
||||
params["Signature"] = base64.b64encode(signature).decode()
|
||||
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
return {
|
||||
"SAMLRequest": self.encode_post(),
|
||||
"RelayState": self.relay_state or "",
|
||||
}
|
||||
|
||||
def _sign_logout_request(self, logout_request: _Element):
|
||||
"""Sign the LogoutRequest element"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
signature = xmlsec.template.create(
|
||||
logout_request,
|
||||
xmlsec.constants.TransformExclC14N,
|
||||
signature_algorithm_transform,
|
||||
ns=xmlsec.constants.DSigNs,
|
||||
)
|
||||
|
||||
issuer = logout_request.find(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
if issuer is not None:
|
||||
issuer.addnext(signature)
|
||||
else:
|
||||
logout_request.insert(0, signature)
|
||||
|
||||
self._sign(logout_request)
|
||||
|
||||
def _sign(self, element: _Element):
|
||||
"""Sign an XML element based on the source's configured signing settings"""
|
||||
digest_algorithm_transform = DIGEST_ALGORITHM_TRANSLATION_MAP.get(
|
||||
self.source.digest_algorithm, xmlsec.constants.TransformSha1
|
||||
)
|
||||
xmlsec.tree.add_ids(element, ["ID"])
|
||||
signature_node = xmlsec.tree.find_node(element, xmlsec.constants.NodeSignature)
|
||||
ref = xmlsec.template.add_reference(
|
||||
signature_node,
|
||||
digest_algorithm_transform,
|
||||
uri="#" + element.attrib["ID"],
|
||||
)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformEnveloped)
|
||||
xmlsec.template.add_transform(ref, xmlsec.constants.TransformExclC14N)
|
||||
key_info = xmlsec.template.ensure_key_info(signature_node)
|
||||
xmlsec.template.add_x509_data(key_info)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
key.load_cert_from_memory(
|
||||
self.source.signing_kp.certificate_data,
|
||||
xmlsec.constants.KeyDataFormatCertPem,
|
||||
)
|
||||
ctx.key = key
|
||||
ctx.sign(remove_xml_newlines(element, signature_node))
|
||||
|
||||
def _build_signable_query_string(self, params: dict) -> str:
|
||||
"""Build query string for signing (order matters per SAML spec)"""
|
||||
ordered = []
|
||||
if "SAMLRequest" in params:
|
||||
ordered.append(f"SAMLRequest={quote(params['SAMLRequest'], safe='')}")
|
||||
if "RelayState" in params:
|
||||
ordered.append(f"RelayState={quote(params['RelayState'], safe='')}")
|
||||
if "SigAlg" in params:
|
||||
ordered.append(f"SigAlg={quote(params['SigAlg'], safe='')}")
|
||||
return "&".join(ordered)
|
||||
|
||||
def _sign_query_string(self, query_string: str) -> bytes:
|
||||
"""Sign the query string for redirect binding"""
|
||||
signature_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha256
|
||||
)
|
||||
|
||||
key = xmlsec.Key.from_memory(
|
||||
self.source.signing_kp.key_data,
|
||||
xmlsec.constants.KeyDataFormatPem,
|
||||
None,
|
||||
)
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
ctx.key = key
|
||||
|
||||
return ctx.sign_binary(query_string.encode("utf-8"), signature_algorithm_transform)
|
||||
96
authentik/sources/saml/processors/logout_response.py
Normal file
96
authentik/sources/saml/processors/logout_response.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""SAML Source LogoutResponse Builder"""
|
||||
|
||||
import base64
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from django.http import HttpRequest
|
||||
from lxml import etree # nosec
|
||||
from lxml.etree import Element
|
||||
|
||||
from authentik.common.saml.constants import (
|
||||
NS_MAP,
|
||||
NS_SAML_ASSERTION,
|
||||
NS_SAML_PROTOCOL,
|
||||
SAML_STATUS_SUCCESS,
|
||||
)
|
||||
from authentik.providers.saml.utils import get_random_id
|
||||
from authentik.providers.saml.utils.encoding import deflate_and_base64_encode
|
||||
from authentik.providers.saml.utils.time import get_time_string
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
|
||||
|
||||
class LogoutResponseBuilder:
|
||||
"""Build SAML LogoutResponse messages for IdP-initiated logout"""
|
||||
|
||||
source: SAMLSource
|
||||
http_request: HttpRequest
|
||||
destination: str
|
||||
in_response_to: str
|
||||
|
||||
_issue_instant: str
|
||||
_response_id: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
source: SAMLSource,
|
||||
http_request: HttpRequest,
|
||||
destination: str,
|
||||
in_response_to: str,
|
||||
):
|
||||
self.source = source
|
||||
self.http_request = http_request
|
||||
self.destination = destination
|
||||
self.in_response_to = in_response_to
|
||||
|
||||
self._issue_instant = get_time_string()
|
||||
self._response_id = get_random_id()
|
||||
|
||||
def build(self) -> Element:
|
||||
"""Build a SAML LogoutResponse as etree Element"""
|
||||
response = Element(f"{{{NS_SAML_PROTOCOL}}}LogoutResponse", nsmap=NS_MAP)
|
||||
response.attrib["ID"] = self._response_id
|
||||
response.attrib["Version"] = "2.0"
|
||||
response.attrib["IssueInstant"] = self._issue_instant
|
||||
response.attrib["Destination"] = self.destination
|
||||
response.attrib["InResponseTo"] = self.in_response_to
|
||||
|
||||
# Issuer
|
||||
issuer = Element(f"{{{NS_SAML_ASSERTION}}}Issuer")
|
||||
issuer.text = self.source.get_issuer(self.http_request)
|
||||
response.append(issuer)
|
||||
|
||||
# Status
|
||||
status = Element(f"{{{NS_SAML_PROTOCOL}}}Status")
|
||||
status_code = Element(f"{{{NS_SAML_PROTOCOL}}}StatusCode")
|
||||
status_code.attrib["Value"] = SAML_STATUS_SUCCESS
|
||||
status.append(status_code)
|
||||
response.append(status)
|
||||
|
||||
return response
|
||||
|
||||
def encode_post(self) -> str:
|
||||
"""Encode LogoutResponse for POST binding"""
|
||||
response = self.build()
|
||||
return base64.b64encode(etree.tostring(response)).decode()
|
||||
|
||||
def encode_redirect(self) -> str:
|
||||
"""Encode LogoutResponse for Redirect binding"""
|
||||
response = self.build()
|
||||
xml_str = etree.tostring(response, encoding="UTF-8", xml_declaration=True)
|
||||
return deflate_and_base64_encode(xml_str.decode("UTF-8"))
|
||||
|
||||
def get_redirect_url(self, relay_state: str | None = None) -> str:
|
||||
"""Build complete URL for redirect binding"""
|
||||
encoded = self.encode_redirect()
|
||||
params = {"SAMLResponse": encoded}
|
||||
if relay_state:
|
||||
params["RelayState"] = relay_state
|
||||
separator = "&" if "?" in self.destination else "?"
|
||||
return f"{self.destination}{separator}{urlencode(params)}"
|
||||
|
||||
def get_post_form_data(self, relay_state: str | None = None) -> dict:
|
||||
"""Get form data for POST binding"""
|
||||
data = {"SAMLResponse": self.encode_post()}
|
||||
if relay_state:
|
||||
data["RelayState"] = relay_state
|
||||
return data
|
||||
@@ -8,6 +8,7 @@ from authentik.common.saml.constants import (
|
||||
NS_SAML_METADATA,
|
||||
NS_SIGNATURE,
|
||||
SAML_BINDING_POST,
|
||||
SAML_BINDING_REDIRECT,
|
||||
)
|
||||
from authentik.providers.saml.utils.encoding import strip_pem_header
|
||||
from authentik.sources.saml.models import SAMLSource
|
||||
@@ -75,6 +76,19 @@ class MetadataProcessor:
|
||||
if encryption_descriptor is not None:
|
||||
sp_sso_descriptor.append(encryption_descriptor)
|
||||
|
||||
if self.source.slo_url:
|
||||
slo_location = self.source.build_full_url(self.http_request, view="slo")
|
||||
|
||||
slo_redirect = SubElement(
|
||||
sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService"
|
||||
)
|
||||
slo_redirect.attrib["Binding"] = SAML_BINDING_REDIRECT
|
||||
slo_redirect.attrib["Location"] = slo_location
|
||||
|
||||
slo_post = SubElement(sp_sso_descriptor, f"{{{NS_SAML_METADATA}}}SingleLogoutService")
|
||||
slo_post.attrib["Binding"] = SAML_BINDING_POST
|
||||
slo_post.attrib["Location"] = slo_location
|
||||
|
||||
sp_sso_descriptor.append(self.get_name_id_format())
|
||||
|
||||
assertion_consumer_service = SubElement(
|
||||
|
||||
@@ -72,7 +72,11 @@ class RequestProcessor:
|
||||
# Create issuer object
|
||||
auth_n_request.append(self.get_issuer())
|
||||
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
@@ -93,7 +97,11 @@ class RequestProcessor:
|
||||
(used for POST Bindings)"""
|
||||
auth_n_request = self.get_auth_n()
|
||||
|
||||
if self.source.signing_kp and self.source.binding_type != SAMLBindingTypes.REDIRECT:
|
||||
if (
|
||||
self.source.signing_kp
|
||||
and self.source.sign_authn_request
|
||||
and self.source.binding_type != SAMLBindingTypes.REDIRECT
|
||||
):
|
||||
xmlsec.tree.add_ids(auth_n_request, ["ID"])
|
||||
|
||||
ctx = xmlsec.SignatureContext()
|
||||
@@ -141,7 +149,7 @@ class RequestProcessor:
|
||||
if self.relay_state != "":
|
||||
response_dict["RelayState"] = self.relay_state
|
||||
|
||||
if self.source.signing_kp:
|
||||
if self.source.signing_kp and self.source.sign_authn_request:
|
||||
sign_algorithm_transform = SIGN_ALGORITHM_TRANSFORM_MAP.get(
|
||||
self.source.signature_algorithm, xmlsec.constants.TransformRsaSha1
|
||||
)
|
||||
|
||||
@@ -42,12 +42,9 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
from authentik.sources.saml.models import SAMLSource, UserSAMLSourceConnection
|
||||
from authentik.sources.saml.processors.request import SESSION_KEY_REQUEST_ID
|
||||
from authentik.sources.saml.stages import PLAN_CONTEXT_SAML_SESSION_DATA, SAMLSourceFlowManager
|
||||
|
||||
LOGGER = get_logger()
|
||||
if TYPE_CHECKING:
|
||||
@@ -240,6 +237,7 @@ class ResponseProcessor:
|
||||
UserSAMLSourceConnection.objects.create(
|
||||
source=self._source, user=user, identifier=name_id.text
|
||||
)
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -249,9 +247,25 @@ class ResponseProcessor:
|
||||
"assertion": self.get_assertion(),
|
||||
"name_id": name_id,
|
||||
},
|
||||
policy_context={},
|
||||
policy_context={
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
def _get_session_index(self) -> str | None:
|
||||
"""Get SessionIndex from AuthnStatement element"""
|
||||
assertion = self._root.find(f"{{{NS_SAML_ASSERTION}}}Assertion")
|
||||
if assertion is None:
|
||||
return None
|
||||
authn_statement = assertion.find(f"{{{NS_SAML_ASSERTION}}}AuthnStatement")
|
||||
if authn_statement is None:
|
||||
return None
|
||||
return authn_statement.attrib.get("SessionIndex")
|
||||
|
||||
def get_assertion(self) -> Element | None:
|
||||
"""Get assertion element, if we have a signed assertion"""
|
||||
if self._assertion is not None:
|
||||
@@ -307,6 +321,7 @@ class ResponseProcessor:
|
||||
if name_id.attrib["Format"] == SAML_NAME_ID_FORMAT_TRANSIENT:
|
||||
return self._handle_name_id_transient()
|
||||
|
||||
session_index = self._get_session_index()
|
||||
return SAMLSourceFlowManager(
|
||||
source=self._source,
|
||||
request=self._http_request,
|
||||
@@ -318,12 +333,10 @@ class ResponseProcessor:
|
||||
},
|
||||
policy_context={
|
||||
"saml_response": etree.tostring(self._root),
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA: {
|
||||
"session_index": session_index or "",
|
||||
"name_id": name_id.text,
|
||||
"name_id_format": name_id.attrib.get("Format", ""),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
@@ -3,12 +3,40 @@
|
||||
from django.contrib.auth.signals import user_logged_out
|
||||
from django.dispatch import receiver
|
||||
from django.http import HttpRequest
|
||||
from django.urls import reverse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, User
|
||||
from authentik.core.models import USER_ATTRIBUTE_DELETE_ON_LOGOUT, AuthenticatedSession, User
|
||||
from authentik.flows.challenge import PLAN_CONTEXT_ATTRS, PLAN_CONTEXT_TITLE, PLAN_CONTEXT_URL
|
||||
from authentik.flows.models import in_memory_stage
|
||||
from authentik.flows.stage import RedirectStage, SessionEndStage
|
||||
from authentik.flows.views.executor import FlowExecutorView
|
||||
from authentik.providers.saml.native_logout import NativeLogoutStageView
|
||||
from authentik.sources.saml.models import SAMLSLOBindingTypes, SAMLSourceSession
|
||||
from authentik.sources.saml.processors.logout_request import LogoutRequestProcessor
|
||||
from authentik.sources.saml.views import PLAN_CONTEXT_SAML_RELAY_STATE, AutosubmitStageView
|
||||
from authentik.stages.user_logout.models import UserLogoutStage
|
||||
from authentik.stages.user_logout.stage import flow_pre_user_logout
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
# Stages that redirect the user away from authentik. Source SLO stages must be
|
||||
# inserted before these so they have a chance to execute.
|
||||
TERMINAL_STAGE_VIEWS = {SessionEndStage, NativeLogoutStageView}
|
||||
|
||||
|
||||
def _insert_before_terminal_stage(plan, stage):
|
||||
"""Insert a stage before any terminal stage (SessionEndStage, NativeLogoutStageView)
|
||||
in the plan. Falls back to append if no terminal stage is found."""
|
||||
for i, binding in enumerate(plan.bindings):
|
||||
try:
|
||||
if binding.stage.view in TERMINAL_STAGE_VIEWS:
|
||||
plan.insert_stage(stage, index=i)
|
||||
return
|
||||
except NotImplementedError:
|
||||
continue
|
||||
plan.append_stage(stage)
|
||||
|
||||
|
||||
@receiver(user_logged_out)
|
||||
def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
@@ -18,3 +46,89 @@ def on_user_logged_out(sender, request: HttpRequest, user: User, **_):
|
||||
if user.attributes.get(USER_ATTRIBUTE_DELETE_ON_LOGOUT, False):
|
||||
LOGGER.debug("Deleted temporary user", user=user)
|
||||
user.delete()
|
||||
|
||||
|
||||
@receiver(flow_pre_user_logout)
|
||||
def handle_saml_source_pre_user_logout(
|
||||
sender, request: HttpRequest, user: User, executor: FlowExecutorView, **kwargs
|
||||
):
|
||||
"""Handle SAML source SP-initiated SLO when user logs out via flow.
|
||||
Injects a stage into the logout flow to redirect the user to the IdP's SLO URL."""
|
||||
if not isinstance(executor.current_stage, UserLogoutStage):
|
||||
return
|
||||
|
||||
if not user.is_authenticated:
|
||||
return
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return
|
||||
|
||||
# Find SAMLSourceSessions for this user's current session
|
||||
saml_source_sessions = SAMLSourceSession.objects.filter(
|
||||
session=auth_session,
|
||||
user=user,
|
||||
).select_related("source")
|
||||
|
||||
for saml_session in saml_source_sessions:
|
||||
source = saml_session.source
|
||||
if not source.slo_url or not source.enabled:
|
||||
continue
|
||||
|
||||
try:
|
||||
# Use the flow executor URL as relay_state so that after the IdP
|
||||
# processes the LogoutRequest and sends a LogoutResponse, the user
|
||||
# is redirected back to the flow to continue remaining stages.
|
||||
relay_state = request.build_absolute_uri(
|
||||
reverse(
|
||||
"authentik_core:if-flow",
|
||||
kwargs={"flow_slug": executor.flow.slug},
|
||||
)
|
||||
)
|
||||
|
||||
# Stash the outbound relay_state so the SLOView can redirect to a
|
||||
# server-known value rather than trusting the echoed request param.
|
||||
executor.plan.context[PLAN_CONTEXT_SAML_RELAY_STATE] = relay_state
|
||||
|
||||
processor = LogoutRequestProcessor(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
name_id=saml_session.name_id,
|
||||
name_id_format=saml_session.name_id_format,
|
||||
session_index=saml_session.session_index,
|
||||
relay_state=relay_state,
|
||||
)
|
||||
|
||||
# Insert before terminal stages (SessionEndStage, NativeLogoutStageView)
|
||||
# so the SLO redirect runs before the flow ends or the user is
|
||||
# redirected away. Provider logout stages (at index 1/2) still run
|
||||
# first since they're inserted earlier.
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = processor.get_redirect_url()
|
||||
stage = in_memory_stage(RedirectStage, destination=redirect_url)
|
||||
else:
|
||||
# POST binding
|
||||
form_data = processor.get_post_form_data()
|
||||
executor.plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
executor.plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
executor.plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
stage = in_memory_stage(AutosubmitStageView)
|
||||
|
||||
_insert_before_terminal_stage(executor.plan, stage)
|
||||
|
||||
LOGGER.debug(
|
||||
"Injected SAML source SLO into logout flow",
|
||||
source=source.name,
|
||||
binding=source.slo_binding,
|
||||
)
|
||||
|
||||
except (KeyError, AttributeError) as exc:
|
||||
LOGGER.warning(
|
||||
"Failed to generate SAML source logout request",
|
||||
source=source.name,
|
||||
exc=exc,
|
||||
)
|
||||
|
||||
# Clean up SAMLSourceSessions for this auth session
|
||||
saml_source_sessions.delete()
|
||||
|
||||
71
authentik/sources/saml/stages.py
Normal file
71
authentik/sources/saml/stages.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""SAML Source stages and flow manager"""
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
from structlog.stdlib import get_logger
|
||||
|
||||
from authentik.core.models import AuthenticatedSession, User
|
||||
from authentik.core.sources.flow_manager import SourceFlowManager
|
||||
from authentik.core.sources.stage import PostSourceStage
|
||||
from authentik.flows.models import Flow, Stage, in_memory_stage
|
||||
from authentik.flows.planner import PLAN_CONTEXT_PENDING_USER, PLAN_CONTEXT_SOURCE
|
||||
from authentik.sources.saml.models import (
|
||||
GroupSAMLSourceConnection,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
UserSAMLSourceConnection,
|
||||
)
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_SESSION_DATA = "saml_session_data"
|
||||
|
||||
|
||||
class SAMLPostSourceStage(PostSourceStage):
|
||||
"""Extends PostSourceStage to also create SAMLSourceSession for SLO support."""
|
||||
|
||||
def dispatch(self, request: HttpRequest) -> HttpResponse:
|
||||
response = super().dispatch(request)
|
||||
|
||||
session_data = self.executor.plan.context.get(PLAN_CONTEXT_SAML_SESSION_DATA)
|
||||
if not session_data:
|
||||
return response
|
||||
|
||||
source = self.executor.plan.context.get(PLAN_CONTEXT_SOURCE)
|
||||
if not isinstance(source, SAMLSource):
|
||||
return response
|
||||
|
||||
user: User = self.executor.plan.context.get(PLAN_CONTEXT_PENDING_USER)
|
||||
if not user or not user.pk:
|
||||
return response
|
||||
|
||||
auth_session = AuthenticatedSession.from_request(request, user)
|
||||
if not auth_session:
|
||||
return response
|
||||
|
||||
SAMLSourceSession.objects.create(
|
||||
source=source,
|
||||
user=user,
|
||||
session=auth_session,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
name_id=session_data.get("name_id", ""),
|
||||
name_id_format=session_data.get("name_id_format", ""),
|
||||
)
|
||||
LOGGER.debug(
|
||||
"Created SAMLSourceSession",
|
||||
source=source.name,
|
||||
user=user,
|
||||
session_index=session_data.get("session_index", ""),
|
||||
)
|
||||
return response
|
||||
|
||||
|
||||
class SAMLSourceFlowManager(SourceFlowManager):
|
||||
"""Source flow manager for SAML Sources"""
|
||||
|
||||
user_connection_type = UserSAMLSourceConnection
|
||||
group_connection_type = GroupSAMLSourceConnection
|
||||
|
||||
def get_stages_to_append(self, flow: Flow) -> list[Stage]:
|
||||
return [
|
||||
in_memory_stage(SAMLPostSourceStage),
|
||||
]
|
||||
@@ -3,7 +3,6 @@
|
||||
from urllib.parse import parse_qsl, urlparse, urlunparse
|
||||
|
||||
from django.contrib.auth import logout
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.core.exceptions import SuspiciousOperation
|
||||
from django.http import Http404, HttpRequest, HttpResponse
|
||||
from django.http.response import HttpResponseBadRequest
|
||||
@@ -13,9 +12,13 @@ from django.utils.http import urlencode
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views import View
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from lxml import etree # nosec
|
||||
from structlog.stdlib import get_logger
|
||||
from xmlsec import InternalError, VerificationError
|
||||
|
||||
from authentik.common.saml.exceptions import CannotHandleAssertion
|
||||
from authentik.common.saml.parsers.logout_request import LogoutRequestParser
|
||||
from authentik.common.saml.parsers.logout_response import LogoutResponseParser
|
||||
from authentik.flows.challenge import (
|
||||
PLAN_CONTEXT_ATTRS,
|
||||
PLAN_CONTEXT_TITLE,
|
||||
@@ -33,7 +36,7 @@ from authentik.flows.planner import (
|
||||
FlowPlan,
|
||||
FlowPlanner,
|
||||
)
|
||||
from authentik.flows.stage import ChallengeStageView
|
||||
from authentik.flows.stage import ChallengeStageView, RedirectStage, SessionEndStage
|
||||
from authentik.flows.views.executor import NEXT_ARG_NAME, SESSION_KEY_GET, SESSION_KEY_PLAN
|
||||
from authentik.lib.views import bad_request_message
|
||||
from authentik.providers.saml.utils.encoding import nice64
|
||||
@@ -44,7 +47,13 @@ from authentik.sources.saml.exceptions import (
|
||||
MissingSAMLResponse,
|
||||
UnsupportedNameIDFormat,
|
||||
)
|
||||
from authentik.sources.saml.models import SAMLBindingTypes, SAMLSource
|
||||
from authentik.sources.saml.models import (
|
||||
SAMLBindingTypes,
|
||||
SAMLSLOBindingTypes,
|
||||
SAMLSource,
|
||||
SAMLSourceSession,
|
||||
)
|
||||
from authentik.sources.saml.processors.logout_response import LogoutResponseBuilder
|
||||
from authentik.sources.saml.processors.metadata import MetadataProcessor
|
||||
from authentik.sources.saml.processors.request import RequestProcessor
|
||||
from authentik.sources.saml.processors.response import ResponseProcessor
|
||||
@@ -52,6 +61,8 @@ from authentik.stages.consent.stage import PLAN_CONTEXT_CONSENT_HEADER, ConsentS
|
||||
|
||||
LOGGER = get_logger()
|
||||
|
||||
PLAN_CONTEXT_SAML_RELAY_STATE = "goauthentik.io/sources/saml/relay_state"
|
||||
|
||||
|
||||
class AutosubmitStageView(ChallengeStageView):
|
||||
"""Wrapper stage to create an autosubmit challenge from plan context variables"""
|
||||
@@ -181,16 +192,195 @@ class ACSView(View):
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
|
||||
class SLOView(LoginRequiredMixin, View):
|
||||
"""Single-Logout-View"""
|
||||
@method_decorator(csrf_exempt, name="dispatch")
|
||||
class SLOView(View):
|
||||
"""Single-Logout-View: handles SP-initiated SLO, IdP-initiated LogoutRequest,
|
||||
and LogoutResponse from IdP"""
|
||||
|
||||
def dispatch(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Log user out and redirect them to the IdP's SLO URL."""
|
||||
def get(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle GET requests: LogoutResponse, LogoutRequest, or initiate SLO."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
logout(request)
|
||||
return redirect(source.slo_url)
|
||||
|
||||
if "SAMLResponse" in request.GET:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.GET["SAMLResponse"],
|
||||
relay_state=request.GET.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.GET:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.GET["SAMLRequest"], is_post=False
|
||||
)
|
||||
|
||||
# No SAML message, initiate SP-initiated SLO
|
||||
return self._initiate_logout(request)
|
||||
|
||||
def post(self, request: HttpRequest, source_slug: str) -> HttpResponse:
|
||||
"""Handle POST requests: LogoutResponse or LogoutRequest from the IdP."""
|
||||
source: SAMLSource = get_object_or_404(SAMLSource, slug=source_slug)
|
||||
if not source.enabled:
|
||||
raise Http404
|
||||
|
||||
if "SAMLResponse" in request.POST:
|
||||
return self._handle_logout_response(
|
||||
request,
|
||||
request.POST["SAMLResponse"],
|
||||
relay_state=request.POST.get("RelayState"),
|
||||
)
|
||||
|
||||
if "SAMLRequest" in request.POST:
|
||||
return self._handle_logout_request(
|
||||
request, source, request.POST["SAMLRequest"], is_post=True
|
||||
)
|
||||
|
||||
return bad_request_message(request, "Missing SAMLRequest or SAMLResponse")
|
||||
|
||||
def _initiate_logout(self, request: HttpRequest) -> HttpResponse:
|
||||
"""Initiate logout using the brand's invalidation flow.
|
||||
|
||||
The invalidation flow contains a UserLogoutStage which fires the
|
||||
flow_pre_user_logout signal. Our signal handler in signals.py picks that up,
|
||||
finds the SAMLSourceSession, and injects the SLO redirect/POST stage."""
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _handle_logout_request(
|
||||
self,
|
||||
request: HttpRequest,
|
||||
source: SAMLSource,
|
||||
raw_request: str,
|
||||
is_post: bool = False,
|
||||
) -> HttpResponse:
|
||||
"""Handle an incoming LogoutRequest from the IdP (IdP-initiated SLO).
|
||||
|
||||
Parses the request, deletes the SAMLSourceSession (to prevent circular
|
||||
redirect back to the IdP), runs the invalidation flow, and appends a
|
||||
final stage to send the LogoutResponse back to the IdP."""
|
||||
parser = LogoutRequestParser()
|
||||
try:
|
||||
if is_post:
|
||||
logout_request = parser.parse(raw_request)
|
||||
else:
|
||||
logout_request = parser.parse_detached(raw_request)
|
||||
except (CannotHandleAssertion, ValueError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutRequest from IdP", exc=exc)
|
||||
return bad_request_message(request, str(exc))
|
||||
|
||||
relay_state = (
|
||||
request.GET.get("RelayState") if not is_post else request.POST.get("RelayState")
|
||||
)
|
||||
|
||||
# Delete SAMLSourceSession so the source signal handler doesn't try to
|
||||
# redirect back to the IdP (which would be circular)
|
||||
SAMLSourceSession.objects.filter(
|
||||
source=source,
|
||||
user=request.user,
|
||||
).delete()
|
||||
|
||||
# Build the LogoutResponse to send back to the IdP after logout
|
||||
response_builder = LogoutResponseBuilder(
|
||||
source=source,
|
||||
http_request=request,
|
||||
destination=source.slo_url,
|
||||
in_response_to=logout_request.id,
|
||||
)
|
||||
|
||||
# Sources do not have an invalidation flow, use the brand's
|
||||
flow = request.brand.flow_invalidation
|
||||
if not flow:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
planner = FlowPlanner(flow)
|
||||
planner.allow_empty_flows = True
|
||||
try:
|
||||
plan = planner.plan(request)
|
||||
except FlowNonApplicableException:
|
||||
logout(request)
|
||||
return self._send_logout_response(response_builder, relay_state)
|
||||
|
||||
# Append logout response stage, then session end
|
||||
self._append_response_stage(plan, source, response_builder, relay_state)
|
||||
plan.append_stage(in_memory_stage(SessionEndStage))
|
||||
return plan.to_redirect(request, flow)
|
||||
|
||||
def _send_logout_response(
|
||||
self,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
) -> HttpResponse:
|
||||
"""Send LogoutResponse back to the IdP directly (no flow).
|
||||
Without a flow we can't render an autosubmit form, so always redirect."""
|
||||
return redirect(response_builder.get_redirect_url(relay_state))
|
||||
|
||||
def _append_response_stage(
|
||||
self,
|
||||
plan: FlowPlan,
|
||||
source: SAMLSource,
|
||||
response_builder: LogoutResponseBuilder,
|
||||
relay_state: str | None = None,
|
||||
):
|
||||
"""Append a stage to send the LogoutResponse back to the IdP."""
|
||||
if source.slo_binding == SAMLSLOBindingTypes.REDIRECT:
|
||||
redirect_url = response_builder.get_redirect_url(relay_state)
|
||||
plan.append_stage(in_memory_stage(RedirectStage, destination=redirect_url))
|
||||
else:
|
||||
# POST binding — use autosubmit form
|
||||
form_data = response_builder.get_post_form_data(relay_state)
|
||||
plan.context[PLAN_CONTEXT_TITLE] = f"Logging out of {source.name}..."
|
||||
plan.context[PLAN_CONTEXT_URL] = source.slo_url
|
||||
plan.context[PLAN_CONTEXT_ATTRS] = form_data
|
||||
plan.append_stage(in_memory_stage(AutosubmitStageView))
|
||||
|
||||
def _handle_logout_response(
|
||||
self, request: HttpRequest, raw_response: str, relay_state: str | None = None
|
||||
) -> HttpResponse:
|
||||
"""Parse and handle a LogoutResponse from the IdP."""
|
||||
processor = LogoutResponseParser(raw_response)
|
||||
try:
|
||||
processor.parse()
|
||||
except (ValueError, etree.XMLSyntaxError) as exc:
|
||||
LOGGER.warning("Failed to parse LogoutResponse", exc=exc)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
processor.verify_status()
|
||||
|
||||
# If a RelayState was provided (e.g. the flow executor URL), advance
|
||||
# past the current stage (RedirectStage) in the plan so the flow
|
||||
# continues to the next stage instead of looping. Only redirect to the
|
||||
# value stashed in the plan context on outbound — never to the value
|
||||
# echoed back in the request, which is attacker-controllable.
|
||||
if relay_state and SESSION_KEY_PLAN in request.session:
|
||||
plan: FlowPlan = request.session[SESSION_KEY_PLAN]
|
||||
stored_relay_state = plan.context.get(PLAN_CONTEXT_SAML_RELAY_STATE, "")
|
||||
if relay_state != stored_relay_state:
|
||||
LOGGER.warning(
|
||||
"SAML logout relay_state mismatch, possible open redirect attempt",
|
||||
received_relay_state=relay_state,
|
||||
stored_relay_state=stored_relay_state,
|
||||
)
|
||||
if plan.bindings:
|
||||
plan.pop()
|
||||
request.session[SESSION_KEY_PLAN] = plan
|
||||
if stored_relay_state:
|
||||
return redirect(stored_relay_state)
|
||||
return redirect("authentik_core:root-redirect")
|
||||
|
||||
|
||||
class MetadataView(View):
|
||||
|
||||
@@ -6049,18 +6049,22 @@
|
||||
"authentik_sources_saml.add_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.add_samlsource",
|
||||
"authentik_sources_saml.add_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.add_samlsourcesession",
|
||||
"authentik_sources_saml.add_usersamlsourceconnection",
|
||||
"authentik_sources_saml.change_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.change_samlsource",
|
||||
"authentik_sources_saml.change_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.change_samlsourcesession",
|
||||
"authentik_sources_saml.change_usersamlsourceconnection",
|
||||
"authentik_sources_saml.delete_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.delete_samlsource",
|
||||
"authentik_sources_saml.delete_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.delete_samlsourcesession",
|
||||
"authentik_sources_saml.delete_usersamlsourceconnection",
|
||||
"authentik_sources_saml.view_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.view_samlsource",
|
||||
"authentik_sources_saml.view_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.view_samlsourcesession",
|
||||
"authentik_sources_saml.view_usersamlsourceconnection",
|
||||
"authentik_sources_scim.add_scimsource",
|
||||
"authentik_sources_scim.add_scimsourcegroup",
|
||||
@@ -11746,18 +11750,22 @@
|
||||
"authentik_sources_saml.add_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.add_samlsource",
|
||||
"authentik_sources_saml.add_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.add_samlsourcesession",
|
||||
"authentik_sources_saml.add_usersamlsourceconnection",
|
||||
"authentik_sources_saml.change_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.change_samlsource",
|
||||
"authentik_sources_saml.change_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.change_samlsourcesession",
|
||||
"authentik_sources_saml.change_usersamlsourceconnection",
|
||||
"authentik_sources_saml.delete_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.delete_samlsource",
|
||||
"authentik_sources_saml.delete_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.delete_samlsourcesession",
|
||||
"authentik_sources_saml.delete_usersamlsourceconnection",
|
||||
"authentik_sources_saml.view_groupsamlsourceconnection",
|
||||
"authentik_sources_saml.view_samlsource",
|
||||
"authentik_sources_saml.view_samlsourcepropertymapping",
|
||||
"authentik_sources_saml.view_samlsourcesession",
|
||||
"authentik_sources_saml.view_usersamlsourceconnection",
|
||||
"authentik_sources_scim.add_scimsource",
|
||||
"authentik_sources_scim.add_scimsourcegroup",
|
||||
@@ -13578,6 +13586,15 @@
|
||||
],
|
||||
"title": "Binding type"
|
||||
},
|
||||
"slo_binding": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"REDIRECT",
|
||||
"POST"
|
||||
],
|
||||
"title": "SLO Binding",
|
||||
"description": "Binding type for Single Logout requests to the IdP."
|
||||
},
|
||||
"verification_kp": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
@@ -13634,6 +13651,16 @@
|
||||
"signed_response": {
|
||||
"type": "boolean",
|
||||
"title": "Signed response"
|
||||
},
|
||||
"sign_authn_request": {
|
||||
"type": "boolean",
|
||||
"title": "Sign AuthnRequest",
|
||||
"description": "Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set."
|
||||
},
|
||||
"sign_logout_request": {
|
||||
"type": "boolean",
|
||||
"title": "Sign LogoutRequest",
|
||||
"description": "Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set."
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
2
go.mod
2
go.mod
@@ -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
4
go.sum
@@ -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=
|
||||
|
||||
4
lifecycle/aws/package-lock.json
generated
4
lifecycle/aws/package-lock.json
generated
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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-29 00:28+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 ""
|
||||
|
||||
@@ -19,7 +19,6 @@ Forti
|
||||
Fortigate
|
||||
Gatus
|
||||
Gestionnaire
|
||||
ghec
|
||||
Gitea
|
||||
Gravitee
|
||||
Homarr
|
||||
|
||||
16
packages/client-ts/src/apis/SourcesApi.ts
generated
16
packages/client-ts/src/apis/SourcesApi.ts
generated
@@ -101,6 +101,7 @@ import type {
|
||||
SCIMSourceUser,
|
||||
SCIMSourceUserRequest,
|
||||
SignatureAlgorithmEnum,
|
||||
SloBindingEnum,
|
||||
Source,
|
||||
SourceType,
|
||||
SyncStatus,
|
||||
@@ -749,10 +750,13 @@ export interface SourcesSamlListRequest {
|
||||
policyEngineMode?: PolicyEngineMode;
|
||||
preAuthenticationFlow?: string;
|
||||
search?: string;
|
||||
signAuthnRequest?: boolean;
|
||||
signLogoutRequest?: boolean;
|
||||
signatureAlgorithm?: SignatureAlgorithmEnum;
|
||||
signedAssertion?: boolean;
|
||||
signedResponse?: boolean;
|
||||
signingKp?: string;
|
||||
sloBinding?: SloBindingEnum;
|
||||
sloUrl?: string;
|
||||
slug?: string;
|
||||
ssoUrl?: string;
|
||||
@@ -7734,6 +7738,14 @@ export class SourcesApi extends runtime.BaseAPI {
|
||||
queryParameters["search"] = requestParameters["search"];
|
||||
}
|
||||
|
||||
if (requestParameters["signAuthnRequest"] != null) {
|
||||
queryParameters["sign_authn_request"] = requestParameters["signAuthnRequest"];
|
||||
}
|
||||
|
||||
if (requestParameters["signLogoutRequest"] != null) {
|
||||
queryParameters["sign_logout_request"] = requestParameters["signLogoutRequest"];
|
||||
}
|
||||
|
||||
if (requestParameters["signatureAlgorithm"] != null) {
|
||||
queryParameters["signature_algorithm"] = requestParameters["signatureAlgorithm"];
|
||||
}
|
||||
@@ -7750,6 +7762,10 @@ export class SourcesApi extends runtime.BaseAPI {
|
||||
queryParameters["signing_kp"] = requestParameters["signingKp"];
|
||||
}
|
||||
|
||||
if (requestParameters["sloBinding"] != null) {
|
||||
queryParameters["slo_binding"] = requestParameters["sloBinding"];
|
||||
}
|
||||
|
||||
if (requestParameters["sloUrl"] != null) {
|
||||
queryParameters["slo_url"] = requestParameters["sloUrl"];
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
|
||||
|
||||
@@ -165,6 +167,12 @@ export interface PatchedSAMLSourceRequest {
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -213,6 +221,18 @@ export interface PatchedSAMLSourceRequest {
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof PatchedSAMLSourceRequest
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -278,6 +298,8 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -295,6 +317,10 @@ export function PatchedSAMLSourceRequestFromJSONTyped(
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -332,6 +358,7 @@ export function PatchedSAMLSourceRequestToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -340,5 +367,7 @@ export function PatchedSAMLSourceRequestToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/client-ts/src/models/SAMLSource.ts
generated
29
packages/client-ts/src/models/SAMLSource.ts
generated
@@ -30,6 +30,8 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { ThemedUrls } from "./ThemedUrls";
|
||||
import { ThemedUrlsFromJSON } from "./ThemedUrls";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
@@ -215,6 +217,12 @@ export interface SAMLSource {
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -263,6 +271,18 @@ export interface SAMLSource {
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSource
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -343,6 +363,8 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -360,6 +382,10 @@ export function SAMLSourceFromJSONTyped(json: any, ignoreDiscriminator: boolean)
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -407,6 +433,7 @@ export function SAMLSourceToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -415,5 +442,7 @@ export function SAMLSourceToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
29
packages/client-ts/src/models/SAMLSourceRequest.ts
generated
29
packages/client-ts/src/models/SAMLSourceRequest.ts
generated
@@ -30,6 +30,8 @@ import {
|
||||
SignatureAlgorithmEnumFromJSON,
|
||||
SignatureAlgorithmEnumToJSON,
|
||||
} from "./SignatureAlgorithmEnum";
|
||||
import type { SloBindingEnum } from "./SloBindingEnum";
|
||||
import { SloBindingEnumFromJSON, SloBindingEnumToJSON } from "./SloBindingEnum";
|
||||
import type { UserMatchingModeEnum } from "./UserMatchingModeEnum";
|
||||
import { UserMatchingModeEnumFromJSON, UserMatchingModeEnumToJSON } from "./UserMatchingModeEnum";
|
||||
|
||||
@@ -165,6 +167,12 @@ export interface SAMLSourceRequest {
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
bindingType?: BindingTypeEnum;
|
||||
/**
|
||||
* Binding type for Single Logout requests to the IdP.
|
||||
* @type {SloBindingEnum}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
sloBinding?: SloBindingEnum;
|
||||
/**
|
||||
* When selected, incoming assertion's Signatures will be validated against this certificate. To allow unsigned Requests, leave on default.
|
||||
* @type {string}
|
||||
@@ -213,6 +221,18 @@ export interface SAMLSourceRequest {
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signedResponse?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signAuthnRequest?: boolean;
|
||||
/**
|
||||
* Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.
|
||||
* @type {boolean}
|
||||
* @memberof SAMLSourceRequest
|
||||
*/
|
||||
signLogoutRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -280,6 +300,8 @@ export function SAMLSourceRequestFromJSONTyped(
|
||||
json["binding_type"] == null
|
||||
? undefined
|
||||
: BindingTypeEnumFromJSON(json["binding_type"]),
|
||||
sloBinding:
|
||||
json["slo_binding"] == null ? undefined : SloBindingEnumFromJSON(json["slo_binding"]),
|
||||
verificationKp: json["verification_kp"] == null ? undefined : json["verification_kp"],
|
||||
signingKp: json["signing_kp"] == null ? undefined : json["signing_kp"],
|
||||
digestAlgorithm:
|
||||
@@ -297,6 +319,10 @@ export function SAMLSourceRequestFromJSONTyped(
|
||||
encryptionKp: json["encryption_kp"] == null ? undefined : json["encryption_kp"],
|
||||
signedAssertion: json["signed_assertion"] == null ? undefined : json["signed_assertion"],
|
||||
signedResponse: json["signed_response"] == null ? undefined : json["signed_response"],
|
||||
signAuthnRequest:
|
||||
json["sign_authn_request"] == null ? undefined : json["sign_authn_request"],
|
||||
signLogoutRequest:
|
||||
json["sign_logout_request"] == null ? undefined : json["sign_logout_request"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -334,6 +360,7 @@ export function SAMLSourceRequestToJSONTyped(
|
||||
force_authn: value["forceAuthn"],
|
||||
name_id_policy: SAMLNameIDPolicyEnumToJSON(value["nameIdPolicy"]),
|
||||
binding_type: BindingTypeEnumToJSON(value["bindingType"]),
|
||||
slo_binding: SloBindingEnumToJSON(value["sloBinding"]),
|
||||
verification_kp: value["verificationKp"],
|
||||
signing_kp: value["signingKp"],
|
||||
digest_algorithm: DigestAlgorithmEnumToJSON(value["digestAlgorithm"]),
|
||||
@@ -342,5 +369,7 @@ export function SAMLSourceRequestToJSONTyped(
|
||||
encryption_kp: value["encryptionKp"],
|
||||
signed_assertion: value["signedAssertion"],
|
||||
signed_response: value["signedResponse"],
|
||||
sign_authn_request: value["signAuthnRequest"],
|
||||
sign_logout_request: value["signLogoutRequest"],
|
||||
};
|
||||
}
|
||||
|
||||
57
packages/client-ts/src/models/SloBindingEnum.ts
generated
Normal file
57
packages/client-ts/src/models/SloBindingEnum.ts
generated
Normal file
@@ -0,0 +1,57 @@
|
||||
/* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
*
|
||||
* @export
|
||||
*/
|
||||
export const SloBindingEnum = {
|
||||
Redirect: "REDIRECT",
|
||||
Post: "POST",
|
||||
UnknownDefaultOpenApi: "11184809",
|
||||
} as const;
|
||||
export type SloBindingEnum = (typeof SloBindingEnum)[keyof typeof SloBindingEnum];
|
||||
|
||||
export function instanceOfSloBindingEnum(value: any): boolean {
|
||||
for (const key in SloBindingEnum) {
|
||||
if (Object.prototype.hasOwnProperty.call(SloBindingEnum, key)) {
|
||||
if (SloBindingEnum[key as keyof typeof SloBindingEnum] === value) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function SloBindingEnumFromJSON(json: any): SloBindingEnum {
|
||||
return SloBindingEnumFromJSONTyped(json, false);
|
||||
}
|
||||
|
||||
export function SloBindingEnumFromJSONTyped(
|
||||
json: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SloBindingEnum {
|
||||
return json as SloBindingEnum;
|
||||
}
|
||||
|
||||
export function SloBindingEnumToJSON(value?: SloBindingEnum | null): any {
|
||||
return value as any;
|
||||
}
|
||||
|
||||
export function SloBindingEnumToJSONTyped(
|
||||
value: any,
|
||||
ignoreDiscriminator: boolean,
|
||||
): SloBindingEnum {
|
||||
return value as SloBindingEnum;
|
||||
}
|
||||
1
packages/client-ts/src/models/index.ts
generated
1
packages/client-ts/src/models/index.ts
generated
@@ -766,6 +766,7 @@ export * from "./SettingsRequest";
|
||||
export * from "./SeverityEnum";
|
||||
export * from "./ShellChallenge";
|
||||
export * from "./SignatureAlgorithmEnum";
|
||||
export * from "./SloBindingEnum";
|
||||
export * from "./Software";
|
||||
export * from "./SoftwareRequest";
|
||||
export * from "./Source";
|
||||
|
||||
@@ -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",
|
||||
@@ -192,6 +192,7 @@ ignore_missing_imports = true
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = [
|
||||
"authentik.common.*",
|
||||
"authentik.admin.*",
|
||||
"authentik.api.*",
|
||||
"authentik.blueprints.*",
|
||||
|
||||
63
schema.yml
63
schema.yml
@@ -24369,6 +24369,14 @@ paths:
|
||||
type: string
|
||||
format: uuid
|
||||
- $ref: '#/components/parameters/QuerySearch'
|
||||
- in: query
|
||||
name: sign_authn_request
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: sign_logout_request
|
||||
schema:
|
||||
type: boolean
|
||||
- in: query
|
||||
name: signature_algorithm
|
||||
schema:
|
||||
@@ -24386,6 +24394,14 @@ paths:
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
- in: query
|
||||
name: slo_binding
|
||||
schema:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: |+
|
||||
Binding type for Single Logout requests to the IdP.
|
||||
|
||||
- in: query
|
||||
name: slo_url
|
||||
schema:
|
||||
@@ -50436,6 +50452,10 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -50473,6 +50493,16 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
PatchedSCIMMappingRequest:
|
||||
type: object
|
||||
description: SCIMMapping Serializer
|
||||
@@ -54176,6 +54206,10 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -54212,6 +54246,16 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
required:
|
||||
- component
|
||||
- icon_themed_urls
|
||||
@@ -54380,6 +54424,10 @@ components:
|
||||
no Policy is sent.
|
||||
binding_type:
|
||||
$ref: '#/components/schemas/BindingTypeEnum'
|
||||
slo_binding:
|
||||
allOf:
|
||||
- $ref: '#/components/schemas/SloBindingEnum'
|
||||
description: Binding type for Single Logout requests to the IdP.
|
||||
verification_kp:
|
||||
type: string
|
||||
format: uuid
|
||||
@@ -54417,6 +54465,16 @@ components:
|
||||
type: boolean
|
||||
signed_response:
|
||||
type: boolean
|
||||
sign_authn_request:
|
||||
type: boolean
|
||||
title: Sign AuthnRequest
|
||||
description: Whether to sign outgoing AuthnRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
sign_logout_request:
|
||||
type: boolean
|
||||
title: Sign LogoutRequest
|
||||
description: Whether to sign outgoing LogoutRequests. Requires a Signing
|
||||
Keypair to be set.
|
||||
required:
|
||||
- name
|
||||
- pre_authentication_flow
|
||||
@@ -55677,6 +55735,11 @@ components:
|
||||
- http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512
|
||||
- http://www.w3.org/2000/09/xmldsig#dsa-sha1
|
||||
type: string
|
||||
SloBindingEnum:
|
||||
enum:
|
||||
- REDIRECT
|
||||
- POST
|
||||
type: string
|
||||
Software:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
8
uv.lock
generated
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
2109
web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
|
||||
@@ -52,6 +52,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24",
|
||||
"npm": ">=11.10.1"
|
||||
"npm": ">=11.6.2"
|
||||
}
|
||||
}
|
||||
|
||||
21
web/scripts/lint-lockfile.sh
Executable file
21
web/scripts/lint-lockfile.sh
Executable 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
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
SAMLNameIDPolicyEnum,
|
||||
SAMLSource,
|
||||
SignatureAlgorithmEnum,
|
||||
SloBindingEnum,
|
||||
SourcesApi,
|
||||
UsageEnum,
|
||||
UserMatchingModeEnum,
|
||||
@@ -226,6 +227,32 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
|
||||
${msg("Optional URL if the IDP supports Single-Logout.")}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("SLO Binding")}
|
||||
required
|
||||
name="sloBinding"
|
||||
>
|
||||
<ak-radio
|
||||
.options=${[
|
||||
{
|
||||
label: msg("Redirect binding"),
|
||||
value: SloBindingEnum.Redirect,
|
||||
default: true,
|
||||
},
|
||||
{
|
||||
label: msg("Post binding"),
|
||||
value: SloBindingEnum.Post,
|
||||
},
|
||||
]}
|
||||
.value=${this.instance?.sloBinding}
|
||||
>
|
||||
</ak-radio>
|
||||
<p class="pf-c-form__helper-text">
|
||||
${msg(
|
||||
"Binding type used for sending Single Logout requests to the IdP.",
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-form-element-horizontal label=${msg("Issuer")} name="issuer">
|
||||
<input
|
||||
type="text"
|
||||
@@ -274,6 +301,22 @@ export class SAMLSourceForm extends BaseSourceForm<SAMLSource> {
|
||||
)}
|
||||
</p>
|
||||
</ak-form-element-horizontal>
|
||||
<ak-switch-input
|
||||
name="signAuthnRequest"
|
||||
label=${msg("Sign AuthnRequest")}
|
||||
?checked=${this.instance?.signAuthnRequest ?? false}
|
||||
help=${msg(
|
||||
"Whether to sign outgoing AuthnRequests. Requires a Signing Keypair to be set.",
|
||||
)}
|
||||
></ak-switch-input>
|
||||
<ak-switch-input
|
||||
name="signLogoutRequest"
|
||||
label=${msg("Sign LogoutRequest")}
|
||||
?checked=${this.instance?.signLogoutRequest ?? false}
|
||||
help=${msg(
|
||||
"Whether to sign outgoing LogoutRequests. Requires a Signing Keypair to be set.",
|
||||
)}
|
||||
></ak-switch-input>
|
||||
<ak-form-element-horizontal
|
||||
label=${msg("Verification Certificate")}
|
||||
name="verificationKp"
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5270,7 +5270,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">
|
||||
@@ -11007,22 +11007,6 @@ Vazby na skupiny/uživatele jsou kontrolovány vůči uživateli události.</tar
|
||||
<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>
|
||||
|
||||
@@ -5295,7 +5295,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">
|
||||
@@ -11040,22 +11040,6 @@ Bindings zu Gruppen/Benutzern werden mit dem Benutzer des Ereignisses abgegliche
|
||||
<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>
|
||||
|
||||
@@ -4089,7 +4089,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>
|
||||
@@ -9010,22 +9010,6 @@ Bindings to groups/users are checked against the user of the event.</source>
|
||||
<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>
|
||||
|
||||
@@ -5235,7 +5235,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">
|
||||
@@ -10965,22 +10965,6 @@ Las vinculaciones a grupos/usuarios se verifican en función del usuario del eve
|
||||
<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>
|
||||
|
||||
@@ -5398,7 +5398,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">
|
||||
@@ -11206,22 +11206,6 @@ Liitokset käyttäjiin/ryhmiin tarkistetaan tapahtuman käyttäjästä.</target>
|
||||
<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
Reference in New Issue
Block a user