Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.test
*.test.exe
50 changes: 39 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -329,25 +329,53 @@ The following fields are set on the Caddy user object:
- `user.tailscale_name`: the display name of the Tailscale user
- `user.tailscale_profile_picture`: the URL of the Tailscale user's profile picture
- `user.tailscale_tailnet`: the name of the Tailscale network the user is a member of
- `user.tailscale_grants`: JSON string of the peer's granted [application capabilities](#application-capabilities), if any

These values can be mapped to HTTP headers that are then passed to
an application that supports proxy authentication such as [Gitea] or [Grafana].
You might have something like the following in your Caddyfile:

```caddyfile
:80 {
bind tailscale/gitea
tailscale_auth
reverse_proxy http://localhost:3000 {
header_up X-Webauth-User {http.auth.user.tailscale_login}
header_up X-Webauth-Email {http.auth.user.tailscale_user}
header_up X-Webauth-Name {http.auth.user.tailscale_name}
When used with a Tailscale listener (described above), that Tailscale node is used to identify the remote user.
Otherwise, the authentication provider will attempt to connect to the Tailscale daemon running on the local machine.

## Application Capabilities

[Tailscale application capabilities](https://tailscale.com/kb/1537/grants-app-capabilities) (grants) allow you to pass
application-specific data defined in your Tailscale ACL policy file to your Caddy site.
These are exposed through the `{tailscale.grants}` [Caddy placeholder](https://caddyserver.com/docs/conventions#placeholders)
for use in [CEL expression matchers](https://caddyserver.com/docs/caddyfile/matchers#expression).

For example, given the following grant in your Tailscale ACL policy:

```json
{
"src": "autogroup:admins",
"dst": "tag:caddy",
"app": {
"example.com/app": [
{ "admin": true }
]
}
}
```

When used with a Tailscale listener (described above), that Tailscale node is used to identify the remote user.
Otherwise, the authentication provider will attempt to connect to the Tailscale daemon running on the local machine.
You can route requests based on grant values in a Caddyfile:

```caddyfile
handle /admin {
@admin expression `{tailscale.grants}["example.com/app"][0].admin == true`
respond @admin "Welcome admin"
respond "not authorized" 401
}
```

Or forward the grants to a backend application using the user metadata:

```caddyfile
reverse_proxy localhost:3000 {
header_up X-Tailscale-User {http.auth.user.tailscale_login}
header_up X-Tailscale-App-Caps {http.auth.user.tailscale_grants}
}
```

[tagged devices]: https://tailscale.com/kb/1068/acl-tags
[Gitea]: https://docs.gitea.com/usage/authentication#reverse-proxy
Expand Down
57 changes: 57 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package tscaddy
// auth.go contains the TailscaleAuth module and supporting logic.

import (
"encoding/json"
"fmt"
"net"
"net/http"
Expand All @@ -18,6 +19,7 @@ import (
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
"github.com/caddyserver/caddy/v2/modules/caddyhttp/caddyauth"
"tailscale.com/client/local"
"tailscale.com/tailcfg"
"tailscale.com/tsnet"
)

Expand Down Expand Up @@ -132,6 +134,7 @@ type tsnetListener interface {
// - tailscale_name: the user's display name
// - tailscale_profile_picture: the user's profile picture URL
// - tailscale_tailnet: the user's tailnet name (if the user is not connecting to a shared node)
// - tailscale_grants: JSON string of the peer's granted capabilities (if any)
func (ta Auth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.User, bool, error) {
user := caddyauth.User{}

Expand All @@ -156,17 +159,71 @@ func (ta Auth) Authenticate(w http.ResponseWriter, r *http.Request) (caddyauth.U
}
}

// Extract grants from CapMap
grants := extractGrants(info.CapMap)

// Register replacer mapping for {tailscale.grants}
repl := r.Context().Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
repl.Map(func(key string) (any, bool) {
if key == "tailscale.grants" {
if len(grants) == 0 {
// Return empty map so CEL operations like "in" work without "no such overload"
return map[string]any{}, true
}
return grants, true
}
return nil, false
})

// Marshal grants to JSON for metadata
var grantsJSON string
if len(grants) > 0 {
b, err := json.Marshal(grants)
if err == nil {
grantsJSON = string(b)
}
}

user.ID = info.UserProfile.LoginName
user.Metadata = map[string]string{
"tailscale_login": strings.Split(info.UserProfile.LoginName, "@")[0],
"tailscale_user": info.UserProfile.LoginName,
"tailscale_name": info.UserProfile.DisplayName,
"tailscale_profile_picture": info.UserProfile.ProfilePicURL,
"tailscale_tailnet": tailnet,
"tailscale_grants": grantsJSON,
}
return user, true, nil
}

// extractGrants filters the CapMap based on acceptAppCaps and returns a map suitable for JSON marshaling.
// If acceptAppCaps is empty, all capabilities are included.
func extractGrants(capMap tailcfg.PeerCapMap) map[string]any {
if len(capMap) == 0 {
return nil
}

grants := make(map[string]any)

for cap, vals := range capMap {
var parsed []any
for _, v := range vals {
var obj any
if err := json.Unmarshal([]byte(v), &obj); err == nil {
parsed = append(parsed, obj)
} else {
parsed = append(parsed, string(v))
}
}
grants[string(cap)] = parsed
}

if len(grants) == 0 {
return nil
}
return grants
}

func parseAuthConfig(_ httpcaddyfile.Helper) (caddyhttp.MiddlewareHandler, error) {
var ta Auth

Expand Down
174 changes: 174 additions & 0 deletions cel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
// Copyright (c) Tailscale Inc & contributors
// SPDX-License-Identifier: Apache-2.0

package tscaddy

import (
"context"
"net/http/httptest"
"testing"

"github.com/caddyserver/caddy/v2"
"github.com/caddyserver/caddy/v2/modules/caddyhttp"
)

// TestTailscaleGrantsCEL verifies that the tailscale.grants placeholder
// can be used in CEL expressions via the expression matcher.
func TestTailscaleGrantsCEL(t *testing.T) {
testGrants := map[string]any{
"example.com/app": []any{
map[string]any{
"admin": true,
"role": "admin",
},
},
"example.com/cap/sql": []any{
map[string]any{
"access": "read",
},
},
}

req := httptest.NewRequest("GET", "http://example.com", nil)

// Create a replacer with the tailscale.grants placeholder handler,
// as Auth.Authenticate does during request processing.
repl := caddy.NewReplacer()
repl.Map(func(key string) (any, bool) {
if key == "tailscale.grants" {
return testGrants, true
}
return nil, false
})
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
req = req.WithContext(ctx)

caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
defer cancel()

t.Run("in_operator_key_exists", func(t *testing.T) {
expr := &caddyhttp.MatchExpression{
Expr: `"example.com/app" in {tailscale.grants}`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(req)
if err != nil {
t.Fatalf("CEL evaluation failed: %v", err)
}
if !matches {
t.Error("expected 'example.com/app' in grants")
}
})

t.Run("map_index_with_list_size", func(t *testing.T) {
expr := &caddyhttp.MatchExpression{
Expr: `{tailscale.grants}["example.com/app"].size() == 1`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(req)
if err != nil {
t.Fatalf("CEL evaluation failed: %v", err)
}
if !matches {
t.Error("expected grants map indexing to return list with 1 element")
}
})

t.Run("deep_field_access", func(t *testing.T) {
// Access a nested field: grants["example.com/app"][0].admin == true
expr := &caddyhttp.MatchExpression{
Expr: `{tailscale.grants}["example.com/app"][0].admin == true`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(req)
if err != nil {
t.Fatalf("CEL evaluation failed: %v", err)
}
if !matches {
t.Error("expected admin to be true")
}
})

t.Run("in_operator_missing_key", func(t *testing.T) {
expr := &caddyhttp.MatchExpression{
Expr: `"example.com/nonexistent" in {tailscale.grants}`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(req)
if err != nil {
t.Fatalf("CEL evaluation failed: %v", err)
}
if matches {
t.Error("expected nonexistent key not to be in grants")
}
})

t.Run("grants_empty_when_not_set", func(t *testing.T) {
// Request without grants in context should get nil from replacer
emptyReq := httptest.NewRequest("GET", "http://example.com", nil)
emptyRepl := caddy.NewReplacer()
emptyCtx := context.WithValue(emptyReq.Context(), caddy.ReplacerCtxKey, emptyRepl)
emptyReq = emptyReq.WithContext(emptyCtx)

emptyRepl.Map(func(key string) (any, bool) {
if key == "tailscale.grants" {
// Return empty map, as Auth.Authenticate now does
return map[string]any{}, true
}
return nil, false
})

// Check that in operator returns false (not an error) when grants are nil
expr := &caddyhttp.MatchExpression{
Expr: `"example.com/app" in {tailscale.grants}`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(emptyReq)
if err != nil {
t.Fatalf("CEL evaluation failed with 'no such overload': %v", err)
}
if matches {
t.Error("expected in operator to return false when grants are empty")
}
})

t.Run("null_check_empty_grants", func(t *testing.T) {
// Same setup: empty grants
emptyReq := httptest.NewRequest("GET", "http://example.com", nil)
emptyRepl := caddy.NewReplacer()
emptyCtx := context.WithValue(emptyReq.Context(), caddy.ReplacerCtxKey, emptyRepl)
emptyReq = emptyReq.WithContext(emptyCtx)

emptyRepl.Map(func(key string) (any, bool) {
if key == "tailscale.grants" {
return map[string]any{}, true
}
return nil, false
})

// Empty map is not null, so == null should be false
expr := &caddyhttp.MatchExpression{
Expr: `{tailscale.grants} == null`,
}
if err := expr.Provision(caddyCtx); err != nil {
t.Fatalf("Provision failed: %v", err)
}
matches, err := expr.MatchWithError(emptyReq)
if err != nil {
t.Fatalf("CEL evaluation failed: %v", err)
}
if matches {
t.Error("expected tailscale.grants to not be null when grants are empty")
}
})
}
Loading