-
Notifications
You must be signed in to change notification settings - Fork 21
created a plugin for authentik / auth request #200
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
daemon-byte
wants to merge
4
commits into
bunkerity:main
Choose a base branch
from
daemon-byte:main
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,3 +4,4 @@ | |
| env | ||
| node_modules | ||
| style.css | ||
| .idea | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,194 @@ | ||
| # Authentik plugin | ||
|
|
||
| This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) | ||
| adds [Authentik](https://goauthentik.io/) forward authentication to a | ||
| BunkerWeb site. It works on top of any existing service configuration — | ||
| reverse proxy, served files, custom location blocks — without replacing them. | ||
|
|
||
| The auth check runs from Lua during BunkerWeb's access phase, so all of | ||
| BunkerWeb's built-in checks (rate limit, bad behavior, antibot, DNSBL, | ||
| whitelist / blacklist, ...) run *before* the Authentik subrequest fires. | ||
| Bots and rate-limited clients get denied without ever touching Authentik. | ||
|
|
||
| # Table of contents | ||
|
|
||
| - [Authentik plugin](#authentik-plugin) | ||
| - [Table of contents](#table-of-contents) | ||
| - [Request flow](#request-flow) | ||
| - [Setup](#setup) | ||
| - [Docker / Swarm](#docker--swarm) | ||
| - [Authentik configuration](#authentik-configuration) | ||
| - [Verifying it works](#verifying-it-works) | ||
| - [Settings](#settings) | ||
| - [Troubleshooting](#troubleshooting) | ||
| - [Notes](#notes) | ||
|
|
||
| # Request flow | ||
|
|
||
| For a request to `https://app.example.com/something`: | ||
|
|
||
| 1. BunkerWeb's access-phase checks run (rate limit, bad behavior, antibot, | ||
| DNSBL, blacklist, ...). If any of them deny, the request stops here. | ||
| 2. `authentik.lua` runs. If the URI is under `AUTHENTIK_OUTPOST_PATH` | ||
| (default `/outpost.goauthentik.io`), it passes through untouched — that's | ||
| the SSO flow itself, served by the outpost. | ||
| 3. Otherwise the handler does an HTTP `GET` against | ||
| `<AUTHENTIK_URL>/outpost.goauthentik.io/auth/nginx`, forwarding the | ||
| browser's cookies and `X-Original-URL`. | ||
| - **200** — request continues to its normal destination (reverse proxy, | ||
| file serving, custom location). Any `Set-Cookie` from Authentik is | ||
| relayed to the client so the session refreshes correctly. | ||
| - **401 / 403** — `302` to `<outpost_path>/start?rd=<original_url>`, which | ||
| kicks off the SSO login. | ||
| 4. The server-level snippet (`confs/server-http/authentik.conf`) raises | ||
| `proxy_buffers` / `proxy_buffer_size`, sets `port_in_redirect off`, and | ||
| declares the `location <outpost_path>` block that proxies the SSO | ||
| endpoints (`/auth`, `/start`, `/callback`, `/sign_out`, ...) back to the | ||
| Authentik outpost. Keeping these on the protected site's own domain is | ||
| what lets the proxy provider's session cookie be scoped correctly. | ||
|
|
||
| # Setup | ||
|
|
||
| See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github) | ||
| of the BunkerWeb documentation for the generic plugin installation procedure | ||
| (the short version: drop the `authentik/` directory into the scheduler's | ||
| `/data/plugins/` and restart). | ||
|
|
||
| ## Docker / Swarm | ||
|
|
||
| `AUTHENTIK_URL` is the URL **BunkerWeb itself** uses to call Authentik — | ||
| typically an internal Docker network address. Users still complete the | ||
| login on Authentik's own public URL (configured separately in Authentik, | ||
| not here). Both BunkerWeb and the user's browser need to be able to reach | ||
| that public URL; otherwise login redirects from `/outpost.../start` go | ||
| nowhere. | ||
|
|
||
| ```yaml | ||
| services: | ||
|
|
||
| bunkerweb: | ||
| image: bunkerity/bunkerweb:1.6.0 | ||
| ... | ||
| networks: | ||
| - bw-services | ||
| - bw-authentik | ||
| ... | ||
|
|
||
| bw-scheduler: | ||
| image: bunkerity/bunkerweb-scheduler:1.6.0 | ||
| ... | ||
| environment: | ||
| SERVER_NAME: "app.example.com" | ||
| USE_REVERSE_PROXY: "yes" | ||
| REVERSE_PROXY_HOST: "http://app:3000" | ||
| REVERSE_PROXY_URL: "/" | ||
|
|
||
| USE_AUTHENTIK: "yes" | ||
| # Internal URL — what BunkerWeb uses to call Authentik: | ||
| AUTHENTIK_URL: "http://authentik-server:9000" | ||
|
|
||
| authentik-server: | ||
| # Must also be reachable on a public URL (e.g. https://authentik.example.com) | ||
| # so users can complete the login flow. | ||
| image: ghcr.io/goauthentik/server:latest | ||
| ... | ||
| networks: | ||
| - bw-authentik | ||
|
|
||
| networks: | ||
| bw-services: | ||
| name: bw-services | ||
| bw-authentik: | ||
| name: bw-authentik | ||
| ``` | ||
|
|
||
| ## Authentik configuration | ||
|
|
||
| In the Authentik admin UI: | ||
|
|
||
| 1. Create a **Proxy Provider** for the protected site in **Forward Auth | ||
| (single application)** mode. *External host* should be the public URL of | ||
| the protected site (e.g. `https://app.example.com`). | ||
| 2. Create or assign an **Application** that uses the provider. | ||
| 3. Attach the application to an **Outpost**. The built-in *authentik Embedded | ||
| Outpost* is the simplest choice — `AUTHENTIK_URL` then points at the | ||
| Authentik server itself (`http://authentik-server:9000` in the example | ||
| above). For a standalone outpost, point `AUTHENTIK_URL` at that outpost's | ||
| address instead. | ||
| 4. Make sure the Authentik server itself has a public URL (defined in | ||
| *System → Brands* or via the `AUTHENTIK_HOST` env var). Browsers are | ||
| redirected there to enter credentials. | ||
|
|
||
| ## Verifying it works | ||
|
|
||
| 1. Reload the scheduler. The Authentik plugin should appear in BunkerWeb's | ||
| plugins list (web UI or scheduler logs). | ||
| 2. Visit a protected URL in a private window. You should land on the | ||
| Authentik login page (note the URL — it's served by Authentik, not by | ||
| BunkerWeb). | ||
| 3. After logging in, you should be redirected back to the protected URL and | ||
| see the upstream service's response. | ||
| 4. In the Authentik server logs you should see one `/outpost.goauthentik.io/auth/nginx` | ||
| call per protected request. If you see far more (e.g. one per static | ||
| asset), the outpost-path skip isn't matching — double-check | ||
| `AUTHENTIK_OUTPOST_PATH`. | ||
|
|
||
| # Settings | ||
|
|
||
| | Setting | Default | Context | Multiple | Description | | ||
| | ----------------------------- | ------------------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------- | | ||
| | `USE_AUTHENTIK` | `no` | multisite | no | Activate Authentik forward authentication for this site. | | ||
| | `AUTHENTIK_URL` | `` | multisite | no | Internal base URL BunkerWeb uses to reach the Authentik outpost. The plugin appends `/outpost.goauthentik.io/auth/nginx` for the subrequest and uses the same base for the outpost proxy. **Required when `USE_AUTHENTIK=yes`.** | | ||
| | `AUTHENTIK_OUTPOST_PATH` | `/outpost.goauthentik.io`| multisite | no | Local URL path under which the outpost endpoints (`/auth`, `/start`, `/callback`, ...) are exposed on the protected site. Must start with `/`. Changing it does not change the upstream path. | | ||
| | `AUTHENTIK_SSL_VERIFY` | `yes` | multisite | no | Verify the Authentik outpost TLS certificate. | | ||
| | `AUTHENTIK_TIMEOUT` | `5000` | global | no | Timeout (ms) for the Lua auth subrequest. | | ||
| | `AUTHENTIK_PROXY_BUFFER_SIZE` | `32k` | multisite | no | `proxy_buffer_size` for this server. Raise if Authentik headers overflow. | | ||
| | `AUTHENTIK_PROXY_BUFFERS` | `8 16k` | multisite | no | `proxy_buffers` for this server. | | ||
| | `AUTHENTIK_PASS_IDENTITY_HEADERS` | `no` | multisite | no | Forward Authentik's identity headers (`X-authentik-username`, `-groups`, `-email`, ...) to the upstream. The client-supplied copy of each listed header is stripped first to prevent spoofing. Enable only for trusted-header backends. | | ||
| | `AUTHENTIK_IDENTITY_HEADERS` | `X-authentik-username X-authentik-groups X-authentik-email X-authentik-name X-authentik-uid` | multisite | no | Space/comma-separated list of headers forwarded (and stripped from the request) when the above is `yes`. Include every `X-authentik-*` header your backend trusts. | | ||
|
|
||
| # Troubleshooting | ||
|
|
||
| - **HTTP 500 on every protected URL, scheduler log says "AUTHENTIK_URL not | ||
| configured".** `USE_AUTHENTIK=yes` is set but `AUTHENTIK_URL` is empty. | ||
| - **`upstream sent too big header while reading response header from upstream`.** | ||
| Raise `AUTHENTIK_PROXY_BUFFER_SIZE` (try `64k`) and/or `AUTHENTIK_PROXY_BUFFERS`. | ||
| - **Login loop — browser cycles between the protected URL and the Authentik | ||
| login page.** Almost always a cookie-domain mismatch. Confirm that | ||
| `AUTHENTIK_OUTPOST_PATH` resolves on the *same domain* as the protected | ||
| app, and that the Authentik proxy provider's *External host* matches that | ||
| domain exactly (scheme included). | ||
| - **`502` from the outpost path.** BunkerWeb can't reach `AUTHENTIK_URL` — | ||
| check the Docker network membership and that the Authentik service is up. | ||
| - **Auth subrequests time out.** Increase `AUTHENTIK_TIMEOUT`, or move the | ||
| Authentik outpost closer to BunkerWeb (ideally same Docker network). | ||
| - **Bots are still hitting Authentik.** They shouldn't be — `bad_behavior` | ||
| and friends run before the Authentik subrequest. If you're seeing | ||
| unauthenticated traffic at the outpost, it's likely the SSO redirect | ||
| fanout from real users; check the Authentik logs by user agent. | ||
|
|
||
| # Notes | ||
|
|
||
| - **Identity headers downstream (opt-in).** By default this plugin only gates | ||
| access and forwards nothing about the user to the upstream. If your backend | ||
| uses trusted-header authentication (Nextcloud, Grafana header-auth, | ||
| Bookstack, ...), set `AUTHENTIK_PASS_IDENTITY_HEADERS=yes` to relay | ||
| Authentik's `X-authentik-*` headers from the auth response to the upstream. | ||
| Customize the set via `AUTHENTIK_IDENTITY_HEADERS`. | ||
|
|
||
| **Security:** every header in `AUTHENTIK_IDENTITY_HEADERS` is stripped from | ||
| the incoming request *before* the Authentik values are applied, so a client | ||
| cannot spoof an identity by sending its own `X-authentik-username` (etc.). | ||
| Only headers Authentik actually returns are set; missing ones are left | ||
| absent rather than carrying a client-supplied value. Make sure the list | ||
| covers **every** identity header your backend trusts — anything not listed | ||
| is neither forwarded nor stripped. | ||
| - **Per-request cost.** Every gated request makes one HTTP call to the | ||
| Authentik outpost's `/auth/nginx`. The outpost caches session lookups, so | ||
| this is cheap — but keep `AUTHENTIK_URL` pointing at something nearby | ||
| (same Docker network is ideal). | ||
| - **Domain-level vs single-application mode.** This plugin assumes the | ||
| *Forward Auth (single application)* provider mode. Domain-level mode | ||
| (shared SSO cookie across `*.example.com`) needs additional Authentik | ||
| configuration but works with the same plugin settings as long as | ||
| `AUTHENTIK_OUTPOST_PATH` resolves on the protected domain. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| local class = require("middleclass") | ||
| local http = require("resty.http") | ||
| local plugin = require("bunkerweb.plugin") | ||
| local utils = require("bunkerweb.utils") | ||
|
|
||
| local authentik = class("authentik", plugin) | ||
|
|
||
| local ngx = ngx | ||
| local ngx_req = ngx.req | ||
| local ERR = ngx.ERR | ||
| local WARN = ngx.WARN | ||
| local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR | ||
| local HTTP_MOVED_TEMPORARILY = ngx.HTTP_MOVED_TEMPORARILY | ||
| local http_new = http.new | ||
| local has_variable = utils.has_variable | ||
| local tostring = tostring | ||
| local tonumber = tonumber | ||
| local sub = string.sub | ||
| local len = string.len | ||
| local gmatch = string.gmatch | ||
|
|
||
| local function starts_with(s, prefix) | ||
| if not s or not prefix or prefix == "" then | ||
| return false | ||
| end | ||
| return sub(s, 1, len(prefix)) == prefix | ||
| end | ||
|
|
||
| local function rstrip_slash(s) | ||
| if not s or s == "" then | ||
| return s | ||
| end | ||
| while sub(s, -1) == "/" do | ||
| s = sub(s, 1, -2) | ||
| end | ||
| return s | ||
| end | ||
|
|
||
| -- Split a space/comma separated header list into an array of names. | ||
| local function split_headers(s) | ||
| local t = {} | ||
| if not s then | ||
| return t | ||
| end | ||
| for name in gmatch(s, "[^%s,]+") do | ||
| t[#t + 1] = name | ||
| end | ||
| return t | ||
| end | ||
|
|
||
| function authentik:initialize(ctx) | ||
| plugin.initialize(self, "authentik", ctx) | ||
| end | ||
|
|
||
| function authentik:is_needed() | ||
| if self.is_loading then | ||
| return false | ||
| end | ||
| if self.is_request and (self.ctx.bw.server_name ~= "_") then | ||
| return self.variables["USE_AUTHENTIK"] == "yes" and not ngx_req.is_internal() | ||
| end | ||
| local is_needed, err = has_variable("USE_AUTHENTIK", "yes") | ||
| if is_needed == nil then | ||
| self.logger:log(ERR, "can't check USE_AUTHENTIK variable : " .. err) | ||
| end | ||
| return is_needed | ||
| end | ||
|
|
||
| function authentik:access() | ||
| if not self:is_needed() then | ||
| return self:ret(true, "authentik not activated") | ||
| end | ||
|
|
||
| local outpost_path = rstrip_slash(self.variables["AUTHENTIK_OUTPOST_PATH"]) | ||
| if outpost_path == nil or outpost_path == "" then | ||
| outpost_path = "/outpost.goauthentik.io" | ||
| end | ||
|
|
||
| -- Outpost endpoints (start, callback, sign_out, ...) handle their own flow, | ||
| -- and the /auth/nginx subrequest must not loop into us. Pass through. | ||
| local uri = self.ctx.bw.uri or ngx.var.uri or "" | ||
| if uri == outpost_path or starts_with(uri, outpost_path .. "/") then | ||
| return self:ret(true, "outpost endpoint, no auth check") | ||
| end | ||
|
|
||
| local upstream = rstrip_slash(self.variables["AUTHENTIK_URL"]) | ||
| if upstream == nil or upstream == "" then | ||
| self.logger:log(WARN, "USE_AUTHENTIK is yes but AUTHENTIK_URL is empty, denying request") | ||
| return self:ret(true, "AUTHENTIK_URL not configured", HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
|
|
||
| local scheme = ngx.var.scheme | ||
| local host = ngx.var.http_host or ngx.var.host | ||
| local request_uri = ngx.var.request_uri or uri | ||
| local original_url = scheme .. "://" .. host .. request_uri | ||
|
|
||
| local headers, err = ngx_req.get_headers() | ||
| if err == "truncated" then | ||
| self.logger:log(WARN, "too many request headers, auth check may be incomplete") | ||
| headers = headers or {} | ||
| end | ||
|
|
||
| local fwd_headers = { | ||
| ["Host"] = host, | ||
| ["X-Original-URL"] = original_url, | ||
| ["X-Original-URI"] = request_uri, | ||
| ["X-Forwarded-For"] = self.ctx.bw.remote_addr, | ||
| ["X-Forwarded-Host"] = host, | ||
| ["X-Forwarded-Proto"] = scheme, | ||
| } | ||
| for _, h in ipairs({ "cookie", "user-agent", "accept", "accept-language", "authorization" }) do | ||
| if headers[h] then | ||
| fwd_headers[h] = headers[h] | ||
| end | ||
| end | ||
|
|
||
| local httpc | ||
| httpc, err = http_new() | ||
| if not httpc then | ||
| return self:ret(true, "failed to create http client : " .. err, HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
| httpc:set_timeout(tonumber(self.variables["AUTHENTIK_TIMEOUT"]) or 5000) | ||
|
|
||
| local ssl_verify = self.variables["AUTHENTIK_SSL_VERIFY"] ~= "no" | ||
| local auth_url = upstream .. "/outpost.goauthentik.io/auth/nginx" | ||
|
|
||
| local res | ||
| res, err = httpc:request_uri(auth_url, { | ||
| method = "GET", | ||
| headers = fwd_headers, | ||
| ssl_verify = ssl_verify, | ||
| keepalive = true, | ||
| }) | ||
| if not res then | ||
| return self:ret(true, "auth subrequest failed : " .. tostring(err), HTTP_INTERNAL_SERVER_ERROR) | ||
| end | ||
|
|
||
| -- Forward any Set-Cookie from Authentik back to the client so the session | ||
| -- cookie / refresh lands on the protected domain. | ||
| local set_cookie = res.headers["Set-Cookie"] | ||
| if set_cookie then | ||
| ngx.header["Set-Cookie"] = set_cookie | ||
| end | ||
|
|
||
| if res.status == 200 then | ||
| -- Optionally forward Authentik's identity headers to the upstream so a | ||
| -- header-auth backend (Grafana, Nextcloud, ...) knows who the user is. | ||
| -- The client-supplied copy of every listed header is stripped first so | ||
| -- it can't be spoofed; only values from Authentik's auth response are set. | ||
| if self.variables["AUTHENTIK_PASS_IDENTITY_HEADERS"] == "yes" then | ||
| for _, h in ipairs(split_headers(self.variables["AUTHENTIK_IDENTITY_HEADERS"])) do | ||
| ngx_req.clear_header(h) | ||
| local value = res.headers[h] | ||
| if value then | ||
| ngx_req.set_header(h, value) | ||
| end | ||
|
daemon-byte marked this conversation as resolved.
|
||
| end | ||
| end | ||
| return self:ret(true, "authentik authorized request") | ||
| end | ||
|
|
||
| if res.status == 401 or res.status == 403 then | ||
| local redirect = outpost_path .. "/start?rd=" .. ngx.escape_uri(original_url) | ||
| return self:ret(true, "authentik signin redirect", HTTP_MOVED_TEMPORARILY, redirect) | ||
| end | ||
|
|
||
| return self:ret( | ||
| true, | ||
| "unexpected status from authentik outpost : " .. tostring(res.status), | ||
| HTTP_INTERNAL_SERVER_ERROR | ||
| ) | ||
| end | ||
|
|
||
| return authentik | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.