Skip to content

Commit 81c30ee

Browse files
authored
server: add application capabilities for ui and DCR (#47)
Add new Application capabilities to limit access to the UI and dynamic client registration (DCR) endpoints. This also unifies the structs used to UnmarshalCapJSON into a single struct with fields for all current uses. Fixes: #44, #16, #17 Signed-off-by: Benson Wong <[email protected]>
1 parent 319e43d commit 81c30ee

File tree

10 files changed

+249
-34
lines changed

10 files changed

+249
-34
lines changed

README.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
- A Tailscale network (tailnet) with magicDNS and HTTPS enabled
1313
- A Tailscale authentication key from your tailnet
1414
- (Recommended) Docker installed on your system
15+
- Ability to set an Application capability grant
1516

1617
## Running tsidp
1718

@@ -71,6 +72,36 @@ _If you're running tsidp for the first time, you may not be able to access it in
7172

7273
</details>
7374

75+
## Setting an Application Capability Grant
76+
77+
tsidp requires an [Application capability grant](https://tailscale.com/kb/1537/grants-app-capabilities) to allow access to the admin UI and dynamic client registration endpoints.
78+
79+
This is a permissive grant that is suitable only for testing purposes:
80+
81+
```json
82+
"grants": [
83+
{
84+
"src": ["*"],
85+
"dst": ["*"],
86+
"app": {
87+
"tailscale.com/cap/tsidp": [
88+
{
89+
// STS controls
90+
"users": ["*"],
91+
"resources": ["*"],
92+
93+
// allow access to UI
94+
"allow_admin_ui": true,
95+
96+
// allow dynamic client registration
97+
"allow_dcr": true,
98+
},
99+
],
100+
},
101+
},
102+
],
103+
```
104+
74105
## Application Configuration Guides
75106

76107
tsidp can be used as IdP server for any application that supports custom OIDC providers.

server/appcap.go

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
// Copyright (c) Tailscale Inc & AUTHORS
2+
// SPDX-License-Identifier: BSD-3-Clause
3+
4+
package server
5+
6+
import (
7+
"context"
8+
"fmt"
9+
"net/http"
10+
"net/netip"
11+
12+
"tailscale.com/client/tailscale/apitype"
13+
"tailscale.com/tailcfg"
14+
)
15+
16+
// shared key for context values
17+
var appCapCtxKey = &accessGrantedRules{}
18+
19+
// Capability rule types
20+
type capRule struct {
21+
IncludeInUserInfo bool `json:"includeInUserInfo"`
22+
ExtraClaims map[string]any `json:"extraClaims,omitempty"` // list of features peer is allowed to edit
23+
24+
// for sts rules
25+
Users []string `json:"users"` // list of users allowed to access resources (supports "*" wildcard)
26+
Resources []string `json:"resources"` // list of audience/resource URIs the user can access
27+
28+
// allow lists
29+
AllowAdminUI bool `json:"allow_admin_ui"`
30+
AllowDCR bool `json:"allow_dcr"` // dynamic client registration
31+
}
32+
33+
// AccessGrantedRules holds the access rules from granted Application Capabilities.
34+
// tsidp uses a deny-all-by-default model, so only the granted capabilities are allowed
35+
type accessGrantedRules struct {
36+
allowAdminUI bool
37+
allowDCR bool
38+
rules []capRule // list of rules
39+
}
40+
41+
// addGrantAccessContext wraps an http.HandlerFunc and adds a AccessGrantedRules to the
42+
// *http.Request's context. Handlers that are protected by an Application capability grant
43+
// can conventiently extract and check the granted capabilities.
44+
func (s *IDPServer) addGrantAccessContext(handler http.HandlerFunc) http.HandlerFunc {
45+
return func(w http.ResponseWriter, r *http.Request) {
46+
// used only for testing to bypass app cap checks
47+
if s.bypassAppCapCheck {
48+
r = r.WithContext(context.WithValue(r.Context(), appCapCtxKey, &accessGrantedRules{
49+
allowAdminUI: true,
50+
allowDCR: true,
51+
rules: []capRule{}, // empty rules for testing
52+
}))
53+
handler(w, r)
54+
return
55+
}
56+
57+
// when local.Client is not available send through a default-deny rules
58+
if s.lc == nil {
59+
r = r.WithContext(context.WithValue(r.Context(), appCapCtxKey, &accessGrantedRules{
60+
rules: []capRule{}, // empty rules for testing
61+
}))
62+
handler(w, r)
63+
return
64+
}
65+
66+
// allow all access when requests are coming from localhost
67+
if ap, err := netip.ParseAddrPort(r.RemoteAddr); err == nil {
68+
if ap.Addr().IsLoopback() {
69+
r = r.WithContext(context.WithValue(r.Context(), appCapCtxKey, &accessGrantedRules{
70+
allowAdminUI: true,
71+
allowDCR: true,
72+
rules: []capRule{},
73+
}))
74+
handler(w, r)
75+
return
76+
}
77+
}
78+
79+
// Build the access rules from granted application capabilities
80+
accessRules := &accessGrantedRules{}
81+
82+
var remoteAddr string
83+
if s.localTSMode {
84+
remoteAddr = r.Header.Get("X-Forwarded-For")
85+
} else {
86+
remoteAddr = r.RemoteAddr
87+
}
88+
89+
var who *apitype.WhoIsResponse
90+
var err error
91+
92+
who, err = s.lc.WhoIs(r.Context(), remoteAddr)
93+
if err != nil {
94+
http.Error(w, fmt.Sprintf("Error getting WhoIs: %v", err), http.StatusInternalServerError)
95+
return
96+
}
97+
98+
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, "tailscale.com/cap/tsidp")
99+
if err != nil {
100+
http.Error(w, fmt.Sprintf("failed unmarshaling app cap rule %s", err.Error()), http.StatusInternalServerError)
101+
return
102+
}
103+
accessRules.rules = rules
104+
105+
// grant rules are accumulated from all granted rules
106+
for _, rule := range rules {
107+
if rule.AllowAdminUI {
108+
accessRules.allowAdminUI = true
109+
}
110+
if rule.AllowDCR {
111+
accessRules.allowDCR = true
112+
}
113+
}
114+
115+
r = r.WithContext(context.WithValue(r.Context(), appCapCtxKey, accessRules))
116+
handler(w, r)
117+
}
118+
}

server/client_test.go

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,24 @@ func TestServeDynamicClientRegistration(t *testing.T) {
119119
isFunnel bool
120120
expectStatus int
121121
checkResponse func(t *testing.T, body []byte)
122+
123+
// Disable app cap override for this test to test for the deny-by-default behaviour
124+
disableAppCapOverride bool
122125
}{
126+
{
127+
name: "Check access without app cap is denied",
128+
method: "POST",
129+
body: `{
130+
"redirect_uris": ["https://example.com/callback"],
131+
"client_name": "Test Client",
132+
"grant_types": ["authorization_code"],
133+
"response_types": ["code"]
134+
}`,
135+
expectStatus: http.StatusForbidden,
136+
disableAppCapOverride: true,
137+
checkResponse: nil,
138+
},
139+
123140
{
124141
name: "POST request - verify JSON field names",
125142
method: "POST",
@@ -347,6 +364,9 @@ func TestServeDynamicClientRegistration(t *testing.T) {
347364
serverURL: "https://idp.test.ts.net",
348365
stateDir: tempDir,
349366
funnelClients: make(map[string]*FunnelClient),
367+
368+
// tt.disableAppCapOverride is true to test the deny-by-default behaviour
369+
bypassAppCapCheck: !tt.disableAppCapOverride,
350370
}
351371

352372
// Mock the storeFunnelClientsLocked function for testing
@@ -363,7 +383,7 @@ func TestServeDynamicClientRegistration(t *testing.T) {
363383
}
364384

365385
rr := httptest.NewRecorder()
366-
s.serveDynamicClientRegistration(rr, req)
386+
s.ServeHTTP(rr, req)
367387

368388
if rr.Code != tt.expectStatus {
369389
t.Errorf("expected status %d, got %d\nBody: %s", tt.expectStatus, rr.Code, rr.Body.String())

server/clients.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,17 @@ func (s *IDPServer) serveDynamicClientRegistration(w http.ResponseWriter, r *htt
292292
return
293293
}
294294

295+
access, ok := r.Context().Value(appCapCtxKey).(*accessGrantedRules)
296+
if !ok {
297+
writeJSONError(w, http.StatusForbidden, "access_denied", "application capability not found")
298+
return
299+
}
300+
301+
if !access.allowDCR {
302+
writeJSONError(w, http.StatusForbidden, "access_denied", "application capability not granted")
303+
return
304+
}
305+
295306
var registrationRequest struct {
296307
RedirectURIs []string `json:"redirect_uris"`
297308
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method,omitempty"`

server/helpers_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ func normalizeMap(t *testing.T, m map[string]any) map[string]any {
122122

123123
// marshalCapRules is a helper to convert stsCapRule slice to JSON for testing
124124
// Migrated from legacy/tsidp_test.go:2653-2661
125-
func marshalCapRules(rules []stsCapRule) []tailcfg.RawMessage {
125+
func marshalCapRules(rules []capRule) []tailcfg.RawMessage {
126126
// UnmarshalCapJSON expects each rule to be a separate RawMessage
127127
var msgs []tailcfg.RawMessage
128128
for _, rule := range rules {

server/server.go

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ type IDPServer struct {
4646
serverURL string // "https://foo.bar.ts.net"
4747
stateDir string // directory for persisted state (keys, etc)
4848
funnel bool
49-
localTSMode bool
49+
localTSMode bool // use local tailscaled instead of tsnet
5050
enableSTS bool
5151

5252
lazyMux lazy.SyncValue[*http.ServeMux]
@@ -58,6 +58,10 @@ type IDPServer struct {
5858
accessToken map[string]*AuthRequest // keyed by random hex
5959
refreshToken map[string]*AuthRequest // keyed by random hex
6060
funnelClients map[string]*FunnelClient // keyed by client ID
61+
62+
// for bypassing application capability checks for testing
63+
// see issue #44
64+
bypassAppCapCheck bool
6165
}
6266

6367
// AuthRequest represents an authorization request
@@ -240,12 +244,11 @@ func (s *IDPServer) newMux() *http.ServeMux {
240244
mux.HandleFunc("/userinfo", s.serveUserInfo)
241245

242246
// Register /register endpoint for Dynamic Client Registration
243-
// Migrated from legacy/tsidp.go:683
244-
mux.HandleFunc("/register", s.serveDynamicClientRegistration)
247+
mux.HandleFunc("/register", s.addGrantAccessContext(s.serveDynamicClientRegistration))
245248

246249
// Register UI handler - must be last as it handles "/"
247250
// Migrated from legacy/tsidp.go:685
248-
mux.HandleFunc("/", s.handleUI)
251+
mux.HandleFunc("/", s.addGrantAccessContext(s.handleUI))
249252
return mux
250253
}
251254

@@ -430,5 +433,3 @@ func ServeOnLocalTailscaled(ctx context.Context, lc *local.Client, st *ipnstate.
430433

431434
return func() { watcher.Close() }, watcherChan, nil
432435
}
433-
434-
// getFunnelClientsPath returns the full path to the funnel clients file

server/token.go

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,6 @@ func (tc tailscaleClaims) toMap() map[string]any {
129129
return m
130130
}
131131

132-
// Capability rule types
133-
// Migrated from legacy/tsidp.go:779-790
134-
135-
type capRule struct {
136-
IncludeInUserInfo bool `json:"includeInUserInfo"`
137-
ExtraClaims map[string]any `json:"extraClaims,omitempty"` // list of features peer is allowed to edit
138-
}
139-
140-
type stsCapRule struct {
141-
Users []string `json:"users"` // list of users allowed to access resources (supports "*" wildcard)
142-
Resources []string `json:"resources"` // list of audience/resource URIs the user can access
143-
}
144-
145132
// serveToken is the main /token endpoint handler
146133
// Migrated from legacy/tsidp.go:921-942
147134
func (s *IDPServer) serveToken(w http.ResponseWriter, r *http.Request) {
@@ -379,7 +366,7 @@ func (s *IDPServer) serveTokenExchange(w http.ResponseWriter, r *http.Request) {
379366

380367
// Check ACL grant for STS token exchange
381368
who := ar.RemoteUser
382-
rules, err := tailcfg.UnmarshalCapJSON[stsCapRule](who.CapMap, "tailscale.com/cap/tsidp")
369+
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, "tailscale.com/cap/tsidp")
383370
if err != nil {
384371
//log.Printf("tsidp: failed to unmarshal STS capability: %v", err)
385372
writeTokenEndpointError(w, http.StatusForbidden, "access_denied", fmt.Sprintf("failed to unmarshal STS capability: %s", err.Error()))
@@ -695,7 +682,7 @@ func (s *IDPServer) identifyClient(r *http.Request) string {
695682
// Migrated from legacy/tsidp.go:426-472
696683
func (s *IDPServer) validateResourcesForUser(who *apitype.WhoIsResponse, requestedResources []string) ([]string, error) {
697684
// Check ACL grant using the same capability as we would use for STS token exchange
698-
rules, err := tailcfg.UnmarshalCapJSON[stsCapRule](who.CapMap, "tailscale.com/cap/tsidp")
685+
rules, err := tailcfg.UnmarshalCapJSON[capRule](who.CapMap, "tailscale.com/cap/tsidp")
699686
if err != nil {
700687
return nil, fmt.Errorf("failed to unmarshal capability: %w", err)
701688
}

server/token_test.go

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ func TestResourceIndicators(t *testing.T) {
2727
name string
2828
authorizationQuery string
2929
tokenFormData url.Values
30-
capMapRules []stsCapRule
30+
capMapRules []capRule
3131
expectStatus int
3232
checkResponse func(t *testing.T, body []byte)
3333
}{
@@ -38,7 +38,7 @@ func TestResourceIndicators(t *testing.T) {
3838
"grant_type": {"authorization_code"},
3939
"redirect_uri": {"https://example.com/callback"},
4040
},
41-
capMapRules: []stsCapRule{
41+
capMapRules: []capRule{
4242
{
4343
Users: []string{"*"},
4444
Resources: []string{"https://api.example.com"},
@@ -75,7 +75,7 @@ func TestResourceIndicators(t *testing.T) {
7575
"grant_type": {"authorization_code"},
7676
"redirect_uri": {"https://example.com/callback"},
7777
},
78-
capMapRules: []stsCapRule{
78+
capMapRules: []capRule{
7979
{
8080
Users: []string{"*"},
8181
Resources: []string{"*"}, // Allow all resources
@@ -113,7 +113,7 @@ func TestResourceIndicators(t *testing.T) {
113113
"redirect_uri": {"https://example.com/callback"},
114114
"resource": {"https://api.example.com"},
115115
},
116-
capMapRules: []stsCapRule{
116+
capMapRules: []capRule{
117117
{
118118
Users: []string{"[email protected]"},
119119
Resources: []string{"https://api.example.com"},
@@ -138,7 +138,7 @@ func TestResourceIndicators(t *testing.T) {
138138
"redirect_uri": {"https://example.com/callback"},
139139
"resource": {"https://unauthorized.example.com"},
140140
},
141-
capMapRules: []stsCapRule{
141+
capMapRules: []capRule{
142142
{
143143
Users: []string{"[email protected]"},
144144
Resources: []string{"https://api.example.com"},
@@ -806,15 +806,15 @@ func TestRefreshTokenWithResources(t *testing.T) {
806806
name string
807807
originalResources []string
808808
refreshResources []string
809-
capMapRules []stsCapRule
809+
capMapRules []capRule
810810
expectStatus int
811811
expectError string
812812
}{
813813
{
814814
name: "refresh with resource downscoping",
815815
originalResources: []string{"https://api1.example.com", "https://api2.example.com"},
816816
refreshResources: []string{"https://api1.example.com"},
817-
capMapRules: []stsCapRule{
817+
capMapRules: []capRule{
818818
{
819819
Users: []string{"*"},
820820
Resources: []string{"*"},
@@ -826,7 +826,7 @@ func TestRefreshTokenWithResources(t *testing.T) {
826826
name: "refresh with resource not in original grant",
827827
originalResources: []string{"https://api1.example.com"},
828828
refreshResources: []string{"https://api2.example.com"},
829-
capMapRules: []stsCapRule{
829+
capMapRules: []capRule{
830830
{
831831
Users: []string{"*"},
832832
Resources: []string{"*"},
@@ -839,7 +839,7 @@ func TestRefreshTokenWithResources(t *testing.T) {
839839
name: "refresh without resource parameter",
840840
originalResources: []string{"https://api1.example.com"},
841841
refreshResources: nil,
842-
capMapRules: []stsCapRule{
842+
capMapRules: []capRule{
843843
{
844844
Users: []string{"*"},
845845
Resources: []string{"*"},

0 commit comments

Comments
 (0)