Compare commits

...

24 Commits

Author SHA1 Message Date
Teffen Ellis
90f27f93e1 web/e2e: fix three regressions blocking the parallel suite
Locally and in CI the new `e2e (playwright)` job appeared to "hang"
under `fullyParallel: true` + `workers: "50%"`. The hang was actually
five tests sharing two unrelated bugs that all manifest as 30s test
timeouts; the cluster only *looks* like a parallelism issue because
multiple workers stall on the same wall-clock window. With these three
fixes the full suite is green in 1m48s on `--workers=2` (was: 5 failed
/ 17 passed in 5m30s).

1. `web/test/browser/600-providers.test.ts`
   PR #21647 dropped the `to:` argument on the `session.login()` call
   in this file's `beforeEach`. Without it, `SessionFixture.login()`
   waits for the auth-flow URL pattern to re-appear — which it does
   immediately, since we just navigated there — so the helper returns
   *before* the post-login redirect lands. The wizard buttons probed
   afterward live on `/if/admin/#/core/providers`, which the user never
   actually reaches; every test in the file then hits the 30s
   `beforeEach` timeout. Pin the destination explicitly, matching the
   shape of every other test file.

2. `web/src/admin/roles/ak-role-list.ts`
   The role-list row anchor had no aria-label, so its accessible name
   was the (random, generated) role name. `500-roles.test.ts` searches
   for that anchor with `getByRole("link", { name: "view details" })`
   — the same selector `400-groups.test.ts` uses against the group
   list, where `GroupListPage.row()` *does* set
   `aria-label="View details of group ..."`. Bring the role row to
   parity with groups; the test wasn't wrong, the UI was missing the
   accessibility hook.

3. `web/test/browser/500-roles.test.ts` ("Edit role from view page")
   The post-edit verification used `page.getByText(updatedName)`, but
   on the role view page the new name renders in two places (the
   "Role <name>" page-navbar heading and the description-list value),
   so the bare text match resolves to two elements and trips
   strict-mode. Add `{ exact: true }` so we assert the canonical value
   the edit wrote rather than the heading template.

Co-Authored-By: Agent (authentik-i21996-internal-achievable-raisin) <279763771+playpen-agent@users.noreply.github.com>
2026-05-01 15:42:07 +00:00
Teffen Ellis
d92bfc473f ci/web: post Playwright result comment + gated S3 upload + !cancelled() guards
Three reviewer-facing improvements to the e2e job:

1. Idempotent PR comment summarising Playwright pass/fail/flaky/skipped
   counts. Marker `<!-- playwright-result -->` lets re-runs edit the
   same comment instead of piling up. Skipped on fork PRs where the
   default GITHUB_TOKEN is read-only.

2. Optional S3 publish of the HTML report to
   `s3://authentik-playwright-artifacts/pr-<n>/run-<id>/attempt-<n>/`,
   gated behind `vars.PLAYWRIGHT_S3_ENABLED == 'true'`. The bucket is
   pending infra provisioning; the public URL pattern is already wired
   into the comment so flipping the variable on later requires no
   workflow changes. Borrows the OIDC + IAM role plumbing from
   `.github/workflows/release-publish.yml`.

3. Switch the failure-guarded reporting/upload steps to `!cancelled()`
   so a superseded (cancelled) run no longer emits failure-shaped noise,
   and so successful runs still produce the artifact bundle reviewers
   expect.

Adds the Playwright JSON reporter so the parse step can pull pass/fail
counts from `playwright-report/results.json` for the comment body.

Co-Authored-By: Agent (authentik-i21996-internal-achievable-raisin) <279763771+playpen-agent@users.noreply.github.com>
2026-05-01 14:57:35 +00:00
Teffen Ellis
4d3ac3f63a Flesh out types. 2026-05-01 16:05:16 +02:00
Teffen Ellis
e29d37bb8a Always parallel. 2026-05-01 15:48:29 +02:00
Teffen Ellis
6fee629926 Update expected path. 2026-05-01 04:04:57 +02:00
Teffen Ellis
cad8395dad Ignore playwright-traces. 2026-05-01 04:04:57 +02:00
Teffen Ellis
191ecc51bd Reorder tests. 2026-05-01 04:04:57 +02:00
Teffen Ellis
71e9092810 Remove guard. 2026-05-01 04:04:56 +02:00
Teffen Ellis
34dbb78df0 Use parallelism. 2026-05-01 04:04:56 +02:00
Teffen Ellis
e29a3eb35a ci/web: format test-admin-user.yaml with prettier
Pick up the 4-space indent that web/'s prettier config enforces. The
file was added under issue #21994 with 2-space indent and tripped the
ci-web format check on push.

Co-Authored-By: Agent (authentik-i21994-better-mobile-tangelo) <279763771+playpen-agent@users.noreply.github.com>
2026-05-01 04:04:56 +02:00
Teffen Ellis
fe8b3d1687 ci/web: make test-admin blueprint self-contained
The previous blueprint used !Find to look up the authentik Admins group,
which raced against system/bootstrap.yaml and resolved to None when the
explicit apply_blueprint step ran before the worker had applied bootstrap.
The serializer rejected groups: [None] with Invalid pk "None".

Define the group in the same blueprint with state: present and reference
it via !KeyOf, so the test admin setup does not depend on any pre-existing
data. If bootstrap has already created the group, state: present is a
no-op on the identifiers; otherwise the group is created here.

Co-Authored-By: Agent (authentik-i21994-better-mobile-tangelo) <279763771+playpen-agent@users.noreply.github.com>
2026-05-01 04:04:56 +02:00
Teffen Ellis
0fd2a13b35 Bump package.json 2026-05-01 04:04:55 +02:00
Teffen Ellis
17d49aa99a ci/web: run Playwright e2e suite on every PR
Boots the full authentik stack (postgres + Go server + Rust worker)
inside the existing ci-web workflow, applies migrations and the
test-admin user blueprint, then runs `corepack npm run --prefix web
test:e2e` against http://localhost:9000. Uploads the HTML report,
traces/videos, and authentik logs as artifacts on failure so reviewers
can debug without rerunning locally.

Also enables the HTML reporter and screenshot/video capture on CI in
playwright.config.js, and updates the full dev-environment docs to
point at the same npm scripts CI uses so local and CI runs stay in
lockstep.

Closes #21994

Co-Authored-By: Agent (authentik-i21994-better-mobile-tangelo) <279763771+playpen-agent@users.noreply.github.com>
2026-05-01 04:04:55 +02:00
Teffen Ellis
b9389544be Flesh out github actions. 2026-05-01 01:23:13 +02:00
Teffen Ellis
ed82b3f623 Lint. 2026-05-01 01:21:05 +02:00
Teffen Ellis
b281bb819d Bump. 2026-05-01 01:21:05 +02:00
Teffen Ellis
3ca633c10a lint. 2026-05-01 01:21:05 +02:00
Teffen Ellis
ad1582c43f Clean up docs container. 2026-05-01 01:21:05 +02:00
Teffen Ellis
53f2826ea1 Flesh out github actions. 2026-05-01 01:21:03 +02:00
Teffen Ellis
ccf7225f03 Update makefile. 2026-05-01 01:20:18 +02:00
Teffen Ellis
85469c86d1 Prep containers. 2026-05-01 01:20:18 +02:00
Teffen Ellis
0bfce2d1f9 Bump engines. 2026-05-01 01:20:18 +02:00
Teffen Ellis
7b4e175d59 Flesh out node scripts. 2026-05-01 01:20:18 +02:00
Teffen Ellis
b47f6f8e56 Fix mounted references. 2026-05-01 01:20:17 +02:00
51 changed files with 2631 additions and 1190 deletions

81
.github/actions/setup-node/action.yml vendored Normal file
View File

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

View File

@@ -18,19 +18,24 @@ 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/
@@ -51,7 +56,8 @@ 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: ""
@@ -67,27 +73,11 @@ runs:
uses: taiki-e/install-action@481c34c1cf3a84c68b5e46f4eccfc82af798415a # v2
with:
tool: cargo-deny cargo-machete cargo-llvm-cov nextest
- name: Setup node (web)
- name: Setup node (root, web)
if: ${{ contains(inputs.dependencies, 'node') }}
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v4
uses: ./.github/actions/setup-node
with:
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
working-directory: web
- name: Setup go
if: ${{ contains(inputs.dependencies, 'go') }}
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v5
@@ -97,7 +87,9 @@ 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
@@ -105,7 +97,7 @@ runs:
run: |
export PSQL_TAG=${{ inputs.postgresql_version }}
docker compose -f .github/actions/setup/compose.yml up -d --wait
cd web && npm ci
corepack npm ci --prefix web
- name: Generate config
if: ${{ contains(inputs.dependencies, 'python') }}
shell: uv run python {0}

View File

@@ -67,6 +67,16 @@ 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
@@ -81,7 +91,8 @@ 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 Normal file
View File

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

View File

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

View File

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

View File

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

View File

@@ -73,7 +73,8 @@ 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:
@@ -91,7 +92,8 @@ 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
@@ -101,7 +103,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:
@@ -109,8 +111,13 @@ 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
@@ -118,10 +125,13 @@ 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/
rm -rf .github/ scripts/ packages/logger-js/
mv ../.github ../scripts .
mv ../packages/logger-js ./packages/
- name: Setup authentik env (stable)
uses: ./.github/actions/setup
with:
@@ -169,7 +179,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
@@ -252,19 +262,22 @@ 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: |
npm ci
npm run build
npm run build:sfe
corepack npm ci
corepack npm run build
corepack npm run build:sfe
- name: run e2e
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -302,14 +315,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: |
npm ci
npm run build
npm run build:sfe
corepack npm ci --prefix web
corepack npm run build --prefix web
corepack npm run build:sfe --prefix web
- name: run conformance
run: |
uv run coverage run manage.py test ${{ matrix.job.glob }}
@@ -375,7 +388,9 @@ jobs:
uses: ./.github/workflows/_reusable-docker-build.yml
secrets: inherit
with:
image_name: ${{ github.repository == 'goauthentik/authentik-internal' && 'ghcr.io/goauthentik/internal-server' || 'ghcr.io/goauthentik/dev-server' }}
image_name: ${{ github.repository == 'goauthentik/authentik-internal' &&
'ghcr.io/goauthentik/internal-server' ||
'ghcr.io/goauthentik/dev-server' }}
release: false
pr-comment:
needs:

View File

@@ -114,8 +114,11 @@ 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' }}
@@ -136,8 +139,8 @@ jobs:
- ldap
- radius
- rac
goos: [linux]
goarch: [amd64, arm64]
goos: [ linux ]
goarch: [ amd64, arm64 ]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
@@ -145,16 +148,11 @@ jobs:
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
with:
go-version-file: "go.mod"
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: ./.github/actions/setup-node
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
working-directory: web
- name: Build web
working-directory: web/
run: |
npm ci
npm run build-proxy
run: corepack npm run build-proxy --prefix web
- name: Build outpost
run: |
set -x

View File

@@ -12,51 +12,280 @@ on:
- main
- version-*
env:
POSTGRES_DB: authentik
POSTGRES_USER: authentik
POSTGRES_PASSWORD: "EK-5jnKfjrGRm<77"
AUTHENTIK_BLUEPRINTS_DIR: "./blueprints"
AUTHENTIK_OUTPOSTS__DISABLE_EMBEDDED_OUTPOST: "true"
# Drives the system/bootstrap.yaml blueprint at startup: creates akadmin with
# these credentials and flips the Setup flag (Setup.set(True)) so the SPA's
# post-login redirect to "/" doesn't bounce through /setup, which would 500
# because the OOBE policy refuses to run once akadmin already has a usable
# password. See authentik/core/setup/signals.py and blueprints/default/flow-oobe.yaml.
AUTHENTIK_BOOTSTRAP_EMAIL: "test-admin@goauthentik.io"
AUTHENTIK_BOOTSTRAP_PASSWORD: "test-runner"
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: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: ./.github/actions/setup-node
with:
node-version-file: ${{ matrix.project }}/package.json
cache: "npm"
cache-dependency-path: ${{ matrix.project }}/package-lock.json
- working-directory: ${{ matrix.project }}/
run: |
npm ci
working-directory: web
- name: Lint
working-directory: ${{ matrix.project }}/
run: npm run ${{ matrix.command }}
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
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: ./.github/actions/setup-node
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
working-directory: web
- name: build
working-directory: web/
run: npm run build
run: corepack npm run build
e2e:
name: e2e (playwright)
runs-on: ubuntu-latest
timeout-minutes: 60
permissions:
contents: read
# Required so the "Comment Playwright result on PR" step can update its
# marker comment via the gh CLI / REST API.
pull-requests: write
# Required so the optional "Upload HTML report to S3" step can mint OIDC
# credentials with aws-actions/configure-aws-credentials. Harmless when
# the upload is gated off.
id-token: write
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- name: Setup authentik env
uses: ./.github/actions/setup
with:
dependencies: system,python,node,go,rust,runtime
- name: Build web UI
run: corepack npm run --prefix web build
- name: Build authentik server (Go)
run: | # shell
go build -o ./bin/authentik-server ./cmd/server
sudo install -m 0755 ./bin/authentik-server /usr/local/bin/authentik-server
- name: Build authentik worker (Rust)
run: | # shell
cargo build --release --bin authentik
sudo install -m 0755 ./target/release/authentik /usr/local/bin/authentik
- name: Apply migrations
run: uv run python -m lifecycle.migrate
- name: Resolve Playwright version
id: playwright-version
working-directory: web
run: | # shell
version=$(node -p "require('@playwright/test/package.json').version")
if [ -z "$version" ]; then
echo "Failed to resolve @playwright/test version" >&2
exit 1
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
- name: Cache Playwright browsers
id: playwright-cache
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v4
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }}
- name: Install Playwright browsers
working-directory: web
run: | # shell
if [ "${{ steps.playwright-cache.outputs.cache-hit }}" = "true" ]; then
corepack npm exec -- playwright install-deps chromium
else
corepack npm exec -- playwright install --with-deps chromium
fi
- name: Start authentik server and worker
run: | # shell
set -euo pipefail
mkdir -p /tmp/ak-logs
# The Go server (authentik-server) spawns gunicorn as a child via PATH lookup
# and inherits the env that `uv run` set up for it. Verify gunicorn resolves
# under the same launcher so we fail fast here instead of waiting for an
# empty 200 from the proxy fallback later.
uv run --frozen sh -c 'command -v gunicorn' \
|| { echo "gunicorn not resolvable from uv run"; exit 1; }
uv run ak server > /tmp/ak-logs/server.log 2>&1 &
echo $! > /tmp/ak-logs/server.pid
# The Rust worker also opens an HTTP/metrics server on listen.http /
# listen.metrics (default :9000 / :9300). On a single CI host that races the
# Go server's binds and silently steals :9000, leaving Playwright talking to
# a healthcheck-only axum router that returns 200/empty for /if/* paths.
# Pin the worker to disjoint ports so the Go server keeps the public 9000.
AUTHENTIK_LISTEN__HTTP="[::]:9001" \
AUTHENTIK_LISTEN__METRICS="[::]:9301" \
uv run ak worker > /tmp/ak-logs/worker.log 2>&1 &
echo $! > /tmp/ak-logs/worker.pid
- name: Wait for authentik to be ready
run: | # shell
set -euo pipefail
# Readiness probes must verify the Go server is actually serving the request,
# not just that *something* on :9000 returned 200. The Go proxy stamps
# `X-authentik-version` on its static responses and the rendered flow page
# contains the <ak-flow-executor> custom element — both are absent from the
# worker's axum healthcheck router, so checking either rules out the
# port-collision failure mode.
timeout 240 bash -c '
until curl -fsS -o /dev/null http://localhost:9000/-/health/ready/; do
sleep 2
done'
timeout 300 bash -c '
until curl -fsS http://localhost:9000/if/flow/default-authentication-flow/ \
| grep -q "ak-flow-executor"; do
sleep 3
done'
- name: Run Playwright tests
working-directory: web
env:
AK_TEST_RUNNER_PAGE_URL: http://localhost:9000
run: corepack npm run test:e2e
# Reporting / upload steps below intentionally use `!cancelled()` rather
# than `failure()`: a cancelled run (e.g. superseded by a newer push) is
# not a real result and shouldn't produce reviewer-facing artifacts or
# comments. `if-no-files-found: ignore` keeps the "passed" case quiet.
- name: Upload Playwright HTML report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report
path: web/playwright-report/
retention-days: 14
if-no-files-found: ignore
- name: Upload Playwright traces and videos
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-traces
path: web/test-results/
retention-days: 14
if-no-files-found: ignore
- name: Upload authentik server and worker logs
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: authentik-logs
path: /tmp/ak-logs/
retention-days: 14
if-no-files-found: ignore
- name: Parse Playwright results
id: playwright-results
if: ${{ !cancelled() }}
run: | # shell
set -euo pipefail
report=web/playwright-report/results.json
if [ ! -f "$report" ]; then
{
echo "available=false"
echo "passed=0"
echo "failed=0"
echo "flaky=0"
echo "skipped=0"
} >> "$GITHUB_OUTPUT"
exit 0
fi
{
echo "available=true"
echo "passed=$(jq -r '.stats.expected // 0' "$report")"
echo "failed=$(jq -r '.stats.unexpected // 0' "$report")"
echo "flaky=$(jq -r '.stats.flaky // 0' "$report")"
echo "skipped=$(jq -r '.stats.skipped // 0' "$report")"
} >> "$GITHUB_OUTPUT"
# The S3 publishing pair below is intentionally gated off until the
# `authentik-playwright-artifacts` bucket is provisioned by infra. Flip
# the repo variable `PLAYWRIGHT_S3_ENABLED=true` to turn it on; the URL
# baked into the PR comment below already points at the eventual key.
- name: Configure AWS credentials for HTML report upload
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && vars.PLAYWRIGHT_S3_ENABLED == 'true' }}
uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0
with:
role-to-assume: "arn:aws:iam::016170277896:role/github_goauthentik_authentik"
aws-region: eu-central-1
- name: Upload HTML report to S3
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && vars.PLAYWRIGHT_S3_ENABLED == 'true' }}
env:
S3_BUCKET: authentik-playwright-artifacts
S3_KEY_PREFIX: pr-${{ github.event.pull_request.number }}/run-${{ github.run_id }}/attempt-${{ github.run_attempt }}
run: | # shell
set -euo pipefail
if [ ! -d web/playwright-report ]; then
echo "No playwright-report/ produced; skipping S3 upload"
exit 0
fi
aws s3 cp \
--recursive \
--acl=public-read \
--cache-control "public, max-age=600" \
web/playwright-report/ \
"s3://${S3_BUCKET}/${S3_KEY_PREFIX}/"
# Same-repo guard: fork PRs run with a read-only GITHUB_TOKEN (even after
# maintainer approval), so the comment + edit calls would 403. Skip cleanly
# rather than failing the job on every fork PR run.
- name: Comment Playwright result on PR
if: ${{ !cancelled() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
AVAILABLE: ${{ steps.playwright-results.outputs.available }}
PASSED: ${{ steps.playwright-results.outputs.passed }}
FAILED: ${{ steps.playwright-results.outputs.failed }}
FLAKY: ${{ steps.playwright-results.outputs.flaky }}
SKIPPED: ${{ steps.playwright-results.outputs.skipped }}
S3_ENABLED: ${{ vars.PLAYWRIGHT_S3_ENABLED }}
REPORT_URL: https://authentik-playwright-artifacts.s3.amazonaws.com/pr-${{ github.event.pull_request.number }}/run-${{ github.run_id }}/attempt-${{ github.run_attempt }}/index.html
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}
run: | # shell
set -euo pipefail
marker='<!-- playwright-result -->'
if [ "$AVAILABLE" = "true" ]; then
if [ "$FAILED" -gt 0 ]; then
status='❌ Failed'
elif [ "$FLAKY" -gt 0 ]; then
status='⚠️ Passed with flakes'
else
status='✅ Passed'
fi
stats=$(printf '| Result | Count |\n|---|---|\n| ✅ Passed | %s |\n| ❌ Failed | %s |\n| ⚠️ Flaky | %s |\n| ⏭️ Skipped | %s |\n' "$PASSED" "$FAILED" "$FLAKY" "$SKIPPED")
else
status='⚠️ No results produced'
stats='The job did not produce `playwright-report/results.json`. The suite likely crashed before the JSON reporter wrote its output — see the workflow run for setup-step failures.'
fi
if [ "$S3_ENABLED" = "true" ]; then
report_line=$(printf '[HTML report](%s) · [Workflow run](%s)' "$REPORT_URL" "$RUN_URL")
else
report_line=$(printf '[Workflow run](%s) · _HTML report hosting is gated off until the `authentik-playwright-artifacts` S3 bucket is provisioned (`vars.PLAYWRIGHT_S3_ENABLED`). Until then, download the `playwright-report` artifact from the run page._' "$RUN_URL")
fi
body=$(printf '%s\n## Playwright e2e — %s\n\n%s\n\n%s\n' "$marker" "$status" "$stats" "$report_line")
existing=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" \
--paginate \
--jq "[.[] | select(.body != null and (.body | startswith(\"$marker\")))] | .[0].id // empty")
if [ -n "$existing" ]; then
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${existing}" -f body="$body" > /dev/null
echo "Updated existing comment ${existing}"
else
gh api -X POST "repos/${GITHUB_REPOSITORY}/issues/${PR_NUMBER}/comments" -f body="$body" > /dev/null
echo "Created new playwright-result comment"
fi
ci-web-mark:
if: always()
needs:
@@ -73,13 +302,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: ./.github/actions/setup-node
with:
node-version-file: web/package.json
cache: "npm"
cache-dependency-path: web/package-lock.json
- working-directory: web/
run: npm ci
working-directory: web
- name: test
working-directory: web/
run: npm run test || exit 0
run: corepack npm run test || exit 0

View File

@@ -3,7 +3,7 @@ name: Packages - Publish NPM packages
on:
push:
branches: [main]
branches: [ main ]
paths:
- packages/tsconfig/**
- packages/eslint-config/**
@@ -35,22 +35,19 @@ jobs:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5
with:
fetch-depth: 2
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v5
- uses: ./.github/actions/setup-node
with:
node-version-file: ${{ matrix.package }}/package.json
registry-url: "https://registry.npmjs.org"
working-directory: ${{ matrix.package }}
- 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: |
npm ci
npm run build
npm publish
corepack npm ci
corepack npm run build
corepack npm publish

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -11,7 +11,20 @@
"cross-env": "^10.1.0"
},
"engines": {
"node": ">=20",
"npm": ">=11.6.2"
}
"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"
}

View File

@@ -7,6 +7,17 @@ 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
@@ -18,7 +29,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 \
npm ci
corepack npm ci
COPY ./package.json /work
COPY ./web /work/web/

View File

@@ -10,12 +10,22 @@ 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 \
npm ci
corepack npm ci
COPY web .
RUN npm run build-proxy

278
scripts/node/lint-lockfile.mjs Executable file
View File

@@ -0,0 +1,278 @@
#!/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));

114
scripts/node/lint-runtime.mjs Executable file
View File

@@ -0,0 +1,114 @@
#!/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));

94
scripts/node/setup-corepack.mjs Executable file
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
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 });
}

175
scripts/node/utils/node.mjs Normal file
View File

@@ -0,0 +1,175 @@
/**
* 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

1
web/.gitignore vendored
View File

@@ -32,6 +32,7 @@ lib-cov
# Coverage directory used by tools like istanbul
coverage
playwright-report
playwright-traces
test-results
*.lcov

View File

@@ -2,12 +2,6 @@ import { PageFixture } from "#e2e/fixtures/PageFixture";
import { Page } from "@playwright/test";
export const GOOD_USERNAME = "test-admin@goauthentik.io";
export const GOOD_PASSWORD = "test-runner";
export const BAD_USERNAME = "bad-username@bad-login.io";
export const BAD_PASSWORD = "-this-is-a-bad-password-";
export interface LoginInit {
username?: string;
password?: string;

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

@@ -0,0 +1,26 @@
/**
* @file Node.js global types and environment variables.
*/
declare module "process" {
global {
namespace NodeJS {
interface ProcessEnv {
/**
* The email address of the bootstrap user created when running the tests.
*
* @format email
*/
readonly AUTHENTIK_BOOTSTRAP_EMAIL?: string;
/**
* The password of the bootstrap user created when running the tests.
*/
readonly AUTHENTIK_BOOTSTRAP_PASSWORD?: string;
/**
* The directory where the authentik blueprints are stored.
*/
readonly AUTHENTIK_BLUEPRINTS_DIR?: string;
}
}
}
}

1881
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -13,7 +13,6 @@
"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",
@@ -205,9 +204,6 @@
"@rollup/rollup-linux-x64-gnu": "^4.57.1",
"chromedriver": "^147.0.4"
},
"workspaces": [
"./packages/*"
],
"wireit": {
"build": {
"#comment": [
@@ -268,11 +264,6 @@
"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"
},
@@ -281,8 +272,7 @@
"dependencies": [
"lint",
"lint:types",
"lint:components",
"lint:lockfile"
"lint:components"
]
},
"storybook:build": {
@@ -303,20 +293,11 @@
},
"engines": {
"node": ">=24",
"npm": ">=11.6.2"
},
"devEngines": {
"runtime": {
"name": "node",
"onFail": "warn",
"version": ">=24"
},
"packageManager": {
"name": "npm",
"version": "11.10.1",
"onFail": "warn"
}
"npm": ">=11.10.1"
},
"workspaces": [
"./packages/*"
],
"prettier": "@goauthentik/prettier-config",
"overrides": {
"@goauthentik/esbuild-plugin-live-reload": {
@@ -352,8 +333,22 @@
"rapidoc": {
"@apitools/openapi-parser": "0.0.37"
},
"tree-sitter": false,
"typescript-eslint": {
"typescript": "$typescript"
}
},
"packageManager": "npm@11.11.0+sha512.f36811c4aae1fde639527368ae44c571d050006a608d67a191f195a801a52637a312d259186254aa3a3799b05335b7390539cf28656d18f0591a1125ba35f973",
"devEngines": {
"runtime": {
"name": "node",
"onFail": "warn",
"version": ">=24"
},
"packageManager": {
"name": "npm",
"version": ">=11.10.1",
"onFail": "warn"
}
}
}

View File

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

View File

@@ -23,10 +23,16 @@ export default defineConfig({
testDir: "./test/browser",
fullyParallel: true,
forbidOnly: CI,
retries: CI ? 2 : 0,
workers: CI ? 1 : undefined,
retries: CI ? 1 : 0,
workers: "50%",
maxFailures: CI ? 5 : 2,
reporter: CI
? "github"
? [
// ---
["github"],
["html", { open: "never", outputFolder: "playwright-report" }],
["json", { outputFile: "playwright-report/results.json" }],
]
: [
// ---
["list", { printSteps: true }],
@@ -36,6 +42,8 @@ export default defineConfig({
testIdAttribute: "data-test-id",
baseURL,
trace: "on-first-retry",
screenshot: "only-on-failure",
video: CI ? "retain-on-failure" : "off",
colorScheme: "dark",
launchOptions: {
logger: {
@@ -93,6 +101,7 @@ export default defineConfig({
dependencies: ["prerequisites"],
use: {
...devices["Desktop Chrome"],
headless: false,
},
},
],

View File

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

View File

@@ -18,7 +18,7 @@ import { RoleForm } from "#admin/roles/ak-role-form";
import { RbacApi, Role } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { msg, str } from "@lit/localize";
import { html, PropertyValues, TemplateResult } from "lit";
import { customElement, state } from "lit/decorators.js";
@@ -83,7 +83,11 @@ export class RoleListPage extends TablePage<Role> {
row(item: Role): SlottedTemplateResult[] {
return [
html`<a href="#/identity/roles/${item.pk}">${item.name}</a>`,
html`<a
href="#/identity/roles/${item.pk}"
aria-label=${msg(str`View details of role "${item.name}"`)}
>${item.name}</a
>`,
html`<div class="ak-c-table__actions">${IconEditButton(RoleForm, item.pk)}</div>`,
];
}

View File

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

View File

@@ -1,6 +1,19 @@
version: 1
metadata:
name: Playwright e2e test admin
entries:
- attrs:
- model: authentik_core.group
state: present
identifiers:
name: "authentik Admins"
attrs:
is_superuser: true
id: admin-group
- model: authentik_core.user
state: present
identifiers:
username: akadmin
attrs:
email: test-admin@goauthentik.io
is_active: true
name: authentik Default Admin
@@ -8,9 +21,4 @@ entries:
path: users
type: internal
groups:
- !Find [authentik_core.group, [name, "authentik Admins"]]
conditions: []
identifiers:
username: akadmin
model: authentik_core.user
state: present
- !KeyOf admin-group

View File

@@ -17,8 +17,6 @@ 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);

View File

@@ -161,8 +161,13 @@ test.describe("Roles", () => {
});
await test.step("Verify role name updated on view page", async () => {
// The role name shows up in both the page-navbar heading (as
// "Role <name>") and the description-list text (raw <name>); a
// bare getByText match hits both and triggers strict-mode. Pin
// to the description list so we're asserting the canonical
// value the edit just wrote, not the heading template.
await expect(
page.getByText(updatedName),
page.getByText(updatedName, { exact: true }),
"Updated role name is visible on view page",
).toBeVisible();
});

View File

@@ -17,7 +17,15 @@ test.describe("Provider Wizard", () => {
const dialog = page.getByRole("dialog", { name: "New Provider Wizard" });
await test.step("Authenticate", async () => session.login());
// session.login() with no `to` waits for the auth flow URL pattern to
// re-appear, which it does immediately (we just navigated there), so
// the helper returns *before* the post-login redirect lands. The
// wizard buttons probed below live on /if/admin/#/core/providers, so
// pin the destination explicitly — same shape as the other test files.
await test.step("Authenticate", async () =>
session.login({
to: "/if/admin/#/core/providers",
}));
await test.step("Navigate to provider wizard", async () => {
await expect(dialog, "Dialog is initially closed").toBeHidden();

View File

@@ -3,9 +3,14 @@ import { expect, test as setup } from "#e2e";
setup("Web server availability", async ({ baseURL }) => {
expect(baseURL, "Base URL is set").toBeTruthy();
const ok = await fetch(baseURL!)
// Probe the default authentication flow rather than the root URL — the root
// redirects to /setup when AUTHENTIK_BOOTSTRAP_* env vars are unset, and
// /setup raises FlowNonApplicableException (HTTP 500) once an admin user
// already has a usable password (as our test-admin blueprint installs).
const probeURL = new URL("/if/flow/default-authentication-flow/", baseURL!);
const ok = await fetch(probeURL)
.then((res) => res.ok)
.catch(() => false);
expect(ok, `Web server should be listening on ${baseURL}`).toBeTruthy();
expect(ok, `Web server should be serving ${probeURL}`).toBeTruthy();
});

View File

@@ -4,17 +4,23 @@ ENV NODE_ENV=production
WORKDIR /work
# TODO: Use setup-corepack.mjs
RUN --mount=type=bind,target=/work/package.json,src=./package.json \
--mount=type=bind,target=/work/package-lock.json,src=./package-lock.json \
npm install --force -g corepack@latest && \
corepack install -g npm@11.11.0+sha512.f36811c4aae1fde639527368ae44c571d050006a608d67a191f195a801a52637a312d259186254aa3a3799b05335b7390539cf28656d18f0591a1125ba35f973 && \
corepack enable
WORKDIR /work/website
--mount=type=bind,target=/work/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/work/packages/logger-js/,src=./packages/logger-js/ \
--mount=type=bind,target=/work/packages/tsconfig/,src=./packages/tsconfig/ \
--mount=type=bind,target=/work/packages/eslint-config/,src=./packages/eslint-config/ \
--mount=type=bind,target=/work/packages/prettier-config/,src=./packages/prettier-config/ \
--mount=type=bind,target=/work/website/package.json,src=./website/package.json \
--mount=type=bind,target=/work/website/package-lock.json,src=./website/package-lock.json \
node ./scripts/node/setup-corepack.mjs --force && \
corepack npm ci \
node ./scripts/node/lint-runtime.mjs ./website
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/scripts/node/,src=./scripts/node/ \
--mount=type=bind,target=/work/packages/logger-js/,src=./packages/logger-js/ \
--mount=type=bind,target=/work/packages/tsconfig/,src=./packages/tsconfig/ \
--mount=type=bind,target=/work/packages/eslint-config/,src=./packages/eslint-config/ \
--mount=type=bind,target=/work/packages/prettier-config/,src=./packages/prettier-config/ \
@@ -26,7 +32,9 @@ RUN --mount=type=bind,target=/work/package.json,src=./package.json \
--mount=type=bind,target=/work/website/integrations/package.json,src=./website/integrations/package.json \
--mount=type=bind,target=/work/website/docs/package.json,src=./website/docs/package.json \
--mount=type=cache,id=npm-website,sharing=shared,target=/root/.npm \
corepack npm ci --workspaces --include-workspace-root
corepack npm ci --workspaces --include-workspace-root --prefix ./website
WORKDIR /work/website
COPY ./website /work/website/
COPY ./blueprints /work/blueprints/
@@ -34,7 +42,7 @@ COPY ./schema.yml /work/
COPY ./lifecycle/container/compose.yml /work/lifecycle/container/
COPY ./SECURITY.md /work/
RUN corepack npm run build
RUN corepack npm run build -w docs
FROM docker.io/library/nginx:1.29-trixie@sha256:6e23479198b998e5e25921dff8455837c7636a67111a04a635cf1bb363d199dc
LABEL org.opencontainers.image.authors="Authentik Security Inc." \

View File

@@ -208,6 +208,13 @@ Copy the generated recovery key and paste it into the URL, after the domain. For
## End-to-End (E2E) Setup
authentik ships two end-to-end test suites:
- The Django/Selenium suite under `tests/e2e/` — driven by Django's test runner. It is exercised in CI by the `test-e2e` job in `ci-main.yml`.
- The browser-side Playwright suite under `web/test/browser/` — driven by `web/playwright.config.js`. It is exercised in CI by the `e2e (playwright)` job in `ci-web.yml` and runs against a live authentik server.
### Django/Selenium suite
Start the E2E test services with the following command:
```shell
@@ -222,6 +229,40 @@ Alternatively, you can connect directly via VNC on port `5900` using the passwor
When using Docker Desktop, host networking needs to be enabled via **Docker Settings** > **Resources** > **Network** > **Enable host networking**.
:::
### Playwright suite
The Playwright suite assumes that:
- An authentik stack is reachable at `http://localhost:9000` (override with `AK_TEST_RUNNER_PAGE_URL`).
- The blueprint at `web/test/blueprints/test-admin-user.yaml` has been applied so that the `test-admin@goauthentik.io` user (password `test-runner`) can log in.
Both prerequisites are satisfied by the standard full development environment described above, plus a one-off blueprint apply. From the repository root:
```shell
# Make the test admin blueprint discoverable by the worker, then start authentik.
mkdir -p blueprints/local
cp web/test/blueprints/test-admin-user.yaml blueprints/local/test-admin-user.yaml
# In separate terminals:
make run-server
make run-worker
```
Once the worker has applied the blueprint, install Playwright's browsers (one-time) and run the suite — these are the same npm scripts CI runs:
```shell
corepack npm exec --prefix web -- playwright install --with-deps chromium
corepack npm run --prefix web test:e2e
```
After a failing run, open the HTML report to inspect traces, screenshots, and (in CI) videos:
```shell
corepack npm exec --prefix web -- playwright show-report
```
In CI, the same artifacts are uploaded under the `playwright-report` and `playwright-traces` artifact names on the failed workflow run.
## Contributing code
### Before submitting a pull request

View File

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

View File

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

View File

@@ -7,7 +7,6 @@
"": {
"name": "@goauthentik/docs",
"version": "0.0.0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
"vendored/*",
@@ -203,7 +202,7 @@
},
"engines": {
"node": ">=24",
"npm": ">=11.6.2"
"npm": ">=11.10.1"
}
},
"docusaurus-theme": {
@@ -247,7 +246,7 @@
},
"engines": {
"node": ">=24",
"npm": ">=11.6.2"
"npm": ">=11.10.1"
}
},
"node_modules/@algolia/abtesting": {

View File

@@ -9,9 +9,7 @@
"build:integrations": "npm run build -w integrations",
"check-types": "tsc -b",
"docusaurus": "docusaurus",
"preinstall": "npm ci --prefix ..",
"lint": "eslint --fix .",
"lint:lockfile": "echo 'Skipping lockfile linting'",
"lint-check": "eslint --max-warnings 0 .",
"prettier": "prettier --write .",
"prettier-check": "prettier --check .",