mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
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:
@@ -275,6 +275,8 @@ jobs:
|
||||
- TestNodeTagCommand
|
||||
- TestNodeRouteCommands
|
||||
- TestNodeBackfillIPsCommand
|
||||
- TestOAuthClientCommand
|
||||
- TestOAuthClientCommandValidation
|
||||
- TestPolicyCheckCommand
|
||||
- TestSSHTestsRejectFailingPolicy
|
||||
- TestPolicyCommand
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user