mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-08 00:50:20 +09:00
hscontrol: add the OAuth client and access-token model
Add the OAuth client type, its database storage, the scope grant package, policy tag-ownership exposure, and the state operations backing the v2 OAuth client-credentials flow.
This commit is contained in:
@@ -831,6 +831,75 @@ WHERE user_id IS NULL
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
{
|
||||
// Add the OAuth client + access token tables backing the v2 API's
|
||||
// OAuth client-credentials flow. They mirror the api_keys /
|
||||
// pre_auth_keys security model: a public id/prefix plus an Argon2id
|
||||
// hash of the secret.
|
||||
//
|
||||
// SQLite uses explicit DDL that matches schema.sql byte-for-byte
|
||||
// (the squibble digest is the SQLite source of truth). Postgres,
|
||||
// which has no digest and rejects SQLite-isms like AUTOINCREMENT,
|
||||
// uses dialect-aware AutoMigrate, mirroring InitSchema's fresh-DB
|
||||
// table creation so an existing Postgres deployment can upgrade.
|
||||
ID: "202606211200-oauth-clients-and-tokens",
|
||||
Migrate: func(tx *gorm.DB) error {
|
||||
if tx.Migrator().HasTable(&types.OAuthClient{}) &&
|
||||
tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if tx.Name() != "sqlite" {
|
||||
return tx.AutoMigrate(&types.OAuthClient{}, &types.OAuthAccessToken{})
|
||||
}
|
||||
|
||||
if !tx.Migrator().HasTable(&types.OAuthClient{}) {
|
||||
err := tx.Exec(`CREATE TABLE oauth_clients(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
client_id text,
|
||||
secret_hash blob,
|
||||
scopes text,
|
||||
tags text,
|
||||
description text,
|
||||
user_id integer,
|
||||
created_at datetime,
|
||||
revoked datetime
|
||||
)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_clients table: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_clients index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !tx.Migrator().HasTable(&types.OAuthAccessToken{}) {
|
||||
err := tx.Exec(`CREATE TABLE oauth_access_tokens(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
prefix text,
|
||||
hash blob,
|
||||
client_id text,
|
||||
scopes text,
|
||||
tags text,
|
||||
expiration datetime,
|
||||
created_at datetime
|
||||
)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_access_tokens table: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Exec(`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating oauth_access_tokens index: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
Rollback: func(db *gorm.DB) error { return nil },
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -842,6 +911,8 @@ WHERE user_id IS NULL
|
||||
&types.APIKey{},
|
||||
&types.Node{},
|
||||
&types.Policy{},
|
||||
&types.OAuthClient{},
|
||||
&types.OAuthAccessToken{},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -857,6 +928,8 @@ WHERE user_id IS NULL
|
||||
`DROP INDEX IF EXISTS "idx_name_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_name_no_provider_identifier"`,
|
||||
`DROP INDEX IF EXISTS "idx_pre_auth_keys_prefix"`,
|
||||
`DROP INDEX IF EXISTS "idx_oauth_clients_client_id"`,
|
||||
`DROP INDEX IF EXISTS "idx_oauth_access_tokens_prefix"`,
|
||||
}
|
||||
|
||||
for _, dropSQL := range dropIndexes {
|
||||
@@ -875,6 +948,8 @@ WHERE user_id IS NULL
|
||||
`CREATE UNIQUE INDEX idx_name_provider_identifier ON users(name, provider_identifier)`,
|
||||
`CREATE UNIQUE INDEX idx_name_no_provider_identifier ON users(name) WHERE provider_identifier IS NULL`,
|
||||
`CREATE UNIQUE INDEX idx_pre_auth_keys_prefix ON pre_auth_keys(prefix) WHERE prefix IS NOT NULL AND prefix != ''`,
|
||||
`CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id)`,
|
||||
`CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix)`,
|
||||
}
|
||||
|
||||
for _, indexSQL := range indexes {
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"golang.org/x/crypto/argon2"
|
||||
"gorm.io/gorm"
|
||||
"tailscale.com/util/rands"
|
||||
"tailscale.com/util/set"
|
||||
)
|
||||
|
||||
const (
|
||||
// OAuth client secret: hskey-client-<clientID(12)>-<secret(64)>. The clientID
|
||||
// is the public, indexed lookup key (the analogue of an API key's prefix) and
|
||||
// is embedded in the secret so the token endpoint can derive it. The prefix
|
||||
// itself lives in the types package ([types.OAuthClientPrefix]).
|
||||
oauthClientIDLength = 12
|
||||
oauthClientSecretLength = 64
|
||||
|
||||
// OAuth access token: hskey-oauthtok-<prefix(12)>-<secret(64)>. The distinct
|
||||
// prefix (vs hskey-api- admin keys, [types.AccessTokenPrefix]) lets the auth
|
||||
// middleware dispatch a scoped token from an all-access admin key alone.
|
||||
accessTokenPrefixLength = 12
|
||||
accessTokenSecretLength = 64
|
||||
)
|
||||
|
||||
var (
|
||||
ErrOAuthClientNotFound = fmt.Errorf("oauth client not found: %w", gorm.ErrRecordNotFound)
|
||||
ErrOAuthClientFailedToParse = errors.New("failed to parse oauth client secret")
|
||||
ErrOAuthClientRevoked = errors.New("oauth client revoked")
|
||||
|
||||
ErrAccessTokenNotFound = fmt.Errorf("oauth access token not found: %w", gorm.ErrRecordNotFound)
|
||||
ErrAccessTokenFailedToParse = errors.New("failed to parse oauth access token")
|
||||
ErrAccessTokenExpired = errors.New("oauth access token expired")
|
||||
ErrAccessTokenClientRevoked = errors.New("oauth access token issuing client revoked or deleted")
|
||||
|
||||
errSecretHashMalformed = errors.New("malformed secret hash")
|
||||
errSecretMismatch = errors.New("secret does not match hash")
|
||||
)
|
||||
|
||||
// Argon2id parameters, OWASP's minimum recommendation (19 MiB, 2 iterations, 1
|
||||
// lane). They are encoded into every stored hash, so raising them later still
|
||||
// verifies credentials stored under the old cost.
|
||||
const (
|
||||
argon2Time = 2
|
||||
argon2Memory = 19 * 1024
|
||||
argon2Threads = 1
|
||||
argon2KeyLen = 32
|
||||
argon2SaltLen = 16
|
||||
)
|
||||
|
||||
// argon2Limiter bounds concurrent Argon2id computations. Each costs ~19 MiB and
|
||||
// the unauthenticated OAuth token endpoint runs one per attempt, so an unbounded
|
||||
// flood could exhaust memory. ponytail: a global semaphore sized to GOMAXPROCS;
|
||||
// revisit only if credential hashing ever becomes a throughput bottleneck.
|
||||
var argon2Limiter = make(chan struct{}, max(2, runtime.GOMAXPROCS(0)))
|
||||
|
||||
// hashSecret hashes a credential secret with Argon2id, encoded in PHC string
|
||||
// form so the parameters travel with the hash. Argon2id is the current OWASP
|
||||
// recommendation, replacing bcrypt for new credential storage.
|
||||
func hashSecret(secret string) ([]byte, error) {
|
||||
salt := make([]byte, argon2SaltLen)
|
||||
|
||||
_, err := rand.Read(salt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating salt: %w", err)
|
||||
}
|
||||
|
||||
hash := argon2.IDKey([]byte(secret), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
|
||||
|
||||
encoded := fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
|
||||
argon2.Version, argon2Memory, argon2Time, argon2Threads,
|
||||
base64.RawStdEncoding.EncodeToString(salt),
|
||||
base64.RawStdEncoding.EncodeToString(hash),
|
||||
)
|
||||
|
||||
return []byte(encoded), nil
|
||||
}
|
||||
|
||||
// verifySecret reports whether secret matches a hashSecret-encoded hash. It
|
||||
// reads the cost parameters from the stored hash and compares in constant time
|
||||
// so a mismatch leaks no timing signal.
|
||||
func verifySecret(encoded []byte, secret string) error {
|
||||
parts := strings.Split(string(encoded), "$")
|
||||
if len(parts) != 6 || parts[1] != "argon2id" {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
var version int
|
||||
if _, err := fmt.Sscanf(parts[2], "v=%d", &version); err != nil || version != argon2.Version { //nolint:noinlineerr
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
var (
|
||||
memory, time uint32
|
||||
threads uint8
|
||||
)
|
||||
|
||||
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { //nolint:noinlineerr
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
|
||||
if err != nil {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
want, err := base64.RawStdEncoding.DecodeString(parts[5])
|
||||
if err != nil {
|
||||
return errSecretHashMalformed
|
||||
}
|
||||
|
||||
argon2Limiter <- struct{}{}
|
||||
//nolint:gosec // want is a 32-byte hash read back from storage, no overflow
|
||||
got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want)))
|
||||
|
||||
<-argon2Limiter
|
||||
|
||||
if subtle.ConstantTimeCompare(got, want) != 1 {
|
||||
return errSecretMismatch
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateOAuthClient creates a new [types.OAuthClient] and returns the plaintext
|
||||
// secret (shown ONCE) alongside the stored client. creatorUserID is the user who
|
||||
// created it (informational), or nil.
|
||||
func (hsdb *HSDatabase) CreateOAuthClient(
|
||||
scopes, tags []string,
|
||||
description string,
|
||||
creatorUserID *uint,
|
||||
) (string, *types.OAuthClient, error) {
|
||||
tags, err := validateACLTags(tags)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
scopes = set.SetOf(scopes).Slice()
|
||||
slices.Sort(scopes)
|
||||
|
||||
clientID := rands.HexString(oauthClientIDLength)
|
||||
secret := rands.HexString(oauthClientSecretLength)
|
||||
secretStr := types.OAuthClientPrefix + clientID + "-" + secret
|
||||
|
||||
hash, err := hashSecret(secret)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
client := types.OAuthClient{
|
||||
ClientID: clientID,
|
||||
SecretHash: hash,
|
||||
Scopes: scopes,
|
||||
Tags: tags,
|
||||
Description: description,
|
||||
UserID: creatorUserID,
|
||||
CreatedAt: &now,
|
||||
}
|
||||
|
||||
err = hsdb.Write(func(tx *gorm.DB) error {
|
||||
return tx.Save(&client).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("saving oauth client: %w", err)
|
||||
}
|
||||
|
||||
return secretStr, &client, nil
|
||||
}
|
||||
|
||||
// AuthenticateOAuthClient validates a presented client secret and returns the
|
||||
// matching, unrevoked [types.OAuthClient]. The client id is derived from the
|
||||
// secret (its middle segment), so any separately-supplied client_id is
|
||||
// redundant, matching Tailscale, where get-authkey passes a dummy id and the
|
||||
// server derives the real one from the secret.
|
||||
func (hsdb *HSDatabase) AuthenticateOAuthClient(secretStr string) (*types.OAuthClient, error) {
|
||||
if secretStr == "" {
|
||||
return nil, ErrOAuthClientFailedToParse
|
||||
}
|
||||
|
||||
// Tailscale allows the secret to carry optional ?key=value attributes when
|
||||
// used directly as an auth key; strip them before parsing.
|
||||
secretStr, _, _ = strings.Cut(secretStr, "?")
|
||||
|
||||
_, rest, found := strings.Cut(secretStr, types.OAuthClientPrefix)
|
||||
if !found {
|
||||
return nil, ErrOAuthClientFailedToParse
|
||||
}
|
||||
|
||||
clientID, secret, err := parsePrefixedKey(
|
||||
rest,
|
||||
oauthClientIDLength,
|
||||
oauthClientSecretLength,
|
||||
ErrOAuthClientFailedToParse,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var client types.OAuthClient
|
||||
if err := hsdb.DB.First(&client, "client_id = ?", clientID).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
if err := verifySecret(client.SecretHash, secret); err != nil { //nolint:noinlineerr
|
||||
return nil, fmt.Errorf("invalid oauth client secret: %w", err)
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return nil, ErrOAuthClientRevoked
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// GetOAuthClientByClientID returns a [types.OAuthClient] by its public client id.
|
||||
func (hsdb *HSDatabase) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
|
||||
var client types.OAuthClient
|
||||
if result := hsdb.DB.First(&client, "client_id = ?", clientID); result.Error != nil {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return &client, nil
|
||||
}
|
||||
|
||||
// ListOAuthClients returns every [types.OAuthClient].
|
||||
func (hsdb *HSDatabase) ListOAuthClients() ([]types.OAuthClient, error) {
|
||||
clients := []types.OAuthClient{}
|
||||
|
||||
err := hsdb.DB.Find(&clients).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return clients, nil
|
||||
}
|
||||
|
||||
// RevokeOAuthClient deletes a client and all access tokens it issued. An unknown
|
||||
// client id returns [ErrOAuthClientNotFound], so a repeated DELETE is a clean
|
||||
// 404. Unlike pre-auth keys (which soft-revoke for node-registration history), an
|
||||
// OAuth client has no such history and is removed outright, matching Tailscale.
|
||||
func (hsdb *HSDatabase) RevokeOAuthClient(clientID string) error {
|
||||
return hsdb.Write(func(tx *gorm.DB) error {
|
||||
err := tx.Where("client_id = ?", clientID).
|
||||
Delete(&types.OAuthAccessToken{}).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("deleting oauth access tokens: %w", err)
|
||||
}
|
||||
|
||||
res := tx.Where("client_id = ?", clientID).Delete(&types.OAuthClient{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// MintAccessToken stores a new [types.OAuthAccessToken] for clientID with the
|
||||
// given (already narrowed) scopes/tags and expiration, returning the plaintext
|
||||
// token (shown ONCE).
|
||||
func (hsdb *HSDatabase) MintAccessToken(
|
||||
clientID string,
|
||||
scopes, tags []string,
|
||||
expiration *time.Time,
|
||||
) (string, *types.OAuthAccessToken, error) {
|
||||
prefix := rands.HexString(accessTokenPrefixLength)
|
||||
secret := rands.HexString(accessTokenSecretLength)
|
||||
tokenStr := types.AccessTokenPrefix + prefix + "-" + secret
|
||||
|
||||
hash, err := hashSecret(secret)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := types.OAuthAccessToken{
|
||||
Prefix: prefix,
|
||||
Hash: hash,
|
||||
ClientID: clientID,
|
||||
Scopes: scopes,
|
||||
Tags: tags,
|
||||
Expiration: expiration,
|
||||
CreatedAt: &now,
|
||||
}
|
||||
|
||||
// Mint inside a transaction that re-checks the client still exists and is
|
||||
// not revoked, so a mint cannot complete against a client being deleted.
|
||||
err = hsdb.Write(func(tx *gorm.DB) error {
|
||||
var client types.OAuthClient
|
||||
|
||||
err := tx.First(&client, "client_id = ?", clientID).Error
|
||||
if err != nil {
|
||||
return ErrOAuthClientNotFound
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return ErrOAuthClientRevoked
|
||||
}
|
||||
|
||||
return tx.Save(&token).Error
|
||||
})
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("saving oauth access token: %w", err)
|
||||
}
|
||||
|
||||
return tokenStr, &token, nil
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates a presented bearer token and returns the
|
||||
// matching, unexpired [types.OAuthAccessToken] (carrying its granted scopes and
|
||||
// tags). A non-nil error means the token is missing, malformed, or expired.
|
||||
func (hsdb *HSDatabase) AuthenticateAccessToken(tokenStr string) (*types.OAuthAccessToken, error) {
|
||||
if tokenStr == "" {
|
||||
return nil, ErrAccessTokenFailedToParse
|
||||
}
|
||||
|
||||
_, rest, found := strings.Cut(tokenStr, types.AccessTokenPrefix)
|
||||
if !found {
|
||||
return nil, ErrAccessTokenFailedToParse
|
||||
}
|
||||
|
||||
prefix, secret, err := parsePrefixedKey(
|
||||
rest,
|
||||
accessTokenPrefixLength,
|
||||
accessTokenSecretLength,
|
||||
ErrAccessTokenFailedToParse,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var token types.OAuthAccessToken
|
||||
if err := hsdb.DB.First(&token, "prefix = ?", prefix).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrAccessTokenNotFound
|
||||
}
|
||||
|
||||
if err := verifySecret(token.Hash, secret); err != nil { //nolint:noinlineerr
|
||||
return nil, fmt.Errorf("invalid oauth access token: %w", err)
|
||||
}
|
||||
|
||||
if token.Expiration != nil && token.Expiration.Before(time.Now()) {
|
||||
return nil, ErrAccessTokenExpired
|
||||
}
|
||||
|
||||
// Bind validity to the issuing client: a token whose client has been
|
||||
// revoked or deleted is rejected. This closes a mint/revoke race (where a
|
||||
// token could be inserted after the client's tokens were purged) and any
|
||||
// orphan left by manual deletion or a future soft-revoke path.
|
||||
var client types.OAuthClient
|
||||
if err := hsdb.DB.First(&client, "client_id = ?", token.ClientID).Error; err != nil { //nolint:noinlineerr
|
||||
return nil, ErrAccessTokenClientRevoked
|
||||
}
|
||||
|
||||
if client.Revoked != nil {
|
||||
return nil, ErrAccessTokenClientRevoked
|
||||
}
|
||||
|
||||
return &token, nil
|
||||
}
|
||||
|
||||
// DeleteExpiredAccessTokens hard-deletes every access token that expired before
|
||||
// cutoff, returning how many were removed. Auth-time checks already reject
|
||||
// expired tokens; the hourly reaper (see app.go) calls this only to keep the
|
||||
// table from growing unbounded.
|
||||
func (hsdb *HSDatabase) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
|
||||
res := hsdb.DB.Where("expiration IS NOT NULL AND expiration < ?", cutoff).
|
||||
Delete(&types.OAuthAccessToken{})
|
||||
|
||||
return res.RowsAffected, res.Error
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/juanfont/headscale/hscontrol/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestVerifySecretConcurrent runs more concurrent verifications than the Argon2
|
||||
// concurrency semaphore admits, asserting the limiter releases correctly (no
|
||||
// deadlock) and stays correct under contention. Run with -race.
|
||||
func TestVerifySecretConcurrent(t *testing.T) {
|
||||
hash, err := hashSecret("s3cr3t")
|
||||
require.NoError(t, err)
|
||||
|
||||
const n = 64
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
errs := make([]error, n)
|
||||
|
||||
for i := range n {
|
||||
wg.Add(1)
|
||||
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
|
||||
if i%2 == 0 {
|
||||
errs[i] = verifySecret(hash, "s3cr3t")
|
||||
} else {
|
||||
errs[i] = verifySecret(hash, "wrong")
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
for i, e := range errs {
|
||||
if i%2 == 0 {
|
||||
assert.NoError(t, e, "correct secret must verify")
|
||||
} else {
|
||||
assert.Error(t, e, "wrong secret must fail")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthClientCreateAndAuthenticate(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
secret, client, err := db.CreateOAuthClient(
|
||||
[]string{"auth_keys", "devices:core"},
|
||||
[]string{"tag:ci"},
|
||||
"my client",
|
||||
nil,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, client)
|
||||
|
||||
// Secret carries the public client id as its middle segment, so it can be
|
||||
// derived from the secret alone (the Tailscale get-authkey trick).
|
||||
assert.True(t, strings.HasPrefix(secret, "hskey-client-"+client.ClientID+"-"))
|
||||
// Scopes/tags are deduplicated and sorted for stable storage.
|
||||
assert.Equal(t, []string{"auth_keys", "devices:core"}, client.Scopes)
|
||||
assert.Equal(t, []string{"tag:ci"}, client.Tags)
|
||||
// Only the Argon2id hash is stored, never the plaintext.
|
||||
assert.NotEmpty(t, client.SecretHash)
|
||||
assert.True(t, strings.HasPrefix(string(client.SecretHash), "$argon2id$"))
|
||||
|
||||
// The secret authenticates, deriving the client id from the secret itself.
|
||||
got, err := db.AuthenticateOAuthClient(secret)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, client.ClientID, got.ClientID)
|
||||
|
||||
// A truncated/garbage secret does not.
|
||||
_, err = db.AuthenticateOAuthClient("hskey-client-deadbeef-nope")
|
||||
require.Error(t, err)
|
||||
|
||||
// Wrong secret for a real client id is rejected by the constant-time compare.
|
||||
_, err = db.AuthenticateOAuthClient("hskey-client-" + client.ClientID + "-" + strings.Repeat("0", 64))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestHashSecretRoundTrip(t *testing.T) {
|
||||
const secret = "a-high-entropy-credential-secret"
|
||||
|
||||
encoded, err := hashSecret(secret)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(string(encoded), "$argon2id$v="))
|
||||
|
||||
// The same secret hashes to a different value each time (random salt) yet
|
||||
// still verifies.
|
||||
encoded2, err := hashSecret(secret)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, encoded, encoded2)
|
||||
|
||||
require.NoError(t, verifySecret(encoded, secret))
|
||||
require.ErrorIs(t, verifySecret(encoded, "wrong-secret"), errSecretMismatch)
|
||||
require.ErrorIs(t, verifySecret([]byte("not-a-phc-string"), secret), errSecretHashMalformed)
|
||||
}
|
||||
|
||||
func TestOAuthClientRevoke(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
secret, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// A token minted by the client survives only until the client is revoked.
|
||||
_, _, err = db.MintAccessToken(client.ClientID, client.Scopes, client.Tags, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t, db.RevokeOAuthClient(client.ClientID))
|
||||
|
||||
// The client no longer authenticates and a repeated revoke is a clean 404.
|
||||
_, err = db.AuthenticateOAuthClient(secret)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, db.RevokeOAuthClient(client.ClientID), ErrOAuthClientNotFound)
|
||||
}
|
||||
|
||||
func TestOAuthAccessTokenMintAuthenticateExpire(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
future := time.Now().Add(time.Hour)
|
||||
tokenStr, token, err := db.MintAccessToken(
|
||||
client.ClientID,
|
||||
[]string{"auth_keys"},
|
||||
[]string{"tag:ci"},
|
||||
&future,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, strings.HasPrefix(tokenStr, "hskey-oauthtok-"))
|
||||
|
||||
got, err := db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, client.ClientID, got.ClientID)
|
||||
assert.Equal(t, []string{"auth_keys"}, got.Scopes)
|
||||
assert.Equal(t, []string{"tag:ci"}, got.Tags)
|
||||
|
||||
// An expired token is rejected even though the row still exists.
|
||||
past := time.Now().Add(-time.Hour)
|
||||
expiredStr, _, err := db.MintAccessToken(client.ClientID, nil, nil, &past)
|
||||
require.NoError(t, err)
|
||||
_, err = db.AuthenticateAccessToken(expiredStr)
|
||||
require.ErrorIs(t, err, ErrAccessTokenExpired)
|
||||
|
||||
// The reaper deletes the expired row; the live token is untouched.
|
||||
n, err := db.DeleteExpiredAccessTokens(time.Now())
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(1), n)
|
||||
|
||||
_ = token
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestAccessTokenRejectedWhenClientGone asserts a token whose issuing client no
|
||||
// longer exists (orphaned by a delete/revoke race) is rejected, even though the
|
||||
// token row itself is valid and unexpired.
|
||||
func TestAccessTokenRejectedWhenClientGone(t *testing.T) {
|
||||
db, err := newSQLiteTestDB()
|
||||
require.NoError(t, err)
|
||||
|
||||
_, client, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
future := time.Now().Add(time.Hour)
|
||||
tokenStr, _, err := db.MintAccessToken(client.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete only the client row, leaving the token orphaned (the state a
|
||||
// mint/revoke race or manual deletion would produce).
|
||||
require.NoError(t, db.DB.Where("client_id = ?", client.ClientID).Delete(&types.OAuthClient{}).Error)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr)
|
||||
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
|
||||
|
||||
// A soft-revoked client (row present, Revoked set) is likewise rejected.
|
||||
_, client2, err := db.CreateOAuthClient([]string{"auth_keys"}, []string{"tag:ci"}, "", nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
tokenStr2, _, err := db.MintAccessToken(client2.ClientID, []string{"auth_keys"}, []string{"tag:ci"}, &future)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
require.NoError(t, db.DB.Model(&types.OAuthClient{}).
|
||||
Where("client_id = ?", client2.ClientID).Update("revoked", now).Error)
|
||||
|
||||
_, err = db.AuthenticateAccessToken(tokenStr2)
|
||||
require.ErrorIs(t, err, ErrAccessTokenClientRevoked)
|
||||
}
|
||||
@@ -25,6 +25,26 @@ var (
|
||||
ErrPreAuthKeyACLTagInvalid = errors.New("auth-key tag is invalid")
|
||||
)
|
||||
|
||||
// validateACLTags deduplicates, sorts, and checks that every tag carries the
|
||||
// "tag:" prefix. Shared by the pre-auth-key and OAuth credential paths so both
|
||||
// enforce the same tag shape.
|
||||
func validateACLTags(tags []string) ([]string, error) {
|
||||
tags = set.SetOf(tags).Slice()
|
||||
slices.Sort(tags)
|
||||
|
||||
for _, tag := range tags {
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: '%s' did not begin with 'tag:'",
|
||||
ErrPreAuthKeyACLTagInvalid,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func (hsdb *HSDatabase) CreatePreAuthKey(
|
||||
uid *types.UserID,
|
||||
reusable bool,
|
||||
@@ -76,20 +96,9 @@ func CreatePreAuthKey(
|
||||
userID = &user.ID
|
||||
}
|
||||
|
||||
// Remove duplicates and sort for consistency
|
||||
aclTags = set.SetOf(aclTags).Slice()
|
||||
slices.Sort(aclTags)
|
||||
|
||||
// TODO(kradalby): factor out and create a reusable tag validation,
|
||||
// check if there is one in Tailscale's lib.
|
||||
for _, tag := range aclTags {
|
||||
if !strings.HasPrefix(tag, "tag:") {
|
||||
return nil, fmt.Errorf(
|
||||
"%w: '%s' did not begin with 'tag:'",
|
||||
ErrPreAuthKeyACLTagInvalid,
|
||||
tag,
|
||||
)
|
||||
}
|
||||
aclTags, err := validateACLTags(aclTags)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -228,6 +237,7 @@ func findAuthKey(tx *gorm.DB, keyStr string) (*types.PreAuthKey, error) {
|
||||
// separator-based to handle dashes in base64 URL-safe characters.
|
||||
func parsePrefixedKey(
|
||||
prefixAndSecret string,
|
||||
//nolint:unparam // kept explicit though every credential kind uses a 12-char prefix and 64-char secret today
|
||||
prefixLen, secretLen int,
|
||||
parseErr error,
|
||||
) (string, string, error) {
|
||||
|
||||
@@ -70,6 +70,36 @@ CREATE TABLE api_keys(
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_api_keys_prefix ON api_keys(prefix);
|
||||
|
||||
-- OAuth 2.0 client-credentials clients for the v2 API. client_id is public and
|
||||
-- embedded in the secret (hskey-client-<client_id>-<secret>); only the bcrypt
|
||||
-- hash of the secret is stored. Mirrors the api_keys security model.
|
||||
CREATE TABLE oauth_clients(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
client_id text,
|
||||
secret_hash blob,
|
||||
scopes text,
|
||||
tags text,
|
||||
description text,
|
||||
user_id integer,
|
||||
created_at datetime,
|
||||
revoked datetime
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_oauth_clients_client_id ON oauth_clients(client_id);
|
||||
|
||||
-- Short-lived bearer access tokens minted by an oauth_client. Stored as a bcrypt
|
||||
-- hash of the secret, looked up by prefix.
|
||||
CREATE TABLE oauth_access_tokens(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
prefix text,
|
||||
hash blob,
|
||||
client_id text,
|
||||
scopes text,
|
||||
tags text,
|
||||
expiration datetime,
|
||||
created_at datetime
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_oauth_access_tokens_prefix ON oauth_access_tokens(prefix);
|
||||
|
||||
CREATE TABLE nodes(
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
machine_key text,
|
||||
|
||||
@@ -33,6 +33,12 @@ type PolicyManager interface {
|
||||
// TagExists reports whether the given tag is defined in the policy.
|
||||
TagExists(tag string) bool
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags may apply
|
||||
// tag: true if tag is one of ownerTags, or tag's tag-to-tag ownership chain
|
||||
// transitively includes one of ownerTags. Authorises the tags an OAuth
|
||||
// access token may set on the auth keys it mints.
|
||||
TagOwnedByTags(tag string, ownerTags []string) bool
|
||||
|
||||
// NodeCanApproveRoute reports whether the given node can approve the given route.
|
||||
NodeCanApproveRoute(node types.NodeView, route netip.Prefix) bool
|
||||
|
||||
|
||||
@@ -968,6 +968,66 @@ func (pm *PolicyManager) NodeCanHaveTag(node types.NodeView, tag string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags is authorised to
|
||||
// apply tag. It is true when tag is one of ownerTags, or when tag's tagOwners
|
||||
// chain (tag-to-tag ownership) transitively includes one of ownerTags. This is
|
||||
// the tag-level check used when an OAuth access token mints an auth key: the
|
||||
// requested tags must each be owned by the token's tags, so an operator token
|
||||
// tagged tag:k8s-operator may mint tag:k8s keys when the policy declares
|
||||
// "tag:k8s": ["tag:k8s-operator"]. It is purely tag-relational and does not
|
||||
// consult node IPs.
|
||||
func (pm *PolicyManager) TagOwnedByTags(tag string, ownerTags []string) bool {
|
||||
if pm == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
owns := make(map[string]bool, len(ownerTags))
|
||||
for _, t := range ownerTags {
|
||||
owns[t] = true
|
||||
}
|
||||
|
||||
// A credential may always apply a tag it directly holds; this needs no policy.
|
||||
if owns[tag] {
|
||||
return true
|
||||
}
|
||||
|
||||
pm.mu.Lock()
|
||||
defer pm.mu.Unlock()
|
||||
|
||||
// Owned-by delegation requires the policy's tagOwners.
|
||||
if pm.pol == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk tag-to-tag ownership transitively, guarding against cycles.
|
||||
visited := make(map[Tag]bool)
|
||||
|
||||
var walk func(t Tag) bool
|
||||
|
||||
walk = func(t Tag) bool {
|
||||
if visited[t] {
|
||||
return false
|
||||
}
|
||||
|
||||
visited[t] = true
|
||||
|
||||
for _, owner := range pm.pol.TagOwners[t] {
|
||||
ot, ok := owner.(*Tag)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if owns[string(*ot)] || walk(*ot) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
return walk(Tag(tag))
|
||||
}
|
||||
|
||||
// userMatchesOwner checks if a user matches a tag owner entry.
|
||||
// This is used as a fallback when the node's IP is not in the [PolicyManager.tagOwnerMap].
|
||||
func (pm *PolicyManager) userMatchesOwner(user types.UserView, owner Owner) bool {
|
||||
|
||||
@@ -2470,3 +2470,75 @@ func TestPeerRelayGrantMakesRelayVisible(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTagOwnedByTags(t *testing.T) {
|
||||
// tag:leaf is owned by tag:mid, which is owned by tag:root: a tag-to-tag
|
||||
// delegation chain, the shape an operator token uses to mint narrower keys.
|
||||
const policy = `{
|
||||
"tagOwners": {
|
||||
"tag:root": [],
|
||||
"tag:mid": ["tag:root"],
|
||||
"tag:leaf": ["tag:mid"],
|
||||
"tag:lone": []
|
||||
},
|
||||
"acls": [{"action": "accept", "src": ["*"], "dst": ["*:*"]}]
|
||||
}`
|
||||
|
||||
pm, err := NewPolicyManager([]byte(policy), nil, types.Nodes{}.ViewSlice())
|
||||
require.NoError(t, err)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
tag string
|
||||
ownerTags []string
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "directly held tag needs no policy",
|
||||
tag: "tag:lone",
|
||||
ownerTags: []string{"tag:lone"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "one-hop owned-by",
|
||||
tag: "tag:mid",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "transitive chain root owns leaf",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "owning one link does not grant a sibling",
|
||||
tag: "tag:lone",
|
||||
ownerTags: []string{"tag:root"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "unowned tag denied",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: []string{"tag:unrelated"},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "empty owners deny a delegated tag",
|
||||
tag: "tag:leaf",
|
||||
ownerTags: nil,
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, pm.TagOwnedByTags(tt.tag, tt.ownerTags))
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("nil policy manager denies", func(t *testing.T) {
|
||||
var nilPM *PolicyManager
|
||||
require.False(t, nilPM.TagOwnedByTags("tag:leaf", []string{"tag:root"}))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package scope models the OAuth capability scopes the Headscale v2 API enforces
|
||||
// and the rule for whether a granted set of scopes satisfies a required one.
|
||||
//
|
||||
// The vocabulary is taken from Tailscale's OpenAPI spec (the same scope names
|
||||
// the Terraform provider and Kubernetes operator request), so a client written
|
||||
// against Tailscale's scopes works unchanged against Headscale. The grant
|
||||
// predicate is kept here, separate from the HTTP/huma layer in
|
||||
// hscontrol/api/v2, so it can be tested exhaustively on its own.
|
||||
package scope
|
||||
|
||||
import "strings"
|
||||
|
||||
// Scope is an OAuth capability an operation requires and a token grants. The names
|
||||
// mirror Tailscale's API scopes; a "...:read" scope is the read-only subset of its
|
||||
// write scope.
|
||||
type Scope string
|
||||
|
||||
const (
|
||||
// All and AllRead are Tailscale's forward-compatible super-scopes: "all"
|
||||
// grants every other scope, "all:read" grants every :read subset.
|
||||
All Scope = "all"
|
||||
AllRead Scope = "all:read"
|
||||
|
||||
AuthKeys Scope = "auth_keys"
|
||||
AuthKeysRead Scope = "auth_keys:read"
|
||||
|
||||
// OAuthKeys gates managing OAuth clients (keyType:"client" on the keys
|
||||
// resource).
|
||||
OAuthKeys Scope = "oauth_keys"
|
||||
OAuthKeysRead Scope = "oauth_keys:read"
|
||||
|
||||
DevicesCore Scope = "devices:core"
|
||||
DevicesCoreRead Scope = "devices:core:read"
|
||||
|
||||
DevicesRoutes Scope = "devices:routes"
|
||||
DevicesRoutesRead Scope = "devices:routes:read"
|
||||
|
||||
PolicyFile Scope = "policy_file"
|
||||
PolicyFileRead Scope = "policy_file:read"
|
||||
|
||||
FeatureSettings Scope = "feature_settings"
|
||||
FeatureSettingsRead Scope = "feature_settings:read"
|
||||
)
|
||||
|
||||
const readSuffix = ":read"
|
||||
|
||||
// Known returns every scope in the vocabulary, in a stable order. Useful for
|
||||
// exhaustive iteration in tests and documentation.
|
||||
func Known() []Scope {
|
||||
return []Scope{
|
||||
All, AllRead,
|
||||
AuthKeys, AuthKeysRead,
|
||||
OAuthKeys, OAuthKeysRead,
|
||||
DevicesCore, DevicesCoreRead,
|
||||
DevicesRoutes, DevicesRoutesRead,
|
||||
PolicyFile, PolicyFileRead,
|
||||
FeatureSettings, FeatureSettingsRead,
|
||||
}
|
||||
}
|
||||
|
||||
// IsRead reports whether s is a read-only scope (its name ends with ":read").
|
||||
func (s Scope) IsRead() bool {
|
||||
return strings.HasSuffix(string(s), readSuffix)
|
||||
}
|
||||
|
||||
// IsWrite reports whether s is a non-empty write scope.
|
||||
func (s Scope) IsWrite() bool {
|
||||
return s != "" && !s.IsRead()
|
||||
}
|
||||
|
||||
// Parse converts scope strings (as stored on a token or client) into Scope values.
|
||||
// Unknown strings are kept verbatim; they simply never satisfy any required scope.
|
||||
func Parse(ss []string) []Scope {
|
||||
out := make([]Scope, len(ss))
|
||||
for i, s := range ss {
|
||||
out[i] = Scope(s)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
// Grants reports whether the granted scopes satisfy the required want scope.
|
||||
func Grants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if satisfies(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// satisfies reports whether a single held scope satisfies want: exact match; a
|
||||
// write scope grants its own :read subset; "all" grants everything; "all:read"
|
||||
// grants any :read scope.
|
||||
func satisfies(have, want Scope) bool {
|
||||
if have == want || have == All {
|
||||
return true
|
||||
}
|
||||
|
||||
if have == AllRead {
|
||||
return want.IsRead()
|
||||
}
|
||||
|
||||
// A write scope grants its own read subset, e.g. auth_keys ⊇ auth_keys:read.
|
||||
return string(want) == string(have)+readSuffix
|
||||
}
|
||||
|
||||
// RequiresTags reports whether any scope obliges a credential to carry tags:
|
||||
// devices:core and auth_keys mint tagged, tailnet-owned credentials.
|
||||
func RequiresTags(scopes []Scope) bool {
|
||||
for _, s := range scopes {
|
||||
if s == DevicesCore || s == AuthKeys {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"pgregory.net/rapid"
|
||||
)
|
||||
|
||||
// scopeGen draws a scope: mostly from the real vocabulary, sometimes adversarial
|
||||
// junk (including read-like junk such as "foo:read") so the rules are exercised
|
||||
// against unknown input too.
|
||||
func scopeGen() *rapid.Generator[Scope] {
|
||||
known := Known()
|
||||
|
||||
return rapid.Custom(func(t *rapid.T) Scope {
|
||||
if rapid.Float64().Draw(t, "junkP") < 0.2 {
|
||||
return Scope(rapid.StringMatching(`[a-z_]{1,12}(:read)?`).Draw(t, "junk"))
|
||||
}
|
||||
|
||||
return rapid.SampledFrom(known).Draw(t, "vocab")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsMatchesOracle fuzzes Grants against the independent oracle over random
|
||||
// granted-sets and want-scopes (vocabulary + junk).
|
||||
func TestGrantsMatchesOracle(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
if got, exp := Grants(granted, want), oracleGrants(granted, want); got != exp {
|
||||
rt.Fatalf("Grants(%v, %q) = %v, oracle = %v", granted, want, got, exp)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestGrantsInvariants asserts the algebraic properties of the grant relation hold
|
||||
// for arbitrary inputs.
|
||||
func TestGrantsInvariants(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
granted := rapid.SliceOfN(scopeGen(), 0, 6).Draw(rt, "granted")
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// Reflexivity: a scope always grants itself.
|
||||
if !Grants([]Scope{want}, want) {
|
||||
rt.Fatalf("reflexivity: %q does not grant itself", want)
|
||||
}
|
||||
|
||||
// The empty set grants nothing.
|
||||
if Grants(nil, want) {
|
||||
rt.Fatalf("empty grant satisfied %q", want)
|
||||
}
|
||||
|
||||
// OR-semantics: a set grants iff some member does.
|
||||
anyMember := slices.ContainsFunc(granted, func(g Scope) bool {
|
||||
return Grants([]Scope{g}, want)
|
||||
})
|
||||
if Grants(granted, want) != anyMember {
|
||||
rt.Fatalf("OR-semantics broken for %v / %q", granted, want)
|
||||
}
|
||||
|
||||
// Monotonicity: adding a scope never withdraws a grant.
|
||||
before := Grants(granted, want)
|
||||
extra := scopeGen().Draw(rt, "extra")
|
||||
after := Grants(append(slices.Clone(granted), extra), want)
|
||||
|
||||
if before && !after {
|
||||
rt.Fatalf("monotonicity broken: adding %q withdrew the grant of %q", extra, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestSuperScopeProperties fuzzes the super-scope rules.
|
||||
func TestSuperScopeProperties(t *testing.T) {
|
||||
rapid.Check(t, func(rt *rapid.T) {
|
||||
want := scopeGen().Draw(rt, "want")
|
||||
|
||||
// all grants everything.
|
||||
if !Grants([]Scope{All}, want) {
|
||||
rt.Fatalf("all did not grant %q", want)
|
||||
}
|
||||
|
||||
// all:read grants exactly the read scopes.
|
||||
if Grants([]Scope{AllRead}, want) != want.IsRead() {
|
||||
rt.Fatalf("all:read grant of %q = %v, want IsRead = %v",
|
||||
want, Grants([]Scope{AllRead}, want), want.IsRead())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// classify decomposes a scope into (resource, super, read) WITHOUT reusing any of
|
||||
// the production logic, so the oracle below is an independent second
|
||||
// implementation of the grant rule: a divergence between it and Grants is a real
|
||||
// bug in one of them, not a tautology.
|
||||
type classified struct {
|
||||
resource string // "" for super-scopes; otherwise the write-scope base (e.g. "auth_keys")
|
||||
super bool // all / all:read
|
||||
read bool // the :read variant
|
||||
}
|
||||
|
||||
func classify(s Scope) classified {
|
||||
str := string(s)
|
||||
read := strings.HasSuffix(str, ":read")
|
||||
base := strings.TrimSuffix(str, ":read")
|
||||
|
||||
if base == "all" {
|
||||
return classified{super: true, read: read}
|
||||
}
|
||||
|
||||
return classified{resource: base, read: read}
|
||||
}
|
||||
|
||||
// oracle re-derives "does have satisfy want" from the classification, independent
|
||||
// of satisfies/Grants.
|
||||
func oracle(have, want Scope) bool {
|
||||
h, w := classify(have), classify(want)
|
||||
|
||||
if h.super {
|
||||
// "all" grants everything; "all:read" grants only reads.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
if h.resource != w.resource {
|
||||
return false
|
||||
}
|
||||
|
||||
// Same resource: a write scope grants both read and write; a read scope grants
|
||||
// only read.
|
||||
return !h.read || w.read
|
||||
}
|
||||
|
||||
func oracleGrants(granted []Scope, want Scope) bool {
|
||||
for _, g := range granted {
|
||||
if oracle(g, want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// TestGrantsHandPicked pins specific (granted, want) outcomes with literal
|
||||
// expected values, independent of any oracle: the anchor for the rules.
|
||||
func TestGrantsHandPicked(t *testing.T) {
|
||||
tests := []struct {
|
||||
granted []Scope
|
||||
want Scope
|
||||
ok bool
|
||||
}{
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{AuthKeys}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{AuthKeysRead}, want: AuthKeysRead, ok: true},
|
||||
{granted: []Scope{DevicesCore}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{DevicesCoreRead}, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{All}, want: AuthKeys, ok: true},
|
||||
{granted: []Scope{All}, want: FeatureSettingsRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFileRead, ok: true},
|
||||
{granted: []Scope{AllRead}, want: PolicyFile, ok: false},
|
||||
{granted: []Scope{AllRead}, want: All, ok: false},
|
||||
{granted: []Scope{DevicesCore, OAuthKeys}, want: OAuthKeys, ok: true},
|
||||
{granted: nil, want: AuthKeysRead, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: AuthKeys, ok: false},
|
||||
{granted: []Scope{"garbage"}, want: "garbage", ok: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v_%s", tt.granted, tt.want), func(t *testing.T) {
|
||||
if got := Grants(tt.granted, tt.want); got != tt.ok {
|
||||
t.Errorf("Grants(%v, %q) = %v, want %v", tt.granted, tt.want, got, tt.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGrantsExhaustive checks every single-grant pair in the vocabulary against
|
||||
// the independent oracle, plus representative multi-grant cases.
|
||||
func TestGrantsExhaustive(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
for _, g := range known {
|
||||
for _, w := range known {
|
||||
got := Grants([]Scope{g}, w)
|
||||
exp := oracle(g, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants([%q], %q) = %v, oracle = %v", g, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
multi := [][]Scope{
|
||||
{All, AuthKeysRead},
|
||||
{AllRead, AuthKeys},
|
||||
{AuthKeys, OAuthKeysRead},
|
||||
{DevicesCore, DevicesRoutes, PolicyFile},
|
||||
{AuthKeys, AuthKeys}, // duplicates
|
||||
}
|
||||
|
||||
for _, granted := range multi {
|
||||
for _, w := range known {
|
||||
got := Grants(granted, w)
|
||||
exp := oracleGrants(granted, w)
|
||||
|
||||
if got != exp {
|
||||
t.Errorf("Grants(%v, %q) = %v, oracle = %v", granted, w, got, exp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestWriteGrantsItsRead and friends assert the structural rules over the whole
|
||||
// vocabulary, deterministically.
|
||||
func TestWriteGrantsItsRead(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsWrite() {
|
||||
continue
|
||||
}
|
||||
|
||||
read := Scope(string(s) + ":read")
|
||||
if !Grants([]Scope{s}, read) {
|
||||
t.Errorf("write scope %q does not grant its read subset %q", s, read)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadNeverGrantsWrite(t *testing.T) {
|
||||
for _, s := range Known() {
|
||||
if !s.IsRead() {
|
||||
continue
|
||||
}
|
||||
|
||||
write := Scope(strings.TrimSuffix(string(s), ":read"))
|
||||
if Grants([]Scope{s}, write) {
|
||||
t.Errorf("read scope %q must not grant write scope %q", s, write)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllGrantsEverything(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
if !Grants([]Scope{All}, w) {
|
||||
t.Errorf("all should grant %q", w)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllReadGrantsReadsOnly(t *testing.T) {
|
||||
for _, w := range Known() {
|
||||
got := Grants([]Scope{AllRead}, w)
|
||||
if got != w.IsRead() {
|
||||
t.Errorf("all:read grants %q = %v, want %v (IsRead)", w, got, w.IsRead())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResourceIsolation: a non-super scope never grants a scope of a different
|
||||
// resource.
|
||||
func TestResourceIsolation(t *testing.T) {
|
||||
for _, a := range Known() {
|
||||
if a == All || a == AllRead {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, b := range Known() {
|
||||
if classify(a).resource == classify(b).resource {
|
||||
continue
|
||||
}
|
||||
|
||||
if Grants([]Scope{a}, b) {
|
||||
t.Errorf("scope %q (resource %q) must not grant %q (resource %q)",
|
||||
a, classify(a).resource, b, classify(b).resource)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiresTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
scopes []Scope
|
||||
requires bool
|
||||
}{
|
||||
{scopes: []Scope{DevicesCore}, requires: true},
|
||||
{scopes: []Scope{AuthKeys}, requires: true},
|
||||
{scopes: []Scope{OAuthKeys}, requires: false},
|
||||
{scopes: []Scope{PolicyFile, AuthKeys}, requires: true},
|
||||
{scopes: []Scope{DevicesCoreRead}, requires: false},
|
||||
{scopes: nil, requires: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(fmt.Sprintf("%v", tt.scopes), func(t *testing.T) {
|
||||
if got := RequiresTags(tt.scopes); got != tt.requires {
|
||||
t.Errorf("RequiresTags(%v) = %v, want %v", tt.scopes, got, tt.requires)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnownIsComplete(t *testing.T) {
|
||||
known := Known()
|
||||
|
||||
seen := make(map[Scope]bool, len(known))
|
||||
for _, s := range known {
|
||||
if seen[s] {
|
||||
t.Errorf("Known() contains duplicate %q", s)
|
||||
}
|
||||
|
||||
seen[s] = true
|
||||
}
|
||||
|
||||
// 7 resources × 2 (write+read) + 2 super-scopes = 16.
|
||||
if len(known) != 16 {
|
||||
t.Errorf("Known() has %d scopes, want 16", len(known))
|
||||
}
|
||||
}
|
||||
@@ -1511,6 +1511,63 @@ func (s *State) DeletePreAuthKey(id uint64) error {
|
||||
return s.db.DeletePreAuthKey(id)
|
||||
}
|
||||
|
||||
// CreateOAuthClient creates a new OAuth client-credentials client, returning the
|
||||
// plaintext secret (shown once) and the stored client.
|
||||
func (s *State) CreateOAuthClient(scopes, tags []string, description string, creatorUserID *uint) (string, *types.OAuthClient, error) {
|
||||
return s.db.CreateOAuthClient(scopes, tags, description, creatorUserID)
|
||||
}
|
||||
|
||||
// AuthenticateOAuthClient validates a client secret and returns the client.
|
||||
func (s *State) AuthenticateOAuthClient(secret string) (*types.OAuthClient, error) {
|
||||
return s.db.AuthenticateOAuthClient(secret)
|
||||
}
|
||||
|
||||
// GetOAuthClientByClientID returns an OAuth client by its public client id.
|
||||
func (s *State) GetOAuthClientByClientID(clientID string) (*types.OAuthClient, error) {
|
||||
return s.db.GetOAuthClientByClientID(clientID)
|
||||
}
|
||||
|
||||
// ListOAuthClients returns every OAuth client.
|
||||
func (s *State) ListOAuthClients() ([]types.OAuthClient, error) {
|
||||
return s.db.ListOAuthClients()
|
||||
}
|
||||
|
||||
// RevokeOAuthClient deletes a client and the access tokens it issued.
|
||||
func (s *State) RevokeOAuthClient(clientID string) error {
|
||||
return s.db.RevokeOAuthClient(clientID)
|
||||
}
|
||||
|
||||
// MintAccessToken stores a new scoped access token for an OAuth client.
|
||||
func (s *State) MintAccessToken(clientID string, scopes, tags []string, expiration *time.Time) (string, *types.OAuthAccessToken, error) {
|
||||
return s.db.MintAccessToken(clientID, scopes, tags, expiration)
|
||||
}
|
||||
|
||||
// AuthenticateAccessToken validates a bearer access token and returns it with
|
||||
// its granted scopes and tags.
|
||||
func (s *State) AuthenticateAccessToken(token string) (*types.OAuthAccessToken, error) {
|
||||
return s.db.AuthenticateAccessToken(token)
|
||||
}
|
||||
|
||||
// TagOwnedByTags reports whether a credential holding ownerTags may apply tag,
|
||||
// per the policy's tag-to-tag ownership. Used to authorise the tags an OAuth
|
||||
// access token sets on the auth keys it mints.
|
||||
func (s *State) TagOwnedByTags(tag string, ownerTags []string) bool {
|
||||
return s.polMan.TagOwnedByTags(tag, ownerTags)
|
||||
}
|
||||
|
||||
// TagExists reports whether tag is defined in the policy's tagOwners. Used to
|
||||
// reject OAuth clients and auth keys carrying tags that no policy authorises,
|
||||
// matching SetNodeTags.
|
||||
func (s *State) TagExists(tag string) bool {
|
||||
return s.polMan.TagExists(tag)
|
||||
}
|
||||
|
||||
// DeleteExpiredAccessTokens hard-deletes OAuth access tokens that expired before
|
||||
// cutoff, returning how many were removed.
|
||||
func (s *State) DeleteExpiredAccessTokens(cutoff time.Time) (int64, error) {
|
||||
return s.db.DeleteExpiredAccessTokens(cutoff)
|
||||
}
|
||||
|
||||
// GetAuthCacheEntry retrieves a pending auth request from the cache.
|
||||
func (s *State) GetAuthCacheEntry(id types.AuthID) (*types.AuthRequest, bool) {
|
||||
return s.authCache.Get(id)
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package types
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
const (
|
||||
// OAuthClientPrefix prefixes an OAuth client secret:
|
||||
// hskey-client-<clientID>-<secret>.
|
||||
OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential
|
||||
|
||||
// AccessTokenPrefix prefixes an OAuth access token:
|
||||
// hskey-oauthtok-<prefix>-<secret>. The v2 auth middleware dispatches a
|
||||
// scope-limited token from an all-access admin key on this prefix alone, so
|
||||
// it is one canonical constant shared by the db and api layers.
|
||||
AccessTokenPrefix = "hskey-oauthtok-" //nolint:gosec // prefix, not a credential
|
||||
)
|
||||
|
||||
// OAuthClient is a long-lived OAuth 2.0 client-credentials principal. It mints
|
||||
// short-lived [OAuthAccessToken]s limited to its Scopes and Tags. The secret is
|
||||
// stored only as an Argon2id hash. ClientID is public and embedded in the secret
|
||||
// string (hskey-client-<ClientID>-<secret>) so the token endpoint can derive it
|
||||
// from the secret alone, matching Tailscale, where the client id is a substring
|
||||
// of the client secret.
|
||||
//
|
||||
// An OAuth client is always tag/tailnet-scoped, never user-owned: the access
|
||||
// tokens it mints, and the auth keys those tokens create, produce tagged nodes.
|
||||
// UserID only records who created the client (informational), mirroring
|
||||
// [APIKey].
|
||||
type OAuthClient struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
ClientID string `gorm:"uniqueIndex"`
|
||||
SecretHash []byte
|
||||
|
||||
// Scopes the client may grant. Tags the client may assign to access tokens
|
||||
// (and, transitively, to the auth keys and nodes those tokens create).
|
||||
Scopes []string `gorm:"serializer:json"`
|
||||
Tags []string `gorm:"serializer:json"`
|
||||
|
||||
Description string
|
||||
|
||||
// UserID records who created the client. Kept as a plain column with no
|
||||
// foreign key so an upgraded database matches a freshly-migrated one.
|
||||
UserID *uint
|
||||
|
||||
CreatedAt *time.Time
|
||||
Revoked *time.Time
|
||||
}
|
||||
|
||||
// TableName pins the table name. GORM's naming strategy would otherwise render
|
||||
// OAuthClient as "o_auth_clients" (it breaks the OAuth initialism), diverging
|
||||
// from the hand-written migration DDL and schema.sql.
|
||||
func (*OAuthClient) TableName() string { return "oauth_clients" }
|
||||
|
||||
// OAuthAccessToken is a short-lived bearer token minted by an [OAuthClient] via
|
||||
// the client-credentials grant. It carries the scope/tag set granted at mint
|
||||
// time (a subset of the issuing client's), is stored as an Argon2id hash of its
|
||||
// secret, and authenticates v2 API requests as Authorization: Bearer.
|
||||
type OAuthAccessToken struct {
|
||||
ID uint64 `gorm:"primary_key"`
|
||||
Prefix string `gorm:"uniqueIndex"`
|
||||
Hash []byte
|
||||
|
||||
// ClientID links back to the issuing [OAuthClient].
|
||||
ClientID string
|
||||
|
||||
Scopes []string `gorm:"serializer:json"`
|
||||
Tags []string `gorm:"serializer:json"`
|
||||
|
||||
Expiration *time.Time
|
||||
CreatedAt *time.Time
|
||||
}
|
||||
|
||||
// TableName pins the table name (see [OAuthClient.TableName]).
|
||||
func (*OAuthAccessToken) TableName() string { return "oauth_access_tokens" }
|
||||
|
||||
// maskedClientID returns the client id in masked form for safe logging.
|
||||
// SECURITY: never log the secret or its hash.
|
||||
func (c *OAuthClient) maskedClientID() string {
|
||||
if c.ClientID != "" {
|
||||
return OAuthClientPrefix + c.ClientID + "-***"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: intentionally does NOT log the secret or hash.
|
||||
func (c *OAuthClient) MarshalZerologObject(e *zerolog.Event) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.Uint64("oauth_client_id", c.ID)
|
||||
|
||||
if masked := c.maskedClientID(); masked != "" {
|
||||
e.Str("oauth_client", masked)
|
||||
}
|
||||
|
||||
if len(c.Scopes) > 0 {
|
||||
e.Strs("oauth_client_scopes", c.Scopes)
|
||||
}
|
||||
|
||||
if len(c.Tags) > 0 {
|
||||
e.Strs("oauth_client_tags", c.Tags)
|
||||
}
|
||||
|
||||
if c.Revoked != nil {
|
||||
e.Time("oauth_client_revoked", *c.Revoked)
|
||||
}
|
||||
}
|
||||
|
||||
// maskedPrefix returns the token prefix in masked form for safe logging.
|
||||
// SECURITY: never log the secret or its hash.
|
||||
func (t *OAuthAccessToken) maskedPrefix() string {
|
||||
if t.Prefix != "" {
|
||||
return AccessTokenPrefix + t.Prefix + "-***"
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// MarshalZerologObject implements [zerolog.LogObjectMarshaler] for safe logging.
|
||||
// SECURITY: intentionally does NOT log the secret or hash.
|
||||
func (t *OAuthAccessToken) MarshalZerologObject(e *zerolog.Event) {
|
||||
if t == nil {
|
||||
return
|
||||
}
|
||||
|
||||
e.Uint64("oauth_token_id", t.ID)
|
||||
|
||||
if masked := t.maskedPrefix(); masked != "" {
|
||||
e.Str("oauth_token", masked)
|
||||
}
|
||||
|
||||
if t.ClientID != "" {
|
||||
e.Str("oauth_token_client", t.ClientID)
|
||||
}
|
||||
|
||||
if len(t.Scopes) > 0 {
|
||||
e.Strs("oauth_token_scopes", t.Scopes)
|
||||
}
|
||||
|
||||
if len(t.Tags) > 0 {
|
||||
e.Strs("oauth_token_tags", t.Tags)
|
||||
}
|
||||
|
||||
if t.Expiration != nil {
|
||||
e.Time("oauth_token_expiration", *t.Expiration)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user