Files
headscale/hscontrol/servertest/apiv2_oauth_scopes_test.go
T
Kristoffer Dalby bb3c0b4cd6 servertest: drive tailscale client oauthkey flow against v2 API
Real feature/oauthkey resolver via tsnet: tskey-client- secret,
?baseURL attributes, CreateKey, register re-advertising key tags.
2026-09-25 17:09:19 +02:00

611 lines
20 KiB
Go

package servertest_test
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/juanfont/headscale/hscontrol/servertest"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "tailscale.com/feature/oauthkey"
"tailscale.com/tsnet"
"tailscale.com/types/logger"
)
// 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)
}
// TestAPIv2OAuthTailscaleClientAuthKey drives the tailscale client's own
// feature/oauthkey resolver (via tsnet, as `tailscale up --authkey` does) with a
// tskey-client- secret against headscale: the client parses the ?attributes,
// exchanges the secret at baseURL, mints a tagged key via CreateKey, and
// registers re-advertising the key's tags. Each row pins what the client sends
// and what headscale must do with it.
func TestAPIv2OAuthTailscaleClientAuthKey(t *testing.T) {
srv := servertest.NewServer(t, servertest.WithRealListener())
owner := srv.CreateUser(t, "apiv2-oauth")
setScopeMatrixPolicy(t, srv)
newClient := func() (string, *types.OAuthClient) {
secret, client, err := srv.State().CreateOAuthClient(
[]string{"auth_keys"}, []string{"tag:ci"}, "tsnet-oauthkey", &owner.ID,
)
require.NoError(t, err)
return secret, client
}
toTS := func(secret string) string {
return types.TailscaleOAuthClientPrefix +
strings.TrimPrefix(secret, types.OAuthClientPrefix)
}
secret, client := newClient()
tsSecret := toTS(secret)
wrongSecret := types.TailscaleOAuthClientPrefix + client.ClientID + "-" +
strings.Repeat("0", 64)
revoked, revokedClient := newClient()
revokedSecret := toTS(revoked)
require.NoError(t, srv.State().RevokeOAuthClient(revokedClient.ClientID))
base := "?baseURL=" + srv.URL
tests := []struct {
name string
authKey string
// clientSecret passes authKey via tsnet's ClientSecret (TS_CLIENT_SECRET,
// `tailscale up --client-secret`) instead of AuthKey.
clientSecret bool
tags []string
wantErr string
wantEphemeral bool
}{
{
name: "ephemeral_preauthorized",
authKey: tsSecret + base + "&ephemeral=true&preauthorized=true",
tags: []string{"tag:ci"},
wantEphemeral: true,
},
{
name: "not_ephemeral",
authKey: tsSecret + base + "&ephemeral=false",
tags: []string{"tag:ci"},
},
{
// The client defaults ephemeral to true when unset.
name: "baseurl_only_defaults_ephemeral",
authKey: tsSecret + base,
tags: []string{"tag:ci"},
wantEphemeral: true,
},
{
// Headscale always authorizes pre-auth-key nodes; preauthorized=false
// does not hold the node for approval.
name: "preauthorized_false",
authKey: tsSecret + base + "&preauthorized=false",
tags: []string{"tag:ci"},
wantEphemeral: true,
},
{
name: "attribute_order_irrelevant",
authKey: tsSecret + "?ephemeral=false&baseURL=" + srv.URL,
tags: []string{"tag:ci"},
},
{
// An empty value means the default.
name: "empty_value_uses_default",
authKey: tsSecret + base + "&ephemeral=",
tags: []string{"tag:ci"},
wantEphemeral: true,
},
{
name: "client_secret_entry_point",
authKey: tsSecret + base,
clientSecret: true,
tags: []string{"tag:ci"},
wantEphemeral: true,
},
{
// The client appends "/api/v2/..." verbatim, so a trailing slash
// yields "//api/v2/oauth/token", which headscale does not route.
name: "baseurl_trailing_slash",
authKey: tsSecret + base + "/",
tags: []string{"tag:ci"},
wantErr: "404",
},
{
name: "invalid_bool",
authKey: tsSecret + base + "&ephemeral=maybe",
tags: []string{"tag:ci"},
wantErr: `ephemeral value "maybe"`,
},
{
name: "baseurl_not_headscale",
authKey: tsSecret + "?baseURL=http://127.0.0.1:1",
tags: []string{"tag:ci"},
wantErr: "connection refused",
},
{
// Without the tskey-client- prefix the client skips the exchange and
// presents the raw secret as a pre-auth key, which is not one.
name: "hskey_prefix_not_exchanged",
authKey: secret + base,
tags: []string{"tag:ci"},
wantErr: "invalid pre auth key",
},
{
// A tag outside the client's grant is refused at CreateKey.
name: "tag_not_granted",
authKey: tsSecret + base,
tags: []string{"tag:other"},
wantErr: "may not assign tag tag:other",
},
{
name: "wrong_secret",
authKey: wrongSecret + base,
tags: []string{"tag:ci"},
wantErr: `"invalid_client"`,
},
{
name: "revoked_client",
authKey: revokedSecret + base,
tags: []string{"tag:ci"},
wantErr: `"invalid_client"`,
},
{
// Only ephemeral, preauthorized and baseURL are understood; the
// client rejects anything else before contacting headscale.
name: "unknown_attribute",
authKey: tsSecret + base + "&reusable=true",
tags: []string{"tag:ci"},
wantErr: `unknown attribute "reusable"`,
},
{
name: "no_advertise_tags",
authKey: tsSecret + base,
wantErr: "require --advertise-tags",
},
{
// The GitHub Action's oauth-secret input appends its own query to the
// secret, folding it into baseURL; the docs steer users to authkey.
name: "action_oauth_secret_appends_query",
authKey: tsSecret + base + "?preauthorized=true&ephemeral=true",
tags: []string{"tag:ci"},
wantErr: "cannot fetch token",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostname := "oauthkey-" + strings.ReplaceAll(tt.name, "_", "-")
ts := &tsnet.Server{
Dir: t.TempDir(),
Hostname: hostname,
ControlURL: srv.URL,
AdvertiseTags: tt.tags,
Logf: logger.Discard,
}
if tt.clientSecret {
ts.ClientSecret = tt.authKey
} else {
ts.AuthKey = tt.authKey
}
t.Cleanup(func() { ts.Close() })
ctx, cancel := context.WithTimeout(t.Context(), 30*time.Second)
defer cancel()
_, err := ts.Up(ctx)
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
for _, n := range srv.State().ListNodes().All() {
require.NotEqual(t, hostname, n.Hostname(), "no node may register")
}
return
}
require.NoError(t, err)
var node types.NodeView
for _, n := range srv.State().ListNodes().All() {
if n.Hostname() == hostname {
node = n
}
}
require.True(t, node.Valid(), "node %q not registered", hostname)
assert.True(t, node.IsTagged())
assert.Equal(t, []string{"tag:ci"}, node.Tags().AsSlice())
assert.Equal(t, tt.wantEphemeral, node.IsEphemeral())
assert.False(t, node.AuthKey().Reusable(), "client mints single-use keys")
})
}
}