Skip to content

Commit 8e5a54c

Browse files
Merge pull request #200 from daemon-byte/main
created a plugin for authentik / auth request
2 parents da3d025 + d83fee6 commit 8e5a54c

6 files changed

Lines changed: 488 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
env
55
node_modules
66
style.css
7+
.idea

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ The installation of external plugins is covered in the [plugins section](https:/
2121

2222
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 :
2323

24+
- [Authentik](https://github.com/bunkerity/bunkerweb-plugins/tree/main/authentik)
2425
- [ClamAV](https://github.com/bunkerity/bunkerweb-plugins/tree/main/clamav)
2526
- [Coraza](https://github.com/bunkerity/bunkerweb-plugins/tree/main/coraza)
2627
- [Discord](https://github.com/bunkerity/bunkerweb-plugins/tree/main/discord)

authentik/README.md

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
# Authentik plugin
2+
3+
This [plugin](https://www.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
4+
adds [Authentik](https://goauthentik.io/) forward authentication to a
5+
BunkerWeb site. It works on top of any existing service configuration —
6+
reverse proxy, served files, custom location blocks — without replacing them.
7+
8+
The auth check runs from Lua during BunkerWeb's access phase, so all of
9+
BunkerWeb's built-in checks (rate limit, bad behavior, antibot, DNSBL,
10+
whitelist / blacklist, ...) run *before* the Authentik subrequest fires.
11+
Bots and rate-limited clients get denied without ever touching Authentik.
12+
13+
# Table of contents
14+
15+
- [Authentik plugin](#authentik-plugin)
16+
- [Table of contents](#table-of-contents)
17+
- [Request flow](#request-flow)
18+
- [Setup](#setup)
19+
- [Docker / Swarm](#docker--swarm)
20+
- [Authentik configuration](#authentik-configuration)
21+
- [Verifying it works](#verifying-it-works)
22+
- [Settings](#settings)
23+
- [Troubleshooting](#troubleshooting)
24+
- [Notes](#notes)
25+
26+
# Request flow
27+
28+
For a request to `https://app.example.com/something`:
29+
30+
1. BunkerWeb's access-phase checks run (rate limit, bad behavior, antibot,
31+
DNSBL, blacklist, ...). If any of them deny, the request stops here.
32+
2. `authentik.lua` runs. If the URI is under `AUTHENTIK_OUTPOST_PATH`
33+
(default `/outpost.goauthentik.io`), it passes through untouched — that's
34+
the SSO flow itself, served by the outpost.
35+
3. Otherwise the handler does an HTTP `GET` against
36+
`<AUTHENTIK_URL>/outpost.goauthentik.io/auth/nginx`, forwarding the
37+
browser's cookies and `X-Original-URL`.
38+
- **200** — request continues to its normal destination (reverse proxy,
39+
file serving, custom location). Any `Set-Cookie` from Authentik is
40+
relayed to the client so the session refreshes correctly.
41+
- **401 / 403**`302` to `<outpost_path>/start?rd=<original_url>`, which
42+
kicks off the SSO login.
43+
4. The server-level snippet (`confs/server-http/authentik.conf`) raises
44+
`proxy_buffers` / `proxy_buffer_size`, sets `port_in_redirect off`, and
45+
declares the `location <outpost_path>` block that proxies the SSO
46+
endpoints (`/auth`, `/start`, `/callback`, `/sign_out`, ...) back to the
47+
Authentik outpost. Keeping these on the protected site's own domain is
48+
what lets the proxy provider's session cookie be scoped correctly.
49+
50+
# Setup
51+
52+
See the [plugins section](https://docs.bunkerweb.io/latest/plugins/?utm_campaign=self&utm_source=github)
53+
of the BunkerWeb documentation for the generic plugin installation procedure
54+
(the short version: drop the `authentik/` directory into the scheduler's
55+
`/data/plugins/` and restart).
56+
57+
## Docker / Swarm
58+
59+
`AUTHENTIK_URL` is the URL **BunkerWeb itself** uses to call Authentik —
60+
typically an internal Docker network address. Users still complete the
61+
login on Authentik's own public URL (configured separately in Authentik,
62+
not here). Both BunkerWeb and the user's browser need to be able to reach
63+
that public URL; otherwise login redirects from `/outpost.../start` go
64+
nowhere.
65+
66+
```yaml
67+
services:
68+
69+
bunkerweb:
70+
image: bunkerity/bunkerweb:1.6.0
71+
...
72+
networks:
73+
- bw-services
74+
- bw-authentik
75+
...
76+
77+
bw-scheduler:
78+
image: bunkerity/bunkerweb-scheduler:1.6.0
79+
...
80+
environment:
81+
SERVER_NAME: "app.example.com"
82+
USE_REVERSE_PROXY: "yes"
83+
REVERSE_PROXY_HOST: "http://app:3000"
84+
REVERSE_PROXY_URL: "/"
85+
86+
USE_AUTHENTIK: "yes"
87+
# Internal URL — what BunkerWeb uses to call Authentik:
88+
AUTHENTIK_URL: "http://authentik-server:9000"
89+
90+
authentik-server:
91+
# Must also be reachable on a public URL (e.g. https://authentik.example.com)
92+
# so users can complete the login flow.
93+
image: ghcr.io/goauthentik/server:latest
94+
...
95+
networks:
96+
- bw-authentik
97+
98+
networks:
99+
bw-services:
100+
name: bw-services
101+
bw-authentik:
102+
name: bw-authentik
103+
```
104+
105+
## Authentik configuration
106+
107+
In the Authentik admin UI:
108+
109+
1. Create a **Proxy Provider** for the protected site in **Forward Auth
110+
(single application)** mode. *External host* should be the public URL of
111+
the protected site (e.g. `https://app.example.com`).
112+
2. Create or assign an **Application** that uses the provider.
113+
3. Attach the application to an **Outpost**. The built-in *authentik Embedded
114+
Outpost* is the simplest choice — `AUTHENTIK_URL` then points at the
115+
Authentik server itself (`http://authentik-server:9000` in the example
116+
above). For a standalone outpost, point `AUTHENTIK_URL` at that outpost's
117+
address instead.
118+
4. Make sure the Authentik server itself has a public URL (defined in
119+
*System → Brands* or via the `AUTHENTIK_HOST` env var). Browsers are
120+
redirected there to enter credentials.
121+
122+
## Verifying it works
123+
124+
1. Reload the scheduler. The Authentik plugin should appear in BunkerWeb's
125+
plugins list (web UI or scheduler logs).
126+
2. Visit a protected URL in a private window. You should land on the
127+
Authentik login page (note the URL — it's served by Authentik, not by
128+
BunkerWeb).
129+
3. After logging in, you should be redirected back to the protected URL and
130+
see the upstream service's response.
131+
4. In the Authentik server logs you should see one `/outpost.goauthentik.io/auth/nginx`
132+
call per protected request. If you see far more (e.g. one per static
133+
asset), the outpost-path skip isn't matching — double-check
134+
`AUTHENTIK_OUTPOST_PATH`.
135+
136+
# Settings
137+
138+
| Setting | Default | Context | Multiple | Description |
139+
| ----------------------------- | ------------------------ | --------- | -------- | -------------------------------------------------------------------------------------------------------- |
140+
| `USE_AUTHENTIK` | `no` | multisite | no | Activate Authentik forward authentication for this site. |
141+
| `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`.** |
142+
| `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. |
143+
| `AUTHENTIK_SSL_VERIFY` | `yes` | multisite | no | Verify the Authentik outpost TLS certificate. |
144+
| `AUTHENTIK_TIMEOUT` | `5000` | global | no | Timeout (ms) for the Lua auth subrequest. |
145+
| `AUTHENTIK_PROXY_BUFFER_SIZE` | `32k` | multisite | no | `proxy_buffer_size` for this server. Raise if Authentik headers overflow. |
146+
| `AUTHENTIK_PROXY_BUFFERS` | `8 16k` | multisite | no | `proxy_buffers` for this server. |
147+
| `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. |
148+
| `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. |
149+
150+
# Troubleshooting
151+
152+
- **HTTP 500 on every protected URL, scheduler log says "AUTHENTIK_URL not
153+
configured".** `USE_AUTHENTIK=yes` is set but `AUTHENTIK_URL` is empty.
154+
- **`upstream sent too big header while reading response header from upstream`.**
155+
Raise `AUTHENTIK_PROXY_BUFFER_SIZE` (try `64k`) and/or `AUTHENTIK_PROXY_BUFFERS`.
156+
- **Login loop — browser cycles between the protected URL and the Authentik
157+
login page.** Almost always a cookie-domain mismatch. Confirm that
158+
`AUTHENTIK_OUTPOST_PATH` resolves on the *same domain* as the protected
159+
app, and that the Authentik proxy provider's *External host* matches that
160+
domain exactly (scheme included).
161+
- **`502` from the outpost path.** BunkerWeb can't reach `AUTHENTIK_URL` —
162+
check the Docker network membership and that the Authentik service is up.
163+
- **Auth subrequests time out.** Increase `AUTHENTIK_TIMEOUT`, or move the
164+
Authentik outpost closer to BunkerWeb (ideally same Docker network).
165+
- **Bots are still hitting Authentik.** They shouldn't be — `bad_behavior`
166+
and friends run before the Authentik subrequest. If you're seeing
167+
unauthenticated traffic at the outpost, it's likely the SSO redirect
168+
fanout from real users; check the Authentik logs by user agent.
169+
170+
# Notes
171+
172+
- **Identity headers downstream (opt-in).** By default this plugin only gates
173+
access and forwards nothing about the user to the upstream. If your backend
174+
uses trusted-header authentication (Nextcloud, Grafana header-auth,
175+
Bookstack, ...), set `AUTHENTIK_PASS_IDENTITY_HEADERS=yes` to relay
176+
Authentik's `X-authentik-*` headers from the auth response to the upstream.
177+
Customize the set via `AUTHENTIK_IDENTITY_HEADERS`.
178+
179+
**Security:** every header in `AUTHENTIK_IDENTITY_HEADERS` is stripped from
180+
the incoming request *before* the Authentik values are applied, so a client
181+
cannot spoof an identity by sending its own `X-authentik-username` (etc.).
182+
Only headers Authentik actually returns are set; missing ones are left
183+
absent rather than carrying a client-supplied value. Make sure the list
184+
covers **every** identity header your backend trusts — anything not listed
185+
is neither forwarded nor stripped.
186+
- **Per-request cost.** Every gated request makes one HTTP call to the
187+
Authentik outpost's `/auth/nginx`. The outpost caches session lookups, so
188+
this is cheap — but keep `AUTHENTIK_URL` pointing at something nearby
189+
(same Docker network is ideal).
190+
- **Domain-level vs single-application mode.** This plugin assumes the
191+
*Forward Auth (single application)* provider mode. Domain-level mode
192+
(shared SSO cookie across `*.example.com`) needs additional Authentik
193+
configuration but works with the same plugin settings as long as
194+
`AUTHENTIK_OUTPOST_PATH` resolves on the protected domain.

authentik/authentik.lua

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
local class = require("middleclass")
2+
local http = require("resty.http")
3+
local plugin = require("bunkerweb.plugin")
4+
local utils = require("bunkerweb.utils")
5+
6+
local authentik = class("authentik", plugin)
7+
8+
local ngx = ngx
9+
local ngx_req = ngx.req
10+
local ERR = ngx.ERR
11+
local WARN = ngx.WARN
12+
local HTTP_INTERNAL_SERVER_ERROR = ngx.HTTP_INTERNAL_SERVER_ERROR
13+
local HTTP_MOVED_TEMPORARILY = ngx.HTTP_MOVED_TEMPORARILY
14+
local http_new = http.new
15+
local has_variable = utils.has_variable
16+
local tostring = tostring
17+
local tonumber = tonumber
18+
local sub = string.sub
19+
local len = string.len
20+
local gmatch = string.gmatch
21+
22+
local function starts_with(s, prefix)
23+
if not s or not prefix or prefix == "" then
24+
return false
25+
end
26+
return sub(s, 1, len(prefix)) == prefix
27+
end
28+
29+
local function rstrip_slash(s)
30+
if not s or s == "" then
31+
return s
32+
end
33+
while sub(s, -1) == "/" do
34+
s = sub(s, 1, -2)
35+
end
36+
return s
37+
end
38+
39+
-- Split a space/comma separated header list into an array of names.
40+
local function split_headers(s)
41+
local t = {}
42+
if not s then
43+
return t
44+
end
45+
for name in gmatch(s, "[^%s,]+") do
46+
t[#t + 1] = name
47+
end
48+
return t
49+
end
50+
51+
function authentik:initialize(ctx)
52+
plugin.initialize(self, "authentik", ctx)
53+
end
54+
55+
function authentik:is_needed()
56+
if self.is_loading then
57+
return false
58+
end
59+
if self.is_request and (self.ctx.bw.server_name ~= "_") then
60+
return self.variables["USE_AUTHENTIK"] == "yes" and not ngx_req.is_internal()
61+
end
62+
local is_needed, err = has_variable("USE_AUTHENTIK", "yes")
63+
if is_needed == nil then
64+
self.logger:log(ERR, "can't check USE_AUTHENTIK variable : " .. err)
65+
end
66+
return is_needed
67+
end
68+
69+
function authentik:access()
70+
if not self:is_needed() then
71+
return self:ret(true, "authentik not activated")
72+
end
73+
74+
local outpost_path = rstrip_slash(self.variables["AUTHENTIK_OUTPOST_PATH"])
75+
if outpost_path == nil or outpost_path == "" then
76+
outpost_path = "/outpost.goauthentik.io"
77+
end
78+
79+
-- Outpost endpoints (start, callback, sign_out, ...) handle their own flow,
80+
-- and the /auth/nginx subrequest must not loop into us. Pass through.
81+
local uri = self.ctx.bw.uri or ngx.var.uri or ""
82+
if uri == outpost_path or starts_with(uri, outpost_path .. "/") then
83+
return self:ret(true, "outpost endpoint, no auth check")
84+
end
85+
86+
local upstream = rstrip_slash(self.variables["AUTHENTIK_URL"])
87+
if upstream == nil or upstream == "" then
88+
self.logger:log(WARN, "USE_AUTHENTIK is yes but AUTHENTIK_URL is empty, denying request")
89+
return self:ret(true, "AUTHENTIK_URL not configured", HTTP_INTERNAL_SERVER_ERROR)
90+
end
91+
92+
local scheme = ngx.var.scheme
93+
local host = ngx.var.http_host or ngx.var.host
94+
local request_uri = ngx.var.request_uri or uri
95+
local original_url = scheme .. "://" .. host .. request_uri
96+
97+
local headers, err = ngx_req.get_headers()
98+
if err == "truncated" then
99+
self.logger:log(WARN, "too many request headers, auth check may be incomplete")
100+
headers = headers or {}
101+
end
102+
103+
local fwd_headers = {
104+
["Host"] = host,
105+
["X-Original-URL"] = original_url,
106+
["X-Original-URI"] = request_uri,
107+
["X-Forwarded-For"] = self.ctx.bw.remote_addr,
108+
["X-Forwarded-Host"] = host,
109+
["X-Forwarded-Proto"] = scheme,
110+
}
111+
for _, h in ipairs({ "cookie", "user-agent", "accept", "accept-language", "authorization" }) do
112+
if headers[h] then
113+
fwd_headers[h] = headers[h]
114+
end
115+
end
116+
117+
local httpc
118+
httpc, err = http_new()
119+
if not httpc then
120+
return self:ret(true, "failed to create http client : " .. err, HTTP_INTERNAL_SERVER_ERROR)
121+
end
122+
httpc:set_timeout(tonumber(self.variables["AUTHENTIK_TIMEOUT"]) or 5000)
123+
124+
local ssl_verify = self.variables["AUTHENTIK_SSL_VERIFY"] ~= "no"
125+
local auth_url = upstream .. "/outpost.goauthentik.io/auth/nginx"
126+
127+
local res
128+
res, err = httpc:request_uri(auth_url, {
129+
method = "GET",
130+
headers = fwd_headers,
131+
ssl_verify = ssl_verify,
132+
keepalive = true,
133+
})
134+
if not res then
135+
return self:ret(true, "auth subrequest failed : " .. tostring(err), HTTP_INTERNAL_SERVER_ERROR)
136+
end
137+
138+
-- Forward any Set-Cookie from Authentik back to the client so the session
139+
-- cookie / refresh lands on the protected domain.
140+
local set_cookie = res.headers["Set-Cookie"]
141+
if set_cookie then
142+
ngx.header["Set-Cookie"] = set_cookie
143+
end
144+
145+
if res.status == 200 then
146+
-- Optionally forward Authentik's identity headers to the upstream so a
147+
-- header-auth backend (Grafana, Nextcloud, ...) knows who the user is.
148+
-- The client-supplied copy of every listed header is stripped first so
149+
-- it can't be spoofed; only values from Authentik's auth response are set.
150+
if self.variables["AUTHENTIK_PASS_IDENTITY_HEADERS"] == "yes" then
151+
for _, h in ipairs(split_headers(self.variables["AUTHENTIK_IDENTITY_HEADERS"])) do
152+
ngx_req.clear_header(h)
153+
local value = res.headers[h]
154+
if value then
155+
ngx_req.set_header(h, value)
156+
end
157+
end
158+
end
159+
return self:ret(true, "authentik authorized request")
160+
end
161+
162+
if res.status == 401 or res.status == 403 then
163+
local redirect = outpost_path .. "/start?rd=" .. ngx.escape_uri(original_url)
164+
return self:ret(true, "authentik signin redirect", HTTP_MOVED_TEMPORARILY, redirect)
165+
end
166+
167+
return self:ret(
168+
true,
169+
"unexpected status from authentik outpost : " .. tostring(res.status),
170+
HTTP_INTERNAL_SERVER_ERROR
171+
)
172+
end
173+
174+
return authentik

0 commit comments

Comments
 (0)