Why I want to use Tailscale

Tailscale is a VPN service that makes it easy to create secure networks and connections between devices. The main reason I want to use Tailscale is that I need a secure and private way to access services that should not be publicly accessible. Tailscale allows me to create a virtual private network (VPN) where my devices and services can communicate securely over the internet, without exposing them to the public.

Why I want to use ZITADEL as an OIDC provider

ZITADEL is an IdP that provides identity and access management. It is already my main IdP for other services, so it makes sense to use it for Tailscale as well. By using ZITADEL as my OIDC provider, I can reuse my existing user accounts and authentication mechanisms, which simplifies the overall management of my Tailscale network.

Why I created this guide

Even though setting up Tailscale with an OIDC provider is quite well documented, I still faced some difficulties during the configuration process. Therefore, I decided to create this guide to help others who might face similar challenges when setting up Tailscale with ZITADEL as their OIDC provider.

First, some background on how this works: Tailscale loads the OIDC configuration using WebFinger, based on the domain of the email address you use to log in. For example, if I want to use my own email account name@example.com to authenticate with Tailscale, Tailscale will look for the OIDC configuration at:

https://example.com/.well-known/webfinger

This endpoint must return the following JSON:

1
2
3
4
5
6
7
8
9
{
  "subject": "acct:name@example.com",
  "links": [
    {
      "rel": "http://openid.net/specs/connect/1.0/issuer",
      "href": "https://account.example.com"
    }
  ]
}

The href field points to the issuer URL of the OIDC provider. You can find this value in the OpenID configuration endpoint:

https://account.example.com/.well-known/openid-configuration

Also note that the rel field is a fixed value defined by the OpenID Connect specification.

Using the WebFinger of my main domain

Since I already have a website running at example.com, but did not want to add the WebFinger file to my main website, I tried to set up a dedicated WebFinger server. However, there are only a few WebFinger server implementations available, and none of them worked well for my use case. Most of them are no longer actively maintained, or cannot easily be deployed on Kubernetes.

For example, I tried https://github.com/peeley/carpal, which seemed to be one of the more popular and maintained projects. However, it defines the WebFinger subject (such as acct:name@example.com) directly in the filename. Kubernetes does not allow mounting files with special characters like : and @, which made this approach unusable for me. I also did not want to introduce an external database just for running a WebFinger server.

In the end, I decided to use a static Caddy server and simply serve a static JSON file.

Caddyfile - ConfigMap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: ConfigMap
metadata:
  name: caddy-config
data:
  Caddyfile: |
    :{{ .Values.service.port | default 8080 }} {
      root * /srv
      try_files {path} /.well-known/webfinger
      file_server

      header {
        Content-Type application/jrd+json
      }
    }

webfinger.json - ConfigMap

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
apiVersion: v1
kind: ConfigMap
metadata:
  name: webfinger-resource
data:
  webfinger.json: |
    {
      "subject": "acct:name@example.com",
      "links": [
        {
          "rel": "http://openid.net/specs/connect/1.0/issuer",
          "href": "https://account.example.com"
        }
      ]
    }

Deployment

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
apiVersion: apps/v1
kind: Deployment
metadata:
  name: webfinger
spec:
  replicas: 1
  selector:
    matchLabels:
      app: webfinger
  template:
    metadata:
      labels:
        app: webfinger
    spec:
      containers:
        - name: caddy
          image: caddy:alpine
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 15m
              memory: 10Mi
            limits:
              memory: 16Mi
          volumeMounts:
            # Caddy config
            - name: caddy-config
              mountPath: /etc/caddy/Caddyfile
              subPath: Caddyfile

            # WebFinger JSON served as a FILE
            - name: webfinger-resource
              mountPath: /srv/.well-known/webfinger
              subPath: webfinger.json

      volumes:
        - name: caddy-config
          configMap:
            name: caddy-config

        - name: webfinger-resource
          configMap:
            name: webfinger-resource

Service

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: v1
kind: Service
metadata:
  name: webfinger-service
spec:
  selector:
    app: webfinger
  ports:
    - name: http
      port: 8080
      targetPort: 8080

Ingress

You can keep your other ingress for the main website and just add a new path for the webfinger service. However, both need to be accessible on the same host.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: "webfinger-path"
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
    nginx.ingress.kubernetes.io/use-regex: "true" # required to mach the guest path
    kubernetes.io/ingress.class: 'nginx'
    cert-manager.io/cluster-issuer: 'letsencrypt-prod'
    nginx.ingress.kubernetes.io/backend-protocol: HTTP
    nginx.ingress.kubernetes.io/force-ssl-redirect: 'true'
    nginx.ingress.kubernetes.io/ssl-redirect: 'true'
spec:
  rules:
    - host: example.com
      http:
        paths:
          - path: /.well-known
            pathType: ImplementationSpecific
            backend:
              service:
                name: webfinger-service
                port:
                  number: 8080
  tls:
    - hosts:
        - example.com
      secretName: webfinger-service-tls

Configure ZITADEL

Create a new ZITADEL application for Tailscale and make sure that the user you want to use for login has access to this application. It is important that the email address or username matches the subject you configured in the WebFinger setup.

Create a Web Application of type Code and add the following Redirect URI:

https://login.tailscale.com/a/oauth_response

Configure Tailscale

In Tailscale, go to the sign-up page and choose OIDC as the authentication method. Enter the email address associated with your ZITADEL account (for example, name@example.com). Tailscale will use WebFinger to discover the OIDC provider and then redirect you to ZITADEL for authentication.

After a successful login, you will be prompted to enter the clientId and clientSecret of the ZITADEL application you created earlier. Once these credentials are entered, you should be redirected back to Tailscale and logged in successfully.

How to verify that everything works

To verify that everything is working correctly, you can open an incognito or private browsing window in your web browser and try to log in to Tailscale using the same email address.

Keep in mind that you must first enter your email address on the Tailscale sign-in page. After that, you will be redirected to ZITADEL for authentication.

Conclusion

Setting up Tailscale with ZITADEL as a OIDC provider needs some tweaks, especially when it comes to the WebFinger server. However, this only lets you create a basic Tailscale account. If you want to protect your services with Tailscale, you will need to add Tailscale in front of your services, which is a topic for another guide.

Additional resources