Zero-click OIDC with Tailscale

#projects (1)#self-hosted (1)#tailscale (1)#auth (1)

Protect your services using Tailscale magic.

I like self-hosted stuff. I don’t particularly enjoy self-hosting things that require OpenID Connect though. Going through the whole process of setting up an OpenID Connect provider, and having to click through their confusing UI (I am looking at you, Authentik) to set up a separate client for each app I want to deploy is always a hassle. So I did something about it.

## Context

I went through several iterations of my home server. At the time of writing this, I have k3s running on a Raspberry Pi CM4 with Flux for GitOps and Tailscale Kubernetes Operator for ingress.

The way that the Tailscale Kubernetes Operator works is by spinning up a Pod for each Ingress you create that runs Tailscale Serve or Tailscale Funnel, depending on whether you want that ingress to be accessible outside of your Tailnet or not. I find this much more pleasant than my previous setup, which used ingress-nginx and IP whitelists when I wanted certain apps to be exclusively accessible locally.

While migrating away from said previous setup, I tried avoiding migrating my Authentik setup for as long as possible, as I didn’t want to bother. When I could no longer avoid it any longer, I thought I’d come up with an alternative.

I happen to know that Tailscale Serve (and by extension, Tailscale Funnel) sets special identity headers on every request. Here’s a rough diagram:

Mermaid Diagram

All requests you make to a Tailscale Serve/Funnel host come from your Tailscale IP address. In the case of Tailscale Serve, it is obvious why: you’re directly communicating with another device in your Tailnet, hence you’re using the Tailscale network device to perform said request.

With Tailscale Funnel, it is slightly less obvious, as the entire point of Funnel is to expose services from your Tailnet to the wider internet, i.e. you’re supposed to be communicating with some public relay server. This is true for devices outside your Tailnet, but Tailscale is smart enough to skip the relay server completely if you’re in the same Tailnet thanks to MagicDNS (which is also where I got the name for my project). In other words, if you’re connected to Tailscale, Tailscale Serve and Tailscale Funnel become equivalent.

For example, here is the A record for my Miniflux instance when I disconnect from Tailscale:

$ drill rss.qilin-qilin.ts.net
;; ANSWER SECTION:
rss.qilin-qilin.ts.net.	300	IN	A	185.40.234.37

;; Query time: 90 msec
;; SERVER: 192.168.100.1  

And here’s when I connect to Tailscale:

$ drill rss.qilin-qilin.ts.net
;; ANSWER SECTION:
rss.qilin-qilin.ts.net.	600	IN	A	100.120.23.78

;; Query time: 0 msec
;; SERVER: 100.100.100.100  

As you can see, in the first example, rss.qilin-qilin.ts.net resolves to the public IP address of some Tailscale relay. In the second example, the IP returned is private — one that belongs to my Tailnet. The DNS server used is 100.100.100.100, which is MagicDNS. Very cool if you ask me.

So why does any of this matter? Well, it allows you to determine whether a request is coming from within your Tailnet or not, and to identify which user of your Tailnet it came from. I’ve previously used this to authenticate requests for some toy projects of mine, so I thought: what if I could do the same for OpenID Connect? The result would be zero-interaction authentication that automagically logs you in if you’re connected to your Tailnet.

## The solution

Magicauth is an OpenID Connect server that authenticates you based on your existing Tailscale identity. It is a single Go binary that implements the bare minimum features required to satisfy most self-hosted apps that support OIDC. It has no infrastructural dependencies besides Tailscale, i.e. no databases, no key-value stores, etc. All it does is look at the Tailscale-User-Login header to determine whether you can log in or not, simple as that.

Magicauth is a thin wrapper over Ory’s Fosite OpenID Connect SDK. Fosite made this project a breeze to implement — the entire project is mostly official example code adapted to my use case. Using Fosite also ensured that I didn’t have to bother with the intricacies of the OIDC spec.

It is configurable via your favorite formats (YAML, TOML, JSON). It is also configurable via environment variables if config files are not your thing. And if neither of these does it for you, you can also create OAuth clients via Kubernetes resources. You can totally mix and match the three as well, I don’t judge.

If any of this sounds interesting, you can learn more about it here.