Organize backend into product executable and shared Go modules

This commit is contained in:
Stephan D committed 2026-09-12 14:14:08 +02:00
1 parent 981e84a4b4
commit f0720ec66b
382 files changed
+1944 -6989

No files matched your search

+121
View File
@@ -0,0 +1,121 @@
package browser
import (
"net/http"
"net/url"
"os"
"path"
"strings"
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
)
const (
TransportHeader = "X-Auth-Transport"
TransportCookie = "cookie"
CSRFHeader = "X-CSRF-Protection"
CSRFValue = "1"
RefreshCookie = "sxchnge_refresh"
)
func UsesCookie(r *http.Request) bool {
return r != nil && strings.EqualFold(strings.TrimSpace(r.Header.Get(TransportHeader)), TransportCookie)
}
func RefreshCredential(r *http.Request, fallback string) (string, error) {
if !UsesCookie(r) {
return strings.TrimSpace(fallback), nil
}
cookie, err := r.Cookie(RefreshCookie)
if err != nil || strings.TrimSpace(cookie.Value) == "" {
return "", merrors.Unauthorized("browser session is missing")
}
return cookie.Value, nil
}
func SetRefreshCookie(w http.ResponseWriter, r *http.Request, token string, expiresAt time.Time) {
if !UsesCookie(r) {
return
}
maxAge := int(time.Until(expiresAt).Seconds())
if maxAge < 1 {
maxAge = 1
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: token,
Path: cookiePath(r),
Expires: expiresAt.UTC(),
MaxAge: maxAge,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ClearRefreshCookie(w http.ResponseWriter, r *http.Request) {
if !UsesCookie(r) {
return
}
http.SetCookie(w, &http.Cookie{
Name: RefreshCookie,
Value: "",
Path: cookiePath(r),
Expires: time.Unix(1, 0).UTC(),
MaxAge: -1,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}
func ValidateCSRF(r *http.Request, allowedOrigins []string) error {
if !UsesCookie(r) || isSafeMethod(r.Method) {
return nil
}
if r.Header.Get(CSRFHeader) != CSRFValue {
return merrors.PermissionDenied("browser CSRF protection header is missing")
}
origin := strings.TrimSpace(r.Header.Get("Origin"))
if origin == "" || !originAllowed(origin, allowedOrigins) {
return merrors.PermissionDenied("browser request origin is not allowed")
}
return nil
}
func cookiePath(r *http.Request) string {
const accountsSegment = "/accounts"
if apiEndpoint := strings.TrimSpace(os.Getenv("API_ENDPOINT")); apiEndpoint != "" {
return path.Join("/", apiEndpoint, accountsSegment)
}
if r != nil && r.URL != nil {
if index := strings.Index(r.URL.Path, accountsSegment); index >= 0 {
return r.URL.Path[:index+len(accountsSegment)]
}
}
return accountsSegment
}
func isSafeMethod(method string) bool {
switch method {
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
return true
default:
return false
}
}
func originAllowed(origin string, allowedOrigins []string) bool {
parsed, err := url.Parse(origin)
if err != nil || parsed.Scheme == "" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" {
return false
}
normalized := strings.TrimSuffix(strings.ToLower(origin), "/")
for _, allowed := range allowedOrigins {
if strings.TrimSuffix(strings.ToLower(strings.TrimSpace(allowed)), "/") == normalized {
return true
}
}
return false
}
@@ -0,0 +1,122 @@
package browser
import (
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestSetRefreshCookieUsesHardenedHostOnlyPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
req.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "secret-refresh-token", time.Now().Add(time.Hour))
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Equal(t, "secret-refresh-token", cookie.Value)
require.Equal(t, "/api/v1/accounts", cookie.Path)
require.Empty(t, cookie.Domain, "an omitted Domain attribute keeps the cookie host-only")
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
require.Positive(t, cookie.MaxAge)
}
func TestSetRefreshCookieUsesConfiguredAPIPathOutsideAccountsRouter(t *testing.T) {
t.Setenv("API_ENDPOINT", "/gateway/v2/")
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/gateway/v2/verification/login", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.Equal(t, "/gateway/v2/accounts", recorder.Result().Cookies()[0].Path)
}
func TestRefreshCookieIsAlwaysSecure(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
tests := []struct {
name string
target string
forwardedProto string
}{
{name: "public hostname", target: "http://app.sendico.io/api/v1/accounts/login"},
{name: "localhost", target: "http://localhost:4000/api/v1/accounts/login"},
{name: "loopback IP", target: "http://127.0.0.1:4000/api/v1/accounts/login"},
{name: "private IP", target: "http://192.168.1.20:4000/api/v1/accounts/login"},
{name: "forwarded HTTP", target: "http://app.sendico.io/api/v1/accounts/login", forwardedProto: "http"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, test.target, nil)
req.Header.Set(TransportHeader, TransportCookie)
if test.forwardedProto != "" {
req.Header.Set("X-Forwarded-Proto", test.forwardedProto)
}
recorder := httptest.NewRecorder()
SetRefreshCookie(recorder, req, "refresh", time.Now().Add(time.Hour))
require.True(t, recorder.Result().Cookies()[0].Secure)
})
}
}
func TestClearRefreshCookieUsesHardenedPolicy(t *testing.T) {
t.Setenv("API_ENDPOINT", "/api/v1")
req := httptest.NewRequest(http.MethodPost, "http://192.168.1.20:4000/api/v1/accounts/logout", nil)
req.Header.Set(TransportHeader, TransportCookie)
recorder := httptest.NewRecorder()
ClearRefreshCookie(recorder, req)
cookies := recorder.Result().Cookies()
require.Len(t, cookies, 1)
cookie := cookies[0]
require.Equal(t, RefreshCookie, cookie.Name)
require.Empty(t, cookie.Value)
require.Equal(t, -1, cookie.MaxAge)
require.True(t, cookie.HttpOnly)
require.True(t, cookie.Secure)
require.Equal(t, http.SameSiteStrictMode, cookie.SameSite)
}
func TestValidateCSRFRejectsCrossSiteAndMissingHeaderRequests(t *testing.T) {
allowed := []string{"https://app.sendico.io"}
missingHeader := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
missingHeader.Header.Set(TransportHeader, TransportCookie)
missingHeader.Header.Set("Origin", allowed[0])
require.Error(t, ValidateCSRF(missingHeader, allowed))
crossSite := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
crossSite.Header.Set(TransportHeader, TransportCookie)
crossSite.Header.Set(CSRFHeader, CSRFValue)
crossSite.Header.Set("Origin", "https://attacker.example")
require.Error(t, ValidateCSRF(crossSite, allowed))
valid := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/payments", nil)
valid.Header.Set(TransportHeader, TransportCookie)
valid.Header.Set(CSRFHeader, CSRFValue)
valid.Header.Set("Origin", allowed[0])
require.NoError(t, ValidateCSRF(valid, allowed))
}
func TestRefreshCredentialNeverFallsBackForCookieTransport(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "https://app.sendico.io/api/v1/accounts/refresh", nil)
req.Header.Set(TransportHeader, TransportCookie)
credential, err := RefreshCredential(req, "body-token")
require.Error(t, err)
require.Empty(t, credential)
}
@@ -0,0 +1,35 @@
package sessionmetrics
import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
type Operation string
const (
Validation Operation = "validation"
CurrentLogout Operation = "current_logout"
AllLogout Operation = "all_logout"
)
type Outcome string
const (
Active Outcome = "active"
Succeeded Outcome = "succeeded"
AlreadyInactive Outcome = "already_inactive"
MissingIdentity Outcome = "missing_identity"
StorageError Outcome = "storage_error"
)
var lifecycle = promauto.NewCounterVec(prometheus.CounterOpts{
Namespace: "sendico",
Subsystem: "auth",
Name: "session_lifecycle_total",
Help: "Session validation and logout outcomes.",
}, []string{"operation", "outcome"})
func Observe(operation Operation, outcome Outcome) {
lifecycle.WithLabelValues(string(operation), string(outcome)).Inc()
}
+25
View File
@@ -0,0 +1,25 @@
package model
import (
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
)
type Client struct {
storable.Base `bson:",inline" json:",inline"`
ClientID string `bson:"clientId"`
ClientName string `bson:"clientName"`
ClientSecret string `bson:"clientSecret,omitempty"`
AllowedCIDRs []string `bson:"allowedCIDRs,omitempty"`
AllowedScopes []string `bson:"allowedScopes"`
RedirectURIs []string `bson:"redirectURIs"`
GrantTypes []string `bson:"grantTypes"`
TokenEndpointAuthMethod string `bson:"tokenEndpointAuthMethod"`
AccountRef *bson.ObjectID `bson:"accountRef,omitempty"` // owner reference
IsRevoked bool `bson:"isRevoked"`
}
func (*Client) Collection() string {
return mservice.Clients
}
+29
View File
@@ -0,0 +1,29 @@
package model
import (
"time"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ClientRefreshToken struct {
SessionIdentifier `bson:",inline" json:",inline"`
RefreshToken string `bson:"token" json:"token"`
}
type RefreshToken struct {
storable.Base `bson:",inline" json:",inline"`
ClientRefreshToken `bson:",inline" json:",inline"`
AccountRef *bson.ObjectID `bson:"accountRef,omitempty" json:"accountRef,omitempty"`
ExpiresAt time.Time `bson:"expiresAt"`
IsRevoked bool `bson:"isRevoked"`
LastUsedAt time.Time `bson:"lastUsedAt,omitempty"`
UserAgent string `bson:"userAgent"`
IPAddress string `bson:"ipAddress"`
}
func (*RefreshToken) Collection() string {
return mservice.RefreshTokens
}
@@ -0,0 +1,7 @@
package model
type SessionIdentifier struct {
ClientID string `bson:"clientId" json:"clientId"`
DeviceID string `bson:"deviceId" json:"deviceId"`
SessionVersion string `bson:"sessionVersion,omitempty" json:"sessionVersion,omitempty"`
}
+11
View File
@@ -0,0 +1,11 @@
package refreshtokens
import (
"context"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
)
func (db *RefreshTokenDB) GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error) {
var client sessionmodel.Client
return &client, db.clients.FindOneByFilter(ctx, filterByClientId(clientID), &client)
}
+189
View File
@@ -0,0 +1,189 @@
package refreshtokens
import (
"context"
"errors"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
func (db *RefreshTokenDB) Create(ctx context.Context, rt *sessionmodel.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 *sessionmodel.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 sessionmodel.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 *sessionmodel.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 *sessionmodel.SessionIdentifier) error {
var rt sessionmodel.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 *sessionmodel.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 *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error) {
var rt sessionmodel.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 *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error) {
var rt sessionmodel.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, "read", *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
}
+162
View File
@@ -0,0 +1,162 @@
package refreshtokens
import (
"context"
"errors"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/database/record"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/errors"
"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[*sessionmodel.RefreshToken]{
Logger: zap.NewNop(),
Repository: repo,
},
}
}
func versionedToken(accountRef bson.ObjectID, version, value string) *sessionmodel.RefreshToken {
return &sessionmodel.RefreshToken{
ClientRefreshToken: sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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(), &sessionmodel.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")
}
+102
View File
@@ -0,0 +1,102 @@
package refreshtokens
import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"strings"
"git.sendico.io/tech/sxchnge/shared/database/repository"
ri "git.sendico.io/tech/sxchnge/shared/database/repository/index"
"git.sendico.io/tech/sxchnge/shared/database/template"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/service"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.uber.org/zap"
)
type Collections struct {
RefreshTokens string
Clients string
}
type RefreshTokenDB struct {
template.DBImp[*sessionmodel.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[*sessionmodel.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
}
+11
View File
@@ -0,0 +1,11 @@
package refreshtokens
const (
ExpiresAtField = "expiresAt"
IsRevokedField = "isRevoked"
TokenField = "token"
UserAgentField = "userAgent"
IPAddressField = "ipAddress"
LastUsedAtField = "lastUsedAt"
SessionVersionField = "sessionVersion"
)
+37
View File
@@ -0,0 +1,37 @@
package refreshtokens
import (
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"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 *sessionmodel.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 *sessionmodel.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 *sessionmodel.SessionIdentifier) builder.Query {
return filter(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
}
func filterSlotByAccount(accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) builder.Query {
return filterSlot(session).And(repository.Query().Comparison(repository.AccountField(), builder.Eq, accountRef))
}
@@ -0,0 +1,19 @@
package refreshtokens
import (
"context"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"git.sendico.io/tech/sxchnge/shared/database/template"
"go.mongodb.org/mongo-driver/v2/bson"
)
type DB interface {
template.DB[*sessionmodel.RefreshToken]
Revoke(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) error
RevokeToken(ctx context.Context, token *sessionmodel.ClientRefreshToken) (bool, error)
RevokeAll(ctx context.Context, accountRef bson.ObjectID, deviceID string) error
GetSession(ctx context.Context, accountRef bson.ObjectID, session *sessionmodel.SessionIdentifier) (*sessionmodel.RefreshToken, error)
GetByCRT(ctx context.Context, t *sessionmodel.ClientRefreshToken) (*sessionmodel.RefreshToken, error)
GetClient(ctx context.Context, clientID string) (*sessionmodel.Client, error)
}
@@ -0,0 +1,92 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
"fmt"
"testing"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/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"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing"
"time"
"git.sendico.io/tech/sxchnge/shared/errors"
"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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
}
err = db.Revoke(ctx, userID, session)
require.NoError(t, err)
crt := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 sessionmodel.RefreshToken
err = db.Get(ctx, *refreshToken.GetID(), &storedToken)
require.NoError(t, err)
assert.True(t, storedToken.ExpiresAt.Before(time.Now()))
crt := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 sessionmodel.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 := &sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/repository/builder"
"git.sendico.io/tech/sxchnge/shared/errors"
"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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.SessionIdentifier{
ClientID: clientID,
DeviceID: deviceID,
}
err = db.Revoke(ctx, userID, session)
require.NoError(t, err)
var revoked sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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,147 @@
//go:build integration
// +build integration
package refreshtokens_test
import (
"context"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.mongodb.org/mongo-driver/v2/bson"
"testing"
"time"
)
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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.RefreshToken{
ClientRefreshToken: sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 sessionmodel.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"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"testing"
"time"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/errors"
factory "git.sendico.io/tech/sxchnge/shared/logging/factory"
"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) *sessionmodel.RefreshToken {
return &sessionmodel.RefreshToken{
ClientRefreshToken: sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.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 := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.SessionIdentifier{
ClientID: clientID,
DeviceID: "tablet-android",
},
RefreshToken: "tablet_token_456",
}
_, err = db.GetByCRT(ctx, tabletCRT)
assert.Error(t, err)
desktopCRT := &sessionmodel.ClientRefreshToken{
SessionIdentifier: sessionmodel.SessionIdentifier{
ClientID: clientID,
DeviceID: "desktop-windows",
},
RefreshToken: "desktop_token_789",
}
_, err = db.GetByCRT(ctx, desktopCRT)
assert.Error(t, err)
})
}
+24
View File
@@ -0,0 +1,24 @@
package refreshtokens
import (
"context"
"time"
"git.sendico.io/tech/sxchnge/shared/database/repository"
"git.sendico.io/tech/sxchnge/shared/database/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
}
@@ -0,0 +1,103 @@
package tokens
import (
"context"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"net/http"
"testing"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
)
type recordingRefreshTokenDB struct {
refreshtokens.DB
token *sessionmodel.RefreshToken
}
func (db *recordingRefreshTokenDB) Create(_ context.Context, token *sessionmodel.RefreshToken) error {
db.token = token
return nil
}
func TestPrepareRefreshTokenStoresResolvedClientIP(t *testing.T) {
tests := []struct {
name string
remoteAddr string
realIP string
want string
}{
{
name: "proxied IPv4",
remoteAddr: "172.19.0.37:45706",
realIP: "203.0.113.10",
want: "203.0.113.10",
},
{
name: "direct IPv4",
remoteAddr: "203.0.113.11:45706",
want: "203.0.113.11",
},
{
name: "proxied IPv6",
remoteAddr: "172.19.0.37:45706",
realIP: "2001:0db8:0000:0000:0000:0000:0000:0012",
want: "2001:db8::12",
},
{
name: "direct IPv6",
remoteAddr: "[2001:db8::13]:45706",
want: "2001:db8::13",
},
{
name: "missing address",
want: "",
},
{
name: "invalid address",
remoteAddr: "not-an-address",
realIP: "also-not-an-address",
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
request := &http.Request{
RemoteAddr: tt.remoteAddr,
Header: make(http.Header),
}
if tt.realIP != "" {
request.Header.Set("X-Real-IP", tt.realIP)
}
account := &customermodel.Account{}
account.SetID(bson.NewObjectID())
db := &recordingRefreshTokenDB{}
_, err := PrepareRefreshToken(
context.Background(),
request,
db,
32,
24,
&sessionmodel.SessionIdentifier{ClientID: "client", DeviceID: "device"},
account,
zap.NewNop(),
)
if err != nil {
t.Fatalf("prepare refresh token: %v", err)
}
if db.token == nil {
t.Fatal("expected refresh token to be stored")
}
if db.token.IPAddress != tt.want {
t.Fatalf("unexpected stored IP address: got %q, want %q", db.token.IPAddress, tt.want)
}
if db.token.SessionVersion == "" {
t.Fatal("expected a server-generated session version")
}
})
}
}
+78
View File
@@ -0,0 +1,78 @@
package tokens
import (
"context"
"crypto/rand"
"encoding/base64"
customermodel "git.sendico.io/tech/sxchnge/product/customers/model"
sessionmodel "git.sendico.io/tech/sxchnge/product/sessions/model"
"io"
"net/http"
"time"
"git.sendico.io/tech/sxchnge/product/auth/ipguard"
"git.sendico.io/tech/sxchnge/product/sessions/store"
"git.sendico.io/tech/sxchnge/shared/errors"
"git.sendico.io/tech/sxchnge/shared/logging"
"git.sendico.io/tech/sxchnge/shared/logging/fields"
"go.uber.org/zap"
)
func generateRefreshTokenData(length int) (string, error) {
randomBytes := make([]byte, length)
if _, err := io.ReadFull(rand.Reader, randomBytes); err != nil {
return "", merrors.InternalWrap(err, "failed to generate secure random bytes")
}
return base64.URLEncoding.EncodeToString(randomBytes), nil
}
func PrepareRefreshToken(
ctx context.Context,
r *http.Request,
rtdb refreshtokens.DB,
length int,
refreshExpiration int,
session *sessionmodel.SessionIdentifier,
account *customermodel.Account,
logger mlogger.Logger,
) (*sessionmodel.RefreshToken, error) {
refreshToken, err := generateRefreshTokenData(length)
if err != nil {
logger.Warn("Failed to generate refresh token", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
sessionVersion, err := generateRefreshTokenData(16)
if err != nil {
logger.Warn("Failed to generate session version", zap.Error(err), mzap.StorableRef(account))
return nil, err
}
versionedSession := *session
versionedSession.SessionVersion = sessionVersion
clientIP := ipguard.ClientIP(r)
ipAddress := ""
if clientIP != nil {
ipAddress = clientIP.String()
}
token := &sessionmodel.RefreshToken{
AccountRef: account.GetID(),
ClientRefreshToken: sessionmodel.ClientRefreshToken{
SessionIdentifier: versionedSession,
RefreshToken: refreshToken,
},
ExpiresAt: time.Now().Add(time.Duration(refreshExpiration) * time.Hour),
IsRevoked: false,
UserAgent: r.UserAgent(),
IPAddress: ipAddress,
}
if err = rtdb.Create(ctx, token); err != nil {
logger.Warn("Failed to store a refresh token", zap.Error(err), mzap.StorableRef(account),
zap.String("client_id", token.ClientID), zap.String("device_id", token.DeviceID))
return nil, err
}
return token, nil
}