mirror of
https://github.com/juanfont/headscale.git
synced 2026-11-05 04:59:26 +09:00
createKey has no static scope, so any token got its body parsed and validated before the handler's scope check.
307 lines
12 KiB
Go
307 lines
12 KiB
Go
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_CreateKeyScopeBeforeBody: createKey declares no static
|
||
// scope, so a token that may create neither kind of key must be refused before
|
||
// huma reads and validates the body.
|
||
func TestAPIv2OAuthMatrix_CreateKeyScopeBeforeBody(t *testing.T) {
|
||
_, baseURL, admin := newOAuthTestServer(t)
|
||
invalid := map[string]any{"tags": []int{0}}
|
||
|
||
for _, s := range scope.Known() {
|
||
held := []scope.Scope{s}
|
||
mayCreate := scope.Grants(held, scope.AuthKeys) || scope.Grants(held, scope.OAuthKeys)
|
||
|
||
status, body := apiReq(t, http.MethodPost, baseURL+"/api/v2/tailnet/-/keys",
|
||
mintScopedToken(t, baseURL, admin, s), invalid)
|
||
if mayCreate {
|
||
assert.Equalf(t, http.StatusUnprocessableEntity, status, "%s: %s", s, body)
|
||
} else {
|
||
assert.Truef(t, scopeDenied(status, body), "%s: %d %s", s, status, body)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 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). Only an untagged 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"])
|
||
|
||
// A tagged all-scope client stays inside its tags; an untagged one 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.StatusBadRequest, stAll, "tagged all-scope client: %v", mAll)
|
||
|
||
_, anySecret := createClient(t, baseURL, admin, []string{"all"}, nil)
|
||
stAny, mAny := mintTokenWithTags(t, baseURL, anySecret, "tag:anything")
|
||
assert.Equalf(t, http.StatusOK, stAny, "untagged all-scope client may assign any tag: %v", mAny)
|
||
}
|
||
|
||
// 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
|
||
}
|