« Back to home

Securing all your self-hosted apps with Single Sign-on

There are multiple approaches to how best secure your self-hosted apps as well as multiple ways to reverse proxy your self-hosted apps but today I will focus on how I use Caddy, http.login plugin, and the http.jwt plugin to secure my apps.

I have a number of self-hosted apps running on my home network and in the cloud and each app likes to implement it's own auth. Some support picking between Basic Auth and a login page while some don't support any authentication at all. For years I've relied on the individual pieces of software to manage themselves using their built-in auth (SyncThing, ZoneMinder, Tautulli, etc). For things that didn't implement auth (DarkStat, NetData, Calibre, etc) I would add a directive to my Caddyfile to implement basic auth.

my-sub-domain.mydomain.com {
   basicauth / user pass
   proxy / IPADDRESS:PORT {
       transparent
       websocket
    }
    gzip
    tls [email protected]
}

Overall it seemed like a decent system. I was really bad about reusing passwords between these services because A) I didn't think I'd be a big target and B) Laziness. Entering a username/password for basicauth on iOS used to be a PITA and while it has gotten better (now that it will suggest a user/pass for you) I have found I can more reliably/more quickly enter my U/P before I can: Click correct login, wait for FaceID to launch it's UI, wait for FaceID to scan my face, wait for page to shift back (entire page re-flows when keyboard hides to show FaceID UI), then click the "Login" button.

Obviously this is a terrible practice and it's bothered me for coming up on years now. I know better, at a bare minimum I should have been using a wide array of U/P's but due to iOS and what I'll called "shared base host name failings in PW managers" I didn't (A lot of autofill solutions think service1.mydomain.com and service2.mydomain.com must use the same U/P 🙄).

For at least a couple months now I've been kicking around the idea of having a single point of truth for logins. One place I could change my U/P for all my services and ideally a way to login to ServiceA and be able to access ServiceB without logging in again. I had read a number of tutorials/guides/blog posts on doing bits and pieces of what I wanted but I never put it all together. Finally I bit the bullet and to my surprised it was way easier that I had anticipated.

I use Caddy for my reverse proxy (as of this going live I'm using v1 still but I will switch to v2 once released and the needed plugins are ported). Even though I was intimately familiar with Nginx due to using it for work/other projects I picked Caddy for my personal needs mainly due to it's stupid-easy TLS support. I am aware that today there exists a number of solutions for auto-generating Let's Encrypt certs in Nginx but when I picked Caddy those solutions were hacky at best (especially when doing all this in docker). In Caddy I can reverse proxy an internal app in just a few lines of code as seen below:

my-sub-domain.mydomain.com {
   proxy / IPADDRESS:PORT {
       transparent
       websocket
    }
    gzip
    tls [email protected]
}

That's it, when Caddy starts up it will automatically do the ACME song and dance to get a Let's Encrypt cert for my-sub-domain.mydomain.com and auto-renew it without me having to do a thing. In the example above you can see how easily it is to add basicauth and a lot of Caddy feature are that easy to use which made Caddy an easy choice for me.

For this blog post I'm just going to implement a basic-auth-like setup but with SSO across all your services. You can easily swap out the username/password pair config to use any of the following authentication methods:

* Htpasswd
* OSIAM
* Simple (user/password pairs by configuration) <- What we are going to use
* Httpupstream
* OAuth2
  * GitHub login
  * Google login
  * Bitbucket login
  * Facebook login
  * Gitlab login

I am going to assume that you, like me, are using a single Caddyfile for all your domains like me. Here is, in its entirety, what your Caddyfile will need to contain:

(secure) {
  jwt {
    path /
    redirect https://auth.mydomain.com/login?backTo=https://{host}{uri}
    # More on these `except`'s below
    except /abc
    except /xyy
  }
}

auth.mydomain.com {
    login {
      # Here is your U/P but we can replace
      # this line with a different auth method
      # (like using Google login) if we want
      simple myusername=mypassword 
      jwt_expiry 24h
      redirect_check_referer false
      # 1 line per host "service1.mydomain.com",
      # this is needed to redirect back after login
      redirect_host_file /root/.caddy/hosts 
      cookie_expiry 2400h
      # Important if your services are all on their own subdomains
      cookie_domain mydomain.com 
    }
}


service1.mydomain.com {
    import secure
    proxy / IPADDRESS:PORT {
      transparent
      websocket
    }
    gzip
    tls [email protected]
}

service2.mydomain.com {
    import secure
    proxy / IPADDRESS:PORT {
      transparent
      websocket
    }
    gzip
    tls [email protected]
}

Let's break this down starting with the (secure) block at the top. If you are unfamiliar with Caddy's syntax (as I was when I embarked on this journey) then you might not know how to have reusable blocks of code in your config. The (secure) block can be import secure'd into any other domain definition we want so that we don't have to keep copy/pasting the same code block and we keep that config in one place for easy editing.

Inside the (secure) block I am using the http.jwt (docs) plugin for Caddy. Very simply it secures the entire domain path / and then tells the Caddy where to redirect you if it doesn't find a valid JWT:

    redirect https://auth.mydomain.com/login?backTo=https://{host}{uri}

I am pointing Caddy over to my auth domain and telling it to redirect me backTo the service I came from once I am authenticated.

Moving on to my auth.mydomain.com block and most of this should be somewhat self-explanatory. We are using the http.login (docs) plugin using it's simple authentication method. Again this is just for the purposes of this blog post, in practice I will probably keep using Google's OAuth but I want to make this.... simple.... to setup as a POC.

Then we just need to import secure in our two services and we are good to go. Really, that's it. Once you reload Caddy and try to visit service1.mydomain.com you will get bounced over to auth.mydomain.com and shown a login screen:

If you take a look at the current URL you will see that will redirect you back to service1 once you login https://auth.mydomain.com/login?backTo=https://service1.mydomain.com. Now we just need to login with the username and password your provided in your Caddyfile simple myusername=mypassword and we should be redirected back to service1 with a fresh new jwt_token cookie.

Now, depending on the apps you are hosting you might find there are routes that don't need to be authenticated via this method. Maybe they are public routes or maybe they have their own authentication method (like an API token). This is where the except /abc comes in. You will need to determine which paths need to be ignored for auth and add them for all your services to the top (secure) block. I wish there was a way to "extend" the http.jwt plugin on a per-host basis but I was unable to find a way and I opted for whitelisting for all domains in the (secure) block rather than copy/pasting the jwt block into each service I wanted to secure.

In practice the endpoints you whitelist either won't be used by your other services or if they are used they are probably protected via an auth method. What I mean by that is if you have to whitelist except /api then any other service that might use /api as a path is probably using an API token as well. This is not a 100% across the board rule but I did my research on every service I have and it was the case for me. At worst you can copy/paste the jwt block into a service if you need to limit the except's. You may end up reverse-proxying some services that don't need to be secured at all (static file host, Plex, other services that use their own auth that you don't want to replace) for these I just leave out the import secure directive.

And that's it, just by adding 2 blocks to your Caddyfile and importing the secure block on the sites you want to protect you have SSO for all your services! If I had known it was this easy I would have do it years ago.