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.
I really like the idea and would love for it to work since a whole LDAP setup is borderline torture.
Unfortunately though there is a loophole which makes this not very helpful. If the just cancels the basic auth dialog the myid cookie gets set nonetheless, thereby bypassing the system and gaining full access. I stumbled upon this whilst playing around with your solution.
If true, this would be bad, but I can’t reproduce this. Canceling the auth dialog doesn’t set myid on my system. I’ve tested on Chrome, Firefox and Safari and I’m not seeing this behavior. Can you provide any more detail? Maybe post your Caddyfile, or the relevant bits?
I am testing on Firefox 89.0 on Voidlinux.
Seems like I screwed something up then.
This is my Caddyfile:
(basic-auth) {
basicauth / {
user 123456
}
}
(proxy-auth) {
@no-auth {
not header_regexp mycookie Cookie myid=asdf
}
route @no-auth {
header Set-Cookie “myreferer={scheme}://{host}{uri}; Domain=example.com; Path=/; Max-Age=30; HttpOnly; SameSite=Lax”
redir http://auth.example.com
}
}
auth.example.com:80 {
route {
import basic-auth
header Set-Cookie “myid=asdf; Domain=example.com; Path=/; Max-Age=3600; HttpOnly; SameSite=Lax”
header +Set-Cookie “myreferer=null; Domain=example.com; Path=/; Expires=Thu, 25 Sep 1971 12:00:00 GMT; HttpOnly; SameSite=Lax”
redir {http.request.cookie.myreferer}
}
}
one.example.com:80 {
reverse_proxy one:8080
}
two.example.com:80 {
reverse_proxy two:8080
}
Just for posterity, Tristan was correct about the bug, which he helped me fix. Loophole should be closed now, and I’ve updated the code in the post.
Hi
just stumbled upon this which i find very useful – yet i do not seem to be able to re-direct from the auth.domain.com page when successfully logging in. Has the declaration {http.request.cookie.myreferer} changed?
Thanks
Hi Josh
Conscious I commented yesterday, i just wanted to follow up. I originally did not see that the string I randomly created had special characters and broke your code. It is now working fine. Thanks a lot for that excellent piece of code.
Best
M
Hi Josh,
Not sure if I’m implementing this right but the Caddy v2 is challenging the auth.domain.com site and coming up empty for establishing a secure site. Is there a TLS skip verify command that I’m missing, or something similar that allows the pseudo site to be created?
Thank you,
Dan
Hi, Dan. If I’m understanding your issue correctly, you do need to have SSL set up on your subdomain, or change the redirection location to http. I proxy all of my sub-domains (including “auth.domain.com”) through Cloudflare and have a self-signed cert.
If you don’t have that sub-domain set up with SSL, you’ll need to do that, or maybe change that line to not use https (but I’d only do that if you never left an internal network:
redir http://auth.example.com
Hopefully this helps.
HI dv,
I have been trying to use the configuration that you have provided, but I don’t undersant why it gives me the error “To many redirects”. I’m new into Caddy, so I don’t really understand all the code you have provided. I give you my code, so maybe you can help me.
(basic-auth) {
basicauth / {
my-user my-password-hashed
}
}
# 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=
# 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 {
tls internal
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.”
}
dashboard.example.com {
import proxy-auth
#basicauth * {
#user my-password-hashed
#}
tls internal
# Set this path to your site’s directory.
root * /home/test/
# Enable the static file server.
file_server
# Another common task is to set up a reverse proxy:
# reverse_proxy localhost:8080
# Or serve a PHP site through php-fpm:
# php_fastcgi localhost:9000
}
# Refer to the Caddy docs for more information:
# https://caddyserver.com/docs/caddyfile
Thanks
Hey, Marc.
A couple things:
– Make sure anywhere the config refers to “example.com” is changed to your actual domain
– The regex for myid on line 11 needs to match whatever you set myid equal to on line 29
Hi DV,
I feel like I’m missing some caddy directive and should have the auth site in a separate section- by any chance DV do you have an example how you used your auth site to get Caddy2 ssl. I don’t use cloudflare and don’t know even if I self cert how to use the auth section correctly? It keeps timing out on challenges with letsencrypt and zerossl.
Thank you,
Dan
Okay, DV – I’ve since got the site up and running with auth now directing to login page securely.
I’m trying to use the myID as the username and the hashed password (both hashed version and prior to hash version) as the password, but getting no where with the login screen – any advice?
Thank you,
Dan
Hi, Dan. I sent you an email the other day. If you share your config with me I might be able to offer more help.
Hi DV,
I’ve sent my redacted config, have you had a chance to look at it? I sure would appreciate the assistance!
Thank you,
Dan
Great stuff and working out of the box for me :-)
One question, though: I’ve yet to find a way to clear cookies in a way that I’m logged out again (eventually for a logout button). Just removing the myid cookie on any of the “real” domains doesn’t help and I can’t specifically delete it from the proxy-auth domain, can I?
Only solution for now seems to be to clear the entire browser history/data…
Hmm, I haven’t run into that, maybe because I’m always in a private browser and it just removes all cookies when I exit? But the myid cookie by default has a max-age of 1hr, so it should expire, no?
Is it be possible to modify this to only ask for basic auth when requests are not coming from local networks, so I’m not asked to authenticate when I’m inside my home network?
Jose,
Maybe try adding an IP check? something like (adjust for your private IP range):
@no-auth {
not {
remote_ip 192.168.0.0/16
header_regexp mycookie Cookie myid=
}
}
Thanks, I initially had solved it by having a similar solution, but on the (basic-auth) block:
(basic-auth) {
@local_networks {
not remote_ip 192.168.0.0/24
}
basicauth @local_networks {
myuser mypasshash
}
}
Your solution works better because by having the condition on the proxy-auth block instead, the redirection to the auth.example.com virtual host is skipped altogether.
Regarding the logout/timeout:
It seems it doesn’t forget the cookie after those 3600secs (I had kept that browser tab idle for more than an hour then did a reload, no password dialog). Also, I deleted all cookies for my test domain – still it let me in without basic auth – which I don’t understand at all. (yes, private session behaves differently) This was on Firefox.
So would you confirm that with this approach a Logout button is not possible?
Thank you for the great idea!!!
Everything works fine, but with one small addition.
Without this add-on, when you directly access AUTH.DOMAIN.TLD website, multiple round-robin redirects begin. Because there is no redirect cookie in this case.
Checking for cookies with the redirect field solves the problem.
Thanks again!
@isset_referer {
header Cookie *myreferer=*
}
redir @isset_referer {http.request.cookie.myreferer}
So I’m stuck and I’m hoping for some help! I’ve implemented this on my caddy and it works fine for my internal addresses. When I hit it with an external IP it just causes a redirect loop between auth.domain.us and either of the subdomains I’m trying to access. I’ve added the portion that A2 mentioned above but either I must not have implemented it correctly. Can anyone give me a suggestion on where to go from here?
(basic-auth) {
basicauth / {
user pass
}
}
# 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 {
remote_ip 192.168.0.0/16 10.10.0.0/16
header_regexp mycookie Cookie myid=string
# 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=domain.us; Path=/; Max-Age=30; HttpOnly; SameSite=Strict; Secure”
redir https://auth.domain.us
}
}
# a pseudo site that only requires basic auth, sets cookie, and redirects back to original site
auth.domain.us {
route / {
# require authentication
import basic-auth
# upon successful auth, set a client token
header Set-Cookie “myid=string; Domain=domain.us; Path=/; Max-Age=3600; HttpOnly; SameSite=Strict; Secure”
#delete the referer cookie
header +Set-Cookie “myreferer=null; Domain=domain.us; Path=/; Expires=Thu, 25 Sep 1971 12:00:00 GMT; HttpOnly; SameSite=Strict; Secure”
# redirect back to the original site
@isset_referer {
header Cookie *myreferer=*
}
redir @isset_referer {http.request.cookie.myreferer}
}
# fallback
respond “Forbidden 403”
}
{$MY_DOMAIN} {
reverse_proxy localhost:5005
}
www.{$MY_DOMAIN} {
root * /usr/share/caddy/www
file_server
}
router.{$MY_DOMAIN} {
import proxy-auth
reverse_proxy 192.168.99.1:444
}
adguard.{$MY_DOMAIN} {
import proxy-auth
reverse_proxy 192.168.99.1:9999
}
Hi, Josh. A couple of things:
1. Is $MY_DOMAIN == “domain.us”? The auth domain needs to be the same as the domains you’re trying to protect, since it is setting a cookie and can only do that for the same domain.
2. The “not” matcher ANDs the two, so they both have to be true to match. Is “string” in the header_regexp matcher actually matching?
I’ve updated my proxy-auth a little bit since this post, and mine looks like this now (using handlers and not using regexp since the latest version of caddy can match cookie strings):
Apologies for the late reply, the updated proxy-auth code you provided fixed things for me. I’m good to go now. Thank you!