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/cmd/esignet/main.go b/esignet-service/cmd/esignet/main.go index 0971e1da5..dad6a5e60 100644 --- a/esignet-service/cmd/esignet/main.go +++ b/esignet-service/cmd/esignet/main.go @@ -2,20 +2,28 @@ package main import ( - "context" + "fmt" "net/http" "github.com/thunder-id/thunderid/pkg/thunderidengine" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/mosip/esignet/internal/clientmgmt" "github.com/mosip/esignet/internal/config" "github.com/mosip/esignet/internal/engine" + "github.com/mosip/esignet/internal/engine/shared" applog "github.com/mosip/esignet/internal/log" ) func main() { logger := applog.GetLogger() - appCfg := config.LoadAppConfig() + appCfg, err := config.LoadAppConfig() + if err != nil { + logger.Fatal("failed to load app config", applog.Error(err)) + } + if err := config.ApplyEnvOverrides(appCfg); err != nil { + logger.Fatal("failed to apply env overrides", applog.Error(err)) + } pgConn, err := appCfg.DB.Open() if err != nil { @@ -63,48 +71,47 @@ func main() { }) clientHandler.RegisterRoutes(mux, scopeMW) - hostCfg := engine.LoadConfig() - thunderCfg, executors, err := engine.BuildThunderConfig(appCfg) - if err != nil { - logger.Fatal("build thunder config", applog.Error(err)) - } - certFile, keyFile, err := engine.PKIPaths(appCfg.DataDir) - if err != nil { - logger.Fatal("signing key paths", applog.Error(err)) - } - actorProvider := engine.NewActorProvider(clientSvc, hostCfg) - roleProvider := engine.NewRoleProvider() - logger.Info("authn provider selected", applog.String("provider", hostCfg.Provider)) - authnProvider, err := engine.NewAuthnProviderFromConfig(hostCfg.Provider, clientSvc) + authnProvider, err := engine.NewAuthnProvider(appCfg.Provider, appCfg, clientSvc) if err != nil { logger.Fatal("authn provider", applog.Error(err)) } + logger.Info("authn provider selected", applog.String("provider", appCfg.Provider)) - eng, err := thunderidengine.New( - thunderidengine.WithRedis(redisClient, appCfg.Redis.KeyPrefix), - thunderidengine.WithConfig(appCfg.DataDir, thunderCfg), - thunderidengine.WithPKIKey("default-key", certFile, keyFile), - thunderidengine.WithHostActorProvider(actorProvider), - thunderidengine.WithHostAuthnProvider(authnProvider), - thunderidengine.WithHostRoleProvider(roleProvider), - thunderidengine.WithExecutorDependencies(thunderidengine.ExecutorDependencies{ - ConsentEnforcer: engine.NewConsentEnforcer(), - }), - thunderidengine.WithEnabledExecutors(executors...), - thunderidengine.WithCustomExecutors(engine.CustomExecutors(authnProvider, hostCfg.Provider)), + _ = thunderidengine.New(mux, + thunderidengine.WithServerHome(appCfg.DataDir), + thunderidengine.WithServerConfig(appCfg.Server), + thunderidengine.WithCacheConfig(appCfg.Cache), + thunderidengine.WithOAuthConfig(appCfg.OAuth), + thunderidengine.WithJWTConfig(appCfg.JWT), + thunderidengine.WithFlowConfig(appCfg.Flow), + thunderidengine.WithObservabilityConfig(appCfg.Observability), + thunderidengine.WithActorProvider(engine.NewActorProvider(clientSvc, appCfg)), + thunderidengine.WithAuthnProvider(authnProvider), + thunderidengine.WithAuthorizationProvider(engine.NewAuthorizationProvider(appCfg)), + thunderidengine.WithConsentProvider(engine.NewConsentEnforcer()), + thunderidengine.WithDesignResolveProvider(engine.NewDesignProvider(appCfg)), + thunderidengine.WithFlowProvider(engine.NewFlowProvider(appCfg)), + thunderidengine.WithI18nProvider(engine.NewI18nProvider(appCfg)), + thunderidengine.WithOUProvider(engine.NewOUProvider(appCfg)), + thunderidengine.WithResourceProvider(engine.NewResourceProvider(appCfg)), + thunderidengine.WithObservabilityProvider(engine.NewObservabilityProvider(appCfg.Observability)), + thunderidengine.WithCustomExecutors(getCustomExecutors(authnProvider)), ) - if err != nil { - logger.Fatal("initialize engine", applog.Error(err)) - } - defer func() { _ = eng.Shutdown(context.Background()) }() - - if err := eng.RegisterRoutes(mux); err != nil { - logger.Fatal("register engine routes", applog.Error(err)) - } - addr := ":" + appCfg.Port + addr := fmt.Sprintf(":%d", appCfg.Port) logger.Info("server listening", applog.String("addr", addr), applog.String("issuer", appCfg.Issuer)) if err := http.ListenAndServe(addr, mux); err != nil { logger.Fatal("server", applog.Error(err)) } } + +// CustomExecutors returns embedder-supplied flow executors keyed by executor name. +func getCustomExecutors(authn providers.AuthnProviderManager) map[string]providers.Executor { + otpAuthn, ok := authn.(shared.ConsolidatedAuthnProvider) + if !ok { + return map[string]providers.Executor{} + } + return map[string]providers.Executor{ + engine.ExecutorNameMosipOTP: engine.NewMosipOtpExecutor(otpAuthn), + } +} diff --git a/esignet-service/data/config/resources/agents/agent-declarative-1.yaml b/esignet-service/data/config/resources/agents/agent-declarative-1.yaml deleted file mode 100644 index d97e11470..000000000 --- a/esignet-service/data/config/resources/agents/agent-declarative-1.yaml +++ /dev/null @@ -1,9 +0,0 @@ -id: decl-agent-1 -ouId: decl-ou-1 -type: default -name: Declarative Test Agent -description: A declarative test agent for integration testing -authFlowId: decl-flow-1 -attributes: - department: engineering - environment: test diff --git a/esignet-service/data/config/resources/flows/flow-declarative-1.yaml b/esignet-service/data/config/resources/flows/flow-declarative-1.yaml deleted file mode 100644 index 57c7eef09..000000000 --- a/esignet-service/data/config/resources/flows/flow-declarative-1.yaml +++ /dev/null @@ -1,50 +0,0 @@ -id: decl-flow-1 -handle: decl-flow-1 -name: Declarative Test Flow -flowType: AUTHENTICATION -activeVersion: 1 -nodes: - - id: start - type: START - onSuccess: prompt_credentials - - id: prompt_credentials - type: PROMPT - prompts: - - inputs: - - ref: input_001 - identifier: username - type: TEXT_INPUT - required: true - - ref: input_002 - identifier: password - type: PASSWORD_INPUT - required: true - action: - ref: action_001 - nextNode: basic_auth - - id: basic_auth - type: TASK_EXECUTION - executor: - name: CredentialsAuthExecutor - inputs: - - ref: input_001 - identifier: username - type: TEXT_INPUT - required: true - - ref: input_002 - identifier: password - type: PASSWORD_INPUT - required: true - onSuccess: authorization_check - - id: authorization_check - type: TASK_EXECUTION - executor: - name: AuthorizationExecutor - onSuccess: auth_assert - - id: auth_assert - type: TASK_EXECUTION - executor: - name: AuthAssertExecutor - onSuccess: end - - id: end - type: END diff --git a/esignet-service/data/config/resources/flows/flow-declarative-multi-flow-1.yaml b/esignet-service/data/config/resources/flows/flow-declarative-multi-flow-1.yaml deleted file mode 100644 index 82fabfdd6..000000000 --- a/esignet-service/data/config/resources/flows/flow-declarative-multi-flow-1.yaml +++ /dev/null @@ -1,824 +0,0 @@ -id: flow-declarative-multi-flow-1 -name: Default Basic Authentication Flow -handle: default-basic-multi-flow -flowType: AUTHENTICATION -nodes: - - id: start - type: START - layout: - size: - width: 101 - height: 34 - position: - x: 100 - y: 1050 - onSuccess: auth_factor - - - id: auth_factor - type: PROMPT - meta: - components: - - id: text_title - type: TEXT - label: Sign In - variant: HEADING_3 - category: DISPLAY - resourceType: ELEMENT - - id: block_auth_options - type: BLOCK - category: BLOCK - components: - - id: action_otp - type: ACTION - label: Verify with OTP - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - - id: action_password - type: ACTION - label: Verify with Password - variant: SECONDARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 280 - position: - x: 350 - y: 1000 - prompts: - - action: - ref: action_otp - nextNode: prompt_otp_phone - - action: - ref: action_password - nextNode: prompt_pwd_phone - - - id: prompt_otp_phone - type: PROMPT - meta: - components: - - id: block_otp_phone - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_phone - ref: username - hint: "" - type: PHONE_INPUT - label: Mobile Number - category: FIELD - required: true - placeholder: Enter your mobile number - postfix: "@phone" - prefixes: - - label: "+91" - value: "+91" - maxLength: 10 - regex: "^[0-9]{10}$" - - label: "+855" - value: "+855" - maxLength: 9 - regex: "^[0-9]{9}$" - validation: - - type: regex - value: "^[0-9]{10}$" - message: "{{i18n(validation:phone.pattern.invalid)}}" - - type: maxLength - value: 10 - message: "{{i18n(validation:phone.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: -100 - prompts: - - action: - ref: action_continue - nextNode: task_sendotp - inputs: - - ref: input_phone - type: PHONE_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[0-9]{10}$" - message: "{{i18n(validation:phone.pattern.invalid)}}" - - type: maxLength - value: 10 - message: "{{i18n(validation:phone.maxLength.invalid)}}" - - action: - ref: tab_email - nextNode: prompt_otp_email - inputs: [] - - action: - ref: tab_vid - nextNode: prompt_otp_vid - inputs: [] - - - id: prompt_otp_email - type: PROMPT - meta: - components: - - id: block_otp_email - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_email - ref: username - hint: "" - type: EMAIL_INPUT - label: Email Address - category: FIELD - required: true - placeholder: Enter your email address - postfix: "@thunder.sky" - validation: - - type: regex - value: "^[a-zA-Z0-9._%+\\-]+$" - message: "{{i18n(validation:email.pattern.invalid)}}" - - type: maxLength - value: 64 - message: "{{i18n(validation:email.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: 450 - prompts: - - action: - ref: tab_phone - nextNode: prompt_otp_phone - inputs: [] - - action: - ref: action_continue - nextNode: task_sendotp - inputs: - - ref: input_email - type: EMAIL_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[a-zA-Z0-9._%+\\-]+$" - message: "{{i18n(validation:email.pattern.invalid)}}" - - type: maxLength - value: 64 - message: "{{i18n(validation:email.maxLength.invalid)}}" - - action: - ref: tab_vid - nextNode: prompt_otp_vid - inputs: [] - - - id: prompt_otp_vid - type: PROMPT - meta: - components: - - id: block_otp_vid - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_vid - ref: username - hint: "" - type: TEXT_INPUT - label: VID - category: FIELD - required: true - inputType: text - placeholder: Enter your VID - validation: - - type: regex - value: "^[A-Z0-9]+$" - message: "{{i18n(validation:vid.pattern.invalid)}}" - - type: maxLength - value: 20 - message: "{{i18n(validation:vid.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: 1000 - prompts: - - action: - ref: tab_phone - nextNode: prompt_otp_phone - inputs: [] - - action: - ref: tab_email - nextNode: prompt_otp_email - inputs: [] - - action: - ref: action_continue - nextNode: task_sendotp - inputs: - - ref: input_vid - type: TEXT_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[A-Z0-9]+$" - message: "{{i18n(validation:vid.pattern.invalid)}}" - - type: maxLength - value: 20 - message: "{{i18n(validation:vid.maxLength.invalid)}}" - - - id: task_sendotp - type: TASK_EXECUTION - layout: - size: - width: 200 - height: 113 - position: - x: 1900 - y: 500 - executor: - name: MosipOtpExecutor - inputs: - - ref: username - type: TEXT_INPUT - required: true - identifier: username - onSuccess: prompt_enter_otp - - - id: prompt_enter_otp - type: PROMPT - meta: - components: - - id: text_otp_title - type: TEXT - label: Enter OTP - variant: HEADING_3 - category: DISPLAY - resourceType: ELEMENT - - id: block_enter_otp - type: BLOCK - category: BLOCK - components: - - id: input_otp - ref: otp - hint: "" - type: OTP_INPUT - label: Please enter the 6-digit OTP sent to your registered contact - category: FIELD - required: true - placeholder: "" - validation: - - type: regex - value: "^[0-9]{6}$" - message: "{{i18n(validation:otp.pattern.invalid)}}" - resourceType: ELEMENT - - id: action_verify - type: ACTION - label: Verify - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - - id: action_resend - type: ACTION - label: Resend OTP - variant: TEXT - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 480 - position: - x: 2350 - y: 450 - prompts: - - action: - ref: action_verify - nextNode: task_verify_otp - inputs: - - ref: input_otp - type: OTP_INPUT - required: true - identifier: otp - validation: - - type: regex - value: "^[0-9]{6}$" - message: "{{i18n(validation:otp.pattern.invalid)}}" - - action: - ref: action_resend - nextNode: task_sendotp - inputs: [] - - - id: task_verify_otp - type: TASK_EXECUTION - layout: - size: - width: 217 - height: 113 - position: - x: 2900 - y: 500 - executor: - name: CredentialsAuthExecutor - inputs: - - ref: username - type: TEXT_INPUT - required: true - identifier: username - - ref: input_otp - type: OTP_INPUT - required: true - identifier: otp - onSuccess: authorization_check - onFailure: prompt_enter_otp - onIncomplete: prompt_otp_phone - - - id: prompt_pwd_phone - type: PROMPT - meta: - components: - - id: block_pwd_phone - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_phone - ref: username - hint: "" - type: PHONE_INPUT - label: Mobile Number - category: FIELD - required: true - placeholder: Enter your mobile number - postfix: "@phone" - prefixes: - - label: "+91" - value: "+91" - maxLength: 10 - regex: "^[0-9]{10}$" - - label: "+855" - value: "+855" - maxLength: 9 - regex: "^[0-9]{9}$" - validation: - - type: regex - value: "^[0-9]{10}$" - message: "{{i18n(validation:phone.pattern.invalid)}}" - - type: maxLength - value: 10 - message: "{{i18n(validation:phone.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: 1600 - prompts: - - action: - ref: action_continue - nextNode: prompt_enter_pwd - inputs: - - ref: input_phone - type: PHONE_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[0-9]{10}$" - message: "{{i18n(validation:phone.pattern.invalid)}}" - - type: maxLength - value: 10 - message: "{{i18n(validation:phone.maxLength.invalid)}}" - - action: - ref: tab_email - nextNode: prompt_pwd_email - inputs: [] - - action: - ref: tab_vid - nextNode: prompt_pwd_vid - inputs: [] - - - id: prompt_pwd_email - type: PROMPT - meta: - components: - - id: block_pwd_email - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_email - ref: username - hint: "" - type: EMAIL_INPUT - label: Email Address - category: FIELD - required: true - placeholder: Enter your email address - postfix: "@thunder.sky" - validation: - - type: regex - value: "^[a-zA-Z0-9._%+\\-]+$" - message: "{{i18n(validation:email.pattern.invalid)}}" - - type: maxLength - value: 64 - message: "{{i18n(validation:email.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: 2150 - prompts: - - action: - ref: tab_phone - nextNode: prompt_pwd_phone - inputs: [] - - action: - ref: action_continue - nextNode: prompt_enter_pwd - inputs: - - ref: input_email - type: EMAIL_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[a-zA-Z0-9._%+\\-]+$" - message: "{{i18n(validation:email.pattern.invalid)}}" - - type: maxLength - value: 64 - message: "{{i18n(validation:email.maxLength.invalid)}}" - - action: - ref: tab_vid - nextNode: prompt_pwd_vid - inputs: [] - - - id: prompt_pwd_vid - type: PROMPT - meta: - components: - - id: block_pwd_vid - type: BLOCK - category: BLOCK - components: - - id: tab_phone - type: ACTION - label: Phone - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_email - type: ACTION - label: Email - variant: OUTLINED - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: tab_vid - type: ACTION - label: VID - variant: PRIMARY - category: ACTION - eventType: TRIGGER - resourceType: ELEMENT - - id: input_vid - ref: username - hint: "" - type: TEXT_INPUT - label: VID - category: FIELD - required: true - inputType: text - placeholder: Enter your VID - validation: - - type: regex - value: "^[A-Z0-9]+$" - message: "{{i18n(validation:vid.pattern.invalid)}}" - - type: maxLength - value: 20 - message: "{{i18n(validation:vid.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_continue - type: ACTION - label: Continue - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 460 - position: - x: 850 - y: 2700 - prompts: - - action: - ref: tab_phone - nextNode: prompt_pwd_phone - inputs: [] - - action: - ref: tab_email - nextNode: prompt_pwd_email - inputs: [] - - action: - ref: action_continue - nextNode: prompt_enter_pwd - inputs: - - ref: input_vid - type: TEXT_INPUT - required: true - identifier: username - validation: - - type: regex - value: "^[A-Z0-9]+$" - message: "{{i18n(validation:vid.pattern.invalid)}}" - - type: maxLength - value: 20 - message: "{{i18n(validation:vid.maxLength.invalid)}}" - - - id: prompt_enter_pwd - type: PROMPT - meta: - components: - - id: text_pwd_title - type: TEXT - label: Enter Password - variant: HEADING_3 - category: DISPLAY - resourceType: ELEMENT - - id: block_enter_pwd - type: BLOCK - category: BLOCK - components: - - id: input_password - ref: password - hint: "" - type: PASSWORD_INPUT - label: Password - category: FIELD - required: true - placeholder: Enter your password - validation: - - type: minLength - value: 8 - message: "{{i18n(validation:password.minLength.invalid)}}" - - type: maxLength - value: 128 - message: "{{i18n(validation:password.maxLength.invalid)}}" - resourceType: ELEMENT - - id: action_login - type: ACTION - label: Login - variant: PRIMARY - category: ACTION - eventType: SUBMIT - resourceType: ELEMENT - resourceType: ELEMENT - layout: - size: - width: 350 - height: 360 - position: - x: 1900 - y: 2200 - prompts: - - action: - ref: action_login - nextNode: task_verify_pwd - inputs: - - ref: input_password - type: PASSWORD_INPUT - required: true - identifier: password - validation: - - type: minLength - value: 8 - message: "{{i18n(validation:password.minLength.invalid)}}" - - type: maxLength - value: 128 - message: "{{i18n(validation:password.maxLength.invalid)}}" - - - id: task_verify_pwd - type: TASK_EXECUTION - layout: - size: - width: 217 - height: 113 - position: - x: 2450 - y: 2250 - executor: - name: CredentialsAuthExecutor - inputs: - - ref: username - type: TEXT_INPUT - required: true - identifier: username - - ref: input_password - type: PASSWORD_INPUT - required: true - identifier: password - onSuccess: authorization_check - onFailure: prompt_enter_pwd - onIncomplete: prompt_pwd_phone - - - id: authorization_check - type: TASK_EXECUTION - layout: - size: - width: 200 - height: 113 - position: - x: 3200 - y: 1150 - executor: - name: AuthorizationExecutor - onSuccess: auth_assert - - - id: auth_assert - type: TASK_EXECUTION - layout: - size: - width: 244 - height: 113 - position: - x: 3500 - y: 1150 - executor: - name: AuthAssertExecutor - onSuccess: end - - - id: end - type: END - layout: - size: - width: 85 - height: 34 - position: - x: 3850 - y: 1167 diff --git a/esignet-service/data/config/resources/flows/flow-declarative-recovery-1.yaml b/esignet-service/data/config/resources/flows/flow-declarative-recovery-1.yaml deleted file mode 100644 index 4a0543aae..000000000 --- a/esignet-service/data/config/resources/flows/flow-declarative-recovery-1.yaml +++ /dev/null @@ -1,35 +0,0 @@ -id: decl-recovery-flow-1 -handle: decl-recovery-flow-1 -name: Declarative Test Recovery Flow -flowType: RECOVERY -activeVersion: 1 -nodes: - - id: start - type: START - onSuccess: prompt_username - - id: prompt_username - type: PROMPT - prompts: - - inputs: - - ref: input_username - identifier: username - type: TEXT_INPUT - required: true - action: - ref: action_submit_username - nextNode: identify_user - - id: identify_user - type: TASK_EXECUTION - executor: - name: IdentifyingExecutor - mode: identify - onSuccess: generate_recovery_token - onFailure: end - - id: generate_recovery_token - type: TASK_EXECUTION - executor: - name: InviteExecutor - mode: generate - onSuccess: end - - id: end - type: END diff --git a/esignet-service/data/config/resources/flows/flow-declarative-registration-1.yaml b/esignet-service/data/config/resources/flows/flow-declarative-registration-1.yaml deleted file mode 100644 index 0d5607c81..000000000 --- a/esignet-service/data/config/resources/flows/flow-declarative-registration-1.yaml +++ /dev/null @@ -1,12 +0,0 @@ -id: decl-reg-flow-1 -handle: decl-reg-flow-1 -name: Declarative Test Registration Flow -flowType: REGISTRATION -activeVersion: 1 -nodes: - - id: start - type: START - - id: login - type: BASIC_AUTHENTICATION - - id: end - type: END diff --git a/esignet-service/data/config/resources/flows/flow-declarative-sunbird-1.yaml b/esignet-service/data/config/resources/flows/flow-declarative-sunbird-1.yaml deleted file mode 100644 index f25255e95..000000000 --- a/esignet-service/data/config/resources/flows/flow-declarative-sunbird-1.yaml +++ /dev/null @@ -1,58 +0,0 @@ -id: decl-sunbird-flow-1 -handle: decl-sunbird-flow-1 -name: SunbirdRC KBI Flow -flowType: AUTHENTICATION -activeVersion: 1 -nodes: - - id: start - type: START - onSuccess: prompt_credentials - - id: prompt_credentials - type: PROMPT - prompts: - - inputs: - - ref: input_001 - identifier: individualId - type: TEXT_INPUT - required: true - - ref: input_002 - identifier: fullName - type: TEXT_INPUT - required: true - - ref: input_003 - identifier: dob - type: TEXT_INPUT - required: true - action: - ref: action_001 - nextNode: basic_auth - - id: basic_auth - type: TASK_EXECUTION - executor: - name: CredentialsAuthExecutor - inputs: - - ref: input_001 - identifier: individualId - type: TEXT_INPUT - required: true - - ref: input_002 - identifier: fullName - type: PASSWORD_INPUT - required: true - - ref: input_003 - identifier: dob - type: PASSWORD_INPUT - required: true - onSuccess: authorization_check - - id: authorization_check - type: TASK_EXECUTION - executor: - name: AuthorizationExecutor - onSuccess: auth_assert - - id: auth_assert - type: TASK_EXECUTION - executor: - name: AuthAssertExecutor - onSuccess: end - - id: end - type: END diff --git a/esignet-service/data/config/resources/identity_providers/idp-declarative-1.yaml b/esignet-service/data/config/resources/identity_providers/idp-declarative-1.yaml deleted file mode 100644 index 21456b7c5..000000000 --- a/esignet-service/data/config/resources/identity_providers/idp-declarative-1.yaml +++ /dev/null @@ -1,14 +0,0 @@ -id: decl-idp-1 -name: Declarative Test IDP -description: A declarative test identity provider -type: GOOGLE -properties: - - name: client_id - value: test-client-id - is_secret: false - - name: client_secret - value: test-client-secret - is_secret: true - - name: redirect_uri - value: https://localhost:8095/callback - is_secret: false diff --git a/esignet-service/data/config/resources/organization_units/ou-declarative-1.yaml b/esignet-service/data/config/resources/organization_units/ou-declarative-1.yaml deleted file mode 100644 index 4ea17f376..000000000 --- a/esignet-service/data/config/resources/organization_units/ou-declarative-1.yaml +++ /dev/null @@ -1,4 +0,0 @@ -id: decl-ou-1 -handle: decl-ou-1 -name: Declarative Test OU -description: A declarative test organization unit for integration testing diff --git a/esignet-service/data/config/resources/resource_servers/resource-declarative-1.yaml b/esignet-service/data/config/resources/resource_servers/resource-declarative-1.yaml deleted file mode 100644 index 44b48bd29..000000000 --- a/esignet-service/data/config/resources/resource_servers/resource-declarative-1.yaml +++ /dev/null @@ -1,15 +0,0 @@ -id: decl-rs-1 -name: Declarative Resource Server -description: A declarative test resource server -handle: decl-rs-1 -ouId: decl-ou-1 -delimiter: ":" -resources: - - name: Test Resource - handle: test-resource - description: A test resource - actions: - - name: Read - handle: read - - name: Write - handle: write diff --git a/esignet-service/data/config/resources/roles/role-declarative-1.yaml b/esignet-service/data/config/resources/roles/role-declarative-1.yaml deleted file mode 100644 index e2fa4fae8..000000000 --- a/esignet-service/data/config/resources/roles/role-declarative-1.yaml +++ /dev/null @@ -1,6 +0,0 @@ -id: decl-role-1 -name: Declarative Test Role -description: A declarative test role for integration testing -ouId: decl-ou-1 -permissions: [] -assignments: [] diff --git a/esignet-service/data/config/resources/user_types/usertype-declarative-1.yaml b/esignet-service/data/config/resources/user_types/usertype-declarative-1.yaml deleted file mode 100644 index edb284099..000000000 --- a/esignet-service/data/config/resources/user_types/usertype-declarative-1.yaml +++ /dev/null @@ -1,15 +0,0 @@ -id: decl-schema-1 -name: Declarative Test Schema -ouId: decl-ou-1 -allowSelfRegistration: false -systemAttributes: - display: "username" -schema: | - { - "email": { - "type": "string" - }, - "username": { - "type": "string" - } - } diff --git a/esignet-service/data/config/resources/users/user-declarative-1.yaml b/esignet-service/data/config/resources/users/user-declarative-1.yaml deleted file mode 100644 index 757c4752a..000000000 --- a/esignet-service/data/config/resources/users/user-declarative-1.yaml +++ /dev/null @@ -1,10 +0,0 @@ -id: decl-user-1 -type: person -ouId: decl-ou-1 -attributes: - username: decl-user-1 - email: decl-user-1@example.com - given_name: Declarative - family_name: User -credentials: - password: "TempPassword123!" diff --git a/esignet-service/data/deployment.yaml b/esignet-service/data/deployment.yaml index ffe3b26a0..1e50c5768 100644 --- a/esignet-service/data/deployment.yaml +++ b/esignet-service/data/deployment.yaml @@ -1,21 +1,66 @@ -declarative_resources: - enabled: true -flow: - store: declarative -resource: - store: declarative -organization_unit: - store: declarative -identity_provider: - store: declarative -role: - store: declarative -theme: - store: declarative -layout: - store: declarative -translation: - store: declarative +identifier: "${NAMESPACE}" +port: 8088 +issuer: "${MOSIP_ESIGNET_HOST:-http://127.0.0.1:8088}" +data_dir: "${DATA_DIR:-./data}" + +provider: "${AUTHN_PROVIDER:-mock}" +auth_flow_id: "${DEFAULT_AUTH_FLOW_ID:-flow-esignet}" +theme_id: "${DEFAULT_THEME_ID:-theme-esignet}" +layout_id: "${DEFAULT_LAYOUT_ID:-layout-esignet}" + +server: + hostname: "0.0.0.0" + port: 8088 + http_only: true + public_url: "${MOSIP_ESIGNET_HOST:-http://127.0.0.1:8088}" + identifier: ${NAMESPACE:-esignet} + +redis: + host: "${REDIS_HOST:-localhost}" + port: "${REDIS_PORT:-6379}" + password: "${REDIS_PASSWORD:-}" + db: ${REDIS_DB:-0} + tls: ${REDIS_TLS:-false} + +db: + dsn: "${DATABASE_URL}" + pool: + max_open_conns: ${DATABASE_MAX_OPEN_CONNS:-10} + max_idle_conns: ${DATABASE_MAX_IDLE_CONNS:-10} + conn_max_lifetime: ${DATABASE_CONN_MAX_LIFETIME:-0} + conn_max_idle_time: ${DATABASE_CONN_MAX_IDLE_TIME:-300} + +cache: + disabled: false + type: redis + size: 1000 + ttl: 3600 + eviction_policy: LRU + cleanup_interval: 300 + redis: + address: "${REDIS_HOST}:${REDIS_PORT}" + db: ${REDIS_DB:-0} + key_prefix: "${NAMESPACE}:" + +gate_client: + scheme: http + hostname: "127.0.0.1" + port: 3000 + login_path: /signin + error_path: /error + +jwt: + preferred_key_id: default-key + audience: application + validity_period: 3600 + +oauth: + refresh_token: + renew_on_grant: false + authorization_code: + validity_period: 120 + par: + expires_in: 3600 + crypto: - encryption: - key: "${CRYPTO_ENCRYPTION_KEY}" + key: "${CRYPTO_ENCRYPTION_KEY}" diff --git a/esignet-service/data/flows/flow-esignet.yaml b/esignet-service/data/flows/flow-esignet.yaml new file mode 100644 index 000000000..c7c19df87 --- /dev/null +++ b/esignet-service/data/flows/flow-esignet.yaml @@ -0,0 +1,441 @@ +# Anchor for heading details +# it contains heading & subheading details +# logo of main application & client +# one divider line as well +heading_details: &heading_details + id: heading_details + resourceType: ELEMENT + type: BLOCK + category: BLOCK + align: center + components: + - category: DISPLAY + id: text_heading + label: Login using eSignet + resourceType: ELEMENT + type: TEXT + variant: HEADING_3 + align: center + - category: DISPLAY + id: text_subheading + label: "
{{meta(application.name)}} is requesting authentication for login
" + resourceType: ELEMENT + type: RICH_TEXT + variant: SUBTITLE_2 + align: center + - id: logo_block + type: STACK + resourceType: ELEMENT + direction: col + justify: center + components: + - category: DISPLAY + id: client_logo + resourceType: ELEMENT + type: IMAGE + src: "{{meta(application.logoUrl)}}" + alt: "{{t(signin:images.app_logo.alt)}}" + height: 52px + width: 52px + - category: DISPLAY + id: logo_divider + label: "⇋" + resourceType: ELEMENT + type: RICH_TEXT + height: 24px + width: 24px + - category: DISPLAY + id: app_logo + resourceType: ELEMENT + type: IMAGE + src: "/logo.png" + alt: "{{t(signin:images.app_logo.alt)}}" + height: 52px + width: 52px + - id: divider_block + type: DIVIDER + +# Anchor for UIN input, with validation +prompt_uin: &prompt_uin + id: username_input + ref: username + type: TEXT_INPUT + label: UIN/VID + required: true + placeholder: Enter your UIN/VID + validation: + - type: maxLength + value: 16 + message: UIN/VID must be between 10 and 16 digits + - type: minLength + value: 10 + message: UIN/VID must be between 10 and 16 digits + - type: regex + value: "[0-9]*" + message: UIN/VID must contain numbers only + +# Anchor for Submit button +# default id is submit_uin, it can be changed +# default label is COntinue, that also can be changed +submit_button: &submit_button + id: submit_uin + label: Continue + type: ACTION + variant: PRIMARY + category: ACTION + eventType: SUBMIT + resourceType: ELEMENT + +# Anchor for Trigger button +trigger_button: &trigger_button + <<: *submit_button + variant: OUTLINE + eventType: TRIGGER + +# Anchor for title for the page +page_title: &page_title + id: acr_text_heading + type: TEXT + label: Select a Login ID Option + align: left + variant: HEADING_5 + category: DISPLAY + resourceType: ELEMENT + +id: flow-esignet +handle: flow-esignet +name: MOSIP Esignet Flow +flowType: AUTHENTICATION +nodes: + - id: start + type: START + onSuccess: prompt_acr + + - id: prompt_acr + type: PROMPT + meta: + components: + - *heading_details + - id: acr_block + type: BLOCK + resourceType: ELEMENT + category: BLOCK + direction: col + justify: center + components: + - <<: *page_title + label: Select a preferred mode of login + - <<: *trigger_button + id: acr_otp + label: Login with OTP + startIcon: "/images/otp_icon.svg" + - <<: *trigger_button + id: acr_password + label: Login with Password + startIcon: "/images/pwd_icon.svg" + - <<: *trigger_button + id: acr_bio + label: Login with Biometrics + startIcon: "/images/bio_icon.svg" + - <<: *trigger_button + id: acr_kbi + label: Login with KBI + startIcon: "/images/kbi_icon.svg" + prompts: + - action: + ref: acr_otp + nextNode: prompt_uin_otp + - action: + ref: acr_password + nextNode: prompt_credentials + - action: + ref: acr_bio + nextNode: prompt_uin_bio + - action: + ref: acr_kbi + nextNode: prompt_kbi_details + + - id: prompt_uin_otp + type: PROMPT + meta: + components: + - *heading_details + - type: BLOCK + id: block_otp + components: + - <<: *page_title + id: title_otp + - *prompt_uin + - <<: *submit_button + id: submit_uin + label: Get OTP + prompts: + - inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + action: + ref: submit_uin + nextNode: send_mosip_otp + + - id: send_mosip_otp + type: TASK_EXECUTION + executor: + name: MosipOtpExecutor + onSuccess: prompt_otp + + - id: prompt_otp + type: PROMPT + meta: + components: + - *heading_details + - id: block_otp + type: BLOCK + category: BLOCK + components: + - <<: *page_title + id: title_otp + label: Enter OTP + - id: otp_input + ref: otp + type: OTP_INPUT + label: Please enter the 6-digit OTP sent to your registered contact + category: FIELD + required: true + - <<: *submit_button + id: action_submit_otp + label: Verify + - id: resend_otp_block + type: BLOCK + category: BLOCK + resourceType: ELEMENT + components: + - id: RESEND_OTP + type: CUSTOM + ref: resend_otp_timer + category: MISCELLANEOUS + resourceType: ELEMENT + timeLeft: 180 + variant: OUTLINE + label: Resend OTP + prompts: + - inputs: + - ref: otp_input + identifier: otp + type: TEXT_INPUT + required: true + action: + ref: action_submit_otp + nextNode: basic_auth + - action: + ref: RESEND_OTP + nextNode: send_mosip_otp + + - id: basic_auth + type: TASK_EXECUTION + executor: + name: CredentialsAuthExecutor + inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: otp_input + identifier: otp + type: PASSWORD_INPUT + required: true + onSuccess: authorization_check + + - id: prompt_credentials + type: PROMPT + meta: + components: + - *heading_details + - id: block_credentials + resourceType: ELEMENT + type: BLOCK + category: BLOCK + components: + - <<: *page_title + id: title_credentials + - *prompt_uin + - id: password_input + ref: password + type: PASSWORD_INPUT + category: FIELD + inputType: password + label: Password + placeholder: Enter your password + required: true + resourceType: ELEMENT + - <<: *submit_button + id: password_authenticate + label: Login + prompts: + - inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: password_input + identifier: password + type: PASSWORD_INPUT + required: true + action: + ref: password_authenticate + nextNode: basic_cred_auth + + - id: basic_cred_auth + type: TASK_EXECUTION + executor: + name: CredentialsAuthExecutor + inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: password_input + identifier: password + type: PASSWORD_INPUT + required: true + onSuccess: authorization_check + + - id: prompt_uin_bio + type: PROMPT + meta: + components: + - *heading_details + - type: BLOCK + id: block_biometrics + components: + - <<: *page_title + id: title_biometrics + - *prompt_uin + - *submit_button + prompts: + - inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + action: + ref: submit_uin + nextNode: prompt_biometric + + - id: prompt_biometric + type: PROMPT + meta: + components: + - *heading_details + - id: block_biometrics + type: BLOCK + components: + - <<: *page_title + id: title_biometrics + - id: SBI_ID + ref: biometrics + type: CUSTOM + category: MISCELLANEOUS + resourceType: ELEMENT + prompts: + - inputs: + - ref: SBI_ID + identifier: biometrics + type: CUSTOM + required: true + action: + ref: SBI_ID + nextNode: basic_bio_auth + + - id: basic_bio_auth + type: TASK_EXECUTION + executor: + name: CredentialsAuthExecutor + inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: SBI_ID + identifier: biometrics + type: CUSTOM + required: true + onSuccess: authorization_check + + - id: prompt_kbi_details + type: PROMPT + meta: + components: + - *heading_details + - id: block_kbi + type: BLOCK + components: + - <<: *page_title + id: title_kbi + - *prompt_uin + - id: fullname_input + ref: fullname + type: TEXT_INPUT + label: Fullname + required: true + placeholder: Enter your full name + - id: dob_input + ref: date_of_birth + type: DATE_INPUT + label: Date of Birth + required: true + placeholder: Select your date of birth + dateFormat: "yyyy-MM-dd" + - <<: *submit_button + id: submit_kbi_details + label: Login + prompts: + - inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: fullname_input + identifier: fullname + type: TEXT_INPUT + required: true + - ref: dob_input + identifier: date_of_birth + type: DATE_INPUT + required: true + action: + ref: submit_kbi_details + nextNode: basic_kbi_auth + + - id: basic_kbi_auth + type: TASK_EXECUTION + executor: + name: CredentialsAuthExecutor + inputs: + - ref: username_input + identifier: username + type: TEXT_INPUT + required: true + - ref: fullname_input + identifier: fullname + type: TEXT_INPUT + required: true + - ref: dob_input + identifier: date_of_birth + type: TEXT_INPUT + required: true + onSuccess: authorization_check + + - id: authorization_check + type: TASK_EXECUTION + executor: + name: AuthorizationExecutor + onSuccess: auth_assert + - id: auth_assert + type: TASK_EXECUTION + executor: + name: AuthAssertExecutor + onSuccess: end + - id: end + type: END \ No newline at end of file diff --git a/esignet-service/data/config/resources/flows/flow-declarative-mosip-otp-1.yaml b/esignet-service/data/flows/otp-flow.yaml similarity index 98% rename from esignet-service/data/config/resources/flows/flow-declarative-mosip-otp-1.yaml rename to esignet-service/data/flows/otp-flow.yaml index adcaf32ab..d769cea89 100644 --- a/esignet-service/data/config/resources/flows/flow-declarative-mosip-otp-1.yaml +++ b/esignet-service/data/flows/otp-flow.yaml @@ -1,6 +1,6 @@ -id: decl-mosip-otp-flow-1 -handle: decl-mosip-otp-flow-1 -name: MOSIP Send OTP Flow +id: otp-flow +handle: otp-flow +name: eSignet OTP Flow flowType: AUTHENTICATION activeVersion: 1 nodes: @@ -211,4 +211,4 @@ nodes: onSuccess: end - id: end - type: END + type: END \ No newline at end of file diff --git a/esignet-service/data/config/resources/layouts/layout-declarative-1.yaml b/esignet-service/data/layouts/layout-esignet.yaml similarity index 53% rename from esignet-service/data/config/resources/layouts/layout-declarative-1.yaml rename to esignet-service/data/layouts/layout-esignet.yaml index fefe6a41e..d2ab1128c 100644 --- a/esignet-service/data/config/resources/layouts/layout-declarative-1.yaml +++ b/esignet-service/data/layouts/layout-esignet.yaml @@ -1,6 +1,6 @@ -id: decl-layout-1 -displayName: Declarative Test Layout -description: A declarative test layout +id: layout-esignet +displayName: Esignet Layout +description: A Esignet layout layout: type: centered components: @@ -9,4 +9,4 @@ layout: - type: form position: center - type: footer - position: bottom + position: bottom \ No newline at end of file diff --git a/esignet-service/data/config/resources/themes/theme-declarative-1.yaml b/esignet-service/data/themes/theme-esignet.yaml similarity index 99% rename from esignet-service/data/config/resources/themes/theme-declarative-1.yaml rename to esignet-service/data/themes/theme-esignet.yaml index 1de8ba6d1..478b942a8 100644 --- a/esignet-service/data/config/resources/themes/theme-declarative-1.yaml +++ b/esignet-service/data/themes/theme-esignet.yaml @@ -505,4 +505,4 @@ theme: - linear-gradient(rgba(255 255 255 / 0.159), rgba(255 255 255 / 0.159)) - linear-gradient(rgba(255 255 255 / 0.161), rgba(255 255 255 / 0.161)) - linear-gradient(rgba(255 255 255 / 0.163), rgba(255 255 255 / 0.163)) - - linear-gradient(rgba(255 255 255 / 0.165), rgba(255 255 255 / 0.165)) + - linear-gradient(rgba(255 255 255 / 0.165), rgba(255 255 255 / 0.165)) \ No newline at end of file diff --git a/esignet-service/go.mod b/esignet-service/go.mod index 8589a5ac9..b1f66d8df 100644 --- a/esignet-service/go.mod +++ b/esignet-service/go.mod @@ -4,15 +4,16 @@ 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 - github.com/thunder-id/thunderid v0.0.0 - software.sslmate.com/src/go-pkcs12 v0.7.1 + github.com/thunder-id/thunderid v0.0.0-20260701124159-a8f0fb52b5c5 + golang.org/x/text v0.32.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect @@ -26,7 +27,6 @@ require ( github.com/google/go-tpm v0.9.6 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/modelcontextprotocol/go-sdk v1.4.1 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect @@ -38,29 +38,18 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/otel v1.43.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect - go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect - go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.uber.org/atomic v1.11.0 // indirect golang.org/x/crypto v0.46.0 // indirect golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/net v0.48.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.32.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect - google.golang.org/grpc v1.79.3 // indirect - google.golang.org/protobuf v1.36.10 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/libc v1.65.2 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.10.0 // indirect modernc.org/sqlite v1.37.0 // indirect ) -replace github.com/thunder-id/thunderid => github.com/anushasunkada/thunder/backend v0.0.0-20260622173216-af8eca3b191b +replace github.com/thunder-id/thunderid => github.com/thunder-id/thunderid/backend v0.0.0-20260701124159-a8f0fb52b5c5 diff --git a/esignet-service/go.sum b/esignet-service/go.sum index a248866d8..270890835 100644 --- a/esignet-service/go.sum +++ b/esignet-service/go.sum @@ -1,7 +1,5 @@ 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/anushasunkada/thunder/backend v0.0.0-20260622173216-af8eca3b191b h1:kSvE3aBpZiP1N8dg7fnBpY0Vsb9UUGDQwPaZ3VifFsI= -github.com/anushasunkada/thunder/backend v0.0.0-20260622173216-af8eca3b191b/go.mod h1:bHFQmUiriPo26b8AYbAqc0yVjqdTYNuiDTpz10Q9OiU= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= @@ -31,8 +29,6 @@ github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs= github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= -github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA= @@ -45,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= @@ -75,6 +73,8 @@ github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/thunder-id/thunderid/backend v0.0.0-20260701124159-a8f0fb52b5c5 h1:GwBDw8G2ON3oQmUExAcbJJ1zaUuzsbXC5RyCIw5X2+s= +github.com/thunder-id/thunderid/backend v0.0.0-20260701124159-a8f0fb52b5c5/go.mod h1:bHFQmUiriPo26b8AYbAqc0yVjqdTYNuiDTpz10Q9OiU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= @@ -95,16 +95,12 @@ go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWv go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= -go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU= @@ -126,8 +122,6 @@ golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= -gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= -gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= @@ -165,5 +159,3 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= -software.sslmate.com/src/go-pkcs12 v0.7.1 h1:bxkUPRsvTPNRBZa4M/aSX4PyMOEbq3V8I6hbkG4F4Q8= -software.sslmate.com/src/go-pkcs12 v0.7.1/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/esignet-service/internal/config/app.go b/esignet-service/internal/config/app.go index d91ecf217..0cbf476a4 100644 --- a/esignet-service/internal/config/app.go +++ b/esignet-service/internal/config/app.go @@ -2,62 +2,233 @@ package config import ( + "errors" "fmt" "os" - "strconv" + "path/filepath" "strings" + + "github.com/kelseyhightower/envconfig" + engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" + "gopkg.in/yaml.v3" ) const ( - defaultPort = "8088" - defaultDataDir = "./data" + defaultDataDir = "./data" + appConfigFileName = "deployment.yaml" + defaultGatePort = 3000 + defaultSigningKeyPath = "./keys/signing.key" + defaultSigningCertPath = "./keys/signing.crt" + defaultGateScheme = "http" + defaultGateHostname = "127.0.0.1" + defaultGateLoginPath = "/signin" + defaultGateErrorPath = "/error" ) // AppConfig holds core HTTP and infrastructure settings for the service. type AppConfig struct { - Port string - Issuer string - DataDir string - DB DB - Redis Redis + Identifier string `yaml:"identifier"` + Port int `yaml:"port"` + Issuer string `yaml:"issuer"` + DataDir string `yaml:"data_dir"` + DB DB `yaml:"db"` + Redis Redis `yaml:"redis"` + Provider string `yaml:"provider"` + AuthFlowID string `yaml:"auth_flow_id"` + ThemeID string `yaml:"theme_id"` + LayoutID string `yaml:"layout_id"` + Server engineconfig.ServerConfig `yaml:"server"` + Cache engineconfig.CacheConfig `yaml:"cache"` + OAuth engineconfig.OAuthConfig `yaml:"oauth"` + JWT engineconfig.JWTConfig `yaml:"jwt"` + Flow engineconfig.FlowConfig `yaml:"flow"` + Observability engineconfig.ObservabilityConfig `yaml:"observability"` + GateClient engineconfig.GateClientConfig `yaml:"gate_client"` + EncryptionConfig engineconfig.EncryptionConfig `yaml:"crypto"` + KeyConfig engineconfig.KeyConfig `yaml:"key"` + Consent engineconfig.ConsentConfig `yaml:"consent"` } -// LoadAppConfig reads application settings from the environment. -func LoadAppConfig() AppConfig { - port := envOrDefault("PORT", defaultPort) - return AppConfig{ - Port: port, - Issuer: envOrDefault("MOSIP_ESIGNET_HOST", fmt.Sprintf("http://127.0.0.1:%s", port)), - DataDir: envOrDefault("DATA_DIR", defaultDataDir), - DB: loadDB(), - Redis: loadRedis(), - } +// 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"` } -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value +// 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) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + expanded := os.ExpandEnv(string(data)) + var cfg AppConfig + decoder := yaml.NewDecoder(strings.NewReader(expanded)) + decoder.KnownFields(true) + if err := decoder.Decode(&cfg); err != nil { + return nil, fmt.Errorf("parse %s: %w", path, err) } - return fallback + + if err := applyDefaults(&cfg); err != nil { + return nil, err + } + return &cfg, nil } -func envInt(key string) int { - raw := os.Getenv(key) - if raw == "" { - return 0 +func applyDefaults(cfg *AppConfig) error { + var s appSpec + if err := envconfig.Process("", &s); err != nil { + return fmt.Errorf("loading app config: %w", err) } - n, err := strconv.Atoi(raw) + + 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 + db, err := loadDB() if err != nil { - return 0 + return err + } + cfg.DB = *db + if !hasDBEnvConfig() && yamlDB.DSN != "" { + cfg.DB.DSN = yamlDB.DSN } - return n + if yamlDB.Pool.MaxOpenConns > 0 { + cfg.DB.Pool = yamlDB.Pool + } + 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 + cfg.Server.PublicURL = cfg.Issuer + cfg.Server.HTTPOnly = strings.HasPrefix(cfg.Issuer, "http://") + + cfg.JWT.Issuer = cfg.Issuer + cfg.JWT.Audience = cfg.Issuer + cfg.JWT.PreferredKeyID = "default-key" + cfg.JWT.ValidityPeriod = 3600 + + cfg.EncryptionConfig.Key = s.EncryptionKey + + cfg.Cache.Disabled = false + cfg.Cache.Type = "redis" + cfg.Cache.Size = 1000 + cfg.Cache.TTL = 3600 + cfg.Cache.EvictionPolicy = "LRU" + cfg.Cache.Redis.Address = fmt.Sprintf("%s:%s", cfg.Redis.Host, cfg.Redis.Port) + cfg.Cache.Redis.Password = cfg.Redis.Password + cfg.Cache.Redis.DB = cfg.Redis.DB + + cfg.Observability.Enabled = true + + cfg.GateClient.Scheme = defaultGateScheme + cfg.GateClient.Hostname = defaultGateHostname + cfg.GateClient.Port = defaultGatePort + cfg.GateClient.LoginPath = defaultGateLoginPath + cfg.GateClient.ErrorPath = defaultGateErrorPath + + cfg.Flow.DefaultAuthFlowHandle = "default" + cfg.Flow.UserOnboardingFlowHandle = "user-onboarding" + cfg.Flow.MaxVersionHistory = 1 + cfg.Flow.AutoInferRegistration = false + cfg.Flow.Store = "memory" + cfg.Flow.Executors = []string{"CredentialsAuthExecutor", "AuthAssertExecutor", "ConsentExecutor"} + cfg.Flow.Interceptors = []string{} + + cfg.Consent.Enabled = false + + cfg.OAuth.RefreshToken.RenewOnGrant = false + cfg.OAuth.AuthorizationCode.ValidityPeriod = 3600 + cfg.OAuth.PAR.ExpiresIn = 3600 + cfg.OAuth.AuthClass.AcrAMR = map[string][]string{} + cfg.OAuth.AuthClass.Amrs = []string{} + cfg.OAuth.AllowWildcardRedirectURI = true + + cfg.KeyConfig.CertFile = defaultSigningCertPath + cfg.KeyConfig.KeyFile = defaultSigningKeyPath + cfg.KeyConfig.ID = "default-key" + + return nil } -func envBool(key string) bool { - switch strings.ToLower(strings.TrimSpace(os.Getenv(key))) { - case "1", "true", "yes", "on": - return true - default: - return false +// 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 { + var s overrideSpec + if err := envconfig.Process("", &s); err != nil { + return fmt.Errorf("loading env overrides: %w", err) + } + + if s.UIScheme != "" { + cfg.GateClient.Scheme = s.UIScheme + } + if s.UIHostname != "" { + cfg.GateClient.Hostname = s.UIHostname + } + 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 + } + if s.UILoginPath != "" { + cfg.GateClient.LoginPath = s.UILoginPath + } + if s.UIErrorPath != "" { + cfg.GateClient.ErrorPath = s.UIErrorPath + } + + if s.AuthCodeLifetimeSecs > 0 { + cfg.OAuth.AuthorizationCode.ValidityPeriod = s.AuthCodeLifetimeSecs + } + if s.PARExpirySecs > 0 { + cfg.OAuth.PAR.ExpiresIn = s.PARExpirySecs + } + 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 f71f9afef..47e7a5b5a 100644 --- a/esignet-service/internal/config/db.go +++ b/esignet-service/internal/config/db.go @@ -8,43 +8,76 @@ import ( "strings" "time" + "github.com/kelseyhightower/envconfig" _ "github.com/lib/pq" // PostgreSQL driver for database/sql ) -const ( - defaultDBMaxOpenConns = 25 - defaultDBMaxIdleConns = 5 - defaultDBConnMaxLifetime = 5 * time.Minute - defaultDBConnMaxIdleTime = 1 * time.Minute - dbPingTimeout = 5 * time.Second -) +const dbPingTimeout = 5 * time.Second // DBPool holds connection pool tuning parameters. type DBPool struct { - MaxOpenConns int - MaxIdleConns int - ConnMaxLifetime time.Duration - ConnMaxIdleTime time.Duration + MaxOpenConns int `yaml:"max_open_conns"` + MaxIdleConns int `yaml:"max_idle_conns"` + ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"` + ConnMaxIdleTime time.Duration `yaml:"conn_max_idle_time"` } // DB holds Postgres connection settings. type DB struct { - DSN string - Pool DBPool + DSN string `yaml:"dsn"` + Pool DBPool `yaml:"pool"` +} + +func hasDBEnvConfig() bool { + return os.Getenv("DATABASE_URL") != "" || + os.Getenv("DATABASE_HOST") != "" || + os.Getenv("DATABASE_PORT") != "" || + os.Getenv("DATABASE_NAME") != "" || + os.Getenv("DATABASE_USERNAME") != "" || + os.Getenv("DATABASE_PASSWORD") != "" || + os.Getenv("DB_DBUSER_PASSWORD") != "" +} + +// 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"` + + 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 either POSTGRES_URL (full DSN) or individual vars: -// POSTGRES_HOST, POSTGRES_PORT, POSTGRES_DB, POSTGRES_USER, POSTGRES_PASSWORD. +// 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 := os.Getenv("POSTGRES_URL") +func loadDB() (*DB, error) { + var s dbSpec + if err := envconfig.Process("", &s); err != nil { + return nil, fmt.Errorf("loading database config: %w", err) + } + if s.Password == "" { + s.Password = s.PasswordAlt + } + + dsn := s.URL if dsn != "" && (strings.HasPrefix(dsn, "postgres://") || strings.HasPrefix(dsn, "postgresql://")) && !strings.Contains(strings.ToLower(dsn), "sslmode=") { @@ -55,58 +88,30 @@ func loadDB() DB { dsn += sep + "sslmode=disable" } if dsn == "" { - host := envOrDefault("DATABASE_HOST", "localhost") - port := envOrDefault("DATABASE_PORT", "5432") - dbname := envOrDefault("DATABASE_NAME", "mosip_esignet") - user := envOrDefault("DATABASE_USERNAME", "postgres") - password := os.Getenv("DB_DBUSER_PASSWORD") - if password != "" { + if s.Password != "" { dsn = fmt.Sprintf( "host=%s port=%s dbname=%s user=%s password=%s sslmode=disable", - host, port, dbname, user, password, + 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", - host, port, dbname, user, + s.Host, s.Port, s.Name, s.User, ) } } - maxOpen := envInt("DB_MAX_OPEN_CONNS") - if maxOpen <= 0 { - maxOpen = defaultDBMaxOpenConns - } - maxIdle := envInt("DB_MAX_IDLE_CONNS") - if maxIdle <= 0 { - maxIdle = defaultDBMaxIdleConns - } - lifetimeSecs := envInt("DB_CONN_MAX_LIFETIME_SECS") - var lifetime time.Duration - if lifetimeSecs > 0 { - lifetime = time.Duration(lifetimeSecs) * time.Second - } else { - lifetime = defaultDBConnMaxLifetime - } - idleSecs := envInt("DB_CONN_MAX_IDLE_TIME_SECS") - var idleTime time.Duration - if idleSecs > 0 { - idleTime = time.Duration(idleSecs) * time.Second - } else { - idleTime = defaultDBConnMaxIdleTime - } - - 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 c66a6b995..9c57380c7 100644 --- a/esignet-service/internal/config/redis.go +++ b/esignet-service/internal/config/redis.go @@ -7,21 +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 = 5 * time.Minute - defaultRedisDialTimeout = 5 * time.Second - defaultRedisReadTimeout = 3 * time.Second - defaultRedisWriteTimeout = 3 * time.Second - defaultRedisKeyPrefix = "esignet:" - redisPingTimeout = 5 * time.Second -) +const redisPingTimeout = 5 * time.Second // Redis holds all settings needed to open and configure a Redis client. // @@ -68,81 +58,73 @@ type Redis struct { SentinelAddrs []string } -func loadRedis() Redis { - poolSize := envInt("REDIS_POOL_SIZE") - if poolSize <= 0 { - poolSize = defaultRedisPoolSize - } - minIdle := envInt("REDIS_MIN_IDLE_CONNS") - if minIdle <= 0 { - minIdle = defaultRedisMinIdleConns - } - - idleSecs := envInt("REDIS_CONN_MAX_IDLE_TIME_SECS") - var idleTime time.Duration - if idleSecs > 0 { - idleTime = time.Duration(idleSecs) * time.Second - } else { - idleTime = defaultRedisConnMaxIdleTime - } - - lifetimeSecs := envInt("REDIS_CONN_MAX_LIFETIME_SECS") - lifetime := time.Duration(lifetimeSecs) * time.Second // 0 = no limit - - dialSecs := envInt("REDIS_DIAL_TIMEOUT_SECS") - var dialTimeout time.Duration - if dialSecs > 0 { - dialTimeout = time.Duration(dialSecs) * time.Second - } else { - dialTimeout = defaultRedisDialTimeout - } +// 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 := envInt("REDIS_READ_TIMEOUT_SECS") - var readTimeout time.Duration - if readSecs > 0 { - readTimeout = time.Duration(readSecs) * time.Second - } else { - readTimeout = defaultRedisReadTimeout +// 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 := envInt("REDIS_WRITE_TIMEOUT_SECS") - var writeTimeout time.Duration - if writeSecs > 0 { - writeTimeout = time.Duration(writeSecs) * time.Second - } else { - writeTimeout = defaultRedisWriteTimeout + // 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: envInt("REDIS_DB"), - 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/actor_provider.go b/esignet-service/internal/engine/actor_provider.go new file mode 100644 index 000000000..bba4658f7 --- /dev/null +++ b/esignet-service/internal/engine/actor_provider.go @@ -0,0 +1,162 @@ +// Package engine provides ThunderID engine host integrations for the embedder. +package engine + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/clientmgmt" + "github.com/mosip/esignet/internal/config" + "github.com/mosip/esignet/internal/engine/shared" +) + +type actorProvider struct { + clientSvc *clientmgmt.Service + config *config.AppConfig +} + +// NewActorProvider returns a minimal host.ActorProvider stub. Declarative resources +// supply applications, flows, and related SoR data to the engine directly. +func NewActorProvider(clientSvc *clientmgmt.Service, config *config.AppConfig) providers.ActorProvider { + return &actorProvider{clientSvc: clientSvc, config: config} +} + +func (p *actorProvider) GetOAuthClientByClientID( + ctx context.Context, clientID string, +) (*providers.OAuthClient, *common.ServiceError) { + client, err := p.clientSvc.GetClient(ctx, clientID) + if err != nil { + return nil, shared.ClientNotFoundError + } + return &providers.OAuthClient{ + ID: client.ClientID, + OUID: client.RpID, + ClientID: client.ClientID, + GrantTypes: []providers.GrantType{providers.GrantTypeAuthorizationCode}, + RedirectURIs: client.RedirectURIs, + ResponseTypes: []providers.ResponseType{providers.ResponseTypeCode}, + TokenEndpointAuthMethod: providers.TokenEndpointAuthMethodPrivateKeyJWT, + PKCERequired: false, + PublicClient: false, + Certificate: &providers.Certificate{ + Type: "JWKS", + Value: getJWKS(client.PublicKey), + }, + }, nil +} + +func (p *actorProvider) GetOAuthProfileByID( + ctx context.Context, id string, +) (*providers.OAuthProfile, *common.ServiceError) { + client, err := p.clientSvc.GetClient(ctx, id) + if err != nil { + return nil, shared.ClientNotFoundError + } + return &providers.OAuthProfile{ + RedirectURIs: client.RedirectURIs, + GrantTypes: client.GrantTypes, + ResponseTypes: []string{string(providers.ResponseTypeCode)}, + TokenEndpointAuthMethod: string(providers.TokenEndpointAuthMethodPrivateKeyJWT), + PKCERequired: true, + PublicClient: false, + RequirePushedAuthorizationRequests: true, + DPoPBoundAccessTokens: true, + IncludeActClaim: true, + Token: &providers.OAuthTokenConfig{ + AccessToken: &providers.AccessTokenConfig{ + ValidityPeriod: 5 * 60, + UserAttributes: client.Claims, + }, + IDToken: &providers.IDTokenConfig{ + ValidityPeriod: 5 * 60, + UserAttributes: []string{}, + }, + }, + Scopes: []string{"openid"}, + UserInfo: &providers.UserInfoConfig{ + ResponseType: providers.UserInfoResponseTypeJSON, + UserAttributes: client.Claims, + }, + ScopeClaims: map[string][]string{ + "openid": {"sub"}, + }, + Certificate: &providers.Certificate{ + Type: "JWKS", + Value: getJWKS(client.PublicKey), + }, + AcrValues: client.AcrValues, + }, nil +} + +func (p *actorProvider) GetInboundClientByID( + ctx context.Context, id string, +) (*providers.InboundClient, *common.ServiceError) { + client, err := p.clientSvc.GetClient(ctx, id) + if err != nil { + return nil, shared.ClientNotFoundError + } + + properties := make(map[string]interface{}) + properties["name"] = client.ClientName + properties["description"] = client.ClientName + + return &providers.InboundClient{ + ID: client.ClientID, + AuthFlowID: p.config.AuthFlowID, + IsRegistrationFlowEnabled: false, + IsRecoveryFlowEnabled: false, + ThemeID: p.config.ThemeID, + LayoutID: p.config.LayoutID, + Assertion: &providers.AssertionConfig{ + ValidityPeriod: 5 * 60, // 5 minutes + UserAttributes: client.Claims, + }, + LoginConsent: &providers.LoginConsentConfig{ + ValidityPeriod: configInt64(client.AdditionalConfig, "consent_expire_in_mins", 0), + }, + Properties: properties, + IsReadOnly: false, + }, nil +} + +func (p *actorProvider) GetActor(_ string) (*providers.Entity, *common.ServiceError) { + return nil, nil +} + +func (p *actorProvider) GetActorGroups(_ string) ([]providers.EntityGroup, *common.ServiceError) { + return nil, nil +} + +func getJWKS(publicKey string) string { + var parsed map[string]json.RawMessage + if err := json.Unmarshal([]byte(publicKey), &parsed); err == nil { + if _, hasKeys := parsed["keys"]; !hasKeys { + publicKey = fmt.Sprintf(`{"keys":[%s]}`, publicKey) + } + } + return publicKey +} + +func configInt64(cfg map[string]any, key string, defaultValue int64) int64 { + if cfg == nil { + return defaultValue + } + v, ok := cfg[key] + if !ok { + return defaultValue + } + switch n := v.(type) { + case int64: + return n + case int: + return int64(n) + case float64: + return int64(n) + default: + return defaultValue + } +} diff --git a/esignet-service/internal/engine/actors.go b/esignet-service/internal/engine/actors.go deleted file mode 100644 index 38fbcfe3e..000000000 --- a/esignet-service/internal/engine/actors.go +++ /dev/null @@ -1,154 +0,0 @@ -// Package engine provides ThunderID engine host integrations for the embedder. -package engine - -import ( - "context" - "encoding/json" - "fmt" - - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" - "github.com/thunder-id/thunderid/pkg/thunderidengine/runtime" - - "github.com/mosip/esignet/internal/clientmgmt" -) - -type actorProvider struct { - clientSvc *clientmgmt.Service - config Config -} - -// NewActorProvider returns a minimal host.ActorProvider stub. Declarative resources -// supply applications, flows, and related SoR data to the engine directly. -func NewActorProvider(clientSvc *clientmgmt.Service, config Config) host.ActorProvider { - return &actorProvider{clientSvc: clientSvc, config: config} -} - -func (*actorProvider) IdentifyEntity(map[string]any) (*string, error) { - return nil, runtime.ErrNotFound -} - -func (p *actorProvider) GetEntity(entityID string) (*host.Actor, error) { - client, err := p.clientSvc.GetClient(context.Background(), entityID) - if err != nil { - return nil, err - } - - systemAttributes := map[string]any{ - "name": client.ClientName, - "description": client.ClientName, - } - systemAttributesBytes, err := json.Marshal(systemAttributes) - if err != nil { - return nil, err - } - return &host.Actor{ - ID: client.ClientID, - EntityType: "client", - OUID: client.RpID, - SystemAttributes: systemAttributesBytes, - }, nil -} - -func (*actorProvider) SearchEntities(map[string]any) ([]*host.Actor, error) { - return nil, nil -} - -func (p *actorProvider) GetApplication(ctx context.Context, appID string) (*host.Application, error) { - client, err := p.clientSvc.GetClient(ctx, appID) - if err != nil { - return nil, err - } - return &host.Application{ - ID: client.RpID, - Name: client.ClientName, - OUID: client.RpID, - EntityID: client.ClientID, - ThemeID: p.config.ThemeID, - LayoutID: p.config.LayoutID, - }, nil -} - -func (p *actorProvider) GetInboundClientByEntityID(ctx context.Context, entityID string) (*host.InboundClient, error) { - client, err := p.clientSvc.GetClient(ctx, entityID) - if err != nil { - return nil, err - } - return inboundClientFromDB(client, p.config), nil -} - -func (p *actorProvider) GetInboundClientByClientID(ctx context.Context, clientID string) (*host.InboundClient, error) { - client, err := p.clientSvc.GetClient(ctx, clientID) - if err != nil { - return nil, err - } - return inboundClientFromDB(client, p.config), nil -} - -func inboundClientFromDB(client clientmgmt.ClientResponse, cfg Config) *host.InboundClient { - tokenAuthMethod := "private_key_jwt" - if len(client.AuthMethods) > 0 { - tokenAuthMethod = client.AuthMethods[0] - } - pkceRequired := configBool(client.AdditionalConfig, "require_pkce") || - configBool(client.AdditionalConfig, "pkce_required") - - var certificate *host.Certificate - if client.PublicKey != "" { - certValue := client.PublicKey - var parsed map[string]json.RawMessage - if err := json.Unmarshal([]byte(certValue), &parsed); err == nil { - if _, hasKeys := parsed["keys"]; !hasKeys { - certValue = fmt.Sprintf(`{"keys":[%s]}`, certValue) - } - } - certificate = &host.Certificate{ - Type: "JWKS", - Value: certValue, - } - } - - return &host.InboundClient{ - ClientID: client.ClientID, - EntityID: client.ClientID, - ApplicationID: client.RpID, - OUID: client.RpID, - LogoURL: client.LogoURI, - GrantTypes: client.GrantTypes, - RedirectURIs: client.RedirectURIs, - ResponseTypes: []string{"code"}, - TokenEndpointAuthMethod: tokenAuthMethod, - PKCERequired: pkceRequired, - PublicClient: false, - Certificate: certificate, - AuthFlowID: cfg.AuthFlowID, - RegistrationFlowID: cfg.RegistrationFlowID, - IsRegistrationFlowEnabled: false, - RecoveryFlowID: cfg.RecoveryFlowID, - IsRecoveryFlowEnabled: false, - } -} - -func configBool(cfg map[string]any, key string) bool { - if cfg == nil { - return false - } - v, ok := cfg[key] - if !ok { - return false - } - switch b := v.(type) { - case bool: - return b - case string: - return b == "true" - default: - return false - } -} - -func (p *actorProvider) GetEntityType(_ context.Context, typeID string) (*host.EntityType, error) { - return &host.EntityType{ - ID: typeID, - Name: "application", - }, nil -} diff --git a/esignet-service/internal/engine/authn_factory.go b/esignet-service/internal/engine/authn_factory.go index 7e115a9ca..d6ee85740 100644 --- a/esignet-service/internal/engine/authn_factory.go +++ b/esignet-service/internal/engine/authn_factory.go @@ -3,28 +3,26 @@ package engine import ( "fmt" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/mosip/esignet/internal/clientmgmt" + "github.com/mosip/esignet/internal/config" "github.com/mosip/esignet/internal/engine/mock" "github.com/mosip/esignet/internal/engine/mosip" "github.com/mosip/esignet/internal/engine/sunbird" ) -// NewAuthnProviderFromConfig creates a host.AuthnProvider based on configuration. -func NewAuthnProviderFromConfig(provider string, clientSvc *clientmgmt.Service) (host.AuthnProvider, error) { - +// NewAuthnProvider creates a providers.AuthnProviderManager based on configuration. +func NewAuthnProvider(provider string, appConfig *config.AppConfig, clientSvc *clientmgmt.Service) (providers.AuthnProviderManager, error) { switch provider { case "mosip": - cfg := mosip.LoadConfig() - authnProvider, err := mosip.NewMosipAuthnProvider(cfg, clientSvc) + authnProvider, err := mosip.NewMosipAuthnProvider(appConfig, clientSvc) if err != nil { return nil, err } return authnProvider, nil case "sunbird": - cfg := sunbird.LoadConfig() - authnProvider, err := sunbird.NewSunbirdAuthnProvider(cfg) + authnProvider, err := sunbird.NewSunbirdAuthnProvider() if err != nil { return nil, err } diff --git a/esignet-service/internal/engine/authz.go b/esignet-service/internal/engine/authz.go deleted file mode 100644 index 71016587e..000000000 --- a/esignet-service/internal/engine/authz.go +++ /dev/null @@ -1,27 +0,0 @@ -package engine - -import ( - "context" - "log" - - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" -) - -type roleProvider struct{} - -// NewRoleProvider returns a permissive role provider for local development. -func NewRoleProvider() host.RoleProvider { - return &roleProvider{} -} - -func (*roleProvider) GetAuthorizedPermissions( - _ context.Context, entityID string, groups []string, requestedPermissions []string, -) ([]string, error) { - log.Println("GetAuthorizedPermissions", entityID, groups, requestedPermissions) - return append([]string(nil), requestedPermissions...), nil -} - -func (*roleProvider) GetUserRoles(_ context.Context, entityID string, groupIDs []string) ([]string, error) { - log.Println("GetUserRoles", entityID, groupIDs) - return []string{}, nil -} diff --git a/esignet-service/internal/engine/authz_provider.go b/esignet-service/internal/engine/authz_provider.go new file mode 100644 index 000000000..f9b6a3c77 --- /dev/null +++ b/esignet-service/internal/engine/authz_provider.go @@ -0,0 +1,42 @@ +package engine + +import ( + "context" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" +) + +type authorizationProvider struct { + cfg *config.AppConfig +} + +// NewAuthorizationProvider returns a permissive authorization provider for local development. +func NewAuthorizationProvider(cfg *config.AppConfig) providers.AuthorizationProvider { + return &authorizationProvider{cfg: cfg} +} + +func (*authorizationProvider) EvaluateAccess( + _ context.Context, + request providers.AccessEvaluationRequest, +) (*providers.AccessEvaluationResponse, *common.ServiceError) { + return &providers.AccessEvaluationResponse{ + Decision: true, + Context: request.Context, + }, nil +} + +func (*authorizationProvider) EvaluateAccessBatch( + _ context.Context, + _ providers.AccessEvaluationsRequest, +) (*providers.AccessEvaluationsResponse, *common.ServiceError) { + return &providers.AccessEvaluationsResponse{ + Evaluations: []providers.AccessEvaluationResponse{ + { + Decision: true, + }, + }, + }, nil +} diff --git a/esignet-service/internal/engine/config.go b/esignet-service/internal/engine/config.go deleted file mode 100644 index 4f4d834e4..000000000 --- a/esignet-service/internal/engine/config.go +++ /dev/null @@ -1,170 +0,0 @@ -package engine - -import ( - "fmt" - "os" - "path/filepath" - "strconv" - "strings" - - "github.com/thunder-id/thunderid/pkg/thunderidengine" - - "github.com/mosip/esignet/internal/config" -) - -// Config holds host-layer settings for auth providers and declarative defaults. -type Config struct { - Provider string - AuthFlowID string - RegistrationFlowID string - RecoveryFlowID string - ThemeID string - LayoutID string -} - -// LoadConfig reads host-layer settings from the environment. -func LoadConfig() Config { - return Config{ - Provider: os.Getenv("AUTHN_PROVIDER"), - ThemeID: os.Getenv("DEFAULT_THEME_ID"), - LayoutID: os.Getenv("DEFAULT_LAYOUT_ID"), - AuthFlowID: os.Getenv("DEFAULT_AUTH_FLOW_ID"), - RegistrationFlowID: os.Getenv("DEFAULT_REGISTRATION_FLOW_ID"), - RecoveryFlowID: os.Getenv("DEFAULT_RECOVERY_FLOW_ID"), - } -} - -const ( - defaultGatePort = "3000" - defaultSigningKeyPath = "./keys/signing.key" - defaultSigningCertPath = "./keys/signing.crt" - defaultGateScheme = "http" - defaultGateHostname = "127.0.0.1" - defaultGateLoginPath = "/signin" - defaultGateErrorPath = "/error" -) - -var enabledExecutors = []string{ - "CredentialsAuthExecutor", - "AuthorizationExecutor", - "AuthAssertExecutor", - "ConsentExecutor", -} - -// BuildThunderConfig merges application env settings into the Thunder engine config. -func BuildThunderConfig(appCfg config.AppConfig) (*thunderidengine.Config, []string, error) { - dataDir := appCfg.DataDir - cfg, err := thunderidengine.LoadEngineConfig(dataDir) - if err != nil { - return nil, nil, err - } - - if encKey := os.Getenv("CRYPTO_ENCRYPTION_KEY"); encKey != "" { - cfg.Crypto.Encryption.Key = encKey - } else { - cfg.Crypto.Encryption.Key = os.ExpandEnv(cfg.Crypto.Encryption.Key) - } - if cfg.Crypto.Encryption.Key == "" { - return nil, nil, fmt.Errorf("CRYPTO_ENCRYPTION_KEY must be set") - } - - cfg.OAuth.RefreshToken.RenewOnGrant = false - - cfg.DeclarativeResources.Enabled = true - cfg.Flow.Store = "declarative" - cfg.Resource.Store = "declarative" - cfg.OrganizationUnit.Store = "declarative" - cfg.IdentityProvider.Store = "declarative" - cfg.Role.Store = "declarative" - cfg.Theme.Store = "declarative" - cfg.Layout.Store = "declarative" - cfg.Translation.Store = "declarative" - - issuer := appCfg.Issuer - audience := envOrDefault("JWT_AUDIENCE", "application") - preferredKeyID := envOrDefault("JWT_PREFERRED_KEY_ID", "default-key") - gateClientScheme := envOrDefault("OIDC_UI_SCHEME", defaultGateScheme) - gateClientHostname := envOrDefault("OIDC_UI_HOSTNAME", defaultGateHostname) - gateClientPort := envOrDefault("OIDC_UI_PORT", defaultGatePort) - gateClientLoginPath := envOrDefault("OIDC_UI_LOGIN_PATH", defaultGateLoginPath) - gateClientErrorPath := envOrDefault("OIDC_UI_ERROR_PATH", defaultGateErrorPath) - - cfg.JWT.Issuer = issuer - cfg.JWT.Audience = audience - cfg.JWT.PreferredKeyID = preferredKeyID - cfg.Server.PublicURL = strings.TrimRight(issuer, "/") - cfg.Server.HTTPOnly = strings.HasPrefix(issuer, "http://") - cfg.GateClient.Scheme = gateClientScheme - cfg.GateClient.Hostname = gateClientHostname - gateClientPortInt, err := strconv.Atoi(gateClientPort) - if err != nil { - return nil, nil, fmt.Errorf("invalid OIDC_UI_PORT: %w", err) - } - if gateClientPortInt < 1 || gateClientPortInt > 65535 { - return nil, nil, fmt.Errorf("invalid OIDC_UI_PORT: port must be between 1 and 65535") - } - cfg.GateClient.Port = gateClientPortInt - cfg.GateClient.LoginPath = gateClientLoginPath - cfg.GateClient.ErrorPath = gateClientErrorPath - - authorizationCodeLifetimeSeconds := envOrDefault("OAUTH_AUTH_CODE_LIFETIME_SECONDS", "120") - authorizationCodeLifetimeSecondsInt, err := strconv.Atoi(authorizationCodeLifetimeSeconds) - if err != nil { - return nil, nil, fmt.Errorf("invalid OAUTH_AUTH_CODE_LIFETIME_SECONDS: %w", err) - } - if authorizationCodeLifetimeSecondsInt > 0 { - cfg.OAuth.AuthorizationCode.ValidityPeriod = int64(authorizationCodeLifetimeSecondsInt) - } - - parexpirySeconds := envOrDefault("OAUTH_PAR_EXPIRY_SECONDS", "3600") - parexpirySecondsInt, err := strconv.Atoi(parexpirySeconds) - if err != nil { - return nil, nil, fmt.Errorf("invalid OAUTH_PAR_EXPIRY_SECONDS: %w", err) - } - if parexpirySecondsInt > 0 { - cfg.OAuth.PAR.ExpiresIn = int64(parexpirySecondsInt) - } - - accessTokenLifetimeSeconds := envOrDefault("OAUTH_ACCESS_TOKEN_LIFETIME_SECONDS", "3600") - accessTokenLifetimeSecondsInt, err := strconv.Atoi(accessTokenLifetimeSeconds) - if err != nil { - return nil, nil, fmt.Errorf("invalid OAUTH_ACCESS_TOKEN_LIFETIME_SECONDS: %w", err) - } - if accessTokenLifetimeSecondsInt > 0 { - cfg.JWT.ValidityPeriod = int64(accessTokenLifetimeSecondsInt) - } - - return cfg, enabledExecutors, nil -} - -// PKIPaths returns signing cert and key paths relative to the data directory. -func PKIPaths(dataDir string) (certFile, keyFile string, err error) { - dataDirAbs, err := filepath.Abs(dataDir) - if err != nil { - return "", "", fmt.Errorf("data dir: %w", err) - } - certAbs, err := filepath.Abs(defaultSigningCertPath) - if err != nil { - return "", "", fmt.Errorf("signing cert path: %w", err) - } - keyAbs, err := filepath.Abs(defaultSigningKeyPath) - if err != nil { - return "", "", fmt.Errorf("signing key path: %w", err) - } - certRel, err := filepath.Rel(dataDirAbs, certAbs) - if err != nil { - return "", "", fmt.Errorf("signing cert path: %w", err) - } - keyRel, err := filepath.Rel(dataDirAbs, keyAbs) - if err != nil { - return "", "", fmt.Errorf("signing key path: %w", err) - } - return certRel, keyRel, nil -} - -func envOrDefault(key, fallback string) string { - if value := os.Getenv(key); value != "" { - return value - } - return fallback -} diff --git a/esignet-service/internal/engine/consent.go b/esignet-service/internal/engine/consent.go deleted file mode 100644 index b74eb54f5..000000000 --- a/esignet-service/internal/engine/consent.go +++ /dev/null @@ -1,48 +0,0 @@ -package engine - -import ( - "context" - - "github.com/thunder-id/thunderid/pkg/thunderidengine" -) - -type consentEnforcer struct{} - -// NewConsentEnforcer returns a no-op consent enforcer for local development. -func NewConsentEnforcer() thunderidengine.ConsentEnforcer { - return &consentEnforcer{} -} - -func (*consentEnforcer) ResolveConsent( - _ context.Context, ouID, appID, appName, userID string, - essentialAttributes, optionalAttributes, authorizedPermissions []string, - availableAttributes *thunderidengine.AttributesResponse, forceReprompt bool, - runtimeMetadata map[string]string, -) (*thunderidengine.ConsentPromptData, *thunderidengine.ServiceError) { - _ = ouID - _ = appID - _ = appName - _ = userID - _ = essentialAttributes - _ = optionalAttributes - _ = authorizedPermissions - _ = availableAttributes - _ = forceReprompt - _ = runtimeMetadata - return nil, nil -} - -func (*consentEnforcer) RecordConsent( - _ context.Context, ouID, appID, userID string, - decisions *thunderidengine.ConsentDecisions, sessionToken string, validityPeriod int64, - runtimeMetadata map[string]string, -) (*thunderidengine.ConsentRecord, *thunderidengine.ServiceError) { - _ = ouID - _ = appID - _ = userID - _ = decisions - _ = sessionToken - _ = validityPeriod - _ = runtimeMetadata - return nil, nil -} diff --git a/esignet-service/internal/engine/consent_provider.go b/esignet-service/internal/engine/consent_provider.go new file mode 100644 index 000000000..a6ccab233 --- /dev/null +++ b/esignet-service/internal/engine/consent_provider.go @@ -0,0 +1,34 @@ +package engine + +import ( + "context" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +type consentEnforcer struct{} + +// NewConsentEnforcer returns a no-op consent enforcer for local development. +func NewConsentEnforcer() providers.ConsentProvider { + return &consentEnforcer{} +} + +func (*consentEnforcer) ResolveConsent(_ context.Context, _, _, _, _ string, + _, _, _ []string, + _ *providers.AttributesResponse, _ bool, + _ map[string]string) ( + *providers.ConsentPromptData, *common.ServiceError) { + + return nil, nil +} + +// RecordConsent records the user's consent decisions and returns the persisted consent record. +// If the user denied any essential attribute, ErrorEssentialConsentDenied is returned. +func (*consentEnforcer) RecordConsent(_ context.Context, _, _, _ string, + _ *providers.ConsentDecisions, _ string, _ int64, + _ map[string]string) ( + *providers.Consent, *common.ServiceError) { + + return nil, nil +} diff --git a/esignet-service/internal/engine/design_provider.go b/esignet-service/internal/engine/design_provider.go new file mode 100644 index 000000000..c79a87755 --- /dev/null +++ b/esignet-service/internal/engine/design_provider.go @@ -0,0 +1,174 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + + "github.com/stretchr/testify/assert/yaml" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" + "github.com/mosip/esignet/internal/engine/shared" +) + +// layoutRequestWithID represents the request structure for creating a layout from file-based config. +type layoutRequestWithID struct { + ID string `yaml:"id"` + Handle string `yaml:"handle"` + DisplayName string `yaml:"displayName"` + Description string `yaml:"description,omitempty"` + Layout interface{} `yaml:"layout"` +} + +// themeRequestWithID represents the request structure for creating a theme from file-based config. +type themeRequestWithID struct { + ID string `yaml:"id"` + Handle string `yaml:"handle"` + DisplayName string `yaml:"displayName"` + Description string `yaml:"description,omitempty"` + Theme interface{} `yaml:"theme"` +} + +// Layout represents a layout configuration. +type Layout struct { + ID string `json:"id" yaml:"id,omitempty"` + Handle string `json:"handle" yaml:"handle"` + DisplayName string `json:"displayName" yaml:"displayName"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + Layout json.RawMessage `json:"layout" yaml:"layout"` + CreatedAt string `json:"createdAt" yaml:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt" yaml:"updatedAt,omitempty"` + IsReadOnly bool `json:"isReadOnly" yaml:"isReadOnly"` +} + +// Theme represents a theme configuration. +type Theme struct { + ID string `json:"id" yaml:"id,omitempty"` + Handle string `json:"handle" yaml:"handle"` + DisplayName string `json:"displayName" yaml:"displayName"` + Description string `json:"description" yaml:"description,omitempty"` + Theme json.RawMessage `json:"theme" yaml:"theme"` + CreatedAt string `json:"createdAt" yaml:"createdAt,omitempty"` + UpdatedAt string `json:"updatedAt" yaml:"updatedAt,omitempty"` + IsReadOnly bool `json:"isReadOnly" yaml:"isReadOnly"` +} + +type designProvider struct { + cfg *config.AppConfig +} + +// NewDesignProvider returns a file-based design provider backed by the configured data directory. +func NewDesignProvider(cfg *config.AppConfig) providers.DesignProvider { + return &designProvider{cfg: cfg} +} + +func (p *designProvider) ResolveDesign( + _ context.Context, _ providers.DesignResolveType, _ string, +) (*providers.DesignResponse, *common.ServiceError) { + + if p.cfg.LayoutID == "" || p.cfg.ThemeID == "" { + return nil, shared.InvalidRequestError + } + + layoutData, err := os.ReadFile(filepath.Join(p.cfg.DataDir, "layouts", p.cfg.LayoutID+".yaml")) + if err != nil { + return nil, shared.FileNotFoundError + } + layout, err := parseToLayout(layoutData) + if err != nil { + return nil, shared.FileUnmarshallError + } + + themeData, err := os.ReadFile(filepath.Join(p.cfg.DataDir, "themes", p.cfg.ThemeID+".yaml")) + if err != nil { + return nil, shared.FileNotFoundError + } + theme, err := parseToTheme(themeData) + if err != nil { + return nil, shared.FileUnmarshallError + } + + designResponse := &providers.DesignResponse{ + Layout: layout.Layout, + Theme: theme.Theme, + } + return designResponse, nil +} + +// parseToLayout converts YAML data into a Layout object. +func parseToLayout(data []byte) (*Layout, error) { + var layoutRequest layoutRequestWithID + + err := yaml.Unmarshal(data, &layoutRequest) + if err != nil { + return nil, err + } + + // Convert layout to JSON bytes + var layoutJSON json.RawMessage + if layoutRequest.Layout != nil { + // Handle both map structure and string format + switch v := layoutRequest.Layout.(type) { + case string: + // JSON string format + layoutJSON = []byte(v) + default: + // Map structure - marshal to JSON + layoutBytes, err := json.Marshal(layoutRequest.Layout) + if err != nil { + return nil, fmt.Errorf("failed to marshal layout to JSON: %w", err) + } + layoutJSON = layoutBytes + } + } + + layout := &Layout{ + ID: layoutRequest.ID, + DisplayName: layoutRequest.DisplayName, + Description: layoutRequest.Description, + Layout: layoutJSON, + } + + return layout, nil +} + +// parseToTheme converts YAML data into a Theme object. +func parseToTheme(data []byte) (*Theme, error) { + var themeRequest themeRequestWithID + + err := yaml.Unmarshal(data, &themeRequest) + if err != nil { + return nil, err + } + + // Convert theme to JSON bytes + var themeJSON json.RawMessage + if themeRequest.Theme != nil { + // Handle both map structure and string format + switch v := themeRequest.Theme.(type) { + case string: + // JSON string format + themeJSON = []byte(v) + default: + // Map structure - marshal to JSON + themeBytes, err := json.Marshal(themeRequest.Theme) + if err != nil { + return nil, fmt.Errorf("failed to marshal theme to JSON: %w", err) + } + themeJSON = themeBytes + } + } + + theme := &Theme{ + ID: themeRequest.ID, + DisplayName: themeRequest.DisplayName, + Description: themeRequest.Description, + Theme: themeJSON, + } + + return theme, nil +} diff --git a/esignet-service/internal/engine/executors.go b/esignet-service/internal/engine/executors.go index 7b899434a..de88f584d 100644 --- a/esignet-service/internal/engine/executors.go +++ b/esignet-service/internal/engine/executors.go @@ -1,33 +1,181 @@ package engine import ( - "github.com/thunder-id/thunderid/pkg/thunderidengine" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" + "crypto/rand" + "fmt" + "strings" - "github.com/mosip/esignet/internal/engine/mock" - "github.com/mosip/esignet/internal/engine/mosip" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/engine/shared" + applog "github.com/mosip/esignet/internal/log" +) + +const ( + // ExecutorNameMosipOTP sends OTP via the MOSIP IDA API. + ExecutorNameMosipOTP = "MosipOtpExecutor" + usernameAttr = "username" ) -// CustomExecutors returns embedder-supplied flow executors keyed by executor name. -func CustomExecutors(authn host.AuthnProvider, provider string) map[string]thunderidengine.ExecutorInterface { - if provider == "mosip" { - otpAuthn, ok := authn.(mosip.OTPAuthnProvider) - if !ok { - return nil +var individualIDInput = providers.Input{ + Identifier: usernameAttr, + Type: providers.InputTypeText, + Required: true, +} + +type mosipOtpExecutor struct { + authn shared.ConsolidatedAuthnProvider +} + +var _ providers.Executor = (*mosipOtpExecutor)(nil) + +// NewMosipOtpExecutor creates an executor that sends OTP via the MOSIP IDA API. +func NewMosipOtpExecutor(authn shared.ConsolidatedAuthnProvider) providers.Executor { + return &mosipOtpExecutor{authn: authn} +} + +func (e *mosipOtpExecutor) GetName() string { + return ExecutorNameMosipOTP +} + +func (e *mosipOtpExecutor) GetType() providers.ExecutorType { + return providers.ExecutorTypeAuthentication +} + +func (e *mosipOtpExecutor) GetDefaultInputs() []providers.Input { + return nil +} + +func (e *mosipOtpExecutor) GetPrerequisites() []providers.Input { + return []providers.Input{individualIDInput} +} + +func (e *mosipOtpExecutor) GetRequiredInputs(ctx *providers.NodeContext) []providers.Input { + if len(ctx.NodeInputs) > 0 { + return ctx.NodeInputs + } + return e.GetDefaultInputs() +} + +func (e *mosipOtpExecutor) GetExecutionPolicy(_ string) *providers.ExecutionPolicy { + return nil +} + +func (e *mosipOtpExecutor) HasRequiredInputs(_ *providers.NodeContext, _ *providers.ExecutorResponse) bool { + return true +} + +func (e *mosipOtpExecutor) GetUserIDFromContext(_ *providers.NodeContext, _ *providers.ExecutorResponse, + _ providers.AuthnProviderManager) string { + return "" +} + +func (e *mosipOtpExecutor) ValidatePrerequisites(ctx *providers.NodeContext, _ *providers.ExecutorResponse, + _ providers.AuthnProviderManager) bool { + _, err := usernameFromContext(ctx) + return err == nil +} + +func (e *mosipOtpExecutor) Execute(ctx *providers.NodeContext) (*providers.ExecutorResponse, error) { + execResp := &providers.ExecutorResponse{ForwardedData: make(map[string]any)} + + if !e.ensurePrerequisites(ctx, execResp) { + return execResp, nil + } + + username, err := usernameFromContext(ctx) + if err != nil { + return nil, err + } + + result, serviceError := e.authn.SendOTP(ctx.Context, map[string]any{"username": username}, buildAuthnMetadata(ctx)) + if serviceError != nil { + return execResp, fmt.Errorf("failed to send OTP via MOSIP API: %s", serviceError.Code) + } + + execResp.ForwardedData["maskedEmail"] = result.MaskedEmail + execResp.ForwardedData["maskedMobile"] = result.MaskedMobile + execResp.Status = providers.ExecComplete + return execResp, nil +} + +func (e *mosipOtpExecutor) ensurePrerequisites( + ctx *providers.NodeContext, execResp *providers.ExecutorResponse, +) bool { + if e.ValidatePrerequisites(ctx, execResp, nil) { + return true + } + + execResp.Status = providers.ExecUserInputRequired + execResp.Inputs = []providers.Input{individualIDInput} + return false +} + +func buildAuthnMetadata(ctx *providers.NodeContext) *providers.AuthnMetadata { + appMetadata := make(map[string]any) + + if ctx.Application.Metadata != nil { + for key, value := range ctx.Application.Metadata { + appMetadata[key] = value } - return map[string]thunderidengine.ExecutorInterface{ - mosip.ExecutorNameMosipOTP: mosip.NewMosipOtpExecutor(otpAuthn), + } + + var clientIDs []string + for _, inboundConfig := range ctx.Application.InboundAuthConfig { + if inboundConfig.OAuthConfig != nil && inboundConfig.OAuthConfig.ClientID != "" { + clientIDs = append(clientIDs, inboundConfig.OAuthConfig.ClientID) } } + if len(clientIDs) > 0 { + appMetadata["client_ids"] = clientIDs + } - if provider == "mock" { - mockOTPAuthn, ok := authn.(mock.OTPAuthnProvider) - if !ok { - return nil + if ctx.RuntimeData == nil { + ctx.RuntimeData = make(map[string]string) + } + + if _, exists := ctx.RuntimeData["ext_TransactionID"]; !exists { + mosipTransactionID, err := generateTransactionID() + if err != nil { + applog.GetLogger().Warn("failed to generate transaction ID", applog.Error(err)) + } else { + ctx.RuntimeData["ext_TransactionID"] = mosipTransactionID } - return map[string]thunderidengine.ExecutorInterface{ - mosip.ExecutorNameMosipOTP: mosip.NewMosipOtpExecutor(mockOTPAuthn), + } + + runtimeMetadata := map[string]string{ + "authorization_request_id": ctx.RuntimeData["authorizationRequestId"], + "current_client_id": ctx.RuntimeData["clientId"], + } + + for key, value := range ctx.RuntimeData { + // Only the ext_* runtime data keys are passed to the authn provider. + if strings.HasPrefix(key, "ext_") { + runtimeMetadata[key] = value } } - return nil + + return &providers.AuthnMetadata{AppMetadata: appMetadata, RuntimeMetadata: runtimeMetadata} +} + +func usernameFromContext(ctx *providers.NodeContext) (string, error) { + if username := ctx.UserInputs[usernameAttr]; username != "" { + return username, nil + } + if username := ctx.RuntimeData[usernameAttr]; username != "" { + return username, nil + } + return "", fmt.Errorf("username not found in context") +} + +func generateTransactionID() (string, error) { + const digitCount = 10 + b := make([]byte, digitCount) + if _, err := rand.Read(b); err != nil { + return "", err + } + for i := range b { + b[i] = '0' + b[i]%10 + } + return string(b), nil } diff --git a/esignet-service/internal/engine/flow_provider.go b/esignet-service/internal/engine/flow_provider.go new file mode 100644 index 000000000..2efbb3bf1 --- /dev/null +++ b/esignet-service/internal/engine/flow_provider.go @@ -0,0 +1,46 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + + "github.com/stretchr/testify/assert/yaml" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" + "github.com/mosip/esignet/internal/engine/shared" +) + +type flowProvider struct { + cfg *config.AppConfig +} + +// NewFlowProvider returns a file-based flow provider backed by the configured data directory. +func NewFlowProvider(cfg *config.AppConfig) providers.FlowProvider { + return &flowProvider{cfg: cfg} +} + +func (p *flowProvider) GetFlowByHandle(_ context.Context, _ string, _ providers.FlowType) ( + *providers.CompleteFlowDefinition, *common.ServiceError) { + return p.parseFlowDefinition() +} + +func (p *flowProvider) GetFlow(_ context.Context, _ string) (*providers.CompleteFlowDefinition, *common.ServiceError) { + return p.parseFlowDefinition() +} + +func (p *flowProvider) parseFlowDefinition() (*providers.CompleteFlowDefinition, *common.ServiceError) { + // Read the flow definition from file in the data directory. + data, err := os.ReadFile(filepath.Join(p.cfg.DataDir, "flows", p.cfg.AuthFlowID+".yaml")) + if err != nil { + return nil, shared.FileNotFoundError + } + var flowDef providers.CompleteFlowDefinition + err = yaml.Unmarshal(data, &flowDef) + if err != nil { + return nil, shared.FileUnmarshallError + } + return &flowDef, nil +} diff --git a/esignet-service/internal/engine/i18n_provider.go b/esignet-service/internal/engine/i18n_provider.go new file mode 100644 index 000000000..8311ead78 --- /dev/null +++ b/esignet-service/internal/engine/i18n_provider.go @@ -0,0 +1,44 @@ +package engine + +import ( + "context" + "os" + "path/filepath" + + "github.com/stretchr/testify/assert/yaml" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" + "github.com/mosip/esignet/internal/engine/shared" +) + +type i18nProvider struct { + cfg *config.AppConfig +} + +// NewI18nProvider returns a file-based i18n provider backed by the configured data directory. +func NewI18nProvider(cfg *config.AppConfig) providers.I18nProvider { + return &i18nProvider{cfg: cfg} +} + +func (p *i18nProvider) ResolveTranslations( + _ context.Context, + language string, + _ string, +) (*providers.LanguageTranslationsResponse, *common.ServiceError) { + data, err := os.ReadFile(filepath.Join(p.cfg.DataDir, "i18n", language+".yaml")) + if err != nil { + return nil, shared.FileNotFoundError + } + var translations providers.LanguageTranslationsResponse + err = yaml.Unmarshal(data, &translations) + if err != nil { + return nil, shared.FileUnmarshallError + } + return &translations, nil +} + +func (p *i18nProvider) ListLanguages(_ context.Context) ([]string, *common.ServiceError) { + return []string{"en"}, nil +} diff --git a/esignet-service/internal/engine/mock/mock_authn.go b/esignet-service/internal/engine/mock/mock_authn.go index 0ca456ef5..cbde94660 100644 --- a/esignet-service/internal/engine/mock/mock_authn.go +++ b/esignet-service/internal/engine/mock/mock_authn.go @@ -2,54 +2,61 @@ package mock import ( "context" - "encoding/json" - "errors" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" - "github.com/mosip/esignet/internal/engine/mosip" + "github.com/mosip/esignet/internal/engine/shared" ) -// OTPAuthnProvider extends host.AuthnProvider with OTP send capability. -type OTPAuthnProvider interface { - host.AuthnProvider - SendOTP(_ context.Context, identifiers map[string]any, metadata *host.AuthnMetadata) (*mosip.SendOTPResult, error) -} - type mockAuthnProvider struct { } // NewMockAuthnProvider creates a new mock authentication provider. -func NewMockAuthnProvider() host.AuthnProvider { +func NewMockAuthnProvider() shared.ConsolidatedAuthnProvider { return &mockAuthnProvider{} } -var _ OTPAuthnProvider = (*mockAuthnProvider)(nil) - -func (p *mockAuthnProvider) SendOTP(_ context.Context, _ map[string]any, - _ *host.AuthnMetadata) (*mosip.SendOTPResult, error) { - return &mosip.SendOTPResult{ +func (p *mockAuthnProvider) SendOTP(_ context.Context, _ map[string]interface{}, + _ *providers.AuthnMetadata) (*shared.SendOTPResult, *common.ServiceError) { + return &shared.SendOTPResult{ MaskedEmail: "maskedEmail", MaskedMobile: "maskedMobile", }, nil } -func (p *mockAuthnProvider) Authenticate(_ context.Context, identifiers, _ map[string]any, _ *host.AuthnMetadata) (*host.AuthnResult, error) { - username, ok := identifiers["username"].(string) - if !ok || username == "" { - return nil, errors.New("missing or invalid username in identifiers") - } - return &host.AuthnResult{ - Authenticated: true, - UserID: username, - AuthToken: "authToken", - Attributes: json.RawMessage(`{"email": "mock@email.com", "mobile": "999999999"}`), - }, nil +func (p *mockAuthnProvider) AuthenticateUser(_ context.Context, _, _ map[string]interface{}, + _ *providers.RequestedAttributes, + _ *providers.AuthnMetadata, + authUser providers.AuthUser) (providers.AuthUser, providers.AuthenticatedClaims, *common.ServiceError) { + authUser.SetAttributeToken("authToken") + authUser.SetEntityReferenceToken("entityReferenceToken") + return authUser, nil, nil +} + +func (p *mockAuthnProvider) GetEntityReference(_ context.Context, authUser providers.AuthUser) ( + providers.AuthUser, *providers.EntityReference, *common.ServiceError) { + return authUser, nil, nil +} + +func (p *mockAuthnProvider) GetUserAvailableAttributes(_ context.Context, + _ providers.AuthUser) (*providers.AttributesResponse, *common.ServiceError) { + return nil, nil } -func (p *mockAuthnProvider) GetAttributes(_ context.Context, _ string, _ *host.RequestedAttributes, - _ *host.GetAttributesMetadata) (*host.GetAttributesResult, error) { - return &host.GetAttributesResult{ - Attributes: json.RawMessage(`{"email": "mock@email.com", "mobile": "999999999"}`), +func (p *mockAuthnProvider) GetUserAttributes(_ context.Context, + _ *providers.RequestedAttributes, + _ *providers.GetAttributesMetadata, + authUser providers.AuthUser) (providers.AuthUser, *providers.AttributesResponse, *common.ServiceError) { + + return authUser, &providers.AttributesResponse{ + Attributes: map[string]*providers.AttributeResponse{ + "email": { + Value: "mock@email.com", + }, + "mobile": { + Value: "999999999", + }, + }, }, nil } 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/model.go b/esignet-service/internal/engine/mosip/model.go index 15a5f5882..1b45a1468 100644 --- a/esignet-service/internal/engine/mosip/model.go +++ b/esignet-service/internal/engine/mosip/model.go @@ -73,12 +73,6 @@ type IdaError struct { ErrorMessage string `json:"errorMessage,omitempty"` } -// SendOTPResult represents the result of an generate and notify OTP attempt. -type SendOTPResult struct { - MaskedEmail string `json:"maskedEmail,omitempty"` - MaskedMobile string `json:"maskedMobile,omitempty"` -} - // IdaSendOtpRequest is the MOSIP IDA send-OTP request payload. type IdaSendOtpRequest struct { ID string `json:"id,omitempty"` diff --git a/esignet-service/internal/engine/mosip/mosip_authn.go b/esignet-service/internal/engine/mosip/mosip_authn.go index ae63882b3..6b63a1046 100644 --- a/esignet-service/internal/engine/mosip/mosip_authn.go +++ b/esignet-service/internal/engine/mosip/mosip_authn.go @@ -24,10 +24,14 @@ import ( "strings" "time" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" - "software.sslmate.com/src/go-pkcs12" + "golang.org/x/crypto/pkcs12" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" "github.com/mosip/esignet/internal/clientmgmt" + "github.com/mosip/esignet/internal/config" + "github.com/mosip/esignet/internal/engine/shared" applog "github.com/mosip/esignet/internal/log" ) @@ -41,47 +45,46 @@ const ( mosipEnvStaging = "Staging" // default MOSIP_ENV; see config.LoadMosipAuthn ) -// OTPAuthnProvider extends host.AuthnProvider with OTP send capability. -type OTPAuthnProvider interface { - host.AuthnProvider - SendOTP(_ context.Context, identifiers map[string]any, metadata *host.AuthnMetadata) (*SendOTPResult, error) -} - type mosipAuthnProvider struct { - cfg Config + appConfig *config.AppConfig client *http.Client clientSvc *clientmgmt.Service + cfg Config } -// NewMosipAuthnProvider creates a MOSIP host.AuthnProvider with OTP send support. -func NewMosipAuthnProvider(cfg Config, clientSvc *clientmgmt.Service) (host.AuthnProvider, error) { +// 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{ - cfg: cfg, + appConfig: cfg, client: newHTTPClient(), clientSvc: clientSvc, + cfg: *mosipCfg, } return provider, nil } -var _ OTPAuthnProvider = (*mosipAuthnProvider)(nil) - -// Authenticate authenticates a user using the MOSIP IDA API. -func (p *mosipAuthnProvider) Authenticate(_ context.Context, identifiers, credentials map[string]interface{}, - authnMetadata *host.AuthnMetadata) (*host.AuthnResult, error) { +func (p *mosipAuthnProvider) AuthenticateUser(_ context.Context, identifiers, credentials map[string]interface{}, + _ *providers.RequestedAttributes, + metadata *providers.AuthnMetadata, + authUser providers.AuthUser) (providers.AuthUser, providers.AuthenticatedClaims, *common.ServiceError) { - relyingPartyID, clientID, err := p.getApplicationAndClientID(authnMetadata.RuntimeMetadata) + relyingPartyID, clientID, err := p.getApplicationAndClientID(metadata.RuntimeMetadata) if err != nil { - return nil, err + return authUser, nil, shared.ClientNotFoundError } individualID, ok := identifiers["username"].(string) if !ok || individualID == "" { - return nil, errors.New("missing or invalid individual_id in identifiers") + return authUser, nil, shared.InvalidIndividualIDError } - transactionID, err := GenerateTransactionID(authnMetadata.RuntimeMetadata) + transactionID, err := GenerateTransactionID(metadata.RuntimeMetadata) if err != nil { - return nil, fmt.Errorf("failed to generate transaction ID: %w", err) + return authUser, nil, shared.InvalidRequestError } claimsMetadataRequired := false @@ -99,7 +102,7 @@ func (p *mosipAuthnProvider) Authenticate(_ context.Context, identifiers, creden } if len(credentials) == 0 { - return nil, errors.New("missing or invalid credentials") + return authUser, nil, shared.InvalidRequestError } authRequest := &AuthRequest{ Timestamp: requestTime, @@ -112,60 +115,60 @@ func (p *mosipAuthnProvider) Authenticate(_ context.Context, identifiers, creden } else if password, ok := credentials["password"].(string); ok && password != "" { authRequest.Password = password credentialSet = true - } else if encodedBiometric, ok := credentials["biometric"].(string); ok && encodedBiometric != "" { + } else if encodedBiometric, ok := biometricCredential(credentials); ok { decodedBiometric, err := B64Decode(encodedBiometric) if err != nil { - return nil, fmt.Errorf("failed to decode biometric data: %w", err) + return authUser, nil, shared.InvalidRequestError } var biometrics []Biometric if err := json.Unmarshal(decodedBiometric, &biometrics); err != nil { - return nil, fmt.Errorf("failed to unmarshal biometric data: %w", err) + return authUser, nil, shared.InvalidRequestError } if len(biometrics) == 0 { - return nil, errors.New("missing or invalid biometric data") + return authUser, nil, shared.InvalidRequestError } authRequest.Biometrics = biometrics credentialSet = true } if !credentialSet { - return nil, errors.New("missing or invalid credentials") + return authUser, nil, shared.InvalidRequestError } authRequestBytes, err := json.Marshal(authRequest) if err != nil { - return nil, fmt.Errorf("failed to marshal auth request: %w", err) + return authUser, nil, shared.AuthenticationFailedError } requestHash, err := GenerateHashWithErr(authRequestBytes) if err != nil { - return nil, fmt.Errorf("failed to hash auth request: %w", err) + return authUser, nil, shared.AuthenticationFailedError } hexEncodedRequestHash, err := EncodeBytesToHexUpper(requestHash) if err != nil { - return nil, fmt.Errorf("failed to encode request hash: %w", err) + return authUser, nil, shared.AuthenticationFailedError } symmetricKey, err := GenerateAESKey() if err != nil { - return nil, fmt.Errorf("failed to generate symmetric key: %w", err) + return authUser, nil, shared.AuthenticationFailedError } encryptedRequest, err := SymmetricEncrypt(authRequestBytes, symmetricKey) if err != nil { - return nil, fmt.Errorf("failed to encrypt request: %w", err) + return authUser, nil, shared.AuthenticationFailedError } encryptedRequestHash, err := SymmetricEncrypt(hexEncodedRequestHash, symmetricKey) if err != nil { - return nil, fmt.Errorf("failed to encrypt request hash: %w", err) + return authUser, nil, shared.AuthenticationFailedError } generatedCert, err := p.fetchIDAPartnerCertificate() if err != nil { - return nil, fmt.Errorf("failed to fetch IDA partner certificate: %w", err) + return authUser, nil, shared.AuthenticationFailedError } encryptedSessionKey, err := AsymmetricEncrypt(generatedCert.PublicKey.(*rsa.PublicKey), symmetricKey) if err != nil { - return nil, fmt.Errorf("failed to encrypt session key: %w", err) + return authUser, nil, shared.AuthenticationFailedError } certThumbprint, err := GetCertificateThumbprint(generatedCert) if err != nil { - return nil, fmt.Errorf("failed to get certificate thumbprint: %w", err) + return authUser, nil, shared.AuthenticationFailedError } idaKycAuthRequest.RequestSessionKey = B64EncodeBytes(encryptedSessionKey) @@ -175,47 +178,62 @@ func (p *mosipAuthnProvider) Authenticate(_ context.Context, identifiers, creden requestBytes, err := json.Marshal(idaKycAuthRequest) if err != nil { - return nil, fmt.Errorf("failed to marshal IDA KYC auth request: %w", err) + return authUser, nil, shared.AuthenticationFailedError } requestSignature, err := p.getRequestSignature(requestBytes) if err != nil { - return nil, fmt.Errorf("failed to get request signature: %w", err) + return authUser, nil, shared.AuthenticationFailedError } - authResult, authnErr := p.callKycAuthEndpoint(requestBytes, requestSignature, relyingPartyID, clientID, claimsMetadataRequired) - if authnErr != nil { - return nil, authnErr + psut, kycToken, err := p.callKycAuthEndpoint(requestBytes, requestSignature, relyingPartyID, clientID, claimsMetadataRequired) + if err != nil { + return authUser, nil, shared.AuthenticationFailedError } - authResult.AuthToken = strings.Join([]string{authResult.AuthToken, individualID}, "||") - return authResult, nil + authUser.SetAttributeToken(strings.Join([]string{kycToken, individualID}, "||")) + authUser.SetEntityReferenceToken(psut) + return authUser, nil, nil +} + +func (p *mosipAuthnProvider) GetEntityReference(_ context.Context, authUser providers.AuthUser) ( + providers.AuthUser, *providers.EntityReference, *common.ServiceError) { + + return authUser, nil, nil } -func (p *mosipAuthnProvider) GetAttributes(_ context.Context, token string, requested *host.RequestedAttributes, - getAttributesMetadata *host.GetAttributesMetadata) (*host.GetAttributesResult, error) { +func (p *mosipAuthnProvider) GetUserAvailableAttributes(_ context.Context, + _ providers.AuthUser) (*providers.AttributesResponse, *common.ServiceError) { + + return nil, nil +} - kycToken := strings.Split(token, "||")[0] // Extract KYC token from token (assuming format "kycToken||username||transactionID") - username := strings.Split(token, "||")[1] // Extract username from token (assuming format "kycToken||username||transactionID") +func (p *mosipAuthnProvider) GetUserAttributes(_ context.Context, + _ *providers.RequestedAttributes, + metadata *providers.GetAttributesMetadata, + authUser providers.AuthUser) (providers.AuthUser, *providers.AttributesResponse, *common.ServiceError) { - transactionID, err := GenerateTransactionID(getAttributesMetadata.RuntimeMetadata) + relyingPartyID, clientID, err := p.getApplicationAndClientID(metadata.RuntimeMetadata) if err != nil { - return nil, fmt.Errorf("failed to generate transaction ID: %w", err) + return authUser, nil, shared.ClientNotFoundError } - consentedAttributes := []string{"sub", "name"} - - if requested != nil && len(requested.Names) > 0 { - consentedAttributes = append(consentedAttributes, requested.Names...) + attributeToken := authUser.AttributeToken() + if attributeToken == nil || attributeToken == "" { + return authUser, nil, nil } - requestedNames := []string{} - if requested != nil { - requestedNames = requested.Names + kycToken := strings.Split(attributeToken.(string), "||")[0] // Extract KYC token from token (assuming format "kycToken||username") + username := strings.Split(attributeToken.(string), "||")[1] // Extract username from token (assuming format "kycToken||username") + + transactionID, err := GenerateTransactionID(metadata.RuntimeMetadata) + if err != nil { + return authUser, nil, shared.InvalidRequestError } - applog.GetLogger().Debug("GetAttributes called", - applog.Any("requestedAttributes", requestedNames), - applog.Any("consentedAttributes", consentedAttributes)) + + consentedAttributes := []string{"sub", "name"} + + // TODO add requested attributes to the request idaKycExchangeRequest := &IdaKycExchangeRequest{ ID: mosipKycExchangeRequestID, @@ -231,31 +249,35 @@ func (p *mosipAuthnProvider) GetAttributes(_ context.Context, token string, requ requestBytes, err := json.Marshal(idaKycExchangeRequest) if err != nil { - return nil, fmt.Errorf("failed to marshal IDA KYC exchange request: %w", err) + return authUser, nil, shared.InvalidRequestError } - relyingPartyID, clientID, err := p.getApplicationAndClientID(getAttributesMetadata.RuntimeMetadata) + requestSignature, err := p.getRequestSignature(requestBytes) if err != nil { - return nil, err + return authUser, nil, shared.InvalidRequestError } - - requestSignature, err := p.getRequestSignature(requestBytes) + attributesResponse, err := p.callKycExchangeEndpoint(requestBytes, requestSignature, relyingPartyID, clientID) if err != nil { - return nil, fmt.Errorf("failed to get request signature: %w", err) + return authUser, nil, shared.AuthenticationFailedError } - return p.callKycExchangeEndpoint(requestBytes, requestSignature, relyingPartyID, clientID) + return authUser, attributesResponse, nil } func (p *mosipAuthnProvider) SendOTP(_ context.Context, identifiers map[string]interface{}, - authnMetadata *host.AuthnMetadata) (*SendOTPResult, error) { + metadata *providers.AuthnMetadata) (*shared.SendOTPResult, *common.ServiceError) { + + relyingPartyID, clientID, err := p.getApplicationAndClientID(metadata.RuntimeMetadata) + if err != nil { + return nil, shared.ClientNotFoundError + } - transactionID, err := GenerateTransactionID(authnMetadata.RuntimeMetadata) + transactionID, err := GenerateTransactionID(metadata.RuntimeMetadata) if err != nil { - return nil, fmt.Errorf("failed to generate transaction ID: %w", err) + return nil, shared.InvalidRequestError } individualID, ok := identifiers["username"].(string) if !ok || individualID == "" { - return nil, errors.New("missing or invalid individual_id in identifiers") + return nil, shared.InvalidRequestError } req := IdaSendOtpRequest{ ID: mosipSendOtpRequestID, @@ -268,19 +290,18 @@ func (p *mosipAuthnProvider) SendOTP(_ context.Context, identifiers map[string]i otpRequestBytes, err := json.Marshal(req) if err != nil { - return nil, fmt.Errorf("failed to marshal send OTP request: %w", err) + return nil, shared.InvalidRequestError } - relyingPartyID, clientID, err := p.getApplicationAndClientID(authnMetadata.RuntimeMetadata) + requestSignature, err := p.getRequestSignature(otpRequestBytes) if err != nil { - return nil, err + return nil, shared.InvalidRequestError } - - requestSignature, err := p.getRequestSignature(otpRequestBytes) + sendOTPResult, err := p.callSendOtpEndpoint(otpRequestBytes, requestSignature, relyingPartyID, clientID) if err != nil { - return nil, fmt.Errorf("failed to get request signature: %w", err) + return nil, shared.SendOTPFailedError } - return p.callSendOtpEndpoint(otpRequestBytes, requestSignature, relyingPartyID, clientID) + return sendOTPResult, nil } func (p *mosipAuthnProvider) getApplicationAndClientID(runtimeMetadata map[string]string) (string, string, error) { @@ -361,10 +382,35 @@ func B64EncodeString(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) } -// B64Decode decodes base64url string (both padded and unpadded accepted) +// B64Decode decodes base64 in unpadded URL-safe, standard, or padded URL-safe form. +// Biometric payloads from the OIDC UI use standard base64 (btoa); internal MOSIP +// payloads use base64url. func B64Decode(s string) ([]byte, error) { - // RawURLEncoding accepts both padded and unpadded input - return base64.RawURLEncoding.DecodeString(s) + s = strings.TrimSpace(s) + if s == "" { + return nil, errors.New("empty base64 input") + } + + var lastErr error + for _, enc := range []*base64.Encoding{ + base64.RawURLEncoding, + base64.StdEncoding, + base64.URLEncoding, + } { + decoded, err := enc.DecodeString(s) + if err == nil { + return decoded, nil + } + lastErr = err + } + return nil, lastErr +} + +func biometricCredential(credentials map[string]interface{}) (string, bool) { + if val, ok := credentials["biometric"].(string); ok && val != "" { + return val, true + } + return "", false } // GenerateHashWithErr returns the SHA-256 hash of data. @@ -596,7 +642,7 @@ func (p *mosipAuthnProvider) callSendOtpEndpoint( signature string, // from helperService.getRequestSignature(requestBody) relyingPartyID string, clientID string, -) (*SendOTPResult, error) { +) (*shared.SendOTPResult, error) { endpointURL, err := buildIDAEndpointURL(p.cfg.SendOTPBaseURL, relyingPartyID, clientID) if err != nil { return nil, fmt.Errorf("invalid send OTP URL: %w", err) @@ -636,7 +682,7 @@ func (p *mosipAuthnProvider) callSendOtpEndpoint( // Success path if wrapper.Response != nil { - return &SendOTPResult{ + return &shared.SendOTPResult{ MaskedEmail: wrapper.Response.MaskedEmail, MaskedMobile: wrapper.Response.MaskedMobile, }, nil @@ -667,15 +713,15 @@ func (p *mosipAuthnProvider) callKycAuthEndpoint( relyingPartyID string, clientID string, _ bool, -) (*host.AuthnResult, error) { +) (string, string, error) { endpointURL, err := buildIDAEndpointURL(p.cfg.KYCAuthBaseURL, relyingPartyID, clientID) if err != nil { - return nil, fmt.Errorf("invalid KYC auth URL: %w", err) + return "", "", err } req, err := http.NewRequest(http.MethodPost, endpointURL, bytes.NewReader(requestBody)) if err != nil { - return nil, fmt.Errorf("failed to create KYC auth request: %w", err) + return "", "", err } req.Header.Set("Content-Type", "application/json; charset=utf-8") @@ -684,39 +730,35 @@ func (p *mosipAuthnProvider) callKycAuthEndpoint( resp, err := p.client.Do(req) if err != nil { - return nil, fmt.Errorf("KYC auth request failed: %w", err) + return "", "", err } defer func() { _ = resp.Body.Close() }() // Read body once bodyBytes, err := io.ReadAll(resp.Body) if err != nil { - return nil, fmt.Errorf("failed to read KYC auth response: %w", err) + return "", "", err } // Check status if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("unexpected KYC auth status: %d - %s", resp.StatusCode, string(bodyBytes)) + return "", "", err } // Parse response var wrapper IdaResponseWrapper if err := json.Unmarshal(bodyBytes, &wrapper); err != nil { - return nil, fmt.Errorf("failed to parse IdaResponseWrapper: %w", err) + return "", "", err } // Success path if wrapper.Response != nil && wrapper.Response.KycStatus && wrapper.Response.KycToken != "" { - return &host.AuthnResult{ - Authenticated: wrapper.Response.KycStatus, - UserID: wrapper.Response.AuthToken, - AuthToken: wrapper.Response.KycToken, - }, nil + return wrapper.Response.AuthToken, wrapper.Response.KycToken, nil } // Error path if wrapper.Response == nil { - return nil, errors.New("response object is missing in wrapper") + return "", "", err } applog.GetLogger().Error("IDA KYC auth error response", @@ -724,12 +766,12 @@ func (p *mosipAuthnProvider) callKycAuthEndpoint( applog.Any("errors", wrapper.Errors)) if len(wrapper.Errors) == 0 { - return nil, errors.New("no errors in response wrapper") + return "", "", err } // Take first error (common pattern) firstErr := wrapper.Errors[0] - return nil, fmt.Errorf("%s: %s", firstErr.ErrorMessage, firstErr.ActionMessage) + return "", "", fmt.Errorf("%s: %s", firstErr.ErrorMessage, firstErr.ActionMessage) } // PerformKycExchange sends the KYC exchange request to IDA and processes the response @@ -738,7 +780,7 @@ func (p *mosipAuthnProvider) callKycExchangeEndpoint( signature string, relyingPartyID string, clientID string, -) (*host.GetAttributesResult, error) { +) (*providers.AttributesResponse, error) { endpointURL, err := buildIDAEndpointURL(p.cfg.KYCExchangeBaseURL, relyingPartyID, clientID) if err != nil { return nil, fmt.Errorf("invalid KYC exchange URL: %w", err) @@ -782,12 +824,15 @@ func (p *mosipAuthnProvider) callKycExchangeEndpoint( if err != nil { return nil, fmt.Errorf("failed to decode encrypted KYC JWT: %w", err) } - attributesJSON, err := json.Marshal(userattributes) - if err != nil { - return nil, fmt.Errorf("failed to marshal user attributes: %w", err) - } - return &host.GetAttributesResult{ - Attributes: attributesJSON, + return &providers.AttributesResponse{ + Attributes: map[string]*providers.AttributeResponse{ + "sub": { + Value: userattributes["sub"], + }, + "name": { + Value: userattributes["name"], + }, + }, }, nil } @@ -849,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/mosip/mosip_otp_executor.go b/esignet-service/internal/engine/mosip/mosip_otp_executor.go deleted file mode 100644 index dfb88753c..000000000 --- a/esignet-service/internal/engine/mosip/mosip_otp_executor.go +++ /dev/null @@ -1,133 +0,0 @@ -package mosip - -import ( - "fmt" - "strings" - - "github.com/thunder-id/thunderid/pkg/thunderidengine" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" - - applog "github.com/mosip/esignet/internal/log" -) - -const ( - // ExecutorNameMosipOTP sends OTP via the MOSIP IDA API. - ExecutorNameMosipOTP = "MosipOtpExecutor" - usernameAttr = "username" -) - -var individualIDInput = thunderidengine.ExecutorInput{ - Identifier: usernameAttr, - Type: "TEXT_INPUT", - Required: true, -} - -type mosipOtpExecutor struct { - thunderidengine.ExecutorInterface - authn OTPAuthnProvider -} - -// NewMosipOtpExecutor creates an executor that sends OTP via the MOSIP IDA API. -func NewMosipOtpExecutor(authn OTPAuthnProvider) thunderidengine.ExecutorInterface { - return &mosipOtpExecutor{ - ExecutorInterface: thunderidengine.NewBaseExecutor( - ExecutorNameMosipOTP, - thunderidengine.ExecutorTypeAuthentication, - nil, - []thunderidengine.ExecutorInput{individualIDInput}, - ), - authn: authn, - } -} - -func (e *mosipOtpExecutor) Execute(ctx *thunderidengine.ExecutorNodeContext) (*thunderidengine.ExecutorResponse, error) { - execResp := &thunderidengine.ExecutorResponse{ForwardedData: make(map[string]any)} - - if !e.ensurePrerequisites(ctx, execResp) { - return execResp, nil - } - - username, err := usernameFromContext(ctx) - if err != nil { - return nil, err - } - - result, err := e.authn.SendOTP(ctx.Context, map[string]any{"username": username}, buildAuthnMetadata(ctx)) - if err != nil { - return execResp, fmt.Errorf("failed to send OTP via MOSIP API: %w", err) - } - - execResp.ForwardedData["maskedEmail"] = result.MaskedEmail - execResp.ForwardedData["maskedMobile"] = result.MaskedMobile - execResp.Status = thunderidengine.ExecComplete - return execResp, nil -} - -func (e *mosipOtpExecutor) ensurePrerequisites( - ctx *thunderidengine.ExecutorNodeContext, execResp *thunderidengine.ExecutorResponse, -) bool { - if e.ValidatePrerequisites(ctx, execResp, nil) { - return true - } - - execResp.Status = thunderidengine.ExecUserInputRequired - execResp.Inputs = []thunderidengine.ExecutorInput{individualIDInput} - return false -} - -func buildAuthnMetadata(ctx *thunderidengine.ExecutorNodeContext) *host.AuthnMetadata { - appMetadata := make(map[string]any) - - if ctx.Application.Metadata != nil { - for key, value := range ctx.Application.Metadata { - appMetadata[key] = value - } - } - - var clientIDs []string - for _, inboundConfig := range ctx.Application.InboundAuthConfig { - if inboundConfig.OAuthConfig != nil && inboundConfig.OAuthConfig.ClientID != "" { - clientIDs = append(clientIDs, inboundConfig.OAuthConfig.ClientID) - } - } - if len(clientIDs) > 0 { - appMetadata["client_ids"] = clientIDs - } - - if ctx.RuntimeData == nil { - ctx.RuntimeData = make(map[string]string) - } - - if _, exists := ctx.RuntimeData["ext_TransactionID"]; !exists { - mosipTransactionID, err := GenerateTransactionID(nil) - if err != nil { - applog.GetLogger().Warn("failed to generate transaction ID", applog.Error(err)) - } else { - ctx.RuntimeData["ext_TransactionID"] = mosipTransactionID - } - } - - runtimeMetadata := map[string]string{ - "authorization_request_id": ctx.RuntimeData["authorizationRequestId"], - "current_client_id": ctx.RuntimeData["clientId"], - } - - for key, value := range ctx.RuntimeData { - // Only the ext_* runtime data keys are passed to the authn provider. - if strings.HasPrefix(key, "ext_") { - runtimeMetadata[key] = value - } - } - - return &host.AuthnMetadata{AppMetadata: appMetadata, RuntimeMetadata: runtimeMetadata} -} - -func usernameFromContext(ctx *thunderidengine.ExecutorNodeContext) (string, error) { - if username := ctx.UserInputs[usernameAttr]; username != "" { - return username, nil - } - if username := ctx.RuntimeData[usernameAttr]; username != "" { - return username, nil - } - return "", fmt.Errorf("username not found in context") -} diff --git a/esignet-service/internal/engine/observability_provider.go b/esignet-service/internal/engine/observability_provider.go new file mode 100644 index 000000000..47542b948 --- /dev/null +++ b/esignet-service/internal/engine/observability_provider.go @@ -0,0 +1,23 @@ +package engine + +import ( + "context" + + engineconfig "github.com/thunder-id/thunderid/pkg/thunderidengine/config" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +type observabilityProvider struct { + enabled bool +} + +// NewObservabilityProvider returns a no-op observability provider for local development. +func NewObservabilityProvider(cfg engineconfig.ObservabilityConfig) providers.ObservabilityProvider { + return &observabilityProvider{enabled: cfg.Enabled} +} + +func (p *observabilityProvider) PublishEvent(_ context.Context, _ *providers.Event) {} + +func (p *observabilityProvider) IsEnabled() bool { + return p.enabled +} diff --git a/esignet-service/internal/engine/ou_provider.go b/esignet-service/internal/engine/ou_provider.go new file mode 100644 index 000000000..79019a180 --- /dev/null +++ b/esignet-service/internal/engine/ou_provider.go @@ -0,0 +1,43 @@ +package engine + +import ( + "context" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" +) + +type ouProvider struct { + cfg *config.AppConfig +} + +// NewOUProvider returns a stub organization unit provider for local development. +func NewOUProvider(cfg *config.AppConfig) providers.OrganizationUnitProvider { + return &ouProvider{cfg: cfg} +} + +func (p *ouProvider) GetOrganizationUnit(_ context.Context, _ string) (providers.OrganizationUnit, *common.ServiceError) { + return providers.OrganizationUnit{}, nil +} + +func (p *ouProvider) GetOrganizationUnitList(_ context.Context, _ int, _ int, _ *common.FilterGroup) (*providers.OrganizationUnitListResponse, *common.ServiceError) { + return &providers.OrganizationUnitListResponse{}, nil +} + +func (p *ouProvider) CreateOrganizationUnit(_ context.Context, _ providers.OrganizationUnitRequestWithID) (providers.OrganizationUnit, *common.ServiceError) { + return providers.OrganizationUnit{}, nil +} + +func (p *ouProvider) IsParent(_ context.Context, _, _ string) (bool, *common.ServiceError) { + return true, nil +} + +func (p *ouProvider) IsOrganizationUnitExists(_ context.Context, _ string) (bool, *common.ServiceError) { + return true, nil +} + +func (p *ouProvider) GetOrganizationUnitChildren(_ context.Context, _ string, _ int, _ int, _ *common.FilterGroup) (*providers.OrganizationUnitListResponse, *common.ServiceError) { + return &providers.OrganizationUnitListResponse{}, nil +} diff --git a/esignet-service/internal/engine/resource_provider.go b/esignet-service/internal/engine/resource_provider.go new file mode 100644 index 000000000..612dd02de --- /dev/null +++ b/esignet-service/internal/engine/resource_provider.go @@ -0,0 +1,37 @@ +package engine + +import ( + "context" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + + "github.com/mosip/esignet/internal/config" +) + +type resourceProvider struct { + cfg *config.AppConfig +} + +// NewResourceProvider returns a stub resource server provider for local development. +func NewResourceProvider(cfg *config.AppConfig) providers.ResourceServerProvider { + return &resourceProvider{cfg: cfg} +} + +func (p *resourceProvider) GetResourceServerByIdentifier( + _ context.Context, _ string, +) (*providers.ResourceServer, *common.ServiceError) { + return &providers.ResourceServer{}, nil +} + +func (p *resourceProvider) ValidatePermissions( + _ context.Context, _ string, _ []string, +) ([]string, *common.ServiceError) { + return []string{}, nil +} + +func (p *resourceProvider) FindResourceServersByPermissions( + _ context.Context, _ []string, +) ([]providers.ResourceServer, *common.ServiceError) { + return []providers.ResourceServer{}, nil +} diff --git a/esignet-service/internal/engine/shared/errors.go b/esignet-service/internal/engine/shared/errors.go new file mode 100644 index 000000000..7cdefcd4e --- /dev/null +++ b/esignet-service/internal/engine/shared/errors.go @@ -0,0 +1,116 @@ +// Package shared provides common types and errors for authentication providers. +package shared + +import "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + +// NotImplementedError is returned when a feature is not implemented. +var NotImplementedError = &common.ServiceError{ + Code: "not_implemented", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "not_implemented", + DefaultValue: "Not implemented", + }, + ErrorDescription: common.I18nMessage{ + Key: "not_implemented_description", + DefaultValue: "The feature is not implemented", + }, +} + +// ClientNotFoundError is returned when a client is not found. +var ClientNotFoundError = &common.ServiceError{ + Code: "client_not_found", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "client_not_found", + DefaultValue: "Client not found", + }, + ErrorDescription: common.I18nMessage{ + Key: "client_not_found_description", + DefaultValue: "The client with the given ID was not found", + }, +} + +// InvalidIndividualIDError is returned when the individual_id in identifiers is missing or invalid. +var InvalidIndividualIDError = &common.ServiceError{ + Code: "missing_or_invalid_individual_id", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "missing_or_invalid_individual_id", + DefaultValue: "Missing or invalid individual_id in identifiers", + }, + ErrorDescription: common.I18nMessage{ + Key: "missing_or_invalid_individual_id_description", + DefaultValue: "The individual_id in identifiers is missing or invalid", + }, +} + +// InvalidRequestError is returned when the request is invalid. +var InvalidRequestError = &common.ServiceError{ + Code: "invalid_request", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "invalid_request", + DefaultValue: "Invalid request", + }, + ErrorDescription: common.I18nMessage{ + Key: "invalid_request_description", + DefaultValue: "The request is invalid", + }, +} + +// AuthenticationFailedError is returned when the authentication failed. +var AuthenticationFailedError = &common.ServiceError{ + Code: "authentication_failed", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "authentication_failed", + DefaultValue: "Authentication failed", + }, + ErrorDescription: common.I18nMessage{ + Key: "authentication_failed_description", + DefaultValue: "The authentication failed with the given credentials", + }, +} + +// SendOTPFailedError is returned when the send OTP failed. +var SendOTPFailedError = &common.ServiceError{ + Code: "send_otp_failed", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "send_otp_failed", + DefaultValue: "Send OTP failed", + }, + ErrorDescription: common.I18nMessage{ + Key: "send_otp_failed_description", + DefaultValue: "The send OTP failed with the given credentials", + }, +} + +// FileNotFoundError is returned when a required configuration file is not found. +var FileNotFoundError = &common.ServiceError{ + Code: "file_not_found", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "file_not_found", + DefaultValue: "File not found", + }, + ErrorDescription: common.I18nMessage{ + Key: "file_not_found_description", + DefaultValue: "The file with the given ID was not found", + }, +} + +// FileUnmarshallError is returned when a configuration file cannot be unmarshalled. +var FileUnmarshallError = &common.ServiceError{ + Code: "file_unmarshall_error", + Type: common.ClientErrorType, + Error: common.I18nMessage{ + Key: "file_unmarshall_error", + DefaultValue: "File unmarshall error", + }, + ErrorDescription: common.I18nMessage{ + Key: "file_unmarshall_error_description", + DefaultValue: "The file could not be unmarshalled", + }, +} diff --git a/esignet-service/internal/engine/shared/interface.go b/esignet-service/internal/engine/shared/interface.go new file mode 100644 index 000000000..907a11679 --- /dev/null +++ b/esignet-service/internal/engine/shared/interface.go @@ -0,0 +1,15 @@ +package shared + +import ( + "context" + + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" +) + +// ConsolidatedAuthnProvider extends providers.AuthnProviderManager with OTP send capability. +type ConsolidatedAuthnProvider interface { + providers.AuthnProviderManager + SendOTP(_ context.Context, identifiers map[string]interface{}, + metadata *providers.AuthnMetadata) (*SendOTPResult, *common.ServiceError) +} diff --git a/esignet-service/internal/engine/shared/model.go b/esignet-service/internal/engine/shared/model.go new file mode 100644 index 000000000..c4dcb563f --- /dev/null +++ b/esignet-service/internal/engine/shared/model.go @@ -0,0 +1,7 @@ +package shared + +// SendOTPResult represents the result of an generate and notify OTP attempt. +type SendOTPResult struct { + MaskedEmail string `json:"maskedEmail,omitempty"` + MaskedMobile string `json:"maskedMobile,omitempty"` +} 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 98d2de0ec..1b024de71 100644 --- a/esignet-service/internal/engine/sunbird/sunbird_authn.go +++ b/esignet-service/internal/engine/sunbird/sunbird_authn.go @@ -9,8 +9,10 @@ import ( "net/http" "time" - "github.com/thunder-id/thunderid/pkg/thunderidengine/host" + "github.com/thunder-id/thunderid/pkg/thunderidengine/common" + "github.com/thunder-id/thunderid/pkg/thunderidengine/providers" + "github.com/mosip/esignet/internal/engine/shared" applog "github.com/mosip/esignet/internal/log" ) @@ -45,7 +47,11 @@ type sunbirdAuthnProvider struct { // NewSunbirdAuthnProvider creates a SunbirdRC registry-backed host.AuthnProvider. // 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(cfg Config) (host.AuthnProvider, error) { +func NewSunbirdAuthnProvider() (shared.ConsolidatedAuthnProvider, error) { + cfg, err := LoadConfig() + if err != nil { + return nil, err + } if err := cfg.Validate(); err != nil { return nil, err } @@ -70,25 +76,32 @@ func NewSunbirdAuthnProvider(cfg Config) (host.AuthnProvider, error) { timeout = 10 * time.Second } return &sunbirdAuthnProvider{ - cfg: cfg, + cfg: *cfg, client: &http.Client{Timeout: timeout}, kbiFieldIDs: kbiFieldIDs, }, nil } -func (p *sunbirdAuthnProvider) Authenticate(ctx context.Context, identifiers, credentials map[string]interface{}, - _ *host.AuthnMetadata) (*host.AuthnResult, error) { +func (p *sunbirdAuthnProvider) SendOTP(_ context.Context, _ map[string]interface{}, + _ *providers.AuthnMetadata) (*shared.SendOTPResult, *common.ServiceError) { + return nil, shared.NotImplementedError +} + +func (p *sunbirdAuthnProvider) AuthenticateUser(ctx context.Context, identifiers, credentials map[string]interface{}, + _ *providers.RequestedAttributes, + _ *providers.AuthnMetadata, + authUser providers.AuthUser) (providers.AuthUser, providers.AuthenticatedClaims, *common.ServiceError) { individualID, ok := identifiers[sunbirdIndividualIDKey].(string) if !ok || individualID == "" { - return nil, fmt.Errorf("missing or invalid %q in identifiers", sunbirdIndividualIDKey) + return authUser, nil, shared.InvalidIndividualIDError } kbiFields := make(map[string]string, len(p.kbiFieldIDs)) for _, id := range p.kbiFieldIDs { val, ok := credentials[id].(string) if !ok || val == "" { - return nil, fmt.Errorf("missing or invalid KBI field %q in credentials", id) + return authUser, nil, shared.InvalidRequestError } kbiFields[id] = val } @@ -96,41 +109,47 @@ func (p *sunbirdAuthnProvider) Authenticate(ctx context.Context, identifiers, cr entityID, err := p.validateKBI(ctx, individualID, kbiFields) if err != nil { if errors.Is(err, errSunbirdKBIAuthFailed) { - return nil, errSunbirdKBIAuthFailed + return authUser, nil, shared.InvalidRequestError } - return nil, fmt.Errorf("sunbird registry search failed: %w", err) + return authUser, nil, shared.AuthenticationFailedError } - return &host.AuthnResult{ - Authenticated: true, - UserID: entityID, - AuthToken: entityID, - }, nil + authUser.SetEntityReferenceToken(entityID) + authUser.SetAttributeToken(entityID) + return authUser, nil, nil } -func (p *sunbirdAuthnProvider) GetAttributes(ctx context.Context, token string, _ *host.RequestedAttributes, - _ *host.GetAttributesMetadata) (*host.GetAttributesResult, error) { - - if p.cfg.EntityURL == "" { - return &host.GetAttributesResult{Attributes: json.RawMessage("{}")}, nil - } +func (p *sunbirdAuthnProvider) GetUserAttributes(ctx context.Context, + _ *providers.RequestedAttributes, + _ *providers.GetAttributesMetadata, + authUser providers.AuthUser) (providers.AuthUser, *providers.AttributesResponse, *common.ServiceError) { - entityData, err := p.fetchEntityData(ctx, token) + entityData, err := p.fetchEntityData(ctx, authUser.EntityReferenceToken().(string)) if err != nil { - return nil, fmt.Errorf("failed to fetch sunbird entity data: %w", err) + return authUser, nil, shared.InvalidRequestError } mappedClaims := buildSunbirdMappedClaims(entityData, p.cfg.ClaimsMapping) - attributesJSON, err := json.Marshal(mappedClaims) - if err != nil { - return nil, fmt.Errorf("failed to marshal sunbird attributes: %w", err) + + mappedClaimsMap := make(map[string]*providers.AttributeResponse, len(mappedClaims)) + for claim, value := range mappedClaims { + mappedClaimsMap[claim] = &providers.AttributeResponse{Value: value} } - return &host.GetAttributesResult{Attributes: attributesJSON}, nil + return authUser, &providers.AttributesResponse{Attributes: mappedClaimsMap}, nil +} + +func (p *sunbirdAuthnProvider) GetEntityReference(_ context.Context, + authUser providers.AuthUser) (providers.AuthUser, *providers.EntityReference, *common.ServiceError) { + return authUser, nil, nil +} + +func (p *sunbirdAuthnProvider) GetUserAvailableAttributes(_ context.Context, + _ providers.AuthUser) (*providers.AttributesResponse, *common.ServiceError) { + return &providers.AttributesResponse{}, nil } -func (p *sunbirdAuthnProvider) validateKBI(ctx context.Context, individualID string, - kbiFields map[string]string) (string, error) { +func (p *sunbirdAuthnProvider) validateKBI(ctx context.Context, individualID string, kbiFields map[string]string) (string, error) { filters := make(map[string]sunbirdSearchFilter, len(kbiFields)+1) filters[p.cfg.IDField] = sunbirdSearchFilter{Eq: individualID} diff --git a/esignet-service/make.sh b/esignet-service/make.sh index 98469b715..700f40d53 100755 --- a/esignet-service/make.sh +++ b/esignet-service/make.sh @@ -57,9 +57,9 @@ fi : "${DOCKER_IMAGE:=esignet:latest}" : "${GOLANGCI_LINT_VERSION:=latest}" : "${SQLC_VERSION:=v1.29.0}" -: "${THUNDER_BRANCH:=engine}" +: "${THUNDER_BRANCH:=main}" : "${RACE:=1}" # set RACE=0 if no C toolchain (go test -race needs gcc on Windows) -THUNDER_MODULE=github.com/anushasunkada/thunder/backend +THUNDER_MODULE=github.com/thunder-id/thunderid/backend need() { command -v "$1" >/dev/null 2>&1 || { echo "make.sh: '$1' not found on PATH (required for this target)"; exit 1; }