Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ DATABASE_POOL_SIZE=25
# Timeout used when opening the initial database connection and pinging.
DATABASE_CONNECTION_TIMEOUT=5s

# Redis address for the challenge store (host:port).
# When empty, falls back to an in-memory store (single-instance only).
REDIS_ADDR=localhost:6379

# Stellar network passphrase.
STELLAR_NETWORK_PASSPHRASE=Test SDF Network ; September 2015

Expand Down
12 changes: 11 additions & 1 deletion apps/api/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"time"

"github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9"

"github.com/suncrestlabs/nester/apps/api/internal/config"
"github.com/suncrestlabs/nester/apps/api/internal/handler"
Expand Down Expand Up @@ -70,7 +71,16 @@ func run() error {
)
adminHandler := handler.NewAdminHandler(adminService)

authService := service.NewAuthService(userService, cfg.Auth())
var challengeStore service.ChallengeStore
if addr := cfg.Redis().Addr(); addr != "" {
redisClient := redis.NewClient(&redis.Options{Addr: addr})
challengeStore = service.NewRedisChallengeStore(redisClient, cfg.Auth().ChallengeExpiry())
baseLogger.Info("challenge store: redis", "addr", addr)
} else {
challengeStore = service.NewInMemoryChallengeStore(cfg.Auth().ChallengeExpiry())
baseLogger.Info("challenge store: in-memory (single-instance only)")
}
authService := service.NewAuthService(challengeStore, userService, cfg.Auth())
authHandler := handler.NewAuthHandler(authService)

mux := http.NewServeMux()
Expand Down
4 changes: 4 additions & 0 deletions apps/api/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@ require (
)

require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
Expand All @@ -26,8 +28,10 @@ require (
github.com/leodido/go-urn v1.4.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/stellar/go-xdr v0.0.0-20231122183749-b53fb00bcac2 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
Expand Down
8 changes: 8 additions & 0 deletions apps/api/go.sum
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
Expand Down Expand Up @@ -50,6 +54,8 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
Expand All @@ -65,6 +71,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/xdrpp/goxdr v0.1.1 h1:E1B2c6E8eYhOVyd7yEpOyopzTPirUeF6mVOfXfGyJyc=
github.com/xdrpp/goxdr v0.1.1/go.mod h1:dXo1scL/l6s7iME1gxHWo2XCppbHEKZS7m/KyYWkNzA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
Expand Down
16 changes: 16 additions & 0 deletions apps/api/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type Config struct {
server ServerConfig
database DatabaseConfig
stellar StellarConfig
redis RedisConfig
settlementProviderURL string
auth AuthConfig
rateLimit RateLimitConfig
Expand Down Expand Up @@ -62,6 +63,10 @@ type LogConfig struct {
format string
}

type RedisConfig struct {
addr string
}

func Load() (*Config, error) {
fileValues, err := loadDotEnvFile(".env")
if err != nil {
Expand Down Expand Up @@ -97,6 +102,9 @@ func Load() (*Config, error) {
rpcURL: loader.requiredURL("STELLAR_RPC_URL"),
horizonURL: loader.requiredURL("STELLAR_HORIZON_URL"),
},
redis: RedisConfig{
addr: loader.stringDefault("REDIS_ADDR", ""),
},
settlementProviderURL: loader.stringDefault("SETTLEMENT_PROVIDER_URL", ""),
auth: AuthConfig{
secret: loader.requiredString("AUTH_JWT_SECRET"),
Expand Down Expand Up @@ -156,6 +164,14 @@ func (c Config) Log() LogConfig {
return c.log
}

func (c Config) Redis() RedisConfig {
return c.redis
}

func (r RedisConfig) Addr() string {
return r.addr
}

func (c *Config) validate(loader *envLoader) {
if strings.TrimSpace(c.server.host) == "" {
loader.addError("SERVER_HOST is required")
Expand Down
51 changes: 16 additions & 35 deletions apps/api/internal/service/auth_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
"encoding/base64"
"encoding/hex"
"errors"
"sync"
"time"

"github.com/stellar/go/keypair"
Expand All @@ -19,11 +18,6 @@ var (
ErrWalletInvalid = errors.New("wallet address is invalid")
)

type challengeData struct {
Challenge string
ExpiresAt time.Time
}

type AuthService interface {
GenerateChallenge(ctx context.Context, walletAddress string) (string, error)
VerifyAndIssue(ctx context.Context, walletAddress, signature, challenge string) (string, error)
Expand All @@ -36,51 +30,46 @@ type AuthConfig interface {
}

type authService struct {
mu sync.RWMutex
challenges map[string]challengeData
store ChallengeStore
userService *UserService
config AuthConfig
}

func NewAuthService(userService *UserService, cfg AuthConfig) AuthService {
func NewAuthService(store ChallengeStore, userService *UserService, cfg AuthConfig) AuthService {
return &authService{
challenges: make(map[string]challengeData),
store: store,
userService: userService,
config: cfg,
}
}

func (s *authService) GenerateChallenge(ctx context.Context, walletAddress string) (string, error) {
// Validate wallet format first
if _, err := keypair.ParseAddress(walletAddress); err != nil {
return "", ErrWalletInvalid
}

bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
challenge := hex.EncodeToString(bytes)
challenge := hex.EncodeToString(b)

s.mu.Lock()
s.challenges[walletAddress] = challengeData{
Challenge: challenge,
ExpiresAt: time.Now().Add(s.config.ChallengeExpiry()),
if err := s.store.Set(ctx, walletAddress, challenge); err != nil {
return "", err
}
s.mu.Unlock()

return challenge, nil
}

func (s *authService) VerifyAndIssue(ctx context.Context, walletAddress, signature string, challenge string) (string, error) {
s.mu.Lock()
data, ok := s.challenges[walletAddress]
if ok {
delete(s.challenges, walletAddress) // One-time use challenge
stored, err := s.store.GetAndDelete(ctx, walletAddress)
if err != nil {
if errors.Is(err, ErrChallengeNotFound) {
return "", ErrChallengeExpired
}
return "", err
}
s.mu.Unlock()

if !ok || time.Now().After(data.ExpiresAt) || data.Challenge != challenge {
if stored != challenge {
return "", ErrChallengeExpired
}

Expand All @@ -98,11 +87,8 @@ func (s *authService) VerifyAndIssue(ctx context.Context, walletAddress, signatu
return "", ErrSignatureInvalid
}

// Try to get user, if not found, register them
user, err := s.userService.GetUserByWallet(ctx, walletAddress)
if err != nil {
// Register a new user if one doesn't exist
// displayName defaults to first 8 chars of wallet
user, err = s.userService.RegisterUser(ctx, walletAddress, walletAddress[:8])
if err != nil {
return "", err
Expand All @@ -122,10 +108,5 @@ func (s *authService) VerifyAndIssue(ctx context.Context, walletAddress, signatu
Roles: roles,
}

token, err := auth.MakeJWT(claims, s.config.Secret())
if err != nil {
return "", err
}

return token, nil
return auth.MakeJWT(claims, s.config.Secret())
}
9 changes: 5 additions & 4 deletions apps/api/internal/service/auth_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,8 @@ func setupAuthService() (AuthService, *keypair.Full) {

repo := newMockRepo()
userService := NewUserService(repo)
authSvc := NewAuthService(userService, cfg)
store := NewInMemoryChallengeStore(cfg.ChallengeExpiry())
authSvc := NewAuthService(store, userService, cfg)

kp, _ := keypair.Random()
return authSvc, kp
Expand Down Expand Up @@ -130,7 +131,7 @@ func TestAuthService_VerifyAndIssue_ExpiredChallenge(t *testing.T) {
challengeExpiry: -1 * time.Second,
}
repo := newMockRepo()
svc := NewAuthService(NewUserService(repo), cfg)
svc := NewAuthService(NewInMemoryChallengeStore(cfg.ChallengeExpiry()), NewUserService(repo), cfg)

kp, _ := keypair.Random()
challenge, _ := svc.GenerateChallenge(context.Background(), kp.Address())
Expand Down Expand Up @@ -161,7 +162,7 @@ func TestAuthService_VerifyAndIssue_AdminRolePopulatedInToken(t *testing.T) {
repo.users[kp.Address()] = adminUser
repo.roles[adminUser.ID] = []string{"admin"}

svc := NewAuthService(NewUserService(repo), cfg)
svc := NewAuthService(NewInMemoryChallengeStore(cfg.ChallengeExpiry()), NewUserService(repo), cfg)

challenge, err := svc.GenerateChallenge(context.Background(), kp.Address())
require.NoError(t, err)
Expand All @@ -186,7 +187,7 @@ func TestAuthService_VerifyAndIssue_RegularUserHasEmptyRoles(t *testing.T) {
}
repo := newMockRepo()
kp, _ := keypair.Random()
svc := NewAuthService(NewUserService(repo), cfg)
svc := NewAuthService(NewInMemoryChallengeStore(cfg.ChallengeExpiry()), NewUserService(repo), cfg)

challenge, err := svc.GenerateChallenge(context.Background(), kp.Address())
require.NoError(t, err)
Expand Down
93 changes: 93 additions & 0 deletions apps/api/internal/service/challenge_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package service

import (
"context"
"errors"
"fmt"
"sync"
"time"

"github.com/redis/go-redis/v9"
)

var ErrChallengeNotFound = errors.New("challenge not found or expired")

// ChallengeStore persists single-use auth challenges.
// Implementations must be safe for concurrent use.
type ChallengeStore interface {
// Set stores challenge for walletAddress, overwriting any existing entry.
Set(ctx context.Context, walletAddress, challenge string) error
// GetAndDelete atomically retrieves and removes the challenge.
// Returns ErrChallengeNotFound when the key is absent or expired.
GetAndDelete(ctx context.Context, walletAddress string) (string, error)
}

// ── Redis implementation ──────────────────────────────────────────────────────

type RedisChallengeStore struct {
client *redis.Client
ttl time.Duration
}

func NewRedisChallengeStore(client *redis.Client, ttl time.Duration) *RedisChallengeStore {
return &RedisChallengeStore{client: client, ttl: ttl}
}

func (s *RedisChallengeStore) Set(ctx context.Context, walletAddress, challenge string) error {
return s.client.Set(ctx, challengeKey(walletAddress), challenge, s.ttl).Err()
}

func (s *RedisChallengeStore) GetAndDelete(ctx context.Context, walletAddress string) (string, error) {
val, err := s.client.GetDel(ctx, challengeKey(walletAddress)).Result()
if errors.Is(err, redis.Nil) {
return "", ErrChallengeNotFound
}
if err != nil {
return "", fmt.Errorf("redis GetDel: %w", err)
}
return val, nil
}

func challengeKey(walletAddress string) string {
return "auth:challenge:" + walletAddress
}

// ── In-memory implementation (dev / single-instance fallback) ─────────────────

type inMemoryEntry struct {
value string
expiresAt time.Time
}

type InMemoryChallengeStore struct {
mu sync.Mutex
m map[string]inMemoryEntry
ttl time.Duration
}

func NewInMemoryChallengeStore(ttl time.Duration) *InMemoryChallengeStore {
return &InMemoryChallengeStore{
m: make(map[string]inMemoryEntry),
ttl: ttl,
}
}

func (s *InMemoryChallengeStore) Set(_ context.Context, walletAddress, challenge string) error {
s.mu.Lock()
s.m[walletAddress] = inMemoryEntry{value: challenge, expiresAt: time.Now().Add(s.ttl)}
s.mu.Unlock()
return nil
}

func (s *InMemoryChallengeStore) GetAndDelete(_ context.Context, walletAddress string) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()

entry, ok := s.m[walletAddress]
if !ok || time.Now().After(entry.expiresAt) {
delete(s.m, walletAddress)
return "", ErrChallengeNotFound
}
delete(s.m, walletAddress)
return entry.value, nil
}
Loading
Loading