diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index bfdb3f57..4326d03d 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -275,6 +275,8 @@ jobs: - TestNodeTagCommand - TestNodeRouteCommands - TestNodeBackfillIPsCommand + - TestOAuthClientCommand + - TestOAuthClientCommandValidation - TestPolicyCheckCommand - TestSSHTestsRejectFailingPolicy - TestPolicyCommand diff --git a/hscontrol/api/v2/guard_test.go b/hscontrol/api/v2/guard_test.go new file mode 100644 index 00000000..7d52e0c7 --- /dev/null +++ b/hscontrol/api/v2/guard_test.go @@ -0,0 +1,58 @@ +package apiv2 + +import ( + "testing" + + "github.com/danielgtaylor/huma/v2" + "github.com/go-chi/chi/v5" + "github.com/juanfont/headscale/hscontrol/scope" +) + +// selfEnforcedKeyOps are the authenticated operations that intentionally declare +// NO static scope because they multiplex on keyType and authorize inside the +// handler via requireKeyScope (see keys.go). Every other authenticated operation +// must declare a scope. +var selfEnforcedKeyOps = map[string]bool{ + "POST /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys": true, + "GET /api/v2/tailnet/{tailnet}/keys/{keyId}": true, + "DELETE /api/v2/tailnet/{tailnet}/keys/{keyId}": true, +} + +// TestEveryAuthenticatedOperationDeclaresScope is the structural guarantee that no +// v2 operation ships unprotected: any operation that requires authentication +// (non-empty Security) must either declare a required scope via requireScope, or +// be one of the keyType-multiplexed keys operations that self-enforce. A new +// operation added without scope protection fails this test. +func TestEveryAuthenticatedOperationDeclaresScope(t *testing.T) { + api := NewAPI(chi.NewMux(), Backend{}) + + for path, item := range api.OpenAPI().Paths { + for method, op := range humaOperations(item) { + if op == nil || len(op.Security) == 0 { + continue // unregistered method or a public operation + } + + key := method + " " + path + if selfEnforcedKeyOps[key] { + continue + } + + if _, ok := op.Metadata[scopeMetaKey].(scope.Scope); !ok { + t.Errorf("operation %q is authenticated but declares no required scope; "+ + "wrap it in requireScope, or add it to selfEnforcedKeyOps if it "+ + "authorizes inside the handler", key) + } + } + } +} + +func humaOperations(item *huma.PathItem) map[string]*huma.Operation { + return map[string]*huma.Operation{ + "GET": item.Get, + "POST": item.Post, + "PUT": item.Put, + "DELETE": item.Delete, + "PATCH": item.Patch, + } +} diff --git a/hscontrol/apiv2_oauth_matrix_test.go b/hscontrol/apiv2_oauth_matrix_test.go new file mode 100644 index 00000000..3e71d730 --- /dev/null +++ b/hscontrol/apiv2_oauth_matrix_test.go @@ -0,0 +1,280 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/juanfont/headscale/hscontrol/scope" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// apiReq issues an arbitrary-method request with an optional bearer token and JSON +// body, returning the status and raw body. It owns the response body. +func apiReq(t *testing.T, method, target, bearer string, body any) (int, []byte) { + t.Helper() + + var r io.Reader + + if body != nil { + b, err := json.Marshal(body) + require.NoError(t, err) + + r = bytes.NewReader(b) + } + + req, err := http.NewRequestWithContext(t.Context(), method, target, r) + require.NoError(t, err) + + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// scopeDenied reports whether a response is a scope-enforcement denial. The scope +// gate runs in the middleware before the handler, so a denial is exactly a 403 +// carrying this message; any other status (200, or a 400/404 from the handler on +// minimal input) means the request passed the scope gate. +func scopeDenied(status int, body []byte) bool { + return status == http.StatusForbidden && + strings.Contains(string(body), "missing the required scope") +} + +// mintScopedToken creates an OAuth client holding exactly scope s (tagged tag:ci so +// tag-requiring scopes are valid) and returns an access token for it. +func mintScopedToken(t *testing.T, baseURL, admin string, s scope.Scope) string { + t.Helper() + + _, secret := createClient(t, baseURL, admin, []string{string(s)}, []string{"tag:ci"}) + + return tokenFor(t, baseURL, secret) +} + +// createAdminAuthKey creates a tagged auth key with the admin key and returns its id. +func createAdminAuthKey(t *testing.T, baseURL, admin string) string { + t.Helper() + + status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys", admin, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: []string{"tag:ci"}}, + }}, + }) + require.Equalf(t, http.StatusOK, status, "create admin auth key: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + + return key.ID +} + +type matrixOp struct { + name string + method string + path string + need scope.Scope + body any + // multiplexed marks the keyType-multiplexed keys ops. They self-enforce in + // the handler and, to avoid an existence oracle, deny an unauthorized token + // with a uniform not-found rather than the middleware's scope-403. So denial + // is "resource not returned" (status != 200), not a specific 403. + multiplexed bool +} + +// TestAPIv2OAuthMatrix_Enforcement is the exhaustive operation×scope cross-product: +// for every scope-gated operation and every scope in the vocabulary, a token +// holding exactly that scope is allowed iff scope.Grants permits it (P3) and denied +// otherwise (P2). +func TestAPIv2OAuthMatrix_Enforcement(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // Stable ids for the keyType-multiplexed get-by-id operations. + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, nil) + authKeyID := createAdminAuthKey(t, baseURL, admin) + _ = app + + ops := []matrixOp{ + {"getDevice", http.MethodGet, "/api/v2/device/1", scope.DevicesCoreRead, nil, false}, + {"listDevices", http.MethodGet, "/api/v2/tailnet/-/devices", scope.DevicesCoreRead, nil, false}, + {"deleteDevice", http.MethodDelete, "/api/v2/device/1", scope.DevicesCore, nil, false}, + {"authorizeDevice", http.MethodPost, "/api/v2/device/1/authorized", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceName", http.MethodPost, "/api/v2/device/1/name", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceTags", http.MethodPost, "/api/v2/device/1/tags", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceKey", http.MethodPost, "/api/v2/device/1/key", scope.DevicesCore, map[string]any{}, false}, + {"setDeviceRoutes", http.MethodPost, "/api/v2/device/1/routes", scope.DevicesRoutes, map[string]any{}, false}, + {"getDeviceRoutes", http.MethodGet, "/api/v2/device/1/routes", scope.DevicesRoutesRead, nil, false}, + {"getACL", http.MethodGet, "/api/v2/tailnet/-/acl", scope.PolicyFileRead, nil, false}, + {"setACL", http.MethodPost, "/api/v2/tailnet/-/acl", scope.PolicyFile, map[string]any{}, false}, + {"getSettings", http.MethodGet, "/api/v2/tailnet/-/settings", scope.FeatureSettingsRead, nil, false}, + {"updateSettings", http.MethodPatch, "/api/v2/tailnet/-/settings", scope.FeatureSettings, map[string]any{}, false}, + {"getKeyClient", http.MethodGet, "/api/v2/tailnet/-/keys/" + clientID, scope.OAuthKeysRead, nil, true}, + {"getKeyAuth", http.MethodGet, "/api/v2/tailnet/-/keys/" + authKeyID, scope.AuthKeysRead, nil, true}, + } + + // One token per scope, reused across every operation. + tokens := make(map[scope.Scope]string, len(scope.Known())) + for _, s := range scope.Known() { + tokens[s] = mintScopedToken(t, baseURL, admin, s) + } + + for _, op := range ops { + for _, s := range scope.Known() { + status, body := apiReq(t, op.method, baseURL+op.path, tokens[s], op.body) + + wantDenied := !scope.Grants([]scope.Scope{s}, op.need) + + // A multiplexed keys op denies via uniform not-found (no existence + // oracle), so denial is "resource not returned"; every other op + // denies with the middleware's scope-403. + denied := scopeDenied(status, body) + if op.multiplexed { + denied = status != http.StatusOK + } + + assert.Equalf(t, wantDenied, denied, + "op %s with scope %q (needs %q): status=%d body=%s", + op.name, s, op.need, status, body) + } + } +} + +// TestAPIv2OAuthMatrix_ScopeNarrowing proves P1 for token minting: a client may +// only mint a token for scopes within its own grant. For every held scope X and +// requested scope Y, the mint succeeds iff scope.Grants([X], Y). +func TestAPIv2OAuthMatrix_ScopeNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + for _, held := range scope.Known() { + _, secret := createClient(t, baseURL, admin, []string{string(held)}, []string{"tag:ci"}) + + for _, want := range scope.Known() { + status, m := mintToken(t, baseURL, "", secret, string(want), false) + + wantOK := scope.Grants([]scope.Scope{held}, want) + if wantOK { + assert.Equalf(t, http.StatusOK, status, "held %q narrow to %q: %v", held, want, m) + } else { + assert.Equalf(t, http.StatusBadRequest, status, + "held %q must not mint %q: %v", held, want, m) + assert.Equal(t, "invalid_scope", m["error"]) + } + } + } +} + +// TestAPIv2OAuthMatrix_TagNarrowing proves P1 for tags at mint time: a client may +// only mint a token for tags within its grant (closing the /oauth/token tags-param +// path). A client with the "all" scope may request any tag. +func TestAPIv2OAuthMatrix_TagNarrowing(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:a", "tag:b"}) + + // In-grant tags mint; an out-of-grant tag is rejected. + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + + for _, tag := range []string{"tag:a", "tag:b"} { + st, mm := mintTokenWithTags(t, baseURL, secret, tag) + assert.Equalf(t, http.StatusOK, st, "in-grant tag %q: %v", tag, mm) + } + + st, mm := mintTokenWithTags(t, baseURL, secret, "tag:c") + assert.Equal(t, http.StatusBadRequest, st, mm) + assert.Equal(t, "invalid_target", mm["error"]) + + // An all-scope client may request any tag. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:a"}) + stAll, mAll := mintTokenWithTags(t, baseURL, allSecret, "tag:anything") + assert.Equalf(t, http.StatusOK, stAll, "all-scope client may assign any tag: %v", mAll) +} + +// TestAPIv2OAuthMatrix_Lifecycle proves expired and revoked credentials are denied +// at the HTTP layer, and that an admin API key bypasses scope checks. +func TestAPIv2OAuthMatrix_Lifecycle(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + devicesPath := baseURL + "/api/v2/tailnet/-/devices" + + // Expired token → 401. + _, client, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "expired", nil) + require.NoError(t, err) + + past := time.Now().Add(-time.Hour) + expiredTok, _, err := app.state.MintAccessToken(client.ClientID, []string{"devices:core:read"}, nil, &past) + require.NoError(t, err) + + status, _ := apiReq(t, http.MethodGet, devicesPath, expiredTok, nil) + assert.Equal(t, http.StatusUnauthorized, status, "expired token must be rejected") + + // Revoked client → its token is denied. + _, revClient, err := app.state.CreateOAuthClient([]string{"devices:core:read"}, []string{"tag:ci"}, "revoked", nil) + require.NoError(t, err) + + future := time.Now().Add(time.Hour) + revTok, _, err := app.state.MintAccessToken(revClient.ClientID, []string{"devices:core:read"}, nil, &future) + require.NoError(t, err) + + // Works before revocation. + okStatus, okBody := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Falsef(t, scopeDenied(okStatus, okBody), "token should pass the scope gate before revoke: %d", okStatus) + + require.NoError(t, app.state.RevokeOAuthClient(revClient.ClientID)) + + revStatus, _ := apiReq(t, http.MethodGet, devicesPath, revTok, nil) + assert.Equal(t, http.StatusUnauthorized, revStatus, "token of a revoked client must be rejected") + + // Admin API key bypasses scope checks: it reaches a devices op that a minimal + // (feature_settings:read) token cannot. + minimalTok := mintScopedToken(t, baseURL, admin, scope.FeatureSettingsRead) + minStatus, minBody := apiReq(t, http.MethodGet, devicesPath, minimalTok, nil) + assert.True(t, scopeDenied(minStatus, minBody), "minimal token must be scope-denied on devices") + + adminStatus, adminBody := apiReq(t, http.MethodGet, devicesPath, admin, nil) + assert.Falsef(t, scopeDenied(adminStatus, adminBody), + "admin key is all-access and must not be scope-denied: %d %s", adminStatus, adminBody) +} + +// mintTokenWithTags mints with a tags narrowing parameter. +func mintTokenWithTags(t *testing.T, baseURL, secret, tags string) (int, map[string]any) { + t.Helper() + + form := fmt.Sprintf("grant_type=client_credentials&client_secret=%s&tags=%s", secret, tags) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} diff --git a/hscontrol/apiv2_oauth_test.go b/hscontrol/apiv2_oauth_test.go new file mode 100644 index 00000000..c3f61043 --- /dev/null +++ b/hscontrol/apiv2_oauth_test.go @@ -0,0 +1,511 @@ +package hscontrol + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + + apiv2 "github.com/juanfont/headscale/hscontrol/api/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newOAuthTestServer builds the full v2 API (the auth+scope middleware and the +// OAuth token endpoint, which the humatest harness in apiv2_keys_test.go does +// not mount) over a real httptest server, returning the app, its base URL, and +// an all-access admin API key. +func newOAuthTestServer(t *testing.T) (*Headscale, string, string) { + t.Helper() + + app := createTestApp(t) + + // Tag creation now requires the tag to exist in policy (matching + // SetNodeTags), so define the tags these tests assign. Tests needing + // specific tag ownership (e.g. delegation) override this policy. + const policy = `{"tagOwners":{"tag:a":[],"tag:b":[],"tag:c":[],"tag:ci":[],"tag:k8s":[],"tag:k8s-operator":[],"tag:other":[],"tag:anything":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + mux, _ := apiv2.Handler(apiv2.Backend{State: app.state, Change: app.Change, Cfg: app.cfg}) + srv := httptest.NewServer(mux) + t.Cleanup(srv.Close) + + adminKey, _, err := app.state.CreateAPIKey(nil) + require.NoError(t, err) + + return app, srv.URL, adminKey +} + +// apiPost POSTs a JSON body with an optional bearer token, returning the status +// and raw body. It owns the response body so callers never leak it. +func apiPost(t *testing.T, target, bearer string, body any) (int, []byte) { + t.Helper() + + b, err := json.Marshal(body) + require.NoError(t, err) + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, target, bytes.NewReader(b)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + return resp.StatusCode, data +} + +// apiGet GETs a target with an optional bearer token, returning the status. It +// drains and closes the response body so callers never leak it. +func apiGet(t *testing.T, target, bearer string) int { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, target, nil) + require.NoError(t, err) + + if bearer != "" { + req.Header.Set("Authorization", "Bearer "+bearer) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + _, _ = io.Copy(io.Discard, resp.Body) + + return resp.StatusCode +} + +// createClient creates an OAuth client through the v2 keys API as the bearer +// credential, returning the client id and the once-shown secret. +func createClient(t *testing.T, baseURL, bearer string, scopes, tags []string) (string, string) { + t.Helper() + + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + KeyType: "client", + Scopes: scopes, + Tags: tags, + }) + require.Equalf(t, http.StatusOK, status, "create client: %s", body) + + var key apiv2.Key + require.NoError(t, json.Unmarshal(body, &key)) + assert.Equal(t, "client", key.KeyType) + require.NotEmpty(t, key.ID, "client id returned") + require.NotEmpty(t, key.Key, "secret returned once on create") + + return key.ID, key.Key +} + +// mintToken runs the client-credentials grant. scope is an optional space-delimited +// narrowing of the client's scopes. credsInBasic sends the secret via HTTP Basic +// instead of the body. Returns the status and decoded JSON body. +func mintToken(t *testing.T, baseURL, clientID, secret, scope string, credsInBasic bool) (int, map[string]any) { + t.Helper() + + form := url.Values{"grant_type": {"client_credentials"}} + if scope != "" { + form.Set("scope", scope) + } + + if !credsInBasic { + form.Set("client_secret", secret) + } + + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + if credsInBasic { + req.SetBasicAuth(clientID, secret) + } + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m +} + +// tokenFor mints and returns a bearer access token for the client. +func tokenFor(t *testing.T, baseURL, secret string) string { + t.Helper() + + status, m := mintToken(t, baseURL, "", secret, "", false) + require.Equalf(t, http.StatusOK, status, "mint token: %v", m) + + tok, _ := m["access_token"].(string) + require.NotEmpty(t, tok) + + return tok +} + +// createTaggedKey attempts to create a tagged auth key and returns the HTTP +// status, so allow/deny is asserted by the caller. +func createTaggedKey(t *testing.T, baseURL, bearer string, tags []string) int { + t.Helper() + + status, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", bearer, apiv2.CreateKeyRequest{ + Capabilities: &apiv2.KeyCapabilities{Devices: apiv2.KeyDeviceCapabilities{ + Create: apiv2.KeyDeviceCreateCapabilities{Reusable: true, Tags: tags}, + }}, + }) + + return status +} + +func TestAPIv2OAuth_TokenEndpoint(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + + // The secret embeds the client id (Tailscale's derive-from-secret trick). + assert.Contains(t, secret, clientID) + + // Mint via the request body. + status, m := mintToken(t, baseURL, clientID, secret, "", false) + require.Equalf(t, http.StatusOK, status, "%v", m) + assert.Equal(t, "Bearer", m["token_type"]) + assert.InDelta(t, 3600, m["expires_in"], 0, "fixed 1h, in seconds") + tok, _ := m["access_token"].(string) + assert.Contains(t, tok, "hskey-oauthtok-", "distinct prefix from admin keys") + + // Mint via HTTP Basic (x/oauth2 auto-detect probes Basic first). + statusBasic, mBasic := mintToken(t, baseURL, clientID, secret, "", true) + require.Equalf(t, http.StatusOK, statusBasic, "%v", mBasic) + assert.NotEmpty(t, mBasic["access_token"]) + + // Bad credentials → RFC 6749 invalid_client. + statusBad, mBad := mintToken(t, baseURL, clientID, "hskey-client-deadbeefdead-"+stringOf("0", 64), "", false) + assert.Equal(t, http.StatusUnauthorized, statusBad) + assert.Equal(t, "invalid_client", mBad["error"]) + + // The token response carries a bearer credential and must not be cached + // (RFC 6749 §5.1). mintToken consumes the body, so probe the header raw. + form := url.Values{"grant_type": {"client_credentials"}, "client_secret": {secret}} + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, + baseURL+"/api/v2/oauth/token", strings.NewReader(form.Encode())) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + resp.Body.Close() + assert.Equal(t, "no-store", resp.Header.Get("Cache-Control")) +} + +func TestAPIv2OAuth_ScopeEnforcement(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A write-scoped token may create an auth key within its tag grant... + _, writeSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + writeTok := tokenFor(t, baseURL, writeSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, writeTok, []string{"tag:ci"}), + "write scope + in-grant tag allowed") + + // ...but not with a tag outside its grant. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, writeTok, []string{"tag:other"}), + "tag outside grant denied") + + // A read-only token is denied the write. + _, readSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + readTok := tokenFor(t, baseURL, readSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, readTok, []string{"tag:ci"}), + "read scope denied write") + + // The wrong write scope (devices:core) cannot create auth keys. + _, devSecret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + devTok := tokenFor(t, baseURL, devSecret) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, devTok, []string{"tag:ci"}), + "wrong scope denied") + + // The "all" super-scope grants auth_keys. + _, allSecret := createClient(t, baseURL, admin, []string{"all"}, []string{"tag:ci"}) + allTok := tokenFor(t, baseURL, allSecret) + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, allTok, []string{"tag:ci"}), + "all grants auth_keys") + + // A token minted from the all-powerful client but narrowed to read-only is + // itself denied writes: narrowing is enforced on the minted token. + statusNarrow, mNarrow := mintToken(t, baseURL, "", allSecret, "auth_keys:read", false) + require.Equalf(t, http.StatusOK, statusNarrow, "narrow mint: %v", mNarrow) + narrowed, _ := mNarrow["access_token"].(string) + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, narrowed, []string{"tag:ci"}), + "token narrowed to :read denied write") +} + +func TestAPIv2OAuth_ClientManagementScopes(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // An oauth_keys token may create a client within its own grant... + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + status, body := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"oauth_keys:read"}, + }) + assert.Equalf(t, http.StatusOK, status, "oauth_keys creates an in-grant client: %s", body) + + // ...but may NOT escalate by granting the new client a scope the token lacks. + statusEsc, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", okTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusEsc, "oauth_keys token cannot mint a broader client") + + // A token without oauth_keys cannot manage clients at all. + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + statusDeny, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", akTok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:ci"}, + }) + assert.Equal(t, http.StatusForbidden, statusDeny, "auth_keys (no oauth_keys) cannot create a client") +} + +func TestAPIv2OAuth_TagOwnedBy(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // tag:k8s is owned by tag:k8s-operator: the operator's tag delegation. + // tag:other exists but is owned by no one, so it tests grant denial (403) + // rather than tag-not-in-policy (400). + const policy = `{"tagOwners":{"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + _, err := app.state.SetPolicy([]byte(policy)) + require.NoError(t, err) + _, err = app.state.SetPolicyInDB(policy) + require.NoError(t, err) + _, err = app.state.ReloadPolicy() + require.NoError(t, err) + + _, secret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:k8s-operator"}) + tok := tokenFor(t, baseURL, secret) + + // Exact-match tag: allowed. + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s-operator"}), + "a token may use its own tag") + + // Owned-by tag: allowed (tag:k8s is owned by tag:k8s-operator). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, tok, []string{"tag:k8s"}), + "a token may use a tag owned by its tag") + + // Unrelated tag: denied. + assert.Equal(t, http.StatusForbidden, createTaggedKey(t, baseURL, tok, []string{"tag:other"}), + "a token may not use an unowned tag") +} + +// TestAPIv2OAuth_NoClientExistenceOracle asserts a token that cannot read OAuth +// clients gets the same response for a real client id as for a missing key, so +// it cannot enumerate which ids are OAuth clients. +func TestAPIv2OAuth_NoClientExistenceOracle(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + + // A token holding only auth_keys:read (no oauth_keys:read). + _, akSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akTok := tokenFor(t, baseURL, akSecret) + + statusReal := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, akTok) + statusMissing := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/deadbeefdead", akTok) + + assert.Equal(t, statusMissing, statusReal, + "a token without oauth_keys:read must not distinguish a real client id from a missing key") + assert.NotEqual(t, http.StatusOK, statusReal, "the client must not be readable") + + // A token that does hold oauth_keys:read can read the client. + _, okSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okTok := tokenFor(t, baseURL, okSecret) + + statusOK := apiGet(t, baseURL+"/api/v2/tailnet/-/keys/"+clientID, okTok) + assert.Equal(t, http.StatusOK, statusOK, "oauth_keys:read may read the client") +} + +// TestAPIv2OAuth_SetDeviceTagsGrant asserts a devices:core token may only set +// tags within its grant on a device. Without the grant check, the scope alone +// would let a token stamp any existing policy tag (e.g. tag:other) onto any node. +func TestAPIv2OAuth_SetDeviceTagsGrant(t *testing.T) { + app, baseURL, admin := newOAuthTestServer(t) + + // A registered, user-owned node to retag. + user := app.state.CreateUserForTest("dut") + node := app.state.CreateRegisteredNodeForTest(user, "dut") + node.User = user + view := app.state.PutNodeInStoreForTest(*node) + devURL := baseURL + "/api/v2/device/" + view.StringID() + "/tags" + + // A devices:core token granted only tag:ci. + _, secret := createClient(t, baseURL, admin, []string{"devices:core"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // In-grant tag is allowed. + st, body := apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:ci"}}) + assert.Equalf(t, http.StatusOK, st, "in-grant tag: %s", body) + + // An existing policy tag outside the token's grant is denied, not silently set. + st, _ = apiPost(t, devURL, tok, map[string]any{"tags": []string{"tag:other"}}) + assert.Equal(t, http.StatusForbidden, st, "out-of-grant tag must be denied") + + // An admin API key is unrestricted. + st, body = apiPost(t, devURL, admin, map[string]any{"tags": []string{"tag:other"}}) + assert.Equalf(t, http.StatusOK, st, "admin may set any policy tag: %s", body) +} + +// TestAPIv2OAuth_UndefinedTagRejected asserts an OAuth token cannot create a +// client or auth key carrying a tag absent from policy (matching SetNodeTags), +// while an admin key retains the historical syntax-only validation. +func TestAPIv2OAuth_UndefinedTagRejected(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + + // A token that may create clients and auth keys, holding tag:ci (in policy). + _, secret := createClient(t, baseURL, admin, []string{"oauth_keys", "auth_keys"}, []string{"tag:ci"}) + tok := tokenFor(t, baseURL, secret) + + // A token-created auth key with a tag not in policy is rejected. + assert.Equal(t, http.StatusBadRequest, createTaggedKey(t, baseURL, tok, []string{"tag:undefined"}), + "token may not create an auth key with an undefined tag") + + // A token-created client with a tag not in policy is rejected. + st, _ := apiPost(t, baseURL+"/api/v2/tailnet/-/keys", tok, apiv2.CreateKeyRequest{ + KeyType: "client", Scopes: []string{"auth_keys"}, Tags: []string{"tag:undefined"}, + }) + assert.Equal(t, http.StatusBadRequest, st, "token may not create a client with an undefined tag") + + // An admin key keeps the historical behaviour: a syntactically valid but + // undefined tag is accepted (consistent with the v1/CLI pre-auth-key path). + assert.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:adminhistorical"}), + "admin retains syntax-only tag validation") +} + +// TestAPIv2OAuth_DenialBranches covers the token-endpoint and bearer-dispatch +// failure paths: each must fail closed with the right status and no session. +func TestAPIv2OAuth_DenialBranches(t *testing.T) { + _, baseURL, _ := newOAuthTestServer(t) + tokenURL := baseURL + "/api/v2/oauth/token" + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + post := func(form string) (int, map[string]any) { + req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, tokenURL, strings.NewReader(form)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + + defer resp.Body.Close() + + data, _ := io.ReadAll(resp.Body) + + var m map[string]any + + _ = json.Unmarshal(data, &m) + + return resp.StatusCode, m + } + + // No credentials → invalid_client. + st, m := post("grant_type=client_credentials") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Wrong grant_type → unsupported_grant_type. + st, m = post("grant_type=authorization_code&client_secret=x") + assert.Equal(t, http.StatusBadRequest, st) + assert.Equal(t, "unsupported_grant_type", m["error"]) + + // Unknown secret → invalid_client. + st, m = post("grant_type=client_credentials&client_secret=not-a-real-secret") + assert.Equal(t, http.StatusUnauthorized, st) + assert.Equal(t, "invalid_client", m["error"]) + + // Bearer dispatch: every malformed/unknown bearer is unauthorized. + for _, bearer := range []string{ + "hskey-oauthtok-deadbeefdead-" + stringOf("0", 64), // well-formed prefix, unknown token + "hskey-oauthtok-garbage", // malformed OAuth token + "hskey-client-deadbeefdead-" + stringOf("0", 64), // client secret presented as API bearer + "totally-bogus", + } { + assert.Equalf(t, http.StatusUnauthorized, apiGet(t, keysURL, bearer), + "bearer %q must be unauthorized", bearer) + } +} + +// TestAPIv2OAuth_KeysMultiplexIsolation asserts the multiplexed keys endpoint +// keeps the two kinds isolated by scope: a token cannot list or delete a kind it +// lacks the scope for, and an admin key sees both. +func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) { + _, baseURL, admin := newOAuthTestServer(t) + keysURL := baseURL + "/api/v2/tailnet/-/keys" + + clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"})) + + listKinds := func(bearer string) map[string]int { + st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil) + require.Equalf(t, http.StatusOK, st, "%s", body) + + var out struct { + Keys []apiv2.Key `json:"keys"` + } + + require.NoError(t, json.Unmarshal(body, &out)) + + kinds := map[string]int{} + for _, k := range out.Keys { + kinds[k.KeyType]++ + } + + return kinds + } + + _, akReadSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, []string{"tag:ci"}) + akKinds := listKinds(tokenFor(t, baseURL, akReadSecret)) + assert.Zero(t, akKinds["client"], "auth_keys:read must not see OAuth clients") + assert.Positive(t, akKinds["auth"], "auth_keys:read sees auth keys") + + _, okReadSecret := createClient(t, baseURL, admin, []string{"oauth_keys:read"}, []string{"tag:ci"}) + okKinds := listKinds(tokenFor(t, baseURL, okReadSecret)) + assert.Zero(t, okKinds["auth"], "oauth_keys:read must not see auth keys") + assert.Positive(t, okKinds["client"], "oauth_keys:read sees OAuth clients") + + adminKinds := listKinds(admin) + assert.Positive(t, adminKinds["auth"]) + assert.Positive(t, adminKinds["client"]) + + // An auth_keys (write) token cannot delete an OAuth client; it survives. + _, akWriteSecret := createClient(t, baseURL, admin, []string{"auth_keys"}, []string{"tag:ci"}) + stDel, _ := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, akWriteSecret), nil) + assert.NotEqual(t, http.StatusOK, stDel, "auth_keys token must not delete an OAuth client") + assert.Positive(t, listKinds(admin)["client"], "client survives the denied delete") + + // An oauth_keys (write) token deletes the client. + _, okWriteSecret := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) + stDel2, body2 := apiReq(t, http.MethodDelete, keysURL+"/"+clientID, tokenFor(t, baseURL, okWriteSecret), nil) + assert.Equalf(t, http.StatusOK, stDel2, "oauth_keys deletes the client: %s", body2) +} + +func stringOf(s string, n int) string { + return strings.Repeat(s, n) +} diff --git a/hscontrol/servertest/apiv2_oauth_scopes_test.go b/hscontrol/servertest/apiv2_oauth_scopes_test.go new file mode 100644 index 00000000..5d3cff67 --- /dev/null +++ b/hscontrol/servertest/apiv2_oauth_scopes_test.go @@ -0,0 +1,391 @@ +package servertest_test + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/juanfont/headscale/hscontrol/servertest" + "github.com/stretchr/testify/require" +) + +// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the +// REAL Tailscale Terraform provider's client-credentials flow: the provider mints +// a scoped access token at /api/v2/oauth/token, then runs one operation whose +// allow/deny outcome must match the token's scope and tag grant. +// +// Each client is created with exactly the scopes/tags under test, and the +// provider requests no scope narrowing, so the minted token carries the client's +// full grant. The matrix covers right-scope-allowed, wrong-scope-denied, read vs +// write, the "all" super-scope, and policy tag ownership (owned-by delegation). +// +// The oauth_keys rows drive the real provider's tailscale_oauth_client resource, +// which omits the "capabilities" body field, so the request schema must make it +// optional (it is). Drift is not asserted on the auth-key rows: the provider +// defaults preauthorized=true on create but the server reads tagged keys back as +// false, so a converged plan is never empty. That is orthogonal to scope +// enforcement, so a clean apply is the allow proof. +// +// tofu is required, not optional: it ships in the nix dev shell, so a missing +// binary means a broken environment and the test fails rather than skipping. +func TestAPIv2OAuthScopes(t *testing.T) { + srv := servertest.NewServer(t, servertest.WithRealListener()) + owner := srv.CreateUser(t, "apiv2-oauth") + creator := owner.ID + + // scopeMatrixPolicy declares every tag the matrix touches: tag:ci for the + // auth-key rows, and the tag:k8s/tag:k8s-operator delegation for the + // owned-by rows. + setScopeMatrixPolicy(t, srv) + + // A registered node is the target for the device-scope rows. Its decimal id + // is the device id the v2 API and the provider's device resources address. + // A devices:core write token also grants devices:core:read, so whatever + // get/post sequence the provider runs within the core family is satisfied; + // the read-scope row then fails on the write, proving read vs write end to + // end. devices:routes and feature_settings are proven exhaustively by the Go + // matrix (apiv2_oauth_matrix_test.go), not here: the provider's routes + // resource issues a cross-family device read a routes-only token would lack, + // and the server's settings endpoint is read-only. + deviceID := srv.CreateRegisteredNode(t, owner).StringID() + deviceAuthorizeConfig := ` +resource "tailscale_device_authorization" "d" { + device_id = "` + deviceID + `" + authorized = true +} +` + + // The configs each exercise exactly one operation so a row's allow/deny + // outcome is unambiguous. + const ( + authKeyConfig = ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["tag:ci"] +} +` + aclConfig = ` +resource "tailscale_acl" "policy" { + acl = jsonencode({ + tagOwners = { + "tag:ci" = ["apiv2-oauth@"] + "tag:k8s-operator" = [] + "tag:k8s" = ["tag:k8s-operator"] + "tag:other" = [] + } + acls = [{ action = "accept", src = ["*"], dst = ["*:*"] }] + }) + overwrite_existing_content = true +} +` + devicesReadConfig = ` +data "tailscale_devices" "all" {} +output "device_count" { value = length(data.tailscale_devices.all.devices) } +` + // oauthClientConfig creates an OAuth client whose scope (oauth_keys:read) + // is within an oauth_keys grant, so the only variable under test is whether + // the caller may manage clients at all. + oauthClientConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-client" + scopes = ["oauth_keys:read"] +} +` + // oauthClientEscalateConfig requests a scope (devices:core:read) that an + // oauth_keys-only caller does not itself hold, so the escalation guard must + // reject minting a broader client. + oauthClientEscalateConfig = ` +resource "tailscale_oauth_client" "c" { + description = "oauth-scope-matrix-escalate" + scopes = ["devices:core:read"] +} +` + ) + + // k8sAuthKeyConfig templates the auth-key tag for the owned-by rows. + k8sAuthKeyConfig := func(tag string) string { + return ` +resource "tailscale_tailnet_key" "k" { + reusable = true + ephemeral = false + expiry = 3600 + description = "oauth-scope-matrix" + tags = ["` + tag + `"] +} +` + } + + tests := []struct { + name string + // scopes/tags the OAuth client (and thus its minted token) holds. + scopes []string + tags []string + // config is the single-operation HCL applied through the OAuth provider. + config string + // deny asserts apply fails; denyContains lists substrings, any of which + // the failure output must contain. allow asserts a clean apply. + deny bool + denyContains []string + }{ + // auth_keys: write scope mints an auth key; read/wrong/all-read do not. + { + name: "auth_keys allows tailnet_key", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "auth_keys:read denies tailnet_key", + scopes: []string{"auth_keys:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "devices:core denies tailnet_key", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "all allows tailnet_key", + scopes: []string{"all"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + }, + { + name: "all:read denies tailnet_key", + scopes: []string{"all:read"}, + tags: []string{"tag:ci"}, + config: authKeyConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // oauth_keys: managing OAuth clients. Read and wrong scopes are denied, and + // a caller cannot mint a client carrying authority it lacks (escalation). + { + name: "oauth_keys allows oauth_client", + scopes: []string{"oauth_keys"}, + config: oauthClientConfig, + }, + { + name: "oauth_keys:read denies oauth_client", + scopes: []string{"oauth_keys:read"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "auth_keys denies oauth_client", + scopes: []string{"auth_keys"}, + tags: []string{"tag:ci"}, + config: oauthClientConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "oauth_keys cannot escalate client scope", + scopes: []string{"oauth_keys"}, + config: oauthClientEscalateConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "beyond the creating token"}, + }, + + // policy_file: write scope sets the ACL; read does not. + { + name: "policy_file allows acl", + scopes: []string{"policy_file"}, + config: aclConfig, + }, + { + name: "policy_file:read denies acl", + scopes: []string{"policy_file:read"}, + config: aclConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core:read reads the devices data source. + { + name: "devices:core:read allows devices read", + scopes: []string{"devices:core:read"}, + config: devicesReadConfig, + }, + { + name: "policy_file:read denies devices read", + scopes: []string{"policy_file:read"}, + config: devicesReadConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // devices:core: a write-scoped token authorizes a device; the read scope + // and a wrong-family scope are denied (read vs write through the real + // provider's tailscale_device_authorization resource). + { + name: "devices:core allows device authorize", + scopes: []string{"devices:core"}, + tags: []string{"tag:ci"}, + config: deviceAuthorizeConfig, + }, + { + name: "devices:core:read denies device authorize", + scopes: []string{"devices:core:read"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + { + name: "policy_file denies device authorize", + scopes: []string{"policy_file"}, + config: deviceAuthorizeConfig, + deny: true, + denyContains: []string{"403", "Forbidden", "missing the required scope"}, + }, + + // Tag owned-by: a tag:k8s-operator client may use its own tag and the + // tag:k8s it owns, but not an unowned tag. + { + name: "owned-by exact tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s-operator"), + }, + { + name: "owned-by delegated tag allowed", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:k8s"), + }, + { + name: "owned-by unowned tag denied", + scopes: []string{"auth_keys"}, + tags: []string{"tag:k8s-operator"}, + config: k8sAuthKeyConfig("tag:other"), + deny: true, + denyContains: []string{"403", "Forbidden", "is not owned"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + secret, client, err := srv.State().CreateOAuthClient(tt.scopes, tt.tags, "scope-matrix", &creator) + require.NoError(t, err) + + tf := newTofuOAuth(t, srv.URL, client.ClientID, secret, oauthHCL(tt.config)) + tf.run("init", "-no-color", "-input=false") + + if tt.deny { + tf.runExpectError(t, tt.denyContains...) + return + } + + tf.run("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1") + }) + } +} + +// setScopeMatrixPolicy installs the policy the scope matrix relies on: tag:ci for +// the auth-key rows, and the tag:k8s-operator → tag:k8s delegation for the +// owned-by rows. +func setScopeMatrixPolicy(t *testing.T, srv *servertest.TestServer) { + t.Helper() + + // tag:other exists but is owned by no one, so the owned-by denial row tests a + // grant denial (403) rather than a tag-not-in-policy rejection (400). + const policy = `{"tagOwners":{"tag:ci":["apiv2-oauth@"],"tag:k8s-operator":[],"tag:k8s":["tag:k8s-operator"],"tag:other":[]},"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}` + + st := srv.State() + + _, err := st.SetPolicy([]byte(policy)) + require.NoError(t, err) + + _, err = st.SetPolicyInDB(policy) + require.NoError(t, err) + + _, err = st.ReloadPolicy() + require.NoError(t, err) +} + +// oauthHCL wraps a single-operation body with the provider block. The provider +// authenticates via the OAuth env vars newTofuOAuth sets, so the block is empty. +func oauthHCL(body string) string { + return ` +terraform { + required_providers { + tailscale = { + source = "tailscale/tailscale" + version = "~> 0.21" + } + } +} + +provider "tailscale" {} +` + body +} + +// newTofuOAuth is a newTofu variant whose provider authenticates with OAuth +// client credentials instead of an API key: it sets TAILSCALE_OAUTH_CLIENT_ID / +// TAILSCALE_OAUTH_CLIENT_SECRET (the env vars the tailscale/tailscale provider +// honors), so the real provider runs the client-credentials grant against +// baseURL/api/v2/oauth/token. The client id is embedded in the secret, so the +// secret embeds the client id (the provider sends both; the server derives the +// client from the secret alone). +func newTofuOAuth(t *testing.T, baseURL, clientID, clientSecret, config string) *tofu { + t.Helper() + + bin, err := exec.LookPath("tofu") + require.NoErrorf(t, err, "tofu is required for TestAPIv2OAuthScopes (provided by the nix dev shell)") + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.tf"), []byte(config), 0o600)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "plugin-cache"), 0o755)) + + env := append( + os.Environ(), + "TAILSCALE_BASE_URL="+baseURL, + "TAILSCALE_OAUTH_CLIENT_ID="+clientID, + "TAILSCALE_OAUTH_CLIENT_SECRET="+clientSecret, + "TAILSCALE_TAILNET=-", + // Keep provider plugins inside the temp dir so the run is self-contained. + "TF_PLUGIN_CACHE_DIR="+filepath.Join(dir, "plugin-cache"), + ) + + cmd := func(args ...string) *exec.Cmd { + c := exec.CommandContext(t.Context(), bin, args...) + c.Dir = dir + c.Env = env + + return c + } + + return &tofu{t: t, cmd: cmd} +} + +// runExpectError runs apply expecting FAILURE, asserting the combined output +// contains at least one of mustContain. This is the deny half of every matrix +// row: a scoped token attempting an operation it lacks the scope (or tag) for. +func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) { + t.Helper() + + out, err := tf.cmd("apply", "-auto-approve", "-no-color", "-input=false", "-parallelism=1").CombinedOutput() + require.Errorf(t, err, "expected apply to fail, but it succeeded:\n%s", out) + + combined := string(out) + for _, want := range mustContain { + if strings.Contains(combined, want) { + return + } + } + + t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined) +} diff --git a/integration/cli_oauth_clients_test.go b/integration/cli_oauth_clients_test.go new file mode 100644 index 00000000..8c6a11d6 --- /dev/null +++ b/integration/cli_oauth_clients_test.go @@ -0,0 +1,126 @@ +package integration + +import ( + "encoding/json" + "testing" + "time" + + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// cliOAuthClient is the JSON the `oauth-clients` commands emit (the v2 Key shape, +// only the fields the test asserts on). +type cliOAuthClient struct { + ID string `json:"id"` + Key string `json:"key"` + KeyType string `json:"keyType"` + Scopes []string `json:"scopes"` + Tags []string `json:"tags"` + Description string `json:"description"` +} + +// TestOAuthClientCommand exercises the `headscale oauth-clients` CLI end to end: +// create (with scopes and tags) -> list (secret hidden) -> delete. The CLI talks +// to the v2 keys handler over the local unix socket, so this also proves the v2 +// API is reachable over the socket with local trust. +func TestOAuthClientCommand(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario(ScenarioSpec{Users: []string{}}) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclient")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + // Create an OAuth client with scopes and a tag. devices:core/auth_keys + // require a tag, which is supplied. + createOut, err := headscale.Execute([]string{ + "headscale", "oauth-clients", "create", + "--scope", "auth_keys", + "--scope", "devices:core", + "--tag", "tag:k8s-operator", + "--description", "operator", + "--output", "json", + }) + require.NoError(t, err) + + var created cliOAuthClient + require.NoError(t, json.Unmarshal([]byte(createOut), &created)) + assert.Equal(t, "client", created.KeyType) + assert.NotEmpty(t, created.ID, "client id returned") + assert.NotEmpty(t, created.Key, "secret returned once on create") + assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, created.Scopes) + assert.Equal(t, []string{"tag:k8s-operator"}, created.Tags) + + // List shows the client without its secret. + var listed []cliOAuthClient + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "oauth-clients", "list", "--output", "json"}, &listed) + assert.NoError(c, err) + assert.Len(c, listed, 1) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list") + + assert.Equal(t, created.ID, listed[0].ID) + assert.Empty(t, listed[0].Key, "secret is never exposed on list") + assert.ElementsMatch(t, []string{"auth_keys", "devices:core"}, listed[0].Scopes) + assert.Equal(t, "operator", listed[0].Description) + + // Delete it. + _, err = headscale.Execute([]string{"headscale", "oauth-clients", "delete", "--id", created.ID}) + require.NoError(t, err) + + var afterDelete []cliOAuthClient + + assert.EventuallyWithT(t, func(c *assert.CollectT) { + err := executeAndUnmarshal(headscale, + []string{"headscale", "oauth-clients", "list", "--output", "json"}, &afterDelete) + assert.NoError(c, err) + assert.Empty(c, afterDelete) + }, integrationutil.ScaledTimeout(10*time.Second), integrationutil.FastPoll, "waiting for oauth client list after delete") +} + +// TestOAuthClientCommandValidation covers the CLI's input validation and the +// server's 404 on deleting an unknown client. +func TestOAuthClientCommandValidation(t *testing.T) { + IntegrationSkip(t) + + scenario, err := NewScenario(ScenarioSpec{Users: []string{}}) + require.NoError(t, err) + + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv([]tsic.Option{}, hsic.WithTestName("cli-oauthclientval")) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + tests := []struct { + name string + args []string + wantErr string + }{ + {name: "no scope", args: []string{"oauth-clients", "create"}, wantErr: "at least one --scope is required"}, + {name: "devices:core needs tag", args: []string{"oauth-clients", "create", "--scope", "devices:core"}, wantErr: "tags are required"}, + {name: "delete no id", args: []string{"oauth-clients", "delete"}, wantErr: "--id is required"}, + {name: "delete nonexistent id", args: []string{"oauth-clients", "delete", "--id", "doesnotexist"}, wantErr: "404"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := headscale.Execute(append([]string{"headscale"}, tt.args...)) + require.ErrorContains(t, err, tt.wantErr) + }) + } +}