diff --git a/hscontrol/db/db.go b/hscontrol/db/db.go index af2a6d34..c9b94ef6 100644 --- a/hscontrol/db/db.go +++ b/hscontrol/db/db.go @@ -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 { diff --git a/hscontrol/db/oauth.go b/hscontrol/db/oauth.go new file mode 100644 index 00000000..6ce757bc --- /dev/null +++ b/hscontrol/db/oauth.go @@ -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--. 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--. 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 +} diff --git a/hscontrol/db/oauth_test.go b/hscontrol/db/oauth_test.go new file mode 100644 index 00000000..1bf8c72d --- /dev/null +++ b/hscontrol/db/oauth_test.go @@ -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) +} diff --git a/hscontrol/db/preauth_keys.go b/hscontrol/db/preauth_keys.go index 1da35ac3..e839ff50 100644 --- a/hscontrol/db/preauth_keys.go +++ b/hscontrol/db/preauth_keys.go @@ -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) { diff --git a/hscontrol/db/schema.sql b/hscontrol/db/schema.sql index 6343ad81..59b60160 100644 --- a/hscontrol/db/schema.sql +++ b/hscontrol/db/schema.sql @@ -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--); 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, diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 4c9dc397..ffde6361 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -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 diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index 49750713..7da5dcb2 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -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 { diff --git a/hscontrol/policy/v2/policy_test.go b/hscontrol/policy/v2/policy_test.go index 9237f33f..bc8b0f25 100644 --- a/hscontrol/policy/v2/policy_test.go +++ b/hscontrol/policy/v2/policy_test.go @@ -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"})) + }) +} diff --git a/hscontrol/scope/scope.go b/hscontrol/scope/scope.go new file mode 100644 index 00000000..6a06917a --- /dev/null +++ b/hscontrol/scope/scope.go @@ -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 +} diff --git a/hscontrol/scope/scope_property_test.go b/hscontrol/scope/scope_property_test.go new file mode 100644 index 00000000..1132ba51 --- /dev/null +++ b/hscontrol/scope/scope_property_test.go @@ -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()) + } + }) +} diff --git a/hscontrol/scope/scope_test.go b/hscontrol/scope/scope_test.go new file mode 100644 index 00000000..0b59aa42 --- /dev/null +++ b/hscontrol/scope/scope_test.go @@ -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)) + } +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index c4d70efd..7432e2bb 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -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) diff --git a/hscontrol/types/oauth.go b/hscontrol/types/oauth.go new file mode 100644 index 00000000..66c87e83 --- /dev/null +++ b/hscontrol/types/oauth.go @@ -0,0 +1,153 @@ +package types + +import ( + "time" + + "github.com/rs/zerolog" +) + +const ( + // OAuthClientPrefix prefixes an OAuth client secret: + // hskey-client--. + OAuthClientPrefix = "hskey-client-" //nolint:gosec // prefix, not a credential + + // AccessTokenPrefix prefixes an OAuth access token: + // hskey-oauthtok--. 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--) 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) + } +}