Authentication
Three modes, selected by APP_AUTH_MODE:
| Mode | Who authenticates | Needs a proxy |
|---|---|---|
dev | nobody: pick a name from a list | no |
authelia | a forward-auth proxy, believed through headers | yes |
oidc | the application itself, against an OpenID Connect provider | no |
authelia and oidc are both production modes and both work with Authelia - they differ in who does the talking. Pick authelia when a proxy already authenticates everything on that host, and oidc when you would rather the application signed people in itself.
Production: Authelia forward-auth
A reverse proxy authenticates the request against Authelia and passes the result on as headers:
Remote-User the username
Remote-Email the mail address
Remote-Name the display name
Remote-Groups comma-separated groupsThose headers are believed only when the request came from a configured proxy. This is the whole of the security of the arrangement. A header is just text: a request arriving from anywhere else claiming Remote-User: admin is anonymous, and there is a test that fires exactly that attack.
APP_TRUSTED_PROXIES takes addresses and CIDRs, IPv4 and IPv6, with the port stripped. A malformed entry is ignored rather than matched, so a typo narrows access instead of opening it. An installation in authelia mode that names no trusted proxy is refused at startup by config.fromMap.
What Authelia decides
Two things, and no more:
| Setting | Effect |
|---|---|
APP_GROUP_ACCESS | must be held to use the application at all; empty admits everyone Authelia lets through |
APP_GROUP_ADMIN | grants the installation-administrator role |
The role someone holds in a family comes from household_members. That is what lets one person be the master of their own family and a parent in another, which no group mapping could express.
No account is created from a header
Authelia may know someone this application does not. They are told so clearly; they do not get an account, and they certainly do not get a place in a family's books. Accounts are created by the family's master or by an administrator.
Production: OpenID Connect
APP_AUTH_MODE=oidc makes the application an OIDC client: no proxy, no headers, no APP_TRUSTED_PROXIES. It sends the reader to the provider, the provider sends them back with a code, and the application exchanges that code for the claims on a back channel it opens itself.
The authorization-code flow with a confidential client, PKCE (S256) on every attempt, state and nonce on every attempt:
/login the button
/oauth2/login mints state, nonce and a PKCE verifier, redirects to the provider
/oauth2/callback checks state, exchanges the code, reads the claims, signs inThe callback URL is APP_BASE_URL + /oauth2/callback - that is the value a provider wants under redirect_uris.
The two have to match character for character, and a provider that is unhappy says so in a way that names the parameter but not what it expected:
invalid_request- The 'redirect_uri' parameter does not match any of the OAuth 2.0 Client's pre-registered 'redirect_uris'.
The usual causes, in order of how often they are the answer:
- a different path -
/oauth2/oidc/callback(Authelia's own documentation example) against this application's/oauth2/callback; httpagainsthttps, or a host that differs by awww.;- a trailing slash on one side;
APP_BASE_URLpointing at the container (http://app:8080) rather than at the address people type.
fk doctor prints the exact value to register, and so does the log line the application writes at startup in oidc mode:
OIDC: issuer https://sso.example.com, client familienkonto,
redirect_uri https://familienkonto.example.com/oauth2/callbackIf the provider's registration cannot be changed, set APP_OIDC_REDIRECT_URL to the URI it already has. The application then tells the provider that URI and serves the callback at its path - so /oauth2/oidc/callback works as well as the default, and the setting is not a way to configure a 404.
| Setting | |
|---|---|
APP_OIDC_ISSUER | the issuer URL; everything else comes from its /.well-known/openid-configuration |
APP_OIDC_CLIENT_ID / APP_OIDC_CLIENT_SECRET | the registered client; the secret is sent with HTTP Basic |
APP_OIDC_REDIRECT_URL | usually empty - derived from APP_BASE_URL |
APP_OIDC_SCOPES | default openid profile email groups |
APP_OIDC_USERNAME_CLAIM | default preferred_username |
APP_OIDC_GROUPS_CLAIM | default groups |
APP_OIDC_CREATE_USERS | create the account on a first sign-in (default true) |
APP_SECRET is required as well: it signs the session and the short-lived cookie that carries state, nonce and the PKCE verifier while the reader is away at the provider. That cookie is why a restart, or a second replica answering the callback, does not lose somebody mid-login.
The Authelia side
This is the client entry Authelia wants - and unlike forward-auth, here it really is needed:
identity_providers:
oidc:
clients:
- client_id: familienkonto
client_name: Familienkonto
client_secret: '$pbkdf2-sha512$310000$...' # the hash; the app gets the plain secret
public: false
authorization_policy: one_factor
require_pkce: true
pkce_challenge_method: S256
redirect_uris:
- 'https://familienkonto.example.com/oauth2/callback'
scopes: [openid, profile, email, groups]
grant_types: [authorization_code]
response_types: [code]
token_endpoint_auth_method: client_secret_basicrequire_pkce: true is safe: the application always sends an S256 challenge.
What is checked, and what is not
The ID token's signature is not re-verified, and that is deliberate rather than missing. The token is not read out of the browser: it arrives on a TLS connection this process opened to the issuer's own token endpoint, authenticated with the client secret, in answer to a code minted seconds earlier - the case OpenID Connect Core §3.1.3.7 exempts. What is checked is everything an attacker could otherwise influence:
- the
statemust equal the one in our signed cookie, or the callback is not the answer to a request this application made; - the
issmust be the issuer from discovery; - the
audmust contain our client id; - the
expmust be in the future; - the
noncemust equal the one generated for this attempt; - the sign-in must have started less than ten minutes ago.
If you would rather have JWKS signature verification as well, that is a change to src/app/oidc.j and nothing else.
The first sign-in creates the account
Unlike forward-auth mode, oidc provisions: somebody the provider authenticates, who holds APP_GROUP_ACCESS, and who has no account here yet gets one on their first sign-in, with the display name and the mail address from the claims. They belong to no family - which family is a decision about a family's books, not about an identity - so they land on the page that says so, and a master or an administrator adds them.
oidc: mplx, name "Martin Maier", email "martin@example.at", groups: app_familienkonto
auth: created account mplx (Martin Maier) from the identity providerSet APP_OIDC_CREATE_USERS=false for an installation that would rather make every account by hand with fk user-add; the refusal is then "Für … ist in dieser Anwendung noch kein Zugang eingerichtet".
While provisioning is on, the administration stops offering to create accounts: Administration › Benutzer says where accounts come from instead of showing the form, and a hand-made POST is refused too. A username typed there is a guess at what the provider will send, and a wrong guess makes a second account beside the one the first sign-in creates.
What the administration is still for: giving somebody a family, correcting the display name and the address, locking an account, and granting or withdrawing administration. The name matters more than it looks - a provider that sends no name claim leaves the account called after its username, and nothing overwrites it afterwards, so it is corrected here or on the person's own Einstellungen page.
APP_GROUP_ACCESS is what decides who gets one, and it is checked before anything is written. With provisioning on and no access group configured, every account the provider authenticates would get one - the application says so at startup rather than letting that be discovered later:
AUTH: an account is created on first sign-in and APP_GROUP_ACCESS is empty -
everybody your identity provider authenticates gets one.On later sign-ins the provider fills in what is missing and changes nothing else: an account without an address adopts the one from the claims, an address somebody corrected in the application stays corrected, and the display name a family typed is never overwritten. The same rule the family page follows when it adds a person who already has an account.
APP_GROUP_ADMIN grants the administrator flag and never withdraws it, which is the rule forward-auth mode follows too. Withdrawing is done under Administration › Benutzer. Anything else would demote an --admin account on its very first sign-in, before any group existed at the provider.
Reading the log
Every sign-in writes one line with everything the next decision is made from, which is the fastest way to see what a provider is actually sending:
oidc: mplx, name "Martin Maier", email "martin@example.at", groups: app_familienkonto, app_familienadmingroups: (none) means the claim did not arrive: check that groups is in the client's scopes at the provider and in APP_OIDC_SCOPES, and that the provider's userinfo is not signed (Authelia: userinfo_signed_response_alg: 'none') - a signed userinfo document reads as empty here, and the groups would then have to come from the ID token alone.
Forward-auth has no callback URL
In authelia mode there is no client_id, no client secret, no token exchange and no redirect URI - the application never talks to Authelia at all. It reads four headers from a proxy it trusts, and that is the entire protocol. (In oidc mode there is a callback, and it is the section above.)
So an entry like this under identity_providers.oidc.clients configures nothing and can be deleted:
# NOT needed - the application is not an OIDC client
- client_id: familienkonto
client_secret: '$pbkdf2-sha512$310000$...'
redirect_uris:
- 'https://familienkonto.example.com/oauth2/oidc/callback'There is no route at that path, and a request to it gets the 404 page. What Authelia needs instead is an access_control rule for the domain (below); the proxy does the rest.
The only Authelia URL the application knows is APP_AUTHELIA_URL, and it is not a callback either: it is where the Anmeldeportal button points after signing out, so somebody can end the Authelia session as well as this one.
Forward-auth is the deliberate choice. It keeps every credential, every second factor and every session out of this application - there is no token to leak here, and no login form to attack - and it is what lets the same deployment sit behind Authelia, Authentik or a plain auth_request without knowing which.
Configuring Authelia and the proxy
Three pieces have to agree: Authelia protects the application, the proxy asks Authelia and forwards the answer, and this application trusts that proxy and nobody else.
1. Authelia
Nothing special is needed beyond an access rule and, if you want to use the groups, groups on the users. A minimal configuration.yml:
access_control:
default_policy: deny
rules:
- domain: familienkonto.example.com
policy: two_factor # one_factor is enough for a family
subject:
- "group:expenses" # everybody who may use it at all
session:
cookies:
- domain: example.com # the parent domain of both hosts
authelia_url: https://sso.example.comThe session cookie domain has to cover both hosts - the portal and the application - or the browser will not send the session to Authelia when the proxy asks.
Groups are ordinary Authelia groups, in users_database.yml or your LDAP:
users:
martin:
displayname: "Martin Maier"
email: martin@example.com
groups:
- expenses # APP_GROUP_ACCESS
- expenses-admins # APP_GROUP_ADMINFamily roles are not groups. expenses-admins administers the installation; who is a parent or a child in which family lives in household_members, because one person is regularly a master in their own family and a parent in another.
2. The proxy
The proxy calls Authelia for every request and copies four headers from the answer onto the upstream request. It must also delete any Remote-* header the client sent itself - otherwise anybody can claim to be anybody, and the trusted-proxy check cannot help, because the request really did arrive from the proxy.
Caddy:
familienkonto.example.com {
forward_auth sso.example.com {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Email Remote-Name Remote-Groups
}
reverse_proxy app:8080 {
header_up -Remote-User
header_up -Remote-Email
header_up -Remote-Name
header_up -Remote-Groups
}
}(forward_auth sets the four headers after those deletions, which is the order that makes this safe.)
nginx:
server {
server_name familienkonto.example.com;
# Whatever the client sent is not evidence.
proxy_set_header Remote-User "";
proxy_set_header Remote-Email "";
proxy_set_header Remote-Name "";
proxy_set_header Remote-Groups "";
location /authelia {
internal;
proxy_pass http://authelia:9091/api/authz/auth-request;
proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
proxy_set_header Content-Length "";
proxy_pass_request_body off;
}
location / {
auth_request /authelia;
auth_request_set $user $upstream_http_remote_user;
auth_request_set $email $upstream_http_remote_email;
auth_request_set $name $upstream_http_remote_name;
auth_request_set $groups $upstream_http_remote_groups;
proxy_set_header Remote-User $user;
proxy_set_header Remote-Email $email;
proxy_set_header Remote-Name $name;
proxy_set_header Remote-Groups $groups;
error_page 401 =302 https://sso.example.com/?rd=$scheme://$http_host$request_uri;
proxy_pass http://app:8080;
}
}Traefik (labels on the application container, with an Authelia middleware):
labels:
- "traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth"
- "traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Email,Remote-Name,Remote-Groups"
- "traefik.http.routers.familienkonto.middlewares=authelia@docker"3. This application
APP_AUTH_MODE=authelia
APP_TRUSTED_PROXIES=10.0.0.0/8,172.16.0.0/12 # where the proxy talks from
APP_AUTHELIA_URL=https://sso.example.com/ # only for the "sign in" link
APP_GROUP_ACCESS=expenses # empty admits everyone Authelia lets through
APP_GROUP_ADMIN=expenses-admins
APP_BASE_URL=https://familienkonto.example.comAPP_TRUSTED_PROXIES is the address the proxy connects from, as this application sees it - in Docker that is the container network, not the address of your router. fk doctor prints what the process is configured with; if every request comes out anonymous with "nicht von einem vertrauenswürdigen Proxy", that list is what is wrong.
4. Then create the accounts
Authelia knowing somebody does not give them an account here. The first one is made on the command line, the rest in the interface:
bin/fk user-add martin --name "Martin Maier" --adminThe username must be exactly what Authelia sends as Remote-User.
Development: passwordless login
APP_AUTH_MODE=dev offers a list of users to pick from. The choice travels in a cookie signed with APP_SECRET (HMAC-SHA256, compared constant-time), so it cannot be edited into somebody else - a test forges exactly that.
Guards:
auth.devUsersandauth.devLoginthrow inautheliamode, so the passwordless path cannot be reached on a production installation.- Forward-auth headers are ignored entirely in
devmode. auth.startupWarningreturns a blunt warning, printed by the server at startup and byfamilienkonto doctor.
Arriving without a session
Any page asked for without a session answers 303 to /login, whatever the mode. There is no "you are not signed in" page in between: it said the same thing as the login page it linked to, and under OIDC that made three screens - not signed in, then sign in here, then the provider - before anybody had typed anything.
The login page carries the reason when there is one worth carrying: a locked account, an unknown username, a request that did not come through the proxy. Simply having no session is not one - everybody starts there - so a first visit is the sign-in button and nothing else.
Session lifetime
authelia mode | Authelia's session decides. This application keeps no session of its own: it reads the forward-auth headers afresh on every request, so when Authelia stops vouching for somebody, the next click is anonymous. |
dev mode | The signed cookie is good for 24 hours (auth.SESSION_SECONDS). |
| Impersonation | One hour (auth.ACT_AS_SECONDS), whichever mode. |
The lifetime is inside the signature, not only in the cookie: mintSession stamps the moment it was made and readSession refuses a token older than the limit. A cookie maxAge is a request to the browser and nothing more - a token copied out of one would otherwise have been valid for ever, which is exactly the case a timeout is for. A stamp from the future is treated as expired rather than trusted: the only ways to get one are a clock that jumped or a token somebody has played with.
There is no idle timeout and no server-side session store: the token is self-contained, so signing out is a matter of clearing the cookie, and there is nothing to garbage-collect.
Identity
auth.identify(conn, cfg, request) returns an Identity. The Request struct carries only plain strings, so every rule is testable without starting a server.
auth.hasFamily- authenticated and placed in a family with a role. Someone authenticated who belongs to no family is a real state, and the interface says so rather than showing an empty ledger.auth.withHousehold- switch families. A household the person does not belong to leaves them where they were rather than turning them out of their own: a tampered id in a request should be ignored, not become a way to lock someone out.auth.mayViewChild- a child sees their own data; everyone else in the family sees every child.
In the web layer
Every handler works the same way: establish the identity, refuse what the role does not carry, then render. Refusals are never left to the interface omitting a link - the navigation hides what a role cannot use, and the handler refuses it.
CSRF
web.csrfToken mints a token and sets a cookie, so calling it twice in one request invalidates whatever the first call produced.
This application does not use it for a signed-in reader. Per-request tokens have a worse problem than per-form ones: the cookie is replaced on every page load, so a page left open in another tab carries a token that no longer matches. Pressing "Abmelden" there answered "Die Sitzung ist abgelaufen" while the session was alive, and left the reader with no way out.
csrfToken in web/app.j therefore derives the token from the account - an HMAC over the user id - so it is the same on every page for as long as the sign-in lasts, unguessable without APP_SECRET, and different per person. The id in the payload is not the secret part; the signature is. It is deliberately not the session cookie, which would put a HttpOnly value into the HTML.
Anonymous requests keep the per-request token: the login page has nothing to bind to, and one fixed token for every visitor could be used to forge the sign-in POST.
The request body can be read only once, and web.csrfCheck reads it looking for the field. A handler that also needs the other fields therefore reads the form itself and passes it to formCsrfOk. Multipart uploads carry their own check, because web.csrfCheck only reads the form field for an urlencoded body.
That "once per request" reaches further than the handler's own forms: the page chrome carries a form too. The sign-out button in the top bar is a POST, so views.page takes the request's token as a parameter and show passes it through - a chrome that minted its own would overwrite the cookie after the body had been rendered, and break every form on the page at once.
tests/login_test.j holds that invariant: it drives a real server and asserts that a page carries exactly one distinct token and that it matches the cookie the response set.
POST /household (switching family) and POST /import/preview (the CSV preview) were missing the check and now carry it - the preview because a multipart body carries its token as a part, so web.csrfCheck cannot find it and the handler has to compare it itself.
Acting as another user
An administrator can work through the interface as somebody else - the way to reproduce "my balance is wrong" without asking for a password. A managing parent can do the same for the children of their own family, from /family, because most installations have one administrator and every family has a managing parent. It is a second, separate cookie (expenses_actas), not a second session:
- the token carries both ids, the taker's and the target's, so a cookie lifted from one account is not usable by another;
- the token also carries the family the takeover is confined to, and for a managing parent that confinement is the whole of the safety:
withHouseholdrefuses to move a pinned identity, the family switcher is not rendered, and?household=does nothing. Without it, taking over a child of separated parents would have handed the taker the other family's books - the child sees them, so the takeover would too; - who may take whom over is decided on every request, not once when the cookie was handed out: an administrator may take over anybody, anywhere; a managing parent may take over an account that is a child in the pinned family, of which they are the master. An account that administers the installation is refused outright, because the way back up must never run through somebody else's login;
- an earlier rule demanded that the taker manage every family the target belongs to. It was safe, and it also meant that in a patchwork family - which this application models deliberately - neither parent could ever use the feature at all. Pinning replaced it;
- a takeover cannot be started while one is already running: a second cookie would overwrite the first and leave the administrator who started it with no way back;
- the resulting identity is entirely the target's - their family, their role, their permissions. Administration is not carried along, so while acting as a child the administration page is as closed as it is for that child;
- the identity keeps
realUserIdandrealDisplayName, which is what lets every page say who is really there and offer the way back; - it expires after an hour, and
POST /admin/releaseends it sooner. That route deliberately needs no rights of its own: the identity holding the cookie is the impersonated one, and whoever holds it must always be able to put it down. It sends the reader back where the takeover started - the administration for an administrator,/familyfor a managing parent, who would only be refused by the other one.
src/app/auth.j decides all of this in actingAs and mayActAsMember; src/app/auth_test.j, tests/administration_test.j and tests/family_test.j cover the refusals, which are the part worth testing.
Signing out
POST /logout only, and it checks the token. There is deliberately no GET route: a URL that ends a session can be fired by anything that makes a browser fetch it, and being signed out by someone else's image tag is a nuisance no reader can explain. The button is rendered on every page a signed-in reader can reach, including the 403 and 404 pages, so a wrong-account sign-in is always one click from being undone.