Files
Kristoffer Dalby ee95cf58d9 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.
2026-06-26 09:18:06 +02:00

154 lines
4.5 KiB
Go

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)
}
}