api/v2: list token-made auth keys to a token without all=true

Keys an OAuth token makes are marked so. No listing holds an invalid
key.
This commit is contained in:
Kristoffer Dalby committed 2026-10-28 07:15:55 +00:00
1 parent 0cbdf9ecd1
commit ee9f2bb318
9 files changed
+189 -25

No files matched your search

+15 -2
View File
@@ -104,7 +104,7 @@ type (
listKeysInput struct { listKeysInput struct {
Tailnet string `path:"tailnet"` Tailnet string `path:"tailnet"`
All bool `doc:"Accepted for compatibility; Headscale returns all keys." query:"all"` All bool `doc:"Without it, a token sees only the auth keys tokens made." query:"all"`
} }
keyByIDInput struct { keyByIDInput struct {
@@ -199,7 +199,15 @@ func registerKeys(api huma.API, b Backend) {
return nil, huma.Error500InternalServerError("listing auth keys", err) return nil, huma.Error500InternalServerError("listing auth keys", err)
} }
now := time.Now()
// As Tailscale lists them: never an invalid key, and to a token
// without all=true only the keys tokens made.
for i := range keys { for i := range keys {
if keys[i].ValidAt(now) != nil || isOAuth && !in.All && !keys[i].CreatedByToken {
continue
}
out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i])) out.Body.Keys = append(out.Body.Keys, keyFromStored(&keys[i]))
} }
} }
@@ -412,7 +420,12 @@ func createAuthKey(ctx context.Context, b Backend, body CreateKeyRequest) (*keyO
expiration := time.Now().Add(expiryDuration(body.ExpirySeconds)) expiration := time.Now().Add(expiryDuration(body.ExpirySeconds))
pak, err := b.State.CreatePreAuthKey( createKey := b.State.CreatePreAuthKey
if isOAuth {
createKey = b.State.CreateTokenPreAuthKey
}
pak, err := createKey(
userID, userID,
create.Reusable, create.Reusable,
create.Ephemeral, create.Ephemeral,
+2 -12
View File
@@ -87,16 +87,7 @@ var apiv2CompatSkipReasons = map[string]string{
"oauth/T5/2/tags": retagRemovesUnownedTag, "oauth/T5/2/tags": retagRemovesUnownedTag,
"oauth/T5/2/owner": retagRemovesUnownedTag, "oauth/T5/2/owner": retagRemovesUnownedTag,
"oauth/D0/1/listed": keyListWithoutAll, "oauth/D0/1/listed": emptyKeyListNull,
"oauth/B11/0/listed": keyListWithoutAll,
"oauth/B11/2/listed": keyListWithoutAll,
"oauth/D0/2/listed": keyListInvalidKeys,
"key-list/L-untagged/1/listed": keyListWithoutAll,
"key-list/L-prod/1/listed": keyListWithoutAll,
"key-list/L-prod-server/1/listed": keyListWithoutAll,
"key-list/L-make-prod/0/listed": keyListWithoutAll,
"key-list/L-make-prod-server/0/listed": keyListWithoutAll,
"oauth/D7/2/status": deletedClientGone, "oauth/D7/2/status": deletedClientGone,
"oauth/D7/2/outcome": deletedClientGone, "oauth/D7/2/outcome": deletedClientGone,
@@ -197,8 +188,7 @@ const (
mintTagsNotEchoed = "TOKEN_TAGS_NOT_ECHOED: SaaS's token answer names the token's tags; headscale's has no tags" mintTagsNotEchoed = "TOKEN_TAGS_NOT_ECHOED: SaaS's token answer names the token's tags; headscale's has no tags"
unknownKeyType = "UNKNOWN_KEY_TYPE: SaaS refuses an unknown keyType with 400 before it checks scopes; headscale checks the scope first and answers 403" unknownKeyType = "UNKNOWN_KEY_TYPE: SaaS refuses an unknown keyType with 400 before it checks scopes; headscale checks the scope first and answers 403"
tagRefusalStatus = "TAG_REFUSAL_STATUS: SaaS refuses a tag outside the token's grant with 400 requested tags are invalid or not permitted; headscale with 403" tagRefusalStatus = "TAG_REFUSAL_STATUS: SaaS refuses a tag outside the token's grant with 400 requested tags are invalid or not permitted; headscale with 403"
keyListWithoutAll = "KEY_LIST_WITHOUT_ALL: without all=true SaaS lists an OAuth token only the auth keys OAuth tokens made, whatever its tags and theirs; headscale lists every key" emptyKeyListNull = "EMPTY_KEY_LIST_NULL: SaaS answers a listing that holds no key with keys null; headscale with []"
keyListInvalidKeys = "KEY_LIST_INVALID_KEYS: SaaS leaves spent and revoked auth keys out of a listing; headscale lists them"
deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it" deletedClientGone = "DELETED_CLIENT_GONE: SaaS keeps a deleted OAuth client readable as invalid and revoked; headscale removes it"
deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized" deletedClientCredentialText = "DELETED_CLIENT_CREDENTIAL_TEXT: SaaS refuses a deleted client's secret and tokens with API token invalid; headscale says invalid client credentials and unauthorized"
missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope" missingScopeText = "MISSING_SCOPE_TEXT: SaaS refuses a delete outside the token's scopes with calling actor does not have enough permissions; headscale names the missing scope"
+2 -2
View File
@@ -286,11 +286,11 @@ func TestAPIv2Key_Delete(t *testing.T) {
require.Equal(t, http.StatusOK, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code) require.Equal(t, http.StatusOK, api.Delete("/api/v2/tailnet/-/keys/"+created.ID).Code)
// DELETE soft-revokes (Tailscale-faithful): the key stays retrievable with // DELETE soft-revokes (Tailscale-faithful): the key stays retrievable with
// invalid set and a revoked timestamp, and is still listed. // invalid set and a revoked timestamp, but listings leave it out.
got := getKey(t, api, created.ID) got := getKey(t, api, created.ID)
assert.True(t, got.Invalid, "revoked key reports invalid") assert.True(t, got.Invalid, "revoked key reports invalid")
assert.NotNil(t, got.Revoked, "revoked timestamp is populated") assert.NotNil(t, got.Revoked, "revoked timestamp is populated")
assert.True(t, containsKeyID(listKeys(t, api), created.ID), "revoked key still listed") assert.False(t, containsKeyID(listKeys(t, api), created.ID), "revoked key not listed")
// Server-side: row kept, revoked stamped. // Server-side: row kept, revoked stamped.
pak := srvKey(t, app, created.ID) pak := srvKey(t, app, created.ID)
+66 -1
View File
@@ -8,6 +8,7 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
@@ -338,6 +339,69 @@ func TestAPIv2OAuth_TaggedKeyCreator(t *testing.T) {
assert.False(t, nv.AuthKey().User().Valid()) assert.False(t, nv.AuthKey().User().Valid())
} }
// TestAPIv2OAuth_KeyListing: a listing never holds an invalid key; without
// all=true a token sees only the auth keys tokens made, whatever their tags.
func TestAPIv2OAuth_KeyListing(t *testing.T) {
app, baseURL, admin := newOAuthTestServer(t)
adminKey := createAdminAuthKey(t, baseURL, admin)
revoked := createAdminAuthKey(t, baseURL, admin)
status, _ := apiReq(t, http.MethodDelete, baseURL+"/api/v2/tailnet/-/keys/"+revoked, admin, nil)
require.Equal(t, http.StatusOK, status)
maker := mintScopedToken(t, baseURL, admin, scope.AuthKeys)
require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, maker, []string{"tag:ci"}))
keys, err := app.state.ListPreAuthKeys()
require.NoError(t, err)
var tokenKey string
for _, k := range keys {
if k.CreatedByToken {
tokenKey = k.StringID()
}
}
require.NotEmpty(t, tokenKey, "the token's key is marked")
_, readerSecret := createClient(t, baseURL, admin, []string{"auth_keys:read"}, nil)
reader := tokenFor(t, baseURL, readerSecret)
listed := func(bearer, query string) []string {
t.Helper()
status, body := apiReq(t, http.MethodGet, baseURL+"/api/v2/tailnet/-/keys"+query, bearer, nil)
require.Equalf(t, http.StatusOK, status, "body: %s", body)
var out struct{ Keys []apiv2.Key }
require.NoError(t, json.Unmarshal(body, &out))
ids := make([]string, 0, len(out.Keys))
for _, k := range out.Keys {
if k.KeyType == "auth" {
ids = append(ids, k.ID)
}
}
slices.Sort(ids)
return ids
}
every := []string{adminKey, tokenKey}
slices.Sort(every)
for _, bearer := range []string{reader, maker} {
assert.Equal(t, []string{tokenKey}, listed(bearer, ""))
assert.Equal(t, every, listed(bearer, "?all=true"))
}
assert.Equal(t, every, listed(admin, ""))
assert.Equal(t, every, listed(admin, "?all=true"))
}
// TestAPIv2OAuth_UnknownKeyTypeRefused: a keyType other than auth or client // TestAPIv2OAuth_UnknownKeyTypeRefused: a keyType other than auth or client
// is refused, not taken as an auth key. // is refused, not taken as an auth key.
func TestAPIv2OAuth_UnknownKeyTypeRefused(t *testing.T) { func TestAPIv2OAuth_UnknownKeyTypeRefused(t *testing.T) {
@@ -676,8 +740,9 @@ func TestAPIv2OAuth_KeysMultiplexIsolation(t *testing.T) {
clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"}) clientID, _ := createClient(t, baseURL, admin, []string{"oauth_keys"}, []string{"tag:ci"})
require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"})) require.Equal(t, http.StatusOK, createTaggedKey(t, baseURL, admin, []string{"tag:ci"}))
// all=true, so a token sees the admin's auth key too.
listKinds := func(bearer string) map[string]int { listKinds := func(bearer string) map[string]int {
st, body := apiReq(t, http.MethodGet, keysURL, bearer, nil) st, body := apiReq(t, http.MethodGet, keysURL+"?all=true", bearer, nil)
require.Equalf(t, http.StatusOK, st, "%s", body) require.Equalf(t, http.StatusOK, st, "%s", body)
var out struct { var out struct {
+47
View File
@@ -705,6 +705,53 @@ func TestSQLiteMigrationToCredentialsIsAtomic(t *testing.T) {
assert.True(t, pak.Reusable) assert.True(t, pak.Reusable)
} }
// TestSQLiteMigrationAddsCreatedByToken upgrades a database whose credentials
// table predates the created_by_token column: existing keys read as not made
// by a token, and the schema matches schema.sql again.
func TestSQLiteMigrationAddsCreatedByToken(t *testing.T) {
dbPath := t.TempDir() + "/headscale_test.db"
cfg := &types.Config{
Database: types.DatabaseConfig{
Type: "sqlite3",
Sqlite: types.SqliteConfig{Path: dbPath},
},
Policy: types.PolicyConfig{Mode: types.PolicyModeDB},
}
hsdb, err := NewHeadscaleDatabase(cfg)
require.NoError(t, err)
pak, err := hsdb.CreatePreAuthKey(nil, true, false, nil, []string{"tag:ci"})
require.NoError(t, err)
require.NoError(t, hsdb.Close())
raw, err := sql.Open("sqlite", dbPath)
require.NoError(t, err)
defer raw.Close()
for _, stmt := range []string{
`ALTER TABLE credentials DROP COLUMN created_by_token`,
`DELETE FROM migrations WHERE id = '202610221200-credential-created-by-token'`,
} {
_, err = raw.ExecContext(context.Background(), stmt)
require.NoError(t, err, stmt)
}
hsdb, err = NewHeadscaleDatabase(cfg)
require.NoError(t, err)
got, err := hsdb.GetPreAuthKeyByID(pak.ID)
require.NoError(t, err)
assert.False(t, got.CreatedByToken)
made, err := hsdb.CreateTokenPreAuthKey(nil, true, false, nil, []string{"tag:ci"})
require.NoError(t, err)
got, err = hsdb.GetPreAuthKeyByID(made.ID)
require.NoError(t, err)
assert.True(t, got.CreatedByToken)
}
// TestSQLiteRejectsPre029Database ensures a real pre-0.29 database is refused // TestSQLiteRejectsPre029Database ensures a real pre-0.29 database is refused
// rather than silently skipping the migrations that were removed. // rather than silently skipping the migrations that were removed.
func TestSQLiteRejectsPre029Database(t *testing.T) { func TestSQLiteRejectsPre029Database(t *testing.T) {
+26
View File
@@ -57,6 +57,32 @@ func (hsdb *HSDatabase) CreatePreAuthKey(
}) })
} }
// CreateTokenPreAuthKey is [HSDatabase.CreatePreAuthKey] for a key an OAuth
// access token makes, marked as such in the same transaction.
func (hsdb *HSDatabase) CreateTokenPreAuthKey(
uid *types.UserID,
reusable bool,
ephemeral bool,
expiration *time.Time,
aclTags []string,
) (*types.PreAuthKeyNew, error) {
return Write(hsdb.DB, func(tx *gorm.DB) (*types.PreAuthKeyNew, error) {
pak, err := CreatePreAuthKey(tx, uid, reusable, ephemeral, expiration, aclTags)
if err != nil {
return nil, err
}
err = tx.Model(&types.Credential{}).
Where("id = ?", pak.ID).
Update("created_by_token", true).Error
if err != nil {
return nil, fmt.Errorf("marking key as made by a token: %w", err)
}
return pak, nil
})
}
const authKeyPrefix = "hskey-auth-" const authKeyPrefix = "hskey-auth-"
// CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it. // CreatePreAuthKey creates a new [types.PreAuthKey] in a user, and returns it.
+25
View File
@@ -162,6 +162,31 @@ func TestDestroyPreAuthKeyKeepsReferencedEphemeralKey(t *testing.T) {
require.ErrorIs(t, err, gorm.ErrRecordNotFound) require.ErrorIs(t, err, gorm.ErrRecordNotFound)
} }
// TestCreateTokenPreAuthKeyIsAtomic: a key a token makes is marked in the
// transaction that creates it, so a failed mark leaves no unmarked key.
func TestCreateTokenPreAuthKeyIsAtomic(t *testing.T) {
db, err := newSQLiteTestDB()
require.NoError(t, err)
pak, err := db.CreateTokenPreAuthKey(nil, true, false, nil, []string{"tag:ci"})
require.NoError(t, err)
got, err := db.GetPreAuthKeyByID(pak.ID)
require.NoError(t, err)
assert.True(t, got.CreatedByToken)
require.NoError(t, db.DB.Exec(`CREATE TRIGGER refuse_mark
BEFORE UPDATE OF created_by_token ON credentials
BEGIN SELECT RAISE(ABORT, 'refused'); END`).Error)
_, err = db.CreateTokenPreAuthKey(nil, true, false, nil, []string{"tag:ci"})
require.Error(t, err)
keys, err := db.ListPreAuthKeys()
require.NoError(t, err)
assert.Len(t, keys, 1, "the failed create left a key behind")
}
func TestCannotDeleteAssignedPreAuthKey(t *testing.T) { func TestCannotDeleteAssignedPreAuthKey(t *testing.T) {
db, err := newSQLiteTestDB() db, err := newSQLiteTestDB()
require.NoError(t, err) require.NoError(t, err)
@@ -35,14 +35,7 @@ import (
// <test_id>/<step>[/<node or action index>]/<check>, with "collection" for // <test_id>/<step>[/<node or action index>]/<check>, with "collection" for
// the step of what killed nodes leave behind. A listed check that matches // the step of what killed nodes leave behind. A listed check that matches
// fails, so a fix must delete its entry. // fails, so a fix must delete its entry.
var lifecycleSkipReasons = map[string]string{ var lifecycleSkipReasons = map[string]string{}
"ephemeral-lifecycle/E3-key-delete/2/keys": deletedKeyListed,
"ephemeral-lifecycle/E3-key-delete/3/keys": deletedKeyListed,
}
const (
deletedKeyListed = "DELETED_KEY_LISTED: headscale lists a deleted auth key; SaaS leaves it out of both key lists, while a read by ID still answers"
)
// TestRouteApprovalCompat replays a user-owned router through admin // TestRouteApprovalCompat replays a user-owned router through admin
// approvals and revokes, same-key and forced re-auth, a hostname change, // approvals and revokes, same-key and forced re-auth, a hostname change,
+5
View File
@@ -2051,6 +2051,11 @@ func (s *State) CreatePreAuthKey(userID *types.UserID, reusable bool, ephemeral
return s.db.CreatePreAuthKey(userID, reusable, ephemeral, expiration, aclTags) return s.db.CreatePreAuthKey(userID, reusable, ephemeral, expiration, aclTags)
} }
// CreateTokenPreAuthKey creates a key an OAuth access token makes.
func (s *State) CreateTokenPreAuthKey(userID *types.UserID, reusable bool, ephemeral bool, expiration *time.Time, aclTags []string) (*types.PreAuthKeyNew, error) {
return s.db.CreateTokenPreAuthKey(userID, reusable, ephemeral, expiration, aclTags)
}
// Test helpers for the state layer // Test helpers for the state layer
// CreateUserForTest creates a test user. This is a convenience wrapper around the database layer. // CreateUserForTest creates a test user. This is a convenience wrapper around the database layer.