mirror of
https://github.com/juanfont/headscale.git
synced 2026-10-04 13:53:37 +09:00
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.
This commit is contained in:
committed by
Kristoffer Dalby
parent
61a626eab7
commit
bb3c0b4cd6
@@ -1,14 +1,21 @@
|
|||||||
package servertest_test
|
package servertest_test
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/servertest"
|
"github.com/juanfont/headscale/hscontrol/servertest"
|
||||||
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"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
|
// TestAPIv2OAuthScopes proves the v2 OAuth scope and tag enforcement through the
|
||||||
@@ -389,3 +396,215 @@ func (tf *tofu) runExpectError(t *testing.T, mustContain ...string) {
|
|||||||
|
|
||||||
t.Fatalf("apply failed but output contained none of %v:\n%s", mustContain, combined)
|
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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user