Bootstrap copied BFF and local accounts with public Sendico contracts (#34)

This commit is contained in:
Stephan D committed 2026-09-12 13:37:48 +02:00
1 parent 67dc5b5b1e
commit 7062672a87
556 files changed
+42291 -777

No files matched your search

@@ -0,0 +1,12 @@
package refreshtokens
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*model.Client, error) {
var client model.Client
return &client, db.clients.FindOneByFilter(ctx, filterByClientId(clientID), &client)
}
@@ -0,0 +1,189 @@
package refreshtokens
import (
"context"
"errors"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"git.sendico.io/tech/sxchnge/bff/pkg/mutil/mzap"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *RefreshTokenDB) Create(ctx context.Context, rt *model.RefreshToken) error {
if rt.LastUsedAt.IsZero() {
rt.LastUsedAt = time.Now()
}
if rt.AccountRef == nil {
return merrors.InvalidArgument("Account reference must have a valid value", "refreshToken.accountRef")
}
matched, err := db.replaceActive(ctx, rt)
if err != nil {
return err
}
if matched {
return nil
}
db.Logger.Debug("Registering refresh token", zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
if err := db.DBImp.Create(ctx, rt); err != nil {
if !errors.Is(err, merrors.ErrDataConflict) {
return err
}
// Another login can establish the same active slot after the patch and
// before this insert. Converge on that row instead of returning the
// unique-index conflict to the caller.
db.Logger.Debug("Concurrent refresh session registration detected",
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
matched, reconcileErr := db.replaceActive(ctx, rt)
if reconcileErr != nil {
return reconcileErr
}
if !matched {
return err
}
}
return nil
}
func (db *RefreshTokenDB) replaceActive(ctx context.Context, rt *model.RefreshToken) (bool, error) {
patch := repository.Patch().
Set(repository.Field(TokenField), rt.RefreshToken).
Set(repository.Field(ExpiresAtField), rt.ExpiresAt).
Set(repository.Field(UserAgentField), rt.UserAgent).
Set(repository.Field(IPAddressField), rt.IPAddress).
Set(repository.Field(LastUsedAtField), rt.LastUsedAt).
Set(repository.Field(IsRevokedField), rt.IsRevoked).
Set(repository.Field(SessionVersionField), rt.SessionVersion)
var stored model.RefreshToken
matched, err := db.Repository.PatchOneByFilter(
ctx,
filterSlotByAccount(*rt.AccountRef, &rt.SessionIdentifier),
patch,
&stored,
)
if err != nil {
db.Logger.Warn("Failed to replace active refresh session", zap.Error(err),
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
return false, err
}
if !matched {
return false, nil
}
db.Logger.Debug("Replaced active refresh session",
zap.String("client_id", rt.ClientID), zap.String("device_id", rt.DeviceID))
rt.SetID(*stored.GetID())
return true, nil
}
func (db *RefreshTokenDB) Update(ctx context.Context, rt *model.RefreshToken) error {
rt.LastUsedAt = time.Now()
// Use Patch instead of Update to avoid race conditions
patch := repository.Patch().
Set(repository.Field(TokenField), rt.RefreshToken).
Set(repository.Field(ExpiresAtField), rt.ExpiresAt).
Set(repository.Field(UserAgentField), rt.UserAgent).
Set(repository.Field(IPAddressField), rt.IPAddress).
Set(repository.Field(LastUsedAtField), rt.LastUsedAt).
Set(repository.Field(IsRevokedField), rt.IsRevoked).
Set(repository.Field(SessionVersionField), rt.SessionVersion)
return db.Patch(ctx, *rt.GetID(), patch)
}
func (db *RefreshTokenDB) Delete(ctx context.Context, tokenRef bson.ObjectID) error {
db.Logger.Debug("Deleting refresh token", mzap.ObjRef("refresh_token_ref", tokenRef))
return db.DBImp.Delete(ctx, tokenRef)
}
func (db *RefreshTokenDB) Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error {
var rt model.RefreshToken
f := filterByAccount(accountRef, session)
if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil {
if errors.Is(err, merrors.ErrNoData) {
db.Logger.Debug("Refresh token not found during revoke",
mzap.AccRef(accountRef), zap.String("client_id", session.ClientID), zap.String("device_id", session.DeviceID))
return nil
}
return err
}
// Use Patch to update the revocation status
patch := repository.Patch().
Set(repository.Field(IsRevokedField), true).
Set(repository.Field(LastUsedAtField), time.Now())
return db.Patch(ctx, *rt.GetID(), patch)
}
func (db *RefreshTokenDB) RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error) {
query := filter(&token.SessionIdentifier).
And(repository.Query().Comparison(repository.Field(TokenField), builder.Eq, token.RefreshToken))
patch := repository.Patch().
Set(repository.Field(IsRevokedField), true).
Set(repository.Field(LastUsedAtField), time.Now())
modified, err := db.Repository.PatchMany(ctx, query, patch)
return modified > 0, err
}
func (db *RefreshTokenDB) GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error) {
var rt model.RefreshToken
if err := db.Repository.FindOneByFilter(ctx, filterByAccount(accountRef, session), &rt); err != nil {
return nil, err
}
if !rt.ExpiresAt.After(time.Now()) {
return nil, merrors.ErrNoData
}
return &rt, nil
}
func (db *RefreshTokenDB) GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error) {
var rt model.RefreshToken
f := filter(&t.SessionIdentifier).And(repository.Query().Filter(repository.Field("token"), t.RefreshToken))
if err := db.Repository.FindOneByFilter(ctx, f, &rt); err != nil {
if !errors.Is(err, merrors.ErrNoData) {
db.Logger.Warn("Failed to fetch refresh token", zap.Error(err),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
}
return nil, err
}
// Check if token is expired
if rt.ExpiresAt.Before(time.Now()) {
db.Logger.Debug("Refresh token expired", mzap.StorableRef(&rt),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID),
zap.Time("expires_at", rt.ExpiresAt))
return nil, merrors.AccessDenied(mservice.RefreshTokens, string(model.ActionRead), *rt.GetID())
}
// Check if token is revoked
if rt.IsRevoked {
db.Logger.Debug("Refresh token is revoked", mzap.StorableRef(&rt),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
return nil, merrors.ErrNoData
}
if rt.GetID() != nil && !rt.GetID().IsZero() {
lastUsedAt := time.Now()
patch := repository.Patch().Set(repository.Field(LastUsedAtField), lastUsedAt)
if err := db.Patch(ctx, *rt.GetID(), patch); err != nil {
db.Logger.Warn("Failed to update refresh token last usage timestamp", zap.Error(err),
zap.String("client_id", t.ClientID), zap.String("device_id", t.DeviceID))
} else {
rt.LastUsedAt = lastUsedAt
}
}
return &rt, nil
}
@@ -0,0 +1,162 @@
package refreshtokens
import (
"context"
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/db/storable"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type createRepository struct {
repository.Repository
filters []bson.D
patches []bson.D
patchMatches []bool
patchErr error
insertCalls int
insertErr error
storedID bson.ObjectID
}
func (r *createRepository) PatchOneByFilter(
_ context.Context,
filter builder.Query,
patch builder.Patch,
result storable.Storable,
) (bool, error) {
r.filters = append(r.filters, filter.BuildQuery())
r.patches = append(r.patches, patch.Build())
if r.patchErr != nil {
return false, r.patchErr
}
call := len(r.filters) - 1
matched := call < len(r.patchMatches) && r.patchMatches[call]
if matched {
result.SetID(r.storedID)
}
return matched, nil
}
func (r *createRepository) Insert(_ context.Context, object storable.Storable, _ builder.Query) error {
r.insertCalls++
if object.GetID() == nil || object.GetID().IsZero() {
object.SetID(bson.NewObjectID())
}
return r.insertErr
}
func newCreateDB(repo repository.Repository) *RefreshTokenDB {
return &RefreshTokenDB{
DBImp: template.DBImp[*model.RefreshToken]{
Logger: zap.NewNop(),
Repository: repo,
},
}
}
func versionedToken(accountRef bson.ObjectID, version, value string) *model.RefreshToken {
return &model.RefreshToken{
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: "web-app",
DeviceID: "browser",
SessionVersion: version,
},
RefreshToken: value,
},
AccountRef: &accountRef,
ExpiresAt: time.Now().Add(24 * time.Hour),
UserAgent: "test-agent",
IPAddress: "192.0.2.1",
}
}
func extendedJSON(t *testing.T, document bson.D) string {
t.Helper()
raw, err := bson.MarshalExtJSON(document, true, false)
require.NoError(t, err)
return string(raw)
}
func TestCreateReplacesSlotWithoutMatchingNewSessionVersion(t *testing.T) {
storedID := bson.NewObjectID()
repo := &createRepository{
patchMatches: []bool{true},
storedID: storedID,
}
db := newCreateDB(repo)
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
err := db.Create(context.Background(), token)
require.NoError(t, err)
require.Equal(t, storedID, *token.GetID())
require.Zero(t, repo.insertCalls)
require.Len(t, repo.filters, 1)
filterJSON := extendedJSON(t, repo.filters[0])
require.Contains(t, filterJSON, "accountRef")
require.Contains(t, filterJSON, "clientId")
require.Contains(t, filterJSON, "deviceId")
require.Contains(t, filterJSON, "isRevoked")
require.NotContains(t, filterJSON, "sessionVersion")
patchJSON := extendedJSON(t, repo.patches[0])
require.Contains(t, patchJSON, "sessionVersion")
require.Contains(t, patchJSON, "version-2")
}
func TestCreateReconcilesConcurrentSlotInsert(t *testing.T) {
storedID := bson.NewObjectID()
repo := &createRepository{
patchMatches: []bool{false, true},
insertErr: merrors.DataConflict("duplicate_key"),
storedID: storedID,
}
db := newCreateDB(repo)
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
err := db.Create(context.Background(), token)
require.NoError(t, err)
require.Equal(t, storedID, *token.GetID())
require.Equal(t, 1, repo.insertCalls)
require.Len(t, repo.filters, 2)
}
func TestCreatePreservesUnrelatedInsertConflict(t *testing.T) {
repo := &createRepository{
patchMatches: []bool{false, false},
insertErr: merrors.DataConflict("duplicate_key"),
storedID: bson.NewObjectID(),
}
db := newCreateDB(repo)
token := versionedToken(bson.NewObjectID(), "version-2", "replacement-token")
err := db.Create(context.Background(), token)
require.Error(t, err)
require.True(t, errors.Is(err, merrors.ErrDataConflict))
require.Equal(t, 1, repo.insertCalls)
require.Len(t, repo.filters, 2)
}
func TestCredentialFilterKeepsSessionVersion(t *testing.T) {
query := filterByAccount(bson.NewObjectID(), &model.SessionIdentifier{
ClientID: "web-app",
DeviceID: "browser",
SessionVersion: "version-2",
})
filterJSON := extendedJSON(t, query.BuildQuery())
require.Contains(t, filterJSON, "sessionVersion")
require.Contains(t, filterJSON, "version-2")
}
@@ -0,0 +1,102 @@
package refreshtokens
import (
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
ri "git.sendico.io/tech/sxchnge/bff/pkg/db/repository/index"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type Collections struct {
RefreshTokens string
Clients string
}
type RefreshTokenDB struct {
template.DBImp[*model.RefreshToken]
clients repository.Repository
collection Collections
}
func Create(logger mlogger.Logger, db *mongo.Database) (*RefreshTokenDB, error) {
return CreateWithCollections(logger, db, Collections{})
}
func (c Collections) WithDefaults() Collections {
if strings.TrimSpace(c.RefreshTokens) == "" {
c.RefreshTokens = mservice.RefreshTokens
}
if strings.TrimSpace(c.Clients) == "" {
c.Clients = mservice.Clients
}
return c
}
func CreateWithCollections(logger mlogger.Logger, db *mongo.Database, collections Collections) (*RefreshTokenDB, error) {
collections = collections.WithDefaults()
p := &RefreshTokenDB{
DBImp: *template.Create[*model.RefreshToken](logger, mservice.Type(collections.RefreshTokens), db),
clients: repository.CreateMongoRepository(db, collections.Clients),
collection: collections,
}
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: "token", Sort: ri.Asc}},
Unique: true,
}); err != nil {
p.Logger.Error("Failed to create unique token index", zap.Error(err))
return nil, err
}
ttl := int32(0)
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: ExpiresAtField, Sort: ri.Asc}},
Name: "expire_sessions",
TTL: &ttl,
}); err != nil {
p.Logger.Error("Failed to create refresh session expiration index", zap.Error(err))
return nil, err
}
// Add unique constraint on account/client/device combination
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{
{Field: "accountRef", Sort: ri.Asc},
{Field: "clientId", Sort: ri.Asc},
{Field: "deviceId", Sort: ri.Asc},
},
Unique: true,
Name: "unique_active_session",
PartialFilter: repository.Filter(IsRevokedField, false),
}); err != nil {
p.Logger.Error("Failed to create unique account/client/device index", zap.Error(err))
return nil, err
}
if err := p.Repository.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: IsRevokedField, Sort: ri.Asc}},
}); err != nil {
p.Logger.Error("Failed to create unique token revokation status index", zap.Error(err))
return nil, err
}
if err := p.clients.CreateIndex(&ri.Definition{
Keys: []ri.Key{{Field: "clientId", Sort: ri.Asc}},
Unique: true,
}); err != nil {
p.Logger.Error("Failed to create unique client identifier index", zap.Error(err))
return nil, err
}
return p, nil
}
func (db *RefreshTokenDB) Collections() Collections {
return db.collection
}
@@ -0,0 +1,11 @@
package refreshtokens
const (
ExpiresAtField = "expiresAt"
IsRevokedField = "isRevoked"
TokenField = "token"
UserAgentField = "userAgent"
IPAddressField = "ipAddress"
LastUsedAtField = "lastUsedAt"
SessionVersionField = "sessionVersion"
)
@@ -0,0 +1,37 @@
package refreshtokens
import (
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func filterByClientId(clientID string) builder.Query {
return repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID)
}
func filterSlot(session *model.SessionIdentifier) builder.Query {
filter := filterByClientId(session.ClientID)
filter.And(
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, session.DeviceID),
repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false),
)
return filter
}
func filter(session *model.SessionIdentifier) builder.Query {
filter := filterSlot(session)
if session.SessionVersion != "" {
filter.And(repository.Query().Comparison(repository.Field("sessionVersion"), builder.Eq, session.SessionVersion))
}
return filter
}
func filterByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query {
return filter(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
}
func filterSlotByAccount(accountRef bson.ObjectID, session *model.SessionIdentifier) builder.Query {
return filterSlot(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
}
@@ -0,0 +1,19 @@
package refreshtokens
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/db/template"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB interface {
template.DB[*model.RefreshToken]
Revoke(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) error
RevokeToken(ctx context.Context, token *model.ClientRefreshToken) (bool, error)
RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error
GetSession(ctx context.Context, accountRef bson.ObjectID, session *model.SessionIdentifier) (*model.RefreshToken, error)
GetByCRT(ctx context.Context, t *model.ClientRefreshToken) (*model.RefreshToken, error)
GetClient(ctx context.Context, clientID string) (*model.Client, error)
}
@@ -0,0 +1,92 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"fmt"
"testing"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestRefreshTokenDB_DatabaseIndexes(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Unique_Token_Constraint", func(t *testing.T) {
userID1 := bson.NewObjectID()
userID2 := bson.NewObjectID()
token := "duplicate_token_123"
refreshToken1 := createTestRefreshToken(userID1, "client1", "device1", token)
err := db.Create(ctx, refreshToken1)
require.NoError(t, err)
refreshToken2 := createTestRefreshToken(userID2, "client2", "device2", token)
err = db.Create(ctx, refreshToken2)
assert.Error(t, err)
assert.Contains(t, err.Error(), "duplicate")
})
t.Run("Query_Performance_By_Revocation_Status", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
for i := 0; i < 10; i++ {
token := createTestRefreshToken(userID, clientID,
fmt.Sprintf("device_%d", i), fmt.Sprintf("token_%d", i))
if i%2 == 0 {
token.IsRevoked = true
}
err := db.Create(ctx, token)
require.NoError(t, err)
}
query := repository.Query().
Filter(repository.AccountField(), userID).
And(repository.Query().Comparison(repository.Field(refreshtokens.IsRevokedField), builder.Eq, false))
ids, err := db.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 5)
})
}
func TestRefreshTokenDB_IndexPartialUniqueActiveSession(t *testing.T) {
db, database, cleanup := setupTestDBWithMongo(t)
defer cleanup()
ctx := context.Background()
cursor, err := database.Collection(db.Repository.Collection()).Indexes().List(ctx)
require.NoError(t, err)
defer cursor.Close(ctx)
found := false
for cursor.Next(ctx) {
var idx bson.M
require.NoError(t, cursor.Decode(&idx))
if idx["name"] == "unique_active_session" {
found = true
assert.Equal(t, true, idx["unique"])
partialRaw, err := bson.Marshal(idx["partialFilterExpression"])
require.NoError(t, err)
var partial bson.M
err = bson.Unmarshal(partialRaw, &partial)
require.NoError(t, err)
assert.Equal(t, bson.M{"isRevoked": false}, partial)
}
}
assert.True(t, found, "unique_active_session index not found")
}
@@ -0,0 +1,253 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestRefreshTokenDB_ClientManagement(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Client_CRUD_Operations", func(t *testing.T) {
// Note: Client management is handled by a separate client database.
// This test verifies that refresh tokens work with different client IDs.
userID := bson.NewObjectID()
webToken := createTestRefreshToken(userID, "web-app", "device1", "token1")
err := db.Create(ctx, webToken)
require.NoError(t, err)
mobileToken := createTestRefreshToken(userID, "mobile-app", "device2", "token2")
err = db.Create(ctx, mobileToken)
require.NoError(t, err)
webCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: "web-app",
DeviceID: "device1",
},
RefreshToken: "token1",
}
retrievedToken, err := db.GetByCRT(ctx, webCRT)
require.NoError(t, err)
assert.Equal(t, "web-app", retrievedToken.ClientID)
assert.Equal(t, "device1", retrievedToken.DeviceID)
mobileCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: "mobile-app",
DeviceID: "device2",
},
RefreshToken: "token2",
}
retrievedToken, err = db.GetByCRT(ctx, mobileCRT)
require.NoError(t, err)
assert.Equal(t, "mobile-app", retrievedToken.ClientID)
assert.Equal(t, "device2", retrievedToken.DeviceID)
})
}
func TestRefreshTokenDB_SecurityScenarios(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Token_Hijacking_Prevention", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-browser"
token := "hijacked_token_123"
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
session := &model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
}
err = db.Revoke(ctx, userID, session)
require.NoError(t, err)
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
_, err = db.GetByCRT(ctx, crt)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Invalid_Token_Attempts", func(t *testing.T) {
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: "invalid-client",
DeviceID: "invalid-device",
},
RefreshToken: "invalid_token_123",
}
_, err := db.GetByCRT(ctx, crt)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
}
func TestRefreshTokenDB_ExpiredTokenHandling(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Expired_Token_Cleanup", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-device"
token := "expired_token_123"
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
refreshToken.ExpiresAt = time.Now().Add(-1 * time.Hour)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
var storedToken model.RefreshToken
err = db.Get(ctx, *refreshToken.GetID(), &storedToken)
require.NoError(t, err)
assert.True(t, storedToken.ExpiresAt.Before(time.Now()))
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
_, err = db.GetByCRT(ctx, crt)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrAccessDenied))
})
}
func TestRefreshTokenDB_ConcurrentAccess(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Concurrent_Token_Usage", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-device"
token := "concurrent_token_123"
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
done := make(chan error, 2)
go func() {
_, err := db.GetByCRT(ctx, crt)
done <- err
}()
go func() {
_, err := db.GetByCRT(ctx, crt)
done <- err
}()
for i := 0; i < 2; i++ {
err := <-done
require.NoError(t, err)
}
})
}
func TestRefreshTokenDB_EdgeCases(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Delete_Token_By_ID", func(t *testing.T) {
userID := bson.NewObjectID()
refreshToken := createTestRefreshToken(userID, "web-app", "device-1", "token_123")
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
tokenID := *refreshToken.GetID()
err = db.Delete(ctx, tokenID)
require.NoError(t, err)
var result model.RefreshToken
err = db.Get(ctx, tokenID, &result)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Revoke_Non_Existent_Token", func(t *testing.T) {
userID := bson.NewObjectID()
session := &model.SessionIdentifier{
ClientID: "non-existent-client",
DeviceID: "non-existent-device",
}
err := db.Revoke(ctx, userID, session)
assert.NoError(t, err)
})
t.Run("RevokeAll_No_Other_Devices", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "only-device"
refreshToken := createTestRefreshToken(userID, clientID, deviceID, "token_123")
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
err = db.RevokeAll(ctx, userID, deviceID)
require.NoError(t, err)
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "token_123",
}
_, err = db.GetByCRT(ctx, crt)
require.NoError(t, err)
})
}
@@ -0,0 +1,193 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"errors"
"testing"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestRefreshTokenDB_SessionReplacement(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("User_Login_From_Same_Device_Twice", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
// First login
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_token_123")
firstToken.SessionVersion = "session-version-1"
err := db.Create(ctx, firstToken)
require.NoError(t, err)
firstTokenID := *firstToken.GetID()
// Second login from same device - should replace existing token
secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_token_456")
secondToken.SessionVersion = "session-version-2"
err = db.Create(ctx, secondToken)
require.NoError(t, err)
// Should reuse the same database record
assert.Equal(t, firstTokenID, *secondToken.GetID())
// First token should no longer work
firstCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
SessionVersion: "session-version-1",
},
RefreshToken: "first_token_123",
}
_, err = db.GetByCRT(ctx, firstCRT)
assert.Error(t, err)
// Second token should work
secondCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
SessionVersion: "session-version-2",
},
RefreshToken: "second_token_456",
}
_, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err)
secondCRT.SessionVersion = "session-version-1"
_, err = db.GetByCRT(ctx, secondCRT)
assert.Error(t, err)
})
t.Run("Create_After_GlobalRevocation_AllowsNewActive", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "revoked_token_123")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
// Global revoke (deviceID empty) - all tokens should be revoked
err = db.RevokeAll(ctx, userID, "")
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, *firstToken.GetID(), &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
// Creating a new token for the same account/client/device must succeed and produce an active token
reissueToken := createTestRefreshToken(userID, clientID, deviceID, "new_token_after_revocation")
err = db.Create(ctx, reissueToken)
require.NoError(t, err)
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "new_token_after_revocation",
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
// Old token must remain unusable
oldCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "revoked_token_123",
}
_, err = db.GetByCRT(ctx, oldCRT)
assert.Error(t, err)
// Both records exist: revoked + new active
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
t.Run("User_Can_Login_Again_After_Session_Revoke", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-laptop"
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_login_token")
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
firstTokenID := *firstToken.GetID()
session := &model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
}
err = db.Revoke(ctx, userID, session)
require.NoError(t, err)
var revoked model.RefreshToken
err = db.Get(ctx, firstTokenID, &revoked)
require.NoError(t, err)
assert.True(t, revoked.IsRevoked)
secondToken := createTestRefreshToken(userID, clientID, deviceID, "second_login_token")
err = db.Create(ctx, secondToken)
require.NoError(t, err)
require.NotNil(t, secondToken.GetID())
assert.NotEqual(t, firstTokenID, *secondToken.GetID())
firstCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "first_login_token",
}
_, err = db.GetByCRT(ctx, firstCRT)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
secondCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "second_login_token",
}
_, err = db.GetByCRT(ctx, secondCRT)
require.NoError(t, err)
query := repository.Query().
Filter(repository.AccountField(), userID).
And(
repository.Query().Comparison(repository.Field("clientId"), builder.Eq, clientID),
repository.Query().Comparison(repository.Field("deviceId"), builder.Eq, deviceID),
)
ids, err := db.Repository.ListIDs(ctx, query)
require.NoError(t, err)
assert.Len(t, ids, 2)
})
}
@@ -0,0 +1,148 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestRefreshTokenDB_TokenRotation(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Token_Rotation_On_Use", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-browser"
initialToken := "initial_token_123"
// Create initial token
refreshToken := createTestRefreshToken(userID, clientID, deviceID, initialToken)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
// Simulate small delay
time.Sleep(10 * time.Millisecond)
// Use token - should update LastUsedAt
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: initialToken,
}
retrievedToken, err := db.GetByCRT(ctx, crt)
require.NoError(t, err)
// Token lookup should preserve data and advance LastUsedAt.
assert.Equal(t, initialToken, retrievedToken.RefreshToken)
// Create new token with rotated value (simulating token rotation)
newToken := "rotated_token_456"
retrievedToken.RefreshToken = newToken
err = db.Update(ctx, retrievedToken)
require.NoError(t, err)
// Old token should no longer work
_, err = db.GetByCRT(ctx, crt)
assert.Error(t, err)
// New token should work
newCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: newToken,
}
_, err = db.GetByCRT(ctx, newCRT)
require.NoError(t, err)
})
t.Run("Create_Defaults_LastUsedAt_When_Replacing_Active_Session", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "replacement-browser"
initialLastUsedAt := time.Now().Add(-2 * time.Hour)
firstToken := createTestRefreshToken(userID, clientID, deviceID, "first_session_token")
firstToken.LastUsedAt = initialLastUsedAt
err := db.Create(ctx, firstToken)
require.NoError(t, err)
require.NotNil(t, firstToken.GetID())
firstTokenID := *firstToken.GetID()
time.Sleep(10 * time.Millisecond)
replacement := &model.RefreshToken{
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: "replacement_session_token",
},
AccountRef: &userID,
ExpiresAt: time.Now().Add(24 * time.Hour),
IsRevoked: false,
UserAgent: "TestUserAgent/2.0",
IPAddress: "10.0.0.5",
}
err = db.Create(ctx, replacement)
require.NoError(t, err)
require.NotNil(t, replacement.GetID())
assert.Equal(t, firstTokenID, *replacement.GetID())
var stored model.RefreshToken
err = db.Get(ctx, firstTokenID, &stored)
require.NoError(t, err)
assert.False(t, stored.LastUsedAt.IsZero())
assert.True(t, stored.LastUsedAt.After(initialLastUsedAt))
assert.Equal(t, "replacement_session_token", stored.RefreshToken)
})
t.Run("GetByCRT_Updates_LastUsedAt", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "refresh-browser"
token := "refresh_token_last_used"
initialLastUsedAt := time.Now().Add(-1 * time.Hour)
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
refreshToken.LastUsedAt = initialLastUsedAt
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
require.NotNil(t, refreshToken.GetID())
time.Sleep(10 * time.Millisecond)
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
retrievedToken, err := db.GetByCRT(ctx, crt)
require.NoError(t, err)
assert.True(t, retrievedToken.LastUsedAt.After(initialLastUsedAt))
var stored model.RefreshToken
err = db.Get(ctx, *refreshToken.GetID(), &stored)
require.NoError(t, err)
assert.True(t, stored.LastUsedAt.After(initialLastUsedAt))
})
}
@@ -0,0 +1,231 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"crypto/sha1"
"errors"
"fmt"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/storage/refreshtokens"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
factory "git.sendico.io/tech/sxchnge/bff/pkg/mlogger/factory"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/modules/mongodb"
"github.com/testcontainers/testcontainers-go/wait"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
func setupTestDB(t *testing.T) (*refreshtokens.RefreshTokenDB, func()) {
db, _, cleanup := setupTestDBWithMongo(t)
return db, cleanup
}
func setupTestDBWithMongo(t *testing.T) (*refreshtokens.RefreshTokenDB, *mongo.Database, func()) {
// mark as helper for better test failure reporting
t.Helper()
startCtx, startCancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer startCancel()
mongoContainer, err := mongodb.Run(startCtx,
"mongo:latest",
mongodb.WithUsername("root"),
mongodb.WithPassword("password"),
testcontainers.WithWaitStrategy(wait.ForListeningPort("27017/tcp").WithStartupTimeout(2*time.Minute)),
)
require.NoError(t, err, "failed to start MongoDB container")
mongoURI, err := mongoContainer.ConnectionString(startCtx)
require.NoError(t, err, "failed to get MongoDB connection string")
clientOptions := options.Client().ApplyURI(mongoURI)
client, err := mongo.Connect(clientOptions)
require.NoError(t, err, "failed to connect to MongoDB")
database := client.Database(fmt.Sprintf("test_refresh_tokens_%x", sha1.Sum([]byte(t.Name()))))
logger := factory.NewLogger(true)
db, err := refreshtokens.Create(logger, database)
require.NoError(t, err, "failed to create refresh tokens db")
cleanup := func() {
_ = database.Drop(context.Background())
_ = client.Disconnect(context.Background())
termCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
_ = mongoContainer.Terminate(termCtx)
}
return db, database, cleanup
}
func createTestRefreshToken(accountRef bson.ObjectID, clientID, deviceID, token string) *model.RefreshToken {
return &model.RefreshToken{
ClientRefreshToken: model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
},
AccountRef: &accountRef,
ExpiresAt: time.Now().Add(24 * time.Hour),
IsRevoked: false,
UserAgent: "TestUserAgent/1.0",
IPAddress: "192.168.1.1",
LastUsedAt: time.Now(),
}
}
func TestRefreshTokenDB_AuthenticationFlow(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("Complete_User_Authentication_Flow", func(t *testing.T) {
// Setup: Create user and client
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-desktop-chrome"
token := "refresh_token_12345"
// Step 1: User logs in - create initial refresh token
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
// Step 2: User uses refresh token to get new access token
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
retrievedToken, err := db.GetByCRT(ctx, crt)
require.NoError(t, err)
require.NotNil(t, retrievedToken.AccountRef)
assert.Equal(t, userID, *retrievedToken.AccountRef)
assert.Equal(t, token, retrievedToken.RefreshToken)
assert.False(t, retrievedToken.IsRevoked)
// Step 3: User logs out - revoke the token
session := &model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
}
err = db.Revoke(ctx, userID, session)
require.NoError(t, err)
// Step 4: Try to use revoked token - should fail
_, err = db.GetByCRT(ctx, crt)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
t.Run("Manual_Token_Revocation_Workaround", func(t *testing.T) {
// Test manual revocation by directly updating the token
userID := bson.NewObjectID()
clientID := "web-app"
deviceID := "user-desktop-chrome"
token := "manual_revoke_token_123"
// Step 1: Create token
refreshToken := createTestRefreshToken(userID, clientID, deviceID, token)
err := db.Create(ctx, refreshToken)
require.NoError(t, err)
// Step 2: Manually revoke token by updating it directly
refreshToken.IsRevoked = true
err = db.Update(ctx, refreshToken)
require.NoError(t, err)
// Step 3: Try to use revoked token - should fail
crt := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
},
RefreshToken: token,
}
_, err = db.GetByCRT(ctx, crt)
assert.Error(t, err)
assert.True(t, errors.Is(err, merrors.ErrNoData))
})
}
func TestRefreshTokenDB_MultiDeviceManagement(t *testing.T) {
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
t.Run("User_With_Multiple_Devices", func(t *testing.T) {
userID := bson.NewObjectID()
clientID := "mobile-app"
// User logs in from phone
phoneToken := createTestRefreshToken(userID, clientID, "phone-ios", "phone_token_123")
err := db.Create(ctx, phoneToken)
require.NoError(t, err)
// User logs in from tablet
tabletToken := createTestRefreshToken(userID, clientID, "tablet-android", "tablet_token_456")
err = db.Create(ctx, tabletToken)
require.NoError(t, err)
// User logs in from desktop
desktopToken := createTestRefreshToken(userID, clientID, "desktop-windows", "desktop_token_789")
err = db.Create(ctx, desktopToken)
require.NoError(t, err)
// User wants to logout from all devices except current (phone)
err = db.RevokeAll(ctx, userID, "phone-ios")
require.NoError(t, err)
// Phone should still work
phoneCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: "phone-ios",
},
RefreshToken: "phone_token_123",
}
_, err = db.GetByCRT(ctx, phoneCRT)
require.NoError(t, err)
// Tablet and desktop should be revoked
tabletCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: "tablet-android",
},
RefreshToken: "tablet_token_456",
}
_, err = db.GetByCRT(ctx, tabletCRT)
assert.Error(t, err)
desktopCRT := &model.ClientRefreshToken{
SessionIdentifier: model.SessionIdentifier{
ClientID: clientID,
DeviceID: "desktop-windows",
},
RefreshToken: "desktop_token_789",
}
_, err = db.GetByCRT(ctx, desktopCRT)
assert.Error(t, err)
})
}
@@ -0,0 +1,24 @@
package refreshtokens
import (
"context"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository"
"git.sendico.io/tech/sxchnge/bff/pkg/db/repository/builder"
"go.mongodb.org/mongo-driver/v2/bson"
)
func (db *RefreshTokenDB) RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error {
query := repository.Query().
Filter(repository.AccountField(), accountRef).
And(repository.Query().Comparison(repository.Field("deviceId"), builder.Ne, deviceID)).
And(repository.Query().Comparison(repository.Field(IsRevokedField), builder.Eq, false))
patch := repository.Patch().
Set(repository.Field(ExpiresAtField), time.Now()).
Set(repository.Field(IsRevokedField), true)
_, err := db.Repository.PatchMany(ctx, query, patch)
return err
}