Runway
Runway is built and operated in the EU!

Identity Provider (tsidp)

tsidp is an experimental OIDC identity provider backed by Tailscale identities. It lets you authenticate your Runway apps with the Tailscale accounts already used by your team - no separate user database needed.

How it works

tsidp embeds tsnet and is itself a Tailscale node. Apps that support OpenID Connect (OIDC) can use it as their identity provider. After authenticating via tsidp, users receive standard OIDC tokens containing their Tailscale identity.

Deploying tsidp on Runway

Prerequisites:

Dockerfile
FROM ghcr.io/tailscale/tsidp:latest
USER 1001:1001
runway app create my-tsidp --persistence --plan scale-m-launch
runway app config set \
  RUNWAY_TS_MANAGED=false \
  TAILSCALE_USE_WIP_CODE=1 \
  TS_AUTHKEY=tskey-auth-... \
  TS_STATE_DIR=/data \
  TS_HOSTNAME=my-tsidp \
  TSIDP_ENABLE_STS=1
runway app route off
runway app deploy

tsidp manages its own Tailscale connection internally, so Runway’s Tailscale integration must be disabled with RUNWAY_TS_MANAGED=false.

It also serves entirely over the Tailscale network interface, not a plain HTTP port, so runway app route off is required. Once deployed, the admin UI is at https://my-tsidp.tailnet-name.ts.net (but access is denied by default, see below).

TS_AUTHKEY is optional - if omitted, tsidp prints a registration link on first start.

Capability grants

tsidp uses Tailscale capability grants to control access. Add this to your Tailscale ACL:

"grants": [
  {
    "src": ["autogroup:member"],
    "dst": ["tag:tsidp"],
    "app": {
      "tailscale.com/cap/tsidp": [{"allow_admin_ui": true}]
    }
  }
]

There are other capabilities one can enable or disable, including dynamic client registration, but only admin UI access is needed for this guide.

Registering an OIDC client

Open https://my-tsidp.tailnet-name.ts.net in a browser on your tailnet, register a new OIDC client for the app you want to protect, and note the client_id and client_secret.

Enabling Funnel

By default tsidp is only reachable within your tailnet. Tailscale Funnel makes tsidp’s OIDC endpoints publicly accessible over the internet.

Note: users logging in always need Tailscale running - tsidp’s /authorize endpoint is tailnet-only. What Funnel changes is whether the app needs to be on the tailnet. Without Funnel, the app must reach tsidp over the tailnet (via TS_AUTHKEY and proxy variables). With Funnel, tsidp’s token validation and discovery endpoints are reachable from anywhere. It also makes tsidp usable as an IdP for SaaS products and other services outside your tailnet.

Grant the funnel attribute to tag:tsidp in your Tailscale ACL:

"nodeAttrs": [
  {
    "target": ["tag:tsidp"],
    "attr": ["funnel"]
  }
]

Then enable Funnel on the tsidp app:

runway app config set -a my-tsidp TSIDP_USE_FUNNEL=1
runway app restart -a my-tsidp

Once enabled, my-tsidp.tailnet-name.ts.net is publicly resolvable and Tailscale proxies HTTPS traffic to it. Apps connecting to tsidp no longer need TS_AUTHKEY or the proxy variables.

Adding tsidp auth to your app

If you have a custom app deployed on Runway and want to gate it behind Tailscale identity, github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 is the standard combination for Go. Below is a minimal but complete example.

Register an OIDC client in the tsidp admin UI using redirect URI https://myapp.pqapp.dev/callback, then note the client_id and client_secret.

/login starts the OIDC flow, /callback handles the code exchange, /private is the protected route. State is verified via a short-lived cookie. The session map is in-memory - sessions are lost on restart.

main.go
// DO NOT USE AS-IS IN PRODUCTION: no locking, error handling omitted throughout.
package main

import (
	"context"
	"crypto/rand"
	"fmt"
	"log"
	"net/http"
	"os"

	"github.com/coreos/go-oidc/v3/oidc"
	"golang.org/x/oauth2"
)

var (
	oauth2Cfg oauth2.Config
	verifier  *oidc.IDTokenVerifier
	sessions  = map[string]string{} // would need locking for concurrent use
)

func main() {
	provider, err := oidc.NewProvider(context.Background(), os.Getenv("OIDC_ISSUER"))
	if err != nil {
		log.Fatal(err)
	}
	verifier = provider.Verifier(&oidc.Config{ClientID: os.Getenv("OIDC_CLIENT_ID")})
	oauth2Cfg = oauth2.Config{
		ClientID:     os.Getenv("OIDC_CLIENT_ID"),
		ClientSecret: os.Getenv("OIDC_CLIENT_SECRET"),
		Endpoint:     provider.Endpoint(),
		Scopes:       []string{oidc.ScopeOpenID, "email"},
	}
	http.HandleFunc("GET /", publicHandler)
	http.HandleFunc("GET /private", requireAuth(privateHandler))
	http.HandleFunc("GET /login", loginHandler)
	http.HandleFunc("GET /callback", callbackHandler)
	log.Println("WARNING: DO NOT USE IN PRODUCTION. This is an example only.")
	log.Fatal(http.ListenAndServe(":"+os.Getenv("PORT"), nil))
}

func publicHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintln(w, "public page - try /private")
}

func privateHandler(w http.ResponseWriter, r *http.Request) {
	fmt.Fprintf(w, "hello, %s\n", r.Context().Value("email"))
}

func requireAuth(next http.HandlerFunc) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		cookie, err := r.Cookie("session")
		if err != nil {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}
		email, ok := sessions[cookie.Value]
		if !ok {
			http.Redirect(w, r, "/login", http.StatusFound)
			return
		}
		next(w, r.WithContext(context.WithValue(r.Context(), "email", email)))
	}
}

func loginHandler(w http.ResponseWriter, r *http.Request) {
	state := randomString(16)
	cfg := oauth2Cfg
	cfg.RedirectURL = "https://" + r.Host + "/callback"
	http.SetCookie(w, &http.Cookie{Name: "oidc_state", Value: state, Path: "/", HttpOnly: true, Secure: true, MaxAge: 300})
	http.Redirect(w, r, cfg.AuthCodeURL(state), http.StatusFound)
}

func callbackHandler(w http.ResponseWriter, r *http.Request) {
	stateCookie, err := r.Cookie("oidc_state")
	if err != nil || stateCookie.Value != r.URL.Query().Get("state") {
		http.Error(w, "invalid state", http.StatusBadRequest)
		return
	}
	cfg := oauth2Cfg
	cfg.RedirectURL = "https://" + r.Host + "/callback"
	token, err := cfg.Exchange(r.Context(), r.URL.Query().Get("code"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	rawID, _ := token.Extra("id_token").(string) // error handling elided
	idToken, err := verifier.Verify(r.Context(), rawID)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	var claims struct{ Email string `json:"email"` }
	idToken.Claims(&claims) // error handling elided
	sessionID := randomString(32)
	sessions[sessionID] = claims.Email
	http.SetCookie(w, &http.Cookie{Name: "session", Value: sessionID, Path: "/", HttpOnly: true, Secure: true})
	http.Redirect(w, r, "/private", http.StatusFound)
}

func randomString(nBytes int) string {
	b := make([]byte, nBytes)
	rand.Read(b) // error handling elided
	return fmt.Sprintf("%x", b)
}

Initialize the Go module, fetch dependencies, and commit everything before deploying:

runway app create myapp
go mod init myapp
go mod tidy
git add .
git commit -m "initial commit"

Deploy it pointing at your tsidp instance.

tsidp without Funnel

Create a Tailscale auth key for the app.

runway app config set \
  TS_AUTHKEY=tskey-auth-... \
  ALL_PROXY=socks5://localhost:1055/ \
  HTTP_PROXY=http://localhost:1055/ \
  HTTPS_PROXY=http://localhost:1055/ \
  OIDC_ISSUER=https://my-tsidp.tailnet-name.ts.net \
  OIDC_CLIENT_ID=<client-id> \
  OIDC_CLIENT_SECRET=<client-secret>
runway app deploy
tsidp with Funnel
runway app config set \
  OIDC_ISSUER=https://my-tsidp.tailnet-name.ts.net \
  OIDC_CLIENT_ID=<client-id> \
  OIDC_CLIENT_SECRET=<client-secret>
runway app deploy

Example: Grafana

Grafana is a metrics and dashboards platform that supports OIDC for login. Grafana’s OIDC integration is entirely env-var driven. Register an OIDC client in the tsidp admin UI using redirect URI https://my-grafana.pqapp.dev/login/generic_oauth, then note the client_id and client_secret.

Dockerfile
FROM grafana/grafana
USER 472:472
tsidp without Funnel

Create a Tailscale auth key for my-grafana.

runway app create my-grafana --persistence --plan scale-m-launch
runway app config set \
  TS_AUTHKEY=tskey-auth-... \
  ALL_PROXY=socks5://localhost:1055/ \
  HTTP_PROXY=http://localhost:1055/ \
  HTTPS_PROXY=http://localhost:1055/ \
  GF_PATHS_DATA=/data \
  GF_AUTH_GENERIC_OAUTH_ENABLED=true \
  GF_AUTH_GENERIC_OAUTH_NAME="Tailscale" \
  GF_AUTH_GENERIC_OAUTH_CLIENT_ID=<client-id> \
  GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<client-secret> \
  GF_AUTH_GENERIC_OAUTH_SCOPES="openid email profile" \
  GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://my-tsidp.tailnet-name.ts.net/authorize \
  GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://my-tsidp.tailnet-name.ts.net/token \
  GF_AUTH_GENERIC_OAUTH_API_URL=https://my-tsidp.tailnet-name.ts.net/userinfo \
  GF_SERVER_ROOT_URL=https://my-grafana.pqapp.dev
runway app deploy
tsidp with Funnel

No Tailscale integration needed on the app - tsidp’s OIDC endpoints are publicly reachable. Users logging in still need Tailscale running.

runway app create my-grafana --persistence --plan scale-m-launch
runway app config set \
  GF_PATHS_DATA=/data \
  GF_AUTH_GENERIC_OAUTH_ENABLED=true \
  GF_AUTH_GENERIC_OAUTH_NAME="Tailscale" \
  GF_AUTH_GENERIC_OAUTH_CLIENT_ID=<client-id> \
  GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=<client-secret> \
  GF_AUTH_GENERIC_OAUTH_SCOPES="openid email profile" \
  GF_AUTH_GENERIC_OAUTH_AUTH_URL=https://my-tsidp.tailnet-name.ts.net/authorize \
  GF_AUTH_GENERIC_OAUTH_TOKEN_URL=https://my-tsidp.tailnet-name.ts.net/token \
  GF_AUTH_GENERIC_OAUTH_API_URL=https://my-tsidp.tailnet-name.ts.net/userinfo \
  GF_SERVER_ROOT_URL=https://my-grafana.pqapp.dev
runway app deploy