Skip to content
Merged
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
2 changes: 2 additions & 0 deletions core/schemas/bifrost.go
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,8 @@ const (
IsLocalAdminContextKey BifrostContextKey = "is_local_admin" // bool (set by auth middleware when password-based auth succeeds - local admin user bypasses RBAC)
BifrostContextKeyPassthroughOverridesPresent BifrostContextKey = "passthrough_overrides_present" // bool (set by HTTP transport) - passthrough raw request requested
BifrostContextKeyConnectionClosed BifrostContextKey = "connection_closed"
BifrostContextKeyTempTokenScope BifrostContextKey = "bifrost-temp-token-scope" // string (set by auth middleware when a temp token authorized the request - names the scope from the temptoken registry)
BifrostContextKeyTempTokenResourceID BifrostContextKey = "bifrost-temp-token-resource-id" // string (set by auth middleware alongside the scope - the resource_id the token is bound to, e.g. an OAuth flow ID for mcp_auth)
)

const (
Expand Down
37 changes: 37 additions & 0 deletions framework/configstore/encryption.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ func (s *RDBConfigStore) EncryptPlaintextRows(ctx context.Context) error {
}
totalEncrypted += count

// temp_tokens
count, err = s.encryptPlaintextTempTokens(ctx)
if err != nil {
return fmt.Errorf("failed to encrypt temp_tokens: %w", err)
}
totalEncrypted += count

// oauth_tokens
count, err = s.encryptPlaintextOAuthTokens(ctx)
if err != nil {
Expand Down Expand Up @@ -185,6 +192,36 @@ func (s *RDBConfigStore) encryptPlaintextSessions(ctx context.Context) (int, err
return count, nil
}

// encryptPlaintextTempTokens finds all temp_tokens rows with plaintext encryption status
// and re-saves them in batches. The TempToken.BeforeSave hook handles encryption.
func (s *RDBConfigStore) encryptPlaintextTempTokens(ctx context.Context) (int, error) {
var count int
for {
var tokens []tables.TempToken
if err := s.DB().WithContext(ctx).
Where("(encryption_status = ? OR encryption_status IS NULL OR encryption_status = '') AND token != ''", encryptionStatusPlainText).
Limit(encryptionBatchSize).
Find(&tokens).Error; err != nil {
return count, err
}
if len(tokens) == 0 {
break
}
if err := s.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for i := range tokens {
if err := tx.Save(&tokens[i]).Error; err != nil {
return err
}
}
return nil
}); err != nil {
return count, err
}
count += len(tokens)
}
return count, nil
}

// encryptPlaintextOAuthTokens finds all oauth_tokens rows with plaintext encryption status
// and re-saves them in batches. The TableOauthToken.BeforeSave hook handles encryption.
func (s *RDBConfigStore) encryptPlaintextOAuthTokens(ctx context.Context) (int, error) {
Expand Down
35 changes: 35 additions & 0 deletions framework/configstore/migrations.go
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error {
if err := migrationAddClientConfigMetadataColumn(ctx, db); err != nil {
return err
}
if err := migrationAddTempTokensTable(ctx, db); err != nil {
return err
}
// Runs LAST in this batch — the refresh does tx.Find(&clientConfigs)
// which GORM projects to every column in TableClientConfig, so it has
// to come after every config_client column-add above (otherwise the
Expand Down Expand Up @@ -8483,3 +8486,35 @@ func migrationAddFrameworkConfigHashColumn(ctx context.Context, db *gorm.DB) err
}
return nil
}

// migrationAddTempTokensTable creates the temp_tokens table that backs the
// temptoken service.
func migrationAddTempTokensTable(ctx context.Context, db *gorm.DB) error {
m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{
ID: "add_temp_tokens_table",
Migrate: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if !mig.HasTable(&tables.TempToken{}) {
if err := mig.CreateTable(&tables.TempToken{}); err != nil {
return fmt.Errorf("failed to create temp_tokens table: %w", err)
}
}
return nil
},
Rollback: func(tx *gorm.DB) error {
tx = tx.WithContext(ctx)
mig := tx.Migrator()
if mig.HasTable(&tables.TempToken{}) {
if err := mig.DropTable(&tables.TempToken{}); err != nil {
return err
}
}
return nil
},
}})
if err := m.Migrate(); err != nil {
return fmt.Errorf("error running add_temp_tokens_table migration: %s", err.Error())
}
return nil
}
59 changes: 58 additions & 1 deletion framework/configstore/rdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -4443,6 +4443,64 @@ func (s *RDBConfigStore) FlushSessions(ctx context.Context) error {
return s.DB().WithContext(ctx).Session(&gorm.Session{AllowGlobalUpdate: true}).Delete(&tables.SessionsTable{}).Error
}

// CreateTempToken inserts a new temp_tokens row. The plaintext token must be
// set on the struct; the BeforeSave hook populates token_hash and (when
// encryption is enabled) encrypts the plaintext in place. The optional tx
// lets callers fold this write into an existing transaction (mirrors the
// pattern used by other mutating configstore methods).
func (s *RDBConfigStore) CreateTempToken(ctx context.Context, token *tables.TempToken, tx ...*gorm.DB) error {
db := s.DB()
if len(tx) > 0 {
db = tx[0]
}
return db.WithContext(ctx).Create(token).Error
}

// GetTempTokenByHash retrieves a temp_tokens row by the SHA-256 hash of its
// plaintext. Returns (nil, nil) when no row matches — callers should treat that
// as "no such token" rather than an error.
func (s *RDBConfigStore) GetTempTokenByHash(ctx context.Context, tokenHash string) (*tables.TempToken, error) {
var token tables.TempToken
err := s.DB().WithContext(ctx).First(&token, "token_hash = ?", tokenHash).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &token, nil
}

// DeleteTempTokensByResourceID hard-deletes every row matching (scope,
// resource_id). Used by lifecycle owners (e.g. OAuth provider on flow
// termination) to invalidate the link as soon as the work it authorized
// completes. The (scope, resource_id) pair — not resource_id alone — keeps
// future scopes that happen to reuse the same opaque ID untouched. The
// optional tx lets callers fold the delete into an existing transaction.
func (s *RDBConfigStore) DeleteTempTokensByResourceID(ctx context.Context, scope, resourceID string, tx ...*gorm.DB) (int64, error) {
db := s.DB()
if len(tx) > 0 {
db = tx[0]
}
res := db.WithContext(ctx).
Where("scope = ? AND resource_id = ?", scope, resourceID).
Delete(&tables.TempToken{})
if res.Error != nil {
return 0, res.Error
}
return res.RowsAffected, nil
}

// DeleteExpiredTempTokens hard-deletes rows whose expires_at is at or before
// the given cutoff. Returns the number of rows deleted.
func (s *RDBConfigStore) DeleteExpiredTempTokens(ctx context.Context, before time.Time) (int64, error) {
res := s.DB().WithContext(ctx).Where("expires_at <= ?", before).Delete(&tables.TempToken{})
if res.Error != nil {
return 0, res.Error
}
return res.RowsAffected, nil
}

// ExecuteTransaction executes a transaction.
func (s *RDBConfigStore) ExecuteTransaction(ctx context.Context, fn func(tx *gorm.DB) error) error {
return s.DB().WithContext(ctx).Transaction(fn)
Expand Down Expand Up @@ -5098,4 +5156,3 @@ func (s *RDBConfigStore) DeleteOrphanedOauthUserTokens(ctx context.Context, olde
}
return result.RowsAffected, nil
}

9 changes: 9 additions & 0 deletions framework/configstore/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,15 @@ type ConfigStore interface {
DeleteSession(ctx context.Context, token string) error
FlushSessions(ctx context.Context) error

// Temp token CRUD
CreateTempToken(ctx context.Context, token *tables.TempToken, tx ...*gorm.DB) error
GetTempTokenByHash(ctx context.Context, tokenHash string) (*tables.TempToken, error)
// DeleteTempTokensByResourceID removes every row matching (scope, resource_id).
// Used by lifecycle owners (e.g. OAuth provider on flow termination) to burn
// the link as soon as the work it authorized is finished.
DeleteTempTokensByResourceID(ctx context.Context, scope, resourceID string, tx ...*gorm.DB) (int64, error)
DeleteExpiredTempTokens(ctx context.Context, before time.Time) (int64, error)
Comment thread
roroghost17 marked this conversation as resolved.

// Model pricing CRUD
GetModelPrices(ctx context.Context) ([]tables.TableModelPricing, error)
UpsertModelPrices(ctx context.Context, pricing *tables.TableModelPricing, tx ...*gorm.DB) error
Expand Down
57 changes: 57 additions & 0 deletions framework/configstore/tables/temp_token.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package tables

import (
"fmt"
"time"

"github.com/maximhq/bifrost/framework/encrypt"
"gorm.io/gorm"
)

// TempToken is a short-lived, narrow-scope credential that authorizes access
// to a specific set of endpoints without requiring dashboard login.
//
// Each row is bound to a (scope, resource_id) pair: the scope names a set of
// allowed routes (registered in framework/temptoken), and the resource_id ties
// the token to the specific resource those routes act on (e.g. the OAuth flow
// ID for the mcp_auth scope). The plaintext token is hashed for lookup and
// encrypted at rest, matching the SessionsTable pattern.
type TempToken struct {
ID string `gorm:"type:varchar(255);primaryKey" json:"id"` // UUID
Token string `gorm:"type:text;not null" json:"-"` // encrypted at rest when encryption is enabled
TokenHash string `gorm:"type:varchar(64);uniqueIndex:idx_temp_token_hash" json:"-"` // SHA-256 of plaintext for lookup
Scope string `gorm:"type:varchar(64);index;not null" json:"scope"` // e.g. "mcp_auth" — keys into the scope registry
ResourceID string `gorm:"type:text;index" json:"resource_id,omitempty"` // resource the scope binds to (semantics per scope); indexed for lifecycle-driven deletes
ExpiresAt time.Time `gorm:"index;not null" json:"expires_at"`
CreatedAt time.Time `gorm:"index;not null" json:"created_at"`
UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"`
EncryptionStatus string `gorm:"type:varchar(20);default:'plain_text'" json:"-"`
}

// TableName sets the table name for the model.
func (TempToken) TableName() string { return "temp_tokens" }

// BeforeSave hashes the plaintext for lookup and encrypts it for storage.
// Hash must be computed before encryption so it always covers the plaintext.
func (t *TempToken) BeforeSave(tx *gorm.DB) error {
if t.Token != "" {
t.TokenHash = encrypt.HashSHA256(t.Token)
}
if encrypt.IsEnabled() && t.Token != "" {
if err := encryptString(&t.Token); err != nil {
return fmt.Errorf("failed to encrypt temp token: %w", err)
}
t.EncryptionStatus = EncryptionStatusEncrypted
}
return nil
}

// AfterFind decrypts the stored plaintext when encryption is in effect.
func (t *TempToken) AfterFind(tx *gorm.DB) error {
if t.EncryptionStatus == EncryptionStatusEncrypted {
if err := decryptString(&t.Token); err != nil {
return fmt.Errorf("failed to decrypt temp token: %w", err)
}
}
return nil
Comment thread
roroghost17 marked this conversation as resolved.
}
Loading
Loading