SealSkin

A proxy that signs users in

Forward-auth in front of SealSkin, where the proxy's header names the user, the check that the proxy removes a visitor's own header, and what to let through.

Authelia, Authentik, and others guard a site through the proxy: the proxy asks them about each request (forward-auth), sends a visitor with no sign-in to their page, and adds headers that name the user to what it passes on. SealSkin takes the user from those headers.

SEALSKIN_TRUSTED_PROXIES=172.20.0.2
SEALSKIN_PROXY_AUTH_USER_HEADER=Remote-User
SEALSKIN_PROXY_AUTH_GROUPS_HEADER=Remote-Groups
SEALSKIN_SSO_ADMIN_GROUP=admins
ProviderUser headerGroups header
AutheliaRemote-UserRemote-Groups (comma-separated)
AuthentikX-authentik-usernameX-authentik-groups (separated by |)
TinyauthRemote-UserRemote-Groups (empty for its own users)
oauth2-proxyX-Auth-Request-Preferred-UsernameX-Auth-Request-Groups

Name only headers the proxy sets for SealSkin's site. The groups header has no default: a header the proxy does not set is one a signed-in visitor can write, and with it their own groups.

The web app opens signed in, with no sign-in page of its own. A name with no SealSkin user gets one at its first request, members of SEALSKIN_SSO_ADMIN_GROUP are administrators, and groups map as for every web sign-in. The header settings can also be written in the dashboard under Sign In; set the administrator group in the environment for the first start, since the dashboard opens to administrators.

Root's application key still signs in, once the browser is past the proxy: open https://sealskin.example.com/#root. That is also how administrators are appointed where the provider names no groups: as root, give a user the administrator switch. Signing out as root returns to the proxy's user.

When the proxy's sign-in runs out under an open web app, the web app reloads, and the proxy sends the browser to its sign-in page.

The headers are believed only from a proxy that sets them

A header is something any visitor can send. The proxy's forward-auth replaces the ones it manages with the provider's answer, or removes them. Two mistakes at the proxy undo that, and each is a line or two of its configuration:

  • The forward-auth is left out of SealSkin's site. A visitor's own header passes, and anyone who sends Remote-User: <an administrator> is that administrator.
  • The proxy sets the user header and not the groups header SealSkin was told to read. A signed-in user who sends Remote-Groups: admins is in that group.

So SealSkin does not take the proxy on trust. The headers count only when:

  • the connection comes from an address in SEALSKIN_TRUSTED_PROXIES;
  • that address is not a session's, since sessions share the server's network;
  • the check passes. The server sends its own public address, SEALSKIN_PUBLIC_URL, requests whose sign-in headers hold a made-up value, and looks at what arrives: one with no sign-in, and one with the cookies of the request it is about to believe, which are the proxy's own sign-in. The web app sends the same from the browser of every user the proxy signs in.
The check findsMeaningHeader sign-ins
guardedThe proxy answered with its sign-in page or a refusal, or passed the requests on with the made-up values gone.Accepted
openA made-up value arrived: the proxy does not set that header for this site.Refused
unreachableThe server could not reach its public address.Refused

The dashboard shows the state under Sign In, with a Test button that runs the check again, and the server log names the header at every change. A passing check is repeated every minute while the web app is in use; a failing one is tried again after 15 seconds, and a made-up value that arrived from a browser holds for five minutes or until Test finds it gone.

A server that cannot reach its public address, behind a router that does not turn its own address around, asks the proxy directly instead when SEALSKIN_TRUSTED_PROXIES names it by a single address: under the public name, on the public port and on 443. Where neither route exists, SEALSKIN_PROXY_AUTH_UNCHECKED=true accepts the headers without the check. Set it only after confirming by hand that forged headers do not get in, with no sign-in and with the proxy's cookie of a signed-in browser:

curl -i -H 'Remote-User: admin' -H 'Remote-Groups: admins' https://sealskin.example.com/api/auth/proxy
curl -i -H 'Remote-User: admin' -H 'Remote-Groups: admins' -b 'authelia_session=...' https://sealskin.example.com/api/auth/proxy

A proxy that sets the headers answers the first with a redirect or a 401, or with empty "user" and "groups", and the second with the user and groups of the cookie. "user":"admin" or "groups":"admins" in either answer, for a visitor who is neither, is an open one.

Which names to guard

Guard the web app's name. With session isolation there are session names too, and for those there is a choice:

  • Left out of forward-auth. A session answers nobody without the token it was launched with, which lives in a cookie of that one name, so the names are not open by being unguarded. This is the simpler setup, and the one where guests can join a collaboration room from a link.
  • Guarded too. It works where the provider's cookie covers the session domain, as an Authelia cookie for example.com covers <session id>.example.com. Set SEALSKIN_SESSION_DOMAIN: the web app finds the session names by asking one for an answer with no credentials, which a guarded name refuses, and takes the named domain as it is when none answers. Room guests then need an account at the provider.

The companion apps

The browser extension and the mobile app sign in with an application key, with no browser session at the provider and no cookie of its, so a forward-auth that guards the whole site sends them to its sign-in page and they never reach SealSkin. For them the proxy has to pass three raw paths straight to the node, with no sign-in of its own:

PathWhat it isWho authenticates it
/companion/The apps' lane: the pages they frame and the API they call, the same as the web app's under one prefix. The web sign-in and the key-file handshake routes answer 404 there.SealSkin, by the application key alone; a cookie or a proxy header counts for nothing there.
/<session id>/, a UUIDThe sessions the apps open in a browser tab or a Custom Tab, and the collaboration rooms (/room/<id>), whose guests arrive by invite link with no account at the provider. A room page loads its assets under /companion/.The session's own cookie, set by the one-time token of its launch, or the room's token.
/public/Public share links, where users share files with people outside.The share's password, where one was set.

All of this is optional. A deployment that wants the web app alone leaves the paths out: the apps are then refused at the proxy's sign-in page, and turning Allow Application Keys off for the users or groups, or for everyone, says so in the dashboard too. Where the paths are passed, nothing of the proxy's sign-in is lost: a request under /companion/ with a made-up key is refused by SealSkin, a session answers nobody without the token it was launched with, and the check above still finds the sign-in headers guarded, since it sends none of these paths. Every proxy page shows the rule in its own syntax: Authelia, Authentik, SWAG, Traefik, Caddy, Nginx Proxy Manager, and oauth2-proxy. A proxy that only terminates TLS passes everything already.

What to let through

  • Public share links are /public/... on the web app's name. Let that path through without a sign-in where users share files with people outside; the provider pages show the rule. A request let through this way reaches SealSkin without the sign-in headers, which the proxies here remove from it.
  • The companion apps pass with an application key where the proxy leaves their raw paths unguarded, as above. The key-file clients cannot pass a proxy's sign-in: use application keys, or turn the key files off with SEALSKIN_LEGACY_AUTH=false.

Signing out

The sign-in is the proxy's, so SealSkin cannot end it. Set SEALSKIN_PROXY_AUTH_LOGOUT_URL to the page that does, and Sign out opens it; left empty, the web app shows no sign-out.

ProviderLogout URL
Autheliahttps://auth.example.com/logout
Authentikhttps://sealskin.example.com/outpost.goauthentik.io/sign_out
Tinyauthhttps://tinyauth.example.com/logout
oauth2-proxyhttps://sealskin.example.com/oauth2/sign_out