hscontrol, integration: test OAuth scope enforcement and the oauth-clients CLI

Add scope-enforcement coverage and integration tests for the
oauth-clients CLI across the test matrix.
This commit is contained in:
Kristoffer Dalby
2026-06-21 17:35:44 +00:00
parent b05f8875c3
commit af46fe8867
6 changed files with 1368 additions and 0 deletions
+2
View File
@@ -275,6 +275,8 @@ jobs:
- TestNodeTagCommand
- TestNodeRouteCommands
- TestNodeBackfillIPsCommand
- TestOAuthClientCommand
- TestOAuthClientCommandValidation
- TestPolicyCheckCommand
- TestSSHTestsRejectFailingPolicy
- TestPolicyCommand
+58
View File
@@ -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,
}
}
+280
View File
@@ -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
}
+511
View File
@@ -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)
}
@@ -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)
}
+126
View File
@@ -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)
})
}
}