From 00b48c5d5240b9f96ba179b4deea9b8430e8a123 Mon Sep 17 00:00:00 2001 From: Nandhukumar Date: Fri, 19 Jun 2026 19:01:12 +0530 Subject: [PATCH] 2010 | Use a Library for Reading Configuration from Environment Variables Signed-off-by: Nandhukumar --- esignet-service/.env.example | 10 +- esignet-service/README.md | 12 +- esignet-service/go.mod | 1 + esignet-service/go.sum | 2 + esignet-service/internal/config/app.go | 181 +++++++++--------- esignet-service/internal/config/db.go | 137 ++++++------- esignet-service/internal/config/redis.go | 130 ++++++------- esignet-service/internal/config/redis_test.go | 122 ++++++++++++ .../internal/engine/mosip/config.go | 100 ++++++---- .../internal/engine/mosip/mosip_authn.go | 15 +- .../internal/engine/sunbird/config.go | 92 ++++----- .../internal/engine/sunbird/sunbird_authn.go | 7 +- 12 files changed, 466 insertions(+), 343 deletions(-) create mode 100644 esignet-service/internal/config/redis_test.go diff --git a/esignet-service/.env.example b/esignet-service/.env.example index 7fa860af9..7827075d8 100644 --- a/esignet-service/.env.example +++ b/esignet-service/.env.example @@ -4,8 +4,10 @@ # Copy this file to .env and adjust values for your environment: # cp .env.example .env # -# make.sh loads .env automatically when present. -# Precedence: VAR=VALUE on the command line > .env > shell environment > defaults. +# make.sh loads .env into the environment before starting the service, so these +# values apply to `go run`, the binary, and tests. For Docker/CI, pass the same +# variables through your usual mechanism. Variables already set in the real +# environment always take precedence over .env. # ───────────────────────────────────────────────────────────────────────────── # ── HTTP / ThunderID engine ─────────────────────────────────────────────────── @@ -53,14 +55,14 @@ OIDC_UI_ERROR_PATH=/error # ── PostgreSQL (client management persistence) ──────────────────────────────── # Option A: full DSN (takes precedence over individual vars) -# POSTGRES_URL=postgres://esignet:secret@localhost:5432/mosip_esignet?sslmode=disable +# DATABASE_URL=postgres://esignet:secret@localhost:5432/mosip_esignet?sslmode=disable # Option B: individual connection params DATABASE_HOST=localhost DATABASE_PORT=5432 DATABASE_NAME=mosip_esignet DATABASE_USERNAME=esignet -# DB_DBUSER_PASSWORD=secret +# DATABASE_PASSWORD=secret # Connection pool tuning (optional — defaults shown) # DB_MAX_OPEN_CONNS=25 diff --git a/esignet-service/README.md b/esignet-service/README.md index b9d5cba21..692df25ed 100644 --- a/esignet-service/README.md +++ b/esignet-service/README.md @@ -60,12 +60,14 @@ The checked-in `go.mod` `replace` directive pins a Thunder backend fork until th ### Quick start (development) ```bash -cp .env.example .env # fill in DATABASE_* / DB_DBUSER_PASSWORD and REDIS_* at minimum +cp .env.example .env # fill in DATABASE_* and REDIS_* at minimum ./make.sh run ``` Copy `.env.example` to `.env` to override defaults, or pass overrides on the command line (`./make.sh run PORT=9090`). +`make.sh` loads `.env` from the working directory into the environment before starting the service, so it applies to `go run`, the built binary, and tests. The service itself only reads variables already present in the environment; for Docker/CI, supply them through your usual mechanism. Real environment variables already set always take precedence over `.env`. + ### Binary ```bash @@ -75,7 +77,7 @@ export PORT=8088 export MOSIP_ESIGNET_HOST=http://127.0.0.1:8088 export DATABASE_HOST=localhost export DATABASE_USERNAME=esignet -export DB_DBUSER_PASSWORD=secret +export DATABASE_PASSWORD=secret export DATABASE_NAME=mosip_esignet export REDIS_HOST=localhost export AUTHN_PROVIDER=mosip @@ -139,12 +141,12 @@ Authorize redirects are sent to the Thunder gate client: | Variable | Default | Purpose | |----------|---------|---------| -| `POSTGRES_URL` | _(empty)_ | Full DSN — takes precedence if set | +| `DATABASE_URL` | _(empty)_ | Full DSN — takes precedence if set | | `DATABASE_HOST` | `localhost` | | | `DATABASE_PORT` | `5432` | | | `DATABASE_NAME` | `mosip_esignet` | | | `DATABASE_USERNAME` | `postgres` | | -| `DB_DBUSER_PASSWORD` | _(empty)_ | | +| `DATABASE_PASSWORD` | _(empty)_ | | | `DB_MAX_OPEN_CONNS` | `25` | Max open connections | | `DB_MAX_IDLE_CONNS` | `5` | Max idle connections | | `DB_CONN_MAX_LIFETIME_SECS` | `300` | Connection lifetime | @@ -339,7 +341,7 @@ curl -s http://127.0.0.1:8088/health docker run --rm -p 8088:8088 \ -e MOSIP_ESIGNET_HOST=http://127.0.0.1:8088 \ -e CRYPTO_ENCRYPTION_KEY=your-64-char-hex-key \ - -e POSTGRES_URL=postgres://esignet:secret@host.docker.internal:5432/mosip_esignet?sslmode=disable \ + -e DATABASE_URL=postgres://esignet:secret@host.docker.internal:5432/mosip_esignet?sslmode=disable \ -e REDIS_URL=redis://host.docker.internal:6379/0 \ -e AUTHN_PROVIDER=mosip \ esignet:latest diff --git a/esignet-service/go.mod b/esignet-service/go.mod index b1e0f3cb1..b1f66d8df 100644 --- a/esignet-service/go.mod +++ b/esignet-service/go.mod @@ -4,6 +4,7 @@ go 1.26 require ( github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/kelseyhightower/envconfig v1.4.0 github.com/lib/pq v1.10.9 github.com/redis/go-redis/v9 v9.18.0 github.com/stretchr/testify v1.11.1 diff --git a/esignet-service/go.sum b/esignet-service/go.sum index 3d490e891..270890835 100644 --- a/esignet-service/go.sum +++ b/esignet-service/go.sum @@ -41,6 +41,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4= +github.com/kelseyhightower/envconfig v1.4.0 h1:Im6hONhd3pLkfDFsbRgu68RDNkGF1r3dvMUtDTo2cv8= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/esignet-service/internal/config/app.go b/esignet-service/internal/config/app.go index 47e1e0cdd..0cbf476a4 100644 --- a/esignet-service/internal/config/app.go +++ b/esignet-service/internal/config/app.go @@ -2,18 +2,18 @@ package config import ( + "errors" "fmt" "os" "path/filepath" - "strconv" "strings" + "github.com/kelseyhightower/envconfig" engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" "gopkg.in/yaml.v3" ) const ( - defaultPort = 8088 defaultDataDir = "./data" appConfigFileName = "deployment.yaml" defaultGatePort = 3000 @@ -49,7 +49,27 @@ type AppConfig struct { Consent engineconfig.ConsentConfig `yaml:"consent"` } -// LoadAppConfig loads the application configuration from the default data directory. +// appSpec is the environment-variable layout for core application settings. +// Issuer carries no default tag because its fallback is derived from the +// resolved Port at load time. +type appSpec struct { + Identifier string `envconfig:"NAMESPACE" default:"esignet"` + Port int `envconfig:"PORT" default:"8088"` + Issuer string `envconfig:"MOSIP_ESIGNET_HOST"` + DataDir string `envconfig:"DATA_DIR" default:"./data"` + Provider string `envconfig:"AUTHN_PROVIDER" default:"mock"` + AuthFlowID string `envconfig:"AUTH_FLOW_ID" default:"flow-esignet"` + ThemeID string `envconfig:"THEME_ID" default:"theme-esignet"` + LayoutID string `envconfig:"LAYOUT_ID" default:"layout-esignet"` + EncryptionKey string `envconfig:"CRYPTO_ENCRYPTION_KEY" required:"true"` +} + +// LoadAppConfig loads the application configuration from the default data +// directory and overlays environment-derived settings. It returns an error +// (and a nil config) if the file cannot be read or any environment variable +// cannot be parsed into its target type, so an invalid configuration fails +// startup rather than being silently coerced or mistaken for a usable +// zero-value struct. func LoadAppConfig() (*AppConfig, error) { path := filepath.Join(defaultDataDir, appConfigFileName) data, err := os.ReadFile(path) @@ -65,28 +85,46 @@ func LoadAppConfig() (*AppConfig, error) { return nil, fmt.Errorf("parse %s: %w", path, err) } - applyDefaults(&cfg) + if err := applyDefaults(&cfg); err != nil { + return nil, err + } return &cfg, nil } -func applyDefaults(cfg *AppConfig) { - cfg.Identifier = envOrDefault("NAMESPACE", "esignet") - cfg.Port = envIntOrDefault("PORT", defaultPort) - cfg.Issuer = envOrDefault("MOSIP_ESIGNET_HOST", fmt.Sprintf("http://127.0.0.1:%d", cfg.Port)) - cfg.DataDir = envOrDefault("DATA_DIR", defaultDataDir) +func applyDefaults(cfg *AppConfig) error { + var s appSpec + if err := envconfig.Process("", &s); err != nil { + return fmt.Errorf("loading app config: %w", err) + } + + cfg.Identifier = s.Identifier + cfg.Port = s.Port + cfg.Issuer = s.Issuer + if cfg.Issuer == "" { + cfg.Issuer = fmt.Sprintf("http://127.0.0.1:%d", cfg.Port) + } + cfg.DataDir = s.DataDir yamlDB := cfg.DB - cfg.DB = loadDB() + db, err := loadDB() + if err != nil { + return err + } + cfg.DB = *db if !hasDBEnvConfig() && yamlDB.DSN != "" { cfg.DB.DSN = yamlDB.DSN } if yamlDB.Pool.MaxOpenConns > 0 { cfg.DB.Pool = yamlDB.Pool } - cfg.Redis = loadRedis() - cfg.Provider = envOrDefault("AUTHN_PROVIDER", "mock") - cfg.LayoutID = envOrDefault("LAYOUT_ID", "layout-esignet") - cfg.ThemeID = envOrDefault("THEME_ID", "theme-esignet") - cfg.AuthFlowID = envOrDefault("AUTH_FLOW_ID", "flow-esignet") + redisCfg, err := loadRedis() + if err != nil { + return err + } + cfg.Redis = *redisCfg + cfg.Provider = s.Provider + cfg.LayoutID = s.LayoutID + cfg.ThemeID = s.ThemeID + cfg.AuthFlowID = s.AuthFlowID cfg.Server.Port = cfg.Port cfg.Server.Identifier = cfg.Identifier @@ -98,10 +136,7 @@ func applyDefaults(cfg *AppConfig) { cfg.JWT.PreferredKeyID = "default-key" cfg.JWT.ValidityPeriod = 3600 - cfg.EncryptionConfig.Key = envOrDefault("CRYPTO_ENCRYPTION_KEY", "") - if cfg.EncryptionConfig.Key == "" { - panic("CRYPTO_ENCRYPTION_KEY must be set") - } + cfg.EncryptionConfig.Key = s.EncryptionKey cfg.Cache.Disabled = false cfg.Cache.Type = "redis" @@ -140,88 +175,60 @@ func applyDefaults(cfg *AppConfig) { cfg.KeyConfig.CertFile = defaultSigningCertPath cfg.KeyConfig.KeyFile = defaultSigningKeyPath cfg.KeyConfig.ID = "default-key" + + return nil +} + +// overrideSpec is the environment-variable layout for optional overrides of +// gate-client and OAuth lifetime settings. Zero values mean "not set" and +// leave the corresponding default in place. +type overrideSpec struct { + UIScheme string `envconfig:"OIDC_UI_SCHEME"` + UIHostname string `envconfig:"OIDC_UI_HOSTNAME"` + UIPort int `envconfig:"OIDC_UI_PORT"` + UILoginPath string `envconfig:"OIDC_UI_LOGIN_PATH"` + UIErrorPath string `envconfig:"OIDC_UI_ERROR_PATH"` + + AuthCodeLifetimeSecs int64 `envconfig:"OAUTH_AUTH_CODE_LIFETIME_SECONDS"` + PARExpirySecs int64 `envconfig:"OAUTH_PAR_EXPIRY_SECONDS"` + AccessTokenLifetimeSecs int64 `envconfig:"OAUTH_ACCESS_TOKEN_LIFETIME_SECONDS"` } // ApplyEnvOverrides overlays environment and application settings onto cfg. // Env vars take precedence over values from app.yaml. func ApplyEnvOverrides(cfg *AppConfig) error { - if v := os.Getenv("OIDC_UI_SCHEME"); v != "" { - cfg.GateClient.Scheme = v - } - if v := os.Getenv("OIDC_UI_HOSTNAME"); v != "" { - cfg.GateClient.Hostname = v - } - if v := os.Getenv("OIDC_UI_PORT"); v != "" { - port, err := strconv.Atoi(v) - if err != nil { - return fmt.Errorf("invalid OIDC_UI_PORT: %w", err) - } - if port < 1 || port > 65535 { - return fmt.Errorf("invalid OIDC_UI_PORT: port must be between 1 and 65535") - } - cfg.GateClient.Port = port - } - if v := os.Getenv("OIDC_UI_LOGIN_PATH"); v != "" { - cfg.GateClient.LoginPath = v - } - if v := os.Getenv("OIDC_UI_ERROR_PATH"); v != "" { - cfg.GateClient.ErrorPath = v + var s overrideSpec + if err := envconfig.Process("", &s); err != nil { + return fmt.Errorf("loading env overrides: %w", err) } - if v := os.Getenv("OAUTH_AUTH_CODE_LIFETIME_SECONDS"); v != "" { - secs, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return fmt.Errorf("invalid OAUTH_AUTH_CODE_LIFETIME_SECONDS: %w", err) - } - if secs > 0 { - cfg.OAuth.AuthorizationCode.ValidityPeriod = secs - } + if s.UIScheme != "" { + cfg.GateClient.Scheme = s.UIScheme } - if v := os.Getenv("OAUTH_PAR_EXPIRY_SECONDS"); v != "" { - secs, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return fmt.Errorf("invalid OAUTH_PAR_EXPIRY_SECONDS: %w", err) - } - if secs > 0 { - cfg.OAuth.PAR.ExpiresIn = secs - } + if s.UIHostname != "" { + cfg.GateClient.Hostname = s.UIHostname } - if v := os.Getenv("OAUTH_ACCESS_TOKEN_LIFETIME_SECONDS"); v != "" { - secs, err := strconv.ParseInt(v, 10, 64) - if err != nil { - return fmt.Errorf("invalid OAUTH_ACCESS_TOKEN_LIFETIME_SECONDS: %w", err) - } - if secs > 0 { - cfg.JWT.ValidityPeriod = secs + if s.UIPort != 0 { + if s.UIPort < 1 || s.UIPort > 65535 { + return errors.New("invalid OIDC_UI_PORT: port must be between 1 and 65535") } + cfg.GateClient.Port = s.UIPort } - return nil -} - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value + if s.UILoginPath != "" { + cfg.GateClient.LoginPath = s.UILoginPath + } + if s.UIErrorPath != "" { + cfg.GateClient.ErrorPath = s.UIErrorPath } - return fallback -} -func envIntOrDefault(key string, fallback int) int { - raw := os.Getenv(key) - if raw == "" { - return fallback + if s.AuthCodeLifetimeSecs > 0 { + cfg.OAuth.AuthorizationCode.ValidityPeriod = s.AuthCodeLifetimeSecs } - n, err := strconv.Atoi(raw) - if err != nil { - return fallback + if s.PARExpirySecs > 0 { + cfg.OAuth.PAR.ExpiresIn = s.PARExpirySecs } - return n -} - -func envBool(key string) bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { - case "1", "true", "yes", "on": - return true - default: - return false + if s.AccessTokenLifetimeSecs > 0 { + cfg.JWT.ValidityPeriod = s.AccessTokenLifetimeSecs } + return nil } diff --git a/esignet-service/internal/config/db.go b/esignet-service/internal/config/db.go index 11d5dab0c..47e7a5b5a 100644 --- a/esignet-service/internal/config/db.go +++ b/esignet-service/internal/config/db.go @@ -8,20 +8,11 @@ import ( "strings" "time" + "github.com/kelseyhightower/envconfig" _ "github.com/lib/pq" // PostgreSQL driver for database/sql ) -const ( - defaultDBHost = "localhost" - defaultDBPort = "5455" - defaultDBName = "mosip_esignet" - defaultDBUser = "postgres" - defaultDBMaxOpenConns = 25 - defaultDBMaxIdleConns = 5 - defaultDBConnMaxLifetimeSecs = 0 // no limit - defaultDBConnMaxIdleTimeSecs = 60 - dbPingTimeout = 5 * time.Second -) +const dbPingTimeout = 5 * time.Second // DBPool holds connection pool tuning parameters. type DBPool struct { @@ -47,90 +38,80 @@ func hasDBEnvConfig() bool { os.Getenv("DB_DBUSER_PASSWORD") != "" } -func ensurePostgresSSLMode(dsn string) string { - if (strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://")) && - !strings.Contains(strings.ToLower(dsn), "sslmode=") { - sep := "?" - if strings.Contains(dsn, "?") { - sep = "&" - } - return dsn + sep + "sslmode=disable" - } - return dsn -} +// dbSpec is the environment-variable layout for Postgres settings. The +// individual connection params are only used to build the DSN when DATABASE_URL +// is empty, but always carry their defaults. +type dbSpec struct { + URL string `envconfig:"DATABASE_URL"` + Host string `envconfig:"DATABASE_HOST" default:"localhost"` + Port string `envconfig:"DATABASE_PORT" default:"5455"` + Name string `envconfig:"DATABASE_NAME" default:"mosip_esignet"` + User string `envconfig:"DATABASE_USERNAME" default:"postgres"` + Password string `envconfig:"DATABASE_PASSWORD"` + // PasswordAlt is the MOSIP deployment's name for the DB password, + // used when DATABASE_PASSWORD is unset. + PasswordAlt string `envconfig:"DB_DBUSER_PASSWORD"` -func resolveDBDSN() string { - if dsn := os.Getenv("DATABASE_URL"); dsn != "" { - return ensurePostgresSSLMode(dsn) - } - - host := envOrDefault("DATABASE_HOST", defaultDBHost) - port := envOrDefault("DATABASE_PORT", defaultDBPort) - dbname := envOrDefault("DATABASE_NAME", defaultDBName) - user := envOrDefault("DATABASE_USERNAME", defaultDBUser) - password := os.Getenv("DATABASE_PASSWORD") - if password == "" { - password = os.Getenv("DB_DBUSER_PASSWORD") - } - if password != "" { - return fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", - host, port, dbname, user, password, - ) - } - // Omit password= when unset — lib/pq mis-parses "password= sslmode=..." - // and falls back to SSL, which fails against local Docker Postgres. - return fmt.Sprintf( - "host=%s port=%s dbname=%s user=%s sslmode=disable", - host, port, dbname, user, - ) + MaxOpenConns int `envconfig:"DB_MAX_OPEN_CONNS" default:"25"` + MaxIdleConns int `envconfig:"DB_MAX_IDLE_CONNS" default:"5"` + ConnMaxLifetime int `envconfig:"DB_CONN_MAX_LIFETIME_SECS"` // 0 = no limit + ConnMaxIdleTime int `envconfig:"DB_CONN_MAX_IDLE_TIME_SECS" default:"60"` } // loadDB reads Postgres connection config and pool settings from the environment. -// Accepts POSTGRES_URL or DATABASE_URL (full DSN), or individual vars: +// Accepts either DATABASE_URL (full DSN) or individual vars: // DATABASE_HOST, DATABASE_PORT, DATABASE_NAME, DATABASE_USERNAME, DATABASE_PASSWORD. // // Pool tuning (all optional): // -// DB_MAX_OPEN_CONNS — default 25 -// DB_MAX_IDLE_CONNS — default 5 -// DB_CONN_MAX_LIFETIME_SECS — default 300 +// DB_MAX_OPEN_CONNS — default 25 +// DB_MAX_IDLE_CONNS — default 5 +// DB_CONN_MAX_LIFETIME_SECS — default 0 (no limit) // DB_CONN_MAX_IDLE_TIME_SECS — default 60 -func loadDB() DB { - dsn := resolveDBDSN() - - maxOpen := envIntOrDefault("DB_MAX_OPEN_CONNS", defaultDBMaxOpenConns) - if maxOpen <= 0 { - maxOpen = defaultDBMaxOpenConns +func loadDB() (*DB, error) { + var s dbSpec + if err := envconfig.Process("", &s); err != nil { + return nil, fmt.Errorf("loading database config: %w", err) } - maxIdle := envIntOrDefault("DB_MAX_IDLE_CONNS", defaultDBMaxIdleConns) - if maxIdle <= 0 { - maxIdle = defaultDBMaxIdleConns + if s.Password == "" { + s.Password = s.PasswordAlt } - lifetimeSecs := envIntOrDefault("DB_CONN_MAX_LIFETIME_SECS", defaultDBConnMaxLifetimeSecs) - var lifetime time.Duration - if lifetimeSecs > 0 { - lifetime = time.Duration(lifetimeSecs) * time.Second - } else { - lifetime = defaultDBConnMaxLifetimeSecs + + dsn := s.URL + if dsn != "" && + (strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://")) && + !strings.Contains(strings.ToLower(dsn), "sslmode=") { + sep := "?" + if strings.Contains(dsn, "?") { + sep = "&" + } + dsn += sep + "sslmode=disable" } - idleSecs := envIntOrDefault("DB_CONN_MAX_IDLE_TIME_SECS", defaultDBConnMaxIdleTimeSecs) - var idleTime time.Duration - if idleSecs > 0 { - idleTime = time.Duration(idleSecs) * time.Second - } else { - idleTime = defaultDBConnMaxIdleTimeSecs + if dsn == "" { + if s.Password != "" { + dsn = fmt.Sprintf( + "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", + s.Host, s.Port, s.Name, s.User, s.Password, + ) + } else { + // Omit password= when unset — lib/pq mis-parses "password= sslmode=..." + // and falls back to SSL, which fails against local Docker Postgres. + dsn = fmt.Sprintf( + "host=%s port=%s dbname=%s user=%s sslmode=disable", + s.Host, s.Port, s.Name, s.User, + ) + } } - return DB{ + return &DB{ DSN: dsn, Pool: DBPool{ - MaxOpenConns: maxOpen, - MaxIdleConns: maxIdle, - ConnMaxLifetime: lifetime, - ConnMaxIdleTime: idleTime, + MaxOpenConns: s.MaxOpenConns, + MaxIdleConns: s.MaxIdleConns, + ConnMaxLifetime: time.Duration(s.ConnMaxLifetime) * time.Second, + ConnMaxIdleTime: time.Duration(s.ConnMaxIdleTime) * time.Second, }, - } + }, nil } // Open opens, configures the pool, and pings the Postgres connection. diff --git a/esignet-service/internal/config/redis.go b/esignet-service/internal/config/redis.go index 405617d3c..9c57380c7 100644 --- a/esignet-service/internal/config/redis.go +++ b/esignet-service/internal/config/redis.go @@ -7,23 +7,11 @@ import ( "strings" "time" + "github.com/kelseyhightower/envconfig" "github.com/redis/go-redis/v9" ) -const ( - defaultRedisHost = "localhost" - defaultRedisPort = "6379" - defaultRedisPoolSize = 10 - defaultRedisMinIdleConns = 2 - defaultRedisConnMaxIdleTime = 300 - defaultRedisDialTimeoutSecs = 5 - defaultRedisReadTimeoutSecs = 3 - defaultRedisWriteTimeoutSecs = 3 - defaultRedisConnMaxLifetimeSecs = 0 // no limit - defaultRedisKeyPrefix = "esignet:" - redisPingTimeout = 5 * time.Second - defaultRedisDB = 0 -) +const redisPingTimeout = 5 * time.Second // Redis holds all settings needed to open and configure a Redis client. // @@ -70,81 +58,73 @@ type Redis struct { SentinelAddrs []string } -func loadRedis() Redis { - poolSize := envIntOrDefault("REDIS_POOL_SIZE", defaultRedisPoolSize) - if poolSize <= 0 { - poolSize = defaultRedisPoolSize - } - minIdle := envIntOrDefault("REDIS_MIN_IDLE_CONNS", defaultRedisMinIdleConns) - if minIdle <= 0 { - minIdle = defaultRedisMinIdleConns - } - - idleSecs := envIntOrDefault("REDIS_CONN_MAX_IDLE_TIME_SECS", defaultRedisConnMaxIdleTime) - var idleTime time.Duration - if idleSecs > 0 { - idleTime = time.Duration(idleSecs) * time.Second - } else { - idleTime = defaultRedisConnMaxIdleTime - } - - lifetimeSecs := envIntOrDefault("REDIS_CONN_MAX_LIFETIME_SECS", defaultRedisConnMaxLifetimeSecs) - lifetime := time.Duration(lifetimeSecs) * time.Second // 0 = no limit - - dialSecs := envIntOrDefault("REDIS_DIAL_TIMEOUT_SECS", defaultRedisDialTimeoutSecs) - var dialTimeout time.Duration - if dialSecs > 0 { - dialTimeout = time.Duration(dialSecs) * time.Second - } else { - dialTimeout = time.Duration(defaultRedisDialTimeoutSecs) * time.Second - } +// redisSpec is the environment-variable layout for Redis settings. +// SentinelAddrs is parsed as a raw string so the historical trim/drop-empty +// splitting is preserved in loadRedis. +type redisSpec struct { + URL string `envconfig:"REDIS_URL"` + Host string `envconfig:"REDIS_HOST" default:"localhost"` + Port string `envconfig:"REDIS_PORT" default:"6379"` + Password string `envconfig:"REDIS_PASSWORD"` + DB int `envconfig:"REDIS_DB"` + TLS bool `envconfig:"REDIS_TLS_ENABLED"` + + PoolSize int `envconfig:"REDIS_POOL_SIZE" default:"10"` + MinIdleConns int `envconfig:"REDIS_MIN_IDLE_CONNS" default:"2"` + ConnMaxIdleTime int `envconfig:"REDIS_CONN_MAX_IDLE_TIME_SECS" default:"300"` + ConnMaxLifetime int `envconfig:"REDIS_CONN_MAX_LIFETIME_SECS"` // 0 = no limit + DialTimeout int `envconfig:"REDIS_DIAL_TIMEOUT_SECS" default:"5"` + ReadTimeout int `envconfig:"REDIS_READ_TIMEOUT_SECS" default:"3"` + WriteTimeout int `envconfig:"REDIS_WRITE_TIMEOUT_SECS" default:"3"` + + KeyPrefix string `envconfig:"REDIS_KEY_PREFIX" default:"esignet:"` + + SentinelMaster string `envconfig:"REDIS_SENTINEL_MASTER"` + SentinelAddrs string `envconfig:"REDIS_SENTINEL_ADDRS"` +} - readSecs := envIntOrDefault("REDIS_READ_TIMEOUT_SECS", defaultRedisReadTimeoutSecs) - var readTimeout time.Duration - if readSecs > 0 { - readTimeout = time.Duration(readSecs) * time.Second - } else { - readTimeout = time.Duration(defaultRedisReadTimeoutSecs) * time.Second +// loadRedis reads Redis settings from environment variables. It returns nil on +// error so a failed load can never be mistaken for a usable zero-value config. +func loadRedis() (*Redis, error) { + var s redisSpec + if err := envconfig.Process("", &s); err != nil { + return nil, fmt.Errorf("loading redis config: %w", err) } - writeSecs := envIntOrDefault("REDIS_WRITE_TIMEOUT_SECS", defaultRedisWriteTimeoutSecs) - var writeTimeout time.Duration - if writeSecs > 0 { - writeTimeout = time.Duration(writeSecs) * time.Second - } else { - writeTimeout = time.Duration(defaultRedisWriteTimeoutSecs) * time.Second + // A non-negative lifetime is required; 0 means "no limit". A negative value + // is rejected rather than silently clamped so a misconfiguration fails loudly. + if s.ConnMaxLifetime < 0 { + return nil, fmt.Errorf("loading redis config: REDIS_CONN_MAX_LIFETIME_SECS must not be negative, got %d", s.ConnMaxLifetime) } + lifetime := time.Duration(s.ConnMaxLifetime) * time.Second // 0 = no limit - keyPrefix := envOrDefault("REDIS_KEY_PREFIX", defaultRedisKeyPrefix) - - sentinelAddrsRaw := envOrDefault("REDIS_SENTINEL_ADDRS", "") var sentinelAddrs []string - if sentinelAddrsRaw != "" { - for _, a := range strings.Split(sentinelAddrsRaw, ",") { + if s.SentinelAddrs != "" { + for _, a := range strings.Split(s.SentinelAddrs, ",") { if a = strings.TrimSpace(a); a != "" { sentinelAddrs = append(sentinelAddrs, a) } } } - return Redis{ - URL: envOrDefault("REDIS_URL", ""), - Host: envOrDefault("REDIS_HOST", defaultRedisHost), - Port: envOrDefault("REDIS_PORT", defaultRedisPort), - Password: envOrDefault("REDIS_PASSWORD", ""), - DB: envIntOrDefault("REDIS_DB", defaultRedisDB), - TLS: envBool("REDIS_TLS_ENABLED"), - PoolSize: poolSize, - MinIdleConns: minIdle, - ConnMaxIdleTime: idleTime, + return &Redis{ + URL: s.URL, + Host: s.Host, + Port: s.Port, + Password: s.Password, + DB: s.DB, + TLS: s.TLS, + PoolSize: s.PoolSize, + MinIdleConns: s.MinIdleConns, + ConnMaxIdleTime: time.Duration(s.ConnMaxIdleTime) * time.Second, ConnMaxLifetime: lifetime, - DialTimeout: dialTimeout, - ReadTimeout: readTimeout, - WriteTimeout: writeTimeout, - KeyPrefix: keyPrefix, - SentinelMaster: envOrDefault("REDIS_SENTINEL_MASTER", ""), + DialTimeout: time.Duration(s.DialTimeout) * time.Second, + ReadTimeout: time.Duration(s.ReadTimeout) * time.Second, + WriteTimeout: time.Duration(s.WriteTimeout) * time.Second, + KeyPrefix: s.KeyPrefix, + SentinelMaster: s.SentinelMaster, SentinelAddrs: sentinelAddrs, - } + }, nil } // Open creates a Redis client, applies pool/timeout settings, and pings the server. diff --git a/esignet-service/internal/config/redis_test.go b/esignet-service/internal/config/redis_test.go new file mode 100644 index 000000000..9b9ce4c5f --- /dev/null +++ b/esignet-service/internal/config/redis_test.go @@ -0,0 +1,122 @@ +package config + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +// redisEnvKeys is every variable loadRedis reads; tests clear them all so a +// stray value in the ambient environment cannot mask a default. +var redisEnvKeys = []string{ + "REDIS_URL", "REDIS_HOST", "REDIS_PORT", "REDIS_PASSWORD", "REDIS_DB", + "REDIS_TLS_ENABLED", "REDIS_POOL_SIZE", "REDIS_MIN_IDLE_CONNS", + "REDIS_CONN_MAX_IDLE_TIME_SECS", "REDIS_CONN_MAX_LIFETIME_SECS", + "REDIS_DIAL_TIMEOUT_SECS", "REDIS_READ_TIMEOUT_SECS", "REDIS_WRITE_TIMEOUT_SECS", + "REDIS_KEY_PREFIX", "REDIS_SENTINEL_MASTER", "REDIS_SENTINEL_ADDRS", +} + +// clearRedisEnv unsets every Redis variable so envconfig's `default` tags apply. +// t.Setenv first registers restoration of any ambient value at test cleanup; +// os.Unsetenv then removes it for the duration of the test (an empty string +// would count as "set" and suppress the default). +func clearRedisEnv(t *testing.T) { + t.Helper() + for _, k := range redisEnvKeys { + t.Setenv(k, "") + require.NoError(t, os.Unsetenv(k)) + } +} + +func TestLoadRedis_defaults(t *testing.T) { + clearRedisEnv(t) + + cfg, err := loadRedis() + require.NoError(t, err) + require.Empty(t, cfg.URL) + require.Equal(t, "localhost", cfg.Host) + require.Equal(t, "6379", cfg.Port) + require.Empty(t, cfg.Password) + require.Equal(t, 0, cfg.DB) + require.False(t, cfg.TLS) + require.Equal(t, 10, cfg.PoolSize) + require.Equal(t, 2, cfg.MinIdleConns) + require.Equal(t, 5*time.Minute, cfg.ConnMaxIdleTime) + require.Equal(t, time.Duration(0), cfg.ConnMaxLifetime) // 0 = no limit + require.Equal(t, 5*time.Second, cfg.DialTimeout) + require.Equal(t, 3*time.Second, cfg.ReadTimeout) + require.Equal(t, 3*time.Second, cfg.WriteTimeout) + require.Equal(t, "esignet:", cfg.KeyPrefix) + require.Empty(t, cfg.SentinelMaster) + require.Nil(t, cfg.SentinelAddrs) +} + +func TestLoadRedis_overrides(t *testing.T) { + clearRedisEnv(t) + t.Setenv("REDIS_URL", "redis://:secret@cache:6380/2") + t.Setenv("REDIS_HOST", "cache") + t.Setenv("REDIS_PORT", "6380") + t.Setenv("REDIS_PASSWORD", "secret") + t.Setenv("REDIS_DB", "2") + t.Setenv("REDIS_TLS_ENABLED", "true") + t.Setenv("REDIS_POOL_SIZE", "20") + t.Setenv("REDIS_MIN_IDLE_CONNS", "4") + t.Setenv("REDIS_CONN_MAX_IDLE_TIME_SECS", "120") + t.Setenv("REDIS_CONN_MAX_LIFETIME_SECS", "600") + t.Setenv("REDIS_DIAL_TIMEOUT_SECS", "7") + t.Setenv("REDIS_READ_TIMEOUT_SECS", "8") + t.Setenv("REDIS_WRITE_TIMEOUT_SECS", "9") + t.Setenv("REDIS_KEY_PREFIX", "test:") + t.Setenv("REDIS_SENTINEL_MASTER", "mymaster") + + cfg, err := loadRedis() + require.NoError(t, err) + require.Equal(t, "redis://:secret@cache:6380/2", cfg.URL) + require.Equal(t, "cache", cfg.Host) + require.Equal(t, "6380", cfg.Port) + require.Equal(t, "secret", cfg.Password) + require.Equal(t, 2, cfg.DB) + require.True(t, cfg.TLS) + require.Equal(t, 20, cfg.PoolSize) + require.Equal(t, 4, cfg.MinIdleConns) + // Each *_SECS value lands on its own distinct field — guards against a + // swapped read/write/dial timeout mapping. + require.Equal(t, 120*time.Second, cfg.ConnMaxIdleTime) + require.Equal(t, 600*time.Second, cfg.ConnMaxLifetime) + require.Equal(t, 7*time.Second, cfg.DialTimeout) + require.Equal(t, 8*time.Second, cfg.ReadTimeout) + require.Equal(t, 9*time.Second, cfg.WriteTimeout) + require.Equal(t, "test:", cfg.KeyPrefix) + require.Equal(t, "mymaster", cfg.SentinelMaster) +} + +func TestLoadRedis_sentinelAddrsTrimAndDropEmpty(t *testing.T) { + clearRedisEnv(t) + t.Setenv("REDIS_SENTINEL_ADDRS", "a:26379, b:26379 ,, c:26379") + + cfg, err := loadRedis() + require.NoError(t, err) + require.Equal(t, []string{"a:26379", "b:26379", "c:26379"}, cfg.SentinelAddrs) +} + +// An unparseable numeric value now fails loudly rather than silently falling +// back to a default. +func TestLoadRedis_invalidNumberReturnsError(t *testing.T) { + clearRedisEnv(t) + t.Setenv("REDIS_POOL_SIZE", "not-a-number") + + _, err := loadRedis() + require.Error(t, err) +} + +// A negative max-lifetime parses as a valid integer but is not a meaningful +// duration, so loadRedis now rejects it rather than silently clamping to 0. +func TestLoadRedis_negativeLifetimeReturnsError(t *testing.T) { + clearRedisEnv(t) + t.Setenv("REDIS_CONN_MAX_LIFETIME_SECS", "-1") + + _, err := loadRedis() + require.Error(t, err) +} diff --git a/esignet-service/internal/engine/mosip/config.go b/esignet-service/internal/engine/mosip/config.go index 443a878b5..1f56cef06 100644 --- a/esignet-service/internal/engine/mosip/config.go +++ b/esignet-service/internal/engine/mosip/config.go @@ -1,9 +1,11 @@ // Package mosip provides MOSIP IDA authentication and OTP executors for the embedder. package mosip -import "os" +import ( + "fmt" -const defaultMosipEnv = "Staging" + "github.com/kelseyhightower/envconfig" +) // Config holds MOSIP IDA integration settings. type Config struct { @@ -18,41 +20,73 @@ type Config struct { P12Password string } +// mosipSpec is the environment-variable layout for MOSIP IDA settings. +// +// The P12 keystore is required: the provider cannot sign any IDA request +// without it, so a missing value fails startup (this spec is only processed +// when AUTHN_PROVIDER=mosip). +// +// LicenseKey and APIInternalHost are optional: they are only used to derive the +// endpoint URLs when those are not supplied individually. A deployment that sets +// every *_IDA_*_URL explicitly never reads them, so they carry no required tag. +type mosipSpec struct { + LicenseKey string `envconfig:"MOSIP_ESIGNET_MISP_KEY"` + APIInternalHost string `envconfig:"MOSIP_API_INTERNAL_HOST"` + IDAPartnerCertURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_IDA_CERT_URL"` + SendOTPBaseURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_IDA_SEND_OTP_URL"` + KYCAuthBaseURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_IDA_KYC_AUTH_URL"` + KYCExchangeBaseURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_IDA_KYC_EXCHANGE_URL"` + DomainURI string `envconfig:"MOSIP_ESIGNET_DOMAIN_URL"` + Env string `envconfig:"IDA_AUTHENTICATOR_ENV" default:"Staging"` + P12Path string `envconfig:"MOSIP_P12_PATH" required:"true"` + P12Password string `envconfig:"MOSIP_P12_PASSWORD" required:"true"` +} + // LoadConfig reads MOSIP auth settings from environment variables. -func LoadConfig() Config { - licenseKey := envOrDefault("MOSIP_ESIGNET_MISP_KEY", "") - apiBase := trimTrailingSlash(envOrDefault("MOSIP_API_INTERNAL_HOST", "")) - - return Config{ - LicenseKey: licenseKey, - IDAPartnerCertificateURL: envOrDefault( - "MOSIP_ESIGNET_AUTHENTICATOR_IDA_CERT_URL", - apiBase+"/mosip-certs/ida-partner.cer", - ), - SendOTPBaseURL: envOrDefault( - "MOSIP_ESIGNET_AUTHENTICATOR_IDA_SEND_OTP_URL", - apiBase+"/idauthentication/v1/otp/"+licenseKey+"/", - ), - KYCAuthBaseURL: envOrDefault( - "MOSIP_ESIGNET_AUTHENTICATOR_IDA_KYC_AUTH_URL", - apiBase+"/idauthentication/v1/kyc-auth/delegated/"+licenseKey+"/", - ), - KYCExchangeBaseURL: envOrDefault( - "MOSIP_ESIGNET_AUTHENTICATOR_IDA_KYC_EXCHANGE_URL", - apiBase+"/idauthentication/v1/kyc-exchange/delegated/"+licenseKey+"/", - ), - DomainURI: envOrDefault("MOSIP_ESIGNET_DOMAIN_URL", apiBase), - Env: envOrDefault("IDA_AUTHENTICATOR_ENV", defaultMosipEnv), - P12Path: os.Getenv("MOSIP_P12_PATH"), - P12Password: os.Getenv("MOSIP_P12_PASSWORD"), +// +// The IDA endpoint URLs and the domain URI default to values derived from +// MOSIP_API_INTERNAL_HOST (and the license key) when not overridden explicitly. +func LoadConfig() (*Config, error) { + var s mosipSpec + if err := envconfig.Process("", &s); err != nil { + return nil, fmt.Errorf("loading mosip config: %w", err) } -} -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value + licenseKey := s.LicenseKey + apiBase := trimTrailingSlash(s.APIInternalHost) + + certURL := s.IDAPartnerCertURL + if certURL == "" { + certURL = apiBase + "/mosip-certs/ida-partner.cer" + } + sendOTP := s.SendOTPBaseURL + if sendOTP == "" { + sendOTP = apiBase + "/idauthentication/v1/otp/" + licenseKey + "/" } - return fallback + kycAuth := s.KYCAuthBaseURL + if kycAuth == "" { + kycAuth = apiBase + "/idauthentication/v1/kyc-auth/delegated/" + licenseKey + "/" + } + kycExchange := s.KYCExchangeBaseURL + if kycExchange == "" { + kycExchange = apiBase + "/idauthentication/v1/kyc-exchange/delegated/" + licenseKey + "/" + } + domainURI := s.DomainURI + if domainURI == "" { + domainURI = apiBase + } + + return &Config{ + LicenseKey: licenseKey, + IDAPartnerCertificateURL: certURL, + SendOTPBaseURL: sendOTP, + KYCAuthBaseURL: kycAuth, + KYCExchangeBaseURL: kycExchange, + DomainURI: domainURI, + Env: s.Env, + P12Path: s.P12Path, + P12Password: s.P12Password, + }, nil } func trimTrailingSlash(value string) string { diff --git a/esignet-service/internal/engine/mosip/mosip_authn.go b/esignet-service/internal/engine/mosip/mosip_authn.go index 3765e715a..6b63a1046 100644 --- a/esignet-service/internal/engine/mosip/mosip_authn.go +++ b/esignet-service/internal/engine/mosip/mosip_authn.go @@ -54,11 +54,15 @@ type mosipAuthnProvider struct { // NewMosipAuthnProvider creates a MOSIP providers.AuthnProviderManager with OTP send support. func NewMosipAuthnProvider(cfg *config.AppConfig, clientSvc *clientmgmt.Service) (shared.ConsolidatedAuthnProvider, error) { + mosipCfg, err := LoadConfig() + if err != nil { + return nil, err + } provider := &mosipAuthnProvider{ appConfig: cfg, client: newHTTPClient(), clientSvc: clientSvc, - cfg: LoadConfig(), + cfg: *mosipCfg, } return provider, nil } @@ -890,13 +894,8 @@ func buildIDAEndpointURL(baseURL, relyingPartyID, clientID string) (string, erro } func (p *mosipAuthnProvider) getRequestSignature(requestBody []byte) (string, error) { - if p.cfg.P12Path == "" { - return "", errors.New("MOSIP_P12_PATH is not configured") - } - if p.cfg.P12Password == "" { - return "", errors.New("MOSIP_P12_PASSWORD is not configured") - } - + // P12Path and P12Password are marked required:"true" in mosipSpec, so the + // config layer guarantees they are set before the provider is constructed. encodedRequestBody := B64EncodeBytes(requestBody) privateKey, signedCertificate, err := LoadRSAPrivateKeyAndCertFromP12(p.cfg.P12Path, p.cfg.P12Password) diff --git a/esignet-service/internal/engine/sunbird/config.go b/esignet-service/internal/engine/sunbird/config.go index c92273687..82b23b04c 100644 --- a/esignet-service/internal/engine/sunbird/config.go +++ b/esignet-service/internal/engine/sunbird/config.go @@ -3,33 +3,10 @@ package sunbird import ( "errors" - "os" - "strconv" + "fmt" "strings" -) - -// SunbirdRC env var names. -const ( - envSunbirdIDField = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_INDIVIDUAL_ID_FIELD" - envSunbirdFieldDetails = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_FIELD_DETAILS" - envSunbirdSearchURL = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_REGISTRY_SEARCH_URL" - envSunbirdEntityIDField = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_KBI_ENTITY_ID_FIELD" - envSunbirdClaimsMapping = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_IDENTITY_OPENID_CLAIMS_MAPPING" - envSunbirdEntityURL = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_REGISTRY_GET_URL" - envSunbirdTimeout = "MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_REQUEST_TIMEOUT_SECS" -) -const ( - defaultSunbirdIDField = "policyNumber" - defaultSunbirdEntityIDField = "osid" - defaultSunbirdTimeoutSecs = 10 - - defaultSunbirdFieldDetails = `[{"id":"policyNumber","type":"text","format":""},` + - `{"id":"fullName","type":"text","format":""},` + - `{"id":"dob","type":"date","format":"dd/mm/yyyy"}]` - - defaultSunbirdClaimsMapping = `{"name":"fullName","email":"email",` + - `"phone_number":"mobile","gender":"gender","birthdate":"dob"}` + "github.com/kelseyhightower/envconfig" ) // Config holds SunbirdRC registry (KBI) integration settings. @@ -43,34 +20,54 @@ type Config struct { TimeoutSecs int } -// LoadConfig reads SunbirdRC auth settings from environment variables. +// sunbirdSpec is the environment-variable layout for SunbirdRC settings. +// +// Defaults are declared in envconfig `default` tags and reflect the released +// MOSIP Insurance registry conventions. The field-details / claims-mapping +// defaults are JSON blobs, so the embedded double quotes are backslash-escaped +// to survive struct-tag parsing. +type sunbirdSpec struct { + SearchURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_REGISTRY_SEARCH_URL"` + EntityURL string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_REGISTRY_GET_URL"` + IDField string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_INDIVIDUAL_ID_FIELD" default:"policyNumber"` + EntityIDField string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_KBI_ENTITY_ID_FIELD" default:"osid"` + FieldDetails string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_AUTH_FACTOR_KBI_FIELD_DETAILS" default:"[{\"id\":\"policyNumber\",\"type\":\"text\",\"format\":\"\"},{\"id\":\"fullName\",\"type\":\"text\",\"format\":\"\"},{\"id\":\"dob\",\"type\":\"date\",\"format\":\"dd/mm/yyyy\"}]"` + ClaimsMapping string `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_IDENTITY_OPENID_CLAIMS_MAPPING" default:"{\"name\":\"fullName\",\"email\":\"email\",\"phone_number\":\"mobile\",\"gender\":\"gender\",\"birthdate\":\"dob\"}"` + Timeout int `envconfig:"MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_REQUEST_TIMEOUT_SECS" default:"10"` +} + +// LoadConfig reads SunbirdRC auth settings from environment variables. It +// returns nil on error so a failed load can never be mistaken for a usable +// zero-value config. // // SearchURL has no default and must be supplied for the sunbird provider to -// start. The remaining fields default to the released MOSIP Insurance registry -// conventions. -func LoadConfig() Config { - timeoutSecs := defaultSunbirdTimeoutSecs - if raw := os.Getenv(envSunbirdTimeout); raw != "" { - if secs, err := strconv.Atoi(raw); err == nil && secs > 0 { - timeoutSecs = secs - } +// start (enforced by Validate). The remaining fields fall back to their +// envconfig `default` tags. +func LoadConfig() (*Config, error) { + var s sunbirdSpec + if err := envconfig.Process("", &s); err != nil { + return nil, fmt.Errorf("loading sunbird config: %w", err) } - return Config{ - SearchURL: strings.TrimSpace(envOrDefault(envSunbirdSearchURL, "")), - EntityURL: trimTrailingSlash(envOrDefault(envSunbirdEntityURL, "")), - IDField: envOrDefault(envSunbirdIDField, defaultSunbirdIDField), - EntityIDField: envOrDefault(envSunbirdEntityIDField, defaultSunbirdEntityIDField), - FieldDetails: envOrDefault(envSunbirdFieldDetails, defaultSunbirdFieldDetails), - ClaimsMapping: envOrDefault(envSunbirdClaimsMapping, defaultSunbirdClaimsMapping), - TimeoutSecs: timeoutSecs, + if s.Timeout <= 0 { + return nil, fmt.Errorf("loading sunbird config: MOSIP_ESIGNET_AUTHENTICATOR_SUNBIRD_RC_REQUEST_TIMEOUT_SECS must be positive, got %d", s.Timeout) } + + return &Config{ + SearchURL: strings.TrimSpace(s.SearchURL), + EntityURL: trimTrailingSlash(strings.TrimSpace(s.EntityURL)), + IDField: strings.TrimSpace(s.IDField), + EntityIDField: strings.TrimSpace(s.EntityIDField), + FieldDetails: strings.TrimSpace(s.FieldDetails), + ClaimsMapping: strings.TrimSpace(s.ClaimsMapping), + TimeoutSecs: s.Timeout, + }, nil } // Validate reports whether the SunbirdRC settings are usable by the provider. // -// It is called at provider construction rather than in LoadSunbirdAuthn, which -// runs unconditionally for every provider; failing there would also break the +// It is called at provider construction rather than in LoadConfig, which runs +// unconditionally for every provider; failing there would also break the // catalog/mosip providers. Gating here means a missing SearchURL only fails when // AUTHN_PROVIDER=sunbird. func (c Config) Validate() error { @@ -86,13 +83,6 @@ func (c Config) Validate() error { return nil } -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} - func trimTrailingSlash(value string) string { for len(value) > 0 && value[len(value)-1] == '/' { value = value[:len(value)-1] diff --git a/esignet-service/internal/engine/sunbird/sunbird_authn.go b/esignet-service/internal/engine/sunbird/sunbird_authn.go index a985ada9e..1b024de71 100644 --- a/esignet-service/internal/engine/sunbird/sunbird_authn.go +++ b/esignet-service/internal/engine/sunbird/sunbird_authn.go @@ -48,7 +48,10 @@ type sunbirdAuthnProvider struct { // It validates the config and parses the KBI field details once, returning an // error when SearchURL is unset or no KBI field other than IDField is configured. func NewSunbirdAuthnProvider() (shared.ConsolidatedAuthnProvider, error) { - cfg := LoadConfig() + cfg, err := LoadConfig() + if err != nil { + return nil, err + } if err := cfg.Validate(); err != nil { return nil, err } @@ -73,7 +76,7 @@ func NewSunbirdAuthnProvider() (shared.ConsolidatedAuthnProvider, error) { timeout = 10 * time.Second } return &sunbirdAuthnProvider{ - cfg: cfg, + cfg: *cfg, client: &http.Client{Timeout: timeout}, kbiFieldIDs: kbiFieldIDs, }, nil