Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -417,6 +417,7 @@ var configstoreMigrationSteps = []migrationStep{
{IDs: []string{"null_legacy_customer_budget_id_refs"}, run: migrationNullLegacyCustomerBudgetID},
{IDs: []string{"add_skills_repo_tables"}, run: migrationAddSkillsRepoTables},
{IDs: []string{"add_oauth2_server_tables"}, run: migrationAddOAuth2ServerTables},
{IDs: []string{"add_oauth2_issuance_tables"}, run: migrationAddOAuth2IssuanceTables},
}

// quoteSQLiteIdentifier quotes a SQLite identifier, escaping any double quotes.
Expand Down Expand Up @@ -10942,3 +10943,57 @@ func migrationAddOAuth2ServerTables(ctx context.Context, db *gorm.DB, logger sch
}
return nil
}

func migrationAddOAuth2IssuanceTables(ctx context.Context, db *gorm.DB, logger schemas.Logger) error {
migrationName := "add_oauth2_issuance_tables"
logger.Info("[configstore] starting migration %s", migrationName)
defer logger.Info("[configstore] finished migration %s", migrationName)
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: migrationName,
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
if !mg.HasTable(&tables.TableOAuth2Client{}) {
if err := mg.CreateTable(&tables.TableOAuth2Client{}); err != nil {
return fmt.Errorf("create oauth2_clients table: %w", err)
}
}
if !mg.HasTable(&tables.TableOAuth2AuthorizeRequest{}) {
if err := mg.CreateTable(&tables.TableOAuth2AuthorizeRequest{}); err != nil {
return fmt.Errorf("create oauth2_authorize_requests table: %w", err)
}
}
if !mg.HasTable(&tables.TableOAuth2RefreshToken{}) {
if err := mg.CreateTable(&tables.TableOAuth2RefreshToken{}); err != nil {
return fmt.Errorf("create oauth2_refresh_tokens table: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mg := tx.Migrator()
// Drop in reverse creation order.
if mg.HasTable(&tables.TableOAuth2RefreshToken{}) {
if err := mg.DropTable(&tables.TableOAuth2RefreshToken{}); err != nil {
return fmt.Errorf("drop oauth2_refresh_tokens table: %w", err)
}
}
if mg.HasTable(&tables.TableOAuth2AuthorizeRequest{}) {
if err := mg.DropTable(&tables.TableOAuth2AuthorizeRequest{}); err != nil {
return fmt.Errorf("drop oauth2_authorize_requests table: %w", err)
}
}
if mg.HasTable(&tables.TableOAuth2Client{}) {
if err := mg.DropTable(&tables.TableOAuth2Client{}); err != nil {
return fmt.Errorf("drop oauth2_clients table: %w", err)
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error while running db migration %s: %w", migrationName, err)
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
165 changes: 165 additions & 0 deletions framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -6779,3 +6779,168 @@ func (s *RDBConfigStore) createOAuth2SigningKey(ctx context.Context) (*tables.OA
key.PrivateKeyPEM = privPEM
return key, nil
}

// --- OAuth2 Clients (DCR) ---

// CreateOAuth2Client persists a new DCR registration.
func (s *RDBConfigStore) CreateOAuth2Client(ctx context.Context, client *tables.TableOAuth2Client) error {
if err := s.DB().WithContext(ctx).Create(client).Error; err != nil {
return fmt.Errorf("create oauth2 client: %w", err)
}
return nil
}

// GetOAuth2ClientByClientID returns the client with the given client_id, or nil
// if not found.
func (s *RDBConfigStore) GetOAuth2ClientByClientID(ctx context.Context, clientID string) (*tables.TableOAuth2Client, error) {
var c tables.TableOAuth2Client
err := s.DB().WithContext(ctx).Where("client_id = ?", clientID).First(&c).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get oauth2 client: %w", err)
}
return &c, nil
}

// --- OAuth2 Authorize Requests ---

// CreateOAuth2AuthorizeRequest persists a new pending authorize request.
func (s *RDBConfigStore) CreateOAuth2AuthorizeRequest(ctx context.Context, req *tables.TableOAuth2AuthorizeRequest) error {
if err := s.DB().WithContext(ctx).Create(req).Error; err != nil {
return fmt.Errorf("create oauth2 authorize request: %w", err)
}
return nil
}

// GetOAuth2AuthorizeRequestByID returns the authorize request with the given ID.
func (s *RDBConfigStore) GetOAuth2AuthorizeRequestByID(ctx context.Context, id string) (*tables.TableOAuth2AuthorizeRequest, error) {
var req tables.TableOAuth2AuthorizeRequest
err := s.DB().WithContext(ctx).Where("id = ?", id).First(&req).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get oauth2 authorize request: %w", err)
}
return &req, nil
}

// GetOAuth2AuthorizeRequestByCodeHash finds a consented authorize request by
// the hash of the auth code. Used by the token endpoint.
func (s *RDBConfigStore) GetOAuth2AuthorizeRequestByCodeHash(ctx context.Context, codeHash string) (*tables.TableOAuth2AuthorizeRequest, error) {
var req tables.TableOAuth2AuthorizeRequest
err := s.DB().WithContext(ctx).
Where("code_hash = ? AND status = ?", codeHash, tables.OAuth2AuthorizeRequestStatusConsented).
First(&req).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get oauth2 authorize request by code hash: %w", err)
}
return &req, nil
}

// ConsentOAuth2AuthorizeRequest atomically transitions a still-pending authorize
// request to consented, recording the minted code hash and resolved identity in
// a single conditional update. The status guard makes the transition idempotent
// under concurrency: a second consent for the same flow matches zero rows and
// returns ErrNotFound rather than overwriting the code hash the first one minted.
func (s *RDBConfigStore) ConsentOAuth2AuthorizeRequest(ctx context.Context, req *tables.TableOAuth2AuthorizeRequest) error {
result := s.DB().WithContext(ctx).Model(&tables.TableOAuth2AuthorizeRequest{}).
Where("id = ? AND status = ?", req.ID, tables.OAuth2AuthorizeRequestStatusPending).
Updates(map[string]any{
"status": tables.OAuth2AuthorizeRequestStatusConsented,
"code_hash": req.CodeHash,
"bf_mode": req.BfMode,
"bf_sub": req.BfSub,
"updated_at": req.UpdatedAt,
})
if result.Error != nil {
return fmt.Errorf("consent authorize request: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrNotFound
}
return nil
}

// SweepExpiredOAuth2AuthorizeRequests deletes pending/consented requests past
// their TTL. Safe to call periodically.
func (s *RDBConfigStore) SweepExpiredOAuth2AuthorizeRequests(ctx context.Context) error {
return s.DB().WithContext(ctx).
Where("expires_at < ? AND status != ?", time.Now(), tables.OAuth2AuthorizeRequestStatusCodeIssued).
Delete(&tables.TableOAuth2AuthorizeRequest{}).Error
}

// --- OAuth2 Refresh Tokens ---

// GetOAuth2RefreshTokenByHash returns the refresh token row for the given hash.
func (s *RDBConfigStore) GetOAuth2RefreshTokenByHash(ctx context.Context, hash string) (*tables.TableOAuth2RefreshToken, error) {
var rt tables.TableOAuth2RefreshToken
err := s.DB().WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hash).First(&rt).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("get oauth2 refresh token: %w", err)
}
return &rt, nil
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// ConsumeOAuth2AuthorizeRequest atomically marks the authorize request as
// code_issued and creates the refresh token in a single transaction.
// If either operation fails the transaction is rolled back — the authorize
// request stays in "consented" state and the client can retry the token exchange.
func (s *RDBConfigStore) ConsumeOAuth2AuthorizeRequest(ctx context.Context, requestID string, rt *tables.TableOAuth2RefreshToken) error {
now := time.Now()
return s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Conditional update guards single-use: only a still-consented, unexpired
// request transitions. A zero-row result means the code was already
// consumed, expired, or never consented — reject before minting a token so
// a racing second exchange can't double-spend one authorization code.
result := tx.Model(&tables.TableOAuth2AuthorizeRequest{}).
Where("id = ? AND status = ? AND expires_at > ?", requestID, tables.OAuth2AuthorizeRequestStatusConsented, now).
Updates(map[string]any{
"status": tables.OAuth2AuthorizeRequestStatusCodeIssued,
"updated_at": now,
})
if result.Error != nil {
return fmt.Errorf("consume authorize request: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrNotFound
}
if err := tx.Create(rt).Error; err != nil {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return fmt.Errorf("create refresh token: %w", err)
}
return nil
})
}

// RotateOAuth2RefreshToken atomically revokes the old refresh token and creates
// the new one in a single transaction. If either operation fails the transaction
// is rolled back — the old token stays active and the client can retry the refresh.
func (s *RDBConfigStore) RotateOAuth2RefreshToken(ctx context.Context, oldID string, newRT *tables.TableOAuth2RefreshToken) error {
now := time.Now()
return s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Only an active (not-yet-revoked) token may be rotated. A zero-row result
// means the token was already revoked — either by a concurrent rotation or
// as a replay — so reject before minting a replacement.
result := tx.Model(&tables.TableOAuth2RefreshToken{}).
Where("id = ? AND revoked_at IS NULL", oldID).
Update("revoked_at", &now)
if result.Error != nil {
return fmt.Errorf("revoke old refresh token: %w", result.Error)
}
if result.RowsAffected == 0 {
return ErrNotFound
}
if err := tx.Create(newRT).Error; err != nil {
return fmt.Errorf("create new refresh token: %w", err)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
return nil
})
}
24 changes: 24 additions & 0 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,30 @@ type ConfigStore interface {
// on first call. Always returns a usable key — never nil on a nil error.
GetOAuth2SigningKey(ctx context.Context) (*tables.OAuth2SigningKey, error)

// OAuth2 clients (DCR)
CreateOAuth2Client(ctx context.Context, client *tables.TableOAuth2Client) error
GetOAuth2ClientByClientID(ctx context.Context, clientID string) (*tables.TableOAuth2Client, error)

// OAuth2 authorize requests
CreateOAuth2AuthorizeRequest(ctx context.Context, req *tables.TableOAuth2AuthorizeRequest) error
GetOAuth2AuthorizeRequestByID(ctx context.Context, id string) (*tables.TableOAuth2AuthorizeRequest, error)
GetOAuth2AuthorizeRequestByCodeHash(ctx context.Context, codeHash string) (*tables.TableOAuth2AuthorizeRequest, error)
// ConsentOAuth2AuthorizeRequest atomically transitions a still-pending request
// to consented (recording the code hash and resolved identity) — returns
// ErrNotFound when no longer pending, so concurrent double-consent can't
// overwrite an already-minted code.
ConsentOAuth2AuthorizeRequest(ctx context.Context, req *tables.TableOAuth2AuthorizeRequest) error
SweepExpiredOAuth2AuthorizeRequests(ctx context.Context) error

// OAuth2 refresh tokens
GetOAuth2RefreshTokenByHash(ctx context.Context, hash string) (*tables.TableOAuth2RefreshToken, error)
// ConsumeOAuth2AuthorizeRequest atomically marks the authorize request as
// code_issued and creates the refresh token — if either fails the client can retry.
ConsumeOAuth2AuthorizeRequest(ctx context.Context, requestID string, rt *tables.TableOAuth2RefreshToken) error
// RotateOAuth2RefreshToken atomically revokes the old token and creates the
// new one — if either fails the old token stays active and the client can retry.
RotateOAuth2RefreshToken(ctx context.Context, oldID string, newRT *tables.TableOAuth2RefreshToken) error

// Cleanup
Close(ctx context.Context) error
}
Expand Down
129 changes: 129 additions & 0 deletions framework/configstore/tables/oauth2_issuance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package tables

import (
"encoding/json"
"time"

"gorm.io/gorm"
)

// TableOAuth2Client holds a registered OAuth2 client created via Dynamic Client
// Registration (RFC 7591). Bifrost only supports public clients
// (token_endpoint_auth_method=none) — no client secrets.
type TableOAuth2Client struct {
ID string `gorm:"type:varchar(255);primaryKey" json:"id"`
ClientID string `gorm:"type:varchar(255);uniqueIndex;not null" json:"client_id"`
ClientName string `gorm:"type:varchar(255)" json:"client_name"`
RedirectURIsJSON string `gorm:"type:text;not null" json:"-"` // JSON []string
GrantTypesJSON string `gorm:"type:text;not null" json:"-"` // JSON []string
Scope string `gorm:"type:varchar(255)" json:"scope"`
CreatedAt time.Time `gorm:"index;not null" json:"created_at"`

// Virtual fields
RedirectURIs []string `gorm:"-" json:"redirect_uris"`
GrantTypes []string `gorm:"-" json:"grant_types"`
}

func (TableOAuth2Client) TableName() string { return "oauth2_clients" }

func (c *TableOAuth2Client) BeforeSave(tx *gorm.DB) error {
if c.RedirectURIs != nil {
data, err := json.Marshal(c.RedirectURIs)
if err != nil {
return err
}
c.RedirectURIsJSON = string(data)
}
if c.GrantTypes != nil {
data, err := json.Marshal(c.GrantTypes)
if err != nil {
return err
}
c.GrantTypesJSON = string(data)
}
return nil
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func (c *TableOAuth2Client) AfterFind(tx *gorm.DB) error {
if c.RedirectURIsJSON != "" {
if err := json.Unmarshal([]byte(c.RedirectURIsJSON), &c.RedirectURIs); err != nil {
return err
}
}
if c.GrantTypesJSON != "" {
if err := json.Unmarshal([]byte(c.GrantTypesJSON), &c.GrantTypes); err != nil {
return err
}
}
return nil
}

// OAuth2AuthorizeRequestStatus is the status of a downstream authorize request.
type OAuth2AuthorizeRequestStatus string

const (
OAuth2AuthorizeRequestStatusPending OAuth2AuthorizeRequestStatus = "pending" // waiting for consent
OAuth2AuthorizeRequestStatusConsented OAuth2AuthorizeRequestStatus = "consented" // identity resolved, code minted
OAuth2AuthorizeRequestStatusCodeIssued OAuth2AuthorizeRequestStatus = "code_issued" // token exchanged, one-time consumed
)

// TableOAuth2AuthorizeRequest tracks a pending downstream OAuth2 authorization
// request from creation at /oauth2/authorize through consent to token exchange
// at /oauth2/token.
//
// State transitions:
// - pending — request created; browser redirected to consent page
// - consented — user approved; identity resolved; auth code minted (CodeHash set)
// - code_issued — auth code exchanged at /oauth2/token; row is consumed (single-use)
type TableOAuth2AuthorizeRequest struct {
ID string `gorm:"type:varchar(255);primaryKey" json:"id"`
ClientID string `gorm:"type:varchar(255);not null;index" json:"client_id"`
RedirectURI string `gorm:"type:text;not null" json:"-"`
State string `gorm:"type:varchar(512);not null" json:"-"` // CSRF; returned in redirect
Scope string `gorm:"type:varchar(255)" json:"scope"`
Resource string `gorm:"type:text;not null" json:"-"` // RFC 8707 resource indicator
CodeChallenge string `gorm:"type:varchar(512);not null" json:"-"` // PKCE S256 challenge
CodeChallengeMethod string `gorm:"type:varchar(10);not null" json:"-"` // always "S256"
Status OAuth2AuthorizeRequestStatus `gorm:"type:varchar(20);not null;index" json:"status"`
// Set by the consent flow once the user approves:
BfMode string `gorm:"type:varchar(20)" json:"bf_mode,omitempty"` // user|vk|session
BfSub string `gorm:"type:varchar(255)" json:"bf_sub,omitempty"` // resolved identity
// nil while pending; set to SHA256(auth_code) at consent. A pointer so unset
// rows store SQL NULL — NULLs are distinct under the unique index, letting many
// requests stay pending at once while still enforcing uniqueness for real hashes.
CodeHash *string `gorm:"type:varchar(255);uniqueIndex" json:"-"`
// TTL:
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
UpdatedAt time.Time `gorm:"not null" json:"updated_at"`
}

func (TableOAuth2AuthorizeRequest) TableName() string { return "oauth2_authorize_requests" }

// TableOAuth2RefreshToken stores a hashed rotating refresh token. The plaintext
// token is only returned to the client once at issuance; only the SHA256 hash
// is persisted. Invalidation paths:
// - rotation on use: old token revoked atomically when a new one is issued
// - bf_sub liveness: VK deleted or user deactivated → invalid_grant on next refresh
// - explicit revocation via the Connected Clients UI
//
// FamilyID links all tokens descended from the same authorization grant (set to
// the authorize request ID at first issuance, propagated on every rotation).
// When a revoked token is presented — indicating the token was stolen and used
// after the legitimate client already rotated — all tokens sharing the FamilyID
// are revoked immediately, per the OAuth 2.0 Security BCP (RFC 9700 §2.2.2).
type TableOAuth2RefreshToken struct {
ID string `gorm:"type:varchar(255);primaryKey" json:"id"`
TokenHash string `gorm:"type:varchar(255);uniqueIndex;not null" json:"-"` // SHA256 hex
FamilyID string `gorm:"type:varchar(255);not null;index" json:"family_id"` // authorize request ID
ClientID string `gorm:"type:varchar(255);not null;index" json:"client_id"`
BfMode string `gorm:"type:varchar(20);not null" json:"bf_mode"` // user|vk|session
BfSub string `gorm:"type:varchar(255);not null" json:"bf_sub"` // resolved identity
Scope string `gorm:"type:varchar(255)" json:"scope"`
Resource string `gorm:"type:text;not null" json:"-"` // RFC 8707 resource indicator; preserved across rotations for the JWT aud claim
RevokedAt *time.Time `gorm:"index" json:"revoked_at,omitempty"`
LastUsedAt *time.Time `gorm:"index" json:"last_used_at,omitempty"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
}

func (TableOAuth2RefreshToken) TableName() string { return "oauth2_refresh_tokens" }
Loading
Loading