diff --git a/CLAUDE.md b/CLAUDE.md index 8fb4c8a7f3..204edbc9df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,15 +11,15 @@ > **Migration plan**: see [`LIFT_HTTP4S_MIGRATION.md`](LIFT_HTTP4S_MIGRATION.md) for the full in-place Lift → http4s strategy, file order, auth stack workstream, and progress tracker. -The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently serves 45 endpoints; most arrived there for historical reasons and stay as-is. +The goal is a full http4s migration — replace Lift Web across all version files and remove it entirely. **API versions are tech-agnostic**: a version bump means a changed/new API signature, never a framework change. Framework migration happens in-place inside the existing version file. v7.0.0 currently serves 46 endpoints; most arrived there for historical reasons and stay as-is. **Request priority chain** (`Http4sApp.baseServices`): `corsHandler` (OPTIONS short-circuit) → `AppsPage` → `StatusPage` → `Http4sResourceDocs` → v510 → v600 → v500 → v700 → Berlin Group v2 → UK v2.0 → UK v3.1 → Berlin Group v1.3 (+Alias) → v400 → v310 → v300 → v220 → v210 → v200 → v140 → v130 → v121 → `dynamicEntityRoutes` → `dynamicEndpointRoutes` → DirectLogin → OpenIdConnect → AliveCheck → `notFoundCatchAll` (JSON 404). There is no Lift fallback — `Http4sLiftWebBridge` has been removed. Any unhandled `/obp/*` path returns a JSON 404 from `notFoundCatchAll`; it does not fall through to Lift. **Key files**: `Http4s700.scala` (v7.0.0 endpoints), `Http4s200.scala` (v2.0.0 endpoints — 37 own + path-rewriting bridge to Http4s140), `Http4s140.scala` (v1.4.0 endpoints — 11 own + path-rewriting bridge to Http4s130), `Http4s130.scala` (v1.3.0 endpoints — 3 own + path-rewriting bridge to Http4s121), `Http4s121.scala` (v1.2.1 endpoints — all 323 API1_2_1Test scenarios), `Http4sSupport.scala` (EndpointHelpers + recordMetric), `ResourceDocMiddleware.scala` (auth, entity resolution, transaction wrapper), `IdempotencyMiddleware.scala` (Redis-backed idempotency, opt-in via `Idempotency-Key` header, nested inside ResourceDocMiddleware), `RequestScopeConnection.scala` (DB transaction propagation to Futures). -**v7.0.0 native endpoints** (45 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. +**v7.0.0 native endpoints** (46 ResourceDocs): root, corePrivateAccountsAllBanks, deleteEntitlement, addEntitlement, getAccountAccessTrace, getConsentsConfig, getErrorMessages, getUserByUserId, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, notifyDeposit, requestWithdrawal, createPaymentAuth, capturePaymentAuth, releasePaymentAuth, getPaymentAuth, createTestEmail, createValidationEmail, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation, createRoutingScheme, getRoutingSchemes, getRoutingScheme, updateRoutingScheme, deleteRoutingScheme, getBankSupportedRoutingSchemes, putBankSupportedRoutingScheme, createPayeeLookup, createTransactionRequestMobileWallet, createTransactionRequestUtility, createTransactionRequestOpenCorridor, createTransactionRequestBulk, factoryResetSystemView. These carry genuinely v7-specific signatures/behaviour. The 20 duplicate "POC" endpoints originally added as migration scaffolding (getBanks, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, getFeatures, getScannedApiVersions, getConnectors, getProviders, getUsers, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces) were **removed** — they cascade to their v6 twin via `v700ToV600Bridge` (getExplicitCounterpartyById → v4, no v6/v5 twin), `X-OBP-Version-Served: v6.0.0`. Kept deliberately in v7: `deleteEntitlement` (204), `addEntitlement` (409), `getUserByUserId` (404) — intentional RESTful response-code improvements over the older v6 200/400 convention. -**Tests**: `Http4s700RoutesTest` (86 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. +**Tests**: `Http4s700RoutesTest` (91 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. ## Migrating a Lift Endpoint to http4s Rules apply regardless of which version file the endpoint lives in. Use v7.0.0 only when the API signature is new or changed; otherwise migrate in-place in the original version file. diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index af90c7ecc0..41cddbdff3 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -806,51 +806,12 @@ super_admin_user_ids=USER_ID1,USER_ID2, ########################## -## Open Id Connect (OIDC) can be used to retrieve User information from an -## external OpenID Connect server. -## This will enable authentication of an external user stored in an external db, -## and will create a local OBP resource user in the OBP DB. -## To use an external OpenID Connect server, -## you will need to change these values. -## The following values provided for a temp test account. -## CallbackURL 127.0.0.1:8080 should work in most cases. -## Note: The email address used for login must match one -## registered on OBP localy. -# openid_connect.enabled=false -# CONFIG CAVEAT: portal-login was removed, so there is no server-side session state to compare. -# The code default is true (fail-closed) and will reject every real (non-empty) state with 401 — -# OIDC deployments MUST set this to false (or reintroduce state storage): -# openid_connect.check_session_state=false -# openid_connect.show_tokens=false -# Response mode -# possible values: query, fragment, form_post, query.jwt, fragment.jwt, form_post.jwt, jwt -# openid_connect.response_mode=form_post -# Response type -# possible values: "code", "id_token", "code id_token" -# openid_connect.response_type=code -# Scope -# possible values: "openid email profile", "openid email", "openid" -# openid_connect.scope=openid email profile -# First identity provider -# openid_connect_1.button_text = Google -# openid_connect_1.client_secret=OYdWujJlU7fFOW_NXzPlDI4T -# openid_connect_1.client_id=883773244832-s4hi72j0rble0iiivq1gn09k7vvptdci.apps.googleusercontent.com -# openid_connect_1.callback_url=http://127.0.0.1:8080/auth/openid-connect/callback -# openid_connect_1.endpoint.authorization=https://accounts.google.com/o/oauth2/v2/auth -# openid_connect_1.endpoint.userinfo=https://openidconnect.googleapis.com/v1/userinfo -# openid_connect_1.endpoint.token=https://oauth2.googleapis.com/token -# openid_connect_1.endpoint.jwks_uri=https://www.googleapis.com/oauth2/v3/certs -# openid_connect_1.access_type_offline=true -## Second identity provder -# openid_connect_2.button_text = name of 2nd provider -# openid_connect_2.client_secret=OYdWujJlU7fFOW_NXzPlDI4T -# openid_connect_2.client_id=883773244832-s4hi72j0rble0iiivq1gn09k7vvptdci.apps.googleusercontent.com -# openid_connect_2.callback_url=http://127.0.0.1:8080/auth/openid-connect/callback-2 -# openid_connect_2.endpoint.authorization=https://accounts.google.com/o/oauth2/v2/auth -# openid_connect_2.endpoint.userinfo=https://openidconnect.googleapis.com/v1/userinfo -# openid_connect_2.endpoint.token=https://oauth2.googleapis.com/token -# openid_connect_2.endpoint.jwks_uri=https://www.googleapis.com/oauth2/v3/certs -# openid_connect_2.access_type_offline=false +# NOTE: OBP-API is a pure OAuth2 *resource server*. The OIDC relying-party login +# callback (/auth/openid-connect/callback{,-1,-2}) and its openid_connect.* / +# openid_connect_$n.* props were removed — the client/BFF runs the OIDC login +# against the provider and calls OBP with `Authorization: Bearer `. +# Configure Bearer-JWT validation via the oauth2.* props further below +# (oauth2.jwk_set.url, oauth2.keycloak.*, oauth2.obp_oidc.*). # When new consumers inserted they should use this setting. consumers_enabled_by_default=true @@ -1320,18 +1281,6 @@ outboundAdapterCallContext.generalContext ## This should include the JWKS URI for OBP-OIDC provider ## Multiple JWKS URIs can be comma-separated #oauth2.jwk_set.url=http://localhost:9000/obp-oidc/jwks - -## OpenID Connect Client Configuration for OBP-OIDC -#openid_connect_1.button_text=OBP-OIDC -#openid_connect_1.client_id=obp-api-client -#openid_connect_1.client_secret=your-client-secret-here -#openid_connect_1.callback_url=http://localhost:8080/auth/openid-connect/callback -#openid_connect_1.endpoint.discovery=http://localhost:9000/obp-oidc/.well-known/openid-configuration -#openid_connect_1.endpoint.authorization=http://localhost:9000/obp-oidc/auth -#openid_connect_1.endpoint.userinfo=http://localhost:9000/obp-oidc/userinfo -#openid_connect_1.endpoint.token=http://localhost:9000/obp-oidc/token -#openid_connect_1.endpoint.jwks_uri=http://localhost:9000/obp-oidc/jwks -#openid_connect_1.access_type_offline=true # ------------------------------ OBP-OIDC oauth2 props end ------------------------------ # ------------------------------ default entitlements ------------------------------ diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index 3abe66ef28..38ae93e188 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -89,6 +89,7 @@ import code.group.Group import code.organisation.Organisation import code.routingscheme.{RoutingScheme, BankSupportedRoutingScheme} import code.payeelookup.PayeeLookup +import code.utilitypayment.UtilityPaymentCallback import code.bulkpayment.{BulkPayment, BulkBatchReference} import code.kycchecks.MappedKycCheck import code.kycdocuments.MappedKycDocument @@ -281,7 +282,7 @@ class Boot extends MdcLoggable { // Please note that migration scripts are executed after Lift Mapper Schemifier Migration.database.executeScripts(startedBeforeSchemifier = false) - // Idempotent seed of country-qualified routing schemes (TZ.MSISDN, GePG, Luku, etc.). + // Idempotent seed of country-qualified routing schemes (TZ.MSISDN, bill, utility, etc.). // Toggle off via routing_schemes.seed_defaults_at_boot=false in environments that don't want defaults. code.routingscheme.RoutingSchemeSeed.runIfEnabled() @@ -479,12 +480,12 @@ class Boot extends MdcLoggable { enableVersionIfAllowed(ApiVersion.`dynamic-endpoint`) enableVersionIfAllowed(ApiVersion.`dynamic-entity`) - // OpenID Connect callbacks (/auth/openid-connect/callback{,-1,-2}), DirectLogin - // (POST /my/logins/direct) and aliveCheck (GET /alive) are now served by their - // native http4s counterparts wired into Http4sApp.baseServices - // (Http4sOpenIdConnect / DirectLoginRoutes / AliveCheckRoutes). The Lift - // dispatches were retired in the http4s migration; any prop gates - // (e.g. `openid_connect.enabled`, `allow_direct_login`) live with those routes. + // DirectLogin (POST /my/logins/direct) and aliveCheck (GET /alive) are now served + // by their native http4s counterparts wired into Http4sApp.baseServices + // (DirectLoginRoutes / AliveCheckRoutes). The Lift dispatches were retired in the + // http4s migration; any prop gates (e.g. `allow_direct_login`) live with those + // routes. The OBP-as-relying-party OpenID Connect callback was removed: OBP is a + // pure OAuth2 resource server (Bearer-JWT validation), login is done by the client/BFF. ////////////////////////////////////////////////////////////////////////////////////////////////// // Resource Docs are used in the process of surfacing endpoints so we enable them explicitly @@ -1029,6 +1030,7 @@ object ToSchemify extends MdcLoggable { RoutingScheme, BankSupportedRoutingScheme, PayeeLookup, + UtilityPaymentCallback, BulkPayment, BulkBatchReference, AccountAccessRequest, diff --git a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala b/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala deleted file mode 100644 index 0d0c5747f5..0000000000 --- a/obp-api/src/main/scala/code/api/Http4sOpenIdConnect.scala +++ /dev/null @@ -1,408 +0,0 @@ -/** -Open Bank Project - API -Copyright (C) 2011-2019, TESOBE GmbH - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU Affero General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU Affero General Public License for more details. - -You should have received a copy of the GNU Affero General Public License -along with this program. If not, see . - -Email: contact@tesobe.com -TESOBE GmbH. -Osloer Strasse 16/17 -Berlin 13359, Germany - -This product includes software developed at -TESOBE (http://www.tesobe.com/) - - */ -package code.api - -import cats.effect.IO -import code.api.OAuth2Login.Hydra -import code.api.util.APIUtil._ -import code.api.util.http4s.{ErrorResponseConverter, Http4sCallContextBuilder} -import code.api.util.{APIUtil, AfterApiAuth, CustomJsonFormats, ErrorMessages, JwtUtil} -import code.api.v6_0_0.JSONFactory600 -import code.consumer.Consumers -import code.loginattempts.LoginAttempt -import code.model.dataAccess.AuthUser -import code.model.{AppType, Consumer} -import code.token.{OpenIDConnectToken, TokensOpenIDConnect} -import code.users.Users -import code.util.Helper.MdcLoggable -import com.openbankproject.commons.model.User -import net.liftweb.common._ -import net.liftweb.db.DB -import net.liftweb.json -import net.liftweb.json.JsonAST.prettyRender -import net.liftweb.json.{Extraction, Formats} -import net.liftweb.mapper.By -import net.liftweb.util.DefaultConnectionIdentifier -import net.liftweb.util.Helpers -import net.liftweb.util.Helpers._ -import org.http4s._ -import org.http4s.dsl.io._ -import org.http4s.headers.`Content-Type` - -import java.net.HttpURLConnection -import javax.net.ssl.HttpsURLConnection - -/** - * Per-identity-provider OpenID Connect configuration, read from - * `openid_connect_$provider.*` props. Moved verbatim from the retired Lift - * `openidconnect.scala`; consumed by [[Http4sOpenIdConnect]]. - */ -case class OpenIdConnectConfig(client_secret: String, - client_id: String, - callback_url: String, - userinfo_endpoint: String, - token_endpoint: String, - authorization_endpoint: String, - jwks_uri: String, - access_type_offline: Boolean - ) - -object OpenIdConnectConfig { - lazy val openIDConnectEnabled = code.api.Constant.openidConnectEnabled - def getProps(props: String): String = { - APIUtil.getPropsValue(props).getOrElse("") - } - def get(provider: Int): OpenIdConnectConfig = { - OpenIdConnectConfig( - getProps(s"openid_connect_$provider.client_secret"), - getProps(s"openid_connect_$provider.client_id"), - getProps(s"openid_connect_$provider.callback_url"), - getProps(s"openid_connect_$provider.endpoint.userinfo"), - getProps(s"openid_connect_$provider.endpoint.token"), - getProps(s"openid_connect_$provider.endpoint.authorization"), - getProps(s"openid_connect_$provider.endpoint.jwks_uri"), - APIUtil.getPropsAsBoolValue(s"openid_connect_$provider.access_type_offline", false), - ) - } -} - -/** - * Native http4s OpenID Connect callback, replacing the Lift `OpenIdConnect` - * `serve {}` dispatch. OBP-API acts as the OIDC relying party: an external - * provider (OBP-OIDC, Keycloak, ...) authenticates the user, redirects the - * browser to one of these callbacks with `?code=...&state=...`, and the handler - * exchanges the code for tokens server-side. - * - * Provider contract preserved unchanged: the three callback paths, the - * form-encoded token exchange to `openid_connect_$provider.endpoint.token` - * (reading the same `openid_connect_$provider.*` props), and JWT validation - * against the provider's `jwks_uri`. - * - * Difference from the Lift version: instead of the (now-vestigial) Lift-session - * `logUserIn` + redirect, on success we mint a usable OBP DirectLogin token and - * return `200 {"token": "..."}`. The client then calls OBP APIs with - * `DirectLogin: token=...`. - * - * Gating: the route only fires when `openid_connect.enabled=true` (default - * false); otherwise the pattern guard fails and the request falls through to - * `notFoundCatchAll` (JSON 404), matching prior behaviour. A second runtime gate - * `allow_openid_connect` (default true) returns 401 when set false. - */ -object Http4sOpenIdConnect extends MdcLoggable { - - private implicit val formats: Formats = CustomJsonFormats.formats - - // Referenced by code.api.OAuth2 (getOrCreateConsumer description); kept here as - // the single home after the Lift OpenIdConnect object was retired. - val openIdConnect = "OpenID Connect" - - // Registration gate, read per request so it stays togglable (default false). - private def enabled: Boolean = getPropsAsBoolValue("openid_connect.enabled", false) - - val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback" if enabled => handle(req, 1) - case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-1" if enabled => handle(req, 1) - case req @ (GET | POST) -> Root / "auth" / "openid-connect" / "callback-2" if enabled => handle(req, 2) - } - - private val jsonContentType = `Content-Type`(MediaType.application.json, Charset.`UTF-8`) - - private def handle(req: Request[IO], identityProvider: Int): IO[Response[IO]] = - Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc => - if (!getPropsAsBoolValue("allow_openid_connect", true)) { - ErrorResponseConverter.createErrorResponse(401, ErrorMessages.OpenIDConnectIsDisabled, cc) - } else { - val code = param(req, cc, "code").getOrElse("") - val state = param(req, cc, "state").getOrElse("0") - // The whole flow is synchronous Lift-mapper / blocking HTTP work; run it off the compute pool. - IO.blocking(processCallback(identityProvider, code, state)).flatMap { - case Right(token) => - Ok(prettyRender(Extraction.decompose(JSONFactory600.createTokenJSON(token)))) - .map(_.withContentType(jsonContentType)) - case Left((httpCode, message)) => - ErrorResponseConverter.createErrorResponse(httpCode, message, cc) - } - } - } - - /** Read a parameter from the query string, falling back to a form-urlencoded body (mirrors Lift `S.param`). */ - private def param(req: Request[IO], cc: code.api.util.CallContext, name: String): Option[String] = - req.uri.query.params.get(name).orElse { - cc.httpBody.flatMap { body => - body.split("&").iterator.map(_.split("=", 2)).collectFirst { - case Array(k, v) if java.net.URLDecoder.decode(k, "UTF-8") == name => java.net.URLDecoder.decode(v, "UTF-8") - } - } - } - - // CONFIG CAVEAT: portal-login was removed, so nothing stores the OIDC `state` server-side; - // `sessionState` is always "" (see processCallback). With the default - // openid_connect.check_session_state=true this fail-closed gate rejects every real (non-empty) - // state with 401 InvalidOpenIDConnectState — so OIDC deployments MUST set - // openid_connect.check_session_state=false (or reintroduce server-side state storage). - // Default kept true (fail-closed) on purpose. Long-term fix: stateless CSRF (PKCE / signed state). - private def checkSessionState(state: String, sessionState: String): Boolean = - if (getPropsAsBoolValue("openid_connect.check_session_state", true)) state == sessionState else true - - /** - * Ports the Lift `callbackUrlCommonCode` business logic. Returns the minted OBP token on success, - * or `(httpCode, message)` on failure. All provider-facing steps (token exchange, JWT validation) - * and all provisioning side effects (resource user, auth user, entitlements, consumer, OIDC-token - * persistence) are preserved verbatim. - */ - private def processCallback(identityProvider: Int, code: String, state: String): Either[(Int, String), String] = { - // Session state was always defaulted to "" once the portal pages were removed; preserved here. - val sessionState = "" - if (!checkSessionState(state, sessionState)) { - Left((401, ErrorMessages.InvalidOpenIDConnectState)) - } else { - exchangeAuthorizationCodeForTokens(code, identityProvider) match { - case Full((idToken, accessToken, tokenType, expiresIn, refreshToken, scope)) => - JwtUtil.validateIdToken(idToken, OpenIdConnectConfig.get(identityProvider).jwks_uri) match { - case Full(_) => - // Restore the single-connection-per-request semantics that Lift's removed - // S.addAround(DB.buildLoanWrapper) gave: all provisioning writes share one - // connection and commit together; a thrown DB error rolls the whole set back - // (same primitive as deletion.DeletionUtil.databaseAtomicTask). The network - // steps above (token exchange + JWKS validation) are kept OUTSIDE the tx so no - // DB connection is held during remote HTTP calls. - DB.use(DefaultConnectionIdentifier) { _ => - getOrCreateResourceUser(idToken) match { - case Full(user) if LoginAttempt.userIsLocked(user.provider, user.name) => - Left((401, ErrorMessages.UsernameHasBeenLocked)) - case Full(user) => - getOrCreateAuthUser(user) match { - case Full(authUser) => - // Grant roles according to the props email_domain_to_space_mappings - AuthUser.grantEmailDomainEntitlementsToUser(authUser) - AuthUser.grantEntitlementsToUseDynamicEndpointsInSpaces(authUser) - // User init actions - AfterApiAuth.innerLoginUserInitAction(Full(authUser)) - getOrCreateConsumer(idToken, user.userId) match { - case Full(consumer) => - saveAuthorizationToken(tokenType, accessToken, idToken, refreshToken, scope, expiresIn, authUser.id.get) match { - case Full(_) => - // Mint a usable OBP DirectLogin token bound to the provisioned user + consumer. - DirectLogin.issueTokenForUser(user.userPrimaryKey.value, consumer.key.get) match { - case Full(token) => Right(token) - case _ => Left((500, ErrorMessages.CouldNotHandleOpenIDConnectData + "issueToken")) - } - case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "saveAuthorizationToken")) - } - case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateConsumer")) - } - case _ => Left((401, ErrorMessages.CouldNotHandleOpenIDConnectData + "getOrCreateAuthUser")) - } - case _ => Left((401, ErrorMessages.CouldNotSaveOpenIDConnectUser)) - } - } - case _ => Left((401, ErrorMessages.CouldNotValidateIDToken)) - } - case _ => Left((401, ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens)) - } - } - } - - // ── Business-logic helpers, ported verbatim from the Lift OpenIdConnect object ──────────────────── - - private def getOrCreateAuthUser(user: User): Box[AuthUser] = { - AuthUser.find(By(AuthUser.user, user.userPrimaryKey.value)) match { - case Full(user) => Full(user) - case _ => createAuthUser(user) - } - } - - private def getOrCreateResourceUser(idToken: String): Box[User] = { - val uniqueIdGivenByProvider = JwtUtil.getSubject(idToken) - val preferredUsername = JwtUtil.getOptionalClaim("preferred_username", idToken) - // Try to get provider from token first, fallback to Hydra resolver - val provider = JwtUtil.getProvider(idToken).getOrElse(Hydra.resolveProvider(idToken)) - val providerId = preferredUsername.orElse(uniqueIdGivenByProvider) - Users.users.vend.getUserByProviderId(provider = provider, idGivenByProvider = providerId.getOrElse("")).or { // Find a user - Users.users.vend.createResourceUser( // Otherwise create a new one - provider = provider, - providerId = providerId, - createdByConsentId = None, - name = providerId, - email = getClaim(name = "email", idToken = idToken), - userId = None, - createdByUserInvitationId = None, - company = None, - lastMarketingAgreementSignedDate = None - ) - } - } - - private def getClaim(name: String, idToken: String): Option[String] = { - val claim = JwtUtil.getClaim(name = name, jwtToken = idToken) - claim match { - case null => None - case string => Some(string) - } - } - - private def createAuthUser(user: User): Box[AuthUser] = tryo { - val newUser = AuthUser.create - .firstName(user.name) - .email(user.emailAddress) - .user(user.userPrimaryKey.value) - .username(user.idGivenByProvider) - .provider(user.provider) - // No need to store password, so store dummy string instead - .password(Helpers.randomString(40)) - .validated(true) - // Save the user in order to be able to log in - newUser.saveMe() - } - - def exchangeAuthorizationCodeForTokens(authorizationCode: String, identityProvider: Int): Box[(String, String, String, Long, String, String)] = { - val config = OpenIdConnectConfig.get(identityProvider) - val data = "client_id=" + config.client_id + "&" + - "client_secret=" + config.client_secret + "&" + - "redirect_uri=" + config.callback_url + "&" + - "code=" + authorizationCode + "&" + - "grant_type=authorization_code" - // Do NOT log `data` — it contains client_secret. Log the endpoint URL only. - logger.debug("Token exchange POST to: " + config.token_endpoint) - val response: Box[String] = fromUrl(String.format("%s", config.token_endpoint), data, "POST") - // Do NOT log the raw response — it contains id/access/refresh tokens. - logger.debug("Token endpoint response received (success=" + response.isDefined + ")") - response match { - case Full(value) => - val tokenResponse = json.parse(value) - for { - idToken <- tryo{(tokenResponse \ "id_token").extractOrElse[String]("")} - accessToken <- tryo{(tokenResponse \ "access_token").extractOrElse[String]("")} - tokenType <- tryo{(tokenResponse \ "token_type").extractOrElse[String]("")} - expiresIn <- tryo{(tokenResponse \ "expires_in").extractOrElse[String]("")} - refreshToken <- tryo{(tokenResponse \ "refresh_token").extractOrElse[String]("")} - scope <- tryo{(tokenResponse \ "scope").extractOrElse[String]("")} - } yield { - // Do NOT log token values (id/access/refresh). Non-sensitive metadata only. - logger.debug(s"OIDC token parsed (tokenType=$tokenType, expiresIn=${expiresIn.toLong}, scope=$scope)") - (idToken, accessToken, tokenType, expiresIn.toLong, refreshToken, scope) - } - case badObject@Failure(_, _, _) => - logger.debug("Error at exchangeAuthorizationCodeForTokens: " + badObject) - badObject - case everythingElse => - logger.debug("Error at exchangeAuthorizationCodeForTokens: " + everythingElse) - Failure(ErrorMessages.InternalServerError + " - exchangeAuthorizationCodeForTokens") - } - } - - private def getOrCreateConsumer(idToken: String, userId: String): Box[Consumer] = { - Consumers.consumers.vend.getOrCreateConsumer( - consumerId=None, - None, - None, - Some(JwtUtil.getAudience(idToken).mkString(",")), - getClaim(name = "azp", idToken = idToken), - JwtUtil.getIssuer(idToken), - JwtUtil.getSubject(idToken), - Some(true), - name = Some(Helpers.randomString(10).toLowerCase), - appType = Some(AppType.Confidential), - description = Some(openIdConnect), - developerEmail = getClaim(name = "email", idToken = idToken), - redirectURL = None, - createdByUserId = Some(userId) - ) - } - - private def saveAuthorizationToken(tokenType: String, - accessToken: String, - idToken: String, - refreshToken: String, - scope: String, - expiresIn: Long, - authUserPrimaryKey: Long): Box[OpenIDConnectToken] = { - val token = TokensOpenIDConnect.tokens.vend.createToken( - tokenType = tokenType, - accessToken = accessToken, - idToken = idToken, - refreshToken = refreshToken, - scope = scope, - expiresIn = expiresIn, - authUserPrimaryKey = authUserPrimaryKey - ) - token match { - case Full(_) => // All good - case error => logger.error(error) - } - token - } - - def fromUrl( url: String, - data: String = "", - method: String, - connectTimeout: Int = 2000, - readTimeout: Int = 10000 - ): Box[String] = { - var content:String = "" - import java.net.URL - try { - val connection = { - if (url.startsWith("https://")) { - val conn: HttpsURLConnection = new URL(url + { - if (method == "GET") data - else "" - }).openConnection.asInstanceOf[HttpsURLConnection] - conn - } - else { - val conn: HttpURLConnection = new URL(url + { - if (method == "GET") data - else "" - }).openConnection.asInstanceOf[HttpURLConnection] - conn - } - } - connection.setConnectTimeout(connectTimeout) - connection.setReadTimeout(readTimeout) - connection.setRequestMethod(method) - connection.setRequestProperty("Accept", "application/json") - if ( data != "" && method == "POST") { - connection.setRequestProperty("Content-type", "application/x-www-form-urlencoded") - connection.setRequestProperty("Charset", "utf-8") - val dataBytes = data.getBytes("UTF-8") - connection.setRequestProperty("Content-Length", dataBytes.length.toString) - connection.setDoOutput( true ) - connection.getOutputStream.write(dataBytes) - } - val inputStream = connection.getInputStream - content = scala.io.Source.fromInputStream(inputStream).mkString - if (inputStream != null) inputStream.close() - Full(content) - } catch { - case e:Throwable => - e.printStackTrace() - logger.error(e) - Failure(e.getMessage) - } - } -} diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index ca359ebfa4..23b345f13b 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -533,7 +533,7 @@ object OAuth2Login extends MdcLoggable { case Full(_) => logger.debug("applyIdTokenRules - ID token validation successful") val user = getOrCreateResourceUser(token) - val consumer = getOrCreateConsumer(token, user.map(_.userId), Some(Http4sOpenIdConnect.openIdConnect)) + val consumer = getOrCreateConsumer(token, user.map(_.userId), Some("OpenID Connect")) LoginAttempt.userIsLocked(user.map(_.provider).getOrElse(""), user.map(_.name).getOrElse("")) match { case true => ((Failure(UsernameHasBeenLocked), Some(cc.copy(consumer = consumer)))) case false => (user, Some(cc.copy(consumer = consumer))) diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index dc28977a05..01e9b66c4e 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -475,17 +475,6 @@ object DirectLogin extends MdcLoggable { } } - /** - * Mint and persist a usable DirectLogin token for an already-authenticated user, bypassing the - * username/password validation in `createTokenCommonPart`. Used by the http4s OpenID Connect - * callback (`Http4sOpenIdConnect`) once the provider has verified the user's identity. - */ - def issueTokenForUser(userPrimaryKey: Long, consumerKey: String): Box[String] = { - val (token, secret) = generateTokenAndSecret(JWTClaimsSet.parse("""{"":""}""")) - if (saveAuthorizationToken(Map("consumer_key" -> consumerKey), token, secret, userPrimaryKey)) Full(token) - else Failure("OpenIDConnect: could not persist DirectLogin token") - } - def getUser : Box[User] = { val httpMethod = "GET" val (httpCode, message, directLoginParameters) = validator("protectedResource") diff --git a/obp-api/src/main/scala/code/api/util/ApiRole.scala b/obp-api/src/main/scala/code/api/util/ApiRole.scala index 7a61c8aab7..a40df52c89 100644 --- a/obp-api/src/main/scala/code/api/util/ApiRole.scala +++ b/obp-api/src/main/scala/code/api/util/ApiRole.scala @@ -429,6 +429,12 @@ object ApiRole extends MdcLoggable{ case class CanCreateSettlementAccountAtOneBank (requiresBankId: Boolean = true) extends ApiRole lazy val canCreateSettlementAccountAtOneBank = CanCreateSettlementAccountAtOneBank() + // System role for the south-side rail/adapter to deliver the asynchronous UTILITY (e.g. LUKU) + // vend result (electricity token / receipt) back to OBP. Not bank-scoped — the rail is a + // trusted system actor, not a per-bank user. + case class CanCreateUtilityVendResult (requiresBankId: Boolean = false) extends ApiRole + lazy val canCreateUtilityVendResult = CanCreateUtilityVendResult() + case class CanGetSettlementAccountAtOneBank (requiresBankId: Boolean = true) extends ApiRole lazy val canGetSettlementAccountAtOneBank = CanGetSettlementAccountAtOneBank() diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 13f3b52fe0..2498ec2156 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -509,6 +509,16 @@ object ErrorMessages { val BulkPaymentAddressMismatch = "OBP-30543: A payment's address does not match the address_pattern of its routing_scheme." val BulkPaymentTransactionRequestError = "OBP-30544: Could not create BULK transaction request." + // UTILITY transaction-request (OBP-30546 .. OBP-30549) + // Polymorphic bill/utility payment (prepaid utility meter, bill control number, ...). + // The destination is identified by a QualifiedIdentifier whose `scheme` must be a + // registered routing scheme of category UTILITY or BILL. + val UtilityIdentifierTypeWrongCategory = "OBP-30546: identifier scheme category is not valid for a UTILITY payment. Allowed categories are: UTILITY, BILL." + val UtilityInvalidIdentifier = "OBP-30547: Invalid identifier value — does not match the address_pattern of the routing scheme (e.g. TZ.UTILITY_METER)." + val UtilityDestinationNotFound = "OBP-30548: No biller/utility account is registered for the supplied identifier. In mapped mode the destination must have an account routing for the identifier scheme (e.g. TZ.UTILITY_METER)." + val UtilityPaymentError = "OBP-30549: Could not create UTILITY transaction request." + val UtilityTransactionRequestNotFound = "OBP-30550: No UTILITY transaction request found for the supplied UTILITY_TRANSACTION_REQUEST_ID." + // Implicit OBP-family routing schemes (OBP-30545) // The schemes "OBP" / "OBP_ACCOUNT_ID" / "OBP_BANK_ID" are reserved self-identifiers — // they map (scheme, address) directly to (kind, primary_key) without a stored row. diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala b/obp-api/src/main/scala/code/api/util/Glossary.scala index 50c392b0cb..65b44bcf81 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -2309,7 +2309,7 @@ object Glossary extends MdcLoggable { | | GET /obp/v3.0.0/users/current HTTP/1.1 | Host: $getServerUrl -| Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA +| Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA | Cache-Control: no-cache | | @@ -2319,7 +2319,7 @@ object Glossary extends MdcLoggable { | | curl -X GET | $getServerUrl/obp/v3.0.0/users/current -| -H 'Authorization: Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA' +| -H 'Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IjA4ZDMyNDVjNjJmODZiNjM2MmFmY2JiZmZlMWQwNjk4MjZkZDFkYzEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6IkFvYVNGQTlVTTdCSGg3YWZYNGp2TmciLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUt4cndjYWR3emhtNE40dFdrNUU4QXZ4aS1aSzZrczRxZy9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTQ3NzExMTE1LCJleHAiOjE1NDc3MTQ3MTV9.MKsyecCSKS4Y0C8R4JP0J0d2Oa-xahvMAbtfFrGHncTm8xBgeaNb50XSJn20ak1YyA8hZiRP2M3el0f4eIVQZsMMa22MrwaiL8pLb1zGfawDLPb1RvOmoCWTDJGc_s1qQMlyc21Wenr9rjuu1bQCerGTYM6M0Aq-Uu_GT0lCEjz5WVDI5xDUf4Mhdi8HYq7UQ1kGz1gQFiBm5nI3_xtYm75EfXFeDg3TejaMmy36NpgtwN_vwpHByoHE5BoTl2J55rJ2creZZ7CmtZttm-9HsT6v1vxT8zi0RXObFrZSk-LgfF0tJQcGZ5LXQZL0yMKXPQVFIMCg8J0Gg7l_QACkCA' | -H 'Cache-Control: no-cache' | -H 'Postman-Token: aa812d04-eddd-4752-adb7-4d56b3a98f36' | diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala index 8f9f568857..f678a66bf1 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -144,7 +144,6 @@ object Http4sApp extends MdcLoggable { .orElse(dynamicEntityRoutes.run(req)) .orElse(dynamicEndpointRoutes.run(req)) .orElse(code.api.DirectLoginRoutes.routes.run(req)) - .orElse(code.api.Http4sOpenIdConnect.routes.run(req)) .orElse(code.api.AliveCheckRoutes.routes.run(req)) .orElse(notFoundCatchAll.run(req)) } diff --git a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala index 2c122eaf76..14869a53c6 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala @@ -7,7 +7,7 @@ import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ import code.api.util.APIUtil.{EmptyBody, _} import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateMetricsArchiveRun, canCreateOrganisation, canCreateRoutingScheme, canCreateTestEmail, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.CommonsEmailWrapper import code.model.dataAccess.AuthUser import code.api.util.ApiTag._ @@ -29,6 +29,7 @@ import code.entitlement.Entitlement import code.organisation.Organisations import code.routingscheme.{RoutingSchemes, RoutingSchemeValidation} import code.payeelookup.PayeeLookups +import code.utilitypayment.{UtilityCallbackDispatcher, UtilityPaymentCallbacks} import code.bulkpayment.{BulkPaymentHandler, BulkPayments} import code.transactionrequests.MappedTransactionRequestProvider import com.openbankproject.commons.model.TransactionRequestCharge @@ -2241,7 +2242,7 @@ object Http4s700 { // ── Routing Schemes ─────────────────────────────────────────────────────── // A registry of country-qualified routing scheme names (e.g. TZ.MSISDN, - // TZ.GEPG_CONTROL_NUMBER) so that downstream adapters and clients agree on + // TZ.BILL_CONTROL_NUMBER) so that downstream adapters and clients agree on // identifier scheme semantics. Two tiers: // • /routing-schemes — system catalogue (5 endpoints) // • /banks/BANK_ID/supported-routing-schemes — per-bank subset (2 endpoints) @@ -2300,7 +2301,7 @@ object Http4s700 { "Create Routing Scheme", """Register a new routing scheme. | - |Scheme names follow the convention `.` — uppercase ISO 3166-1 alpha-2 country code, a dot, then an uppercase local scheme name (e.g. `TZ.MSISDN`, `TZ.GEPG_CONTROL_NUMBER`). + |Scheme names follow the convention `.` — uppercase ISO 3166-1 alpha-2 country code, a dot, then an uppercase local scheme name (e.g. `TZ.MSISDN`, `TZ.BILL_CONTROL_NUMBER`). | |Globally-unique schemes `IBAN`, `BIC`, `OBP` are accepted unprefixed; their `country` MUST be the literal `INT`. | @@ -2376,7 +2377,7 @@ object Http4s700 { |- `country` — ISO 3166-1 alpha-2, e.g. `TZ` |- `category` — ACCOUNT, BANK, BRANCH, IDENTITY, BILL, UTILITY |- `status` — defaults to `ACTIVE`. Pass `ALL` to include DEPRECATED and RETIRED. - |- `rail` — match against the `downstream_rails` list (e.g. `TIPS`, `GEPG`) + |- `rail` — match against the `downstream_rails` list (e.g. `TIPS`, `RTGS`) |- `limit` (default 100, max 500), `offset` (default 0)""".stripMargin, EmptyBody, JSONFactory700.RoutingSchemesJsonV700( @@ -2564,11 +2565,11 @@ object Http4s700 { |Authentication is Required.""".stripMargin, EmptyBody, JSONFactory700.BankSupportedRoutingSchemesJsonV700( - bank_id = "nmb.tz", + bank_id = "bank.tz", supported_routing_schemes = List( JSONFactory700.BankSupportedRoutingSchemeJsonV700( scheme = "TZ.MSISDN", - bank_notes = Some("Routed via Gateway X to TIPS.") + bank_notes = Some("Routed via the instant-payment rail (TIPS).") ) ) ), @@ -2616,12 +2617,12 @@ object Http4s700 { | |Authentication is Required.""".stripMargin, JSONFactory700.PutBankSupportedRoutingSchemeJsonV700( - bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT."), + bank_notes = Some("Routed via the instant-payment rail (TIPS). Daily cutoff 22:00 EAT."), enabled = Some(true) ), JSONFactory700.BankSupportedRoutingSchemeJsonV700( scheme = "TZ.MSISDN", - bank_notes = Some("Routed via Gateway X to TIPS. Daily cutoff 22:00 EAT.") + bank_notes = Some("Routed via the instant-payment rail (TIPS). Daily cutoff 22:00 EAT.") ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, BankNotFound, RoutingSchemeNotFound, RoutingSchemeNotSupportedByBank, @@ -2721,8 +2722,8 @@ object Http4s700 { |Examples: |- Mobile-money / TIPS payee: `identifier: { scheme: TZ.MSISDN, value: 255778300336, fsp_id: 503 }` |- TIPS bank-account name verify: `identifier: { scheme: TZ.BANK_ACCOUNT, value: 24110000296 }` - |- GePG bill inquiry: `identifier: { scheme: TZ.GEPG_CONTROL_NUMBER, value: 991043383705 }` - |- Luku meter inquiry: `identifier: { scheme: TZ.LUKU_METER, value: 24730238417 }` + |- Bill inquiry: `identifier: { scheme: TZ.BILL_CONTROL_NUMBER, value: 991043383705 }` + |- Utility meter inquiry: `identifier: { scheme: TZ.UTILITY_METER, value: 24730238417 }` | |The response includes a `lookup_id` valid for 10 minutes. A subsequent transaction-request can quote it via `verified_payee_lookup_id` to prove the payer saw the resolved name (Confirmation-of-Payee handshake). | @@ -2738,8 +2739,8 @@ object Http4s700 { identifier = JSONFactory700.QualifiedIdentifierJsonV700( scheme = "TZ.MSISDN", value = "255778300336", fsp_id = Some("503") ), - network_provider = Some("ZANTEL"), - full_name = "ERASTO EMILE MALEMA", + network_provider = Some("PROVIDERA"), + full_name = "Jane Doe", account_category = Some("PERSON"), account_type = Some("WALLET"), identity = None @@ -2827,15 +2828,15 @@ object Http4s700 { to = JSONFactory700.MobileWalletToJsonV700( msisdn = "255778300336", fsp_id = Some("503"), - network_provider = Some("AIRTEL"), - full_name = Some("Chinua Achebe"), + network_provider = Some("PROVIDERA"), + full_name = Some("Jane Doe"), account_category = Some("PERSON"), account_type = Some("WALLET"), identity = None ), value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "1000"), - description = "buy airtime", - client_reference = Some("MK45078200"), + description = "wallet payment", + client_reference = Some("ref-0001"), verified_payee_lookup_id = None, country_code = Some("TZ"), data_fields = Some(List(JSONFactory700.MobileWalletDataFieldJsonV700("fieldName1", "fieldValue1"))), @@ -2888,6 +2889,291 @@ object Http4s700 { // ── End MOBILE_WALLET ───────────────────────────────────────────────────── + // ── UTILITY transaction request ─────────────────────────────────────────── + // Polymorphic bill / utility payment (prepaid utility meter token purchase, bill + // payment, ...). The destination biller is identified by a QualifiedIdentifier + // whose `scheme` must be a registered routing scheme of category UTILITY or BILL — + // e.g. TZ.UTILITY_METER (prepaid electricity meter). Verify the destination first + // via POST .../payees/lookup, then pay quoting `verified_payee_lookup_id` + // (Confirmation-of-Payee handshake). Plugs into the v400 payment pipeline. + // If `callback_url` is supplied, a one-shot callback is registered and the + // result is POSTed back asynchronously — a failed callback never fails the + // payment. + val UtilityValidCategories: Set[String] = Set("UTILITY", "BILL") + + val createTransactionRequestUtility: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "accounts" / _ / _ / "transaction-request-types" / "UTILITY" / "transaction-requests" => + EndpointHelpers.withViewAndBodyCreated[JSONFactory700.TransactionRequestBodyUtilityJsonV700, JSONFactory700.TransactionRequestWithChargeUtilityJsonV700](req) { (user, fromAccount, view, body, cc) => + val callCtx = Some(cc) + val chargePolicy = body.charge_policy.getOrElse("SHARED") + for { + // 1. identifier.scheme must be a registered routing scheme. + scheme <- Future(RoutingSchemes.routingScheme.vend.getRoutingScheme(body.to.scheme)) + .map(unboxFullOrFail(_, callCtx, PayeeLookupIdentifierTypeNotRegistered, 400)) + // 2. scheme category must be UTILITY or BILL. + _ <- Helper.booleanToFuture(UtilityIdentifierTypeWrongCategory, 400, callCtx) { + UtilityValidCategories.contains(scheme.category) + } + // 3. identifier.value must match the scheme's address_pattern. + _ <- Helper.booleanToFuture(UtilityInvalidIdentifier, 400, callCtx) { + RoutingSchemeValidation.addressMatchesPattern(scheme.addressPattern, body.to.value) + } + // 4. optional Confirmation-of-Payee handshake against a prior lookup. + _ <- body.verified_payee_lookup_id match { + case Some(lkpId) => + for { + lkp <- Future(PayeeLookups.payeeLookup.vend.getActivePayeeLookup(lkpId)) + .map(unboxFullOrFail(_, callCtx, PayeeLookupExpiredOrNotFound, 400)) + _ <- Helper.booleanToFuture(PayeeLookupMismatch, 400, callCtx) { + lkp.identifier == body.to.value && lkp.identifierType == body.to.scheme + } + } yield () + case None => Future.successful(()) + } + // 5. resolve the destination biller/utility account via routing. + destinationBox <- BankConnector.connector.vend + .getBankAccountByRouting(None, body.to.scheme, body.to.value, callCtx) + .map(_._1) + toAccount <- Future { + unboxFullOrFail(destinationBox, callCtx, UtilityDestinationNotFound, 404) + } + // 6. standard view authorisation check (same as v4 COUNTERPARTY). + _ <- NewStyle.function.checkAuthorisationToCreateTransactionRequest( + view.viewId, BankIdAccountId(fromAccount.bankId, fromAccount.accountId), user, callCtx + ) + // 7. serialise the body to JSON for the connector's audit blob. + detailsPlain = prettyRender(Extraction.decompose(body)) + // 8. create the transaction request via the standard pipeline. + txnReqType = TransactionRequestType("UTILITY") + (tr, _) <- NewStyle.function.createTransactionRequestv400( + user, + view.viewId, + fromAccount, + toAccount, + txnReqType, + body, + detailsPlain, + chargePolicy, + Some(ChallengeType.OBP_TRANSACTION_REQUEST_CHALLENGE), + None, + None, + callCtx + ) + // 9. Register the one-shot result callback (step c), if asked. It is NOT fired + // here: the vend is asynchronous, so the token does not yet exist. The callback + // is fired by createUtilityVendResult once the rail delivers the vend result — + // carrying the real token, and from a separate, already-committed request (which + // also avoids racing this request's transaction commit). + callbackJson = body.callback_url.flatMap { url => + val callbackId = APIUtil.generateUUID() + UtilityPaymentCallbacks.utilityPaymentCallback.vend.createCallback( + callbackId = callbackId, + transactionRequestId = tr.id.value, + callbackUrl = url, + identifierType = body.to.scheme, + identifier = body.to.value, + fromBankId = fromAccount.bankId.value, + fromAccountId = fromAccount.accountId.value, + createdByUserId = user.userId + ).toOption.map { stored => + JSONFactory700.UtilityCallbackJsonV700( + callback_id = stored.callbackId, + callback_url = stored.callbackUrl, + status = stored.status + ) + } + } + // vend_result is None at creation — it arrives asynchronously via createUtilityVendResult. + } yield JSONFactory700.createTransactionRequestWithChargeUtilityJsonV700(tr, body, callbackJson, None, Nil, Nil) + } + } + + val utilityBodyExample = JSONFactory700.TransactionRequestBodyUtilityJsonV700( + to = JSONFactory700.QualifiedIdentifierJsonV700( + scheme = "TZ.UTILITY_METER", value = "24730238417", fsp_id = None + ), + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "1000"), + description = "Prepaid utility meter token purchase", + client_reference = Some("ref-0001"), + verified_payee_lookup_id = None, + payer = Some(JSONFactory700.UtilityPayerJsonV700( + phone = Some("255700000000"), + name = Some("Jane Doe"), + email = Some("jane.doe@example.com") + )), + callback_url = Some("https://example.com/utility/callback"), + data_fields = None, + charge_policy = Some("SHARED") + ) + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createTransactionRequestUtility), + "POST", + "/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/UTILITY/transaction-requests", + "Create Transaction Request (UTILITY)", + """Initiate a bill / utility payment — e.g. a prepaid-electricity meter token purchase, or a bill payment. + | + |The endpoint is **polymorphic on `to.scheme`**: the destination biller is identified by a `QualifiedIdentifier` whose `scheme` must be a registered routing scheme of category **UTILITY** or **BILL** (e.g. `TZ.UTILITY_METER`, `TZ.BILL_CONTROL_NUMBER`). The scheme must be registered in the routing-scheme catalogue (`GET /obp/v7.0.0/routing-schemes/TZ.UTILITY_METER`) and `to.value` must match its `address_pattern`. + | + |**Confirmation-of-Payee handshake** (recommended): call `POST /banks/.../accounts/.../payees/lookup` first (the meter-number / control-number inquiry), then pass the returned `lookup_id` here as `verified_payee_lookup_id`. The endpoint rejects the request if the lookup has expired or does not match the supplied identifier. + | + |**Payer block**: `payer` carries the depositor's phone / name / email for the biller receipt. + | + |**Callback** (optional): supply `callback_url` to register a one-shot callback. The vend is asynchronous — the electricity token does not exist yet at creation, so the response returns `vend_result: null` and the registered callback's status is `REGISTERED`. Once the downstream rail delivers the vend result (via the system endpoint `POST /banks/BANK_ID/utility-payments/UTILITY_TRANSACTION_REQUEST_ID/vend-result`), OBP records the token/receipt on the transaction request and POSTs the enriched result to `callback_url`. A failed or unreachable callback never fails the payment. + | + |**Provider passthrough**: `data_fields` carries arbitrary name/value pairs that adapters forward to the downstream rail without OBP interpretation. + | + |Authentication is Required.""".stripMargin, + utilityBodyExample, + JSONFactory700.TransactionRequestWithChargeUtilityJsonV700( + id = "4050046c-63b3-4868-8a22-14b4181d33a6", + `type` = "UTILITY", + from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140( + bank_id = "gh.29.uk", + account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f1" + ), + details = utilityBodyExample, + transaction_ids = List("902ba3bb-dedd-45e7-9319-2fd3f2cd98a1"), + status = "COMPLETED", + start_date = code.api.util.APIUtil.DateWithDayExampleObject, + end_date = code.api.util.APIUtil.DateWithDayExampleObject, + challenges = Nil, + charge = code.api.v2_0_0.TransactionRequestChargeJsonV200( + summary = "Total charges for completed transaction", + value = com.openbankproject.commons.model.AmountOfMoneyJsonV121(currency = "TZS", amount = "0.00") + ), + callback = Some(JSONFactory700.UtilityCallbackJsonV700( + callback_id = "cbk_01HXY7Z8AB9C0D1E2F3G4H5J6K", + callback_url = "https://example.com/utility/callback", + status = "REGISTERED" + )), + vend_result = None, + attributes = None + ), + List($AuthenticatedUserIsRequired, InvalidJsonFormat, + PayeeLookupIdentifierTypeNotRegistered, UtilityIdentifierTypeWrongCategory, + UtilityInvalidIdentifier, PayeeLookupExpiredOrNotFound, PayeeLookupMismatch, + UtilityDestinationNotFound, UtilityPaymentError, UnknownError), + apiTagTransactionRequest :: apiTagPayee :: Nil, + None, + http4sPartialFunction = Some(createTransactionRequestUtility) + ) + + // ── UTILITY vend-result delivery (inbound, asynchronous) ─────────────────── + // The downstream rail/adapter calls this once the utility vend settles, delivering + // the electricity token / receipt (e.g. a LUKU 20-digit STS token). OBP persists the + // vend fields as transaction-request attributes and — if the payer registered a + // callback_url on the original request — POSTs the vend result to it. The rail is a + // trusted system actor, gated by canCreateUtilityVendResult. Returns 200. + // System path: a flat /utility-payments/UTILITY_TRANSACTION_REQUEST_ID segment avoids + // ACCOUNT_ID/VIEW_ID middleware resolution (the rail has no view on the payer's account). + val createUtilityVendResult: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> `prefixPath` / "banks" / _ / "utility-payments" / trIdStr / "vend-result" => + EndpointHelpers.withUserAndBody[JSONFactory700.PostUtilityVendResultJsonV700, JSONFactory700.UtilityVendResultResponseJsonV700](req) { (_, body, cc) => + val callCtx = Some(cc) + val trId = com.openbankproject.commons.model.TransactionRequestId(trIdStr) + // Only present fields are persisted; the vend status is always written. + val attrs: List[(String, String)] = + (JSONFactory700.UtilityVendAttribute.VendStatus -> body.status) :: List( + body.luku_token.map(JSONFactory700.UtilityVendAttribute.Token -> _), + body.rcpt_num.map(JSONFactory700.UtilityVendAttribute.RcptNum -> _), + body.units.map(JSONFactory700.UtilityVendAttribute.Units -> _), + body.gwx_reference.map(JSONFactory700.UtilityVendAttribute.GwxReference -> _), + body.provider_message.map(JSONFactory700.UtilityVendAttribute.ProviderMessage -> _) + ).flatten + for { + // 1. The transaction request must exist (404 otherwise). + (tr, _) <- Future(BankConnector.connector.vend.getTransactionRequestImpl(trId, callCtx)) + .map(unboxFullOrFail(_, callCtx, UtilityTransactionRequestNotFound, 404)) + bankId = BankId(tr.from.bank_id) + // 2. Persist the vend fields as transaction-request attributes. + _ <- Future.sequence(attrs.map { case (name, value) => + NewStyle.function.createOrUpdateTransactionRequestAttribute( + bankId, trId, None, name, + com.openbankproject.commons.model.enums.TransactionRequestAttributeType.STRING, value, callCtx + ) + }) + // 3. Read attributes back and project the typed vend_result. + (attributes, _) <- NewStyle.function.getTransactionRequestAttributesFromProvider(trId, callCtx) + vendResult = JSONFactory700.utilityVendResultFromAttributes(attributes) + // 4. If the payer registered a callback, deliver the vend result to it. The callback + // row was committed by the create request, so the dispatcher's async status update + // no longer races an uncommitted row. A failed callback never fails this request. + callbackJson = UtilityPaymentCallbacks.utilityPaymentCallback.vend + .getCallbackByTransactionRequestId(trIdStr).toOption.map { cb => + val payload = prettyRender(Extraction.decompose( + JSONFactory700.UtilityVendResultResponseJsonV700( + transaction_request_id = tr.id.value, `type` = tr.`type`, + status = tr.status, vend_result = vendResult, callback = None + ) + )) + UtilityCallbackDispatcher.deliver(cb.callbackId, cb.callbackUrl, payload) + JSONFactory700.UtilityCallbackJsonV700( + callback_id = cb.callbackId, callback_url = cb.callbackUrl, status = cb.status + ) + } + } yield JSONFactory700.UtilityVendResultResponseJsonV700( + transaction_request_id = tr.id.value, `type` = tr.`type`, + status = tr.status, vend_result = vendResult, callback = callbackJson + ) + } + } + + resourceDocs += ResourceDoc( + implementedInApiVersion, + nameOf(createUtilityVendResult), + "POST", + "/banks/BANK_ID/utility-payments/UTILITY_TRANSACTION_REQUEST_ID/vend-result", + "Deliver UTILITY Vend Result", + """**System endpoint** — called by the downstream rail/adapter (not the payer) to deliver the + |asynchronous result of a UTILITY payment, e.g. a LUKU (TANESCO prepaid electricity) purchase. + | + |The vend is asynchronous: the original `POST .../transaction-request-types/UTILITY/transaction-requests` + |returns immediately with `vend_result: null`, and the actual deliverable — the **20-digit STS + |electricity token** plus receipt (`rcpt_num`, `units`, provider reference) — arrives here once the + |rail settles the vend. OBP records the vend fields as attributes on the transaction request and, + |if the payer registered a `callback_url`, POSTs this vend result to that URL (a failed or + |unreachable callback never fails this request). + | + |`UTILITY_TRANSACTION_REQUEST_ID` is the `id` returned by the original UTILITY transaction request. + | + |Requires the `CanCreateUtilityVendResult` system entitlement.""".stripMargin, + JSONFactory700.PostUtilityVendResultJsonV700( + status = "COMPLETED", + luku_token = Some("1234 5678 9012 3456 7890"), + rcpt_num = Some("202306141018422348674"), + units = Some("46.5"), + gwx_reference = Some("GWX800930701197"), + provider_message = Some("Vend successful") + ), + JSONFactory700.UtilityVendResultResponseJsonV700( + transaction_request_id = "4050046c-63b3-4868-8a22-14b4181d33a6", + `type` = "UTILITY", + status = "COMPLETED", + vend_result = Some(JSONFactory700.UtilityVendResultJsonV700( + status = "COMPLETED", + luku_token = Some("1234 5678 9012 3456 7890"), + rcpt_num = Some("202306141018422348674"), + units = Some("46.5"), + gwx_reference = Some("GWX800930701197"), + provider_message = Some("Vend successful") + )), + callback = Some(JSONFactory700.UtilityCallbackJsonV700( + callback_id = "cbk_01HXY7Z8AB9C0D1E2F3G4H5J6K", + callback_url = "https://example.com/utility/callback", + status = "REGISTERED" + )) + ), + List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, + UtilityTransactionRequestNotFound, UnknownError), + apiTagTransactionRequest :: apiTagPayee :: Nil, + Some(List(canCreateUtilityVendResult)), + http4sPartialFunction = Some(createUtilityVendResult) + ) + + // ── End UTILITY ─────────────────────────────────────────────────────────── + // ── OPEN_CORRIDOR transaction request ───────────────────────────────────── // Travel-Rule-friendly TR with FATF Recommendation 16 originator block. // Money-movement is identical to SIMPLE; the originator is persisted as a @@ -3115,7 +3401,7 @@ object Http4s700 { batch_reference = "BATCH-2026-05-13-001", status = "COMPLETED", from = code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140( - bank_id = "nmb.tz", account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0" + bank_id = "bank.tz", account_id = "8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0" ), total_value = com.openbankproject.commons.model.AmountOfMoneyJsonV121("TZS", "75000.00"), total_payments = 2, diff --git a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala index e5a4e45219..1cf34cc660 100644 --- a/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala +++ b/obp-api/src/main/scala/code/api/v7_0_0/JSONFactory7.0.0.scala @@ -769,6 +769,159 @@ object JSONFactory700 extends MdcLoggable with code.api.util.CustomJsonFormats { ) } + // ── UTILITY transaction-request body ─────────────────────────────────────── + // + // A polymorphic bill / utility payment. The destination is a QualifiedIdentifier + // whose `scheme` must be a registered routing scheme of category UTILITY or BILL + // — e.g. `TZ.UTILITY_METER` (prepaid electricity meter), later `TZ.BILL_CONTROL_NUMBER`. + // Mirrors the meter/bill token-purchase flow: verify the destination via + // POST .../payees/lookup, then pay quoting `verified_payee_lookup_id`. + + /** Payer block — the depositor's phone / name / email for the biller receipt. */ + case class UtilityPayerJsonV700( + phone: Option[String], + name: Option[String], + email: Option[String] + ) + + /** + * Body for `POST .../transaction-request-types/UTILITY/transaction-requests`. + * + * Implements `TransactionRequestCommonBodyJSON` so it plugs into the existing + * v400 transaction-request pipeline (which requires `value` + `description`). + * + * `callback_url`, when present, registers a fire-and-forget callback that OBP + * POSTs the final token-purchase result to. + */ + case class TransactionRequestBodyUtilityJsonV700( + to: QualifiedIdentifierJsonV700, + value: com.openbankproject.commons.model.AmountOfMoneyJsonV121, + description: String, + client_reference: Option[String], + verified_payee_lookup_id: Option[String], + payer: Option[UtilityPayerJsonV700], + callback_url: Option[String], + data_fields: Option[List[MobileWalletDataFieldJsonV700]], + charge_policy: Option[String] + ) extends com.openbankproject.commons.model.TransactionRequestCommonBodyJSON + + /** Registration status of the per-request callback (step c). */ + case class UtilityCallbackJsonV700( + callback_id: String, + callback_url: String, + status: String // REGISTERED | DELIVERED | FAILED + ) + + // The asynchronous vend result delivered by the downstream rail/adapter after the + // utility purchase settles — e.g. the 20-digit STS electricity token for a LUKU + // (TANESCO prepaid electricity) meter. Field names mirror the Gateway X + // `gepgVendCustInfoRes` payload. Persisted on the transaction request as attributes + // and surfaced here (and on the client callback) once the vend completes. + case class UtilityVendResultJsonV700( + status: String, // ACCEPTED | COMPLETED | FAILED (provider vend status) + luku_token: Option[String], // the 20-digit STS token the customer keys into the meter + rcpt_num: Option[String], // provider receipt number + units: Option[String], // electricity units purchased (kWh) + gwx_reference: Option[String], // downstream rail reference (gwxReference) + provider_message: Option[String] // free-text provider remark + ) + + /** Inbound body for the vend-result delivery endpoint (rail/adapter → OBP). */ + case class PostUtilityVendResultJsonV700( + status: String, + luku_token: Option[String], + rcpt_num: Option[String], + units: Option[String], + gwx_reference: Option[String], + provider_message: Option[String] + ) + + // Response of the vend-result delivery endpoint, and the payload OBP POSTs to the + // payer's registered callback_url. Deliberately lean — it carries the vend result + // (the token), not an echo of the original request (the payer already has that from + // the create response). Mirrors the Gateway X callback, which delivers the vend result. + case class UtilityVendResultResponseJsonV700( + transaction_request_id: String, + `type`: String, // always "UTILITY" + status: String, // the transaction request's status + vend_result: Option[UtilityVendResultJsonV700], + callback: Option[UtilityCallbackJsonV700] // delivery status, when a callback was registered + ) + + // Attribute names under which the vend result is persisted on the transaction request. + object UtilityVendAttribute { + val Token = "LUKU_TOKEN" + val RcptNum = "LUKU_RCPT_NUM" + val Units = "LUKU_UNITS" + val GwxReference = "LUKU_GWX_REFERENCE" + val VendStatus = "LUKU_VEND_STATUS" + val ProviderMessage = "LUKU_PROVIDER_MESSAGE" + } + + // v7 response shape for UTILITY. Mirrors MOBILE_WALLET's wrapper and adds the + // optional callback-registration block and the asynchronous vend result. + case class TransactionRequestWithChargeUtilityJsonV700( + id: String, + `type`: String, + from: code.api.v1_4_0.JSONFactory1_4_0.TransactionRequestAccountJsonV140, + details: TransactionRequestBodyUtilityJsonV700, + transaction_ids: List[String], + status: String, + start_date: java.util.Date, + end_date: java.util.Date, + challenges: List[code.api.v4_0_0.ChallengeJsonV400], + charge: code.api.v2_0_0.TransactionRequestChargeJsonV200, + callback: Option[UtilityCallbackJsonV700], + vend_result: Option[UtilityVendResultJsonV700], + attributes: Option[List[code.api.v4_0_0.BankAttributeBankResponseJsonV400]] + ) + + def createTransactionRequestWithChargeUtilityJsonV700( + tr: com.openbankproject.commons.model.TransactionRequest, + requestBody: TransactionRequestBodyUtilityJsonV700, + callback: Option[UtilityCallbackJsonV700], + vendResult: Option[UtilityVendResultJsonV700], + challenges: List[com.openbankproject.commons.model.ChallengeTrait], + transactionRequestAttribute: List[com.openbankproject.commons.model.TransactionRequestAttributeTrait] + ): TransactionRequestWithChargeUtilityJsonV700 = { + val v4 = code.api.v4_0_0.JSONFactory400.createTransactionRequestWithChargeJSON( + tr, challenges, transactionRequestAttribute + ) + TransactionRequestWithChargeUtilityJsonV700( + id = v4.id, + `type` = v4.`type`, + from = v4.from, + details = requestBody, + transaction_ids = v4.transaction_ids, + status = v4.status, + start_date = v4.start_date, + end_date = v4.end_date, + challenges = v4.challenges, + charge = v4.charge, + callback = callback, + vend_result = vendResult, + attributes = v4.attributes + ) + } + + /** Build the typed vend-result block from the transaction request's persisted attributes. + * Returns None when no vend has been recorded yet. */ + def utilityVendResultFromAttributes( + attributes: List[com.openbankproject.commons.model.TransactionRequestAttributeTrait] + ): Option[UtilityVendResultJsonV700] = { + val byName = attributes.map(a => a.name -> a.value).toMap + byName.get(UtilityVendAttribute.VendStatus).map { status => + UtilityVendResultJsonV700( + status = status, + luku_token = byName.get(UtilityVendAttribute.Token), + rcpt_num = byName.get(UtilityVendAttribute.RcptNum), + units = byName.get(UtilityVendAttribute.Units), + gwx_reference = byName.get(UtilityVendAttribute.GwxReference), + provider_message = byName.get(UtilityVendAttribute.ProviderMessage) + ) + } + } + // ── BULK transaction-request body ───────────────────────────────────────── case class BulkPaymentItemJsonV700( diff --git a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala index 216c240278..44893f9d43 100644 --- a/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala +++ b/obp-api/src/main/scala/code/routingscheme/RoutingSchemeSeed.scala @@ -43,8 +43,8 @@ object RoutingSchemeSeed { "TIPS Financial Service Provider code (3 digits).", List("TIPS")), Entry("TZ.NETWORK_PROVIDER", "TZ", "BANK", - "^[A-Z]+$", "AIRTEL", - "Mobile network operator name (AIRTEL, MPESA, VODACOM, HALOTEL, TTCL, MIX, ZANTEL).", + "^[A-Z]+$", "PROVIDERA", + "Mobile network operator short name (uppercase letters).", List("MNO_DIRECT")), Entry("TZ.BANK_ACCOUNT", "TZ", "ACCOUNT", "^[0-9]{8,16}$", "24110000296", @@ -52,27 +52,27 @@ object RoutingSchemeSeed { List("TIPS", "RTGS")), Entry("TZ.BANK_CODE", "TZ", "BANK", "^[0-9]{3}$", "003", - "Tanzanian domestic bank code (e.g. NMB = 003).", + "Tanzanian domestic bank code (3 digits).", List("TIPS", "RTGS")), Entry("TZ.BRANCH_CODE", "TZ", "BRANCH", "^[0-9]{3}$", "208", "Tanzanian branch routing code.", List("RTGS")), - Entry("TZ.GEPG_CONTROL_NUMBER", "TZ", "BILL", + Entry("TZ.BILL_CONTROL_NUMBER", "TZ", "BILL", "^[0-9]{12}$", "991043383705", - "GePG (Government e-Payment Gateway) bill control number.", - List("GEPG")), - Entry("TZ.GEPG_SP_CODE", "TZ", "BILL", + "Government / biller payment control number.", + List("BILL")), + Entry("TZ.BILL_SP_CODE", "TZ", "BILL", "^SP[0-9]{5}$", "SP99103", - "GePG service-provider code.", - List("GEPG")), - Entry("TZ.LUKU_METER", "TZ", "UTILITY", + "Biller service-provider code.", + List("BILL")), + Entry("TZ.UTILITY_METER", "TZ", "UTILITY", "^[0-9]{8,14}$", "24730238417", - "TANESCO LUKU prepaid electricity meter number.", - List("LUKU")), + "Prepaid electricity / utility meter number.", + List("UTILITY")), Entry("TZ.NIN", "TZ", "IDENTITY", "^[0-9]{20}$", "19331007175010005135", - "Tanzania National Identification Number (NIDA).", + "Tanzania National Identification Number.", Nil), Entry("TZ.TIN", "TZ", "IDENTITY", "^[0-9]{9}$", "123456789", diff --git a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala index 754fe61f90..a770e14c53 100644 --- a/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala +++ b/obp-api/src/main/scala/code/scheduler/MetricsArchiveScheduler.scala @@ -48,8 +48,15 @@ object MetricsArchiveScheduler extends MdcLoggable { logger.info(s"--------- Clean up Jobs ---------") logger.info(s"Delete all Jobs created by api_instance_id=$apiInstanceId") - JobScheduler.findAll(By(JobScheduler.Name, apiInstanceId)).map { i => - println(s"Job name: ${i.name}, Date: ${i.createdAt}") + // On boot this instance cannot have a genuinely-running job, so clear any of its + // own leftover lock rows (e.g. orphaned by a kill -9 / OOM / container eviction + // that bypassed the finally in runOnce). Match on ApiInstanceId, NOT Name — lock + // rows store Name=jobName and ApiInstanceId=apiInstanceId, so the old + // `By(Name, apiInstanceId)` never matched and a redeploy could not self-heal + // (only the 5-day sweep below would, leaving archiving stalled up to 5 days). + // Keyed on this instance's own id, so another node's running job is untouched. + JobScheduler.findAll(By(JobScheduler.ApiInstanceId, apiInstanceId)).map { i => + logger.info(s"Deleting leftover Job name: ${i.name}, Date: ${i.createdAt}, api_instance_id: $apiInstanceId") i }.map(_.delete_!) logger.info(s"Delete all Jobs older than 5 days") diff --git a/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala b/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala new file mode 100644 index 0000000000..30ac081178 --- /dev/null +++ b/obp-api/src/main/scala/code/utilitypayment/UtilityCallbackDispatcher.scala @@ -0,0 +1,57 @@ +package code.utilitypayment + +import java.io.IOException + +import code.util.Helper.MdcLoggable +import okhttp3._ + +/** + * Fire-and-forget delivery of UTILITY payment callbacks. + * + * On a UTILITY transaction-request that carried a `callback_url`, the endpoint + * persists a [[UtilityPaymentCallback]] row and calls [[deliver]] with the final + * result payload. We POST asynchronously and record the outcome on the row; the + * caller's request is never blocked on the callback, and a failed/unreachable + * callback URL does not fail the payment. + */ +object UtilityCallbackDispatcher extends MdcLoggable { + + private val client = new OkHttpClient + private val jsonType = MediaType.parse("application/json; charset=utf-8") + + /** + * @param callbackId the persisted UtilityPaymentCallback.CallbackId + * @param callbackUrl absolute URL to POST the result to + * @param payload JSON body (already rendered) + */ + def deliver(callbackId: String, callbackUrl: String, payload: String): Unit = { + val body = RequestBody.create(jsonType, payload) + val request = new Request.Builder().url(callbackUrl).post(body).build() + try { + client.newCall(request).enqueue(new Callback() { + def onFailure(call: Call, e: IOException): Unit = { + logger.warn(s"[UtilityCallbackDispatcher] delivery failed for callbackId=$callbackId url=$callbackUrl: ${e.getMessage}") + UtilityPaymentCallbacks.utilityPaymentCallback.vend + .recordAttempt(callbackId, UtilityCallbackStatus.Failed, None) + } + + def onResponse(call: Call, response: Response): Unit = { + val responseBody = response.body + try { + val code = response.code() + val status = if (response.isSuccessful) UtilityCallbackStatus.Delivered else UtilityCallbackStatus.Failed + logger.debug(s"[UtilityCallbackDispatcher] callbackId=$callbackId url=$callbackUrl responded $code") + UtilityPaymentCallbacks.utilityPaymentCallback.vend + .recordAttempt(callbackId, status, Some(code.toString)) + } finally if (responseBody != null) responseBody.close() + } + }) + } catch { + // Malformed URL or client-level failure — record and swallow; never fail the payment. + case e: Exception => + logger.warn(s"[UtilityCallbackDispatcher] could not enqueue callbackId=$callbackId url=$callbackUrl: ${e.getMessage}") + UtilityPaymentCallbacks.utilityPaymentCallback.vend + .recordAttempt(callbackId, UtilityCallbackStatus.Failed, None) + } + } +} diff --git a/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala b/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala new file mode 100644 index 0000000000..4cb8dbeab9 --- /dev/null +++ b/obp-api/src/main/scala/code/utilitypayment/UtilityPaymentCallback.scala @@ -0,0 +1,153 @@ +package code.utilitypayment + +import net.liftweb.common.Box +import net.liftweb.mapper._ +import net.liftweb.util.Helpers.tryo +import net.liftweb.util.SimpleInjector + +/** + * Per-request callback registry for UTILITY transaction-requests. When a caller + * supplies a `callback_url` on a UTILITY payment, OBP persists a row here and + * fires a fire-and-forget POST of the final result to that URL via + * [[UtilityCallbackDispatcher]]. + * + * Distinct from the account-event webhook system (code.webhook.*): those are + * standing, account-scoped subscriptions; this is a one-shot callback bound to + * a single transaction request. + */ +object UtilityPaymentCallbacks extends SimpleInjector { + val utilityPaymentCallback = new Inject(buildOne _) {} + + def buildOne: UtilityPaymentCallbackProvider = MappedUtilityPaymentCallbackProvider +} + +object UtilityCallbackStatus { + val Registered = "REGISTERED" + val Delivered = "DELIVERED" + val Failed = "FAILED" +} + +trait UtilityPaymentCallbackProvider { + def createCallback( + callbackId: String, + transactionRequestId: String, + callbackUrl: String, + identifierType: String, + identifier: String, + fromBankId: String, + fromAccountId: String, + createdByUserId: String + ): Box[UtilityPaymentCallbackTrait] + + def getCallbackByTransactionRequestId(transactionRequestId: String): Box[UtilityPaymentCallbackTrait] + + /** Record a delivery attempt outcome (increments the attempt counter). */ + def recordAttempt( + callbackId: String, + status: String, + responseCode: Option[String] + ): Box[UtilityPaymentCallbackTrait] +} + +trait UtilityPaymentCallbackTrait { + def callbackId: String + def transactionRequestId: String + def callbackUrl: String + def identifierType: String + def identifier: String + def fromBankId: String + def fromAccountId: String + def createdByUserId: String + def status: String + def attempts: Int + def responseCode: Option[String] + def createdAt: java.util.Date + def lastAttemptAt: Option[java.util.Date] +} + +object MappedUtilityPaymentCallbackProvider extends UtilityPaymentCallbackProvider { + + override def createCallback( + callbackId: String, + transactionRequestId: String, + callbackUrl: String, + identifierType: String, + identifier: String, + fromBankId: String, + fromAccountId: String, + createdByUserId: String + ): Box[UtilityPaymentCallbackTrait] = tryo { + UtilityPaymentCallback.create + .CallbackId(callbackId) + .TransactionRequestId(transactionRequestId) + .CallbackUrl(callbackUrl) + .IdentifierType(identifierType) + .Identifier(identifier) + .FromBankId(fromBankId) + .FromAccountId(fromAccountId) + .CreatedByUserId(createdByUserId) + .Status(UtilityCallbackStatus.Registered) + .Attempts(0) + .CreationDate(new java.util.Date()) + .saveMe() + } + + override def getCallbackByTransactionRequestId(transactionRequestId: String): Box[UtilityPaymentCallbackTrait] = + UtilityPaymentCallback.find(By(UtilityPaymentCallback.TransactionRequestId, transactionRequestId)) + + override def recordAttempt( + callbackId: String, + status: String, + responseCode: Option[String] + ): Box[UtilityPaymentCallbackTrait] = + UtilityPaymentCallback.find(By(UtilityPaymentCallback.CallbackId, callbackId)).map { row => + row + .Status(status) + .Attempts(row.Attempts.get + 1) + .ResponseCode(responseCode.getOrElse("")) + .LastAttemptDate(new java.util.Date()) + .saveMe() + } +} + +class UtilityPaymentCallback extends UtilityPaymentCallbackTrait with LongKeyedMapper[UtilityPaymentCallback] with IdPK { + def getSingleton = UtilityPaymentCallback + + object CallbackId extends MappedString(this, 64) + object TransactionRequestId extends MappedString(this, 64) + object CallbackUrl extends MappedString(this, 2048) + object IdentifierType extends MappedString(this, 64) + object Identifier extends MappedString(this, 255) + object FromBankId extends MappedString(this, 255) + object FromAccountId extends MappedString(this, 255) + object CreatedByUserId extends MappedString(this, 255) + object Status extends MappedString(this, 32) + object Attempts extends MappedInt(this) + object ResponseCode extends MappedString(this, 32) + object CreationDate extends MappedDateTime(this) { + override def defaultValue = new java.util.Date() + } + object LastAttemptDate extends MappedDateTime(this) + + private def opt(s: String): Option[String] = + if (s == null || s.isEmpty) None else Some(s) + + override def callbackId: String = CallbackId.get + override def transactionRequestId: String = TransactionRequestId.get + override def callbackUrl: String = CallbackUrl.get + override def identifierType: String = IdentifierType.get + override def identifier: String = Identifier.get + override def fromBankId: String = FromBankId.get + override def fromAccountId: String = FromAccountId.get + override def createdByUserId: String = CreatedByUserId.get + override def status: String = Status.get + override def attempts: Int = Attempts.get + override def responseCode: Option[String] = opt(ResponseCode.get) + override def createdAt: java.util.Date = CreationDate.get + override def lastAttemptAt: Option[java.util.Date] = Option(LastAttemptDate.get) +} + +object UtilityPaymentCallback extends UtilityPaymentCallback with LongKeyedMetaMapper[UtilityPaymentCallback] { + override def dbTableName = "UtilityPaymentCallback" + override def dbIndexes = UniqueIndex(CallbackId) :: Index(TransactionRequestId) :: super.dbIndexes +} diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala deleted file mode 100644 index e9ff0c58e5..0000000000 --- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectRoutesTest.scala +++ /dev/null @@ -1,105 +0,0 @@ -package code.api - -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import code.api.util.ErrorMessages -import code.setup.ServerSetup -import org.http4s.{Method, Request, Uri} - -/** - * Pure route test for the native http4s OpenID Connect callback - * (`Http4sOpenIdConnect`). No live provider, no TCP, no DB — drives the routes - * in-process and flips the gating Props via [[PropsReset]]. - * - * Pins the gates and path matching that the OBP-OIDC / Keycloak integration - * depends on: - * - `openid_connect.enabled=false` (default) → the callback paths do not match - * and fall through (None), exactly as before the migration. - * - `openid_connect.enabled=true` → the three callback paths match GET and POST. - * - `allow_openid_connect=false` → 401 OpenIDConnectIsDisabled. - * - the session-state gate and the token-exchange failure both surface as 401. - * - * The success path (200 {token}) needs a real provider to mint the OIDC tokens, - * so it is covered by the manual end-to-end verification, not here. - */ -class Http4sOpenIdConnectRoutesTest extends ServerSetup { - - private def run(req: Request[IO]): Option[(Int, String)] = - Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp => - val body = new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8") - (resp.status.code, body) - } - - private def get(path: String): Request[IO] = Request[IO](Method.GET, Uri.unsafeFromString(path)) - private def post(path: String): Request[IO] = Request[IO](Method.POST, Uri.unsafeFromString(path)) - - feature("OpenID Connect callback gating (openid_connect.enabled)") { - - scenario("Disabled by default — callback paths fall through (None)") { - Given("openid_connect.enabled is not set (default false)") - When("the three callback paths are invoked with GET and POST") - Then("none match — request falls through to the next route (ultimately notFoundCatchAll / JSON 404)") - run(get("/auth/openid-connect/callback")) shouldBe None - run(post("/auth/openid-connect/callback")) shouldBe None - run(get("/auth/openid-connect/callback-1")) shouldBe None - run(post("/auth/openid-connect/callback-2")) shouldBe None - } - - scenario("Enabled — the three callback paths match GET and POST") { - Given("openid_connect.enabled=true and the session-state check disabled (no portal)") - setPropsValues( - "openid_connect.enabled" -> "true", - "openid_connect.check_session_state" -> "false" - ) - When("the three callback paths are invoked with GET and POST (no provider configured)") - Then("each matches and yields a 401 token-exchange failure (not None)") - // No provider props → exchangeAuthorizationCodeForTokens fails → 401 CouldNotExchange... - List( - get("/auth/openid-connect/callback"), - post("/auth/openid-connect/callback"), - get("/auth/openid-connect/callback-1"), - post("/auth/openid-connect/callback-1"), - get("/auth/openid-connect/callback-2"), - post("/auth/openid-connect/callback-2") - ).foreach { req => - val (code, body) = run(req).getOrElse(fail(s"route did not match ${req.method} ${req.uri}")) - code shouldBe 401 - body should include(ErrorMessages.CouldNotExchangeAuthorizationCodeForTokens) - } - } - - scenario("Enabled but allow_openid_connect=false → 401 OpenIDConnectIsDisabled") { - Given("openid_connect.enabled=true but allow_openid_connect=false") - setPropsValues( - "openid_connect.enabled" -> "true", - "allow_openid_connect" -> "false" - ) - When("the callback is invoked") - val (code, body) = run(post("/auth/openid-connect/callback")) - .getOrElse(fail("route did not match")) - Then("it returns 401 OpenIDConnectIsDisabled before any token exchange") - code shouldBe 401 - body should include(ErrorMessages.OpenIDConnectIsDisabled) - } - - scenario("Enabled, default session-state check, non-matching state → 401 InvalidOpenIDConnectState") { - Given("openid_connect.enabled=true with the session-state check left at its default (true)") - setPropsValues("openid_connect.enabled" -> "true") - When("a callback arrives whose state does not equal the (empty) session state") - val (code, body) = run(get("/auth/openid-connect/callback?code=abc&state=non-empty")) - .getOrElse(fail("route did not match")) - Then("it returns 401 InvalidOpenIDConnectState before any token exchange") - code shouldBe 401 - body should include(ErrorMessages.InvalidOpenIDConnectState) - } - - scenario("Enabled — an unrelated /auth/openid-connect path does not match") { - Given("openid_connect.enabled=true") - setPropsValues("openid_connect.enabled" -> "true") - When("a path that is not one of the three callbacks is invoked") - Then("the route does not match") - run(get("/auth/openid-connect/callback-3")) shouldBe None - run(get("/auth/openid-connect/other")) shouldBe None - } - } -} diff --git a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala b/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala deleted file mode 100644 index 2df62e119a..0000000000 --- a/obp-api/src/test/scala/code/api/Http4sOpenIdConnectSuccessTest.scala +++ /dev/null @@ -1,133 +0,0 @@ -package code.api - -import cats.effect.IO -import cats.effect.unsafe.implicits.global -import code.setup.ServerSetup -import code.users.Users -import com.comcast.ip4s._ -import com.nimbusds.jose.crypto.RSASSASigner -import com.nimbusds.jose.jwk.JWKSet -import com.nimbusds.jose.jwk.gen.RSAKeyGenerator -import com.nimbusds.jose.{JWSAlgorithm, JWSHeader} -import com.nimbusds.jwt.{JWTClaimsSet, SignedJWT} -import org.http4s.dsl.io._ -import org.http4s.ember.server.EmberServerBuilder -import org.http4s.implicits._ -import org.http4s.{HttpRoutes, Method, Request, Uri} - -import java.util.Date - -/** - * Success-path integration test for [[Http4sOpenIdConnect]] — the `200 {"token": ...}` - * branch that the routing/failure suite ([[Http4sOpenIdConnectRoutesTest]]) cannot reach - * because it needs a provider to mint the OIDC tokens. - * - * Self-contained, no live provider: stands up a local stub OIDC provider (Ember) that - * serves - * - `POST /token` → a token response whose `id_token` is a locally-signed RS256 JWT - * - `GET /jwks` → the matching public JWK set - * points the `openid_connect_1.*` props at it, then drives the callback route in-process. - * It asserts the handler exchanges the code, validates the JWT against the JWKS, - * provisions the resource user, and returns `200 {"token": ...}`. - * - * Why a freshly-signed token is enough: `JwtUtil.validateIdToken` reads `iss`/`aud` from - * the token itself and only enforces the signature (against the served JWKS) and expiry, - * so no configured iss/aud matching is required. The JWS header `kid` matches the served - * JWK so the verification key selector picks the right key. - * - * This also exercises the M1 change — the provisioning block is now wrapped in - * `DB.use(DefaultConnectionIdentifier)` (one connection for all OIDC writes). - */ -class Http4sOpenIdConnectSuccessTest extends ServerSetup { - - private val providerClaim = "http://127.0.0.1/oidc-test-provider" - private val preferredUser = "oidctestuser" - private val clientId = "obp-oidc-test-client" - - // RSA keypair used to sign the id_token; its public half is served at /jwks. - private val rsaJwk = new RSAKeyGenerator(2048).keyID("oidc-test-kid").generate() - - private def signedIdToken(issuer: String): String = { - val claims = new JWTClaimsSet.Builder() - .issuer(issuer) - .subject("oidc-test-subject") - .audience(clientId) - .expirationTime(new Date(System.currentTimeMillis() + 3600L * 1000)) - .issueTime(new Date()) - .claim("preferred_username", preferredUser) - .claim("email", "oidctest@example.com") - .claim("provider", providerClaim) - .claim("azp", clientId) - .build() - val jwt = new SignedJWT( - new JWSHeader.Builder(JWSAlgorithm.RS256).keyID(rsaJwk.getKeyID).build(), - claims - ) - jwt.sign(new RSASSASigner(rsaJwk)) - jwt.serialize() - } - - /** Allocate an ephemeral free port for the stub provider. */ - private def freePort(): Int = { - val socket = new java.net.ServerSocket(0) - try socket.getLocalPort finally socket.close() - } - - private def stubProvider(issuer: String): HttpRoutes[IO] = HttpRoutes.of[IO] { - case POST -> Root / "token" => - Ok(s"""{"id_token":"${signedIdToken(issuer)}","access_token":"access-xyz",""" + - s""""token_type":"Bearer","expires_in":"3600","refresh_token":"refresh-xyz","scope":"openid"}""") - case GET -> Root / "jwks" => - Ok(new JWKSet(rsaJwk.toPublicJWK).toString) - } - - private def run(req: Request[IO]): (Int, String) = - Http4sOpenIdConnect.routes.run(req).value.unsafeRunSync().map { resp => - (resp.status.code, new String(resp.body.compile.to(Array).unsafeRunSync(), "UTF-8")) - }.getOrElse(fail(s"route did not match ${req.method} ${req.uri}")) - - feature("OpenID Connect callback — success path") { - - scenario("valid code + signed id_token → 200 {token} and the user is provisioned") { - val port = freePort() - val portObj = Port.fromInt(port).getOrElse(fail(s"invalid free port $port")) - val issuer = s"http://127.0.0.1:$port" - - val server = EmberServerBuilder - .default[IO] - .withHost(ipv4"127.0.0.1") - .withPort(portObj) - .withHttpApp(stubProvider(issuer).orNotFound) - .build - - server.use { _ => - IO { - Given("a local stub OIDC provider and openid_connect_1.* pointed at it") - setPropsValues( - "openid_connect.enabled" -> "true", - "openid_connect.check_session_state" -> "false", - "allow_openid_connect" -> "true", - "openid_connect_1.client_id" -> clientId, - "openid_connect_1.client_secret" -> "test-secret", - "openid_connect_1.callback_url" -> "http://localhost/auth/openid-connect/callback", - "openid_connect_1.endpoint.token" -> s"$issuer/token", - "openid_connect_1.endpoint.jwks_uri" -> s"$issuer/jwks" - ) - - When("the provider redirects back to the callback with an authorization code") - val (code, body) = run( - Request[IO](Method.GET, - Uri.unsafeFromString("/auth/openid-connect/callback?code=auth-code-123&state=ignored")) - ) - - Then("the handler returns 200 with a minted OBP DirectLogin token") - code shouldBe 200 - body should include("token") - - And("the resource user was provisioned from the validated claims") - Users.users.vend.getUserByProviderId(providerClaim, preferredUser).isDefined shouldBe true - } - }.unsafeRunSync() - } - } -} diff --git a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala index 8756b26b99..8422565431 100644 --- a/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala +++ b/obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala @@ -6,8 +6,9 @@ import code.api.util.http4s.Http4sStandardHeaders import code.api.Constant.SYSTEM_OWNER_VIEW_ID import code.api.ResponseHeader import code.api.util.APIUtil -import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} -import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId} +import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateOrganisation, canCreateRoutingScheme, canCreateUtilityVendResult, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canUpdateSystemView, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetCardsForBank, canGetConnectorHealth, canCreateMetricsArchiveRun, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMetricsDiagnostics, canGetMigrations, canReadResourceDoc, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme} +import code.api.util.ErrorMessages.{AuthenticatedUserIsRequired, BankNotFound, EntitlementAlreadyExists, InvalidOrganisationIdFormat, InvalidRoutingSchemeName, MobileWalletDestinationNotFound, MobileWalletInvalidMsisdn, OrganisationAlreadyExists, OrganisationNotFound, PayeeLookupAddressMismatch, PayeeLookupIdentifierTypeNotRegistered, PayeeNotFound, RoutingSchemeAlreadyExists, RoutingSchemeExampleAddressMismatch, RoutingSchemeNotFound, SystemViewNotFound, UserHasMissingRoles, UserNotFoundByUserId, UtilityIdentifierTypeWrongCategory, UtilityInvalidIdentifier, UtilityTransactionRequestNotFound} +import code.utilitypayment.{UtilityCallbackStatus, UtilityPaymentCallbacks} import code.api.Constant.SYSTEM_AUDITOR_VIEW_ID import code.views.MapperViews import code.views.system.ViewPermission @@ -26,7 +27,7 @@ import org.typelevel.ci.CIString import java.util.Date import code.setup.ServerSetupWithTestData import net.liftweb.json.JValue -import net.liftweb.json.JsonAST.{JArray, JBool, JField, JObject, JString} +import net.liftweb.json.JsonAST.{JArray, JBool, JField, JNull, JObject, JString} import net.liftweb.json.JsonParser.parse import org.scalatest.Tag @@ -1240,14 +1241,14 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { createTestRoutingScheme(scheme) val headers = Map("DirectLogin" -> s"token=${token1.value}") - val body = """{"enabled":true,"bank_notes":"Routed via Gateway X. Cutoff 22:00."}""" + val body = """{"enabled":true,"bank_notes":"Routed via the payment gateway. Cutoff 22:00."}""" val (statusCode, json, _) = makeHttpRequestWithBody("PUT", s"/obp/v7.0.0/banks/$bankId/supported-routing-schemes/$scheme", body, headers) statusCode shouldBe 200 json match { case JObject(fields) => val map = toFieldMap(fields) map.get("scheme") shouldBe Some(JString(scheme)) - map.get("bank_notes") shouldBe Some(JString("Routed via Gateway X. Cutoff 22:00.")) + map.get("bank_notes") shouldBe Some(JString("Routed via the payment gateway. Cutoff 22:00.")) case _ => fail("Expected JSON object") } } @@ -1616,6 +1617,259 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } + // ─── UTILITY transaction request ────────────────────────────────────────── + + /** Seed a UTILITY/BILL-category routing scheme plus a destination account_routing + * so the biller resolves (mirrors seedPayeeForLookup but with a non-ACCOUNT category). */ + private def seedUtilityBiller(prefix: String, category: String, address: String, destBankId: String, destAccountId: String): String = { + val scheme = freshSchemeName(prefix) + RoutingSchemes.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = category, + addressPattern = "^[0-9]+$", secondaryAddressPattern = None, + exampleAddress = address, description = "Test biller", downstreamRails = Nil, + status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + BankAccountRouting.create + .BankId(destBankId) + .AccountId(destAccountId) + .AccountRoutingScheme(scheme) + .AccountRoutingAddress(address) + .saveMe() + scheme + } + + feature("Http4s700 createTransactionRequestUtility endpoint") { + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = """{"to":{"scheme":"TZ.UTILITY_METER","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}""" + val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body) + statusCode shouldBe 401 + } + + scenario("Return 400 when identifier scheme is not registered", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val body = """{"to":{"scheme":"TZ.UNKNOWN_BILLER","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(PayeeLookupIdentifierTypeNotRegistered) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when identifier scheme category is not UTILITY or BILL", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // Register an ACCOUNT-category scheme — valid pattern, wrong category for a UTILITY payment. + val scheme = freshSchemeName("ACAT") + RoutingSchemes.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = "ACCOUNT", + addressPattern = "^[0-9]+$", secondaryAddressPattern = None, + exampleAddress = "24730238417", description = "Account scheme", + downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + val body = s"""{"to":{"scheme":"$scheme","value":"24730238417"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UtilityIdentifierTypeWrongCategory) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 400 when identifier value does not match the scheme's address_pattern", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + // UTILITY-category scheme with a strict numeric pattern; send a non-numeric value. + val scheme = freshSchemeName("USTR") + RoutingSchemes.routingScheme.vend.createRoutingScheme( + scheme = scheme, country = "TZ", category = "UTILITY", + addressPattern = "^[0-9]{8,14}$", secondaryAddressPattern = None, + exampleAddress = "24730238417", description = "Strict meter", + downstreamRails = Nil, status = "ACTIVE", createdByUserId = resourceUser1.userId + ) + val body = s"""{"to":{"scheme":"$scheme","value":"not-a-meter"},"value":{"currency":"TZS","amount":"1000"},"description":"utility"}""" + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers) + statusCode shouldBe 400 + json match { + case JObject(fields) => + toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UtilityInvalidIdentifier) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 201 with a registered callback when the biller resolves", Http4s700RoutesTag) { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + + val meter = s"247${(System.currentTimeMillis() % 100000000L).toString.reverse.padTo(8, '0').reverse}" + val scheme = seedUtilityBiller("UTIL", "UTILITY", meter, bankId, accountId) + + val body = + s"""{ + | "to": {"scheme":"$scheme","value":"$meter"}, + | "value": {"currency":"$acctCurrency","amount":"1000"}, + | "description": "utility token purchase", + | "client_reference": "ref-0001", + | "payer": {"phone":"255700000000","name":"Jane Doe","email":"jane.doe@example.com"}, + | "callback_url": "https://example.com/utility/callback" + |}""".stripMargin + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers) + statusCode shouldBe 201 + val trId = json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.keys should contain allOf ("id", "type", "from", "details", "status", "callback") + map.get("type") shouldBe Some(JString("UTILITY")) + map.get("callback") match { + case Some(JObject(cbFields)) => + val cb = toFieldMap(cbFields) + cb.get("callback_url") shouldBe Some(JString("https://example.com/utility/callback")) + cb.keys should contain allOf ("callback_id", "status") + // The vend is asynchronous: the callback is only REGISTERED at create time, + // not yet fired (the token does not exist until the rail delivers the vend result). + cb.get("status") shouldBe Some(JString("REGISTERED")) + case other => fail(s"Expected callback object, got: $other") + } + // No token at creation time — vend_result must be absent / null. + map.get("vend_result").foreach(_ shouldBe JNull) + map.get("id") match { + case Some(JString(id)) => id + case _ => fail("Expected id as JSON string") + } + case _ => fail("Expected JSON object") + } + + // The one-shot callback row was persisted against this transaction request, still REGISTERED. + val stored = UtilityPaymentCallbacks.utilityPaymentCallback.vend.getCallbackByTransactionRequestId(trId) + stored.isDefined shouldBe true + stored.openOrThrowException("callback row").callbackUrl shouldBe "https://example.com/utility/callback" + stored.openOrThrowException("callback row").status shouldBe UtilityCallbackStatus.Registered + } + } + + // ─── UTILITY vend-result delivery (asynchronous token) ──────────────────── + + /** Create a UTILITY transaction request and return its id, so the vend-result endpoint + * has a real TR (with a registered callback) to deliver against. */ + private def createUtilityTrWithCallback(): String = { + val bankId = testBankId1.value + val accountId = testAccountId0.value + val acctCurrency = code.bankconnectors.Connector.connector.vend + .getBankAccountLegacy(testBankId1, testAccountId0, None) + .map(_._1.currency).openOrThrowException("test account") + val meter = s"247${(System.currentTimeMillis() % 100000000L).toString.reverse.padTo(8, '0').reverse}" + val scheme = seedUtilityBiller("VEND", "UTILITY", meter, bankId, accountId) + val body = + s"""{ + | "to": {"scheme":"$scheme","value":"$meter"}, + | "value": {"currency":"$acctCurrency","amount":"1000"}, + | "description": "utility token purchase", + | "callback_url": "https://example.com/utility/callback" + |}""".stripMargin + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (sc, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/$bankId/accounts/$accountId/owner/transaction-request-types/UTILITY/transaction-requests", body, headers) + sc shouldBe 201 + json match { + case JObject(fields) => toFieldMap(fields).get("id") match { + case Some(JString(id)) => id + case _ => fail("Expected id in create response") + } + case _ => fail("Expected JSON object from create") + } + } + + feature("Http4s700 createUtilityVendResult endpoint") { + + val vendBody = + """{"status":"COMPLETED","luku_token":"1234 5678 9012 3456 7890","rcpt_num":"202306141018422348674","units":"46.5","gwx_reference":"GWX800930701197"}""" + + scenario("Reject unauthenticated POST", Http4s700RoutesTag) { + val (statusCode, _, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/${testBankId1.value}/utility-payments/any-tr-id/vend-result", vendBody) + statusCode shouldBe 401 + } + + scenario("Return 403 when authenticated but missing canCreateUtilityVendResult role", Http4s700RoutesTag) { + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/${testBankId1.value}/utility-payments/any-tr-id/vend-result", vendBody, headers) + statusCode shouldBe 403 + json match { + case JObject(fields) => toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(canCreateUtilityVendResult.toString) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 404 when the transaction request does not exist", Http4s700RoutesTag) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateUtilityVendResult.toString) + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/${testBankId1.value}/utility-payments/does-not-exist/vend-result", vendBody, headers) + statusCode shouldBe 404 + json match { + case JObject(fields) => toFieldMap(fields).get("message") match { + case Some(JString(msg)) => msg should include(UtilityTransactionRequestNotFound) + case _ => fail("Expected message field") + } + case _ => fail("Expected JSON object") + } + } + + scenario("Return 200 and persist the vend result (token) against the transaction request", Http4s700RoutesTag) { + Entitlement.entitlement.vend.addEntitlement("", resourceUser1.userId, canCreateUtilityVendResult.toString) + val trId = createUtilityTrWithCallback() + + val headers = Map("DirectLogin" -> s"token=${token1.value}") + val (statusCode, json, _) = makeHttpRequestWithBody("POST", s"/obp/v7.0.0/banks/${testBankId1.value}/utility-payments/$trId/vend-result", vendBody, headers) + statusCode shouldBe 200 + json match { + case JObject(fields) => + val map = toFieldMap(fields) + map.get("transaction_request_id") shouldBe Some(JString(trId)) + map.get("type") shouldBe Some(JString("UTILITY")) + map.get("vend_result") match { + case Some(JObject(vrFields)) => + val vr = toFieldMap(vrFields) + vr.get("luku_token") shouldBe Some(JString("1234 5678 9012 3456 7890")) + vr.get("rcpt_num") shouldBe Some(JString("202306141018422348674")) + vr.get("status") shouldBe Some(JString("COMPLETED")) + case other => fail(s"Expected vend_result object, got: $other") + } + // The callback registered on the original request is surfaced here (delivery triggered). + map.get("callback") match { + case Some(JObject(cbFields)) => + toFieldMap(cbFields).get("callback_url") shouldBe Some(JString("https://example.com/utility/callback")) + case other => fail(s"Expected callback object, got: $other") + } + case _ => fail("Expected JSON object") + } + // Note: the response's vend_result is built by reading the attributes back from the + // provider, so the assertions above already prove the token was persisted and round-tripped. + } + } + // ─── factoryResetSystemView ─────────────────────────────────────────────── feature("Http4s700 factoryResetSystemView endpoint") { diff --git a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala index 08677166b7..5962a9af1d 100644 --- a/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala +++ b/obp-commons/src/main/scala/com/openbankproject/commons/model/enums/Enumerations.scala @@ -127,6 +127,7 @@ object TransactionRequestTypes extends OBPEnumeration[TransactionRequestTypes]{ object MOBILE_WALLET extends Value object BULK extends Value object OPEN_CORRIDOR extends Value + object UTILITY extends Value } sealed trait StrongCustomerAuthentication extends EnumValue