Compare commits

..

4 Commits

Author SHA1 Message Date
Goyban 04e84fde7a docs: explain the proxy target address in the forward auth example 2026-09-03 14:27:22 +02:00
Goyban 2f678693bf added Gitea 2026-09-03 14:26:31 +02:00
Goyban cd1e3f836d docs: fill in the Authentik SSO guide
Forward auth with the Caddyfile used in front of Firefly III, the native
OIDC endpoint reference, group mapping, and the gotchas collected while
setting up jellyfin and audiobookshelf.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-01 10:11:17 +02:00
Goyban 50f9aff43a Document Authentik forwards auth for apps without SSO support, like Firefly iii 2026-09-01 10:02:28 +02:00
5 changed files with 413 additions and 19 deletions
+198 -16
View File
@@ -1,6 +1,8 @@
# Single sign-on with Authentik # Single sign-on with Authentik
TODO: fill in as you go. Outline below so the shape is decided in advance. One login for everything self-hosted here, and one place to revoke it. The
services themselves stay unaware — nothing in any `compose.yaml` in this repo
mentions Authentik.
## Two ways to protect a service ## Two ways to protect a service
@@ -16,30 +18,210 @@ fallback.
## Native OIDC — the shape ## Native OIDC — the shape
TODO: provider + application setup in Authentik, then the four values every Use this wherever the app supports it. You get real accounts, real logout, and
app asks for: clients that aren't browsers keep working.
- Issuer / discovery URL ### In Authentik
- Client ID
- Client secret → `.env`, never committed
- Redirect URI
## Forward auth — the shape **Applications → Create**, provider type **OAuth2/OpenID Connect**. Three things
to note as you go:
TODO: the Caddy snippet, and which services you apply it to. - the application **slug** — it appears in half the URLs below
- the **client ID** and **client secret**
- pick a **signing key**, or the app can't validate the token it gets back
Then add the **redirect URIs**, which is where this usually goes wrong — see the
gotchas.
### The four values every app asks for
| Value | Where it comes from |
|-------|--------------------|
| Issuer / discovery URL | `https://authentik.example.com/application/o/<slug>/` |
| Client ID | Authentik, on the provider |
| Client secret | Authentik → the service's `.env`, never committed |
| Redirect URI | The app tells you — don't guess |
Most apps take the issuer URL and discover the rest themselves; Audiobookshelf
calls this **Auto-populate**. When one wants the endpoints spelled out
individually:
| Endpoint | Path |
|----------|------|
| OpenID configuration | `/application/o/<slug>/.well-known/openid-configuration` |
| Authorization | `/application/o/authorize/` |
| Token | `/application/o/token/` |
| User info | `/application/o/userinfo/` |
| End session | `/application/o/<slug>/end-session/` |
| JWKS | `/application/o/<slug>/jwks/` |
Note which paths carry the slug and which don't: authorize, token and userinfo
are shared across every application, while discovery, JWKS and logout are
per-application. Putting the slug where it doesn't belong gives a 404 that looks
like the provider is broken.
Worked examples in this repo:
[audiobookshelf](../audiobookshelf/README.md#single-sign-on-with-authentik) and
[jellyfin](../jellyfin/README.md#single-sign-on-with-authentik).
## Forward auth — putting an app with no SSO behind Authentik
This is the fallback, and it's what most self-hosted apps need. The app itself
learns nothing and changes nothing: **Caddy asks Authentik whether to allow the
request before it ever reaches the container.** An app with no accounts at all,
or one with its own login you can't replace, ends up behind a real SSO login
page either way.
### In Authentik
1. **Providers → Create → Proxy Provider**, mode **Forward auth (single
application)**. The external host is the public URL — `https://firefly.domain.com`.
2. **Applications → Create**, and bind it to that provider.
3. **Outposts** — add the application to an outpost. The built-in *embedded
outpost* is the one already listening on port **9000**, which is what the
Caddyfile below points at.
Step 3 is the one that gets forgotten. Provider and application exist, the
Caddyfile is right, and every request still fails because the outpost was never
told the application is its responsibility.
### In Caddy
```caddyfile
firefly.domain.com {
# directive execution order is only as stated if enclosed with route.
route {
# always forward outpost path to actual outpost
reverse_proxy /outpost.goauthentik.io/* http://127.0.0.1:9000
# forward authentication to outpost
forward_auth http://127.0.0.1:9000 {
uri /outpost.goauthentik.io/auth/caddy
# capitalization of the headers is important, otherwise they will be empty
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Entitlements X-Authentik-Email X-Authentik-Name X-Authentik-Uid X-Authentik-Jwt X-Authentik-Meta-Jwks X-Authentik-Meta-Outpost X-Authentik-Meta-Provider X-Authentik-Meta-App X-Authentik-Meta-Version
# optional, in this config trust all private ranges, should probably be set to the outposts IP
trusted_proxies private_ranges
}
# actual site configuration below, for example
reverse_proxy 10.10.0.2:8564
}
}
```
Only the last line changes per service: swap the address and port, and the
hostname at the top. Everything between is identical for every app.
`10.10.0.2` there is a **private address, not a public one** — in this setup a
WireGuard host, so Caddy reaches the app over the tunnel rather than across the
internet. Whatever address your proxy uses to reach the service goes there: a
LAN IP, a WireGuard or Tailscale peer, or a container name if Caddy runs in the
same Compose project. The only requirement is that Caddy can reach it and the
outside world can't.
### Why each piece is there
**`route { }` is not optional.** Caddy normally executes directives in its own
fixed order, not the order you wrote them. Without `route`, the final
`reverse_proxy` can run before `forward_auth`, and the app is served to
unauthenticated visitors. The config *looks* correct and protects nothing —
which is the worst possible failure mode for this feature, so wrap it and check
in a private window.
**The `/outpost.goauthentik.io/*` line comes first** because the login flow
itself lives on that path. If it fell through to the app, the browser would be
sent to a URL the app doesn't serve and the redirect loop would never resolve.
**`copy_headers` capitalization is load-bearing** — get it wrong and the headers
arrive empty rather than missing, so an app that reads them sees a blank
username instead of an error.
**`127.0.0.1:9000` assumes Caddy and Authentik are on the same host.** If Caddy
runs on a VPS and Authentik on the NAS, this is the line to change — and
`trusted_proxies private_ranges` should then name the outpost's actual address
rather than trusting every private range.
### What you give up
- **The app doesn't know who you are.** It sees an authenticated-by-someone
request. Apps with their own accounts still show their own login, so you get
**two logins** — Authentik, then the app. Some apps can read the
`X-Authentik-*` headers and skip theirs; most can't.
- **Non-browser clients break.** Mobile apps, API clients and anything doing
`curl` can't complete an interactive login flow. This is why Jellyfin and
Audiobookshelf use native OIDC instead — see
[jellyfin](../jellyfin/README.md) and
[audiobookshelf](../audiobookshelf/README.md). If a service has an API you
actually call, forward auth in front of it will stop that too.
- **It is all-or-nothing per hostname.** Every path behind that block requires
login, including webhooks and health checks.
### Where I use it
- **[Firefly III](../firefly/README.md)** — the config above is verbatim what
runs in front of it. Firefly keeps its own login underneath, so it's two
logins, and for bank data that's a trade worth making.
- **[copyparty](../copyparty/README.md)** and other apps with their own account
system, where the second login is the price of not exposing the app's login
form to the internet.
Prefer native OIDC wherever the app supports it. Reach for this when it doesn't.
## Groups and roles ## Groups and roles
TODO: how you map Authentik groups to per-app roles, and the admin/user The mechanism is the same everywhere: Authentik puts your group membership in
split. the token, and the app decides what that means.
**You usually don't need a custom mapping.** The standard `profile` scope
already includes group membership, so most apps see your groups with nothing
configured. Reach for a custom **scope mapping** (Customization → Property
Mappings) only when an app wants the claim under a different name or in a
different shape.
**Each app consumes it differently**, and that's the part to check per service —
Audiobookshelf has group and permission claim fields under its OpenID settings;
the Jellyfin SSO-Auth plugin has its own role and admin filters. There is no
single setting that makes "the admins group" mean administrator everywhere.
**The split worth setting up:** one group for ordinary access, one for admin,
and grant the app's admin role only to the second. The reason is the failure
mode below — if everyone who can log in is an admin, an SSO misconfiguration
isn't just an outage, it's an escalation.
TODO: the actual group names and per-app role mapping used here, once the same
shape has survived a few services.
## Gotchas ## Gotchas
TODO: collect these as you hit them. Known ones worth writing down: - **Keep a local fallback admin until the flow is proven.** If the only account
that can administer an app is behind SSO and SSO breaks, you have locked
- Locking yourself out of an app whose only admin is now behind SSO — keep a yourself out of the thing you need in order to fix it. Prove the login works,
local fallback admin until the flow is proven. in a private window, *before* removing the local account.
- Redirect URI mismatches, which usually surface as a generic error. - **Redirect URI mismatches surface as a generic error** that never names the
URI. Read the exact URIs off the application's own settings page rather than
copying them from a guide — Audiobookshelf prints them, and
[its README](../audiobookshelf/README.md#the-redirect-uris-are-where-this-goes-wrong)
documents a case where the published guide was wrong for that install.
- **Behind TLS, apps build `http://` redirect URLs.** The proxy terminates TLS,
the app sees a plain HTTP request, and the callback it generates is insecure —
so the login fails halfway through. Every app has its own name for the fix;
Jellyfin's SSO-Auth plugin calls it
[Scheme Override](../jellyfin/README.md#the-setting-that-cost-me-the-time).
Firefly's `TRUSTED_PROXIES=**` is the same class of setting.
- **`email_verified` is `False` by default.** Some apps refuse to authenticate a
user whose email isn't marked verified, and the resulting error rarely says
so. Fix it in the scope mapping.
- **Forward auth breaks everything that isn't a browser.** Mobile apps and API
clients can't complete an interactive login. Check what talks to a service
before putting it behind forward auth — this is the single biggest reason to
prefer native OIDC.
- **Adding the application to an outpost is a separate step** from creating the
provider and the application, and forgetting it fails in a way that looks like
a Caddy problem.
- **The client secret belongs in the service's `.env`**, which is git-ignored.
Never in `compose.yaml`, which is committed.
## Related ## Related
+12 -3
View File
@@ -221,9 +221,18 @@ behind a proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the default choice. - [Reverse proxy with Caddy](../docs/reverse-proxy.md) — the default choice.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for this one. It's a text - [Cloudflare Tunnel](../cloudflare-tunnel/) — fine for this one. It's a text
app; nothing here bumps the ~100 MB body cap unless you attach large files. app; nothing here bumps the ~100 MB body cap unless you attach large files.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — **think first.** - [Single sign-on with Authentik](../docs/authentik-sso.md#forward-auth--putting-an-app-with-no-sso-behind-authentik)
This is your bank data, and Firefly's own login is the only thing in front of — **what I do.** Firefly has no OIDC support, so it sits behind Authentik
it today. Keep a working local admin until any SSO flow is proven. forward auth: Caddy checks with Authentik before the request reaches the
container. That means two logins, Authentik then Firefly, which for bank data
is a trade worth making — the app's own login form is never exposed to the
internet. The exact Caddyfile is in that doc.
Two things to keep in mind here specifically. Firefly's **API is behind the
same wall**, so external API clients stop working (the bundled importer is
unaffected — it talks to `app:8080` inside the compose network, never through
the proxy). And keep a working local admin until the flow is proven; locking
yourself out of your own ledger is an unpleasant afternoon.
## Links ## Links
+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and adjust. .env is git-ignored; this file is not.
# Every value here has a default in compose.yaml, so a fresh clone runs
# with no .env at all.
# ─── Identity ───────────────────────────────────────────────────────
# Become USER_UID / USER_GID in the container, which is how Gitea's `git`
# user is made to match the owner of the bind-mounted data directory.
# Wrong values mean Gitea cannot write its own data.
#
# id -u ; id -g
#
#PUID=1000
#PGID=1000
# ─── Paths ──────────────────────────────────────────────────────────
# Repositories, the SQLite database and conf/app.ini. This is the whole
# backup -- nothing in it regenerates.
#DATA_PATH=./data
# ─── Ports ──────────────────────────────────────────────────────────
#PORT=3000
# Host port for SSH clone; the container always listens on 22. Port 22 on
# the host belongs to the host's own sshd, which is why this isn't 22.
#
# If you change it, also uncomment GITEA__server__SSH_PORT in compose.yaml
# so the clone URLs Gitea advertises match -- otherwise it hands out
# port 22 and clients land on the host's sshd instead.
#SSH_PORT=222
# ─── Timezone ───────────────────────────────────────────────────────
# There is no TZ variable here on purpose: compose.yaml mounts the host's
# /etc/timezone and /etc/localtime read-only, so the container follows the
# host automatically.
+129
View File
@@ -0,0 +1,129 @@
# Gitea
Self-hosted Git with a web UI, issues and pull requests. A few hundred MB of Go
that starts in a second, where the alternative is a JVM and a shopping list of
services.
This is the stack the rest of this repo is developed against — the compose files
you're reading live in a Gitea instance, and GitHub is the mirror.
## Quick start
```bash
cp .env.example .env
$EDITOR .env # PUID/PGID must match the owner of ./gitea
docker compose up -d
```
Open `http://<host>:3000`. You get Gitea's **install wizard**, not a login page —
it writes `app.ini` from what you enter and that's the moment the instance is
configured. Two things to settle there rather than later:
1. **Server and Third-Party Service Settings → Base URL.** Set it to the URL
people will actually use. Wrong here means every clone URL it hands out is
wrong.
2. **Create the admin account** at the bottom of the wizard. If you skip it,
*the first user to register becomes the administrator* — which is fine on a
LAN and not fine on anything reachable.
## Ports
| Port | Proto | Purpose |
|------|-------|---------|
| 3000 | http | Web UI and the HTTP(S) clone endpoint |
| 222 | ssh | SSH clone endpoint (container listens on 22) |
## Why it looks like this
**It started as upstream's example** —
[Install with Docker](https://docs.gitea.com/installation/install-with-docker/)
— with four changes to bring it in line with the rest of this repo, listed at
the end of this section. Everything else is upstream's, and what follows is what
those choices actually mean.
**SQLite by default.** There's no database container here, and none is needed:
with no `GITEA__database__DB_TYPE` set, Gitea uses SQLite inside `/data`. For one
person or a small team that is genuinely the right answer — one less container,
one less backup, and the performance ceiling is far above what a personal
instance reaches. Upstream documents MySQL and PostgreSQL variants using
`GITEA__database__DB_TYPE=mysql` / `postgres`; switch if you outgrow it, and do
it before there's data to migrate.
**`USER_UID` / `USER_GID` exist because of the bind mount.** `./gitea:/data` is a
host directory, so the `git` user inside the container has to match whoever owns
it. Upstream notes that named volumes avoid the problem entirely — Docker manages
ownership — at the cost of your data being somewhere less obvious.
**SSH is on 222, not 22**, because 22 on the host belongs to the host's own sshd.
This is the source of the most common Gitea confusion: see the gotchas.
**Environment variables map to `app.ini` sections** with the pattern
`GITEA__section__KEY`. So `GITEA__server__ROOT_URL` sets `ROOT_URL` under
`[server]`. Useful to know, because the wizard only exposes a fraction of what
Gitea can be told.
### What was changed from upstream's example
Four things, all for the same reason — this file is published, and a fresh clone
has to work:
- **`${PUID:-1000}` / `${PGID:-1000}` instead of bare `${PUID}`.** Compose
interpolates an unset variable to an *empty string* and starts the container
anyway, so upstream's version hands Gitea `USER_UID=` on any clone without a
`.env`. Defaults are the convention here for exactly this reason.
- **`restart: unless-stopped` instead of `always`**, so a deliberate stop
survives a reboot.
- **`${DATA_PATH:-./data}` instead of `./gitea`**, which also stops the data
landing at the slightly comic `gitea/gitea/`.
- **Pinned to `1.27.3` instead of `:latest`**, because Gitea runs schema
migrations between releases.
Two settings are also present but commented out —
`GITEA__server__SSH_PORT` and `GITEA__server__ROOT_URL` — because both are
things you will need the moment you publish this beyond localhost. See the
gotchas.
## Gotchas
- **SSH clone URLs advertise the wrong port by default.** Gitea knows it listens
on 22 *inside* the container; it has no idea you published it on 222. So it
offers `git@host:user/repo.git`, which sends the client to port 22 — the host's
sshd — and the failure looks like a key problem rather than a port problem.
Uncomment `GITEA__server__SSH_PORT` in `compose.yaml` (it tracks `SSH_PORT`,
so the two can't drift), or set the SSH port in the wizard.
- **Behind a reverse proxy, `ROOT_URL` is the setting that matters.** Get it
wrong and the UI works fine while every clone URL, webhook and OAuth redirect
points somewhere else. See [reverse proxy](../docs/reverse-proxy.md), which
already lists Gitea as a service needing this.
- **The install wizard runs once.** After it writes `app.ini`, that file wins —
changing an environment variable afterwards does *not* change a setting the
wizard already recorded. Edit `gitea/gitea/conf/app.ini` (or delete it and
reinstall) if you need to change course.
- **First registered user becomes admin** if you didn't create an admin in the
wizard. Disable registration once your accounts exist:
`GITEA__service__DISABLE_REGISTRATION=true`.
- **Back up `./gitea`.** It holds every repository, the SQLite database, and
`conf/app.ini`. There is nothing else to back up, and nothing in it
regenerates.
- **Mirror pushes need credentials that outlive your session.** If you push from
here to GitHub, that's an access token or deploy key stored in Gitea, not your
laptop's agent.
## Exposing it
Publishes plain HTTP; TLS belongs at the proxy.
- [Reverse proxy with Caddy](../docs/reverse-proxy.md) — **needs `ROOT_URL`**,
see above. HTTP clone and the web UI both go through it.
- [Cloudflare Tunnel](../cloudflare-tunnel/) — works for the web UI and HTTP
clone, but **SSH clone won't pass** and the ~100 MB body cap will meet you at
the first large push. Fine for browsing, awkward as your only route.
- [Single sign-on with Authentik](../docs/authentik-sso.md) — Gitea speaks OIDC
natively, so use that rather than forward auth, which would break `git` over
HTTP as surely as it breaks any other non-browser client.
## Links
- Install with Docker: <https://docs.gitea.com/installation/install-with-docker/>
- Configuration cheat sheet: <https://docs.gitea.com/administration/config-cheat-sheet>
- Image: <https://hub.docker.com/r/gitea/gitea>
+40
View File
@@ -0,0 +1,40 @@
networks:
gitea:
external: false
services:
server:
# Pinned. Gitea runs schema migrations between releases, so an unattended
# pull picking up a new minor is not something to meet by surprise.
image: docker.gitea.com/gitea:1.27.3
container_name: gitea
restart: unless-stopped
networks:
- gitea
environment:
# Gitea's `git` user is made to match the owner of the bind-mounted data
# directory. Defaults so a fresh clone runs with no .env at all -- bare
# ${PUID} would interpolate to an empty string and start anyway.
- USER_UID=${PUID:-1000}
- USER_GID=${PGID:-1000}
# Gitea knows it listens on 22 inside the container and has no idea it is
# published on 222, so the SSH clone URLs it hands out send clients to the
# host's own sshd. Uncomment to match the published port below.
#- GITEA__server__SSH_PORT=${SSH_PORT:-222}
# Set this once you're behind a proxy, or every clone URL, webhook and
# OAuth redirect points at the wrong host.
#- GITEA__server__ROOT_URL=https://git.example.com/
volumes:
# Every repository, the SQLite database and conf/app.ini. Back this up --
# nothing in it regenerates.
- ${DATA_PATH:-./data}:/data
# Timezone comes from the host rather than a TZ variable.
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "${PORT:-3000}:3000"
# 22 on the host belongs to the host's own sshd, hence 222.
- "${SSH_PORT:-222}:22"