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:
Kristoffer Dalby
2026-06-21 17:35:14 +00:00
parent 5aeeff98d0
commit 3fdc068c8c
13 changed files with 1509 additions and 14 deletions
+75
View File
@@ -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 {
+385
View File
@@ -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
}
+204
View File
@@ -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)
}
+24 -14
View File
@@ -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) {
+30
View File
@@ -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,