headscale/hscontrol/apiv2_oauth_test.go
Kristoffer Dalby af46fe8867 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.
2026-06-26 09:18:06 +02:00

511 lines
20 KiB
Go

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)
}