Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
env
node_modules
style.css
.idea
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ The installation of external plugins is covered in the [plugins section](https:/

Each plugin is located in a subdirectory of this repository. A README file located in each subdirectory contains documentation about the plugin. Here is the list :

- [Authentik](https://github.com/bunkerity/bunkerweb-plugins/tree/main/authentik)
- [ClamAV](https://github.com/bunkerity/bunkerweb-plugins/tree/main/clamav)
- [Coraza](https://github.com/bunkerity/bunkerweb-plugins/tree/main/coraza)
- [Discord](https://github.com/bunkerity/bunkerweb-plugins/tree/main/discord)
Expand Down
194 changes: 194 additions & 0 deletions authentik/README.md
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. |

Comment thread
daemon-byte marked this conversation as resolved.
# 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.
174 changes: 174 additions & 0 deletions authentik/authentik.lua
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
Comment thread
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
Loading