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

+25
View File
@@ -0,0 +1,25 @@
package api
import (
"context"
"git.sendico.io/tech/sxchnge/bff/pkg/auth"
"git.sendico.io/tech/sxchnge/bff/pkg/db"
"git.sendico.io/tech/sxchnge/bff/pkg/domainprovider"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
type API interface {
LifecycleContext() context.Context
Logger() mlogger.Logger
DomainProvider() domainprovider.DomainProvider
Config() *Config
ProfileConfig() ConfigProvider
DBFactory() db.Factory
Permissions() auth.Provider
RuntimeComponent(name string) any
Register() Register
}
type MicroServiceFactoryT = func(API) (mservice.MicroService, error)
@@ -0,0 +1,41 @@
package api
type AuthStorageConfig struct {
AccountsCollection string `yaml:"accounts_collection"`
RefreshTokensCollection string `yaml:"refresh_tokens_collection"`
ClientsCollection string `yaml:"clients_collection"`
VerificationTokensCollection string `yaml:"verification_tokens_collection"`
LoginAttemptsCollection string `yaml:"login_attempts_collection"`
VerificationRateLimitsCollection string `yaml:"verification_rate_limits_collection"`
}
func (c *AuthStorageConfig) WithDefaults() AuthStorageConfig {
if c == nil {
c = &AuthStorageConfig{}
}
res := *c
if res.AccountsCollection == "" {
res.AccountsCollection = "accounts"
}
if res.RefreshTokensCollection == "" {
res.RefreshTokensCollection = "refresh_tokens"
}
if res.ClientsCollection == "" {
res.ClientsCollection = "clients"
}
if res.VerificationTokensCollection == "" {
res.VerificationTokensCollection = "verification_tokens"
}
if res.LoginAttemptsCollection == "" {
res.LoginAttemptsCollection = "login_attempts"
}
if res.VerificationRateLimitsCollection == "" {
res.VerificationRateLimitsCollection = "verification_rate_limits"
}
return res
}
type VerificationConfig struct {
AllowedPurposes []string `yaml:"allowed_purposes"`
RateLimits *VerificationRateLimitsConfig `yaml:"rate_limits"`
}
@@ -0,0 +1,17 @@
package api
import (
mwa "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
storageconfig "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/storage"
)
type Config struct {
Mw *mwa.Config `yaml:"middleware"`
AuthStorage *AuthStorageConfig `yaml:"auth_storage"`
Verification *VerificationConfig `yaml:"verification"`
Storage *storageconfig.Config `yaml:"storage"`
}
func (c *Config) SharedConfig() *Config {
return c
}
@@ -0,0 +1,15 @@
package api
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/sresponse"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/api/ws"
api "git.sendico.io/tech/sxchnge/bff/pkg/api/http"
"git.sendico.io/tech/sxchnge/bff/pkg/mservice"
)
type Register interface {
Handler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.HandlerFunc)
AccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.AccountHandlerFunc)
PendingAccountHandler(service mservice.Type, endpoint string, method api.HTTPMethod, handler sresponse.PendingAccountHandlerFunc)
WSHandler(messageType string, handler ws.HandlerFunc)
}
@@ -0,0 +1,40 @@
package api
import "git.sendico.io/tech/sxchnge/bff/pkg/mservice"
type ConfigProvider interface {
SharedConfig() *Config
}
type ConfigFactory func() ConfigProvider
type RuntimeComponents map[string]any
type RuntimeBootstrapFunc func(API) (RuntimeComponents, error)
type RuntimeFinishFunc func(API) error
type RuntimeConfig struct {
Name string
Services []ServiceFactory
EnableWebSocket bool
ConfigFactory ConfigFactory
Bootstrap RuntimeBootstrapFunc
Finish RuntimeFinishFunc
}
type ServiceFactory struct {
Name mservice.Type
Factory MicroServiceFactoryT
}
func (cfg RuntimeConfig) RuntimeName() string {
if cfg.Name == "" {
return "edge"
}
return cfg.Name
}
func (cfg RuntimeConfig) NewConfig() ConfigProvider {
if cfg.ConfigFactory != nil {
return cfg.ConfigFactory()
}
return &Config{}
}
@@ -0,0 +1,12 @@
package srequest
import (
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type ChangePolicies struct {
RoleRef bson.ObjectID `json:"roleRef"`
Add *[]model.RolePolicy `json:"add,omitempty"`
Remove *[]model.RolePolicy `json:"remove,omitempty"`
}
@@ -0,0 +1,8 @@
package srequest
import "go.mongodb.org/mongo-driver/v2/bson"
type ChangeRole struct {
AccountRef bson.ObjectID `json:"accountRef"`
NewRoleDescriptionRef bson.ObjectID `json:"newRoleDescriptionRef"`
}
@@ -0,0 +1,9 @@
package srequest
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type Login struct {
model.SessionIdentifier `json:",inline"`
model.LoginData `json:"login"`
ClientSecret string `json:"clientSecret,omitempty"`
}
@@ -0,0 +1,5 @@
package srequest
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type AccessTokenRefresh = model.ClientRefreshToken
@@ -0,0 +1,5 @@
package srequest
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type TokenRefreshRotate = model.ClientRefreshToken
@@ -0,0 +1,5 @@
package srequest
type Validatable interface {
Validate() error
}
@@ -0,0 +1,15 @@
package srequest
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type VerificationCodeRequest struct {
Purpose string `json:"purpose"`
Target string `json:"target,omitempty"`
IdempotencyKey string `json:"idempotencyKey"`
}
type CodeVerificationRequest struct {
VerificationCodeRequest `json:",inline"`
Code string `json:"code"`
SessionIdentifier model.SessionIdentifier `json:"sessionIdentifier"`
}
@@ -0,0 +1,60 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AccountData struct {
model.AccountPublic `json:",inline"`
IsAnonymous bool `json:"isAnonymous"`
}
type accountResponse struct {
authResponse `json:",inline"`
Account AccountData `json:"account"`
}
type accountsResponse struct {
authResponse `json:",inline"`
Accounts []AccountData `json:"accounts"`
}
func NewAccountData(account *model.Account, isAnonymous bool) *AccountData {
return &AccountData{
AccountPublic: account.AccountPublic,
IsAnonymous: isAnonymous,
}
}
func Account(logger mlogger.Logger, account *model.Account, accessToken *TokenData) http.HandlerFunc {
return response.Ok(
logger,
&accountResponse{
Account: *NewAccountData(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
func Accounts(logger mlogger.Logger, accounts []model.Account, orgRef bson.ObjectID, accessToken *TokenData) http.HandlerFunc {
publicAccounts := make([]AccountData, len(accounts))
for i := range accounts {
publicAccounts[i] = *NewAccountData(
&accounts[i],
model.AccountIsAnonymous(&accounts[i].UserDataBase, orgRef),
)
}
return response.Ok(
logger,
&accountsResponse{
Accounts: publicAccounts,
authResponse: authResponse{AccessToken: *accessToken},
},
)
}
@@ -0,0 +1,7 @@
package sresponse
type AuthResponse struct {
AccessToken TokenData `json:"accessToken"`
}
type authResponse = AuthResponse
@@ -0,0 +1,16 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
)
type fileUploaded struct {
URL string `json:"url"`
}
func FileUploaded(logger mlogger.Logger, url string) http.HandlerFunc {
return response.Ok(logger, &fileUploaded{URL: url})
}
@@ -0,0 +1,35 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type loginResponse struct {
accountResponse
RefreshToken TokenData `json:"refreshToken"`
}
func Login(logger mlogger.Logger, account *model.Account, accessToken, refreshToken *TokenData) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
responseRefreshToken := *refreshToken
if browser.UsesCookie(r) {
browser.SetRefreshCookie(w, r, refreshToken.Token, refreshToken.Expiration)
responseRefreshToken.Token = ""
}
response.Ok(
logger,
&loginResponse{
accountResponse: accountResponse{
Account: *NewAccountData(account, false),
authResponse: authResponse{AccessToken: *accessToken},
},
RefreshToken: responseRefreshToken,
},
).ServeHTTP(w, r)
}
}
@@ -0,0 +1,29 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type pendingLoginResponse struct {
Account accountResponse `json:"account"`
PendingToken TokenData `json:"pendingToken"`
Target string `json:"target"`
}
func LoginPending(logger mlogger.Logger, account *model.Account, pendingToken *TokenData, target string) http.HandlerFunc {
return response.Accepted(
logger,
&pendingLoginResponse{
Account: accountResponse{
Account: *NewAccountData(account, false),
authResponse: authResponse{},
},
PendingToken: *pendingToken,
Target: target,
},
)
}
@@ -0,0 +1,46 @@
package sresponse
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/auth/browser"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
func TestBrowserLoginMovesRefreshCredentialToHttpOnlyCookie(t *testing.T) {
refresh := &TokenData{Token: "refresh-secret", Expiration: time.Now().Add(time.Hour)}
access := &TokenData{Token: "access-secret", Expiration: time.Now().Add(time.Minute)}
req := httptest.NewRequest(http.MethodPost, "http://app.sendico.io/api/v1/accounts/login", nil)
req.Header.Set(browser.TransportHeader, browser.TransportCookie)
req.Header.Set("X-Forwarded-Proto", "https")
recorder := httptest.NewRecorder()
Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
require.NotContains(t, recorder.Body.String(), refresh.Token)
require.Contains(t, recorder.Body.String(), access.Token)
setCookie := recorder.Header().Get("Set-Cookie")
require.True(t, strings.HasPrefix(setCookie, browser.RefreshCookie+"="))
require.Contains(t, setCookie, "HttpOnly")
require.Contains(t, setCookie, "Secure")
require.Contains(t, setCookie, "SameSite=Strict")
}
func TestNativeLoginKeepsRefreshCredentialInResponse(t *testing.T) {
refresh := &TokenData{Token: "native-refresh", Expiration: time.Now().Add(time.Hour)}
access := &TokenData{Token: "native-access", Expiration: time.Now().Add(time.Minute)}
req := httptest.NewRequest(http.MethodPost, "http://native.test/api/v1/accounts/login", nil)
recorder := httptest.NewRecorder()
Login(zap.NewNop(), &model.Account{}, access, refresh).ServeHTTP(recorder, req)
require.Contains(t, recorder.Body.String(), refresh.Token)
require.Empty(t, recorder.Header().Get("Set-Cookie"))
}
@@ -0,0 +1,35 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type organizationsResponse struct {
authResponse `json:",inline"`
Organizations []model.Organization `json:"organizations"`
}
func Organization(logger mlogger.Logger, organization *model.Organization, accessToken *TokenData) http.HandlerFunc {
return Organizations(logger, []model.Organization{*organization}, accessToken)
}
func Organizations(logger mlogger.Logger, organizations []model.Organization, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, organizationsResponse{
Organizations: organizations,
authResponse: authResponse{AccessToken: *accessToken},
})
}
type organizationPublicResponse struct {
Organizations []model.OrganizationBase `json:"organizations"`
}
func OrganizationPublic(logger mlogger.Logger, organization *model.OrganizationBase) http.HandlerFunc {
return response.Ok(logger, organizationPublicResponse{
Organizations: []model.OrganizationBase{*organization},
})
}
@@ -0,0 +1,45 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type permissionsDescription struct {
Roles []model.RoleDescription `json:"roles"`
Policies []model.PolicyDescription `json:"policies"`
}
type permissionsData struct {
Roles []model.Role `json:"roles"`
Policies []model.RolePolicy `json:"policies"`
Permissions []model.Permission `json:"permissions"`
}
type permissionsResponse struct {
authResponse `json:",inline"`
Descriptions permissionsDescription `json:"descriptions"`
Permissions permissionsData `json:"permissions"`
}
func Permissions(logger mlogger.Logger,
rolesDescs []model.RoleDescription, policiesDescs []model.PolicyDescription,
roles []model.Role, policies []model.RolePolicy, permissions []model.Permission,
accessToken *TokenData,
) http.HandlerFunc {
return response.Ok(logger, permissionsResponse{
Descriptions: permissionsDescription{
Roles: rolesDescs,
Policies: policiesDescs,
},
Permissions: permissionsData{
Roles: roles,
Policies: policies,
Permissions: permissions,
},
authResponse: authResponse{AccessToken: *accessToken},
})
}
@@ -0,0 +1,14 @@
package sresponse
import (
"net/http"
emodel "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/model"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
)
type (
HandlerFunc = func(r *http.Request) http.HandlerFunc
AccountHandlerFunc = func(r *http.Request, account *model.Account, accessToken *TokenData) http.HandlerFunc
PendingAccountHandlerFunc = func(r *http.Request, account *model.Account, token *emodel.AccountToken) http.HandlerFunc
)
@@ -0,0 +1,27 @@
package sresponse
import (
"net/http"
"git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
"git.sendico.io/tech/sxchnge/bff/pkg/mlogger"
)
type resultAuth struct {
authResponse `json:",inline"`
response.Result `json:",inline"`
}
func Success(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Ok(logger, &resultAuth{
Result: response.Result{Result: true},
authResponse: authResponse{AccessToken: *accessToken},
})
}
func Failed(logger mlogger.Logger, accessToken *TokenData) http.HandlerFunc {
return response.Accepted(logger, &resultAuth{
Result: response.Result{Result: false},
authResponse: authResponse{AccessToken: *accessToken},
})
}
@@ -0,0 +1,8 @@
package sresponse
import "time"
type TokenData struct {
Token string `json:"token"`
Expiration time.Time `json:"expiration"`
}
@@ -0,0 +1,8 @@
package sresponse
type VerificationResponse struct {
IdempotencyKey string `json:"idempotencyKey"`
TTLSeconds int `json:"ttl_seconds"`
CooldownSeconds int `json:"cooldown_seconds"`
Target string `json:"target"`
}
@@ -0,0 +1,39 @@
package api
import (
"time"
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
)
type VerificationRateLimitsConfig struct {
PerDestination sharedlimit.WindowConfig `yaml:"per_destination"`
PerIP sharedlimit.WindowConfig `yaml:"per_ip"`
}
type VerificationRateLimits struct {
PerDestination sharedlimit.Window
PerIP sharedlimit.Window
}
func DefaultVerificationRateLimits() VerificationRateLimits {
return VerificationRateLimits{
PerDestination: sharedlimit.Window{Limit: 5, Window: time.Hour},
PerIP: sharedlimit.Window{Limit: 20, Window: time.Hour},
}
}
func (c *VerificationConfig) ResolveRateLimits() (VerificationRateLimits, error) {
if c == nil || c.RateLimits == nil {
return DefaultVerificationRateLimits(), nil
}
destination, err := sharedlimit.ResolveWindow(c.RateLimits.PerDestination, "verification.rate_limits.per_destination")
if err != nil {
return VerificationRateLimits{}, err
}
ip, err := sharedlimit.ResolveWindow(c.RateLimits.PerIP, "verification.rate_limits.per_ip")
if err != nil {
return VerificationRateLimits{}, err
}
return VerificationRateLimits{PerDestination: destination, PerIP: ip}, nil
}
@@ -0,0 +1,32 @@
package api
import (
"errors"
"testing"
"time"
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
func TestVerificationRateLimitsDefaults(t *testing.T) {
got, err := (*VerificationConfig)(nil).ResolveRateLimits()
if err != nil {
t.Fatalf("resolve defaults: %v", err)
}
if got.PerDestination.Limit != 5 || got.PerDestination.Window != time.Hour ||
got.PerIP.Limit != 20 || got.PerIP.Window != time.Hour {
t.Fatalf("unexpected defaults: %+v", got)
}
}
func TestVerificationRateLimitsRejectInvalidPolicy(t *testing.T) {
cfg := &VerificationConfig{RateLimits: &VerificationRateLimitsConfig{
PerDestination: sharedlimit.WindowConfig{Limit: 5, Window: "1h"},
PerIP: sharedlimit.WindowConfig{Limit: 0, Window: "1h"},
}}
_, err := cfg.ResolveRateLimits()
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
}
@@ -0,0 +1,9 @@
package ws
import (
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
)
type (
Config = middleware.WebSocketConfig
)
@@ -0,0 +1,12 @@
package ws
import (
"context"
"golang.org/x/net/websocket"
)
type (
ResponseHandler func(messageType string, conn *websocket.Conn)
HandlerFunc func(ctx context.Context, msg Message) ResponseHandler
)
@@ -0,0 +1,9 @@
package ws
import "git.sendico.io/tech/sxchnge/bff/pkg/api/http/response"
type Message struct {
response.BaseResponse
ID string `json:"id"`
MessageType string `json:"messageType"`
}
@@ -0,0 +1,49 @@
package middleware
import "testing"
func TestCredentialedCORSRequiresExplicitOrigins(t *testing.T) {
tests := []struct {
name string
config CORSSettings
wantErr bool
}{
{
name: "explicit origin",
config: CORSSettings{
AllowCredentials: true,
AllowedOrigins: []string{"https://app.sendico.io"},
},
},
{
name: "wildcard",
config: CORSSettings{
AllowCredentials: true,
AllowedOrigins: []string{"*"},
},
wantErr: true,
},
{
name: "missing origin",
config: CORSSettings{
AllowCredentials: true,
},
wantErr: true,
},
{
name: "non-credentialed compatibility",
config: CORSSettings{
AllowedOrigins: []string{"*"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
}
})
}
}
@@ -0,0 +1,47 @@
package middleware
import (
"time"
sharedlimit "git.sendico.io/tech/sxchnge/bff/edge/shared/interface/ratelimit"
)
const (
defaultLoginAccountLimit = 5
defaultLoginAccountWindow = 15 * time.Minute
defaultLoginIPLimit = 50
defaultLoginIPWindow = 15 * time.Minute
)
type LoginRateLimitWindowConfig = sharedlimit.WindowConfig
type LoginRateLimitsConfig struct {
PerAccount LoginRateLimitWindowConfig `yaml:"per_account"`
PerIP LoginRateLimitWindowConfig `yaml:"per_ip"`
}
type LoginRateLimitWindow = sharedlimit.Window
type LoginRateLimits struct {
PerAccount LoginRateLimitWindow
PerIP LoginRateLimitWindow
}
func (c *Config) ResolveLoginRateLimits() (LoginRateLimits, error) {
if c == nil || c.LoginRateLimits == nil {
return LoginRateLimits{
PerAccount: LoginRateLimitWindow{Limit: defaultLoginAccountLimit, Window: defaultLoginAccountWindow},
PerIP: LoginRateLimitWindow{Limit: defaultLoginIPLimit, Window: defaultLoginIPWindow},
}, nil
}
account, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerAccount, "middleware.login_rate_limits.per_account")
if err != nil {
return LoginRateLimits{}, err
}
ip, err := sharedlimit.ResolveWindow(c.LoginRateLimits.PerIP, "middleware.login_rate_limits.per_ip")
if err != nil {
return LoginRateLimits{}, err
}
return LoginRateLimits{PerAccount: account, PerIP: ip}, nil
}
@@ -0,0 +1,60 @@
package middleware
import (
"errors"
"testing"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
func TestResolveLoginRateLimitsUsesSecureDefaults(t *testing.T) {
got, err := (&Config{}).ResolveLoginRateLimits()
if err != nil {
t.Fatalf("resolve defaults: %v", err)
}
if got.PerAccount.Limit != 5 || got.PerAccount.Window != 15*time.Minute {
t.Fatalf("unexpected account defaults: %+v", got.PerAccount)
}
if got.PerIP.Limit != 50 || got.PerIP.Window != 15*time.Minute {
t.Fatalf("unexpected IP defaults: %+v", got.PerIP)
}
}
func TestResolveLoginRateLimitsRejectsUnsafeConfiguration(t *testing.T) {
tests := []struct {
name string
cfg *LoginRateLimitsConfig
}{
{
name: "zero account threshold",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Window: "15m"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
},
},
{
name: "invalid IP window",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "15m"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "forever"},
},
},
{
name: "excessive account window",
cfg: &LoginRateLimitsConfig{
PerAccount: LoginRateLimitWindowConfig{Limit: 5, Window: "25h"},
PerIP: LoginRateLimitWindowConfig{Limit: 50, Window: "15m"},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, err := (&Config{LoginRateLimits: test.cfg}).ResolveLoginRateLimits()
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
})
}
}
@@ -0,0 +1,116 @@
package middleware
import (
"os"
"strings"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
type CORSSettings struct {
MaxAge int `yaml:"max_age"`
AllowedOrigins []string `yaml:"allowed_origins"`
AllowedMethods []string `yaml:"allowed_methods"`
AllowedHeaders []string `yaml:"allowed_headers"`
ExposedHeaders []string `yaml:"exposed_headers"`
AllowCredentials bool `yaml:"allow_credentials"`
}
func (c CORSSettings) Validate() error {
if !c.AllowCredentials {
return nil
}
if len(c.AllowedOrigins) == 0 {
return merrors.InvalidArgument("credentialed CORS requires explicit allowed origins", "middleware.CORS.allowed_origins")
}
for _, origin := range c.AllowedOrigins {
if strings.TrimSpace(origin) == "*" {
return merrors.InvalidArgument("credentialed CORS does not allow wildcard origins", "middleware.CORS.allowed_origins")
}
}
return nil
}
type Signature struct {
PublicKey any
PrivateKey []byte
Algorithm string
}
type SignatureConfig struct {
PublicKeyEnv string `yaml:"public_key_env,omitempty"`
PrivateKeyEnv string `yaml:"secret_key_env"`
Algorithm string `yaml:"algorithm"`
}
type TokenExpiration struct {
Account int `yaml:"account"`
Refresh int `yaml:"refresh"`
}
type TokenConfig struct {
Expiration TokenExpiration `yaml:"expiration_hours"`
Length int `yaml:"length"`
}
const MaxAccessTokenTTLHours = 1
func (c TokenConfig) Validate() error {
if c.Expiration.Account <= 0 || c.Expiration.Account > MaxAccessTokenTTLHours {
return merrors.InvalidArgument("access token expiration must be between 1 and 1 hour", "middleware.token.expiration_hours.account")
}
if c.Expiration.Refresh <= c.Expiration.Account {
return merrors.InvalidArgument("refresh token expiration must exceed access token expiration", "middleware.token.expiration_hours.refresh")
}
if c.Length < 32 {
return merrors.InvalidArgument("refresh token length must be at least 32 bytes", "middleware.token.length")
}
return nil
}
type WebSocketConfig struct {
EndpointEnv string `yaml:"endpoint_env"`
Timeout int `yaml:"timeout"`
}
type PasswordChecks struct {
Digit bool `yaml:"digit"`
Upper bool `yaml:"upper"`
Lower bool `yaml:"lower"`
Special bool `yaml:"special"`
MinLength int `yaml:"min_length"`
}
type PasswordConfig struct {
TokenLength int `yaml:"token_length"`
Check PasswordChecks `yaml:"check"`
}
type Config struct {
DomainEnv string `yaml:"domain_env"`
EndPointEnv string `yaml:"api_endpoint_env"`
APIProtocolEnv string `yaml:"api_protocol_env"`
Signature SignatureConfig `yaml:"signature"`
CORS CORSSettings `yaml:"CORS"`
WebSocket WebSocketConfig `yaml:"websocket"`
Token TokenConfig `yaml:"token"`
Password PasswordConfig `yaml:"password"`
LoginRateLimits *LoginRateLimitsConfig `yaml:"login_rate_limits"`
}
type MapClaims = map[string]any
func getKey(osEnv string) any {
if len(osEnv) == 0 {
return nil
}
return []byte(os.Getenv(osEnv))
}
func SignatureConf(conf *Config) Signature {
return Signature{
PrivateKey: []byte(os.Getenv(conf.Signature.PrivateKeyEnv)),
PublicKey: getKey(conf.Signature.PublicKeyEnv),
Algorithm: conf.Signature.Algorithm,
}
}
@@ -0,0 +1,52 @@
package middleware
import "testing"
func TestTokenConfigValidate(t *testing.T) {
tests := []struct {
name string
config TokenConfig
wantErr bool
}{
{
name: "secure explicit TTLs",
config: TokenConfig{
Expiration: TokenExpiration{Account: 1, Refresh: 720},
Length: 32,
},
},
{
name: "rejects legacy 24 hour access token",
config: TokenConfig{
Expiration: TokenExpiration{Account: 24, Refresh: 720},
Length: 32,
},
wantErr: true,
},
{
name: "rejects missing access TTL",
config: TokenConfig{
Expiration: TokenExpiration{Refresh: 720},
Length: 32,
},
wantErr: true,
},
{
name: "rejects weak refresh token",
config: TokenConfig{
Expiration: TokenExpiration{Account: 1, Refresh: 720},
Length: 16,
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.config.Validate()
if (err != nil) != tt.wantErr {
t.Fatalf("Validate() error = %v, wantErr %t", err, tt.wantErr)
}
})
}
}
@@ -0,0 +1,140 @@
package model
import (
"fmt"
"time"
"git.sendico.io/tech/sxchnge/bff/edge/shared/interface/middleware"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
"git.sendico.io/tech/sxchnge/bff/pkg/model"
mduration "git.sendico.io/tech/sxchnge/bff/pkg/mutil/duration"
"go.mongodb.org/mongo-driver/v2/bson"
)
type AccountToken struct {
AccountRef bson.ObjectID
Login string
Name string
Locale string
model.SessionIdentifier
Expiration time.Time
Pending bool
}
func createAccountToken(a *model.Account, expiration int, session model.SessionIdentifier) AccountToken {
return AccountToken{
AccountRef: *a.GetID(),
Login: a.Login,
Name: a.Name,
Locale: a.Locale,
SessionIdentifier: session,
Expiration: time.Now().Add(mduration.Param2Duration(expiration, time.Hour)),
Pending: false,
}
}
func getTokenParam(claims middleware.MapClaims, param string) (string, error) {
id, ok := claims[param].(string)
if !ok {
return "", merrors.NoData(fmt.Sprintf("param '%s' not found", param))
}
return id, nil
}
const (
paramNameID = "id"
paramNameName = "name"
paramNameLocale = "locale"
paramNameLogin = "login"
paramNameClientID = "clientId"
paramNameDeviceID = "deviceId"
paramNameSessionVersion = "sessionVersion"
paramNameExpiration = "exp"
paramNamePending = "pending"
)
func Claims2Token(claims middleware.MapClaims) (*AccountToken, error) {
var at AccountToken
var err error
var account string
if account, err = getTokenParam(claims, paramNameID); err != nil {
return nil, err
}
if at.AccountRef, err = bson.ObjectIDFromHex(account); err != nil {
return nil, err
}
if at.Login, err = getTokenParam(claims, paramNameLogin); err != nil {
return nil, err
}
if at.Name, err = getTokenParam(claims, paramNameName); err != nil {
return nil, err
}
if at.Locale, err = getTokenParam(claims, paramNameLocale); err != nil {
return nil, err
}
if clientID, ok := claims[paramNameClientID]; ok {
if clientIDText, ok := clientID.(string); ok {
at.ClientID = clientIDText
}
}
if deviceID, ok := claims[paramNameDeviceID]; ok {
if deviceIDText, ok := deviceID.(string); ok {
at.DeviceID = deviceIDText
}
}
if version, ok := claims[paramNameSessionVersion]; ok {
if versionText, ok := version.(string); ok {
at.SessionVersion = versionText
}
}
if pending, ok := claims[paramNamePending]; ok {
if pbool, ok := pending.(bool); ok {
at.Pending = pbool
}
}
if expValue, ok := claims[paramNameExpiration]; ok {
switch exp := expValue.(type) {
case time.Time:
at.Expiration = exp
case float64:
at.Expiration = time.Unix(int64(exp), 0)
case int64:
at.Expiration = time.Unix(exp, 0)
default:
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
}
} else {
return nil, merrors.InvalidDataType(fmt.Sprintf("expiration param is of invalid type: %T", expValue))
}
return &at, nil
}
func Account2ClaimsForSession(a *model.Account, expiration int, session model.SessionIdentifier) middleware.MapClaims {
t := createAccountToken(a, expiration, session)
return middleware.MapClaims{
paramNameID: t.AccountRef.Hex(),
paramNameLogin: t.Login,
paramNameName: t.Name,
paramNameLocale: t.Locale,
paramNameClientID: t.ClientID,
paramNameDeviceID: t.DeviceID,
paramNameSessionVersion: t.SessionVersion,
paramNameExpiration: t.Expiration.Unix(),
paramNamePending: t.Pending,
}
}
func PendingAccount2Claims(a *model.Account, expirationMinutes int) middleware.MapClaims {
t := createAccountToken(a, expirationMinutes/60, model.SessionIdentifier{})
t.Expiration = time.Now().Add(time.Duration(expirationMinutes) * time.Minute)
t.Pending = true
return middleware.MapClaims{
paramNameID: t.AccountRef.Hex(),
paramNameLogin: t.Login,
paramNameName: t.Name,
paramNameLocale: t.Locale,
paramNameExpiration: t.Expiration.Unix(),
paramNamePending: t.Pending,
}
}
@@ -0,0 +1,32 @@
package model
import (
"testing"
basemodel "git.sendico.io/tech/sxchnge/bff/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestAccountTokenPreservesSessionIdentity(t *testing.T) {
account := &basemodel.Account{}
account.SetID(bson.NewObjectID())
account.Login = "security@example.com"
account.Name = "Security"
account.Locale = "en"
session := basemodel.SessionIdentifier{
ClientID: "web",
DeviceID: "device-a",
SessionVersion: "session-version-a",
}
token, err := Claims2Token(Account2ClaimsForSession(account, 1, session))
if err != nil {
t.Fatalf("decode account claims: %v", err)
}
if token.SessionIdentifier != session {
t.Fatalf("session identity = %#v, want %#v", token.SessionIdentifier, session)
}
if token.Expiration.IsZero() {
t.Fatal("access token expiration is missing")
}
}
@@ -0,0 +1,38 @@
package ratelimit
import (
"strings"
"time"
"git.sendico.io/tech/sxchnge/bff/pkg/merrors"
)
const (
MinWindow = time.Second
MaxWindow = 24 * time.Hour
MaxLimit = 10000
)
type WindowConfig struct {
Limit int `yaml:"limit"`
Window string `yaml:"window"`
}
type Window struct {
Limit int
Window time.Duration
}
func ResolveWindow(raw WindowConfig, field string) (Window, error) {
if raw.Limit < 1 || raw.Limit > MaxLimit {
return Window{}, merrors.InvalidArgument("rate limit must be between 1 and 10000", field+".limit")
}
window, err := time.ParseDuration(strings.TrimSpace(raw.Window))
if err != nil {
return Window{}, merrors.InvalidArgument("rate limit window must be a valid duration", field+".window")
}
if window < MinWindow || window > MaxWindow {
return Window{}, merrors.InvalidArgument("rate limit window must be between 1s and 24h", field+".window")
}
return Window{Limit: raw.Limit, Window: window}, nil
}
@@ -0,0 +1,25 @@
package storage
import "git.sendico.io/tech/sxchnge/bff/pkg/model"
type Driver string
const (
LocalFS Driver = "local_fs"
AWSS3 Driver = "aws_s3"
)
type Config = model.DriverConfig[Driver]
type LocalFSConfig struct {
RootPath string `mapstructure:"root_path" yaml:"root_path"`
}
type AWSS3Config struct {
AccessKeyIDEnv string `mapstructure:"access_key_id_env" yaml:"access_key_id_env"`
SecretAccessKeyEnv string `mapstructure:"secret_access_key_env" yaml:"secret_access_key_env"`
RegionEnv string `mapstructure:"region_env" yaml:"region_env"`
BucketNameEnv string `mapstructure:"bucket_name_env" yaml:"bucket_name_env"`
EndpointEnv string `mapstructure:"endpoint_env" yaml:"endpoint_env"`
UsePathStyle bool `mapstructure:"use_path_style" yaml:"use_path_style"`
}