diff --git a/.github/actions/apt/action.yml b/.github/actions/apt/action.yml index bb8c485a..3e29c8f1 100644 --- a/.github/actions/apt/action.yml +++ b/.github/actions/apt/action.yml @@ -191,6 +191,19 @@ runs: Content-Type: application/vnd.debian.binary-package Cache-Control: public, max-age=86400 + # Nix binary cache headers + /nix/nix-cache-info + Content-Type: text/plain + Cache-Control: public, max-age=300 + + /nix/*.narinfo + Content-Type: text/x-nix-narinfo + Cache-Control: public, max-age=3600 + + /nix/nar/* + Content-Type: application/x-nix-archive + Cache-Control: public, max-age=604800 + # Security headers /* X-Frame-Options: DENY diff --git a/.github/actions/nix/action.yml b/.github/actions/nix/action.yml new file mode 100644 index 00000000..8bce04fc --- /dev/null +++ b/.github/actions/nix/action.yml @@ -0,0 +1,161 @@ +name: 'Build Nix Packages' +description: 'Builds Nix packages and creates a binary cache' + +inputs: + signing-key: + description: 'Nix signing private key (plain text)' + required: false + output-path: + description: 'Output path for binary cache' + required: false + default: './nix-cache' + packages: + description: 'Space-separated list of cross-platform packages to build' + required: false + default: 'ak-cli ak-agent ak-browser-support' + linux-only-packages: + description: 'Space-separated list of Linux-only packages to build' + required: false + default: 'ak-sysd libpam-authentik libnss-authentik' + +outputs: + cache-path: + description: 'Path to generated binary cache' + value: ${{ steps.build.outputs.cache-path }} + cache-size: + description: 'Size of the generated cache' + value: ${{ steps.stats.outputs.size }} + +runs: + using: 'composite' + steps: + - name: Setup output directory + id: setup + shell: bash + run: | + set -xeuo pipefail + mkdir -p "${{ inputs.output-path }}/nar" + CACHE_PATH=$(realpath "${{ inputs.output-path }}") + echo "cache-path=$CACHE_PATH" >> $GITHUB_OUTPUT + echo "CACHE_PATH=$CACHE_PATH" >> $GITHUB_ENV + + - name: Setup signing key + if: ${{ inputs.signing-key != '' }} + shell: bash + run: | + set -xeuo pipefail + SIGNING_KEY_FILE="${{ runner.temp }}/nix-signing-key.pem" + echo "${{ inputs.signing-key }}" > "$SIGNING_KEY_FILE" + chmod 600 "$SIGNING_KEY_FILE" + echo "SIGNING_KEY_FILE=$SIGNING_KEY_FILE" >> $GITHUB_ENV + + - name: Detect platform + id: platform + shell: bash + run: | + set -xeuo pipefail + case "$(uname -s)" in + Linux) PLATFORM="linux" ;; + Darwin) PLATFORM="darwin" ;; + *) PLATFORM="unknown" ;; + esac + echo "platform=$PLATFORM" >> $GITHUB_OUTPUT + + - name: Build packages + id: build + shell: bash + run: | + set -xeuo pipefail + + PACKAGES="${{ inputs.packages }}" + LINUX_ONLY_PACKAGES="${{ inputs.linux-only-packages }}" + + # Build cross-platform packages + for pkg in $PACKAGES; do + echo "Building $pkg..." + nix build ".#$pkg" --no-link --print-out-paths + done + + # Build Linux-only packages + if [ "${{ steps.platform.outputs.platform }}" = "linux" ]; then + for pkg in $LINUX_ONLY_PACKAGES; do + echo "Building $pkg..." + nix build ".#$pkg" --no-link --print-out-paths + done + fi + + echo "cache-path=$CACHE_PATH" >> $GITHUB_OUTPUT + + - name: Copy to binary cache + shell: bash + run: | + set -xeuo pipefail + + PACKAGES="${{ inputs.packages }}" + LINUX_ONLY_PACKAGES="${{ inputs.linux-only-packages }}" + + # Determine signing args + SIGN_ARGS="" + if [ -n "${SIGNING_KEY_FILE:-}" ]; then + SIGN_ARGS="--secret-key-files $SIGNING_KEY_FILE" + fi + + # Copy cross-platform packages + for pkg in $PACKAGES; do + echo "Copying $pkg to cache..." + nix copy --to "file://$CACHE_PATH" ".#$pkg" $SIGN_ARGS + done + + # Copy Linux-only packages + if [ "${{ steps.platform.outputs.platform }}" = "linux" ]; then + for pkg in $LINUX_ONLY_PACKAGES; do + echo "Copying $pkg to cache..." + nix copy --to "file://$CACHE_PATH" ".#$pkg" $SIGN_ARGS + done + fi + + - name: Create cache metadata + shell: bash + run: | + set -xeuo pipefail + cat > "$CACHE_PATH/nix-cache-info" << EOF + StoreDir: /nix/store + WantMassQuery: 1 + Priority: 40 + EOF + + - name: Generate cache statistics + id: stats + shell: bash + run: | + set -xeuo pipefail + cd "$CACHE_PATH" + + size=$(du -sh . | cut -f1) + echo "Cache size: $size" + echo "size=$size" >> $GITHUB_OUTPUT + + # Count files + narinfo_count=$(find . -name "*.narinfo" 2>/dev/null | wc -l | tr -d ' ') + nar_count=$(find ./nar -name "*.nar*" 2>/dev/null | wc -l | tr -d ' ') + echo "NAR info files: $narinfo_count" + echo "NAR archives: $nar_count" + echo "narinfo-count=$narinfo_count" >> $GITHUB_OUTPUT + echo "nar-count=$nar_count" >> $GITHUB_OUTPUT + + echo "Cache structure:" + find . -type f | head -20 + + - name: Validate cache structure + shell: bash + run: | + set -xeuo pipefail + cd "$CACHE_PATH" + + # Check required files exist + if [ ! -f "nix-cache-info" ]; then + echo "Error: nix-cache-info missing" + exit 1 + fi + + echo "Cache validation passed" diff --git a/.github/actions/repo/index.html b/.github/actions/repo/index.html index 56091f27..8187cdb3 100644 --- a/.github/actions/repo/index.html +++ b/.github/actions/repo/index.html @@ -57,6 +57,67 @@

Step 2: Install

+

Nix/NixOS Setup

+
+

Option 1: Direct Installation

+
# Install with binary cache
+nix profile install github:goauthentik/platform#ak-cli \
+  --extra-substituters "${REPOSITORY_URL}nix" \
+  --extra-trusted-public-keys "authentik-pkg:ZZHUD/9SkS8T1BVVoksE/+QjIo0s3F8/AM/h0J3ckaw="
+
+# Available packages: ak-cli, ak-sysd, ak-agent, ak-browser-support
+# Linux only: libpam-authentik, libnss-authentik
+
+
+

Option 2: Permanent Cache Configuration

+

Add to /etc/nix/nix.conf or ~/.config/nix/nix.conf:

+
extra-substituters = ${REPOSITORY_URL}nix
+extra-trusted-public-keys = authentik-pkg:ZZHUD/9SkS8T1BVVoksE/+QjIo0s3F8/AM/h0J3ckaw=
+

Then install normally:

+
nix profile install github:goauthentik/platform#ak-cli
+
+
+

Option 3: NixOS Configuration

+
# In your flake.nix inputs:
+authentik.url = "github:goauthentik/platform";
+
+# In your NixOS configuration:
+{ authentik, ... }: {
+  imports = [ authentik.nixosModules.default ];
+  services.authentik = {
+    enable = true;
+    enablePAM = true;
+    enableNSS = true;
+  };
+}
+
+
+

Option 4: nix-darwin Configuration (macOS)

+
# In your flake.nix inputs:
+authentik.url = "github:goauthentik/platform";
+
+# In your darwin configuration:
+{ authentik, ... }: {
+  imports = [ authentik.darwinModules.default ];
+  nixpkgs.overlays = [ authentik.overlays.default ];
+  services.authentik.enable = true;
+}
+

This installs the .app bundle to /Applications/ and configures launchd to run ak-sysd.

+
+
+

Available Nix Packages

+ + + + + + + + +
PackageDescriptionPlatforms
ak-cliCommand-line interfaceLinux, macOS
ak-sysdSystem daemonLinux, macOS
ak-agentLocal agent (includes all binaries on macOS)Linux, macOS
ak-browser-supportBrowser extension supportLinux, macOS
libpam-authentikPAM authentication moduleLinux
libnss-authentikNSS name resolution moduleLinux
+
+
+

Repository Information

Built from: ${REF} (${SHA})

GPG Key fingerprint: 82EE AAD5 531A 856A 9C72 6132 2217 2AF2 AAE3 A237

diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e88c32b3..ad554182 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -132,11 +132,41 @@ jobs: with: name: authentik_${{ matrix.platform }}_${{ matrix.target }} path: bin/ + build_nix: + name: Build (Nix) + strategy: + fail-fast: false + matrix: + platform: + - ubuntu-24.04 + - ubuntu-24.04-arm + - macos-15 + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v5 + - uses: DeterminateSystems/nix-installer-action@v21 + - uses: DeterminateSystems/magic-nix-cache-action@v13 + - uses: ./.github/actions/nix + id: nix-build + with: + signing-key: ${{ secrets.NIX_SIGNING_KEY }} + output-path: ./nix-cache + - name: Show build info + shell: bash + run: | + echo "Cache size: $NIX_CACHE_SIZE" + env: + NIX_CACHE_SIZE: ${{ steps.nix-build.outputs.cache-size }} + - uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v5 + with: + name: nix-cache-${{ matrix.platform }} + path: ./nix-cache/ deploy: needs: - build_go - build_js - build_rs + - build_nix runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v5 @@ -150,6 +180,37 @@ jobs: with: package-path: bin/ gpg-private-key: "${{ secrets.GPG_PRIVATE }}" + - name: Merge Nix caches + shell: bash + run: | + set -xeuo pipefail + mkdir -p ./repo/nix/nar + + # Merge all platform caches + for cache_dir in ./bin/nix-cache-*/; do + if [ -d "$cache_dir" ]; then + echo "Merging cache from: $cache_dir" + # Copy narinfo files (don't overwrite existing) + find "$cache_dir" -maxdepth 1 -name "*.narinfo" -exec cp -n {} ./repo/nix/ \; 2>/dev/null || true + # Copy nar archives + if [ -d "$cache_dir/nar" ]; then + find "$cache_dir/nar" -name "*.nar*" -exec cp -n {} ./repo/nix/nar/ \; 2>/dev/null || true + fi + fi + done + + # Create unified nix-cache-info + cat > ./repo/nix/nix-cache-info << EOF + StoreDir: /nix/store + WantMassQuery: 1 + Priority: 40 + EOF + + # Show cache stats + echo "Merged Nix cache:" + du -sh ./repo/nix + echo "NAR info files: $(find ./repo/nix -maxdepth 1 -name '*.narinfo' | wc -l)" + echo "NAR archives: $(find ./repo/nix/nar -name '*.nar*' 2>/dev/null | wc -l)" - name: Upload repository uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v5 with: @@ -171,12 +232,16 @@ jobs: steps: - name: Determine component name id: component + shell: bash run: | - if [ "${{ github.event_name }}" = "pull_request" ]; then - echo "component=pr-${{ github.event.number }}" >> $GITHUB_OUTPUT + if [ "$EVENT_NAME" = "pull_request" ]; then + echo "component=pr-$EVENT_NUMBER" >> $GITHUB_OUTPUT else echo "component=main" >> $GITHUB_OUTPUT fi + env: + EVENT_NAME: ${{ github.event_name }} + EVENT_NUMBER: ${{ github.event.number }} - name: Download repository uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v6 with: @@ -187,6 +252,11 @@ jobs: with: name: all-packages path: ./deploy/packages + - name: Show deployment structure + shell: bash + run: | + echo "APT repo size: $(du -sh ./deploy/dists 2>/dev/null | cut -f1 || echo 'N/A')" + echo "Nix cache size: $(du -sh ./deploy/nix 2>/dev/null | cut -f1 || echo 'N/A')" - id: app-token uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 with: diff --git a/.gitignore b/.gitignore index 00108531..266343ea 100644 --- a/.gitignore +++ b/.gitignore @@ -106,3 +106,6 @@ target/ # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. #.idea/ + +# Nix symlink +result diff --git a/Makefile b/Makefile index f67517d5..32e30552 100644 --- a/Makefile +++ b/Makefile @@ -115,8 +115,12 @@ test-full: clean agent/test-deploy sysd/test-deploy cli/test-deploy nss/test-dep dev--initialize: containers/test/local-build bump: - sed -i 's/VERSION = .*/VERSION = ${version}/g' common.mk - sed -i 's/^version = ".*"/version = "${version}"/g' ${TOP}/Cargo.toml + sed -i '' 's/VERSION = .*/VERSION = ${version}/g' common.mk 2>/dev/null || \ + sed -i 's/VERSION = .*/VERSION = ${version}/g' common.mk + sed -i '' 's/^version = ".*"/version = "${version}"/g' ${TOP}/Cargo.toml 2>/dev/null || \ + sed -i 's/^version = ".*"/version = "${version}"/g' ${TOP}/Cargo.toml + sed -i '' 's/version = "[0-9]*\.[0-9]*\.[0-9]*";/version = "${version}";/' ${TOP}/flake.nix 2>/dev/null || \ + sed -i 's/version = "[0-9]*\.[0-9]*\.[0-9]*";/version = "${version}";/' ${TOP}/flake.nix "$(MAKE)" browser-ext/bump "$(MAKE)" agent/bump "$(MAKE)" nss/bump @@ -159,3 +163,65 @@ containers/test/%: containers/e2e/%: "$(MAKE)" -C "${TOP}/containers/e2e" $* + +# Nix targets +.PHONY: nix-build +nix-build: + nix build .#ak-cli + nix build .#ak-agent + nix build .#ak-browser-support +ifeq ($(shell uname -s),Linux) + nix build .#ak-sysd + nix build .#libpam-authentik + nix build .#libnss-authentik +endif + +.PHONY: nix-build-go +nix-build-go: + nix build .#ak-cli + nix build .#ak-agent + nix build .#ak-browser-support +ifeq ($(shell uname -s),Linux) + nix build .#ak-sysd +endif + +.PHONY: nix-develop +nix-develop: + nix develop + +.PHONY: nix-check +nix-check: + nix flake check + +.PHONY: nix-update +nix-update: + nix flake update + +.PHONY: nix-update-vendor-hash +nix-update-vendor-hash: + @echo "Updating Go vendor hash in flake.nix..." + sed -i '' 's/vendorHash = "sha256-.*";/vendorHash = "";/' ${TOP}/flake.nix 2>/dev/null || \ + sed -i 's/vendorHash = "sha256-.*";/vendorHash = "";/' ${TOP}/flake.nix + @NEW_HASH=$$(nix build .#ak-cli 2>&1 | grep -o 'got:[[:space:]]*sha256-[A-Za-z0-9+/=]*' | sed 's/got:[[:space:]]*//' | head -1) && \ + if [ -n "$$NEW_HASH" ]; then \ + sed -i '' "s/vendorHash = \"\";/vendorHash = \"$$NEW_HASH\";/" ${TOP}/flake.nix 2>/dev/null || \ + sed -i "s/vendorHash = \"\";/vendorHash = \"$$NEW_HASH\";/" ${TOP}/flake.nix; \ + echo "Updated vendorHash to: $$NEW_HASH"; \ + else \ + echo "Failed to extract hash. Check nix build output manually."; \ + exit 1; \ + fi + +.PHONY: nix-cache +nix-cache: + mkdir -p ${TOP}/bin/nix-cache/nar + nix copy --to "file://${TOP}/bin/nix-cache" .#ak-cli + nix copy --to "file://${TOP}/bin/nix-cache" .#ak-agent + nix copy --to "file://${TOP}/bin/nix-cache" .#ak-browser-support +ifeq ($(shell uname -s),Linux) + nix copy --to "file://${TOP}/bin/nix-cache" .#ak-sysd + nix copy --to "file://${TOP}/bin/nix-cache" .#libpam-authentik + nix copy --to "file://${TOP}/bin/nix-cache" .#libnss-authentik +endif + echo "StoreDir: /nix/store" > ${TOP}/bin/nix-cache/nix-cache-info + echo "WantMassQuery: 1" >> ${TOP}/bin/nix-cache/nix-cache-info diff --git a/browser-ext/src/background/background.ts b/browser-ext/src/background/background.ts index 5ec28df0..2c3822f9 100644 --- a/browser-ext/src/background/background.ts +++ b/browser-ext/src/background/background.ts @@ -3,25 +3,45 @@ import { sentry } from "../utils/sentry"; sentry("background"); -chrome.runtime.onInstalled.addListener(() => { - console.debug("authentik Extension Installed"); -}); +const browserApi = (globalThis as typeof globalThis & { browser?: typeof chrome }).browser; +const runtimeApi = browserApi?.runtime ?? chrome.runtime; + +function stringifyError(exc: unknown): string { + if (exc instanceof Error) { + return exc.message; + } + return String(exc); +} const native = new Native(); -chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => { +async function handleMessage(msg: { action?: string; profile?: string; challenge?: string }) { switch (msg.action) { case "platform_sign_endpoint_header": - native - .platformSignEndpointHeader(msg.profile, msg.challenge) - .then((r) => { - sendResponse(r); - }) - .catch((exc) => { - console.warn("Failed to send request for platform sign", exc); - sendResponse(null); - }); - break; + try { + return await native.platformSignEndpointHeader(msg.profile ?? "default", msg.challenge ?? ""); + } catch (exc) { + console.warn("Failed to send request for platform sign", exc); + return { + error: stringifyError(exc), + }; + } + default: + return false; + } +} + +runtimeApi.onMessage.addListener( + ( + msg: { action?: string; profile?: string; challenge?: string }, + _sender: unknown, + sendResponse: (response: unknown) => void, + ) => { + const response = handleMessage(msg); + if (browserApi?.runtime) { + return response; } + response.then(sendResponse); return true; -}); + }, +); diff --git a/browser-ext/src/content/content.ts b/browser-ext/src/content/content.ts index 28d3d65a..1e0df467 100644 --- a/browser-ext/src/content/content.ts +++ b/browser-ext/src/content/content.ts @@ -1,3 +1,57 @@ +function stringifyError(value: unknown): string | null { + if (value && typeof value === "object" && "error" in value) { + const err = (value as { error?: unknown }).error; + if (typeof err === "string") { + return err; + } + } + return null; +} + +const browserApi = (globalThis as typeof globalThis & { browser?: typeof chrome }).browser; +const runtimeApi = browserApi?.runtime ?? chrome.runtime; + +function sendRuntimeMessage(message: { + action: string; + profile: string; + challenge: string; +}): Promise { + return new Promise((resolve, reject) => { + let settled = false; + const finish = (fn: (value: unknown) => void, value: unknown) => { + if (settled) { + return; + } + settled = true; + fn(value); + }; + try { + const maybePromise = runtimeApi.sendMessage(message, (response: unknown) => { + const lastError = + typeof chrome !== "undefined" ? chrome.runtime?.lastError : undefined; + if (lastError) { + finish(reject, new Error(lastError.message)); + return; + } + finish(resolve, response); + }) as unknown; + if ( + maybePromise && + typeof maybePromise === "object" && + "then" in maybePromise && + typeof maybePromise.then === "function" + ) { + maybePromise.then( + (response: unknown) => finish(resolve, response), + (error: unknown) => finish(reject, error), + ); + } + } catch (exc) { + finish(reject, exc); + } + }); +} + window.addEventListener( "message", (event) => { @@ -11,19 +65,31 @@ window.addEventListener( if (event.source !== window) { return; } - chrome.runtime - .sendMessage({ + sendRuntimeMessage({ action: "platform_sign_endpoint_header", profile: "default", challenge: event.data.challenge, }) .then((signed) => { + const error = stringifyError(signed); + if (error) { + console.warn( + "authentik/bext: failed to sign endpoint challenge", + error, + ); + return; + } if (signed) { window.postMessage({ _ak_ext: "authentik-platform-sso", response: signed, - }); + }, window.location.origin); + } else { + console.warn("authentik/bext: background returned empty response"); } + }) + .catch((exc) => { + console.warn("authentik/bext: background request failed", exc); }); } catch (exc) { console.warn(`authentik/bext: ${exc}`); diff --git a/browser-ext/src/utils/native.ts b/browser-ext/src/utils/native.ts index d5a8ee4d..36ec799d 100644 --- a/browser-ext/src/utils/native.ts +++ b/browser-ext/src/utils/native.ts @@ -11,8 +11,12 @@ export interface Message { export interface Response { response_to: string; data: { [key: string]: unknown }; + error?: string; } +const browserApi = (globalThis as typeof globalThis & { browser?: typeof chrome }).browser; +const runtimeApi = browserApi?.runtime ?? chrome.runtime; + function createRandomString(length: number = 16) { const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; let result = ""; @@ -25,60 +29,125 @@ function createRandomString(length: number = 16) { } const defaultReconnectDelay = 5; +const requestTimeoutMs = 2500; + +type PendingRequest = PromiseWithResolvers & { + timeout?: ReturnType; +}; export class Native { #port?: chrome.runtime.Port; - #promises: Map> = new Map(); + #promises: Map = new Map(); #reconnectDelay = defaultReconnectDelay; #reconnectTimeout = 0; + #isConnected = false; constructor() { this.#connect(); } #connect() { - this.#port = chrome.runtime.connectNative("io.goauthentik.platform"); - this.#port.onMessage.addListener(this.#listener.bind(this)); - this.#port.onDisconnect.addListener(() => { + const port = runtimeApi.connectNative("io.goauthentik.platform"); + this.#port = port; + this.#isConnected = true; + this.#reconnectDelay = defaultReconnectDelay; + port.onMessage.addListener(this.#listener.bind(this)); + port.onDisconnect.addListener(() => { + this.#isConnected = false; this.#reconnectDelay *= 1.35; this.#reconnectDelay = Math.min(this.#reconnectDelay, 3600); - // @ts-ignore - const err = chrome.runtime.lastError || this.#port?.error; - console.debug( - `authentik/bext/native: Disconnected, reconnecting in ${this.#reconnectDelay}`, - err, + const err = + (typeof chrome !== "undefined" ? chrome.runtime?.lastError : undefined) || + (port as chrome.runtime.Port & { error?: unknown }).error; + this.#rejectPending( + new Error( + `native host disconnected${err ? `: ${String(err)}` : ""}`, + ), ); + this.#port = undefined; clearTimeout(this.#reconnectTimeout); this.#reconnectTimeout = setTimeout(() => { this.#connect(); }, this.#reconnectDelay * 1000); }); - console.debug("authentik/bext/native: Connected to native"); } #listener(msg: Response) { const prom = this.#promises.get(msg.response_to); - console.debug(`authentik/bext/native[${msg.response_to}]: Got response`); if (!prom) { - console.debug(`authentik/bext/native[${msg.response_to}]: No promise to resolve`); return; } + if (msg.error) { + if (prom.timeout) { + clearTimeout(prom.timeout); + } + prom.reject(new Error(msg.error)); + this.#promises.delete(msg.response_to); + return; + } + if (prom.timeout) { + clearTimeout(prom.timeout); + } prom.resolve(msg); + this.#promises.delete(msg.response_to); + } + + #postMessage(msg: Message, retry: boolean) { + if (!this.#port || !this.#isConnected) { + this.#connect(); + } + if (!this.#port) { + throw new Error("native host is not connected"); + } + try { + this.#port.postMessage(msg); + } catch (exc) { + const err = exc instanceof Error ? exc.message : String(exc); + if (retry && err.includes("disconnected port")) { + this.#isConnected = false; + this.#port = undefined; + this.#connect(); + this.#postMessage(msg, false); + return; + } + throw exc; + } } postMessage(msg: Partial): Promise { msg.id = createRandomString(); const promise = Promise.withResolvers(); try { - this.#promises.set(msg.id, promise); - this.#port?.postMessage(msg); - console.debug(`authentik/bext/native[${msg.id}]: Sending message ${msg.path}`); + const pending = promise as PendingRequest; + pending.timeout = setTimeout(() => { + this.#promises.delete(msg.id as string); + pending.reject( + new Error(`native host timed out after ${requestTimeoutMs}ms`), + ); + }, requestTimeoutMs); + this.#promises.set(msg.id, pending); + this.#postMessage(msg as Message, true); } catch (exc) { - this.#promises.get(msg.id)?.reject(exc); + const pending = this.#promises.get(msg.id); + if (pending?.timeout) { + clearTimeout(pending.timeout); + } + pending?.reject(exc); + this.#promises.delete(msg.id); } return promise.promise; } + #rejectPending(error: Error) { + for (const [id, pending] of this.#promises) { + if (pending.timeout) { + clearTimeout(pending.timeout); + } + pending.reject(error); + this.#promises.delete(id); + } + } + async ping(): Promise { return this.postMessage({ version: "1", diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..56c7a564 --- /dev/null +++ b/flake.lock @@ -0,0 +1,82 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1772736753, + "narHash": "sha256-au/m3+EuBLoSzWUCb64a/MZq6QUtOV8oC0D9tY2scPQ=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "917fec990948658ef1ccd07cef2a1ef060786846", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1772852295, + "narHash": "sha256-3FB/WzLZSiU2Mc50C9q9VXU1LRUZbsU6UHKmZG1C+hU=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "c10801f59c68e14c308aea8fa6b0b3d81d43c61e", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..f3664c6b --- /dev/null +++ b/flake.nix @@ -0,0 +1,139 @@ +{ + description = "authentik platform"; + + nixConfig = { + extra-substituters = [ "https://pr-525--authentik-pkg.netlify.app/nix" ]; + extra-trusted-public-keys = [ + "authentik-pkg:ZZHUD/9SkS8T1BVVoksE/+QjIo0s3F8/AM/h0J3ckaw=" + ]; + }; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-utils.url = "github:numtide/flake-utils"; + rust-overlay = { + url = "github:oxalica/rust-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = { self, nixpkgs, flake-utils, rust-overlay }: + let + systems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + cargoVersion = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).package.version; + version = "0.40.5"; + vendorHash = "sha256-RosEV9rlG46zvHQVmC64haOcZZo5b/LWu0EgN0IEtdA="; + mkOverlay = final: prev: + let + system = prev.stdenv.hostPlatform.system; + in + { + authentik-cli = self.packages.${system}.ak-cli; + authentik-sysd = self.packages.${system}.ak-sysd; + authentik-agent = self.packages.${system}.ak-agent; + authentik-browser-support = self.packages.${system}.ak-browser-support; + } // prev.lib.optionalAttrs prev.stdenv.isLinux { + authentik-pam = self.packages.${system}.libpam-authentik; + authentik-nss = self.packages.${system}.libnss-authentik; + }; + in + assert version == cargoVersion; + flake-utils.lib.eachSystem systems + (system: + let + pkgs = import nixpkgs { + inherit system; + overlays = [ rust-overlay.overlays.default ]; + }; + lib = pkgs.lib; + rustToolchain = pkgs.rust-bin.stable.latest.default.override { + extensions = [ "rust-src" ]; + }; + buildHash = if self ? rev then self.rev else "dirty"; + commonGoArgs = { + buildGoModule = pkgs.buildGo126Module; + inherit version vendorHash; + goBuildInputs = with pkgs; [ + pkg-config + ] ++ lib.optionals pkgs.stdenv.isDarwin [ + libiconv + ]; + goLdflags = [ + "-s" + "-w" + "-X goauthentik.io/platform/pkg/meta.Version=${version}" + "-X goauthentik.io/platform/pkg/meta.BuildHash=${buildHash}" + ]; + }; + mkGoPackage = path: pkgs.callPackage path commonGoArgs; + basePackages = rec { + ak-cli = mkGoPackage ./nix/packages/ak-cli.nix; + ak-sysd = mkGoPackage ./nix/packages/ak-sysd.nix; + ak-agent = mkGoPackage ./nix/packages/ak-agent.nix; + ak-browser-support = mkGoPackage ./nix/packages/ak-browser-support.nix; + default = ak-agent; + }; + linuxPackages = lib.optionalAttrs pkgs.stdenv.isLinux { + libpam-authentik = pkgs.callPackage ./nix/packages/libpam-authentik.nix { + inherit version; + }; + libnss-authentik = pkgs.callPackage ./nix/packages/libnss-authentik.nix { + inherit version; + }; + }; + packages = basePackages // linuxPackages; + in + { + inherit packages; + + apps.default = { + type = "app"; + program = "${packages.default}/bin/ak-agent"; + }; + + checks = packages; + + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ + go_1_26 + gopls + gotools + go-tools + rustToolchain + rust-analyzer + protobuf + protoc-gen-go + protoc-gen-go-grpc + pkg-config + openssl + nodejs + gnumake + ] ++ lib.optionals pkgs.stdenv.isLinux [ + pam + ] ++ lib.optionals pkgs.stdenv.isDarwin [ + libiconv + ]; + + shellHook = '' + echo "authentik platform development shell" + echo "Go: $(go version)" + echo "Rust: $(rustc --version)" + ''; + }; + + formatter = pkgs.nixpkgs-fmt; + }) // { + nixosModules.default = import ./nix/modules/authentik.nix; + nixosModules.authentik = self.nixosModules.default; + + darwinModules.default = import ./nix/modules/darwin.nix; + darwinModules.authentik = self.darwinModules.default; + + overlays.default = mkOverlay; + }; +} diff --git a/nix/modules/authentik.nix b/nix/modules/authentik.nix new file mode 100644 index 00000000..8fb94745 --- /dev/null +++ b/nix/modules/authentik.nix @@ -0,0 +1,155 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.authentik; +in { + options.services.authentik = { + enable = mkEnableOption "authentik platform agent"; + + package = mkOption { + type = types.package; + default = pkgs.authentik-sysd or (throw "authentik-sysd package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-sysd"; + description = "The authentik sysd package to use."; + }; + + cliPackage = mkOption { + type = types.package; + default = pkgs.authentik-cli or (throw "authentik-cli package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-cli"; + description = "The authentik CLI package to use."; + }; + + agentPackage = mkOption { + type = types.package; + default = pkgs.authentik-agent or (throw "authentik-agent package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-agent"; + description = "The authentik agent package to use."; + }; + + domain = mkOption { + type = types.nullOr types.str; + default = null; + description = "The authentik server domain to connect to."; + }; + + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to the authentik configuration file."; + }; + + enablePAM = mkOption { + type = types.bool; + default = false; + description = "Enable PAM authentication via authentik."; + }; + + pamPackage = mkOption { + type = types.package; + default = pkgs.authentik-pam or (throw "authentik-pam package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-pam"; + description = "The authentik PAM module package to use."; + }; + + pamServices = mkOption { + type = types.listOf types.str; + default = [ "login" "sshd" "sudo" ]; + description = "List of PAM services to enable authentik authentication for."; + }; + + enableNSS = mkOption { + type = types.bool; + default = false; + description = "Enable NSS name resolution via authentik."; + }; + + nssPackage = mkOption { + type = types.package; + default = pkgs.authentik-nss or (throw "authentik-nss package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-nss"; + description = "The authentik NSS module package to use."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra command-line arguments to pass to ak-sysd."; + }; + }; + + config = mkIf cfg.enable { + # Install CLI and agent packages + environment.systemPackages = [ + cfg.cliPackage + cfg.agentPackage + ]; + + # Create configuration directory + environment.etc."authentik/.keep".text = ""; + + # Systemd service for ak-sysd + systemd.services.ak-sysd = { + description = "authentik System Agent"; + after = [ "network.target" ]; + wantedBy = [ "multi-user.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${cfg.package}/bin/ak-sysd agent ${concatStringsSep " " cfg.extraArgs}"; + Restart = "always"; + RestartSec = 5; + RuntimeDirectory = "authentik"; + RuntimeDirectoryMode = "0777"; + StateDirectory = "authentik"; + ConfigurationDirectory = "authentik"; + }; + + environment = mkIf (cfg.domain != null) { + AUTHENTIK_DOMAIN = cfg.domain; + }; + }; + + # User systemd service for ak-agent (optional, user must enable) + systemd.user.services.ak-agent = { + description = "authentik Local Agent"; + after = [ "graphical-session.target" ]; + wantedBy = [ "default.target" ]; + + serviceConfig = { + Type = "simple"; + ExecStart = "${cfg.agentPackage}/bin/ak-agent"; + Restart = "always"; + RestartSec = 5; + }; + }; + + # PAM configuration + security.pam.services = mkIf cfg.enablePAM ( + genAttrs cfg.pamServices (service: { + text = mkAfter '' + # authentik PAM authentication + auth sufficient ${cfg.pamPackage}/lib/security/pam_authentik.so + session required ${cfg.pamPackage}/lib/security/pam_authentik.so + ''; + }) + ); + + # NSS configuration + system.nssModules = mkIf cfg.enableNSS [ cfg.nssPackage ]; + system.nssDatabases.passwd = mkIf cfg.enableNSS [ "authentik" ]; + system.nssDatabases.group = mkIf cfg.enableNSS [ "authentik" ]; + + # Add NSS library to the dynamic linker path + environment.etc."ld.so.conf.d/authentik-nss.conf" = mkIf cfg.enableNSS { + text = "${cfg.nssPackage}/lib"; + }; + + # Ensure ldconfig is run after configuration changes + system.activationScripts.authentik-ldconfig = mkIf cfg.enableNSS '' + ${pkgs.glibc.bin}/bin/ldconfig + ''; + }; +} diff --git a/nix/modules/darwin.nix b/nix/modules/darwin.nix new file mode 100644 index 00000000..b786141b --- /dev/null +++ b/nix/modules/darwin.nix @@ -0,0 +1,123 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.services.authentik; + appPath = "/Applications/authentik Agent.app"; + packageAppPath = "${cfg.package}/Applications/authentik Agent.app"; + packageResourcesPath = "${packageAppPath}/Contents/Resources"; + browserSupportPath = "${packageAppPath}/Contents/MacOS/ak-browser-support"; + defaultConfigPath = "/opt/authentik/config/config.json"; + targetConfigPath = if cfg.configFile != null then toString cfg.configFile else defaultConfigPath; + defaultSysdConfig = ../../cmd/agent_local/package/macos/scripts/sysd-config.json; +in +{ + options.services.authentik = { + enable = mkEnableOption "authentik platform agent"; + + package = mkOption { + type = types.package; + default = pkgs.authentik-agent or (throw "authentik-agent package not found. Add the authentik overlay to your nixpkgs."); + defaultText = literalExpression "pkgs.authentik-agent"; + description = "The authentik agent package to use (includes all binaries)."; + }; + + domain = mkOption { + type = types.nullOr types.str; + default = null; + description = "The authentik server domain to connect to."; + }; + + configFile = mkOption { + type = types.nullOr types.path; + default = null; + description = "Path to the authentik configuration file."; + }; + + extraArgs = mkOption { + type = types.listOf types.str; + default = [ ]; + description = "Extra command-line arguments to pass to ak-sysd."; + }; + }; + + config = mkIf cfg.enable { + # Install the package (makes binaries available in PATH) + environment.systemPackages = [ cfg.package ]; + + # Expose the .app bundle at the upstream path and install browser native messaging hosts. + system.activationScripts.authentik-agent.text = '' + echo "Installing authentik Agent.app..." + rm -rf "${appPath}" + ln -sfn "${packageAppPath}" "${appPath}" + mkdir -p /Library/Logs/io.goauthentik + chmod 755 /Library/Logs/io.goauthentik + + echo "Preparing authentik runtime directories..." + mkdir -p /opt/authentik/config + mkdir -p /opt/authentik/domains + mkdir -p /opt/authentik/runtime + chmod 700 /opt/authentik + chmod 700 /opt/authentik/config + chmod 700 /opt/authentik/domains + chmod 700 /opt/authentik/runtime + + if [ ! -e "${targetConfigPath}" ]; then + echo "Seeding default authentik sysd config..." + mkdir -p "$(dirname "${targetConfigPath}")" + install -m 600 "${defaultSysdConfig}" "${targetConfigPath}" + fi + + echo "Installing browser native messaging hosts..." + # Chrome/Chromium (system-wide) + mkdir -p "/Library/Google/Chrome/NativeMessagingHosts" + sed "s|/Applications/authentik Agent.app/Contents/MacOS/ak-browser-support|${browserSupportPath}|g" \ + "${packageResourcesPath}/browser-host-chrome.json" \ + > "/Library/Google/Chrome/NativeMessagingHosts/io.goauthentik.platform.json" + chmod 644 "/Library/Google/Chrome/NativeMessagingHosts/io.goauthentik.platform.json" + + mkdir -p "/Library/Application Support/Chromium/NativeMessagingHosts" + sed "s|/Applications/authentik Agent.app/Contents/MacOS/ak-browser-support|${browserSupportPath}|g" \ + "${packageResourcesPath}/browser-host-chrome.json" \ + > "/Library/Application Support/Chromium/NativeMessagingHosts/io.goauthentik.platform.json" + chmod 644 "/Library/Application Support/Chromium/NativeMessagingHosts/io.goauthentik.platform.json" + + # Edge (system-wide) + mkdir -p "/Library/Microsoft/Edge/NativeMessagingHosts" + sed "s|/Applications/authentik Agent.app/Contents/MacOS/ak-browser-support|${browserSupportPath}|g" \ + "${packageResourcesPath}/browser-host-chrome.json" \ + > "/Library/Microsoft/Edge/NativeMessagingHosts/io.goauthentik.platform.json" + chmod 644 "/Library/Microsoft/Edge/NativeMessagingHosts/io.goauthentik.platform.json" + + # Firefox (system-wide) + mkdir -p "/Library/Application Support/Mozilla/NativeMessagingHosts" + sed "s|/Applications/authentik Agent.app/Contents/MacOS/ak-browser-support|${browserSupportPath}|g" \ + "${packageResourcesPath}/browser-host-firefox.json" \ + > "/Library/Application Support/Mozilla/NativeMessagingHosts/io.goauthentik.platform.json" + chmod 644 "/Library/Application Support/Mozilla/NativeMessagingHosts/io.goauthentik.platform.json" + ''; + + # Launchd daemon for ak-sysd (runs as root) + launchd.daemons.io-goauthentik-platform-sysd = { + serviceConfig = { + Label = "io.goauthentik.platform.sysd"; + ProgramArguments = [ + "${packageAppPath}/Contents/MacOS/ak-sysd" + "agent" + ] ++ lib.optionals (cfg.configFile != null) [ + "--config-file" + (toString cfg.configFile) + ] ++ cfg.extraArgs; + EnvironmentVariables = mkIf (cfg.domain != null) { + AUTHENTIK_DOMAIN = cfg.domain; + }; + UserName = "root"; + RunAtLoad = true; + KeepAlive = true; + StandardOutPath = "/Library/Logs/io.goauthentik/sysd.log"; + StandardErrorPath = "/Library/Logs/io.goauthentik/sysd.log"; + }; + }; + }; +} diff --git a/nix/packages/ak-agent.nix b/nix/packages/ak-agent.nix new file mode 100644 index 00000000..405001ba --- /dev/null +++ b/nix/packages/ak-agent.nix @@ -0,0 +1,86 @@ +{ lib +, buildGoModule +, version +, vendorHash +, goLdflags +, goBuildInputs +, stdenv +}: + +buildGoModule { + pname = "ak-agent"; + inherit version vendorHash; + + src = ../..; + + subPackages = [ + "cmd/agent_local" + "cmd/agent_system" + "cmd/cli" + "cmd/browser_support" + ]; + + nativeBuildInputs = goBuildInputs; + + ldflags = goLdflags; + + postInstall = + let + # Reference source root - the space in "authentik Agent.app" is handled in the string + srcRoot = ../..; + in + '' + # Rename binaries + mv $out/bin/agent_local $out/bin/ak-agent + mv $out/bin/agent_system $out/bin/ak-sysd + mv $out/bin/cli $out/bin/ak + mv $out/bin/browser_support $out/bin/ak-browser-support + ln -s $out/bin/ak $out/bin/ak-vault + '' + lib.optionalString stdenv.isLinux '' + # Install user systemd service file from source + mkdir -p $out/lib/systemd/user + sed "s|/usr/bin/ak-agent|$out/bin/ak-agent|g" \ + "${srcRoot}/cmd/agent_local/package/linux/etc/systemd/user/ak-agent.service" \ + > $out/lib/systemd/user/ak-agent.service + + # Install polkit policy + mkdir -p $out/share/polkit-1/actions + cp "${srcRoot}/cmd/agent_local/package/linux/usr/share/polkit-1/actions/io.goauthentik.platform.policy" \ + $out/share/polkit-1/actions/ + '' + lib.optionalString stdenv.isDarwin '' + # Create macOS .app bundle + APP_DIR="$out/Applications/authentik Agent.app/Contents" + mkdir -p "$APP_DIR/MacOS" + mkdir -p "$APP_DIR/Resources" + mkdir -p "$out/Library/LaunchDaemons" + + # Copy all binaries into the app bundle + cp $out/bin/ak-agent "$APP_DIR/MacOS/ak-agent" + cp $out/bin/ak-sysd "$APP_DIR/MacOS/ak-sysd" + cp $out/bin/ak "$APP_DIR/MacOS/ak" + cp $out/bin/ak-browser-support "$APP_DIR/MacOS/ak-browser-support" + ln -s "ak" "$APP_DIR/MacOS/ak-vault" + + # Copy resources from source + cp "${srcRoot}/cmd/agent_local/package/macos/authentik Agent.app/Contents/Resources/icon.icns" "$APP_DIR/Resources/" + cp "${srcRoot}/cmd/agent_local/package/macos/authentik Agent.app/Contents/Resources/browser-host-chrome.json" "$APP_DIR/Resources/" + cp "${srcRoot}/cmd/agent_local/package/macos/authentik Agent.app/Contents/Resources/browser-host-firefox.json" "$APP_DIR/Resources/" + + # Use the current upstream Info.plist as-is so bundle metadata stays aligned. + cp "${srcRoot}/cmd/agent_local/package/macos/authentik Agent.app/Contents/Info.plist" \ + "$APP_DIR/Info.plist" + + # Copy launchd plist from source (daemon.plist) + cp "${srcRoot}/cmd/agent_local/package/macos/scripts/daemon.plist" \ + "$out/Library/LaunchDaemons/io.goauthentik.platform.sysd.plist" + ''; + + meta = with lib; { + description = "authentik Local Agent for user authentication"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux ++ platforms.darwin; + mainProgram = "ak-agent"; + }; +} diff --git a/nix/packages/ak-browser-support.nix b/nix/packages/ak-browser-support.nix new file mode 100644 index 00000000..5b5182dd --- /dev/null +++ b/nix/packages/ak-browser-support.nix @@ -0,0 +1,45 @@ +{ lib +, buildGoModule +, version +, vendorHash +, goLdflags +, goBuildInputs +, stdenv +}: + +buildGoModule { + pname = "ak-browser-support"; + inherit version vendorHash; + + src = ../..; + + subPackages = [ "cmd/browser_support" ]; + + nativeBuildInputs = goBuildInputs; + + ldflags = goLdflags; + + postInstall = let + srcRoot = ../..; + in '' + mv $out/bin/browser_support $out/bin/ak-browser-support + + # Include browser native messaging manifests in share for modules to install + mkdir -p $out/share/authentik + sed "s|/usr/bin/ak-browser-support|$out/bin/ak-browser-support|g" \ + "${srcRoot}/cmd/agent_system/package/linux/browser-host-chrome.json" \ + > $out/share/authentik/browser-host-chrome.json + sed "s|/usr/bin/ak-browser-support|$out/bin/ak-browser-support|g" \ + "${srcRoot}/cmd/agent_system/package/linux/browser-host-firefox.json" \ + > $out/share/authentik/browser-host-firefox.json + ''; + + meta = with lib; { + description = "authentik Browser Support for native messaging"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux ++ platforms.darwin; + mainProgram = "ak-browser-support"; + }; +} diff --git a/nix/packages/ak-cli.nix b/nix/packages/ak-cli.nix new file mode 100644 index 00000000..c94afa8b --- /dev/null +++ b/nix/packages/ak-cli.nix @@ -0,0 +1,36 @@ +{ lib +, buildGoModule +, version +, vendorHash +, goLdflags +, goBuildInputs +}: + +buildGoModule { + pname = "ak-cli"; + inherit version vendorHash; + + src = ../..; + + subPackages = [ "cmd/cli" ]; + + nativeBuildInputs = goBuildInputs; + + ldflags = goLdflags; + + postInstall = '' + mv $out/bin/cli $out/bin/ak + + # Match the packaged CLI entrypoint aliases. + ln -s $out/bin/ak $out/bin/ak-vault + ''; + + meta = with lib; { + description = "authentik Platform CLI"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux ++ platforms.darwin; + mainProgram = "ak"; + }; +} diff --git a/nix/packages/ak-sysd.nix b/nix/packages/ak-sysd.nix new file mode 100644 index 00000000..23159805 --- /dev/null +++ b/nix/packages/ak-sysd.nix @@ -0,0 +1,56 @@ +{ lib +, buildGoModule +, stdenv +, version +, vendorHash +, goLdflags +, goBuildInputs +}: + +buildGoModule { + pname = "ak-sysd"; + inherit version vendorHash; + + src = ../..; + + subPackages = [ "cmd/agent_system" ]; + + nativeBuildInputs = goBuildInputs; + + ldflags = goLdflags; + + postInstall = '' + mv $out/bin/agent_system $out/bin/ak-sysd + + # Install systemd service file (Linux only) + ${lib.optionalString stdenv.isLinux '' + mkdir -p $out/lib/systemd/system + cat > $out/lib/systemd/system/ak-sysd.service << EOF + [Unit] + Description=authentik sysd + After=network.target + + [Service] + Restart=always + ExecStart=$out/bin/ak-sysd agent + RuntimeDirectory=authentik + RuntimeDirectoryMode=0777 + + [Install] + WantedBy=multi-user.target + EOF + ''} + + # Create default config directory structure + mkdir -p $out/etc/authentik + ''; + + meta = with lib; { + description = "authentik System Agent daemon"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux ++ platforms.darwin; + mainProgram = "ak-sysd"; + }; +} diff --git a/nix/packages/libnss-authentik.nix b/nix/packages/libnss-authentik.nix new file mode 100644 index 00000000..c85feffb --- /dev/null +++ b/nix/packages/libnss-authentik.nix @@ -0,0 +1,44 @@ +{ lib +, stdenv +, rustPlatform +, pkg-config +, openssl +, version +}: + +rustPlatform.buildRustPackage { + pname = "libnss-authentik"; + inherit version; + + src = ../..; + + cargoLock = { + lockFile = ../../Cargo.lock; + }; + + # Build only the NSS module + buildAndTestSubdir = "nss"; + + nativeBuildInputs = [ + pkg-config + ]; + + buildInputs = [ + openssl + ]; + + postInstall = '' + # Install the NSS module (must be named libnss_.so.2) + mkdir -p $out/lib + cp target/${stdenv.hostPlatform.rust.rustcTarget}/release/libauthentik_nss.so $out/lib/libnss_authentik.so.2 + ln -s libnss_authentik.so.2 $out/lib/libnss_authentik.so + ''; + + meta = with lib; { + description = "NSS module for authentik name resolution"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux; + }; +} diff --git a/nix/packages/libpam-authentik.nix b/nix/packages/libpam-authentik.nix new file mode 100644 index 00000000..bac78273 --- /dev/null +++ b/nix/packages/libpam-authentik.nix @@ -0,0 +1,61 @@ +{ lib +, stdenv +, rustPlatform +, pkg-config +, pam +, openssl +, version +}: + +rustPlatform.buildRustPackage { + pname = "libpam-authentik"; + inherit version; + + src = ../..; + + cargoLock = { + lockFile = ../../Cargo.lock; + }; + + # Build only the PAM module + buildAndTestSubdir = "pam"; + + nativeBuildInputs = [ + pkg-config + ]; + + buildInputs = [ + pam + openssl + ]; + + postInstall = '' + # Install the PAM module to the correct location + mkdir -p $out/lib/security + cp target/${stdenv.hostPlatform.rust.rustcTarget}/release/libauthentik_pam.so $out/lib/security/pam_authentik.so + + # Install PAM configuration for pam-auth-update + mkdir -p $out/share/pam-configs + cat > $out/share/pam-configs/authentik << 'EOF' + Name: authentik Authentication + Default: yes + Priority: 512 + Auth-Type: Primary + Auth: + [success=end default=ignore] pam_authentik.so + Auth-Initial: + [success=end default=ignore] pam_authentik.so + Session-Type: Additional + Session: + required pam_authentik.so + EOF + ''; + + meta = with lib; { + description = "PAM module for authentik authentication"; + homepage = "https://goauthentik.io"; + license = licenses.mit; + maintainers = [ ]; + platforms = platforms.linux; + }; +} diff --git a/pkg/agent_system/agent_starter/starter.go b/pkg/agent_system/agent_starter/starter.go index 57083fee..c0bf7389 100644 --- a/pkg/agent_system/agent_starter/starter.go +++ b/pkg/agent_system/agent_starter/starter.go @@ -3,6 +3,8 @@ package agentstarter import ( "errors" "os" + "path/filepath" + "runtime" "time" "github.com/avast/retry-go/v4" @@ -10,7 +12,6 @@ import ( "goauthentik.io/platform/pkg/agent_system/component" "goauthentik.io/platform/pkg/agent_system/config" "goauthentik.io/platform/pkg/agent_system/session" - "goauthentik.io/platform/pkg/platform/pstr" "goauthentik.io/platform/pkg/shared/events" "goauthentik.io/platform/vnd/fleet/orbit/pkg/execuser" userpkg "goauthentik.io/platform/vnd/fleet/orbit/pkg/user" @@ -50,7 +51,12 @@ func (as *Server) Stop() error { func (as *Server) RegisterForID(id string, s grpc.ServiceRegistrar) {} func (as *Server) start() { - if _, err := os.Stat(as.agentExec().ForCurrent()); errors.Is(err, os.ErrNotExist) { + agentExec := as.agentExec() + if agentExec == "" { + return + } + if _, err := os.Stat(agentExec); errors.Is(err, os.ErrNotExist) { + as.log.WithField("path", agentExec).Debug("agent executable path not found, skipping") return } for { @@ -75,11 +81,16 @@ func (as *Server) start() { } } -func (as *Server) agentExec() pstr.PlatformString { - return pstr.PlatformString{ - Darwin: new("/Applications/authentik Agent.app"), - Linux: new("/usr/bin/ak-agent"), - Windows: new(`C:\Program Files\Authentik Security Inc\agent\ak-agent.exe`), +func (as *Server) agentExec() string { + switch runtime.GOOS { + case "darwin": + return darwinAgentExec() + case "linux": + return "/usr/bin/ak-agent" + case "windows": + return `C:\Program Files\Authentik Security Inc\agent\ak-agent.exe` + default: + return "" } } @@ -113,10 +124,63 @@ func (as *Server) startSingle() error { // To be able to run the desktop application (mostly to register the icon in the system tray) // we need to run the application as the login user. // Package execuser provides multi-platform support for this. - lastLogs, err := execuser.Run(as.agentExec().ForCurrent(), opts...) + lastLogs, err := execuser.Run(as.agentExec(), opts...) if err != nil { as.log.WithField("logs", lastLogs).WithError(err).Debug("execuser.Run") return err } return nil } + +func darwinAgentExec() string { + if override := os.Getenv("AUTHENTIK_AGENT_APP_PATH"); override != "" { + return override + } + executablePath, err := os.Executable() + if err != nil { + executablePath = "" + } + return firstExistingPath(darwinAgentExecCandidates(executablePath), func(path string) bool { + _, err := os.Stat(path) + return err == nil + }) +} + +func darwinAgentExecCandidates(executablePath string) []string { + candidates := []string{ + "/Applications/authentik Agent.app", + "/Applications/Nix Apps/authentik Agent.app", + } + if executablePath != "" { + candidates = append(candidates, filepath.Clean(filepath.Join(executablePath, "..", "..", ".."))) + } + return uniqueStrings(candidates) +} + +func firstExistingPath(candidates []string, exists func(string) bool) string { + for _, candidate := range candidates { + if candidate != "" && exists(candidate) { + return candidate + } + } + if len(candidates) == 0 { + return "" + } + return candidates[0] +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + unique := make([]string, 0, len(values)) + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} diff --git a/pkg/agent_system/agent_starter/starter_test.go b/pkg/agent_system/agent_starter/starter_test.go new file mode 100644 index 00000000..7204edb0 --- /dev/null +++ b/pkg/agent_system/agent_starter/starter_test.go @@ -0,0 +1,52 @@ +package agentstarter + +import "testing" + +func TestDarwinAgentExecCandidates(t *testing.T) { + executablePath := "/nix/store/test-ak-agent/Applications/authentik Agent.app/Contents/MacOS/ak-sysd" + + candidates := darwinAgentExecCandidates(executablePath) + + if len(candidates) != 3 { + t.Fatalf("expected 3 candidates, got %d: %#v", len(candidates), candidates) + } + if candidates[0] != "/Applications/authentik Agent.app" { + t.Fatalf("unexpected first candidate: %q", candidates[0]) + } + if candidates[1] != "/Applications/Nix Apps/authentik Agent.app" { + t.Fatalf("unexpected second candidate: %q", candidates[1]) + } + if candidates[2] != "/nix/store/test-ak-agent/Applications/authentik Agent.app" { + t.Fatalf("unexpected bundle candidate: %q", candidates[2]) + } +} + +func TestFirstExistingPath(t *testing.T) { + candidates := []string{ + "/Applications/authentik Agent.app", + "/Applications/Nix Apps/authentik Agent.app", + } + + selected := firstExistingPath(candidates, func(path string) bool { + return path == "/Applications/Nix Apps/authentik Agent.app" + }) + + if selected != "/Applications/Nix Apps/authentik Agent.app" { + t.Fatalf("unexpected selected path: %q", selected) + } +} + +func TestFirstExistingPathFallback(t *testing.T) { + candidates := []string{ + "/Applications/authentik Agent.app", + "/Applications/Nix Apps/authentik Agent.app", + } + + selected := firstExistingPath(candidates, func(string) bool { + return false + }) + + if selected != "/Applications/authentik Agent.app" { + t.Fatalf("unexpected fallback path: %q", selected) + } +} diff --git a/pkg/agent_system/config/config.go b/pkg/agent_system/config/config.go index 23e904c9..7b2cadac 100644 --- a/pkg/agent_system/config/config.go +++ b/pkg/agent_system/config/config.go @@ -78,12 +78,12 @@ func (c *Config) Domains() []*DomainConfig { func (c *Config) SaveDomain(dom *DomainConfig) error { path := filepath.Join(c.DomainDir, dom.Domain+".json") + dom.FallbackToken = dom.Token err := keyring.Set(keyring.Service("domain_token"), dom.Domain, keyring.AccessibleAlways, dom.Token) if err != nil { if !errors.Is(err, keyring.ErrUnsupportedPlatform) { c.log.WithError(err).Warning("failed to save domain token in keyring") } - dom.FallbackToken = dom.Token } b, err := json.Marshal(dom) if err != nil { @@ -103,7 +103,6 @@ func (c *Config) DeleteDomain(dom *DomainConfig) error { if !errors.Is(err, keyring.ErrUnsupportedPlatform) { c.log.WithError(err).Warning("failed to delete domain token in keyring") } - dom.FallbackToken = dom.Token } err = os.Remove(path) if err != nil { diff --git a/pkg/agent_system/config/config_test.go b/pkg/agent_system/config/config_test.go new file mode 100644 index 00000000..c8ae6125 --- /dev/null +++ b/pkg/agent_system/config/config_test.go @@ -0,0 +1,41 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "goauthentik.io/platform/pkg/storage/state" +) + +func TestSaveDomainPersistsFallbackToken(t *testing.T) { + dir := t.TempDir() + testState, err := state.Open(filepath.Join(dir, "state.db"), nil) + require.NoError(t, err) + st = testState + t.Cleanup(func() { + require.NoError(t, testState.Close()) + st = nil + }) + + cfg := &Config{ + DomainDir: dir, + log: (&Config{}).Default().(*Config).log, + } + + dom := cfg.NewDomain() + dom.Domain = "authentik" + dom.AuthentikURL = "https://authentik.company" + dom.Token = "device-token" + + require.NoError(t, cfg.SaveDomain(dom)) + + raw, err := os.ReadFile(filepath.Join(dir, "authentik.json")) + require.NoError(t, err) + + saved := &DomainConfig{} + require.NoError(t, json.Unmarshal(raw, saved)) + require.Equal(t, "device-token", saved.FallbackToken) +} diff --git a/pkg/agent_system/device/signed.go b/pkg/agent_system/device/signed.go index c41f0360..8a950b9d 100644 --- a/pkg/agent_system/device/signed.go +++ b/pkg/agent_system/device/signed.go @@ -6,7 +6,6 @@ import ( "time" "github.com/MicahParks/jwkset" - "github.com/MicahParks/keyfunc/v3" "github.com/golang-jwt/jwt/v5" "github.com/mitchellh/mapstructure" "goauthentik.io/platform/pkg/agent_system/config" @@ -16,28 +15,48 @@ import ( "goauthentik.io/platform/pkg/platform/facts/hardware" ) -func (ds *Server) validateChallenge(ctx context.Context, rawToken string) (*token.Token, *config.DomainConfig, error) { - for _, dom := range config.Manager().Get().Domains() { - var st jwkset.Storage - jw := jwkset.JWKSMarshal{} - err := mapstructure.Decode(dom.Config().JwksChallenge, &jw) - if err != nil { - ds.log.WithField("domain", dom.Domain).WithError(err).Warning("failed to load config") - continue +func parseChallengeToken(rawToken string, rawJWKS map[string]any) (*jwt.Token, error) { + jw := jwkset.JWKSMarshal{} + err := mapstructure.Decode(rawJWKS, &jw) + if err != nil { + return nil, err + } + keys, err := jw.JWKSlice() + if err != nil { + return nil, err + } + headerOnly := &token.AuthentikClaims{} + parser := jwt.NewParser() + unverified, _, err := parser.ParseUnverified(rawToken, headerOnly) + if err != nil { + return nil, err + } + targetKID, _ := unverified.Header["kid"].(string) + var lastErr error + for _, key := range keys { + if targetKID != "" { + marshaled := key.Marshal() + if marshaled.KID != "" && marshaled.KID != targetKID { + continue + } } - sst, err := jw.ToStorage() - if err != nil { - ds.log.WithField("domain", dom.Domain).WithError(err).Warning("failed to parse jwks") - continue + parsed, err := jwt.ParseWithClaims(rawToken, &token.AuthentikClaims{}, func(*jwt.Token) (any, error) { + return key.Key(), nil + }) + if err == nil { + return parsed, nil } - st = sst + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("no matching jwk found for challenge") +} - k, err := keyfunc.New(keyfunc.Options{Storage: st, Ctx: ctx}) - if err != nil { - ds.log.WithField("domain", dom.Domain).WithError(err).Warning("failed to create keyfunc") - continue - } - t, err := jwt.ParseWithClaims(rawToken, &token.AuthentikClaims{}, k.Keyfunc) +func (ds *Server) validateChallenge(_ context.Context, rawToken string) (*token.Token, *config.DomainConfig, error) { + for _, dom := range config.Manager().Get().Domains() { + t, err := parseChallengeToken(rawToken, dom.Config().JwksChallenge) if err != nil { ds.log.WithField("domain", dom.Domain).WithError(err).Warning("failed to validate token") continue diff --git a/pkg/agent_system/device/signed_test.go b/pkg/agent_system/device/signed_test.go new file mode 100644 index 00000000..ed90b796 --- /dev/null +++ b/pkg/agent_system/device/signed_test.go @@ -0,0 +1,53 @@ +package device + +import ( + "crypto/rand" + "crypto/rsa" + "encoding/json" + "testing" + + "github.com/MicahParks/jwkset" + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/require" +) + +func TestParseChallengeToken(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + jwk, err := jwkset.NewJWKFromKey(&key.PublicKey, jwkset.JWKOptions{ + Metadata: jwkset.JWKMetadataOptions{ + ALG: jwkset.ALG("RS256"), + KID: "test-kid", + USE: jwkset.USE("sig"), + }, + }) + require.NoError(t, err) + + jwksBytes, err := json.Marshal(jwkset.JWKSMarshal{ + Keys: []jwkset.JWKMarshal{jwk.Marshal()}, + }) + require.NoError(t, err) + + var jwks map[string]any + require.NoError(t, json.Unmarshal(jwksBytes, &jwks)) + + rawToken, err := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": "stage", + "aud": "goauthentik.io/platform/endpoint", + }).SignedString(key) + require.NoError(t, err) + + // Rebuild the token with the correct kid header. + signed := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{ + "iss": "stage", + "aud": "goauthentik.io/platform/endpoint", + }) + signed.Header["kid"] = "test-kid" + rawToken, err = signed.SignedString(key) + require.NoError(t, err) + + parsed, err := parseChallengeToken(rawToken, jwks) + require.NoError(t, err) + require.NotNil(t, parsed) +} diff --git a/pkg/browser_support/handler.go b/pkg/browser_support/handler.go index 439e8cd8..38f24f1b 100644 --- a/pkg/browser_support/handler.go +++ b/pkg/browser_support/handler.go @@ -31,6 +31,7 @@ func (m message) MessageID() string { type response struct { Data map[string]any `json:"data"` + Error string `json:"error,omitempty"` ResponseTo string `json:"response_to"` } @@ -74,7 +75,6 @@ func (bs *BrowserSupport) setup() { }, nil }) bs.l.Handle("get_token", func(in message) (*response, error) { - bs.log.Debugf("Browser host message: '%+v'\n", in) curr, err := bs.agentClient.GetCurrentToken(bs.ctx, &pb.CurrentTokenRequest{ Header: &pb.RequestHeader{ Profile: in.Profile, @@ -83,7 +83,9 @@ func (bs *BrowserSupport) setup() { }) if err != nil { bs.log.WithError(err).Warning("failed to get current token") - return nil, err + return &response{ + Error: err.Error(), + }, nil } return &response{ Data: map[string]any{ @@ -96,7 +98,9 @@ func (bs *BrowserSupport) setup() { res, err := bs.agentClient.ListProfiles(bs.ctx, &emptypb.Empty{}) if err != nil { bs.log.WithError(err).Warning("failed to list profiles") - return nil, err + return &response{ + Error: err.Error(), + }, nil } return &response{ Data: map[string]any{ @@ -113,7 +117,9 @@ func (bs *BrowserSupport) setup() { }) if err != nil { bs.log.WithError(err).Warning("failed to get endpoint header") - return nil, err + return &response{ + Error: err.Error(), + }, nil } return &response{ Data: map[string]any{