Single Sign-on in Caddy Server Using only the Caddyfile and Basic Authentication
As mentioned, I've started self-hosting a lot of services, and in front of them all I have a reverse proxy using the Caddy Server. As always, with all web services, authentication is one of the bigger pain points. Typically, every service implements their own authentication mechanism, which becomes tiresome when you have to log in separately to half-a-dozen different websites. There are many solutions to this problem, such as Authelia, that integrate with your reverse proxy and your upstream web services to provide single sign-on. There are even some third-party modules for Caddy that will implement an Auth Portal for your services.
But that's a lot of work.
What if we could just use a vanilla Caddy server with no extra modules, along with the standard Caddyfile, to implement some sort of proxy authentication or single sign-on? Well, let's try.
Caddy supports basic auth, so we could start by putting basic auth in front of all of the services. If we use the same user/password, that's only one thing to remember, but we still have to authenticate separately for each service.
one.example.com {
basicauth / {
user hashed-password
}
reverse\_proxy service1:8001
}
two.example.com {
basicauth / {
user hashed-password
}
reverse\_proxy service2:8002
}
three.example.com {
basicauth / {
user hashed-password
}
reverse\_proxy service3:8003
}
...
Caddy also supports "snippets" , reusable chunks of configuration that you can "import" elsewhere, so let's clean up the basic auth a bit. This is basically the same as above, but now the user and password are only in one place and easier to manage.
(basic-auth) {
basicauth / {
user hashed-password
}
}
one.example.com {
import basic-auth
reverse\_proxy service1:8001
}
two.example.com {
import basic-auth
reverse\_proxy service2:8002
}
three.example.com {
import basic-auth
reverse\_proxy service3:8003
}
...
Ok, that's a little better, but not much as we still have to authenticate separately for each service. If only there were some way to log in once, and have Caddy recognize that one authentication for each service. Hmm...
(basic-auth) {
basicauth / {
user hashed-password
}
}
# a snippet to check if a cookie token is set. if not, store the current page as the referer and redirect to auth site
(proxy-auth) {
# if cookie not = some-token-nonsense
@no-auth {
not header\_regexp mycookie Cookie myid=<regex-to-match-id>
# https://github.com/caddyserver/caddy/issues/3916
}
# store current time, page and redirect to auth
route @no-auth {
header Set-Cookie "myreferer={scheme}://{host}{uri}; Domain=example.com; Path=/; Max-Age=30; HttpOnly; SameSite=Strict; Secure"
redir https://auth.example.com
}
}
# a pseudo site that only requires basic auth, sets cookie, and redirects back to original site
auth.example.com {
route / {
# require authentication
import basic-auth
# upon successful auth, set a client token
header Set-Cookie "myid=some-long-hopefully-random-string; Domain=example.com; Path=/; Max-Age=3600; HttpOnly; SameSite=Strict; Secure"
#delete the referer cookie
header +Set-Cookie "myreferer=null; Domain=example.com; Path=/; Expires=Thu, 25 Sep 1971 12:00:00 GMT; HttpOnly; SameSite=Strict; Secure"
# redirect back to the original site
redir {http.request.cookie.myreferer}
}
# fallback
respond "Hi."
}
one.example.com {
import proxy-auth
reverse\_proxy service1:8001
}
two.example.com {
import proxy-auth
reverse\_proxy service2:8002
}
three.example.com {
import proxy-auth
reverse\_proxy service3:8003
}
...
Nice! With this new configuration, you'll only need to authenticate once for all sub-domains protected by "proxy-auth". So what's happening here? Let's walk through it:
- When you first go to one of the sub-domains, say two.example.com, the "proxy-auth" import will be invoked, and it will check to see if the "myid" cookie is set to a certain value. I've used a header_regexp matcher because the string varies in my case, but you could hard code it and use just a header matcher.
- Since the "myid" cookie is not set, a new "myreferer" cookie will be set with the current URL you are trying to access, and you will be redirected to the "virtual" auth.example.com sub-domain. This "auth" sub-domain is "virtual" in the sense that there is no actual service; it only exists as Caddy configuration.
- Upon redirection to the auth.example.com sub-domain, you will be prompted for the basic authentical credentials of the "basic-auth" snippet. When you successfully authenticate, the "myreferer" cookie will be deleted, a new "myid" cookie will be set with your "token", and you will be redirected back to the original URL you were trying to access (the URL was stored in the "myreferer" cookie).
- Now, back at the original URL and sub-domain, the "proxy-auth" import will be invoked again, but this time, the "myid" cookie is set with the correct token, and should match the header_regexp, thus bypassing the "no-auth" route, and letting you access the upstream service.
- Subsequently navigating to any of the sub-domains protected with "proxy-auth" should check the "myid" cookie token, see that it matches, and let you in without having to authenticate again.
Clear as mud, right? It's not the most robust system in the world, or the most secure, but it works, it's simple, and it only requires a Caddy configuration file. No need for third-party authentication services.
Caveats.
- How secure is it? I wouldn't put my bank services behind it, but it's as secure as basic authentication. Someone could guess your "myid" Cookie value, and spoof that, but they could also guess your basic authentication credentials. So make both of them hard to guess. Like I said, I've made the token in the "myid" cookie on my setup quite long and somewhat random, and use a regular expression to match the value. Caddy doesn't support a lot of variables in snippets, or hashing in configuration, so it's difficult to be too secure, but I'd say it's moderately secure.
- It only works if all of your services are sub-domains on the same domain. The cookies use the bare domain (example.com), and thus are valid on any sub-domain of example.com. If you have a second domain, the cookies won't be sent, and the "proxy-auth" will never match.
- Adjust the cookie values to your use case. I set the cookie lifetime for "myid" to an hour, and the myreferer cookie lifetime to 30 seconds.
- You can still use the "basic-auth" snippet on other sub-domains, say for a service with an API.
And there it is, a poor-man's single sign-on (or proxy authentication) for Caddy services using only configuration. A further step would be to pass the authenticated user name on to the upstream services and configure them to use that for authorization, but that's for someone else to figure out.
Hope this is useful, and let me know if you find faults, improvements, or just have a general comment.