Ent-Backed BFF Session Store¶
CoreForge provides an Ent-backed session store for production BFF deployments. This stores sessions in your database with encrypted tokens at rest.
Overview¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ CoreForge (Library) │
├─────────────────────────────────────────────────────────────────────────────┤
│ session/bff/store.go session/bff/store_ent.go │
│ │
│ type Store interface { type EntStore struct { │
│ Create(...) config EntStoreConfig │
│ Get(...) encryptor *Encryptor │
│ Update(...) } │
│ Delete(...) │
│ } func NewEntStore(...) (*EntStore, error) │
└────────────────────────────────────────────────────────┬────────────────────┘
│
│ implements
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ App-Level Implementation │
├─────────────────────────────────────────────────────────────────────────────┤
│ internal/ent/schema/bff_session.go internal/auth/bff_store.go │
│ │
│ Uses mixin: Implements EntClientInterface: │
│ coreforge/identity/ent/mixin wraps generated Ent client │
└─────────────────────────────────────────────────────────────────────────────┘
Features¶
- Encrypted tokens - AES-256-GCM encryption for tokens at rest
- Automatic cleanup - Background goroutine removes expired sessions
- Database agnostic - Works with PostgreSQL, MySQL, SQLite
- Reusable schema - Ent mixin provides consistent field definitions
Setup¶
Step 1: Add Schema to Your App¶
Create an Ent schema using the CoreForge mixin:
// internal/ent/schema/bff_session.go
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"github.com/grokify/coreforge/identity/ent/mixin"
)
// BFFSession holds the schema definition.
type BFFSession struct {
ent.Schema
}
// Mixin of the BFFSession.
func (BFFSession) Mixin() []ent.Mixin {
return []ent.Mixin{
mixin.BFFSession{},
}
}
// Edges of the BFFSession (optional, for relations).
func (BFFSession) Edges() []ent.Edge {
return []ent.Edge{
edge.From("user", User.Type).
Ref("bff_sessions").
Field("user_id").
Required().
Unique(),
}
}
Then run go generate ./ent to generate the Ent code.
Step 2: Implement Client Interface¶
Create a wrapper that implements bff.EntClientInterface:
// internal/auth/bff_store.go
package auth
import (
"context"
"time"
"github.com/google/uuid"
"github.com/grokify/coreforge/session/bff"
"myapp/internal/ent"
"myapp/internal/ent/bffsession"
)
// EntClientWrapper implements bff.EntClientInterface.
type EntClientWrapper struct {
client *ent.Client
}
// NewEntClientWrapper creates a new wrapper.
func NewEntClientWrapper(client *ent.Client) *EntClientWrapper {
return &EntClientWrapper{client: client}
}
func (w *EntClientWrapper) CreateBFFSession(ctx context.Context, session *bff.Session) error {
create := w.client.BFFSession.Create().
SetID(session.ID).
SetUserID(session.UserID).
SetAccessTokenEncrypted(session.EncryptedAccessToken()).
SetRefreshTokenEncrypted(session.EncryptedRefreshToken()).
SetAccessTokenExpiresAt(session.AccessTokenExpiresAt).
SetRefreshTokenExpiresAt(session.RefreshTokenExpiresAt).
SetExpiresAt(session.ExpiresAt).
SetCreatedAt(session.CreatedAt).
SetUpdatedAt(session.UpdatedAt).
SetLastAccessedAt(session.LastAccessedAt)
if session.OrganizationID != nil {
create = create.SetOrganizationID(*session.OrganizationID)
}
if session.IPAddress != "" {
create = create.SetIPAddress(session.IPAddress)
}
if session.UserAgent != "" {
create = create.SetUserAgent(session.UserAgent)
}
if len(session.EncryptedDPoPKeyPair()) > 0 {
create = create.SetDpopKeyPairEncrypted(session.EncryptedDPoPKeyPair())
}
if session.DPoPThumbprint != "" {
create = create.SetDpopThumbprint(session.DPoPThumbprint)
}
if session.Metadata != nil {
create = create.SetMetadata(session.Metadata)
}
_, err := create.Save(ctx)
return err
}
func (w *EntClientWrapper) GetBFFSession(ctx context.Context, id string) (*bff.Session, error) {
s, err := w.client.BFFSession.Query().
Where(bffsession.IDEQ(id)).
Only(ctx)
if err != nil {
if ent.IsNotFound(err) {
return nil, bff.ErrSessionNotFound
}
return nil, err
}
session := &bff.Session{
ID: s.ID,
UserID: s.UserID,
OrganizationID: s.OrganizationID,
AccessTokenExpiresAt: s.AccessTokenExpiresAt,
RefreshTokenExpiresAt: s.RefreshTokenExpiresAt,
DPoPThumbprint: s.DpopThumbprint,
IPAddress: s.IPAddress,
UserAgent: s.UserAgent,
Metadata: s.Metadata,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
LastAccessedAt: s.LastAccessedAt,
ExpiresAt: s.ExpiresAt,
}
// Set encrypted fields for decryption by EntStore
session.SetEncryptedTokens(
s.AccessTokenEncrypted,
s.RefreshTokenEncrypted,
s.DpopKeyPairEncrypted,
)
return session, nil
}
func (w *EntClientWrapper) UpdateBFFSession(ctx context.Context, session *bff.Session) error {
update := w.client.BFFSession.UpdateOneID(session.ID).
SetAccessTokenEncrypted(session.EncryptedAccessToken()).
SetRefreshTokenEncrypted(session.EncryptedRefreshToken()).
SetAccessTokenExpiresAt(session.AccessTokenExpiresAt).
SetRefreshTokenExpiresAt(session.RefreshTokenExpiresAt).
SetExpiresAt(session.ExpiresAt).
SetUpdatedAt(session.UpdatedAt).
SetLastAccessedAt(session.LastAccessedAt)
if len(session.EncryptedDPoPKeyPair()) > 0 {
update = update.SetDpopKeyPairEncrypted(session.EncryptedDPoPKeyPair())
}
if session.DPoPThumbprint != "" {
update = update.SetDpopThumbprint(session.DPoPThumbprint)
}
if session.Metadata != nil {
update = update.SetMetadata(session.Metadata)
}
_, err := update.Save(ctx)
return err
}
func (w *EntClientWrapper) DeleteBFFSession(ctx context.Context, id string) error {
return w.client.BFFSession.DeleteOneID(id).Exec(ctx)
}
func (w *EntClientWrapper) DeleteBFFSessionsByUserID(ctx context.Context, userID uuid.UUID) (int, error) {
return w.client.BFFSession.Delete().
Where(bffsession.UserIDEQ(userID)).
Exec(ctx)
}
func (w *EntClientWrapper) TouchBFFSession(ctx context.Context, id string) error {
_, err := w.client.BFFSession.UpdateOneID(id).
SetLastAccessedAt(time.Now()).
Save(ctx)
return err
}
func (w *EntClientWrapper) CleanupExpiredBFFSessions(ctx context.Context, limit int) (int, error) {
return w.client.BFFSession.Delete().
Where(bffsession.ExpiresAtLT(time.Now())).
Exec(ctx)
}
Step 3: Wire Up the Store¶
// main.go or wire.go
import (
"github.com/grokify/coreforge/session/bff"
"myapp/internal/auth"
)
// Create the Ent store
store, err := bff.NewEntStore(bff.EntStoreConfig{
Client: auth.NewEntClientWrapper(entClient),
EncryptionKey: []byte(cfg.SessionEncryptionKey), // 32 bytes
CleanupInterval: 5 * time.Minute,
CleanupBatchSize: 100,
})
if err != nil {
log.Fatal(err)
}
defer store.Close()
// Use with BFF handler
bffHandler, err := bff.NewHandler(bff.HandlerConfig{
Store: store,
AllowedOrigins: cfg.AllowedOrigins,
// ...
})
Configuration¶
EntStoreConfig¶
type EntStoreConfig struct {
// Required: Ent client wrapper
Client EntClientInterface
// Required: 32-byte encryption key for AES-256
EncryptionKey []byte
// Optional: cleanup interval (0 = disabled)
CleanupInterval time.Duration
// Optional: max sessions deleted per cleanup (default: 100)
CleanupBatchSize int
}
Encryption Key¶
The encryption key must be exactly 32 bytes for AES-256-GCM:
// From environment variable (base64 encoded)
key, _ := base64.StdEncoding.DecodeString(os.Getenv("SESSION_ENCRYPTION_KEY"))
// Or generate one for development
key := make([]byte, 32)
rand.Read(key)
fmt.Println("Key:", base64.StdEncoding.EncodeToString(key))
Mixin Fields¶
The mixin.BFFSession provides these fields:
| Field | Type | Description |
|---|---|---|
id |
string | Session ID (unique, immutable) |
user_id |
UUID | Owner user |
organization_id |
UUID | Organization context (optional) |
access_token_encrypted |
[]byte | Encrypted access token |
refresh_token_encrypted |
[]byte | Encrypted refresh token |
access_token_expires_at |
time | Access token expiry |
refresh_token_expires_at |
time | Refresh token expiry |
dpop_key_pair_encrypted |
[]byte | Encrypted DPoP key pair (optional) |
dpop_thumbprint |
string | DPoP JWK thumbprint (optional) |
ip_address |
string | Client IP (optional) |
user_agent |
string | Client User-Agent (optional) |
metadata |
JSON | Additional metadata (optional) |
last_accessed_at |
time | Last access timestamp |
expires_at |
time | Session expiry |
created_at |
time | Creation timestamp |
updated_at |
time | Update timestamp |
Indexes¶
The mixin creates indexes for common queries:
index.Fields("user_id") // Find sessions by user
index.Fields("expires_at") // Cleanup expired sessions
index.Fields("organization_id") // Filter by organization
Encryption¶
Tokens are encrypted using AES-256-GCM before storage:
// Encryption happens automatically in Create/Update
store.Create(ctx, session)
// session.AccessToken and RefreshToken are encrypted
// Decryption happens automatically in Get
session, _ := store.Get(ctx, id)
// session.AccessToken and RefreshToken are decrypted
The Encryptor is also available for direct use:
encryptor, _ := bff.NewEncryptor(key)
// Encrypt
ciphertext, _ := encryptor.Encrypt([]byte("secret"))
ciphertext, _ := encryptor.EncryptString("secret")
// Decrypt
plaintext, _ := encryptor.Decrypt(ciphertext)
str, _ := encryptor.DecryptString(ciphertext)
Automatic Cleanup¶
When CleanupInterval is set, expired sessions are automatically deleted:
store, _ := bff.NewEntStore(bff.EntStoreConfig{
// ...
CleanupInterval: 5 * time.Minute,
CleanupBatchSize: 100, // Delete at most 100 per run
})
// Cleanup runs in background goroutine
// Stop it on shutdown:
store.Close()
Manual cleanup is also available:
Database Migration¶
Example SQL migration (PostgreSQL):
CREATE TABLE bff_sessions (
id VARCHAR(64) PRIMARY KEY,
user_id UUID NOT NULL,
organization_id UUID,
access_token_encrypted BYTEA NOT NULL,
refresh_token_encrypted BYTEA NOT NULL,
access_token_expires_at TIMESTAMP NOT NULL,
refresh_token_expires_at TIMESTAMP NOT NULL,
dpop_key_pair_encrypted BYTEA,
dpop_thumbprint VARCHAR(64),
ip_address VARCHAR(45),
user_agent VARCHAR(500),
metadata JSONB,
last_accessed_at TIMESTAMP NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
);
CREATE INDEX idx_bff_sessions_user_id ON bff_sessions(user_id);
CREATE INDEX idx_bff_sessions_expires_at ON bff_sessions(expires_at);
CREATE INDEX idx_bff_sessions_organization_id ON bff_sessions(organization_id);
Security Considerations¶
- Protect the encryption key - Store in secrets manager, not in code
- Rotate keys periodically - Implement key rotation strategy
- Use HTTPS - Encrypted storage doesn't help if transport is insecure
- Monitor session counts - Alert on unusual session creation rates
- Audit access - Log session creation/deletion for security review