diff --git a/CLAUDE.md b/CLAUDE.md index 482ff89a9c..f03fae5336 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ The goal is a full http4s migration — replace Lift Web across all version file **Migrated endpoints** (45): root, getBanks, getCards, getCardsForBank, getResourceDocsObpV700, getBank, getCurrentUser, getCoreAccountById, getPrivateAccountByIdFull, getExplicitCounterpartyById, deleteEntitlement, addEntitlement, getAccountAccessTrace, getFeatures, getScannedApiVersions, getConnectors, getErrorMessages, getProviders, getUsers, getUserByUserId, getCustomersAtOneBank, getCustomerByCustomerId, getAccountsAtBank, createTradingOffer, getTradingOffer, getTradingOffers, cancelTradingOffer, createMarketOrder, getMarketOrder, cancelMarketOrder, createMarketMatch, getMarketTrade, requestSettlement, requestWithdrawal, getCacheConfig, getCacheInfo, getDatabasePoolInfo, getStoredProcedureConnectorHealth, getMigrations, getCacheNamespaces, createOrganisation, getOrganisations, getOrganisation, updateOrganisation, deleteOrganisation. -**Tests**: `Http4s700RoutesTest` (111 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. +**Tests**: `Http4s700RoutesTest` (102 scenarios, port 8087). `makeHttpRequest` returns `(Int, JValue, Map[String, String])`. `makeHttpRequestWithBody(method, path, body, headers)` for POST/PUT. ## Migrating a Lift Endpoint to http4s @@ -292,9 +292,9 @@ Compile times are consistent across all three shards — Zinc cache restores cor At the integration level both frameworks are similarly server/DB-bound (~0.32–0.45 s/test). The real http4s gain is the **unit/pure tier** — tests that don't need a running server are 54× faster. As more logic moves into pure functions (request parsing, response building, auth checks) these unit tests replace integration tests and the savings compound. -The 6 integration suites (pre-merge timings; Http4s700RoutesTest has grown to 111 scenarios): +The 6 integration suites (pre-merge timings; Http4s700RoutesTest is currently 102 scenarios): - `obp-api/src/test/scala/code/api/http4sbridge/Http4sLiftBridgePropertyTest.scala` — 51 tests, 31.9s -- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — 111 tests (was 75, 23.8s pre-merge) +- `obp-api/src/test/scala/code/api/v7_0_0/Http4s700RoutesTest.scala` — 102 tests (was 75 pre-merge, 23.8s) - `obp-api/src/test/scala/code/api/v7_0_0/V7ResourceDocsAggregationTest.scala` — intentionally failing until resource-docs aggregation bug is fixed - `obp-api/src/test/scala/code/api/http4sbridge/Http4sServerIntegrationTest.scala` — 16 tests, 5.0s - `obp-api/src/test/scala/code/api/v5_0_0/Http4s500SystemViewsTest.scala` — 13 tests, 4.4s @@ -346,9 +346,8 @@ Per-endpoint integration test cost stays roughly constant as endpoints move Lift | Version | Genuine Lift handlers still on the bridge | |---|---| -| v1.2.1, v1.3.0, v2.0.0, v2.1.0, v2.2.0, v3.0.0, v4.0.0, v5.0.0, v5.1.0, v6.0.0 | 0 — fully on http4s | -| v1.4.0 | `testResourceDoc` (dev-mode-only stub; tracked in "Per-version Lift leftovers") | -| v3.1.0 | `getMessageDocsSwagger`, `getObpConnectorLoopback` (both tracked as leftovers; retire via Resource-docs / bridge-removal workstreams) | +| v1.2.1, v1.3.0, v1.4.0, v2.0.0, v2.1.0, v2.2.0, v3.0.0, v4.0.0, v5.0.0, v5.1.0, v6.0.0 | 0 — fully on http4s | +| v3.1.0 | `getMessageDocsSwagger`, `getObpConnectorLoopback`. `getMessageDocsSwagger`'s URL is in production already served by `Http4sResourceDocs.routes` (the Lift `lazy val` is shadowed dead code), but the Lift definition is intentionally kept — deleting it would reduce v3.1.0's frozen STABLE API surface (caught by `FrozenClassTest`) and require touching a v3.1.0 test. Retires together with the bridge-removal PR. `getObpConnectorLoopback` likewise deferred to the bridge-removal PR. | ### v6.0.0 migration — done (243 / 243) Phase 1 (35 overrides) and Phase 2 (208 originals) both complete. All v6 routes live in `Http4s600.scala`, wired into `Http4sApp.baseServices` ahead of the Lift bridge. @@ -363,3 +362,4 @@ Architectural note from the v6 migration: around the 140-endpoint mark `Implemen - **CI speed-up** (not done): two-tier fast gate + full suite; surefire parallel forks. - **Disabled tests to fix**: `Http4s500RoutesTest` (@Ignore, in-process issue), `RootAndBanksTest` (@Ignore), `V500ContractParityTest` (@Ignore), `CardTest` (fully commented out). `v5_0_0`: 13 skipped tests (setup cost paid, no value). - **`V7ResourceDocsAggregationTest`**: intentionally failing — encodes the fix for the resource-docs aggregation bug (v7 endpoint returns only ~10 own docs instead of 500+ aggregated). Fix the bug to make this suite pass. +- **Flaky `MakerCheckerTransactionRequestTest` — TTL/proxy connection race in v4 createTransactionRequest** (pre-existing, predates the auth-stack migration). Scenario *"Multiple challenges with maker-checker: different users answer their own challenges"* (`MakerCheckerTransactionRequestTest.scala:246`) fails ~40% of local runs and was observed once in CI shard1. Diagnosed root cause: inside one HTTP request, `LocalMappedConnector.createTransactionRequestv210` writes N rows to `MappedExpectedChallengeAnswer` via the request-scoped proxy connection (auto-commit=false, request-end commit) and then reads them back via `getChallengesByTransactionRequestId`. When `RequestScopeConnection.currentProxy` (a `TransmittableThreadLocal`) fails to propagate to the read `Future`'s worker thread, `RequestAwareConnectionManager.newConnection` returns `null` → falls back to a fresh pool connection (autocommit=true) that cannot see the proxy connection's uncommitted writes → read returns 0 rows. Diagnostic confirmed: in failing runs, `createChallengesC2` is called with the correct 2 userIds, but `MappedExpectedChallengeAnswer.findAll()` (no WHERE clause) returns 0 rows — i.e. the entire table is empty from the read connection's view. Only the multi-user path (`REQUIRED_CHALLENGE_ANSWERS > 1`) hits this because it adds an extra synchronous `Views.views.vend.permissions(...)` inside `getAccountAttributesByAccount.map` that shifts the Future-scheduling timing. The other 3 scenarios in the file always pass because they take the default `REQUIRED_CHALLENGE_ANSWERS=1` shortcut. **Fix direction:** every DB-touching `Future { ... }` inside the connector chain needs to go through `RequestScopeConnection.fromFuture` (which atomically sets+submits+clears the TTL inside `IO.defer`) instead of being raw Scala `Future { ... }` chained via `flatMap`. Alternatively: stop relying on TTL and pass the proxy connection explicitly down the connector call-chain (bigger change, but eliminates the race class entirely). diff --git a/LIFT_HTTP4S_MIGRATION.md b/LIFT_HTTP4S_MIGRATION.md index 5167da65df..76b46f9343 100644 --- a/LIFT_HTTP4S_MIGRATION.md +++ b/LIFT_HTTP4S_MIGRATION.md @@ -118,7 +118,7 @@ Bottom-up — each version depends on the one below it being done. | 5 | `APIMethods210` | 28 | **Done** — `Http4s210.scala`: 25 own endpoints + path-rewriting bridge to `Http4s200`; all 79 v2.1.0 tests pass | | 6 | `APIMethods220` | 19 | **Done** — `Http4s220.scala`: 18 own endpoints + path-rewriting bridge to `Http4s210`; all 27 v2.2.0 tests pass | | 7 | `APIMethods300` | 47 | **Done** — `Http4s300.scala`: 47 own endpoints + path-rewriting bridge to `Http4s220`; all 86 v3.0.0 tests pass | -| 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. Two endpoints tracked separately in "Per-version Lift leftovers" (`getMessageDocsSwagger`, `getObpConnectorLoopback`) — they retire via the Resource-docs workstream / bridge-removal PR, not as v3.1.0 follow-up. | +| 8 | `APIMethods310` | 102 | **Done** — `Http4s310.scala` has all 100 functional endpoints (42 GET, 10 DELETE, 19 POST, 25 PUT, 1 GET-shaped revoke, 3 SCA aliases) + path-rewriting bridge to `Http4s300`; 181 v3.1.0 tests pass. `getMessageDocsSwagger` is shadowed in production by `Http4sResourceDocs.routes` but the Lift `lazy val` is intentionally kept — its deletion is caught by `FrozenClassTest` as a STABLE-API surface reduction. Both `getMessageDocsSwagger` and `getObpConnectorLoopback` retire together in the bridge-removal PR. | | 9 | `APIMethods400` | 258 | **Done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers (`lazy val NAME: HttpRoutes[IO]`) plus 8 ResourceDoc aliases for the transaction-request-type variants (ACCOUNT, ACCOUNT_OTP, SEPA, COUNTERPARTY, REFUND, FREE_FORM, SIMPLE, AGENT_CASH_WITHDRAWAL — handled by the shared `createTransactionRequest` wildcard handler; the `literalAllCapsSegments` set in `Http4sSupport.scala` dispatches the matcher to the per-type doc for swagger purposes). Adopts the **lazy val + helper-def init pattern** (Batches 1–19) introduced in v6 to dodge the JVM 64KB `` method-size limit. **Bridge-cascade hijack** historically threatened v4's overrides; resolved by migrating all 35/35 v4-over-older URL+verb overrides. | | 10 | `APIMethods500` | 10 | **Done** — `Http4s500.scala` (all v5.0.0 originals migrated) | | 11 | `APIMethods510` | 111 | **Done** — `Http4s510.scala`. v5.1.0's `createConsent` Lift handler is exposed in Http4s510 under the alias name `createConsentImplicit` (a single handler with `if scaMethod == "EMAIL" \|\| scaMethod == "SMS" \|\| scaMethod == "IMPLICIT"` guard covers all three SCA-method URLs). | @@ -170,12 +170,12 @@ Token-generation paths — not version-file endpoints. Each `extends RestHelper` | Component | Path | Notes | |---|---|---| -| `DirectLogin` | `POST /my/logins/direct` | | -| `GatewayLogin` | gateway JWT exchange | | -| `DAuth` | dAuth JWT exchange | | -| `OAuth` | OAuth 1.0a token endpoints | Most complex | +| `DirectLogin` | `POST /my/logins/direct` | Done — served by `Http4s600.directLoginEndpoint` (versioned) and `DirectLoginRoutes` (bare path). | +| `GatewayLogin` | gateway JWT exchange | Library-only validator (no routes). | +| `DAuth` | dAuth JWT exchange | Library-only validator (no routes). | +| `OpenIdConnect` | OIDC callback | Blocked — last hard dependency on Lift Web in the request path. See auth-stack leftovers table. | -These are the last hard dependency on Lift Web in the request path. The Lift bridge cannot be removed until all four are done. +OAuth 1.0a token endpoints were removed entirely in commit `51820c75e` (2026-02-20); the workstream collapsed. --- @@ -185,9 +185,9 @@ An `APIMethods{version}` file is marked **done** in the progress table when ever | Endpoint | Origin | Why on Lift | Retired by | |---|---|---|---| -| `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `APIMethods310` | Same shape as `getResourceDocsObpV700` / `openapi.yaml` — runtime Swagger generation with shared caching | The **Http4sResourceDocs** workstream (step 4) | +| `getMessageDocsSwagger` (`GET /message-docs/CONNECTOR/swagger2.0`) | `APIMethods310` | The URL is already served by `Http4sResourceDocs.routes` (`handleGetMessageDocsSwagger`), so the Lift `lazy val` is shadowed dead code. **But** deleting it reduces v3.1.0's STABLE API surface, which `FrozenClassTest` correctly rejects (the frozen-API guard sees a `lazy val ... : OBPEndpoint` go missing from `Implementations3_1_0`). Refreshing the frozen snapshot via `FrozenClassUtil.main` is the documented way out, but doing so also requires touching `GetMessageDocsSwaggerTest`, which is below v7.0.0. Kept as-is until the bridge-removal PR retires it. | The bridge-removal PR. | | `getObpConnectorLoopback` (`GET /connector/loopback`) | `APIMethods310` | Deprecated stub that unconditionally throws `IllegalStateException(NotImplemented)`; no functional behaviour | Either a 3-line native http4s route that throws the same exception or outright deletion, decided when the Lift bridge is removed | -| `testResourceDoc` (`GET /dummy`) | `APIMethods140` | Dev-only stub gated behind `if (Props.devMode) { ... }`. Returns a dummy `APIInfoJSON` payload for testing the resource-doc renderer. Has no production behaviour worth porting. | Deleted in the bridge-removal PR (no native equivalent needed). | +| ~~`testResourceDoc`~~ | ~~`APIMethods140`~~ | Dev-mode-only `/dummy` stub deleted — returned a dummy `APIInfoJSON`, no production behaviour. Removed from `OBPAPI1_4_0.routes` and `Implementations1_4_0`. `FrozenClassTest` did not flag it because v1.4.0's `testResourceDoc` ResourceDoc was registered behind `if (Props.devMode)` — the frozen snapshot (captured in test mode) never contained it. | **Done.** | Track new leftovers here when later version files are migrated — the bridge-removal milestone in "Done Criteria" only requires the per-version files to be **done** in this table's sense (functional endpoints migrated, tests green). Leftovers folded into the Resource-docs or Auth-stack workstreams retire via those workstreams. @@ -201,14 +201,14 @@ Things still on Lift that block the `Http4sLiftWebBridge` from being removed. Us | Handler | File | Routes | Status | |---|---|---|---| -| `DirectLogin` | `code/api/directlogin.scala` | `POST /my/logins/direct` | http4s version inside `Http4s600.scala`; Lift dispatch **still registered** as fallback. Earlier-version paths still hit Lift. The key gotcha: `createTokenFuture(allParameters)` ignores its argument and re-reads from Lift's `S.request` via `getAllParameters`. Use `validatorFutureWithParams(...)` + `createTokenCommonPart(...)` instead — this is the http4s-friendly entry point. | -| `GatewayLogin` | `code/api/GatewayLogin.scala` | Gateway JWT exchange | Lift only | -| `DAuth` | `code/api/dauth.scala` | dAuth JWT exchange | Lift only | -| `OAuth 1.0a` | OAuth files | OAuth 1.0a token endpoints | Lift only | -| `OAuth2` | `code/api/OAuth2.scala` | OAuth 2.0 token & callback | Lift only | -| `OpenIdConnect` | `code/api/openidconnect.scala` | OIDC callback — registered via `LiftRules.dispatch.append` | Lift only | +| `DirectLogin` | `code/api/directlogin.scala` | `POST /my/logins/direct` | **Done.** Versioned path (`/obp/v6.0.0/my/logins/direct`) served by `Http4s600.directLoginEndpoint`; bare path (`/my/logins/direct`) served by `code.api.DirectLoginRoutes` wired into `Http4sApp.baseServices` just before the Lift bridge. `LiftRules.statelessDispatch.append(DirectLogin)` removed from `Boot.scala`. The `allow_direct_login` prop gate moved into `DirectLoginRoutes`. The `dlServe { case Req("my" :: "logins" :: "direct" :: Nil, …) }` block inside `directlogin.scala` is now dead code (no longer registered with `LiftRules`); the surrounding `DirectLogin` object stays — its `getUserFromDirectLoginHeaderFuture` etc. are still called from auth flows. Cleanup of the dead `dlServe` block + `extends RestHelper` is a separate small PR. Key migration gotcha (kept for the auth-stack workstream): `createTokenFuture(allParameters)` ignores its argument and re-reads from Lift's `S.request` via `getAllParameters` — use `validatorFutureWithParams(...)` + `createTokenCommonPart(...)` instead. | +| `GatewayLogin` | `code/api/GatewayLogin.scala` | Gateway JWT exchange | **No routes.** Library only — same shape as `OAuth2Login`. Consumed via `GatewayLogin.getUserFromGatewayLoginHeaderFuture` etc. from auth flows. `extends RestHelper` was vestigial and was removed (Formats implicit re-declared locally). | +| `DAuth` | `code/api/dauth.scala` | dAuth JWT exchange | **No routes.** Library only — same shape as `OAuth2Login`/`GatewayLogin`. `extends RestHelper` was vestigial and was removed (object-level `implicit val formats` added for the `.extract[...]` call sites). | +| `OAuth 1.0a` | — | OAuth 1.0a token endpoints | **Done — removed.** Commit `51820c75e` (2026-02-20, "refactor/(auth): Remove OAuth 1.0a support and consolidate authentication") deleted `oauth1.0.scala`, unregistered `OAuthHandshake` from `Boot.scala`'s `LiftRules.statelessDispatch`, removed OAuth header detection from `OBPRestHelper.scala`, and added `getConsumerFromDirectLoginToken` / `getUserFromDirectLoginToken` to take over the consumer/user-lookup responsibilities previously held by `OAuthHandshake`. **Dead-code follow-up cleanup also done**: `AuthHeaderParser.parseOAuthHeader`, the `oAuthParams` field on `ParsedAuthHeader` / `CallContext`, the `oAuthToken` field on `CallContextLight`, `extractOAuthParams` in `Http4sSupport`, `APIUtil.hasAnOAuthHeader`, and stale `OAuthHandshake` comments in `directlogin.scala` and `AuthUser.scala` all removed. `OpenAPI31JSONFactory`'s phantom OAuth2 `authorizationCode` flow (which pointed at the deleted `/oauth/authorize` and `/oauth/token` URLs) replaced with `type: http, scheme: bearer, bearerFormat: JWT` — accurate for OBP's actual OAuth2 model (Bearer-token validation against external IdPs; OBP does not issue its own OAuth2 tokens). Kept on purpose: `code/model/OAuth.scala` (backs the general `Consumer` entity used by all auth methods, not OAuth 1.0a-specific) and `APIUtil.OAuth` (misnamed but live test infrastructure — the `<@` signer adds `Authorization: DirectLogin token=...` headers and is imported by ~hundreds of test files; renaming is a separate cleanup). | +| `OAuth2` | `code/api/OAuth2.scala` (`OAuth2Login`) | **No routes.** Library only — Bearer-token validator (Google / Yahoo / Azure / Keycloak / OBPOIDC / Hydra) consumed by `APIUtil.getUserFuture` and `OBPRestHelper.OAuth2.getUser`. Both Lift and http4s endpoints already call it. The `extends RestHelper` mixin was vestigial and was removed (the only thing it provided was an implicit `Formats`, now declared locally at the one `extract[List[String]]` site). No remaining auth-stack work in this file. | +| `OpenIdConnect` | `code/api/openidconnect.scala` | OIDC callback — registered via `LiftRules.dispatch.append` | **Lift only — blocked on a portal-session decision.** 3 callback routes (`/auth/openid-connect/callback`, `…/callback-1`, `…/callback-2`) all funnel into `callbackUrlCommonCode`, whose success branch calls `AuthUser.logUserIn(user, () => S.redirectTo(...))`. `logUserIn` is inherited from `MetaMegaProtoUser` and writes the logged-in user into Lift `SessionVar`s that the portal reads; `S.redirectTo` sets Lift's session cookie. No tests cover the callback success path. Three forks: (a) **Drop portal-login** — pure http4s callback that issues a token but doesn't seed a portal session. Behaviour change for anyone using OIDC to sign into the portal UI; cheap if that user is nobody, surprising if it isn't. Needs a stakeholder check. (b) **Lift-session shim** — keep `lift-webkit` forever for this one callback. Cheapest in code, but "Lift Web removed" never actually ships. (c) **Replace portal session entirely** (e.g. Redis/JWT-backed). Months of work; also decouples session storage from Lift, which makes the lift-mapper conversation easier later. | -These four (DirectLogin/GatewayLogin/DAuth/OAuth) are the most-complex remaining dependencies on Lift `S.request` and they collectively block bridge removal. +DirectLogin's request-path is now off Lift. `OAuth2Login`, `GatewayLogin`, and `DAuth` turned out to be library-only (no routes); their vestigial `extends RestHelper` mixins were dropped. OAuth 1.0a was removed entirely in commit `51820c75e`. OpenIdConnect remains on Lift pending a portal-session decision — it is now the **only** auth handler still blocking bridge removal. ### Resource-docs workstream @@ -224,14 +224,20 @@ Already partly described in the next major section, but counted here for complet | Endpoint | File | Notes | |---|---|---| -| `aliveCheck` | `code/api/aliveCheck.scala` | One-line liveness probe. Trivial port. | -| `ImporterAPI` | `code/api/ImporterAPI.scala` (gated by props) | Sandbox data-import endpoint. Single Lift file. | +| `aliveCheck` | `code/api/aliveCheck.scala` → `code/api/AliveCheckRoutes.scala` | **Done.** Native http4s route serves `GET /alive`; `LiftRules.statelessDispatch.append(aliveCheck)` removed from `Boot.scala`. | +| `ImporterAPI` | `code/management/ImporterAPI.scala` → `code/management/ImporterAPIRoutes.scala` | **Done.** Native http4s route serves `POST /obp_transactions_saver/api/transactions`; secret read from URL query, body parsed from `req.bodyText`, `TransactionInserter` LiftActor still invoked synchronously (wrapped in `IO.blocking`). `ImporterTest` (8 scenarios) green. | | `OpenIdConnect` | (auth-stack table above) | OIDC callback, registered separately from OAuth2. | ### Open-banking standards (large, deferred indefinitely) Lift implementations of 3rd-party regulatory standards. All currently pass through `Http4sLiftWebBridge` and continue to work; they are *not* OBP API per se but optional regulatory shims. Migrating them is out of scope for the "remove Lift Web" milestone if you accept keeping the bridge for these stacks only. If total Lift removal is the goal, each needs its own workstream. +Three forks for how this workstream resolves: + +- **(a) Migrate each to http4s.** Weeks per standard × 7 standards. Highest cost; cleanest end state. +- **(b) "Regulatory mode" feature-flagged Lift.** Keep `Http4sLiftWebBridge` wired in only when an `obf-*` / standards prop is set; otherwise the bridge is unregistered at boot. Lets "Lift Web removed from the OBP API path" ship, but Lift Web stays in the codebase as an opt-in shim. Defeats the milestone technically; ships the headline. +- **(c) Extract as plugin projects.** Move each standard out of this repo into its own project that depends on OBP API. Probably right long-term — these are optional, externally-governed standards on different release cadences — but socially expensive and reshapes the build. + | Standard | Files / location | Status | |---|---|---| | Berlin Group v1.3 | `code/api/berlin/group/v1_3/*` — 7 files (AIS / PIS / PIIS / signing baskets / common) | Lift | @@ -277,14 +283,24 @@ Everything in lines 1–7 is request-path-related and will go in the bridge-remo 4. **`isStatisticallyTooPermissive` is sample-pool-dependent** — locally, a fresh test DB with a single user causes spurious rejections. Tests built against this check must seed enough users. 5. **Reserved ALL_CAPS placeholders** in middleware (`BANK_ID`, `ACCOUNT_ID`, `VIEW_ID`, `COUNTERPARTY_ID`) — when an endpoint needs a same-shape var without middleware lookup, rename to a non-reserved variant (e.g. `COUNTERPARTY_ID_PARAM`) in both the http4s and Lift ResourceDocs. +### Decision gates + +Two non-engineering decisions must land before the bridge-removal PR can be cut. They are stakeholder calls, not author calls — making either of them in code reviews tends to surface objections after the fact. + +1. **OIDC portal-session strategy** (auth-stack OpenIdConnect row, options a/b/c). Until one of the three forks is picked, the OIDC callback can't be migrated and the bridge can't be removed. The cheapest option (drop portal-login) is a behaviour change and needs explicit sign-off from anyone using OIDC as a portal-UI sign-in. **This is now the only auth-handler decision blocking bridge removal.** + +A second decision is *not* required for bridge removal, but is required for the public claim that follows it: + +2. **Open-banking standards strategy** (forks a/b/c above). If "Lift Web removed" is the headline, fork (b) is acceptable. If "Lift Web removed *from this repo*" is the headline, only (a) or (c) qualify. Cf. the "Lift Web removed vs. Lift removed" note under Done Criteria. + ### Suggested ordering for the remaining work 1. ~~**v4.0.0 bulk port**~~ — done (258/258, 100%). -2. **`aliveCheck`, `ImporterAPI`** — easiest wins, retire two `LiftRules.statelessDispatch` entries. -3. **`Http4sResourceDocs` centralised service** — single PR removes 6 dispatch entries + the `openapi.yaml` raw-serve block. -4. **Auth stack: OAuth2 / OpenIdConnect** — smaller and fewer call sites than the others. -5. **DirectLogin** — already half done in v6; needs to cover earlier versions and retire the `LiftRules.statelessDispatch.append(DirectLogin)` entry. -6. **GatewayLogin + DAuth + OAuth 1.0a** — biggest remaining auth work. +2. ~~**DirectLogin**~~ — done. `code.api.DirectLoginRoutes` serves the bare `/my/logins/direct`; per-version paths served by their own `Http4sXxx`. `LiftRules.statelessDispatch.append(DirectLogin)` retired. +3. ~~**`aliveCheck`, `ImporterAPI`**~~ — done. `code.api.AliveCheckRoutes` serves `GET /alive`; `code.management.ImporterAPIRoutes` serves `POST /obp_transactions_saver/api/transactions`. Both Lift dispatches retired. +4. ~~**`Http4sResourceDocs` centralised service**~~ — done. `code.api.util.http4s.Http4sResourceDocs` serves `/obp/*/resource-docs/{API_VERSION}/{obp,swagger,openapi,openapi.yaml}`, `/obp/*/banks/{BANK_ID}/resource-docs/{API_VERSION}/obp`, and `/obp/*/message-docs/{CONNECTOR}/swagger2.0`. 10 `LiftRules.statelessDispatch.append(ResourceDocs140..600)` retired + `openapi.yaml` raw `serve { ... }` block removed. ResourceDocsTest (63) + SwaggerDocsTest (10) green. +5. **Auth stack: OAuth2 / GatewayLogin / DAuth** — done. All three turned out to be library-only token validators (no `serve` blocks, no `LiftRules` registration). Vestigial `extends RestHelper` mixins removed. +6. **OpenIdConnect** — the only remaining auth-stack work. Blocked on a portal-session decision (its success path calls `AuthUser.logUserIn` / `S.redirectTo`, which mutate Lift `SessionVar`s — see auth-stack table). OAuth 1.0a was removed entirely in commit `51820c75e`; no migration needed. 7. **Bridge-removal PR** — delete `Http4sLiftWebBridge` + the request-path entries from `Boot.scala` (lines 1–7 above). 8. **Open-banking standards** — decide whether to migrate or keep a thin Lift remnant. Weeks of work if migrating. 9. **`lift-mapper`** — separate long-term effort, out of scope here. @@ -324,8 +340,27 @@ corsHandler | Lift Web removed | `lift-webkit` removed from `pom.xml`; `Boot.scala` reduced to DB init + scheduler startup. | | `lift-mapper` | Separate long-term effort — not in scope here. | +**"Lift Web removed" ≠ "Lift removed."** The two are distinct milestones and the difference matters for public claims: + +- *Lift Web removed* means the HTTP request path no longer touches Lift — `lift-webkit` is out of `pom.xml`, `Http4sLiftWebBridge` is deleted, `Boot.scala` request-path hooks are gone. `lift-mapper` is still present and still the ORM. +- *Lift removed* means `net.liftweb:*` is fully out of the dependency graph — requires the multi-month `lift-mapper` replacement (Doobie/Slick or similar). + +Decide which bar a release is hitting before announcing it; conflating them invites either an overstatement or an avoidable months-long delay before the announcement. + --- +## Risks + +Things that can derail the remaining workstreams. The facts behind each are documented in the relevant section above; collected here so the bridge-removal PR author doesn't have to rediscover them. + +| Risk | Detail | Mitigation | +|---|---|---| +| `FrozenClassTest` ratchet | Every deletion of a Lift `lazy val ... : OBPEndpoint` reduces the STABLE-API surface and trips `FrozenClassTest`. The v3.1.0 leftovers (`getMessageDocsSwagger`, `getObpConnectorLoopback`) are deferred to the bridge-removal PR specifically because of this; subsequent ports may surface more. | Plan the frozen-snapshot refresh as part of the bridge-removal PR, not as a follow-up. Document each removed `lazy val` in the PR description. | +| OIDC callback success path has no tests | Whichever of the three OIDC forks ships, there is no automated safety net. Manual integration test against a real OIDC provider is the only verification. | Before picking a fork, write at least one integration test against a test OIDC provider (Keycloak in a container is the established pattern in this repo). | +| `S.request` translation gotchas | DirectLogin's `createTokenFuture` ignored its parameters and re-read from `S.request` via `getAllParameters`; the http4s migration needed `validatorFutureWithParams` to thread parsed params through. If a future auth/handshake handler is migrated (e.g. OIDC's callback), expect the same shape — its handlers will reference `S.request` in ways the existing function signatures hide. | Audit the handler for `S.request`/`S.param`/`S.queryString` reads before designing the http4s entry point. Replicate the DirectLogin pattern. | +| Bridge-cascade hijack on partial migrations | Documented in CLAUDE.md. Surfaced once during v4 migration; can resurface anywhere a new version is wired into the chain before its overrides are migrated. | When adding a new `Http4sXxx` to `baseServices`, audit URL+verb overrides against older versions first. | +| `isStatisticallyTooPermissive` flakiness | Local test DB with too few users trips the ABAC permissiveness check. Not a regression, but easy to misdiagnose during the bridge-removal PR's full test run. | Seed enough test users in any test that exercises ABAC rules. Document in the suite, not as a runtime mitigation. | + ## Why http4s? - **Non-blocking I/O** — Uses a small fixed thread pool (CPU cores) and suspends fibres on I/O. Thousands of concurrent requests without thread-pool tuning. @@ -358,15 +393,17 @@ Binds to `hostname` / `dev.port` from your props file (defaults: `127.0.0.1:8080 | `APIMethods210` | done — `Http4s210.scala` (25 own endpoints; path-rewriting bridge to Http4s200) | | `APIMethods220` | done — `Http4s220.scala` (18 own endpoints; path-rewriting bridge to Http4s210) | | `APIMethods300` | done — `Http4s300.scala` (47 own endpoints; path-rewriting bridge to Http4s220; all 86 v3.0.0 tests pass) | -| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints + `updateCustomerAddress`; path-rewriting bridge to Http4s300; 2 endpoints intentionally left on Lift: `getMessageDocsSwagger`, `getObpConnectorLoopback`) | +| `APIMethods310` | done — `Http4s310.scala` (100 own endpoints + `updateCustomerAddress`; path-rewriting bridge to Http4s300; 2 endpoints still on Lift: `getMessageDocsSwagger` (shadowed by `Http4sResourceDocs.routes`, kept to satisfy `FrozenClassTest`) and `getObpConnectorLoopback`) | | `APIMethods400` | **done — 258 / 258 (100%)**. `Http4s400.scala` covers all 253 unique handlers + 8 ResourceDoc aliases for transaction-request-type variants (served by the shared wildcard handler). | | `APIMethods500` | done — `Http4s500.scala` (all 10 v5.0.0 originals on http4s) | | `APIMethods510` | done — `Http4s510.scala` (all 111 v5.1.0 originals on http4s; `createConsent` exposed as `createConsentImplicit` with a guard covering EMAIL/SMS/IMPLICIT SCA methods) | | `APIMethods600` | **done — 243 / 243 (100%)**. `Http4s600.scala` covers all 35 overrides + 208 originals. | -| Auth: DirectLogin | todo | -| Auth: GatewayLogin | todo | -| Auth: DAuth | todo | -| Auth: OAuth | todo | +| Auth: DirectLogin | done — `code.api.DirectLoginRoutes` serves the bare `/my/logins/direct` (gated on `allow_direct_login`); per-version paths served by their own `Http4sXxx`; `LiftRules.statelessDispatch.append(DirectLogin)` removed from `Boot.scala` | +| Auth: GatewayLogin | done — library-only (no `serve` block, no `LiftRules` registration). Vestigial `extends RestHelper` removed. | +| Auth: DAuth | done — library-only (no `serve` block, no `LiftRules` registration). Vestigial `extends RestHelper` removed. | +| Auth: OAuth2 | done — library-only Bearer-token validator. Vestigial `extends RestHelper` removed. | +| Auth: OAuth 1.0a | done — removed entirely in commit `51820c75e` (2026-02-20). `oauth1.0.scala` deleted, `OAuthHandshake` unregistered from `Boot.scala`, header detection removed from `OBPRestHelper.scala`. See "Per-version Lift leftovers → Auth stack" for surviving dead-code references that are cleanup candidates. | +| Auth: OpenIdConnect | blocked — callback success path calls `AuthUser.logUserIn` / `S.redirectTo` (Lift `SessionVar`s). Needs a portal-session decision before migration. | | Resource-docs: aggregation bug fix | done | | Resource-docs: `Http4sResourceDocs` service | todo | | Resource-docs: `openapi.yaml` route | todo | diff --git a/obp-api/src/main/resources/docs/brief_system_documentation.md b/obp-api/src/main/resources/docs/brief_system_documentation.md index 8f0d0d39c3..08a7315b9c 100644 --- a/obp-api/src/main/resources/docs/brief_system_documentation.md +++ b/obp-api/src/main/resources/docs/brief_system_documentation.md @@ -25,7 +25,7 @@ _System Architecture, Workflows, Security, and API Reference_ **Key integration points** -- **AuthN/AuthZ**: OAuth 1.0a (legacy), OAuth 2.0, OIDC, DirectLogin; role‑based entitlements; fine‑grained _Views_ for account/transaction level access; Consents for OB/PSD2 style access. +- **AuthN/AuthZ**: OAuth 2.0, OIDC, DirectLogin; role‑based entitlements; fine‑grained _Views_ for account/transaction level access; Consents for OB/PSD2 style access. - **Standards**: UK OB, Berlin Group, Bahrain OBF mapping via endpoints/consents; JWS signatures, mTLS where required. --- @@ -124,7 +124,7 @@ User ──(has roles/entitlements)──► Bank/System actions ## 6) Access Control & Security Mechanisms -- **Authentication**: OAuth 1.0a (legacy), OAuth 2.0, OIDC, DirectLogin (automation/dev only). +- **Authentication**: OAuth 2.0, OIDC, DirectLogin (automation/dev only). - **Authorisation**: Role‑based **Entitlements** (system/bank scope) + account‑level **Views**. - **Consents**: OB/PSD2 style consent objects with permissions/scopes, linked to tokens. - **Crypto**: JWS request/response signing where profiles demand; JWKS for key discovery. diff --git a/obp-api/src/main/resources/docs/introductory_system_documentation.md b/obp-api/src/main/resources/docs/introductory_system_documentation.md index c1e99af082..d20d8931a6 100644 --- a/obp-api/src/main/resources/docs/introductory_system_documentation.md +++ b/obp-api/src/main/resources/docs/introductory_system_documentation.md @@ -69,7 +69,7 @@ The Open Bank Project (OBP) is an open-source RESTful API platform for banks tha - **Transparency & Privacy**: Configurable data sharing with views, data blurring to preserve sensitive information - **Data Enrichment**: Add tags, comments, images, and metadata to transactions - **Multi-Bank Abstraction**: Universal API layer across different core banking systems - - **Flexible Authentication**: OAuth 1.0a, OAuth 2.0, OpenID Connect, Direct Login, Gateway Login + - **Flexible Authentication**: OAuth 2.0, OpenID Connect, Direct Login, Gateway Login - **Comprehensive Banking APIs**: 1000+ endpoints covering accounts, payments, customers, KYC, cards, products - **Real-Time & Batch Operations**: Support for both synchronous and asynchronous processing @@ -126,7 +126,7 @@ The Open Bank Project (OBP) is an open-source RESTful API platform for banks tha #### 1.2.7 Authentication & Authorization -- **Multiple Auth Methods**: OAuth 1.0a, OAuth 2.0, OpenID Connect (OIDC) with multiple concurrent providers, Direct Login, Gateway Login +- **Multiple Auth Methods**: OAuth 2.0, OpenID Connect (OIDC) with multiple concurrent providers, Direct Login, Gateway Login - **Consumer Management**: API consumer registration and key management - **Token Management**: Access token lifecycle management - **Consent Management**: PSD2-compliant consent workflows (AIS, PIS, PIIS) @@ -201,7 +201,7 @@ The Open Bank Project (OBP) is an open-source RESTful API platform for banks tha ### 1.2.15 Technical Capabilities - **Multi-Standard Support:** Open Bank Project, Berlin Group NextGenPSD2, UK Open Banking, Bahrain OBF, STET PSD2, Polish API, AU CDR, Mexico OF -- **Authentication Methods:** OAuth 1.0a, OAuth 2.0, OpenID Connect (OIDC) with multiple concurrent providers, Direct Login, Gateway Login +- **Authentication Methods:** OAuth 2.0, OpenID Connect (OIDC) with multiple concurrent providers, Direct Login, Gateway Login - **Extensibility:** Dynamic endpoints, dynamic entities, connector architecture, method routing - **Rate Limiting:** Built-in support with Redis or in-memory backends - **Multi-Database Support:** PostgreSQL, Oracle, MySQL, MS SQL Server, H2 @@ -410,7 +410,7 @@ The Open Bank Project (OBP) is an open-source RESTful API platform for banks tha - Multi-version API support (v1.2.1 - v6.0.0+) - Pluggable connector architecture (Mapped, Kafka, RabbitMQ, Akka, gRPC, REST) -- OAuth 1.0a/2.0/OIDC authentication +- OAuth 2.0/OIDC authentication - Role-based access control (RBAC) - Dynamic endpoint creation - Rate limiting and quotas @@ -2628,43 +2628,7 @@ After the upgrade completes successfully: OBP-API supports multiple authentication methods to accommodate different use cases and integration scenarios. -#### 7.1.1 OAuth 1.0a - -**Overview:** Traditional three-legged OAuth flow for third-party applications - -**Use Cases:** - -- Legacy integrations -- Apps requiring delegated access without OpenID Connect support - -**Flow:** - -1. Consumer obtains request token -2. User redirected to OBP for authorization -3. User approves access -4. Consumer exchanges request token for access token -5. Access token used for API calls - -**Implementation:** - -```bash -# Get request token -POST /oauth/initiate -Authorization: OAuth oauth_consumer_key="xxx", oauth_signature_method="HMAC-SHA256" - -# User authorization -GET /oauth/authorize?oauth_token=REQUEST_TOKEN - -# Get access token -POST /oauth/token -Authorization: OAuth oauth_token="REQUEST_TOKEN", oauth_verifier="VERIFIER" - -# API call with access token -GET /obp/v5.1.0/banks -Authorization: OAuth oauth_token="ACCESS_TOKEN", oauth_signature="..." -``` - -#### 7.1.2 OAuth 2.0 +#### 7.1.1 OAuth 2.0 **Overview:** Modern authorization framework supporting various grant types @@ -2707,7 +2671,7 @@ GET /obp/v5.1.0/users/current Authorization: Bearer ACCESS_TOKEN ``` -#### 7.1.3 OpenID Connect (OIDC) +#### 7.1.2 OpenID Connect (OIDC) **Overview:** Identity layer on top of OAuth 2.0 providing user authentication @@ -2756,7 +2720,7 @@ openid_connect_2.access_type_offline=true oauth2.jwk_set.url=http://keycloak:7070/realms/obp/protocol/openid-connect/certs ``` -#### 7.1.4 Direct Login +#### 7.1.3 Direct Login **Overview:** Simplified username/password authentication for trusted applications @@ -4578,9 +4542,6 @@ connector=mapped # mapped | kafka | akka | rest | grpc_vFeb2026 | star cache.redis.url=127.0.0.1 cache.redis.port=6379 -# OAuth 1.0a -allow_oauth1_login=true - # OAuth 2.0 allow_oauth2_login=true oauth2.jwk_set.url=http://localhost:9000/jwks @@ -5186,39 +5147,7 @@ For the latest updates visit Open Bank Project GitHub or contact TESOBE. ### 6.1 Authentication Methods -#### 6.1.1 OAuth 1.0a - -**Overview:** Legacy OAuth method, still supported for backward compatibility - -**Flow:** - -1. Request temporary credentials (request token) -2. Redirect user to authorization endpoint -3. User grants access -4. Exchange request token for access token -5. Use access token for API requests - -**Configuration:** - -```properties -# Enable OAuth 1.0a (enabled by default) -allow_oauth1=true -``` - -**Example Request:** - -```http -GET /obp/v4.0.0/users/current -Authorization: OAuth oauth_consumer_key="xxx", - oauth_token="xxx", - oauth_signature_method="HMAC-SHA1", - oauth_signature="xxx", - oauth_timestamp="1234567890", - oauth_nonce="xxx", - oauth_version="1.0" -``` - -#### 6.1.2 OAuth 2.0 / OpenID Connect +#### 6.1.1 OAuth 2.0 / OpenID Connect **Overview:** Modern OAuth2 with OIDC for authentication @@ -5292,7 +5221,7 @@ GET /obp/v4.0.0/users/current Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9... ``` -#### 6.1.3 Direct Login +#### 6.1.2 Direct Login **Overview:** Simplified authentication method for trusted applications diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index d80d6e9a97..f4ccee3a3f 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -610,9 +610,6 @@ webui_support_platform_url = https://chat.openbankproject.com # Link to Direct Login glossary on api explorer webui_direct_login_documentation_url = -# Link to OAuth 1.0a glossary on api explorer -webui_oauth_1_documentation_url = - # Link to OAuth 2.0 glossary on api explorer webui_oauth_2_documentation_url = diff --git a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala index c580ae3c29..bf2f7f50f3 100644 --- a/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala +++ b/obp-api/src/main/scala/bootstrap/liftweb/Boot.scala @@ -95,7 +95,6 @@ import code.kycdocuments.MappedKycDocument import code.kycmedias.MappedKycMedia import code.kycstatuses.MappedKycStatus import code.loginattempts.{LoginAttempt, MappedBadLoginAttempt} -import code.management.ImporterAPI import code.meetings.{MappedMeeting, MappedMeetingInvitee} import code.metadata.comments.MappedComment import code.metadata.counterparties.{MappedCounterparty, MappedCounterpartyBespoke, MappedCounterpartyMetadata, MappedCounterpartyWhereTag} @@ -499,19 +498,13 @@ class Boot extends MdcLoggable { LiftRules.dispatch.append(OpenIdConnect) } } - def enableAPIs: LiftRules#RulesSeq[DispatchPF] = { - - // JWT auth endpoints - if (APIUtil.getPropsAsBoolValue("allow_direct_login", true)) { - LiftRules.statelessDispatch.append(DirectLogin) - } - - // TODO Wrap these with enableVersionIfAllowed as well - //add management apis - LiftRules.statelessDispatch.append(ImporterAPI) - } - - enableAPIs + // DirectLogin (POST /my/logins/direct), ImporterAPI (POST + // /obp_transactions_saver/api/transactions), and aliveCheck (GET /alive) + // are now served by their native http4s counterparts wired into + // Http4sApp.baseServices (DirectLoginRoutes / ImporterAPIRoutes / + // AliveCheckRoutes). The Lift dispatches were retired in the http4s + // migration; any prop gates (e.g. `allow_direct_login`) live with those + // routes. @@ -522,16 +515,11 @@ class Boot extends MdcLoggable { // Resource Docs are used in the process of surfacing endpoints so we enable them explicitly // to avoid a circular dependency. // Make the (currently identical) endpoints available to different versions. - LiftRules.statelessDispatch.append(ResourceDocs140) - LiftRules.statelessDispatch.append(ResourceDocs200) - LiftRules.statelessDispatch.append(ResourceDocs210) - LiftRules.statelessDispatch.append(ResourceDocs220) - LiftRules.statelessDispatch.append(ResourceDocs300) - LiftRules.statelessDispatch.append(ResourceDocs310) - LiftRules.statelessDispatch.append(ResourceDocs400) - LiftRules.statelessDispatch.append(ResourceDocs500) - LiftRules.statelessDispatch.append(ResourceDocs510) - LiftRules.statelessDispatch.append(ResourceDocs600) + // ResourceDocs140..600 are now served by code.api.util.http4s.Http4sResourceDocs + // (wired into Http4sApp.baseServices). The Lift dispatches were retired in the + // http4s migration. The 10 ResourceDocs* objects remain in the codebase as a + // source of `ImplementationsResourceDocs.getResourceDocsList` (used by the + // centralised service) but no longer participate in request dispatch. //////////////////////////////////////////////////// @@ -1082,7 +1070,7 @@ class Boot extends MdcLoggable { } } - LiftRules.statelessDispatch.append(aliveCheck) + // aliveCheck (GET /alive) is served by code.api.AliveCheckRoutes (wired into Http4sApp.baseServices). } diff --git a/obp-api/src/main/scala/code/api/AliveCheckRoutes.scala b/obp-api/src/main/scala/code/api/AliveCheckRoutes.scala new file mode 100644 index 0000000000..4cc8ec4736 --- /dev/null +++ b/obp-api/src/main/scala/code/api/AliveCheckRoutes.scala @@ -0,0 +1,24 @@ +package code.api + +import cats.effect.IO +import org.http4s.{Charset, HttpRoutes, MediaType} +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` + +/** + * Native http4s route for the `GET /alive` liveness probe. + * + * Mirrors `code.api.aliveCheck`'s Lift serve block. Replaces + * `LiftRules.statelessDispatch.append(aliveCheck)` in `Boot.scala`. + * + * Note: `StatusPage` also exposes `/health` and `/status`. `/alive` is kept + * as a separate route because external monitoring/load-balancers may already + * be wired to it. + */ +object AliveCheckRoutes { + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case GET -> Root / "alive" => + Ok("true").map(_.withContentType(`Content-Type`(MediaType.application.json, Charset.`UTF-8`))) + } +} diff --git a/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala b/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala new file mode 100644 index 0000000000..c5c88a62f1 --- /dev/null +++ b/obp-api/src/main/scala/code/api/DirectLoginRoutes.scala @@ -0,0 +1,78 @@ +package code.api + +import cats.effect.IO +import code.api.util.{CallContext, CustomJsonFormats} +import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes} +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.v6_0_0.JSONFactory600 +import com.openbankproject.commons.ExecutionContext.Implicits.global +import net.liftweb.common.Empty +import net.liftweb.json.Formats +import org.http4s._ +import org.http4s.dsl.io._ + +import scala.concurrent.Future + +/** + * Native http4s route for the non-versioned DirectLogin endpoint. + * + * Handles `POST /my/logins/direct` (no `/obp/vX.Y.Z` prefix). Version-prefixed + * paths are served by per-version files (e.g. `Http4s600.directLoginEndpoint`). + * + * Adding this route lets us retire `LiftRules.statelessDispatch.append(DirectLogin)` + * in `Boot.scala` — it was the last consumer of the bare path on the Lift bridge. + */ +object DirectLoginRoutes { + + private implicit val formats: Formats = CustomJsonFormats.formats + + /** + * DirectLogin header parser — mirrors `DirectLogin.getAllParameters` but reads + * from `CallContext.requestHeaders` (populated by `Http4sCallContextBuilder.fromRequest`) + * instead of Lift's thread-local `S.request`. + */ + private def parseDirectLoginParams(cc: CallContext): Map[String, String] = { + def find(name: String): Option[String] = cc.requestHeaders + .find(_.name.equalsIgnoreCase(name)) + .flatMap(_.values.headOption) + val directLoginHeader = find("DirectLogin") + val authHeader = find("Authorization") + val raw = directLoginHeader + .orElse(authHeader.filter(h => h.startsWith("DirectLogin") || h.contains("DirectLogin"))) + .getOrElse("") + val cleaned = raw.stripPrefix("DirectLogin").split(",").map(_.trim).toList + val keys = Set("consumer_key", "token", "username", "password") + cleaned.flatMap { entry => + if (entry.contains("=")) { + val s = entry.split("=", 2) + val v = s(1).replaceAll("^\"|\"$", "") + if (keys.contains(s(0)) && v.nonEmpty) Some(s(0) -> v) else None + } else None + }.toMap + } + + private val directLoginAllowed: Boolean = + code.api.util.APIUtil.getPropsAsBoolValue("allow_direct_login", true) + + val routes: HttpRoutes[IO] = if (!directLoginAllowed) HttpRoutes.empty[IO] else HttpRoutes.of[IO] { + case req @ POST -> Root / "my" / "logins" / "direct" => + Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc => + val reqWithCC = req.withAttribute(Http4sRequestAttributes.callContextKey, cc) + EndpointHelpers.executeFutureCreated(reqWithCC) { + val parsed = parseDirectLoginParams(cc) + val params = + if (parsed.isEmpty) Map("error" -> code.api.util.ErrorMessages.MissingDirectLoginHeader) + else parsed + for { + triple <- DirectLogin.validatorFutureWithParams("authorizationToken", "POST", params) + (httpCode, message, dlParams) = triple + tokenTriple = DirectLogin.createTokenCommonPart(httpCode, message, dlParams) + _ <- Future(DirectLogin.grantEntitlementsToUseDynamicEndpointsInSpacesInDirectLogin(tokenTriple._3)) + } yield { + if (tokenTriple._1 == 200) JSONFactory600.createTokenJSON(tokenTriple._2) + else code.api.util.APIUtil.unboxFullOrFail(Empty, None, tokenTriple._2, tokenTriple._1) + } + } + } + } +} diff --git a/obp-api/src/main/scala/code/api/GatewayLogin.scala b/obp-api/src/main/scala/code/api/GatewayLogin.scala index e6f5b86032..63bc6472fe 100755 --- a/obp-api/src/main/scala/code/api/GatewayLogin.scala +++ b/obp-api/src/main/scala/code/api/GatewayLogin.scala @@ -39,7 +39,6 @@ import com.nimbusds.jwt.JWTClaimsSet import com.openbankproject.commons.model.{InboundAccount, User} import net.liftweb.common._ import net.liftweb.http._ -import net.liftweb.http.rest.RestHelper import net.liftweb.json._ import net.liftweb.util.Helpers @@ -67,7 +66,12 @@ object JSONFactoryGateway { } -object GatewayLogin extends RestHelper with MdcLoggable { +// GatewayLogin is a gateway-JWT exchange library consumed by the auth path. It has no +// HTTP routes of its own; the legacy `extends RestHelper` mixin was vestigial. +object GatewayLogin extends MdcLoggable { + + // For lift-json `.extract[PayloadOfJwtJSON]` calls below. + private implicit val formats: Formats = DefaultFormats val gateway = "Gateway" // This value is used for ResourceUser.provider and Consumer.description diff --git a/obp-api/src/main/scala/code/api/OAuth2.scala b/obp-api/src/main/scala/code/api/OAuth2.scala index f4af9ed949..637e7e9f77 100644 --- a/obp-api/src/main/scala/code/api/OAuth2.scala +++ b/obp-api/src/main/scala/code/api/OAuth2.scala @@ -43,7 +43,6 @@ import com.openbankproject.commons.ExecutionContext.Implicits.global import com.openbankproject.commons.model.User import net.liftweb.common.Box.tryo import net.liftweb.common._ -import net.liftweb.http.rest.RestHelper import net.liftweb.util.Helpers import org.apache.commons.lang3.StringUtils import sh.ory.hydra.model.OAuth2TokenIntrospection @@ -57,7 +56,10 @@ import scala.collection.JavaConverters._ * so they could authenticate their users. */ -object OAuth2Login extends RestHelper with MdcLoggable { +// OAuth2Login is a Bearer-token validator (Google / Yahoo / Azure / Keycloak / OBPOIDC / +// Hydra) consumed by `APIUtil.getUserFuture` and `OBPRestHelper.OAuth2.getUser`. It has no +// HTTP routes of its own — the legacy `extends RestHelper` mixin was vestigial. +object OAuth2Login extends MdcLoggable { private def getValueOfOAuh2HeaderField(sc: CallContext) = { val valueOfAuthReqHeaderField = sc.authReqHeaderField.getOrElse("") @@ -692,6 +694,7 @@ object OAuth2Login extends RestHelper with MdcLoggable { if(sourceOfTruth) { logger.debug("Extracting roles from Access Token") import net.liftweb.json._ + implicit val formats: Formats = DefaultFormats val jsonString = JwtUtil.getSignedPayloadAsJson(token) val json = parse(jsonString.getOrElse("")) val openBankRoles: List[String] = diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index d271a52c55..9462198c08 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -512,16 +512,15 @@ object OpenAPI31JSONFactory extends MdcLoggable { name = Some("Authorization"), in = Some("header") ), + // OBP API consumes Bearer tokens issued by external IdPs (Google, Yahoo, + // Azure, Keycloak, Hydra) — it does not issue its own OAuth2 tokens. The + // accurate OpenAPI representation is `type: http, scheme: bearer`, not an + // `oauth2` flow with token-issuance URLs. "OAuth2" -> SecuritySchemeJson( - `type` = "oauth2", + `type` = "http", description = Some(oAuth2Description(hostname)), - flows = Some(OAuthFlowsJson( - authorizationCode = Some(OAuthFlowJson( - authorizationUrl = Some(s"${hostname}/oauth/authorize"), - tokenUrl = Some(s"${hostname}/oauth/token"), - scopes = Map.empty - )) - )) + scheme = Some("bearer"), + bearerFormat = Some("JWT") ) ) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala index 231cb437e3..031807bcae 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocs140.scala @@ -132,90 +132,8 @@ object ResourceDocs300 extends OBPRestHelper with ResourceDocsAPIMethods with Md // ImplementationsResourceDocs.getStaticResourceDocsObp ) registerRoutes(routes, ImplementationsResourceDocs.localResourceDocs, apiPrefix) - - // Register YAML endpoint using standard RestHelper approach - serve { - case Req("obp" :: versionStr :: "resource-docs" :: requestedApiVersionString :: "openapi.yaml" :: Nil, _, GetRequest) if versionStr == version.toString => - val (resourceDocTags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() - - // Validate parameters - if (S.param("tags").exists(_.trim.isEmpty)) { - PlainTextResponse("Invalid tags parameter - empty values not allowed", 400) - } else if (S.param("functions").exists(_.trim.isEmpty)) { - PlainTextResponse("Invalid functions parameter - empty values not allowed", 400) - } else if (S.param("api-collection-id").exists(_.trim.isEmpty)) { - PlainTextResponse("Invalid api-collection-id parameter - empty values not allowed", 400) - } else if (S.param("content").isDefined && contentParam.isEmpty) { - PlainTextResponse("Invalid content parameter. Valid values: static, dynamic, all", 400) - } else { - try { - val requestedApiVersion = ApiVersionUtils.valueOf(requestedApiVersionString) - if (!versionIsAllowed(requestedApiVersion)) { - PlainTextResponse(s"API Version not supported: $requestedApiVersionString", 400) - } else if (locale.isDefined && APIUtil.obpLocaleValidation(locale.get) != SILENCE_IS_GOLDEN) { - PlainTextResponse(s"Invalid locale: ${locale.get}", 400) - } else { - val isVersion4OrHigher = true - val cacheKey = APIUtil.createResourceDocCacheKey( - Some("openapi31yaml"), - requestedApiVersionString, - resourceDocTags, - partialFunctions, - locale, - contentParam, - apiCollectionIdParam, - Some(isVersion4OrHigher) - ) - val cacheValueFromRedis = Caching.getStaticSwaggerDocCache(cacheKey) - - val yamlString = if (cacheValueFromRedis.isDefined) { - cacheValueFromRedis.get - } else { - // Generate OpenAPI JSON and convert to YAML - val openApiJValue = try { - val resourceDocsJsonFiltered = locale match { - case _ if (apiCollectionIdParam.isDefined) => - val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(apiCollectionIdParam.getOrElse("")).map(_.operationId).map(getObpFormatOperationId) - val resourceDocs = ResourceDoc.getResourceDocs(operationIds) - val resourceDocsJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, locale, includeTechnology = includeTechnologyInResponse) - resourceDocsJson.resource_docs - case _ => - contentParam match { - case Some(DYNAMIC) => - ImplementationsResourceDocs.getResourceDocsObpDynamicCached(resourceDocTags, partialFunctions, locale, None, isVersion4OrHigher).head.resource_docs - case Some(STATIC) => { - ImplementationsResourceDocs.getStaticResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, isVersion4OrHigher).head.resource_docs - } - case _ => { - ImplementationsResourceDocs.getAllResourceDocsObpCached(requestedApiVersionString, resourceDocTags, partialFunctions, locale, contentParam, isVersion4OrHigher).head.resource_docs - } - } - } - - val hostname = HostName - val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJsonFiltered, requestedApiVersionString, hostname) - code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.OpenAPI31JsonFormats.toJValue(openApiDoc) - } catch { - case e: Exception => - logger.error(s"Error generating OpenAPI JSON: ${e.getMessage}", e) - throw e - } - - val yamlResult = YAMLUtils.jValueToYAMLSafe(openApiJValue, s"# Error converting OpenAPI to YAML: ${openApiJValue.toString}") - Caching.setStaticSwaggerDocCache(cacheKey, yamlResult) - yamlResult - } - - val headers = List("Content-Type" -> YAMLUtils.getYAMLContentType, (ResponseHeader.`Correlation-Id` -> getCorrelationId())) - val bytes = yamlString.getBytes("UTF-8") - InMemoryResponse(bytes, headers, Nil, 200) - } - } catch { - case _: Exception => - PlainTextResponse(s"Invalid API version: $requestedApiVersionString", 400) - } - } - } + // openapi.yaml raw `serve { ... }` block was migrated to + // `code.api.util.http4s.Http4sResourceDocs.handleGetResourceDocsOpenAPI31Yaml`. } } diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala index 4925b614dc..abb60e052f 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/ResourceDocsAPIMethods.scala @@ -272,13 +272,15 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth logger.debug(s"Generating getAllResourceDocsObpCached-Docs requestedApiVersion is $requestedApiVersionString") val requestedApiVersion = ApiVersionUtils.valueOf(requestedApiVersionString) + // Always recompute specifiedUrl per-request. specifiedUrl is a var on a shared + // ResourceDoc instance; if we kept the existing value (the prior `case Some(_) => it` + // shortcut), a request for /obp/v7.0.0/resource-docs would inherit a stale + // /obp/dynamic-endpoint/... value set by an earlier request and return the wrong URL. val dynamicDocs = allDynamicResourceDocs - .map(it => it.specifiedUrl match { - case Some(_) => it - case _ => - it.specifiedUrl = if (it.partialFunctionName.startsWith("dynamicEntity")) Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-entity`}${it.requestUrl}") else Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-endpoint`}${it.requestUrl}") - it - }) + .map { it => + it.specifiedUrl = Some(s"/${it.implementedInApiVersion.urlPrefix}/${requestedApiVersion.vDottedApiVersion}${it.requestUrl}") + it + } val filteredDocs = resourceDocTags match { // We have tags @@ -311,14 +313,15 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth bankId: Option[String], isVersion4OrHigher: Boolean ) = { + // Always reset specifiedUrl to the dynamic-* prefix for the dynamic-content endpoint + // (don't keep a stale value left over from a different aggregated request that may have + // overwritten this var on the shared ResourceDoc). val dynamicDocs = allDynamicResourceDocs .filter(rd => if (bankId.isDefined) rd.createdByBankId == bankId else true) - .map(it => it.specifiedUrl match { - case Some(_) => it - case _ => - it.specifiedUrl = if (it.partialFunctionName.startsWith("dynamicEntity")) Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-entity`}${it.requestUrl}") else Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-endpoint`}${it.requestUrl}") - it - }) + .map { it => + it.specifiedUrl = if (it.partialFunctionName.startsWith("dynamicEntity")) Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-entity`}${it.requestUrl}") else Some(s"/${it.implementedInApiVersion.urlPrefix}/${ApiVersion.`dynamic-endpoint`}${it.requestUrl}") + it + } .toList val filteredDocs = resourceDocTags match { @@ -441,21 +444,10 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth } } - localResourceDocs += ResourceDoc( - getResourceDocsObpV400, - implementedInApiVersion, - nameOf(getResourceDocsObpV400), - "GET", - "/resource-docs/API_VERSION/obp", - "Get Resource Docs", - getResourceDocsDescription(false), - EmptyBody, - EmptyBody, - UnknownError :: Nil, - List(apiTagDocumentation, apiTagApi), - Some(List(canReadResourceDoc)) - ) - + // Note: getResourceDocsObpV400 intentionally has NO ResourceDoc registration. + // It shares the URL "/resource-docs/API_VERSION/obp" + GET with getResourceDocsObp + // (registered above). One ResourceDoc entry per (URL, verb) is enough; registering + // both produced a duplicate that broke the v7 aggregation dedup. lazy val getResourceDocsObpV400 : OBPEndpoint = { case "resource-docs" :: requestedApiVersionString :: "obp" :: Nil JsonGet _ => { val (tags, partialFunctions, locale, contentParam, apiCollectionIdParam) = ResourceDocsAPIMethodsUtil.getParams() @@ -977,7 +969,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth yamlString } - private def convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = { + def convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = { logger.debug(s"Generating OpenAPI 3.1-convertResourceDocsToOpenAPI31JvalueAndSetCache requestedApiVersion is $requestedApiVersionString") val hostname = HostName val openApiDoc = code.api.ResourceDocs1_4_0.OpenAPI31JSONFactory.createOpenAPI31Json(resourceDocsJson, requestedApiVersionString, hostname) @@ -989,7 +981,7 @@ trait ResourceDocsAPIMethods extends MdcLoggable with APIMethods220 with APIMeth openApiJValue } - private def convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = { + def convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey: String, requestedApiVersionString: String, resourceDocsJson: List[JSONFactory1_4_0.ResourceDocJson]) : JValue = { logger.debug(s"Generating Swagger-getResourceDocsSwaggerAndSetCache requestedApiVersion is $requestedApiVersionString") val swaggerDocJsonJValue = getResourceDocsSwagger(requestedApiVersionString, resourceDocsJson).head diff --git a/obp-api/src/main/scala/code/api/dauth.scala b/obp-api/src/main/scala/code/api/dauth.scala index 481f5feab2..70437e6cbf 100755 --- a/obp-api/src/main/scala/code/api/dauth.scala +++ b/obp-api/src/main/scala/code/api/dauth.scala @@ -36,7 +36,6 @@ import com.nimbusds.jwt.JWTClaimsSet import com.openbankproject.commons.model.User import net.liftweb.common._ import net.liftweb.http._ -import net.liftweb.http.rest.RestHelper import net.liftweb.json._ import com.openbankproject.commons.ExecutionContext.Implicits.global import net.liftweb.http.provider.HTTPParam @@ -63,7 +62,12 @@ object JSONFactoryDAuth { } -object DAuth extends RestHelper with MdcLoggable { +// DAuth is a dAuth-JWT exchange library consumed by the auth path. It has no HTTP routes +// of its own; the legacy `extends RestHelper` mixin was vestigial. +object DAuth extends MdcLoggable { + + // For lift-json `.extract[...]` calls below. + private implicit val formats: Formats = DefaultFormats def createJwt(payloadAsJsonString: String) : String = { diff --git a/obp-api/src/main/scala/code/api/directlogin.scala b/obp-api/src/main/scala/code/api/directlogin.scala index a165e68a7c..fa2f8ca31a 100644 --- a/obp-api/src/main/scala/code/api/directlogin.scala +++ b/obp-api/src/main/scala/code/api/directlogin.scala @@ -653,7 +653,6 @@ object DirectLogin extends RestHelper with MdcLoggable { /** * DirectLogin-specific method to get consumer from token - * This replaces the dependency on OAuthHandshake.getConsumerFromTokenFuture * @param token DirectLogin token key * @return Future[Box[Consumer]] */ @@ -670,7 +669,6 @@ object DirectLogin extends RestHelper with MdcLoggable { /** * DirectLogin-specific method to get user from token - * This replaces the dependency on OAuthHandshake.getUserFromTokenFuture * @param token DirectLogin token key * @return Future[Box[User]] */ diff --git a/obp-api/src/main/scala/code/api/util/APIUtil.scala b/obp-api/src/main/scala/code/api/util/APIUtil.scala index ae472bbc74..5e5e3e1838 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -211,8 +211,6 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ def hasAuthorizationHeader(requestHeaders: List[HTTPParam]): Boolean = requestHeaders.find(_.name == "Authorization").isDefined - def hasAnOAuthHeader(authorization: Box[String]): Boolean = hasHeader("OAuth", authorization) - /* The OAuth 2.0 Authorization Framework: Bearer Token For example, the "bearer" token type defined in [RFC6750] is utilized diff --git a/obp-api/src/main/scala/code/api/util/ApiSession.scala b/obp-api/src/main/scala/code/api/util/ApiSession.scala index 72b2e122ed..4de0ce75b7 100644 --- a/obp-api/src/main/scala/code/api/util/ApiSession.scala +++ b/obp-api/src/main/scala/code/api/util/ApiSession.scala @@ -45,7 +45,6 @@ case class CallContext( operationId: Option[String] = None, // Dynamic Endpoint Unique Identifier. Important for Rate Limiting. authReqHeaderField: Box[String] = Empty, directLoginParams: Map[String, String] = Map(), - oAuthParams: Map[String, String] = Map(), httpCode: Option[Int] = None, httpBody: Option[String] = None, requestHeaders: List[HTTPParam] = Nil, @@ -141,7 +140,6 @@ case class CallContext( requestHeaders = this.requestHeaders, partialFunctionName = this.resourceDocument.map(_.partialFunctionName).getOrElse(""), directLoginToken = this.directLoginParams.get("token").getOrElse(""), - oAuthToken = this.oAuthParams.get("oauth_token").getOrElse(""), xRateLimitLimit = this.xRateLimitLimit, xRateLimitRemaining = this.xRateLimitRemaining, xRateLimitReset = this.xRateLimitReset, @@ -208,7 +206,6 @@ case class CallContextLight(gatewayLoginRequestPayload: Option[PayloadOfJwtJSON] requestHeaders: List[HTTPParam] = Nil, partialFunctionName: String = "", directLoginToken: String = "", - oAuthToken: String = "", xRateLimitLimit : Long = -1, xRateLimitRemaining : Long = -1, xRateLimitReset : Long = -1, diff --git a/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala b/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala index 989b6eb6d2..f0f89d10d4 100644 --- a/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala +++ b/obp-api/src/main/scala/code/api/util/AuthHeaderParser.scala @@ -7,12 +7,12 @@ import net.liftweb.common.{Box, Empty, Full} * into the subset of CallContext fields used by the authentication chain. * * The auth chain in [[APIUtil.getUserAndSessionContextFuture]] identifies which - * scheme to use (OAuth 2, OAuth 1.0a, DirectLogin, Gateway Login, DAuth) by - * reading CallContext.authReqHeaderField / directLoginParams / oAuthParams — - * *not* requestHeaders. Every transport that supports authentication (REST - * via http4s, gRPC, etc.) must populate these three fields identically, - * otherwise schemes will silently fail to match and the chain will fall through - * to "OBP-20080 Authorization Header format is not supported". + * scheme to use (OAuth 2 / OIDC, DirectLogin, Gateway Login, DAuth) by reading + * CallContext.authReqHeaderField / directLoginParams — *not* requestHeaders. + * Every transport that supports authentication (REST via http4s, gRPC, etc.) + * must populate these fields identically, otherwise schemes will silently fail + * to match and the chain will fall through to "OBP-20080 Authorization Header + * format is not supported". * * This helper is the single source of truth for that parsing so that all * transports stay in sync. @@ -22,19 +22,18 @@ object AuthHeaderParser { /** Result of parsing an Authorization header value. */ final case class ParsedAuthHeader( authReqHeaderField: Box[String], - directLoginParams: Map[String, String], - oAuthParams: Map[String, String] + directLoginParams: Map[String, String] ) private val EmptyParsed: ParsedAuthHeader = - ParsedAuthHeader(Empty, Map.empty, Map.empty) + ParsedAuthHeader(Empty, Map.empty) private val DirectLoginAllowedParameters: List[String] = List("consumer_key", "token", "username", "password") /** - * Parse an Authorization header value (e.g. "Bearer eyJ...", "DirectLogin token=...", - * 'OAuth oauth_consumer_key="..."') into the auth-related CallContext fields. + * Parse an Authorization header value (e.g. "Bearer eyJ...", "DirectLogin token=...") + * into the auth-related CallContext fields. * * Returns empty fields when no header value is present. */ @@ -44,8 +43,7 @@ object AuthHeaderParser { case Some(value) => ParsedAuthHeader( authReqHeaderField = Full(value), - directLoginParams = if (value.contains("DirectLogin")) parseDirectLoginHeader(value) else Map.empty, - oAuthParams = if (value.startsWith("OAuth ")) parseOAuthHeader(value) else Map.empty + directLoginParams = if (value.contains("DirectLogin")) parseDirectLoginHeader(value) else Map.empty ) } @@ -74,14 +72,4 @@ object AuthHeaderParser { } }.toMap } - - /** - * Parse an OAuth 1.0a Authorization header value into its named parameters. - * Format: `OAuth oauth_consumer_key="xxx", oauth_token="yyy", ...` - */ - def parseOAuthHeader(headerValue: String): Map[String, String] = { - val oauthPart = headerValue.stripPrefix("OAuth ").trim - val pattern = """(\w+)="([^"]*)"""".r - pattern.findAllMatchIn(oauthPart).map(m => m.group(1) -> m.group(2)).toMap - } } 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 2c6cd62154..fb4136c33b 100644 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala +++ b/obp-api/src/main/scala/code/api/util/Glossary.scala @@ -979,7 +979,7 @@ object Glossary extends MdcLoggable { description = s""" |The client identifier issued to the client during the registration process. It is a unique string representing the registration information provided by the client. - |At the time the consumer_key was introduced OAuth 1.0a was only available. The OAuth 2.0 counterpart for this value is client_id + |The name `consumer_key` is historical (it originated in OAuth 1.0a, which is no longer supported by OBP). The OAuth 2.0 counterpart for this value is `client_id`, and the two are used interchangeably. |""".stripMargin) glossaryItems += GlossaryItem( @@ -1371,9 +1371,9 @@ object Glossary extends MdcLoggable { | |There are several methods of obtaining and using a token which vary in their ease of use and security. | -|Direct Login and OAuth 1.0a are used for testing purposes / local installations and are built into OBP. +|Direct Login is built into OBP and used for testing purposes / local installations. | -|OAuth2 / Open ID Connect (OIDC) depend on the configuration of Identity Provider solutions such as Keycloak or Hydra or external services such as Google or Yahoo. +|OAuth2 / Open ID Connect (OIDC) is the recommended method for production use, and depends on the configuration of Identity Provider solutions such as Keycloak or Hydra or external services such as Google or Yahoo. | |Open Bank Project can support multiple identity providers per OBP instance. For example, for a single OBP installation, some Users could authenticate against Google and some could authenticate against a local identity provider. |In the cases where multiple identity providers are configured, OBP differentiates between Users by not only their Username but also by their "Identity Provider". i.e. J.Brown logged in via Google is distinct from J.Brown who logged in via a local OBP instance. diff --git a/obp-api/src/main/scala/code/api/util/Glossary.scala.bak b/obp-api/src/main/scala/code/api/util/Glossary.scala.bak deleted file mode 100644 index 7a691e90c1..0000000000 --- a/obp-api/src/main/scala/code/api/util/Glossary.scala.bak +++ /dev/null @@ -1,5491 +0,0 @@ -package code.api.util - -import code.api.Constant -import code.api.Constant._ -import code.api.util.APIUtil.{getObpApiRoot, getServerUrl} -import code.api.util.ExampleValue.{accountIdExample, bankIdExample, customerIdExample, userIdExample} -import code.util.Helper.MdcLoggable -import code.webuiprops.MappedWebUiPropsProvider.getWebUiPropsValue - -import java.io.File -import scala.collection.mutable.ArrayBuffer - - -object Glossary extends MdcLoggable { - - def getGlossaryItem(title: String): String = { - - //logger.debug(s"getGlossaryItem says Hello. title to find is: $title") - - val something = glossaryItems.find(_.title.toLowerCase == title.toLowerCase) match { - case Some(foundItem) => - /** - * Two important rules: - * 1. Make sure you have an **empty line** after the closing `` tag, otherwise the markdown/code blocks won't show correctly. - * 2. Make sure you have an **empty line** after the closing `` tag if you have multiple collapsible sections. - */ - s""" - |
- | ${foundItem.title} - | - | ${foundItem.htmlDescription} - |
- | - |

- |""".stripMargin - case None => "glossary-item-not-found" - } - //logger.debug(s"getGlossaryItem says the text to return is $something") - something - } - - def getGlossaryItemSimple(title: String): String = { - // This function just returns a string without Title and collapsable element. - // Can use this if getGlossaryItem is problematic with a certain glossary item (e.g. JSON Schema Validation Glossary Item) or just want a simple inclusion of text. - - //logger.debug(s"getGlossaryItemSimple says Hello. title to find is: $title") - - val something = glossaryItems.find(_.title.toLowerCase == title.toLowerCase) match { - case Some(foundItem) => - s""" - | ${foundItem.htmlDescription} - |""".stripMargin - case None => "glossary-item-simple-not-found" - } - //logger.debug(s"getGlossaryItemSimple says the text to return is $something") - something - } - - def getGlossaryItemLink(title: String): String = { - // This function just returns a link to the Glossary Item in question. - // Can reduce bandwith and maybe make things semantically clearer if we use links instead of includes. - - val something = glossaryItems.find(_.title.toLowerCase == title.toLowerCase) match { - case Some(foundItem) => - // We use the title because anchors are case sensitive, but we find it so we can log / display not found. - s"""[here](/glossary#${title})""" - case None => "glossary-item-link-not-found" - } - something - } - - - // reason of description is function: because we want make description is dynamic, so description can read - // webui_ props dynamic instead of a constant string. - case class GlossaryItem( - title: String, - description: () => String, - htmlDescription: String, - textDescription: String - ) - - def makeGlossaryItem (title: String, connectorField: ConnectorField) : GlossaryItem = { - GlossaryItem( - title = title, - description = - s""" - |Example value: ${connectorField.value} - | - |Description: ${connectorField.description} - | - """.stripMargin - ) - } - - object GlossaryItem { - - // Constructs a GlossaryItem from just two parameters. - def apply(title: String, description: => String): GlossaryItem = { - - // Convert markdown to HTML - val htmlDescription = PegdownOptions.convertPegdownToHtmlTweaked(description) - - // Try and generate a plain text string (requires valid HTML) - val textDescription: String = try { - scala.xml.XML.loadString(htmlDescription).text - } catch { - // Fallback to the html - case _ : Throwable => htmlDescription - } - - new GlossaryItem( - title, - () => description, - htmlDescription, - textDescription - ) - } - - } - - - - - val glossaryItems = ArrayBuffer[GlossaryItem]() - - // NOTE! Some glossary items are defined in ExampleValue.scala - - - val latestConnector : String = "rest_vMar2019" - - def messageDocLink(process: String) : String = { - s"""$process""" - } - - val latestAkkaConnector : String = "akka_vDec2018" - def messageDocLinkAkka(process: String) : String = { - s"""$process""" - } - - val latestRabbitMQConnector : String = "rabbitmq_vOct2024" - def messageDocLinkRabbitMQ(process: String) : String = { - s"""$process""" - } - - // Note: this doesn't get / use an OBP version - def getApiExplorerLink(title: String, operationId: String) : String = { - val apiExplorerPrefix = APIUtil.getPropsValue("webui_api_explorer_url", "http://localhost:5174") - // Note: This is hardcoded for API Explorer II - s"""$title""" - } - - // Consumer registration URL helper - def getConsumerRegistrationUrl(): String = { - val apiExplorerUrl = APIUtil.getPropsValue("webui_api_explorer_url", "http://localhost:5174") - s"$apiExplorerUrl/consumers/register" - } - - glossaryItems += GlossaryItem( - title = "Cheat Sheet", - description = - s""" - |### A selection of links to get you started using the Open Bank Project API platform, applications and tools. - | - |[OBP API Installation](https://github.com/OpenBankProject/OBP-API/blob/develop/README.md) - | - |[OBP API Contributing](https://github.com/OpenBankProject/OBP-API/blob/develop/CONTRIBUTING.md) - | - |[Access Control](/glossary#API.Access-Control) - | -|[Versioning](https://github.com/OpenBankProject/OBP-API/wiki/API-Versioning) -| - |[Authentication](https://github.com/OpenBankProject/OBP-API/wiki/Authentication) -| - |[Interfaces](/glossary#API.Interfaces) - | - |[Endpoints](https://apiexplorersandbox.openbankproject.com) - | - |[Glossary](/glossary) - | - |[Access Control](/glossary#API.Access-Control) - | - |[OBP Akka](/glossary#Adapter.Akka.Intro) - | - |[API Explorer](https://github.com/OpenBankProject/API-Explorer/blob/develop/README.md) - | - |[API Manager](https://github.com/OpenBankProject/API-Manager/blob/master/README.md) - | - |[API Tester](https://github.com/OpenBankProject/API-Tester/blob/master/README.md) - | - | -""") - - - - - - - glossaryItems += GlossaryItem( - title = "Rate Limiting", - description = - s""" - |Rate Limiting controls the number of API requests a Consumer can make within specific time periods. This prevents abuse and ensures fair resource allocation across all API consumers. - | - |### Architecture - Single Source of Truth - | - |``` - |┌─────────────────────────────────────────────────────────────────────────┐ - |│ RateLimitingUtil.scala │ - |│ │ - |│ ┌───────────────────────────────────────────────────────────────────┐ │ - |│ │ │ │ - |│ │ getActiveRateLimitsWithIds(consumerId, date): │ │ - |│ │ Future[(CallLimit, List[String])] │ │ - |│ │ │ │ - |│ │ ═══════════════════════════════════════════════════════ │ │ - |│ │ Single Source of Truth │ │ - |│ │ ═══════════════════════════════════════════════════════ │ │ - |│ │ │ │ - |│ │ This function calculates active rate limits │ │ - |│ │ │ │ - |│ │ Logic: │ │ - |│ │ 1. Query RateLimiting table for active records │ │ - |│ │ 2. If found: │ │ - |│ │ • Sum positive values (> 0) for each period │ │ - |│ │ • Return -1 if no positive values (unlimited) │ │ - |│ │ • Extract rate_limiting_ids │ │ - |│ │ 3. If not found: │ │ - |│ │ • Return system defaults from props │ │ - |│ │ • Empty ID list │ │ - |│ │ 4. Return: (CallLimit, List[rate_limiting_ids]) │ │ - |│ │ │ │ - |│ └───────────────────────────────────────────────────────────────────┘ │ - |│ ▲ │ - |│ │ │ - |└──────────────────────────────┼──────────────────────────────────────────┘ - | │ - | │ Both callers use - | │ the same function - | │ - | ┌───────────────┴───────────────┐ - | │ │ - | │ │ - | ┌──────────▼──────────┐ ┌──────────▼──────────┐ - | │ │ │ │ - | │ AfterApiAuth.scala │ │ APIMethods600.scala │ - | │ │ │ │ - | │ checkRateLimiting()│ │ getActiveCallLimits │ - | │ │ │ AtDate │ - | │ ───────────────── │ │ ──────────────── │ - | │ │ │ │ - | │ Called: Every │ │ Endpoint: │ - | │ API request │ │ GET /management/ │ - | │ │ │ consumers/ID/ │ - | │ Uses: │ │ consumer/active- │ - | │ (rateLimit, _) │ │ rate-limits/DATE │ - | │ │ │ │ - | │ Ignores IDs, │ │ Uses: │ - | │ just needs the │ │ (rateLimit, ids) │ - | │ CallLimit for │ │ │ - | │ enforcement │ │ Returns both in │ - | │ │ │ JSON response │ - | │ │ │ │ - | └─────────────────────┘ └─────────────────────┘ - |``` - | - |**Key Point**: There is one function that calculates active rate limits. Both enforcement and API reporting call this one function. - | - |### How It Works - | - |1. **Rate Limit Records**: Stored in the `RateLimiting` table with date ranges (from_date, to_date) - |2. **Multiple Records**: A consumer can have multiple active rate limit records that overlap - |3. **Aggregation**: When multiple records are active, their limits are summed together (positive values only) - |4. **Enforcement**: On every API request, the system checks Redis counters against the aggregated limits - | - |### Time Periods - | - |Rate limits can be set for six time periods: - |- **per_second_rate_limit**: Maximum requests per second - |- **per_minute_rate_limit**: Maximum requests per minute - |- **per_hour_rate_limit**: Maximum requests per hour - |- **per_day_rate_limit**: Maximum requests per day - |- **per_week_rate_limit**: Maximum requests per week - |- **per_month_rate_limit**: Maximum requests per month - | - |A value of `-1` means unlimited for that period. - | - |### HTTP Headers - | - |When rate limiting is active, responses include: - |- `X-Rate-Limit-Limit`: Maximum allowed requests for the period - |- `X-Rate-Limit-Remaining`: Remaining requests in current period - |- `X-Rate-Limit-Reset`: Seconds until the limit resets - | - |### HTTP Status Codes - | - |- **200 OK**: Request allowed, headers show current limit status - |- **429 Too Many Requests**: Rate limit exceeded for a time period - | - |### Querying Active Rate Limits - | - |Use the endpoint: - |``` - |GET /obp/v6.0.0/management/consumers/{CONSUMER_ID}/active-rate-limits/{DATE_WITH_HOUR} - |``` - | - |Where `DATE_WITH_HOUR` is in format `YYYY-MM-DD-HH` in **UTC timezone** (e.g., `2025-12-31-13` for hour 13:00-13:59 UTC on Dec 31, 2025). - | - |Returns the aggregated active rate limits for the specified hour, including which rate limit records contributed to the totals. - | - |Rate limits are cached and queried at hour-level granularity for performance. All hours are interpreted in UTC for consistency across all servers. - | - |### System Defaults - | - |If no rate limit records exist for a consumer, system-wide defaults are used from properties: - |- `rate_limiting_per_second` - |- `rate_limiting_per_minute` - |- `rate_limiting_per_hour` - |- `rate_limiting_per_day` - |- `rate_limiting_per_week` - |- `rate_limiting_per_month` - | - |Default value: `-1` (unlimited) - | - |### Example - | - |A consumer with two overlapping rate limit records: - |- Record 1: 10 requests/second, 100 requests/minute - |- Record 2: 5 requests/second, 50 requests/minute - | - |**Aggregated limits**: 15 requests/second, 150 requests/minute - | - |### Configuration - | - |Enable rate limiting by setting: - |``` - |use_consumer_limits=true - |``` - | - |For anonymous access, configure: - |``` - |user_consumer_limit_anonymous_access=1000 - |``` - |(Default: 1000 requests per hour) - | - |### Related Concepts - | - |- **Consumer**: The API client subject to rate limiting - |- **Redis**: Storage system for tracking request counts - |- **Single Source of Truth**: `RateLimitingUtil.getActiveRateLimitsWithIds()` function calculates all active rate limits - """.stripMargin) - - glossaryItems += GlossaryItem( - title = "API-Explorer-II-Help", - description = s""" - |## API Explorer II - How to Use - | - |API Explorer II is an interactive Swagger/OpenAPI interface for discovering and testing OBP and other standard endpoints. - | - |### Key Features - | - |* Browse and search all available API endpoints - |* Execute API calls directly from your browser - |* View request and response examples - |* Test authentication and authorization flows - | - |### Finding Dynamic Entities - | - |Dynamic Entities can be found under the **More** list of API Versions. Look for versions starting with `OBPdynamic-entity` or similar in the version selector. - | - |To programmatically discover all Dynamic Entity endpoints, use: `GET /resource-docs/API_VERSION/obp?content=dynamic` - | - |For more information about Dynamic Entities see ${getGlossaryItemLink("Dynamic-Entities")} - | - |### Creating Favorites - | - |If you click the star icon next to an endpoint, it will be added to your favorites list. - | - |Favorites appear in the Collections section in the left panel interface. - | - |Note: Favorites are a special type of collection. You can create other collections using endpoints. -""" - ) - - - - glossaryItems += GlossaryItem( - title = "Adapter.Akka.Intro", - description = - s""" - |## Use Akka as an interface between OBP and your Core Banking System (CBS). -| -|For an introduction to Akka see [here](https://akka.io/) -| -|The OBP Akka interface allows integrators to write Java or Scala Adapters (any JVM language with Akka support) -|respond to requests for data and services from OBP. -| -|For the message definitions see [here](/message-docs?connector=akka_vDec2018) -| -|### Installation Prerequisites -| -| -|* You have OBP-API running. -| -|* Ideally you have API Explorer running (the application serving this page) but its not necessary - you could use any other REST client. -|* You might want to also run API Manager as it makes it easier to grant yourself roles, but its not necessary - you could use the API Explorer / any REST client instead. -| -| -|### Create a Customer User and an Admin User -| -|* Register a User who will use the API as a Customer. -|* Register another User that will use the API as an Admin. The Admin user will need some Roles. See [here](/index#OBPv2_0_0-addEntitlement). You can bootstrap an Admin user by editing the Props file. See the README for that. -| -|### Add some authentication context to the Customer User -| -|* As the Admin User, use the [Create Auth Context](/index#OBPv3_1_0-createUserAuthContext) endpoint to add one or more attributes to the Customer User. -|For instance you could add the name/value pair CUSTOMER_NUMBER/889763 and this will be sent to the Adapter / CBS inside the AuthInfo object. -| -| -|Now you should be able to use the [Get Auth Contexts](/index#OBPv3_1_0-getUserAuthContexts) endpoint to see the data you added. -| -|### Write or Build an Adapter to respond to the following messages. -| -| When getting started, we suggest that you implement the messages in the following order: -| -|1) Core (Prerequisites) - Get Adapter, Get Banks, Get Bank -| -|* ${messageDocLinkAkka("obp.getAdapterInfo")} -| -|Now you should be able to use the [Adapter Info](/index#OBPv3_1_0-getAdapterInfo) endpoint -| -|* ${messageDocLinkAkka("obp.getBanks")} -| -|Now you should be able to use the [Get Banks](/index#OBPv3_0_0-getBanks) endpoint -| -|* ${messageDocLinkAkka("obp.getBank")} -| -|Now you should be able to use the [Get Bank](/index#OBPv3_0_0-bankById) endpoint -| -| -|2) Get Customers by USER_ID -| -|* ${messageDocLinkAkka("obp.getCustomersByUserId")} -| -|Now you should be able to use the [Get Customers](/index#OBPv3_0_0-get.CustomersByUserId) endpoint. -| -| -|3) Get Accounts -| -|* ${messageDocLinkAkka("obp.checkBankAccountExists")} -|* ${messageDocLinkAkka("obp.getCoreBankAccounts")} -| -| The above messages should enable at least the following endpoints: -| -|* [Get Accounts at Bank (IDs only)](/index#OBPv3_0_0-getPrivateAccountIdsbyBankId) -|* [Get Accounts at Bank (Minimal).](/index#OBPv3_0_0-privateAccountsAtOneBank) -|* [Get Accounts at all Banks (private)](/index#OBPv3_0_0-corePrivateAccountsAllBanks) -| -|4) Get Account -| -|* ${messageDocLinkAkka("obp.getBankAccount")} -| -| The above message should enable at least the following endpoints: -| -|* [Get Account by Id - Core](/index#OBPv3_0_0-getCoreAccountById) -|* [Get Account by Id - Full](/index#OBPv3_0_0-getPrivateAccountById) -| -|5) Get Transactions -| -|* ${messageDocLinkAkka("obp.getTransactions")} -|* ${messageDocLinkAkka("obp.getTransaction")} -| -|6) Manage Counterparties -| -|* ${messageDocLinkAkka("obp.getCounterparties")} -| -|7) Get Transaction Request Types -| -|* This is configured using OBP Props - No messages required -| -| -|This glossary item is Work In Progress. -| -""") - - glossaryItems += GlossaryItem( - title = "Adapter.Stored_Procedure.Intro", - description = - s""" - |## Use Stored_Procedure as an interface between OBP and your Core Banking System (CBS). - | - | - |For an introduction to Stored Procedures see [here](https://en.wikipedia.org/wiki/Stored_procedure) - | - |### Installation Prerequisites - | - | - |* You have OBP-API running and it is connected to a stored procedure related database. - |* Ideally you have API Explorer running (the application serving this page) but its not necessary - you could use any other REST client. - |* You might want to also run API Manager as it makes it easier to grant yourself roles, but its not necessary - you could use the API Explorer / any REST client instead. - |""" - ) - - glossaryItems += GlossaryItem( - title = "Roles of Open Bank Project", - description = - s"""
    ${ApiRole.availableRoles.sorted.map(i => "
  1. " + i + "
  2. ").mkString}
""".stripMargin - ) - - - - - // ***Note***! Don't use "--" (double hyphen) in the description because API Explorer scala.xml.XML.loadString cannot parse. - - glossaryItems += GlossaryItem( - title = "Connector", - description = - s"""In OBP, most internal functions / methods can have different implementations which follow the same interface. - | - |These functions are called connector methods and their implementations. - | - |The default implementation of the connector is the "mapped" connector. - | - |It's called "mapped" because the default datasource on OBP is a relational database, and access to that database is always done through an Object-Relational Mapper (ORM) called Mapper (from a framework we use called Liftweb). - | - | - |
-				 |[=============]                                                                     [============]       [============]
-				 |[.............]                                                                     [            ]       [            ]
-				 |[...OBP API...] ===> OBP Endpoints call connector functions (aka methods) ===>      [  Connector ] ===>  [  Database  ]
-				 |[.............]          The default implementation is called "Mapped"              [  (Mapped)  ]       [  (Adapter) ]
-				 |[=============]              The Mapped Connector talks to a Database               [============]       [============]
-				 |
-				 |
- | - |However, there are multiple available connector implementations - and you can also mix and create your own.| - | - |E.g. RabbitMq - | - |
-				 |[=============]                              [============]       [============]     [============]       [============]
-				 |[             ]                              [            ]       [            ]     [            ]       [            ]
-				 |[   OBP API   ] ===> RabbitMq Connector ===> [  RabbitMq  ] ===>  [  RabbitMq  ]     [ OBP RabbitMq] ===> [     CBS    ]
-				 |[             ]      Puts OBP Messages       [  Connector ]       [  Cluster   ]     [  Adapter   ]       [            ]
-				 |[=============]       onto a RabbitMq           [============]       [============]     [============]       [============]
-				 |
-				 |
- | - | - | - |You can mix and match them using the Star connector and you can write your own in Scala. You can also write Adapters in any language which respond to messages sent by the connector. - | - |we use the term "Connector" to mean the Scala/Java/Other JVM code in OBP that connects directly or indirectly to the systems of record i.e. the Core Banking Systems, Payment Systems and Databases. - | - | - | A "Direct Connector" is considered to be one that talks directly to the system of record or existing service layer. - | - | i.e. API -> Connector -> CBS - | - | An "Indirect Connector" is considered one which pairs with an Adapter which in turn talks to the system of record or service layer. - | - | i.e. API -> Connector -> Adapter -> CBS - | - | The advantage of a Direct connector is that its perhaps simpler. The disadvantage is that you have to code in a JVM language, understand a bit about OBP internals and a bit of Scala. - | - | The advantage of the Indirect Connector is that you can write the Adapter in any language and the Connector and Adapter are decoupled (you just have to respect the Outbound / Inbound message format). - | - | The default Connector in OBP is a Direct Connector called "mapped". It is called the "mapped" connector because it talks directly to the OBP database (Postgres, MySQL, Oracle, MSSQL etc.) via the Liftweb ORM which is called Mapper. - | - |If you want to create your own (Direct) Connector you can fork any of the connectors within OBP. - | - | - | There is a special Connector called the Star Connector which can use functions from all the normal connectors. - | - | Using the Star Connector we can dynamically reroute function calls to different Connectors per function per bank_id. - | - | The OBP API Manager has a GUI to manage this or you can use the OBP Method Routing APIs to set destinations for each function call. - | - | Note: We generate the source code for individual connectors automatically. - | - |""" - ) - - glossaryItems += GlossaryItem( - title = "Connector.User.Authentication", - description = - s""" - |### Overview - | - |The property `connector.user.authentication` (default: `false`) controls whether OBP can authenticate a user via the Connector when they are not found locally. - | - |OBP always checks for users locally first. When this property is enabled and a user is not found locally (or exists but is from an external provider), OBP will attempt to authenticate them against an external identity provider or Core Banking System (CBS) via the Connector. - | - |### Configuration - | - |In your props file: - | - |``` - |connector.user.authentication=true - |``` - | - |### Behavior When Enabled (true) - | - |**1. Login Authentication Flow:** - | - |When a user attempts to log in: - | - |``` - |User Login Request - | │ - | ▼ - |┌─────────────────────────┐ - |│ 1. Check if user exists │ - |│ locally in OBP │ - |└───────────┬─────────────┘ - | │ - | ┌────────┼────────┬─────────────────┐ - | │ │ │ │ - | ▼ ▼ ▼ ▼ - |Found Found Found Not Found - |(local (external (external (and property - |provider) provider) provider enabled) - | │ property property │ - | │ disabled) enabled) │ - | │ │ │ │ - | ▼ ▼ ▼ ▼ - |┌────────┐ ┌────┐ ┌─────────────────────────┐ - |│Check │ │Fail│ │ 2. Call Connector: │ - |│local │ │ │ │ checkExternalUser │ - |│password│ │ │ │ Credentials() │ - |└───┬────┘ └────┘ └───────────┬─────────────┘ - | │ │ - | ▼ ┌────────┴────────┐ - | Success/ │ │ - | Failure ▼ ▼ - | Success Failure - | │ │ - | ▼ ▼ - | ┌─────────────┐ ┌─────────────┐ - | │Create local │ │Increment │ - | │AuthUser if │ │bad login │ - | │not exists │ │attempts │ - | └─────────────┘ └─────────────┘ - |``` - | - |**2. Username Uniqueness Validation:** - | - |During user signup, OBP checks if the username already exists in the external system by calling `checkExternalUserExists()`. - | - |**3. Auto Creation of Local Users:** - | - |If external authentication succeeds but the user doesn't exist locally, OBP automatically creates a local `AuthUser` record linked to the external provider. - | - |### Behavior When Disabled (false, default) - | - |* Users must exist locally in OBP's database - |* Authentication is performed against locally stored credentials - |* No connector calls are made for authentication - | - |### Required Connector Methods - | - |When enabled, your Connector must implement: - | - |* ${messageDocLinkRabbitMQ("obp.checkExternalUserCredentials")} : Validates username and password against external system. Returns `InboundExternalUser` with user details (sub, iss, email, name, userAuthContexts). - | - |* ${messageDocLinkRabbitMQ("obp.checkExternalUserExists")} : Checks if a username exists in the external system. Used during signup validation. - | - |### InboundExternalUser Response - | - |The connector should return user information including: - | - |* `sub`: Subject identifier (username) - |* `iss`: Issuer (provider identifier) - |* `email`: User's email address - |* `name`: User's display name - |* `userAuthContexts`: Optional list of auth contexts (e.g., customer numbers) - | - |### Use Cases - | - |**Enable when:** - |* You have an external identity provider (LDAP, Active Directory, OAuth provider) - |* User credentials are managed by the Core Banking System - |* You want single sign on with an existing user directory - | - |**Disable when:** - |* OBP manages all user authentication locally - |* You're using OBP's built in user management - |* You don't have an external authentication system - | - |### Related Properties - | - |* `connector`: Specifies which connector implementation to use - |* `connector.user.authcontext.read.in.login`: Read user auth contexts during login - | - |""" - ) - - - - - - - glossaryItems += GlossaryItem( - title = "Adapter.authInfo", - description = - s"""authInfo is a JSON object sent by the Connector to the Adapter so the Adapter and/or Core Banking System can - | identify the User making the call. - | - | The authInfo object contains several optional objects and fields. - | - |Please see the Message Docs for your connector for the current JSON structure. The following serves as a guide: - | - |* userId is the user_id as generated by OBP - |* username can be chosen explicitly to match an existing customer number (not recommended) - |* linkedCustomers is a list of Customers the User is explicitly linked to. Use the Create User Customer Link endpoint to populate this data. - |* userAuthContexts may contain the customer number or other tokens in order to boot strap the User Customer Links - |or provide an alternative method of tagging the User with an authorisation context. - |Use the Create UserAuthContext endpoint to populate this data. - |* cbsToken is a token used by the CBS to identify the user's session. Either generated by the CBS or Gateway. - |* isFirst is a flag that indicates that OBP should refresh the user's list of accounts from the CBS (and flush / invalidate any User's cache) - |* correlationId just identifies the API call. - |* authViews are entitlements given by account holders to third party users e.g. Sam may grant her accountant Jill read only access to her business account. See the Create View endpoint - | - |authinfo_annotated_1 - | - | - | - |""" - ) - - - glossaryItems += GlossaryItem( - title = "API.Interfaces", - description = - s""" - |OBP Interfaces Image - | - | - | - |""" - ) - - glossaryItems += GlossaryItem( - title = "API.Timeouts", - description = - s""" - |OBP Timeouts Image - | - | - | - |""" - ) - - - - - glossaryItems += GlossaryItem( - title = "API.Access Control", - description = - s""" -| -|Access Control is achieved via the following mechanisms in OBP: -| -|* APIs are enabled in Props. See the README.md -| -|* Consumers (AKA Clients or Apps) are granted access to Roles and Views via Scopes -| -|See [here](/index#group-Scope) for related endpoints and documentation. -| -|* Users are granted access to System or Bank Roles via Entitlements. -| -|See [here](/index#group-Role) for related endpoints and documentation. -| -|Users may request Entitlement Requests [here](/index#OBPv3_0_0-addEntitlementRequest) -| -|Entitlements and Entitlement Requests can be managed in the OBP API Manager. -| -|* Users are granted access to Customer Accounts, Transactions and Payments via Views. -| -|See [here](/index#group-View) for related endpoints and documentation. -| -|User Views can be managed via the OBP Sofit Consent App. -| -| - |OBP Access Control Image - | - | - | - |""" - ) - - - - val justInTimeEntitlements : String = if (APIUtil.getPropsAsBoolValue("create_just_in_time_entitlements", false)) - {"Just in Time Entitlements are ENABLED on this instance."} else {"Just in Time Entitlements are NOT enabled on this instance."} - - - glossaryItems += GlossaryItem( - title = "Just In Time Entitlements", - description = - s""" - | - |${justInTimeEntitlements} - | - |This is how Just in Time Entitlements work: - | - |If Just in Time Entitlements are enabled then OBP does the following: - |If a user is trying to use a Role (via an endpoint) and the user could grant them selves the required Role(s), then OBP automatically grants the Role. - |i.e. if the User already has canCreateEntitlementAtOneBank or canCreateEntitlementAtAnyBank then OBP will automatically grant a role that would be granted by a manual process anyway. - |This speeds up the process of granting of roles. Certain roles are excluded from this automation: - | - CanCreateEntitlementAtOneBank - | - CanCreateEntitlementAtAnyBank - |If create_just_in_time_entitlements is again set to false after it was true for a while, any auto granted Entitlements to roles are kept in place. - |Note: In the entitlements model we set createdbyprocess=create_just_in_time_entitlements. For manual operations we set createdbyprocess=manual - | - |To enable / disable this feature set the Props create_just_in_time_entitlements=true or false. The default is false. - | - |""" - ) - - - - - - - - glossaryItems += GlossaryItem( - title = - "Account", - description = - """The thing that tokens of value (money) come in and out of. - |An account has one or more `owners` which are `Users`. - |In the future, `Customers` may also be `owners`. - |An account has a balance in a specified currency and zero or more `transactions` which are records of successful movements of money. - |""" - ) - - glossaryItems += GlossaryItem( - title = - "Age", - description = - """The user Age""" - ) - - glossaryItems += GlossaryItem( - title = "Account.account_id", - description = - s""" - |An identifier for the account that MUST NOT leak the account number or other identifier nomrally used by the customer or bank staff. - |It SHOULD be a UUID. It MUST be unique in combination with the BANK_ID. ACCOUNT_ID is used in many URLS so it should be considered public. - |(We do NOT use account number in URLs since URLs are cached and logged all over the internet.) - |In local / sandbox mode, ACCOUNT_ID is generated as a UUID and stored in the database. - |In non sandbox modes (RabbitMq etc.), ACCOUNT_ID is mapped to core banking account numbers / identifiers at the South Side Adapter level. - |ACCOUNT_ID is used to link Metadata and Views so it must be persistant and known to the North Side (OBP-API). - | - | Example value: ${accountIdExample.value} - | - """) - - glossaryItems += GlossaryItem( - title = "Bank", - description = - """ - |A Bank (aka Space) represents a financial institution, brand or organizational unit under which resources such as endpoints and entities exist. -| -|Both standard entities (e.g. financial products and bank accounts in the OBP standard) and dynamic entities and endpoints (created by you or your organisation) can exist at the Bank level. -| -|For example see [Bank/Space level Dynamic Entities](/?version=OBPv4.0.0&operation_id=OBPv4_0_0-createBankLevelDynamicEntity) and [Bank/Space level Dynamic Endpoints](http://localhost:5174/?version=OBPv4.0.0&operation_id=OBPv4_0_0-createBankLevelDynamicEndpoint) -| -|The Bank is important because many Roles can be granted at the Bank level. In this way, it's possible to create segregated or partitioned sets of endpoints and data structures in a single OBP instance. -| -|A User creating a Bank (if they have the right so to do), automatically gets the Entitlement to grant any Role for that Bank. Thus the creator of a Bank / Space becomes the "god" of that Bank / Space. -| -|Basic attributes for the bank resource include identifying information such as name, logo and website. -| -|Using the OBP endpoints for bank accounts it's possible to view accounts at one Bank or aggregate accounts from all Banks connected to the OBP instance. -| -|See also Props settings named "brand". - """) - - - glossaryItems += GlossaryItem( - title = "Bank.bank_id", - description = - s""" - |An identifier that uniquely identifies the bank or financial institution on the OBP-API instance. - | - |It is typically a human (developer) friendly string for ease of identification. - | - |It SHOULD NOT contain spaces. - | - |In sandbox mode it typically has the form: "financialinstitutuion.sequencennumber.region.language". e.g. "bnpp-irb.01.it.it" - | - |For production, it's value could be the BIC of the institution. - | - | - |Example value: ${bankIdExample.value} - """) - - glossaryItems += GlossaryItem( - title = "Consumer", - description = - s""" - |The "consumer" of the API, i.e. the web, mobile or serverside "App" that calls on the OBP API on behalf of the end user (or system). - | - |Each Consumer has a consumer key and secret which allows it to enter into secure communication with the API server. - | - |A Consumer is given a Consumer ID (a UUID) which appears in logs and messages to the backend. - | - |A Consumer may be pinned to an mTLS certificate i.e. the consumer record in the database is given a field which matches the PEM representation of the certificate. - | - |After pinning, the consumer must present the certificate in all communication with the server. - | - |There is a one to one relationship between a Consumer and its certificate. i.e. OBP does not (currently) store the history of certificates bound to a Consumer. If a certificate expires, the third party provider (TPP) must generate a new consumer using a new certificate. In this case, related resources such as rate limits and scopes must be copied from the old consumer to the new consumer. In the future, OBP may store multiple certificates for a consumer, but a certificate will always identify only one consumer record. - | - """) - - glossaryItems += GlossaryItem( - title = "Consumer.consumer_key (Consumer Key)", - description = - s""" - |The client identifier issued to the client during the registration process. It is a unique string representing the registration information provided by the client. - |At the time the consumer_key was introduced OAuth 1.0a was only available. The OAuth 2.0 counterpart for this value is client_id - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "client_id (Client ID)", - description = - s"""Please see Consumer.consumer_key""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Customer", - description = - """ - |The legal entity that has the relationship to the bank. Customers are linked to Users via `User Customer Links`. Customer attributes include Date of Birth, Customer Number etc. - | - """) - - glossaryItems += GlossaryItem( - title = "Customer.customer_id", - description = - s""" - |The identifier that MUST NOT leak the customer number or other identifier normally used by the customer or bank staff. It SHOULD be a UUID and MUST be unique in combination with BANK_ID. - | - |Example value: ${customerIdExample.value} - """) - - glossaryItems += GlossaryItem( - title = "Transaction", - description = - """ - |Transactions are records of successful movements of value into or out of an `Account`. - | - |OBP Transactions don't contain any "draft" or "pending" Transactions; pending transactions see represented by Transaction Requests. - | - |OBP Transactions are modelled on a Bank statement where everything is based on the perspective of my account. - |That is, if I look at "my account", I see credits (positive numbers) and debits (negative numbers) - - |An OBP transaction stores information including the: - |Bank ID - |Account ID - |Currency - |Amount (positive for a credit, negative for a debit) - |Date - |Counterparty (information that describes the other party in the transaction) - |- optionally description and new balance. -| -|Note, OBP operates a Double-Entry Bookkeeping system which means that every transfer of value within OBP is represented by *two* transactions. -| -|For instance, to represent 5 Euros going from Account A to Account B, we would have 2 transactions: -| -|Transaction 1. -| -|Account: A -|Currency: EUR -|Amount: -5 -|CounterpartyCounterpartyCounterparty: Account B -| -|Transaction 2. -| -|Account: B -|Currency: EUR -|Amount: +5 -|Counterparty: Account A -| -|The sum of the two transactions must be zero. -| -|What about representing value coming into or out of the system? Here we use "settlement accounts": -| -|OBP-INCOMING-SETTLEMENT-ACCOUNT is typically the ID for a default incoming settlement account -| -|OBP-OUTGOING-SETTLEMENT-ACCOUNT is typically the ID for a default outgoing settlement account -| -|See the following diagram: -| -|![OBP Double-Entry Bookkeeping](https://user-images.githubusercontent.com/485218/167990092-e76e6265-faa2-4425-b366-e570ed3301b9.png) -| -|See the [Get Double Entry Transaction](/index?version=OBPv4.0.0&operation_id=OBPv4_0_0-getDoubleEntryTransaction¤tTag=Transaction#OBPv4_0_0-getDoubleEntryTransaction) endpoint -| -| -| - """) - - glossaryItems += GlossaryItem( - title = "Transaction Requests", - description = - """ - |Transaction Requests are records of transaction / payment requests coming to the API. They may or may not result in Transactions (following authorisation, security challenges and sufficient funds etc.) - | - |A successful Transaction Request results in a Transaction. - | - |For more information [see here](https://github.com/OpenBankProject/OBP-API/wiki/Transaction-Requests) - """) - - glossaryItems += GlossaryItem( - title = "User", - description = - """ - |The entity that accesses the API with a login / authorisation token and has access to zero or more resources on the OBP API. The User is linked to the core banking user / customer at the South Side Adapter layer. - """) - - glossaryItems += GlossaryItem( - title = "User.user_id", - description = - s""" - |An identifier that MUST NOT leak the user name or other identifier nomrally used by the customer or bank staff. It SHOULD be a UUID and MUST be unique on the OBP instance. - | - | Example value: ${userIdExample.value} - """) - - glossaryItems += GlossaryItem( - title = "User.provider", - description = - """ - |The host name of the authentication service. e.g. the OBP hostname or OIDC host. - """) - - glossaryItems += GlossaryItem( - title = "User.provider_id", - description = - """ - |The id of the user given by the authentication provider. This is UNIQUE in combination with PROVIDER name. - """) - - glossaryItems += GlossaryItem( - title = "User Customer Links", - description = - """ - |Link Users and Customers in a many to many relationship. A User can represent many Customers (e.g. the bank may have several Customer records for the same individual or a dependant). In this way Customers can easily be attached / detached from Users. - """) - - glossaryItems += GlossaryItem( - title = "Consent", - description = - s"""Consents provide a mechanism through which a resource owner (e.g. a customer) can grant a third party certain access to their resources. -| -|The following are important considerations in Consent flows: -| -|1) The privacy of the resource owner (the Customer or User) should be preserved. -| -|This means that when a TPP first asks a User if they would like to provide their data, the user should not be authenticated. -|Thus the start of the Consent process authenticates the Client (TPP) but not the User. -| -|Authentication of the user comes later. -| -|${getApiExplorerLink("This endpoint initiates a consent in OBP", "OBPv5.0.0-createConsentRequest")} -| -|2) Consent finalisation often involves SCA. -| -|Since a consent gives its holder privileges on the API, we need to make sure it is not created lightly, therefore some second factor of authentication is employed. -| -|${getApiExplorerLink("This endpoint finalises an OBP consent", "OBPv5.0.0-createConsentByConsentRequestIdSms")} -| -|3) A User should be able to list and revoke their consents. -| -| -| -|${getApiExplorerLink("This endpoint lists consents for the authenticated user.", "OBPv5.1.0-getMyConsents")} -| -|${getApiExplorerLink("This endpoint revokes a consent for the current user.", "OBPv3.1.0-revokeConsent")} -| -|This gives the user visibility over the consents they have granted to various apps for various purposes and confidence they can stop the TPP acting for a certain purpose. -| -|4) The consent manager should be able to list and revoke consents. -| -|${getApiExplorerLink("This is a management endpoint lists consents with various query parameters", "OBPv5.1.0-getConsentsAtBank")} -| -|${getApiExplorerLink("This is a management endpoint to revoke a consent", "OBPv5.1.0-revokeConsentAtBank")} -| -|The consent manager may want to list the consents by each Client or User and the ability to revoke individual consents (rather than disabling a client completely). -| -|This requires that the resource server stores the CONSENT_ID and other information so that it can be disabled or queried. -| -|However, the consent manager should not be able to see the CONSENT_ID since this would make it easier to actually use it. -| -|5) A consent is bound to the application has created it. -| -|The User gave consent to a certain application not any application. -| -|6) The consent will have a limited life time. -| -|The consent can become valid in the future and need not last forever. -| -|7) The consent will be signed using JWT. -| -|This increases the security of the claims contained in the consent. -| -| -| - |See ${getGlossaryItemLink("Consent_OBP_Flow_Example")} for an example flow. - |See ${getGlossaryItemLink("Consent_Account_Onboarding")} for more information about onboarding. -| - |OBP Access Control Image - |""".stripMargin) - - - glossaryItems += GlossaryItem( - title = "Authentication: Consent OBP Flow Example", - description = - s""" - |#### 1) Call endpoint Create Consent Request using application access (Client Credentials) - | - |Url: [$getObpApiRoot/v5.0.0/consumer/consent-requests]($getObpApiRoot/v5.0.0/consumer/consent-requests) - | - |Post body: - | - |``` - |{ - | "everything": false, - | "account_access": [], - | "entitlements": [ - | { - | "bank_id": "gh.29.uk.x", - | "role_name": "CanGetCustomersAtOneBank" - | } - | ], - | "email": "marko@tesobe.com" - |} - |``` - | - |Output: - |``` - |{ - | "consent_request_id":"bc0209bd-bdbe-4329-b953-d92d17d733f4", - | "payload":{ - | "everything":false, - | "account_access":[], - | "entitlements":[{ - | "bank_id":"gh.29.uk.x", - | "role_name":"CanGetCustomersAtOneBank" - | }], - | "email":"marko@tesobe.com" - | }, - | "consumer_id":"0b34068b-cb22-489a-b1ee-9f49347b3346" - |} - |``` - | - | - | - | - |#### 2) Call endpoint Create Consent By CONSENT_REQUEST_ID (SMS) with logged on user - | - |Url: $getObpApiRoot/v5.0.0/consumer/consent-requests/bc0209bd-bdbe-4329-b953-d92d17d733f4/EMAIL/consents - | - |Output: - |``` - |{ - | "consent_id":"155f86b2-247f-4702-a7b2-671f2c3303b6", - | "jwt":"eyJhbGciOiJIUzI1NiJ9.eyJlbnRpdGxlbWVudHMiOlt7InJvbGVfbmFtZSI6IkNhbkdldEN1c3RvbWVyIiwiYmFua19pZCI6ImdoLjI5LnVrLngifV0sImNyZWF0ZWRCeVVzZXJJZCI6ImFiNjUzOWE5LWIxMDUtNDQ4OS1hODgzLTBhZDhkNmM2MTY1NyIsInN1YiI6IjU3NGY4OGU5LTE5NDktNDQwNy05NTMwLTA0MzM3MTU5YzU2NiIsImF1ZCI6IjFhMTA0NjNiLTc4NTYtNDU4ZC1hZGI2LTViNTk1OGY1NmIxZiIsIm5iZiI6MTY2OTg5NDU5OSwiaXNzIjoiaHR0cDpcL1wvMTI3LjAuMC4xOjgwODAiLCJleHAiOjE2Njk4OTgxOTksImlhdCI6MTY2OTg5NDU5OSwianRpIjoiMTU1Zjg2YjItMjQ3Zi00NzAyLWE3YjItNjcxZjJjMzMwM2I2Iiwidmlld3MiOltdfQ.lLbn9BtgKvgAcb07if12SaEyPAKgXOEmr6x3Y5pU-vE", - | "status":"INITIATED", - | "consent_request_id":"bc0209bd-bdbe-4329-b953-d92d17d733f4" - |} - |``` - | - |#### 3) We receive the SCA message via SMS - |Your consent challenge : 29131491, Application: Any application - | - | - | - | - |#### 4) Call endpoint Answer Consent Challenge with logged on user - |Url: $getObpApiRoot/v5.0.0/banks/gh.29.uk.x/consents/155f86b2-247f-4702-a7b2-671f2c3303b6/challenge - |Post body: - |``` - |{ - | "answer": "29131491" - |} - |``` - |Output: - |``` - |{ - | "consent_id":"155f86b2-247f-4702-a7b2-671f2c3303b6", - | "jwt":"eyJhbGciOiJIUzI1NiJ9.eyJlbnRpdGxlbWVudHMiOlt7InJvbGVfbmFtZSI6IkNhbkdldEN1c3RvbWVyIiwiYmFua19pZCI6ImdoLjI5LnVrLngifV0sImNyZWF0ZWRCeVVzZXJJZCI6ImFiNjUzOWE5LWIxMDUtNDQ4OS1hODgzLTBhZDhkNmM2MTY1NyIsInN1YiI6IjU3NGY4OGU5LTE5NDktNDQwNy05NTMwLTA0MzM3MTU5YzU2NiIsImF1ZCI6IjFhMTA0NjNiLTc4NTYtNDU4ZC1hZGI2LTViNTk1OGY1NmIxZiIsIm5iZiI6MTY2OTg5NDU5OSwiaXNzIjoiaHR0cDpcL1wvMTI3LjAuMC4xOjgwODAiLCJleHAiOjE2Njk4OTgxOTksImlhdCI6MTY2OTg5NDU5OSwianRpIjoiMTU1Zjg2YjItMjQ3Zi00NzAyLWE3YjItNjcxZjJjMzMwM2I2Iiwidmlld3MiOltdfQ.lLbn9BtgKvgAcb07if12SaEyPAKgXOEmr6x3Y5pU-vE", - | "status":"ACCEPTED" - |} - |``` - | - | - | - | - |#### 5) Call endpoint Get Customer by CUSTOMER_ID with Consent Header - | - |Url: $getObpApiRoot/v5.0.0/banks/gh.29.uk.x/customers/a9c8bea0-4f03-4762-8f27-4b463bb50a93 - | - |Request Header: - |``` - |Consent-JWT:eyJhbGciOiJIUzI1NiJ9.eyJlbnRpdGxlbWVudHMiOlt7InJvbGVfbmFtZSI6IkNhbkdldEN1c3RvbWVyIiwiYmFua19pZCI6ImdoLjI5LnVrLngifV0sImNyZWF0ZWRCeVVzZXJJZCI6ImFiNjUzOWE5LWIxMDUtNDQ4OS1hODgzLTBhZDhkNmM2MTY1NyIsInN1YiI6IjU3NGY4OGU5LTE5NDktNDQwNy05NTMwLTA0MzM3MTU5YzU2NiIsImF1ZCI6IjFhMTA0NjNiLTc4NTYtNDU4ZC1hZGI2LTViNTk1OGY1NmIxZiIsIm5iZiI6MTY2OTg5NDU5OSwiaXNzIjoiaHR0cDpcL1wvMTI3LjAuMC4xOjgwODAiLCJleHAiOjE2Njk4OTgxOTksImlhdCI6MTY2OTg5NDU5OSwianRpIjoiMTU1Zjg2YjItMjQ3Zi00NzAyLWE3YjItNjcxZjJjMzMwM2I2Iiwidmlld3MiOltdfQ.lLbn9BtgKvgAcb07if12SaEyPAKgXOEmr6x3Y5pU- - |``` - |Output: - |``` - |{ - | "bank_id":"gh.29.uk.x", - | "customer_id":"a9c8bea0-4f03-4762-8f27-4b463bb50a93", - | "customer_number":"0908977830011-#2", - | "legal_name":"NONE", - | "mobile_phone_number":"+3816319549071", - | "email":"marko@tesobe.com1", - | "face_image":{ - | "url":"www.openbankproject", - | "date":"2017-09-18T22:00:00Z" - | }, - | "date_of_birth":"2017-09-18T22:00:00Z", - | "relationship_status":"Single", - | "dependants":5, - | "dob_of_dependants":[], - | "credit_rating":{ - | "rating":"3", - | "source":"OBP" - | }, - | "credit_limit":{ - | "currency":"EUR", - | "amount":"10001" - | }, - | "highest_education_attained":"Bachelor’s Degree", - | "employment_status":"Employed", - | "kyc_status":true, - | "last_ok_date":"2017-09-18T22:00:00Z", - | "title":null, - | "branch_id":"3210", - | "name_suffix":null, - | "customer_attributes":[] - |} - |``` - |""".stripMargin) - - - - glossaryItems += GlossaryItem( - title = "Consent_Account_Onboarding", - description = - """|*Consent*, or *Account onboarding*, is the process by which the account owner gives permission for their account(s) to be accessible to the API endpoints. -| -|In OBP, the account, transaction and payment APIs are all guarded by Account *Views* - with one exception, the account holders endpoint which can be used to -|bootstrap account on-boarding. -| -|Note: the account holders endpoint is generally made available only to the Account Onboarding App, so if a View does not exist, no API access to the account is possible. -| -|*Consent* or *Account onboarding* can be managed in one of two ways: -| -|1) A backend system (CBS or other) is the system of record for User Consent, and OBP mirrors this. -| -|In this case: -| -| a) OBP requires the CBS or other backend system to return a list of accounts and permissions associated with a User. -| -| b) At User login, OBP automatically creates one or more Views for that User based on the permissions supplied by the CBS. -| -|2) OBP is the system of record for User Consent. -| -|In this case: -| -| a) OBP requires the CBS, Gateway or other system to provide just a basic list of accounts owned by the User. -| -| b) The Onboarding App or Bank's Onboarding Page then authenticates the User and calls the Create View endpoint. -| -| c) The account, transaction and payment API endpoints then work as moderated by the relevant View permissions. -| -| d) The User can revoke access by calling the delete View endpoint. -| -| -|In summary: -| -|Prior to Views being created on an Account for a User, only the 'accounts held' endpoint will work for the account holder, and this endpoint only provides enough information -|to identify the account so it can be selected and on-boarded into the API. -| -|Once a View exists for an Account, a User can interact with the Account via the API based on permissions defined in the View. -| -|""") - - - - glossaryItems += GlossaryItem( - title = "Authentication", - description = - s""" - |Authentication generally refers to a set of processes which result in a resource server (in this case, OBP-API) knowing about the User and/or Application that is making the http request it receives. -| -|In most cases when we talk about authentication we are thinking about User authentication, e.g. the user J.Brown is requesting data from the API. -|However, user authentication is pretty much always accompanied by knowledge of the Client AKA Consumer, TPP or Application. -|In some cases, we only perform Client authentication which results in knowledge of the Application but not the human that is making the call. This is useful when we want to protect the identity of a user but still want to control access to the API. -| -|In most cases, OBP-API server knows about at least two entities involved in the http request / call: The Client and the User - but it will also know about (and trust) the Identity Server (Provider) that authenticated the user and other elements in the chain of trust such as load balancers and certificate authorities. -| -|In simple terms, there are two phases of the Authentication process: -| -|1) The phase where an authorisation token is obtained. -|2) The phase where an authorisation token is used. -| -|Phase 1 is an exchange of credentials such as a username and password and possibly knowledge of a "second factor" for a token. -| -|Phase 2 is the execution of an http call which contains the token in a "header" in exchange for some response data or some resource being created, update or deleted. -| -|There are several methods of obtaining and using a token which vary in their ease of use and security. -| -|Direct Login and OAuth 1.0a are used for testing purposes / local installations and are built into OBP. -| -|OAuth2 / Open ID Connect (OIDC) depend on the configuration of Identity Provider solutions such as Keycloak or Hydra or external services such as Google or Yahoo. -| -|Open Bank Project can support multiple identity providers per OBP instance. For example, for a single OBP installation, some Users could authenticate against Google and some could authenticate against a local identity provider. -|In the cases where multiple identity providers are configured, OBP differentiates between Users by not only their Username but also by their "Identity Provider". i.e. J.Brown logged in via Google is distinct from J.Brown who logged in via a local OBP instance. -| -|Phase 1 generally results in a temporary token i.e. a token that is valid for a limited amount of time e.g. 2 hours or 3 minutes. -| -|Phase 1 might also result in a token that represents a subset of the User's full permissions. This token is generally called a Consent. i.e. a User might give consent for an application to access one of her accounts but not all of them. A Consent is generally given to a Client and bound to that Client i.e. no other application may use it. -| -|Phase 2 results in OBP having identified a User record in the OBP database so that Authorisation can proceed. -| -""") - - - glossaryItems += GlossaryItem( - title = "Authorization", - description = - s""" -|If Authentication involves the process of determining the *identity* of a user or application, Authorization involves the process of determining *what* the user or application can do. -| -|In OBP, Endpoints are protected by "Guards". -| -|There are two types of permissions which can be granted: -| -|1) *Entitlements to Roles* provide course grained access to resources which are related to the OBP system or a bank / space e.g. CanCreateAtm would allow the holder to create an ATM record. -| -|2) *Account Access records* provide fine grained permissions to customer bank accounts, their transactions and payments through Views. e.g. the A User with the Balances View on Account No 12345 would be allowed to get the balances on that account. -| -|Both types of permissions can be encapsulated in Consents or other authentication mechanisms. -| -|When OBP receives a call, after authentication is performed, OBP checks if the caller has sufficient permissions. -| -|If an endpoint guard blocks a call due to insufficient permissions / authorization, OBP will return an OBP- error message. -| -|If the caller passes the guards, the OBP-API forwards the request to the next step in the process. -| -|Note: All OBP- error messages can be found in the OBP-API logs and OBP source code for debugging purposes. -""") - - - - glossaryItems += GlossaryItem( - title = "Authentication: Direct Login", - description = - s""" - |Direct Login is a simple authentication process to be used at hackathons and trusted environments: - | - | - |### 1) Get your App key - | - |[Sign up]($getServerUrl/user_mgt/sign_up) or [login]($getServerUrl/user_mgt/login) as a developer. - | - |Register your App key [HERE](${getConsumerRegistrationUrl()}) - | - |Copy and paste the consumer key for step two below. - | - |### 2) Authenticate - | - | - |Using your favorite http client: - | - | POST $getServerUrl/my/logins/direct - | - |Body - | - | Leave Empty! - | - | - |Headers: - | - | Content-Type: application/json - | - | - | directlogin: username=janeburel, - | password=the-password-of-jane, - | consumer_key=your-consumer-key-from-step-one - | - |Here is it all together: - | - | POST $getServerUrl/my/logins/direct HTTP/1.1 - | $directLoginHeaderName: username=janeburel, password=686876, consumer_key=GET-YOUR-OWN-API-KEY-FROM-THE-OBP - | Content-Type: application/json - | Host: 127.0.0.1:8080 - | Connection: close - | User-Agent: Paw/2.3.3 (Macintosh; OS X/10.11.3) GCDHTTPRequest - | Content-Length: 0 - | - |Note: HTTP/2.0 requires that header names are *lower* case. Currently the header name for $directLoginHeaderName is case insensitive. - | - |To troubleshoot request headers, you may want to ask your administrator to Echo Request headers. - | - |You should receive a token: - | - | {"token":"a-long-token-string"} - | - |### 3) Make authenticated API calls - | - |In subsequent calls you can use the token received in step 2 - | - |e.g. - | - | - |Action: - | - | PUT $getObpApiRoot/v2.0.0/banks/obp-bankx-n/accounts/my-new-account-id - | - |Body: - | - | { "type":"CURRENT", "balance":{ "currency":"USD", "amount":"0" }} - | - |Headers: - | - | Content-Type: application/json - | - | $directLoginHeaderName: token=your-token-from-step-2 - | - |Here is another example: - | - | PUT $getObpApiRoot/v2.0.0/banks/enbd-egy--p3/accounts/newaccount1 HTTP/1.1 - | $directLoginHeaderName: token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyIiOiIifQ.C8hJZNPDI59OOu78pYs4BWp0YY_21C6r4A9VbgfZLMA - | Content-Type: application/json - | Cookie: JSESSIONID=7h1ssu6d7j151u08p37a6tsx1 - | Host: 127.0.0.1:8080 - | Connection: close - | User-Agent: Paw/2.3.3 (Macintosh; OS X/10.11.3) GCDHTTPRequest - | Content-Length: 60 - | - | {"type":"CURRENT","balance":{"currency":"USD","amount":"0"}} - | - | - |### More information - | - | Parameter names and values are case sensitive. - | The following parameters must be sent by the client to the server: - | - | username - | The name of the user to authenticate. - | - | password - | The password used to authenticate user. Alphanumeric string. - | - | consumer_key - | The application identifier. Generated on OBP side via - | ${getConsumerRegistrationUrl()} endpoint. - | - | - | Each parameter MUST NOT appear more than once per request. - | - """) - - - glossaryItems += GlossaryItem( - title = "Echo Request Headers", - description = - s""" - |Question: How can I see the request headers that OBP API finally receives from a REST client after the request has passed through HTTP infrastructure such as load balancers, firewalls and proxies? -| -|Answer: If your OBP administrator (you?) sets the following OBP API Props: -| -|```echo_request_headers=true``` -| -|then OBP API will echo all the request headers it receives to the response headers except that every request header name is prefixed with echo_ -| -|e.g. if you send the request header:value "DirectLogin:hello" it will be echoed in the response headers as "echo_DirectLogin:hello" -| -|Note: HTTP/2.0 requires that header names must be *lower* case. This can be a source of confusion as some libraries / tools may drop or convert header names to lowercase. - | - """) - - - glossaryItems += GlossaryItem( - title = "Scenario 1: Onboarding a User", - description = - s""" - |### 1) Create a user - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/users - | - |Body: - | - | { "email":"ellie@example.com", "username":"ellie", "password":"P@55w0RD123", "first_name":"Ellie", "last_name":"Williams"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |Please note the user_id - | - |### 2) Create customer - | - |Requires CanCreateCustomer and CanCreateUserCustomerLink roles - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/customers - | - |Body: - | - | { "legal_name":"Eveline Tripman", "mobile_phone_number":"+44 07972 444 876", "email":"eveline@example.com", "face_image":{ "url":"www.openbankproject", "date":"1100-01-01T00:00:00Z" }, "date_of_birth":"1100-01-01T00:00:00Z", "relationship_status":"single", "dependants":10, "dob_of_dependants":["1100-01-01T00:00:00Z"], "credit_rating":{ "rating":"OBP", "source":"OBP" }, "credit_limit":{ "currency":"EUR", "amount":"10" }, "highest_education_attained":"Master", "employment_status":"worker", "kyc_status":true, "last_ok_date":"1100-01-01T00:00:00Z", "title":"Dr.", "branch_id":"DERBY6", "name_suffix":"Sr"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: $directLoginHeaderName token="your-token-from-direct-login" - | - |### 3) List customers for the user - | - |Action: - | - | GET $getObpApiRoot/v4.0.0/users/current/customers - | - |Body: - | - | Leave empty! - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 4) Create user customer link - | - |Requires CanCreateCustomer and CanCreateUserCustomerLink roles - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/user_customer_links - | - |Body: - | - | { "user_customer_link_id":"String", "customer_id":"customer-id-from-step-2", "user_id":"user-id-from-step-1", "date_inserted":"2018-03-22T00:08:00Z", "is_active":true } - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 5) Create account - | - |Requires CanCreateAccount role - | - |Action: - | - | PUT $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/ACCOUNT_ID - | - |Body: - | - | { "user_id":"userid-from-step-1", "label":"My Account", "product_code":"AC", "balance":{ "currency":"EUR", "amount":"10" }, "branch_id":"DERBY6", "account_routing":{ "scheme":"AccountNumber", "address":"4930396" }, "account_attributes":[{ "product_code":"saving1", "account_attribute_id":"613c83ea-80f9-4560-8404-b9cd4ec42a7f", "name":"OVERDRAFT_START_DATE", "type":"DATE_WITH_DAY", "value":"2012-04-23" }]} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 6) List accounts - | - |Action: - | - | GET $getObpApiRoot/v4.0.0/my/banks/BANK_ID/accounts/account-id-from-step-5/account - | - |Body: - | - | Leave empty! - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 7) Create card - | - |Requires CanCreateCardsForBank role - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/management/banks/BANK_ID/cards - | - |Body: - | - | { "card_number":"364435172576215", "card_type":"Credit", "name_on_card":"SusanSmith", "issue_number":"1", "serial_number":"1324234", "valid_from_date":"2017-09-19T00:00:00Z", "expires_date":"2017-09-19T00:00:00Z", "enabled":true, "technology":"technology1", "networks":["network1","network2"], "allows":["credit","debit"], "account_id":"account_id from step 5", "replacement":{ "requested_date":"2017-09-19T00:00:00Z", "reason_requested":"RENEW" }, "pin_reset":[{ "requested_date":"2017-09-19T00:00:00Z", "reason_requested":"FORGOT" },{ "requested_date":"2020-01-18T16:39:23Z", "reason_requested":"GOOD_SECURITY_PRACTICE" }], "collected":"2017-09-19T00:00:00Z", "posted":"2017-09-19T00:00:00Z", "customer_id":"customer_id from step 2"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct0login" - | - |### 8) List cards - | - |Action: - | - | GET $getObpApiRoot/v3.0.0/cards - | - |Body: - | - | Leave empty! - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct0login" - | - """) - - glossaryItems += GlossaryItem( - title = "Scenario 2: Create a Public Account", - description = - s""" - |### 1) Create account - | - |Create an account as described in Step 5 of section [Onboarding a user](#Onboarding-a-user) - | - |### 2) Create a view - | - |Action: - | - | POST $getObpApiRoot/v3.0.0/banks/BANK_ID/accounts/your-account-id-from-step-1/views - | - |Body: - | - | { "name":"_test", "description":"This view is for family", "metadata_view":"_test", "is_public":true, "which_alias_to_use":"family", "hide_metadata_if_alias_used":false, "allowed_actions":[$CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT,$CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT,$CAN_SEE_TRANSACTION_METADATA,,$CAN_SEE_TRANSACTION_AMOUNT,$CAN_SEE_TRANSACTION_TYPE,$CAN_SEE_TRANSACTION_CURRENCY,$CAN_SEE_TRANSACTION_START_DATE,$CAN_SEE_TRANSACTION_FINISH_DATE,$CAN_SEE_TRANSACTION_BALANCE,$CAN_SEE_COMMENTS,$CAN_SEE_TAGS,$CAN_SEE_IMAGES,$CAN_SEE_BANK_ACCOUNT_OWNERS,$CAN_SEE_BANK_ACCOUNT_TYPE,$CAN_SEE_BANK_ACCOUNT_BALANCE,$CAN_SEE_BANK_ACCOUNT_CURRENCY,$CAN_SEE_BANK_ACCOUNT_LABEL,$CAN_SEE_BANK_ACCOUNT_NATIONAL_IDENTIFIER,$CAN_SEE_BANK_ACCOUNT_SWIFT_BIC,$CAN_SEE_BANK_ACCOUNT_IBAN,$CAN_SEE_BANK_ACCOUNT_NUMBER,$CAN_SEE_BANK_ACCOUNT_BANK_NAME,$CAN_SEE_OTHER_ACCOUNT_NATIONAL_IDENTIFIER,$CAN_SEE_OTHER_ACCOUNT_SWIFT_BIC,$CAN_SEE_OTHER_ACCOUNT_IBAN,$CAN_SEE_OTHER_ACCOUNT_BANK_NAME,$CAN_SEE_OTHER_ACCOUNT_NUMBER,$CAN_SEE_OTHER_ACCOUNT_METADATA,$CAN_SEE_OTHER_ACCOUNT_KIND,$CAN_SEE_MORE_INFO,$CAN_SEE_URL,$CAN_SEE_IMAGE_URL,$CAN_SEE_OPEN_CORPORATES_URL,$CAN_SEE_CORPORATE_LOCATION,$CAN_SEE_PHYSICAL_LOCATION,$CAN_SEE_PUBLIC_ALIAS,$CAN_SEE_PRIVATE_ALIAS,$CAN_ADD_MORE_INFO,$CAN_ADD_URL,$CAN_ADD_IMAGE_URL,$CAN_ADD_OPEN_CORPORATES_URL,$CAN_ADD_CORPORATE_LOCATION,$CAN_ADD_PHYSICAL_LOCATION,$CAN_ADD_PUBLIC_ALIAS,$CAN_ADD_PRIVATE_ALIAS,$CAN_DELETE_CORPORATE_LOCATION,$CAN_DELETE_PHYSICAL_LOCATION,$CAN_ADD_COMMENT,$CAN_DELETE_COMMENT,$CAN_ADD_TAG,$CAN_DELETE_TAG,$CAN_ADD_IMAGE,$CAN_DELETE_IMAGE,$CAN_ADD_WHERE_TAG,$CAN_SEE_WHERE_TAG,$CAN_DELETE_WHERE_TAG,$CAN_SEE_BANK_ROUTING_SCHEME,$CAN_SEE_BANK_ROUTING_ADDRESS,$CAN_SEE_BANK_ACCOUNT_ROUTING_SCHEME,$CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS,$CAN_SEE_OTHER_BANK_ROUTING_SCHEME,$CAN_SEE_OTHER_BANK_ROUTING_ADDRESS,$CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME,$CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS,$CAN_QUERY_AVAILABLE_FUNDS,$CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT,$CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT,$CAN_SEE_BANK_ACCOUNT_CREDIT_LIMIT,$CAN_CREATE_DIRECT_DEBIT,$CAN_CREATE_STANDING_ORDER]} | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - |### 3) Grant user access to view - | - |Action: - | - | POST $getObpApiRoot/v3.0.0/banks/BANK_ID/accounts/your-account-id-from-step-1/permissions/PROVIDER/PROVIDER_ID/views/view-id-from-step-2 - | - |Body: - | - | { "json_string":"{}"} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - """) - - glossaryItems += GlossaryItem( - title = "Scenario 3: Create counterparty and make payment", - description = - s""" - |### 1) Create counterparty - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/account-id-from-account-creation/VIEW_ID/counterparties - | - |Body: - | - | { "name":"CounterpartyName", "description":"My landlord", "other_account_routing_scheme":"accountNumber", "other_account_routing_address":"7987987-2348987-234234", "other_account_secondary_routing_scheme":"IBAN", "other_account_secondary_routing_address":"DE89370400440532013000", "other_bank_routing_scheme":"bankCode", "other_bank_routing_address":"10", "other_branch_routing_scheme":"branchNumber", "other_branch_routing_address":"10010", "is_beneficiary":true, "bespoke":[{ "key":"englishName", "value":"english Name" }]} | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - |### 2) Make payment by SEPA - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/SEPA/transaction-requests - | - |Body: - | - | { "value":{ "currency":"EUR", "amount":"10" }, "to":{ "iban":"123" }, "description":"This is a SEPA Transaction Request", "charge_policy":"SHARED"} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" | - | - | - |### 3) Make payment by COUNTERPARTY - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/ACCOUNT_ID/VIEW_ID/transaction-request-types/COUNTERPARTY/transaction-requests - | - |Body: - | - | { "to":{ "counterparty_id":"counterparty-id-from-step-1" }, "value":{ "currency":"EUR", "amount":"10" }, "description":"A description for the transaction to the counterparty", "charge_policy":"SHARED"} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - | - """) - - glossaryItems += GlossaryItem( - title = "Scenario 4: Grant account access to another User", - description = - s""" - |### 1) Create account - | - |Create an account as described in Step 5 of section [Onboarding a user](#Onboarding-a-user) - | - |### 2) Create a view (private) - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/your-account-id-from-step-1/views - | - |Body: - | - | { "name":"_test", "description":"good", "is_public":false, "which_alias_to_use":"accountant", "hide_metadata_if_alias_used":false, "allowed_actions": [$CAN_SEE_TRANSACTION_THIS_BANK_ACCOUNT,$CAN_SEE_TRANSACTION_OTHER_BANK_ACCOUNT,$CAN_SEE_TRANSACTION_METADATA,,$CAN_SEE_TRANSACTION_AMOUNT,$CAN_SEE_TRANSACTION_TYPE,$CAN_SEE_TRANSACTION_CURRENCY,$CAN_SEE_TRANSACTION_START_DATE,$CAN_SEE_TRANSACTION_FINISH_DATE,$CAN_SEE_TRANSACTION_BALANCE,$CAN_SEE_COMMENTS,$CAN_SEE_TAGS,$CAN_SEE_IMAGES,$CAN_SEE_BANK_ACCOUNT_OWNERS,$CAN_SEE_BANK_ACCOUNT_TYPE,$CAN_SEE_BANK_ACCOUNT_BALANCE,$CAN_SEE_BANK_ACCOUNT_CURRENCY,$CAN_SEE_BANK_ACCOUNT_LABEL,$CAN_SEE_BANK_ACCOUNT_NATIONAL_IDENTIFIER,$CAN_SEE_BANK_ACCOUNT_SWIFT_BIC,$CAN_SEE_BANK_ACCOUNT_IBAN,$CAN_SEE_BANK_ACCOUNT_NUMBER,$CAN_SEE_BANK_ACCOUNT_BANK_NAME,$CAN_SEE_OTHER_ACCOUNT_NATIONAL_IDENTIFIER,$CAN_SEE_OTHER_ACCOUNT_SWIFT_BIC,$CAN_SEE_OTHER_ACCOUNT_IBAN,$CAN_SEE_OTHER_ACCOUNT_BANK_NAME,$CAN_SEE_OTHER_ACCOUNT_NUMBER,$CAN_SEE_OTHER_ACCOUNT_METADATA,$CAN_SEE_OTHER_ACCOUNT_KIND,$CAN_SEE_MORE_INFO,$CAN_SEE_URL,$CAN_SEE_IMAGE_URL,$CAN_SEE_OPEN_CORPORATES_URL,$CAN_SEE_CORPORATE_LOCATION,$CAN_SEE_PHYSICAL_LOCATION,$CAN_SEE_PUBLIC_ALIAS,$CAN_SEE_PRIVATE_ALIAS,$CAN_ADD_MORE_INFO,$CAN_ADD_URL,$CAN_ADD_IMAGE_URL,$CAN_ADD_OPEN_CORPORATES_URL,$CAN_ADD_CORPORATE_LOCATION,$CAN_ADD_PHYSICAL_LOCATION,$CAN_ADD_PUBLIC_ALIAS,$CAN_ADD_PRIVATE_ALIAS,$CAN_DELETE_CORPORATE_LOCATION,$CAN_DELETE_PHYSICAL_LOCATION,$CAN_ADD_COMMENT,$CAN_DELETE_COMMENT,$CAN_ADD_TAG,$CAN_DELETE_TAG,$CAN_ADD_IMAGE,$CAN_DELETE_IMAGE,$CAN_ADD_WHERE_TAG,$CAN_SEE_WHERE_TAG,$CAN_DELETE_WHERE_TAG,$CAN_SEE_BANK_ROUTING_SCHEME,$CAN_SEE_BANK_ROUTING_ADDRESS,$CAN_SEE_BANK_ACCOUNT_ROUTING_SCHEME,$CAN_SEE_BANK_ACCOUNT_ROUTING_ADDRESS,$CAN_SEE_OTHER_BANK_ROUTING_SCHEME,$CAN_SEE_OTHER_BANK_ROUTING_ADDRESS,$CAN_SEE_OTHER_ACCOUNT_ROUTING_SCHEME,$CAN_SEE_OTHER_ACCOUNT_ROUTING_ADDRESS,$CAN_QUERY_AVAILABLE_FUNDS,$CAN_ADD_TRANSACTION_REQUEST_TO_OWN_ACCOUNT,$CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT,$CAN_SEE_BANK_ACCOUNT_CREDIT_LIMIT,$CAN_CREATE_DIRECT_DEBIT,$CAN_CREATE_STANDING_ORDER]} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - |### 3) Get User (Current) - | - |Action: - | - | GET $getObpApiRoot/v4.0.0/users/current - | - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - |### 4) Grant user access to himself - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/your-account-id-from-step-1/account-access/grant - | - |Body: - | - | { "user_id":"your-user-id-from-step3", "view":{ "view_id":"_test", "is_system":false }} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - |### 5) Grant user access to view to another user - | - |Action: - | - | POST $getObpApiRoot/v4.0.0/banks/BANK_ID/accounts/your-account-id-from-step-1/account-access/grant - | - |Body: - | - | { "user_id":"another-user-id", "view":{ "view_id":"_test", "is_system":false }} - | - | Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token" - | - | - """) - - glossaryItems += GlossaryItem( - title = "Scenario 5: Onboarding a User using Auth Context ", - description = - s""" - |### 1) Create a user - | - |Action: - | - | POST $getObpApiRoot/v3.0.0/users - | - |Body: - | - | { "email":"ellie@example.com", "username":"ellie", "password":"P@55w0RD123", "first_name":"Ellie", "last_name":"Williams"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |Please note the user_id - | - |### 2) Create User Auth Context - | - | These key value pairs will be propagated over connector to adapter and to bank. So the bank can use these key value paris - | to map obp user to real bank customer. - | - |Action: - | - | POST $getObpApiRoot/obp/v4.0.0/users/USER_ID/auth-context - | - |Body: - | - | { "key":"CUSTOMER_NUMBER", "value":"78987432"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 3) Create customer - | - |Requires CanCreateCustomer or canCreateCustomerAtAnyBank roles - | - |Action: - | - | POST $getObpApiRoot/v3.1.0/banks/BANK_ID/customers - | - |Body: - | - | { "user_id":"user-id-from-step-1", "customer_number":"687687678", "legal_name":"NONE", "mobile_phone_number":"+44 07972 444 876", "email":"person@example.com", "face_image":{ "url":"www.openbankproject", "date":"2013-01-22T00:08:00Z" }, "date_of_birth":"2013-01-22T00:08:00Z", "relationship_status":"Single", "dependants":5, "dob_of_dependants":["2013-01-22T00:08:00Z"], "credit_rating":{ "rating":"OBP", "source":"OBP" }, "credit_limit":{ "currency":"EUR", "amount":"10" }, "highest_education_attained":"Bachelor’s Degree", "employment_status":"Employed", "kyc_status":true, "last_ok_date":"2013-01-22T00:08:00Z"} - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - |### 4) Get Customers for Current User - | - |Action: - | - | GET $getObpApiRoot/v3.0.0/users/current/customers - | - |Body: - | - | Leave empty! - | - |Headers: - | - | Content-Type: application/json - | - | Authorization: DirectLogin token="your-token-from-direct-login" - | - - """) - - glossaryItems += GlossaryItem( - title = "Scenario 6: Update credit score based on transaction and device data.", - description = - s""" - |### 1) Use Case - | - | As an App developer you want to give a Credit Rating to a Customer based on their Transactions and also device data. - | -|### 2) Solution Overview: -| -|In general your application will need to: -| 1) Loop through Customers -| 2) For each Customer, get its related Users and associated device data -| 3) For each Customer or User get the related accounts -| 4) For each Account, get its Transaction data -| 5) Update the Credit Rating and Credit Rating Readiness score of the Customer. -| -|### 3) Authentication and Authorisation -| -|Depending on the configuration of this OBP instance, and the endpoints being called, the Consumer / Client may need Scopes and / or the User may need Entitlements and Account Access. -|To get started, we suggest requesting Entitlements via the API Explorer. -| -|### 4) Endpoints -| -|* Get Customers (minimal). Click [here](/index?version=OBPv4.0.0&operation_id=OBPv4_0_0-getCustomersMinimalAtAnyBank¤tTag=Customer#OBPv4_0_0-getCustomersMinimalAtAnyBank) for documentation. -| -|The above endpoints return a list of bank_id and customer_id which can be used for getting correlated Users and their attributes: -| -|* Get Correlated Users for a Customer. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv4_0_0-getCustomersMinimalAtAnyBank¤tTag=Customer#OBPv4_0_0-getCorrelatedUsersInfoByCustomerId) for documentation. -| -|Then get Accounts related to a Customer: -| -|* GET Accounts Minimal for a Customer. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv4_0_0-getAccountsMinimalByCustomerId¤tTag=Account#OBPv4_0_0-getAccountsMinimalByCustomerId) for documentation. -| -|Once you have the list of bank_ids and account_ids, you can get their transactions which include tags for each transaction: -| -|* GET Firehose Transactions. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv3_0_0-getFirehoseTransactionsForBankAccount¤tTag=Transaction#OBPv3_0_0-getFirehoseTransactionsForBankAccount) for documentation. -| -|After your processing of the data you can update the Credit Score: -| -|* Update Credit Score. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv3_1_0-updateCustomerCreditRatingAndSource¤tTag=Customer#OBPv3_1_0-updateCustomerCreditRatingAndSource) for documentation. -| -|You can create a CREDIT_SCORE_READINESS attribute using the following endpoint: -| -|* Create Customer Attribute. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv3_1_0-updateCustomerCreditRatingAndSource¤tTag=Customer#OBPv4_0_0-createCustomerAttribute) for documentation. -| -|And update it here: -| -|* Update Customer Attribute. Click [here](/index?version=OBPv4.0.0&operation_id=OBPv3_1_0-updateCustomerCreditRatingAndSource¤tTag=Customer#OBPv4_0_0-updateCustomerAttribute) for documentation. -| -|""") - - glossaryItems += GlossaryItem( - title = "Scenario 7: Onboarding a User with multiple User Auth Context records", - description = - s""" - |### 1) Assuming a User is registered. - | - |The User can authenticate using OAuth, OIDC, Direct Login etc. - | - |### 2) Create a first User Auth Context record e.g. ACCOUNT_NUMBER - | - | The setting of the first User Auth Context record for a User, typically involves sending an SMS to the User. - | The phone number used for the SMS is retrieved from the bank's Core Banking System via an Account Number to Phone Number lookup. - | If this step succeeds we can be reasonably confident that the User who initiated it has access to a SIM card that can use the Phone Number linked to the Bank Account on the Core Banking System. - | - |Action: Create User Auth Context Update Request - | - | POST $getObpApiRoot/obp/v5.0.0/banks/BANK_ID/users/current/auth-context-updates/SMS - | - |Body: - | - | { "key":"ACCOUNT_NUMBER", "value":"78987432"} - | - |Headers: - | - | Content-Type: application/json - | - | $directLoginHeaderName: token="your-token-from-direct-login" - | - | When customer get the the challenge answer from SMS, then need to call `Answer Auth Context Update Challenge` to varify the challenge. - | Then the customer create the 1st `User Auth Context` successfully. - | - | - |Action: Answer Auth Context Update Challenge - | - | POST $getObpApiRoot/obp/v5.0.0/banks/BANK_ID/users/current/auth-context-updates/AUTH_CONTEXT_UPDATE_ID/challenge - | - |Body: - | - | { "answer": "12345678"} - | - |Headers: - | - | Content-Type: application/json - | - | $directLoginHeaderName: token="your-token-from-direct-login" - | -|### 3) Create a second User Auth Context record e.g. SMALL_PAYMENT_VERIFIED -| -| Once the first User Auth Context record is set, we can require the App to set a second record which builds on the information of the first. -| -|Action: Create User Auth Context Update Request -| -| POST $getObpApiRoot/obp/v5.0.0/banks/BANK_ID/users/current/auth-context-updates/SMS -| -|Body: -| -| { "key":"SMALL_PAYMENT_VERIFIED", "value":"78987432"} -| -|Headers: -| -| Content-Type: application/json -| -| $directLoginHeaderName: token="your-token-from-direct-login" -| -| -| -|Following `Create User Auth Context Update Request` request the API will send a small payment with a random code from the Users bank account specified in the SMALL_PAYMENT_VERIFIED key value. -| -|In order to answer the challenge, the User must have access to the online banking statement (or some other App that already can read transactions in realtime) so they can read the code in the description of the payment. -| -| -|Then Action:Answer Auth Context Update Challenge -| -| POST $getObpApiRoot/obp/v5.0.0/banks/BANK_ID/users/current/auth-context-updates/AUTH_CONTEXT_UPDATE_ID/challenge -| -|Body: -| -| { "answer": "12345678"} -| -|Headers: -| -| Content-Type: application/json -| -| $directLoginHeaderName: token="your-token-from-direct-login" -| -| Note! The above logic must be encoded in a dynamic connector method for the OBP internal function validateUserAuthContextUpdateRequest which is used by the endpoint Create User Auth Context Update Request See the next step. -| -|### 4) Create or Update Connector Method for validateUserAuthContextUpdateRequest -| -| Using this endpoint you can modify the Scala logic -| -|Action: -| -| POST $getObpApiRoot/obp/v4.0.0/management/connector-methods -| -|Body: -| -| { "method_name":"validateUserAuthContextUpdateRequest", "method_body":"%20%20%20%20%20%20Future.successful%28%0A%20%20%20%20%20%20%20%20Full%28%28BankCommons%28%0A%20%20%20%20%20%20%20%20%20%20BankId%28%22Hello%20bank%20id%22%29%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%221%22%2C%0A%20%20%20%20%20%20%20%20%20%20%228%22%0A%20%20%20%20%20%20%20%20%29%2C%20None%29%29%0A%20%20%20%20%20%20%29"} -| -|Headers: -| -| Content-Type: application/json -| -| $directLoginHeaderName: token="your-token-from-direct-login" -| -|### 5) Allow automated access to the App with Create Consent (SMS) -| -| -| Following the creation of User Auth Context records, OBP will create the relevant Account Access Views which allows the User to access their account(s). -| The App can then request an OBP consent which can be used as a bearer token and have automated access to the accounts. -| The Consent can be deleted at any time by the User. -| -| The Consent can have access to everything the User has access to, or a subset of this. -| -|Action: -| -| POST $getObpApiRoot/obp/v4.0.0/banks/BANK_ID/my/consents/SMS -| -|Body: -| -| { "everything":false, "views":[{ "bank_id":"gh.29.uk", "account_id":"8ca8a7e4-6d02-40e3-a129-0b2bf89de9f0", "view_id":${Constant.SYSTEM_OWNER_VIEW_ID}], "entitlements":[{ "bank_id":"gh.29.uk", "role_name":"CanGetCustomersAtOneBank" }], "consumer_id":"7uy8a7e4-6d02-40e3-a129-0b2bf89de8uh", "phone_number":"+44 07972 444 876", "valid_from":"2022-04-29T10:40:03Z", "time_to_live":3600} -| -|Headers: -| -| Content-Type: application/json -| -| $directLoginHeaderName: token="your-token-from-direct-login" -| -|![OBP User Auth Context, Views, Consents 2022](https://user-images.githubusercontent.com/485218/165982767-f656c965-089b-46de-a5e6-9f05b14db182.png) -| -| - """) - - - glossaryItems += GlossaryItem( - title = "KYC (Know Your Customer)", - description = - s""" -|KYC is the process by which the Bank can be assured that the customer is who they say they are. -| -|OBP provides a [number of endpoints](/index?ignoredefcat=true&tags=KYC) that KYC Apps can interact with in order to get and store relevant data and update the KYC status of a Customer. -| -|For instance: -| -| - |1) Use KYC documents to add information about passports, ID cards, driving licenses including: - | -| * customer_number -| * type (of document) -| * number (of document) -| * issue_date -| * issue_place -| * expiry_date -| -| - |For more info see [here](/index?ignoredefcat=true&tags=KYC#OBPv2_0_0-addKycDocument). -| - | - |2) Use KYC check to record a check of the customer via phone call, in person meeting or PEP database search. -| -|KYC Checks store: -| -| * customer_number -| * date -| * how (FACE_TO_FACE_MEETING, PHONE_INTERVIEW, PEP_SEARCH) -| * staff_user_id (who recorded the record) -| * staff_name (who recorded the record) -| * satisfied (True/False) -| * comments -| - |For more info see [here](/index?ignoredefcat=true&tags=KYC#OBPv2_0_0-addKycCheck). -| - |3) Use KYC Media to add media (image or video etc.) related to: -| - |A) the documents listed in KYC documents -| - |B) media that identifies the user e.g. a video ident. -| - |C) media that relates to a KYC check -| - | - |KYC Media stores: - | -| * customer_number -| * type -| * url -| * date -| * relates_to_kyc_document_id -| * relates_to_kyc_check_id -| -| - |For more information see [here](/index?ignoredefcat=true&tags=KYC#OBPv2_0_0-addKycMedia). -| - | - |4) Use KYC status to update the KYC status of a customer following a meeting or using one of the above calls. -| -|KYC status stores: -| -| * customer_number -| * ok (True/False) -| * date -| - | - |For more information see [here](/index?ignoredefcat=true&tags=KYC#OBPv2_0_0-addKycStatus). - | - | - |5) Use other Customer related endpoints shown [here](/index?ignoredefcat=true&tags=KYC) to check for known Addresses, contact details, Tax Residences etc. -| - """) - - glossaryItems += GlossaryItem( - title = "Authentication: OAuth 1.0a", - description = - s""" - |The following steps will explain how to connect an instance of the Open Bank Project OAuth Server 1.0a. This authentication mechanism is necessary so a third party application can consume the Open Bank project API securely. - | - |The following documentation is based on the OAuth 1.0a specification so if you need more details you can refer to it. - | - |Before starting to interact with the API, third party applications needs to get OAuth keys (consumer key and secret key). You can register your application here to get those keys for interacting with real data. If you want to use the sandbox before handling real data, please register your application here - | - |### 1) Obtain a request token - | - |To start a sign in flow, the application must obtain a request token by sending a signed message to : - | - | POST $getServerUrl/oauth/initiate - | - |* oauth_callback: an absolute URI back to which the server will redirect the resource owner (user) when Authorization step is completed. If the application is unable to receive callbacks the parameter value MUST be set to “oob” (case sensitive), to indicate an out-of-band configuration. - | - |* oauth_consumer_key : The identifier portion of the client credentials (consumer key) which is obtained after application registration. - | - |* oauth_nonce : A nonce is a random string, uniquely generated by the client to allow the server to verify that a request has never been made before. The nonce value MUST be unique across all requests with the same timestamp, application credentials, and token combinations. - | - |* oauth_signature : the result of signing the request. Explained in detail here. - | - |* oauth_signature_method : The name of the signature method that will be used by the application to sign the request, as defined in OAuth protocol. The Open Bank Project OAuth server support "SHA1" and "SHA256" so the parameter MUST be set to “HMAC-SHA1" or “HMAC-SHA256” - | - |* oauth_timestamp : The timestamp value MUST be a positive integer and is expressed in the number of seconds since January 1, 1970 00:00:00 GMT. - | - |* oauth_version : OPTIONAL. If present, MUST be set to "1.0". Provides the version of the authentication process as defined in the OAuth 1.0 protocol specification. - | - |Example: - | - | POST /oauth/initiate HTTP/1.1 - | - | Host: $getServerUrl - | - | Authorization: OAuth - | - | oauth_callback="http%3A%2F%2Fprinter.example.com%2Fready", - | - | oauth_consumer_key="cChZNFj6T5R0TigYB9yd1w", - | - | oauth_nonce="ea9ec8429b68d6b77cd5600adbbb0456", - | - | oauth_signature="F1Li3tvehgcraF8DMJ7OyxO4w9Y%3D", - | - | oauth_signature_method="HMAC-SHA256", - | - | oauth_timestamp="1318467427", - | - | oauth_version="1.0" - | - |important: We will explain below in the "signature" section how to calculate the value of the "oauth_signature" field. - | - |Note : line breaks are for display purposes only, the application MUST send the parameters on one line and the only separator between the parameters is a coma “,”. - | - |The server validates the request and replies with a set of temporary credentials in the body of the HTTP response. - | - |Example (line breaks are for display purposes only) : - | - | HTTP/1.1 200 OK - | Content-Type: application/x-www-form-urlencoded - | oauth_token=hh5s93j4hdidpola&oauth_token_secret=hdhd0244k9j7ao03&oauth_callback_confirmed=true - | - |The application should examine the HTTP status of the response. Any value other than 200 indicates a failure. The body of the response will contain the oauth_token, oauth_token_secret, and oauth_callback_confirmed parameters. The application should verify that oauth_callback_confirmed is true and store the other two values for the next steps. - | - |### 2) Redirect the user - | - |The next step is to direct the user to Open Bank Project so that he may complete the authentication. - | - |Direct the user to : - | - | GET oauth/authorize - | - |and the request token obtained in step 1 should be passed as the oauth_token parameter. - | - |The most seamless way for a website to implement this would be to issue a HTTP 302 redirect as the response to the original request. Mobile and desktop applications should open a new browser window or direct to the URL via an embedded web view. - | - |Example : - | - | $getServerUrl/oauth/authorize?oauth_token=NPcudxy0yU5T3tBzho7iCotZ3cnetKwcTIRlX0iwRl0 - | - |Upon a successful authentication, the callback URL would receive a request containing the oauth_token and oauth_verifier parameters. The application should verify that the token matches the request token received in step 1. - | - |If the callback URL was not specified (oob) than the verifier will be shown in the page and the user has to enter it into the application manually. - | - |### 3) Convert the request token to an access token - | - |To convert the request token into a usable access token, the application must make a: - | - | POST $getServerUrl/oauth/token - | - |request containing the oauth_verifier value obtained in step 2. The request token is also passed as oauth_token parameter of the header. - | - |Note : The oauth_callback_url parameter is not necessary any more. - | - |Example : - | - | POST /oauth/token HTTP/1.1 - | - | Host: $getServerUrl - | - | Authorization: OAuth - | - | oauth_verifier="9312832", - | - | oauth_token=”aze2342352aze”, - | - | oauth_consumer_key="cChZNFj6T5R0TigYB9yd1w", - | - | oauth_nonce="ea9ec8429b68d6b77cd5600adbbb0456", - | - | oauth_signature="F1Li3tvehgcraF8DMJ7OyxO4w9Y%3D", - | - | oauth_signature_method="HMAC-SHA256", - | - | oauth_timestamp="1318467427", - | - | oauth_version="1.0" - | - |Like the step 1, a successful response contains the oauth_token & oauth_token_secret and they should be stored and used for future authenticated requests to the OBP API. - | - |The application can now use the access token to access protected resources. - | - |### 4) Access protected resources - | - |Once the application has an a access token and secret token, it can access protected resources. The request is the same as in step 3 except the oauth_verifer which MUST not be included in the header. - | - |Please see the API documentation for more details on how to access protected resources. - | - |### Recommended OAuth 1.0 libraries: - | - |If you want to use a OAuth library to handle the OAuth process for your application, we have successfully tested these ones: - | - |* JAVA: - | - | [signpost](http://code.google.com/p/oauth-signpost/). Warning any version below 1.2.1.2 probably will not work. Version 1.2 which is the current Maven version seems to cause problems. - | - |* PHP: - | - | [OAuth Consumer And Server Library] (https://code.google.com/p/oauth-php/) - | - |* Scala: - | - | [Dispatch] (http://dispatch.databinder.net/Dispatch.html) - | - |* OBP SDKs / examples of client code with OAuth: - | - | [OBP SDKs](https://github.com/OpenBankProject/OBP-API/wiki/OAuth-Client-SDKS) - | - |### Examples : - | - |To show the OAuth integration in concrete examples, please check out these projects listed here: - |[Hello-OBP-OAuth1.0a-LANGUAGE/PLATFORM](https://github.com/OpenBankProject) - | - |### Signature : - | - |According to the [section-3.4](http://tools.ietf.org/html/rfc5849#section-3.4) in the OAuth 1.0 protocol specification the signature computation is done following theses steps : - | - | - |a) Signature Base String : - | - |The signature base string is a consistent, reproducible concatenation of several of the HTTP request elements into a single string. The string is used as an input to the signature methods. - | - |The signature base string includes the following components of the HTTP request: - | - |* The HTTP request method (e.g., "GET", "POST", etc.). - | - |* The authority as declared by the HTTP "Host" request header field. - | - |* The path and query components of the request resource URI. - | - |* he protocol parameters excluding the "oauth_signature". - | - |The signature base string does not cover the entire HTTP request. Most notably, it does not include the entity-body in most requests, nor does it include most HTTP entity-headers. - | - |The signature base string is constructed by concatenating together, in order, the following HTTP request elements: - | - |1. The HTTP request method in uppercase. For example: "HEAD", "GET", "POST", etc. If the request uses a custom HTTP method, it MUST be encoded (Section 3.6). - | - |2. An "&" character (ASCII code 38). - | - |3. The base string URI from Section 3.4.1.2, after being encoded (Section 3.6). - | - |4. An "&" character (ASCII code 38). - | - |5. The request parameters as normalized in Section 3.4.1.3.2, after being encoded (Section 3.6). - | - |Explained shortly below the example. - | - |Example: - | - | POST /oauth/token HTTP/1.1 - | Host: $getServerUrl - | Content-Type: application/x-www-form-urlencoded - | Authorization: OAuth - | oauth_consumer_key="91919", - | oauth_token="OGESD9MrWQEGPXOyPjHCRrCw7BPelWJjnomibV6bePU", - | oauth_signature_method="HMAC-SHA256", - | oauth_timestamp="1340878170", - | oauth_nonce="DFXOQFZVK8K46KDR11", - | oauth_signature="bYT5CMsGcbgUdFHObYMEfcx6bsw%3D" - | - |Is represented by the following signature base string (line breaks are for display purposes only): - | - | POST&https%3A%2F%2F$getServerUrl&oauth_consumer_key%3D91919%26oauth_nonce%3DDFXOQFZVK8K46KDR11%26oauth_signature_method%3Dhmac-sha256%26oauth_timestamp%3D1340878170%26oauth_token%3DOGESD9MrWQEGPXOyPjHCRrCw7BPelWJjnomibV6bePU%26oauth_verifier%3DT0dXUDBZR09LUVlGTU9NSlhIUUc%26oauth_version%3D1 - | - |The request parameters normalization : - | - |1. The name and value of each parameter are encoded Section 3.6. - | - |2. The parameters are sorted by name, using ascending byte value ordering. - | - |3. The name of each parameter is concatenated to its corresponding value using an "=" character (ASCII code 61) as a separator, even if the value is empty. - | - |4. The sorted name/value pairs are concatenated together into a single string by using an "&" character (ASCII code 38) as separator. - | - |B) Signing the request : - | - |The Open Bank Project OAuth 1.0 implementation uses the “HMAC-SHA1” and “HMAC-SHA256” as signing methods. The key to sign the base string is the concatenation of the consumer secret and the token secret with the “&” character in the middle like this: oauth_consumer_secret&oauth_token_secret, in the first step the application does not have yet a token so it will be an empty string. - | - |The signature that results from the signature process MUST be encoded in base 64 also since the protocol requires encoding all the OAuth parameters. - | - |### Illustration of integration with a bank back-end : - | - |The following link shows how the integration of the OAuth process would be with a bank back-end: [https://github.com/OpenBankProject/OBP-API/wiki/OAuth-Integration-Illustration](https://github.com/OpenBankProject/OBP-API/wiki/OAuth-Integration-Illustration) - | - | - | - """) - - - - val oauth2EnabledMessage : String = if (APIUtil.getPropsAsBoolValue("allow_oauth2_login", false)) - {"OAuth2 is allowed on this instance."} else {"Note: *OAuth2 is NOT allowed on this instance!*"} - - glossaryItems += GlossaryItem( - title = "Authentication: OAuth 2", - description = - s""" - | - |$oauth2EnabledMessage - | - |OAuth2 is an authorization framework that enables applications to obtain limited access to user accounts on an HTTP service, in this case any OBP REST call. It works by delegating user authentication to the service that hosts the user account, and authorizing third-party applications to access the user account. OAuth 2 provides authorization flows for web and desktop applications, and mobile devices. - | - |### OAuth2 Roles - | - |The following is a general introduction to a so called "3 legged OAuth2" flow: - | - |* Resource Owner - |* Client - |* Resource Server - |* Authorization Server - | - |### Resource Owner: User - | - |The resource owner is the user who authorizes an application to access their account. The application's access to the user's account is limited to the "scope" of the authorization granted (e.g. openid). - | - |### Authorization Server: API - | - |The authorization server verifies the identity of the user then issues access tokens to the application. E.g. Hydra - | - |### Resource Server: API - | - |The resource server hosts the protected user resources. E.g. OBP-API - | - |### Client: Application - | - |The client is the application that wants to access the user's resource. In order to do that, it must be authorized by the user, and the authorization must be validated by the Authorization Server: API. - | - |### Authorization Grant - | - |OAuth 2 defines four grant types, each of which is useful in different cases: - | - |* Authorization Code: used with server-side Applications - | - |* Implicit: used with Mobile Apps or Web Applications (applications that run on the user's device) - | - |* Resource Owner Password Credentials: used with trusted Applications, such as those owned by the service itself - | - |* Client Credentials: used with Applications API access - | - |### Step 1: Get your App key - | - |[Sign up]($getServerUrl/user_mgt/sign_up) or [login]($getServerUrl/user_mgt/login) as a developer - | - |Register your App key [HERE](${getConsumerRegistrationUrl()}) - | - |Copy and paste the CLIENT ID (AKA CONSUMER KEY), CLIENT SECRET (AKA CONSUMER SECRET) and REDIRECT_URL for the subsequent steps below. - | - | - |### Step 2: Initiate the OAuth 2.0 / OpenID Connect Flow - | - |Once you have registered your App you should initiate the OAuth2 / OIDC flow using the following URL - | - |${APIUtil.getHydraPublicServerUrl}/oauth2/auth - | - |WITH THE following parameters: - | - |${APIUtil.getHydraPublicServerUrl}/oauth2/auth?client_id=YOUR-CLIENT-ID&response_type=code&state=GENERATED_BY_YOUR_APP&scope=openid+offline+ReadAccountsBasic+ReadAccountsDetail+ReadBalances+ReadTransactionsBasic+ReadTransactionsDebits+ReadTransactionsDetail&redirect_uri=https%3A%2F%2FYOUR-APP.com%2Fmain.html - | - |### Step 3: Exchange the code for an access token - | - |The token endpoint is: - | - |${APIUtil.getHydraPublicServerUrl}/oauth2/token - | - | - |For further information please see [here](https://www.ory.sh/hydra/docs/concepts/login#initiating-the-oauth-20--openid-connect-flow) - | - |In this sandbox, this will cause the following flow: - | - |1) The User is authenticated using OAuth2 / OpenID Connect against the banks authentication system - |2) The User grants consent to the App on the bank's Consent page. - |3) The User grants access to one or more accounts that they own on the bank's Account Selection page - |4) The User is redirected back to the App where they can now see the Accounts they have selected. - | - | - | - | - | - | - | - |An example Consent Testing App (Hola) using this flow can be found [here](https://github.com/OpenBankProject/OBP-Hola) - | - | - | - """) - - - - - - - glossaryItems += GlossaryItem( - title = "OpenID Connect with Google", - description = - s""" -| -|$oauth2EnabledMessage -| -|## OpenID Connect with Google -| - |### Introduction -|Google's OAuth 2.0 APIs can be used for both authentication and authorization. This document describes our OAuth 2.0 implementation for authentication, which conforms to the OpenID Connect specification, and is OpenID Certified. -|For complete documentation please refer to the official doc's page: [OpenID Connect](https://developers.google.com/identity/protocols/OpenIDConnect) -| -|OpenID Connect with Google Image -| - |### Obtain OAuth 2.0 credentials -|Please refer to the official doc's page: [OpenID Connect](https://developers.google.com/identity/protocols/OpenIDConnect) -|In case you use Google's [OAuth 2.0 Playground](https://developers.google.com/oauthplayground/) -|example of an response is shown below: -|{ -| "access_token": "ya29.a0Adw1xeVr_WAYaipiH_6QKCFjIFsnZxW7kbxA8a2RU_uy5meEufErwPDLSHMga8IEQghNSX2GbkOfZUQb6j_fMGHL_HaW3RoULZq5AayUdEjI9bC4TMe-Nd4cZR17C0Rg3GLNzuHTXXe05UyMmNODZ6Up0aXZBBTHl-4", -| "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6ImE1NDFkNmVmMDIyZDc3YTIzMThmN2RkNjU3ZjI3NzkzMjAzYmVkNGEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2FjY291bnRzLmdvb2dsZS5jb20iLCJhenAiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJhdWQiOiI0MDc0MDg3MTgxOTIuYXBwcy5nb29nbGV1c2VyY29udGVudC5jb20iLCJzdWIiOiIxMTM5NjY4NTQyNDU3ODA4OTI5NTkiLCJlbWFpbCI6Im1hcmtvLm1pbGljLnNyYmlqYUBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiYXRfaGFzaCI6ImtrcENIWUFaSTZVOFZiZEJsRHNfX1EiLCJuYW1lIjoiTWFya28gTWlsacSHIiwicGljdHVyZSI6Imh0dHBzOi8vbGg1Lmdvb2dsZXVzZXJjb250ZW50LmNvbS8tWGQ0NGhuSjZURG8vQUFBQUFBQUFBQUkvQUFBQUFBQUFBQUEvQUtGMDVuQ1pyaTdmWHdkUUhuZUNwN09pTVh1WGlOMkpVQS9zOTYtYy9waG90by5qcGciLCJnaXZlbl9uYW1lIjoiTWFya28iLCJmYW1pbHlfbmFtZSI6Ik1pbGnEhyIsImxvY2FsZSI6ImVuIiwiaWF0IjoxNTg0NTIxNDU3LCJleHAiOjE1ODQ1MjUwNTd9.LgwY-OhltYS2p91l2Lt4u5lUR5blR7L8097J0ZpK0GyxWxOlnhSouk9MRMmyfSGuYfWKBtdSUy3Esaphk2f7wpLS-wBx3KJpvrXhgbsyemt9s7eu5bAdHaCteO8MqHPjbU9tych8iH0tA1MSL_tVZ73hy56rS2irzIC33wYDoBf8C5nEOd2uzQ758ydK5QvvdFwRgkLhKDS8vq2qVJTWgtk9VVd5JwJ5OfiVimXfGUzNJmGreEJKj14iUj-78REybpUbI9mGevRhjLPhs51Uc9j-SsdRMymVbVhVxlbsWAPTpjLAJnOodeHzAvmKFkOUfahQHHctx4fl8V3PVYf1aA", -| "expires_in": 3599, -| "token_type": "Bearer", -| "scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email openid", -| "refresh_token": "1//04w7RCdl9ZnG-CgYIARAAGAQSNwF-L9IrNZVxs6fliP7xAlHjKcZpfpw7JoYyBsvxKMD7n0xyB74G8aRlFoBkkCbloETrWMU6yOA" -|} -|Note: The OAuth Playground will automatically revoke refresh tokens after 24h. You can avoid this by specifying your own application OAuth credentials using the Configuration panel. -| - |### An ID token's payload -| - | -| { -| "iss": "https://accounts.google.com", -| "azp": "407408718192.apps.googleusercontent.com", -| "aud": "407408718192.apps.googleusercontent.com", -| "sub": "113966854245780892959", -| "email": "marko.milic.srbija@gmail.com", -| "email_verified": true, -| "at_hash": "nGKRToKNnVA28H6MhwXBxw", -| "name": "Marko Milić", -| "picture": "https://lh5.googleusercontent.com/-Xd44hnJ6TDo/AAAAAAAAAAI/AAAAAAAAAAA/AKxrwcadwzhm4N4tWk5E8Avxi-ZK6ks4qg/s96-c/photo.jpg", -| "given_name": "Marko", -| "family_name": "Milić", -| $PARAM_LOCALE: "en", -| "iat": 1547705691, -| "exp": 1547709291 -| } -| -| - |### Try a REST call using the authorization's header -| Using your favorite http client: -| - | GET /obp/v3.0.0/users/current -| - |Body -| - |Leave Empty! -| - |Headers: -| - | -| Authorization: Bearer ID_TOKEN -| -| - |Here is it all together: -| - | -| - | 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 -| Cache-Control: no-cache -| - | -| - |CURL example: -| - | -| 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 'Cache-Control: no-cache' -| -H 'Postman-Token: aa812d04-eddd-4752-adb7-4d56b3a98f36' -| - | -| - |And we get the response: -| - | -| { -| "user_id": "6d411bce-50c1-4eb8-b8b0-3953e4211773", -| "email": "marko.milic.srbija@gmail.com", -| "provider_id": "113966854245780892959", -| "provider": "https://accounts.google.com", -| "username": "Marko Milić", -| "entitlements": { -| "list": [] -| } -| } -| -| -|""") - - - - - val gatewayLoginEnabledMessage : String = if (APIUtil.getPropsAsBoolValue("allow_gateway_login", false)) - {"Note: Gateway Login is enabled."} else {"Note: *Gateway Login is NOT enabled on this instance!*"} - - - glossaryItems += GlossaryItem( - title = "Authentication: Gateway Login", - description = - s""" - |### Introduction -| -|$gatewayLoginEnabledMessage -| -|Gateway Login Authorisation is made by including a specific header (see step 3 below) in any OBP REST call. -| -|Note: Gateway Login does *not* require an explicit POST like Direct Login to create the token. -| -|The **Gateway is responsible** for creating a token which is trusted by OBP **absolutely**! -| -|When OBP recieves a token via Gateway Login, OBP creates or gets a user based on the username supplied. -| -|![obp login via gateway and jwt](https://user-images.githubusercontent.com/485218/32783397-e39620ee-c94b-11e7-92e3-b244b8e841dd.png) -| -| -|To use Gateway Login: -| -|### 1) Configure OBP API to accept Gateway Login. -| -|Set up properties in a props file -| -|``` -|# -- Gateway login -------------------------------------- -|# Enable/Disable Gateway communication at all -|# In case isn't defined default value is false -|# allow_gateway_login=false -|# Define comma separated list of allowed IP addresses -|# gateway.host=127.0.0.1 -|# Define secret used to validate JWT token -|# jwt_token_secret=your-at-least-256-bit-secret-token -|# -------------------------------------- Gateway login -- -|``` -|Please keep in mind that property jwt_token_secret is used to validate JWT token to check it is not changed or corrupted during transport. -| -|### 2) Create / have access to a JWT -| -| -| -|HEADER:ALGORITHM & TOKEN TYPE -| -|``` -|{ -| "alg": "HS256", -| "typ": "JWT" -|} -|``` -|PAYLOAD:DATA -| -|``` -|{ -| "login_user_name": "username", -| "is_first": false, -| "app_id": "85a965f0-0d55-4e0a-8b1c-649c4b01c4fb", -| "app_name": "GWL", -| "time_stamp": "2018-08-20T14:13:40Z", -| "cbs_token": "your_token", -| "cbs_id": "your_cbs_id", -| "session_id": "123456789" -|} -|``` -|VERIFY SIGNATURE -|``` -|HMACSHA256( -| base64UrlEncode(header) + "." + -| base64UrlEncode(payload), -| -|) your-at-least-256-bit-secret-token -|``` -| -|Here is the above example token: -| -|``` -|eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. -|eyJsb2dpbl91c2VyX25hbWUiOiJ1c2VybmFtZSIsImlzX2ZpcnN0IjpmYWxzZSwiYXBwX2lkIjoiODVhOTY1ZjAtMGQ1NS00ZTBhLThiMWMtNjQ5YzRiMDFjNGZiIiwiYXBwX25hbWUiOiJHV0wiLCJ0aW1lX3N0YW1wIjoiMjAxOC0wOC0yMFQxNDoxMzo0MFoiLCJjYnNfdG9rZW4iOiJ5b3VyX3Rva2VuIiwiY2JzX2lkIjoieW91cl9jYnNfaWQiLCJzZXNzaW9uX2lkIjoiMTIzNDU2Nzg5In0. -|bfWGWttEEcftiqrb71mE6Xy1tT_I-gmDPgjzvn6kC_k -|``` -| -| -| -|### 3) Try a REST call using the header -| -| -|Using your favorite http client: -| -| GET $getServerUrl/obp/v3.0.0/users/current -| -|Body -| -| Leave Empty! -| -| -|Headers: -| -| Authorization: GatewayLogin token="your-jwt-from-step-above" -| -|Here is it all together: -| -| GET $getServerUrl/obp/v3.0.0/users/current HTTP/1.1 -| Host: localhost:8080 -| User-Agent: curl/7.47.0 -| Accept: */* -| Authorization: GatewayLogin token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. -|AS8D76F7A89S87D6F7A9SD876FA789SD78F6A7S9D78F6AS79DF87A6S7D9F7A6S7D9F78A6SD798F78679D786S789D78F6A7S9D78F6AS79DF876A7S89DF786AS9D87F69AS7D6FN1bWVyIn0. -|KEuvjv3dmwkOhQ3JJ6dIShK8CG_fd2REApOGn1TRmgU" -| -|CURL example -| -|``` -|curl -v -H 'Authorization: GatewayLogin token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. -|eyJsb2dpbl91c2VyX25hbWUiOiJ1c2VybmFtZSIsImlzX2ZpcnN0IjpmYWxzZSwiYXBwX2lkIjoiODVhOTY1ZjAtMGQ1NS00ZTBhLThiMWMtNjQ5YzRiMDFjNGZiIiwiYXBwX25hbWUiOiJHV0wiLCJ0aW1lX3N0YW1wIjoiMjAxOC0wOC0yMFQxNDoxMzo0MFoiLCJjYnNfdG9rZW4iOiJ5b3VyX3Rva2VuIiwiY2JzX2lkIjoieW91cl9jYnNfaWQiLCJzZXNzaW9uX2lkIjoiMTIzNDU2Nzg5In0. -|bfWGWttEEcftiqrb71mE6Xy1tT_I-gmDPgjzvn6kC_k"' $getServerUrl/obp/v3.0.0/users/current -|``` -| -| -|You should receive a response like: -| -|``` -|{ -| "user_id": "33fd104f-3e6f-4025-97cc-b76bbdc9148e", -| "email": "marko@tesobe.com", -| "provider_id": "marko.milic", -| "provider": "https://tesobe.openbankproject.com", -| "username": "marko.milic", -| "entitlements": { -| "list": [] -| } -|} -|``` -|and custom response header i.e. OBP returns a new token in the custom response header called GatewayLogin (to the Gateway) -| -|``` -|{ -|"username": "simonr", -|"CBS_auth_token": "fapsoidfuipoi889w3ih", (Encrypted by OBP Adapter) -|"timestamp": "timestamp", -|"consumer_id": "123", -|"consumer_name": "Name of Consumer" -|} -|``` -|GatewayLogin token="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. -|AS8D76F7A89S87D6F7A9SD876FA789SD78F6A7S9D78F6AS79DF87A6S7D9F7A6S7D9F78A6SD798F78679D786S789D78F6A7S9D78F6AS79DF876A7S89DF786AS9D87F69AS7D6FN1bWVyIn0. -|KEuvjv3dmwkOhQ3JJ6dIShK8CG_fd2REApOGn1TRmgU" -| -|### Example python script -|``` -|import jwt -|from datetime import datetime, timezone -|import requests -| -|env = 'local' -|DATE_FORMAT = '%Y-%m-%dT%H:%M:%SZ' -| -|obp_api_host = 'https://yourhost.com' -|payload = { -| "login_user_name": "username", -| "is_first": False, -| "app_id": "85a965f0-0d55-4e0a-8b1c-649c4b01c4fb", -| "app_name": "Name", -| "time_stamp": datetime.now(timezone.utc).strftime(DATE_FORMAT), -| "cbs_token": "yourtokenforcbs", -| "cbs_id": "yourcbs_id", -| "session_id": "123456789" -|} -| -| -|token = jwt.encode(payload, 'your-at-least-256-bit-secret-token', algorithm='HS256').decode("utf-8") -|authorization = 'GatewayLogin token="{}"'.format(token) -|headers = {'Authorization': authorization} -|url = obp_api_host + '/obp/v4.0.0/users/current' -|req = requests.get(url, headers=headers) -|print(req.text) -|``` -| -|### Under the hood -| -|The file, GatewayLogin.scala handles the Gateway Login. -| -|We: -| -|``` -|-> Check if Props allow_gateway_login is true -| -> Check if GatewayLogin header exists -| -> Check if getRemoteIpAddress is OK -| -> Look for "token" -| -> If "is_first" is true -OR- CBS_auth_token is empty then, call CBS to get accounts -|``` -| -|The CBS_auth_token (either the new one from CBS or existing one from previous token) is returned in the GatewayLogin custom response header. -| -| -| -|### More information -| -| Parameter names and values are case sensitive. -| -| -| Each parameter MUST NOT appear more than once per request. -| - """) - - - val dauthEnabledMessage : String = if (APIUtil.getPropsAsBoolValue("allow_dauth", false)) - {"Note: DAuth is enabled."} else {"Note: *DAuth is NOT enabled on this instance!*"} - - - glossaryItems += GlossaryItem( - title = APIUtil.DAuthHeaderKey, - description = - s""" - |### DAuth Introduction, Setup and Usage -| -| -|DAuth is an experimental authentication mechanism that aims to pin an ethereum or other blockchain Smart Contract to an OBP "User". -| -|In the future, it might be possible to be more specific and pin specific actors (wallets) that are acting within the smart contract, but so far, one smart contract acts on behalf of one User. -| -|Thus, if a smart contract "X" calls the OBP API using the DAuth header, OBP will get or create a user called X and the call will proceed in the context of that User "X". -| -| -|DAuth is invoked by the REST client (caller) including a specific header (see step 3 below) in any OBP REST call. -| -|When OBP receives the DAuth token, it creates or gets a User with a username based on the smart_contract_address and the provider based on the network_name. The combination of username and provider is unique in OBP. -| -|If you are calling OBP-API via an API3 Airnode, the Airnode will take care of constructing the required header. -| -|When OBP detects a DAuth header / token it first checks if the Consumer is allowed to make such a call. OBP will validate the Consumer ip address and signature etc. -| -|Note: The DAuth flow does *not* require an explicit POST like Direct Login to create the token. -| -|Permissions may be assigned to an OBP User at any time, via the UserAuthContext, Views, Entitlements to Roles or Consents. -| -|$dauthEnabledMessage -| -|Note: *The DAuth client is responsible for creating a token which will be trusted by OBP absolutely*! -| -| -|To use DAuth: -| -|### 1) Configure OBP API to accept DAuth. -| -|Set up properties in your props file -| -|``` -|# -- DAuth -------------------------------------- -|# Define secret used to validate JWT token -|# jwt.public_key_rsa=path-to-the-pem-file -|# Enable/Disable DAuth communication at all -|# In case isn't defined default value is false -|# allow_dauth=false -|# Define comma separated list of allowed IP addresses -|# dauth.host=127.0.0.1 -|# -------------------------------------- DAuth-- -|``` -|Please keep in mind that property jwt.public_key_rsa is used to validate JWT token to check it is not changed or corrupted during transport. -| -|### 2) Create / have access to a JWT -| -|The following videos are available: -| * [DAuth in local environment](https://vimeo.com/644315074) -| -|HEADER:ALGORITHM & TOKEN TYPE -| -|``` -|{ -| "alg": "RS256", -| "typ": "JWT" -|} -|``` -|PAYLOAD:DATA -| -|``` -|{ -| "smart_contract_address": "0xe123425E7734CE288F8367e1Bb143E90bb3F051224", -| "network_name": "AIRNODE.TESTNET.ETHEREUM", -| "msg_sender": "0xe12340927f1725E7734CE288F8367e1Bb143E90fhku767", -| "consumer_key": "0x1234a4ec31e89cea54d1f125db7536e874ab4a96b4d4f6438668b6bb10a6adb", -| "timestamp": "2021-11-04T14:13:40Z", -| "request_id": "0Xe876987694328763492876348928736497869273649" -|} -|``` -|VERIFY SIGNATURE -|``` -|RSASHA256( -| base64UrlEncode(header) + "." + -| base64UrlEncode(payload), -| -|) your-RSA-key-pair -|``` -| -|Here is an example token: -| -|``` -|eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzbWFydF9jb250cmFjdF9hZGRyZXNzIjoiMHhlMTIzNDI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGJiM0YwNTEyMjQiLCJuZXR3b3JrX25hbWUiOiJFVEhFUkVVTSIsIm1zZ19zZW5kZXIiOiIweGUxMjM0MDkyN2YxNzI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGZoa3U3NjciLCJjb25zdW1lcl9rZXkiOiIweDEyMzRhNGVjMzFlODljZWE1NGQxZjEyNWRiNzUzNmU4NzRhYjRhOTZiNGQ0ZjY0Mzg2NjhiNmJiMTBhNmFkYiIsInRpbWVzdGFtcCI6IjIwMjEtMTEtMDRUMTQ6MTM6NDBaIiwicmVxdWVzdF9pZCI6IjBYZTg3Njk4NzY5NDMyODc2MzQ5Mjg3NjM0ODkyODczNjQ5Nzg2OTI3MzY0OSJ9.XSiQxjEVyCouf7zT8MubEKsbOBZuReGVhnt9uck6z6k -|``` -| -| -| -|### 3) Try a REST call using the header -| -| -|Using your favorite http client: -| -| GET $getServerUrl/obp/v3.0.0/users/current -| -|Body -| -| Leave Empty! -| -| -|Headers: -| -| DAuth: your-jwt-from-step-above -| -|Here is it all together: -| -| GET $getServerUrl/obp/v3.0.0/users/current HTTP/1.1 -| Host: localhost:8080 -| User-Agent: curl/7.47.0 -| Accept: */* -| DAuth: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzbWFydF9jb250cmFjdF9hZGRyZXNzIjoiMHhlMTIzNDI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGJiM0YwNTEyMjQiLCJuZXR3b3JrX25hbWUiOiJFVEhFUkVVTSIsIm1zZ19zZW5kZXIiOiIweGUxMjM0MDkyN2YxNzI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGZoa3U3NjciLCJjb25zdW1lcl9rZXkiOiIweDEyMzRhNGVjMzFlODljZWE1NGQxZjEyNWRiNzUzNmU4NzRhYjRhOTZiNGQ0ZjY0Mzg2NjhiNmJiMTBhNmFkYiIsInRpbWVzdGFtcCI6IjIwMjEtMTEtMDRUMTQ6MTM6NDBaIiwicmVxdWVzdF9pZCI6IjBYZTg3Njk4NzY5NDMyODc2MzQ5Mjg3NjM0ODkyODczNjQ5Nzg2OTI3MzY0OSJ9.XSiQxjEVyCouf7zT8MubEKsbOBZuReGVhnt9uck6z6k -| -|CURL example -| -|``` -|curl -v -H 'DAuth: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzbWFydF9jb250cmFjdF9hZGRyZXNzIjoiMHhlMTIzNDI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGJiM0YwNTEyMjQiLCJuZXR3b3JrX25hbWUiOiJFVEhFUkVVTSIsIm1zZ19zZW5kZXIiOiIweGUxMjM0MDkyN2YxNzI1RTc3MzRDRTI4OEY4MzY3ZTFCYjE0M0U5MGZoa3U3NjciLCJjb25zdW1lcl9rZXkiOiIweDEyMzRhNGVjMzFlODljZWE1NGQxZjEyNWRiNzUzNmU4NzRhYjRhOTZiNGQ0ZjY0Mzg2NjhiNmJiMTBhNmFkYiIsInRpbWVzdGFtcCI6IjIwMjEtMTEtMDRUMTQ6MTM6NDBaIiwicmVxdWVzdF9pZCI6IjBYZTg3Njk4NzY5NDMyODc2MzQ5Mjg3NjM0ODkyODczNjQ5Nzg2OTI3MzY0OSJ9.XSiQxjEVyCouf7zT8MubEKsbOBZuReGVhnt9uck6z6k' $getServerUrl/obp/v3.0.0/users/current -|``` -| -| -|You should receive a response like: -| -|``` -|{ -| "user_id": "4c4d3175-1e5c-4cfd-9b08-dcdc209d8221", -| "email": "", -| "provider_id": "0xe123425E7734CE288F8367e1Bb143E90bb3F051224", -| "provider": "ETHEREUM", -| "username": "0xe123425E7734CE288F8367e1Bb143E90bb3F051224", -| "entitlements": { -| "list": [] -| } -|} -|``` -| -|### Under the hood -| -|The file, dauth.scala handles the DAuth, -| -|We: -| -|``` -|-> Check if Props allow_dauth is true -| -> Check if DAuth header exists -| -> Check if getRemoteIpAddress is OK -| -> Look for "token" -| -> parse the JWT token and getOrCreate the user -| -> get the data of the user -|``` -| -|### More information -| -| Parameter names and values are case sensitive. -| Each parameter MUST NOT appear more than once per request. -| - """) - - - - glossaryItems += GlossaryItem( - title = "SCA (Strong Customer Authentication)", - description = - s"""| -|SCA is the process by which a Customer of the Bank securely identifies him/her self to the Bank. -| -|Generally this involves using an Out Of Band (OOB) form of communication e.g. a One Time Password (OTP) / code sent to a mobile phone. -| -|In the OBP APIs, SCA is invoked during Transaction Requests and Consent creation. -| -|See the following endpoints: -| -|[Create Consent via SMS](/index#OBPv3_1_0-createConsentSms) -|[Create Consent via Email (for testing purposes)](/index#OBPv3_1_0-createConsentEmail) -|[Answer Consent Challenge](/index#OBPv3_1_0-answerConsentChallenge) -| -|[Create Transaction Request](/index#OBPv2_1_0-createTransactionRequestSandboxTan) -|[Answer Transaction Request Challenge](/index#OBPv2_1_0-answerTransactionRequestChallenge) -| -|Possible SCA flows: -| -|obp-sca-image-1 -| -| -| -""") - - - - glossaryItems += GlossaryItem( - title = "Dummy Customer Logins", - description = - s"""| -|The following dummy Customer Logins may be used by developers testing their applications on this sandbox: -| -|${getWebUiPropsValue("webui_dummy_user_logins", "")} -| -| -| -|${scala.xml.Unparsed(getWebUiPropsValue("webui_api_documentation_url", "") + "#customer-logins")} -| -| -| -| -| -| -""") - - glossaryItems += GlossaryItem( - title = "Sandbox Introduction", - description = - s""" - ${getWebUiPropsValue("webui_sandbox_introduction", "")} - """) - - - glossaryItems += GlossaryItem( - title = "Data Model Overview", - description = - s""" -| -|An overview of the Open Bank Project Data Model. -| -|This diagram may help in understanding the Open Bank Project entities, capabilities and concepts. However, it is subject to change. If we change the data model, we release migration scripts. -| -| -|obp-data-model-overview -| -| -| - """) - glossaryItems += GlossaryItem( - title = "Qualified Certificate Profiles (PSD2 context)", - description = - s""" - |An overview of the Qualified Certificate Profiles. - | - |qualified-certificate-profiles - | - | """.stripMargin) - - glossaryItems += GlossaryItem( - title = "Consumer, Consent, Transport and Payload Security", - description = - s""" -| -|Consumer, Consent, Transport and Payload Security with MTLS and JWS - |This glossary item aims to give an overview of how the communication between an Application and the OBP API server is secured with Consents, Consumer records, MTLs and JWS. - | - |It includes some implementation step notes for the Application developer. - | -|The following components are required: -| -|## Consumer record -| -|The Application must have an active API Consumer / Client record on the server. -| -|## MTLS -| -|With Mutual TLS both the Consumer and the Server (OBP API) must use certificates. -| -|## JWS -| -|The Request is signed by the Consumer with a JWS using the client certificate of the Consumer. Example: [OBP-Hola private void requestIntercept](https://github.com/OpenBankProject/OBP-Hydra-OAuth2/blob/40359cf569a814c1aec4ce593303b39ddf9bdded/src/main/java/com/openbankproject/hydra/auth/RestTemplateConfig.java#L106) -|The Request is verified by the OBP API Server using the JWS provided by the Consumer. See [OBP-API def verifySignedRequest](https://github.com/OpenBankProject/OBP-API/blob/752044a35ca73ea4d3563c6ced57ee80903b6d30/obp-api/src/main/scala/code/api/util/JwsUtil.scala#L121) -|The Response is signed by the OBP API Server with a JWS. See [OBP-API def signResponse](https://github.com/OpenBankProject/OBP-API/blob/752044a35ca73ea4d3563c6ced57ee80903b6d30/obp-api/src/main/scala/code/api/util/JwsUtil.scala#L233) -|The Response is verified by the Client using the JWS provided by the OBP API Server. Example: [OBP-Hola private void responseIntercept](https://github.com/OpenBankProject/OBP-Hydra-OAuth2/blob/c2e4589ad7e6e6b156b54e535bdcd93638317ff7/src/main/java/com/openbankproject/hydra/auth/RestTemplateConfig.java#L121) -| -| -|## Consent -| -|The end user must give permission to the Application in order for the Application to see his/her account and transaction data. -| -|obp-permission-transport-and-payload-security -| -|## In order to get an App / Consumer key -| -|[Sign up]($getServerUrl/user_mgt/sign_up) or [login]($getServerUrl/user_mgt/login) as a developer. -| -|Register your App / Consumer [HERE](${getConsumerRegistrationUrl()}) -| -|Be sure to enter your Client Certificate in the registration form. To create the user.crt file see [HERE](https://fardog.io/blog/2017/12/30/client-side-certificate-authentication-with-nginx/) -| -| -|## Authenticate -| -|To test the service your App will need to authenticate using OAuth2. -| -|You can use the [OBP Hola App](https://github.com/OpenBankProject/OBP-Hydra-OAuth2) as an example / starting point for your App. -| - """) - - -// TODO put the following wiki text here in source code with soft coded hosts etc. The problem is the text is currently too long - glossaryItems += GlossaryItem( - title = "Hola App log trace", - description = - s""" - Please see: - [OBP Hola App Log Trace](https://github.com/OpenBankProject/OBP-API/wiki/Log-trace-of-the-Hola-App-performing-Georgian-flavour-of-Berlin-Group-authentication,-consent-generation-and-consuming-Berlin-Group-Account,-Balance-and-Transaction-resources) - """) - - - glossaryItems += GlossaryItem( - title = "Berlin Group Mandatory Headers", - description = - s""" -|OBP validates mandatory HTTP request headers for Berlin Group (NextGenPSD2) API endpoints. -| -|When a request targets a Berlin Group endpoint (identified by the Berlin Group URL prefix), OBP checks for the presence of required headers before processing the request. -| -|## Mandatory Headers -| -|The following headers are required on all Berlin Group API requests by default: -| -|* **Content-Type** - The media type of the request body -|* **Date** - The date and time of the request (must be a valid RFC 7231 date) -|* **Digest** - A digest of the request body for integrity verification -|* **PSU-Device-ID** - UUID of the device used by the Payment Service User (PSU) -|* **PSU-Device-Name** - Name of the device used by the PSU -|* **PSU-IP-Address** - IP address of the PSU device -|* **Signature** - Digital signature of the request (keyId must match the serial number from the TPP certificate) -|* **TPP-Signature-Certificate** - The certificate used by the TPP to sign the request -|* **X-Request-ID** - UUID that uniquely identifies the request (must not be reused for POST requests that returned 201) -| -|## Additional Consent Headers -| -|When creating a consent (POST to /consents), the following additional header is required: -| -|* **TPP-Redirect-URI** - URI to redirect the PSU to after consent authorization -| -|## TPP Requests Without PSU Involvement -| -|For background/batch requests where no PSU is directly involved, set: -| -|* PSU-IP-Address: 0.0.0.0 -|* PSU-Device-ID: no-psu-involved -|* PSU-Device-Name: no-psu-involved -| -|This enables OBP to apply different consent frequency rules for TPP-initiated requests. -| -|## Configuration -| -|The mandatory headers can be customized in the Props file: -| -|* `berlin_group_mandatory_headers` - Comma-separated list of mandatory header names. Set to empty to disable header checks. -|* `berlin_group_mandatory_header_consent` - Additional headers required for consent creation endpoints. -| -|Example Props configuration: -| -| # Use default mandatory headers -| #berlin_group_mandatory_headers = Content-Type,Date,Digest,PSU-Device-ID,PSU-Device-Name,PSU-IP-Address,Signature,TPP-Signature-Certificate,X-Request-ID -| #berlin_group_mandatory_header_consent = TPP-Redirect-URI -| -| # Disable mandatory header checks (e.g. for testing) -| berlin_group_mandatory_headers = -| berlin_group_mandatory_header_consent = -| -|## Validation Chain -| -|OBP performs the following validation steps on Berlin Group requests in order: -| -|1. **Missing headers check** - Returns HTTP 400 if any mandatory headers are absent -|2. **Date format check** - Validates the Date header conforms to RFC 7231 -|3. **X-Request-ID format check** - Validates the X-Request-ID is a valid UUID -|4. **X-Request-ID uniqueness check** - Ensures the X-Request-ID has not been used in a previous successful POST (201) request -|5. **Signature header check** - Parses the Signature header and verifies the keyId serial number matches the TPP certificate -|6. **Consent-ID usage check** - Ensures the Consent-ID header is not sent on consent management endpoints where it is not expected -| -|If any check fails, OBP returns an appropriate error message (OBP-20251 through OBP-20256) with HTTP status 400. -| - """) - - - glossaryItems += GlossaryItem( - title = "Berlin Group Transaction and Consent Lifecycle", - description = - s""" -|OBP provides background schedulers that automatically manage the lifecycle of Berlin Group transactions and consents. -| -|## Outdated Transactions -| -|Berlin Group payment transactions with status "received" (RCVD) that remain unprocessed beyond a configured time threshold are automatically rejected by a background scheduler task. -| -|* `berlin_group_outdated_transactions_time_in_seconds` - Time in seconds after which a "received" transaction is considered outdated. Default: **300** (5 minutes). -|* `berlin_group_outdated_transactions_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated transactions. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled). -| -|Example: -| -| # Reject transactions stuck in "received" status for more than 5 minutes, checking every 60 seconds -| berlin_group_outdated_transactions_time_in_seconds = 300 -| berlin_group_outdated_transactions_interval_in_seconds = 60 -| -|## Outdated Consents -| -|Berlin Group consents with status "received" that remain unfinished (e.g. the PSU never completed the SCA flow) beyond a configured time threshold are automatically rejected. -| -|* `berlin_group_outdated_consents_time_in_seconds` - Time in seconds after which an unfinished consent is considered outdated. Default: **300** (5 minutes). -|* `berlin_group_outdated_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for outdated consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled). -| -|Example: -| -| # Reject consents stuck in "received" status for more than 5 minutes, checking every 60 seconds -| berlin_group_outdated_consents_time_in_seconds = 300 -| berlin_group_outdated_consents_interval_in_seconds = 60 -| -|## Expired Consents -| -|Berlin Group consents with status "valid" whose `validUntil` date has passed are automatically transitioned to "expired" status. -| -|* `berlin_group_expired_consents_interval_in_seconds` - How often (in seconds) the scheduler checks for expired consents. Must be set to a value greater than 0 to enable the task. **Not set by default** (task is disabled). -| -|Example: -| -| # Check for expired consents every 120 seconds -| berlin_group_expired_consents_interval_in_seconds = 120 -| - """) - - - glossaryItems += GlossaryItem( - title = "Berlin Group URL and Path Configuration", - description = - s""" -|OBP allows customization of the URL paths used for Berlin Group (NextGenPSD2) API endpoints. -| -|## Canonical Path -| -|* `berlin_group_version_1_canonical_path` - Overrides the version segment of the Berlin Group v1 URL path. By default, the built-in path is `v1.3` (i.e. endpoints are served at `/berlin-group/v1.3/...`). Setting this property changes the version segment. -| -|Example: -| -| # Serve Berlin Group endpoints at /berlin-group/v1.3.12/... -| berlin_group_version_1_canonical_path = v1.3.12 -| -|## Alias Path -| -|* `berlin_group_v1_3_alias_path` - Defines an alternative URL prefix under which Berlin Group v1.3 endpoints are also available. The format must be `xxx/yyy`. When set, all Berlin Group v1.3 endpoints are duplicated under this alternative path. -| -|Example: -| -| # Also serve Berlin Group endpoints at /0.6/v1/... -| berlin_group_v1_3_alias_path = 0.6/v1 -| - """) - - - glossaryItems += GlossaryItem( - title = "Berlin Group Response Formatting", - description = - s""" -|OBP provides several configuration options to control how Berlin Group API responses are formatted. -| -|## Account Name Visibility -| -|* `BG_v1312_show_account_name` - Boolean flag that controls whether the `name` field is included in Berlin Group account responses (at `/berlin-group/v1.3/accounts` and `/berlin-group/v1.3/accounts/{accountId}`). Default: **true**. -| -|Some implementations may require omitting the account name for privacy or compliance reasons. -| -|Example: -| -| # Hide account names in Berlin Group responses -| BG_v1312_show_account_name = false -| -|## Amount Sign Removal -| -|* `BG_remove_sign_of_amounts` - Boolean flag that controls whether the sign (positive/negative indicator) is removed from transaction amount values in Berlin Group responses. Default: **false**. -| -|When enabled, amounts such as "-100.00" are returned as "100.00". This can be useful when the sign is conveyed by other means (e.g. booked vs pending lists, or credit/debit indicators). -| -|Example: -| -| # Remove the sign from transaction amounts -| BG_remove_sign_of_amounts = true -| -|## Error Message Path Visibility -| -|* `berlin_group_error_message_show_path` - Boolean flag that controls whether the request URL path is included in Berlin Group error response messages. Default: **true**. -| -|When enabled, error responses include the `path` field showing which URL triggered the error. This can be disabled for privacy or security reasons. -| -|Example: -| -| # Hide the request path in error messages -| berlin_group_error_message_show_path = false -| - """) - - - glossaryItems += GlossaryItem( - title = "Berlin Group Consent Settings", - description = - s""" -|OBP provides configuration options for Berlin Group consent creation and SCA (Strong Customer Authentication) flows. -| -|## Frequency Per Day Limit -| -|* `berlin_group_frequency_per_day_upper_limit` - Maximum allowed value for the `frequencyPerDay` field when creating a Berlin Group consent. Default: **4**. -| -|When a TPP creates a consent, the requested `frequencyPerDay` must be greater than 0 and less than or equal to this upper limit. For one-off access consents, the frequency must be exactly 1. -| -|Example: -| -| # Allow up to 10 requests per day per consent -| berlin_group_frequency_per_day_upper_limit = 10 -| -|## ASPSP SCA Approach -| -|* `berlin_group_aspsp_sca_approach` - Defines the SCA approach advertised by the ASPSP (Account Servicing Payment Service Provider) in the `ASPSP-SCA-Approach` response header for consent creation endpoints. Default: **redirect**. -| -|Possible values include: -| -|* `redirect` - The PSU is redirected to the ASPSP for authentication -|* `embedded` - Authentication is performed within the TPP interface -|* `decoupled` - Authentication is performed on a separate device/channel -| -|This header is returned in the response to POST `/consents` requests to inform the TPP which SCA method the ASPSP supports. -| -|Example: -| -| # Use embedded SCA approach -| berlin_group_aspsp_sca_approach = embedded -| - """) - - - glossaryItems += GlossaryItem( - title = "API Collection", - description = s"""An API Collection is a collection of endpoints grouped together for a certain purpose. -| -|Having read access to a Collection does not constitute execute access on the endpoints in the Collection. -| -|(Execute access is governed by Entitlements to Roles - and in some cases, Views.) -| -|Collections can be created and shared. You can make a collection non-sharable but the default is sharable. -| -|Your "Favourites" in API Explorer is actually a collection you control named "Favourites". -| -|To share a Collection (e.g. your Favourites) just click on your Favourites in the API Explorer and share the URL in the browser. If you want to share the Collection via an API, just share the collection_id with a developer. -| -|If you share a Collection it can't be modified by anyone else, but anyone can use it as a basis for their own Favourites or another collection. -| -|There are over 13 endpoints for controlling Collections. -|Some of these endpoints require Entitlements to Roles and some operate on your own personal collections such as your favourites. -| - """) - - glossaryItems += GlossaryItem( - title = "Space", - description = - s"""In OBP, if you have access to a "Space", you have access to a set of Dynamic Endpoints and Dynamic Entities that belong to that Space. -|Internally, Spaces are defined as a "Banks" thus Spaces are synonymous with OBP Banks. -| -|A user can have access to several spaces. The API Explorer shows these under the Spaces menu. -| -|In order to see the documentation for the Dynamic Endpoints and Dynamic Entities, a user may need to have access to the CanReadDynamicResourceDocsAtOneBank Role. -| -|You can create your own Space by creating an OBP Bank. -| -""".stripMargin) - - - glossaryItems += GlossaryItem( - title = "Dynamic-Entity-Intro", - description = - s""" -| -|Dynamic Entities can be used to store and retrieve custom data objects (think your own tables and fields) in the OBP instance. -| -|You can define your own Dynamic Entities or use Dynamic Entities created by others. -| -|You would use Dynamic Entities if you want to go beyond the OBP standard data model and store custom data structures. Note, if you want to extend the core OBP banking model of Customers, Products, Accounts, Transactions and so on you can also add Custom Attributes to these standard objects. -| -|You would use Dynamic Endpoints if you want to go beyond the standard OBP or other open banking standard APIs. -| -|Dynamic Entities have their own REST APIs so you can easily Create, Read, Update and Delete records. However, you can also connect Dynamic Endpoints with your own API definitions (via Swagger) and so create custom GET endpoints connecting to any combination of Dynamic Entities. -| -|Dynamic Endpoints can retrieve the data of Dynamic Entities so you can effectively create bespoke endpoint / data combinations - at least for GET endpoints - using Dynamic Endpoints, Entities and Endpoint Mapping. -| -|In order to use Dynamic Entities you will need to have the appropriate Entitlements to Create, Read, Update or Delete records in the Dynamic Entity. -| -|You define your Dynamic Entities in JSON. -| -|Fields are typed, have an example value and a (markdown) description. They can also be constrained in size. -| -|You can also create field "references" to other fields in other Entities. These are like foreign keys to other Dynamic or Static (built in) entities. -|In other words, if you create an Entity called X which has a field called A, you can force the values of X.A to match the values of Y.B where Y is another Dynamic Entity or Z.B where Z is a Static (OBP) Entity. -|If you want to add data to an existing Entity, you can create a Dynamic Entity which has a reference field to the existing entity. -| -|Dynamic Entities can be created at the System level (bank_id is null) - or Bank / Space level (bank_id is not null). You might want to create Bank level Dynamic Entities in order to grant automated roles based on user email domain. -| -|When creating a Dynamic Entity, OBP automatically: -| -|* Creates a data structure in the OBP database in which to store the records of the new Entity. -|* Creates a primary key for the Entity which can be used to update and delete the Entity. -|* Creates Create, Read, Update and Delete endpoints to operate on the Entity so you can insert, get, modify and delete records. These CRUD operations are all available over the generated REST endpoints. -|* Creates Roles to guard the above endpoints. -| -|Following the creation of a Dynamic Entity you will need to grant yourself or others the appropriate roles before you can insert or get records. -| -|The generated Roles required for CRUD operations on a Dynamic Entity are like any other OBP Role i.e. they can be requested, granted, revoked and auto-granted using the API Explorer / API Manager or via REST API. To see the Roles required for a Dynamic Entities endpoints, see the API Explorer for each endpoint concerned. -| -|Each Dynamic Entity gets a dynamicEntityId which uniquely identifies it and also the userId which identifies the user who created the Entity. The dynamicEntityId is used to update the definition of the Entity. -| -|To visualise any data contained in Dynamic Entities you could use external BI tools and use the GET endpoints and authenticate using OAuth or Direct Login. -| -|The following videos are available: -| -| * [Introduction to Dynamic Entities](https://vimeo.com/426524451) -| * [Features of Dynamic Entities](https://vimeo.com/446465797) -| -""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Dynamic-Entities", - description = - s""" -| -|Dynamic Entities allow you to create custom data structures and their corresponding CRUD endpoints at runtime without writing code or restarting the OBP-API instance. -| -|**Overview:** -| -|Dynamic Entities enable you to define custom business objects (entities) with their fields, types, and validation rules via API calls. Once created, OBP automatically generates fully functional REST API endpoints for Create, Read, Update, and Delete operations. -| -|**Types of Dynamic Entities:** -| -|1. **System Level Dynamic Entities** - Available across the entire OBP instance -|2. **Bank Level Dynamic Entities** - Scoped to a specific bank -| -|**Creating a Dynamic Entity:** -| -|```json -|POST /management/system-dynamic-entities -|{ -| "hasPersonalEntity": true, -| "CustomerPreferences": { -| "description": "Customer preferences and settings", -| "required": ["theme"], -| "properties": { -| "theme": { -| "type": "string", -| "example": "dark" -| }, -| "language": { -| "type": "string", -| "example": "en" -| }, -| "notifications_enabled": { -| "type": "boolean", -| "example": "true" -| } -| } -| } -|} -|``` -| -|**IMPORTANT - JSON Structure:** -| -|The entity name (e.g., "CustomerPreferences") MUST be a direct top-level key in the JSON. The root object can contain at most TWO fields: your entity name and optionally "hasPersonalEntity". -| -|**Common mistake - DO NOT do this:** -|```json -|{ -| "entity": { -| "CustomerPreferences": { ... } -| } -|} -|``` -|This will fail with error: "There must be 'required' field in entity" -| -|**Supported field types:** -| -|STRING, INTEGER, DOUBLE, BOOLEAN, DATE_WITH_DAY (format: yyyy-MM-dd), JSON (objects and arrays), and reference types (foreign keys) -| -|**The hasPersonalEntity flag:** -| -|When **hasPersonalEntity = true** (default): -| -|OBP generates TWO sets of endpoints: -| -|1. **Regular endpoints** - Access all entities (requires specific roles) -| * POST /CustomerPreferences -| * GET /CustomerPreferences -| * GET /CustomerPreferences/ID -| * PUT /CustomerPreferences/ID -| * DELETE /CustomerPreferences/ID -| -|2. **Personal 'my' endpoints** - User-scoped access (see ${getGlossaryItemLink("My-Dynamic-Entities")}) -| * POST /my/CustomerPreferences -| * GET /my/CustomerPreferences -| * GET /my/CustomerPreferences/ID -| * PUT /my/CustomerPreferences/ID -| * DELETE /my/CustomerPreferences/ID -| -|When **hasPersonalEntity = false**: -| -|OBP generates ONLY the regular endpoints. No 'my' endpoints are created. Use this when the entity represents shared data that should not be user-scoped. -| -|**Data Storage Differences:** -| -|Both personal and non-personal entities use the same database table (DynamicData), but the key difference is how user ownership is handled: -| -|When **hasPersonalEntity = true**: -| -|* Each record stores the UserId of the user who created it -|* The UserId is **actively used in all queries** to filter results -|* Users can only see, update, and delete their own records via 'my' endpoints -|* The 'my' endpoints **skip role checks** - user isolation provides the authorization -|* Cascade delete (deleting the entity definition and all data at once) is **not allowed** -| -|When **hasPersonalEntity = false**: -| -|* UserId may be stored for audit purposes but is **ignored in queries** -|* All authorized users see the same shared data -|* Role-based authorization is **required** (e.g., CanGetDynamicEntity_FooBar) -|* Cascade delete **is allowed** - you can delete the entity definition and all its records in one operation -| -|**Summary table:** -| -|| Feature | hasPersonalEntity=true | hasPersonalEntity=false | -||---------|------------------------|-------------------------| -|| Data visibility | Per-user (isolated) | Shared (all users) | -|| UserId in queries | Yes (filters results) | No (ignored) | -|| 'my' endpoints | Generated | Not generated | -|| Authorization | User-scoped (no roles needed for 'my' endpoints) | Role-based | -|| Cascade delete | Blocked | Allowed | -| -|**For bank-level entities**, endpoints include the bank ID: -| -|* POST /banks/BANK_ID/CustomerPreferences -|* POST /banks/BANK_ID/my/CustomerPreferences (if hasPersonalEntity = true) -| -|**Auto-generated roles:** -| -|When you create a Dynamic Entity named 'FooBar', OBP automatically creates these roles: -| -|* CanCreateDynamicEntity_FooBar -|* CanUpdateDynamicEntity_FooBar -|* CanGetDynamicEntity_FooBar -|* CanDeleteDynamicEntity_FooBar -| -|**Management endpoints:** -| -|* POST /management/system-dynamic-entities - Create system level entity -|* POST /management/banks/BANK_ID/dynamic-entities - Create bank level entity -|* GET /management/system-dynamic-entities - List all system level entities -|* GET /management/banks/BANK_ID/dynamic-entities - List bank level entities -|* PUT /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Update entity definition -|* DELETE /management/system-dynamic-entities/DYNAMIC_ENTITY_ID - Delete entity (and all its data) -| -|**Discovering Dynamic Entity Endpoints (for application developers):** -| -|Once Dynamic Entities are created, their auto-generated CRUD endpoints are documented in the Resource Docs API. To programmatically discover all available Dynamic Entity endpoints, use: -| -|``` -|GET /resource-docs/API_VERSION/obp?content=dynamic -|``` -| -|For example: `GET /resource-docs/v5.1.0/obp?content=dynamic` -| -|This returns documentation for all dynamic endpoints (both Dynamic Entities and Dynamic Endpoints) including: -| -|* Endpoint paths and HTTP methods -|* Request and response schemas with examples -|* Required roles and authentication -|* Field descriptions and types -| -|You can also get this documentation in OpenAPI/Swagger format for code generation and API client tooling. -| -|**Required roles to manage Dynamic Entities:** -| -|* CanCreateSystemLevelDynamicEntity -|* CanCreateBankLevelDynamicEntity -| -|**Use cases:** -| -|* Customer preferences and settings -|* Custom metadata for accounts or transactions -|* Business-specific data structures -|* Rapid prototyping of new features -|* Extension of core banking data model -| -|For user-scoped Dynamic Entities, see ${getGlossaryItemLink("My-Dynamic-Entities")} -| -|For more detailed information about managing Dynamic Entities, see ${getGlossaryItemLink("Dynamic-Entity-Intro")} -| -""".stripMargin) - - glossaryItems += GlossaryItem( - title = "My-Dynamic-Entities", - description = - s""" -| -|My Dynamic Entities are user-scoped endpoints that are automatically generated when you create a Dynamic Entity with hasPersonalEntity set to true (which is the default). -| -|**How it works:** -| -|1. Create a Dynamic Entity definition (System or Bank Level) with hasPersonalEntity = true -|2. OBP automatically generates both regular CRUD endpoints AND 'my' endpoints -|3. The 'my' endpoints only return data created by the authenticated user -| -|**Example workflow:** -| -|**Step 1:** Create a Dynamic Entity definition -| -|```json -|POST /management/system-dynamic-entities -|{ -| "hasPersonalEntity": true, -| "CustomerPreferences": { -| "description": "User preferences", -| "required": ["theme"], -| "properties": { -| "theme": {"type": "string"}, -| "language": {"type": "string"} -| } -| } -|} -|``` -| -|**Step 2:** Use the auto-generated 'my' endpoints: -| -|* POST /my/CustomerPreferences - Create my preference -|* GET /my/CustomerPreferences - Get all my preferences -|* GET /my/CustomerPreferences/ID - Get one of my preferences -|* PUT /my/CustomerPreferences/ID - Update my preference -|* DELETE /my/CustomerPreferences/ID - Delete my preference -| -|**For bank-level entities:** -| -|* POST /banks/BANK_ID/my/CustomerPreferences -|* GET /banks/BANK_ID/my/CustomerPreferences -|* GET /banks/BANK_ID/my/CustomerPreferences/ID -|* PUT /banks/BANK_ID/my/CustomerPreferences/ID -|* DELETE /banks/BANK_ID/my/CustomerPreferences/ID -| -|**Key differences:** -| -|* **Regular endpoints** (e.g., /CustomerPreferences): Access ALL entities (requires roles) -|* **My endpoints** (e.g., /my/CustomerPreferences): Access only your own entities (user-scoped) -| -|**Note:** If hasPersonalEntity is set to false, no 'my' endpoints are generated. -| -|**Management endpoints for Dynamic Entity definitions (available from v4.0.0):** -| -|* GET /my/dynamic-entities - Get all Dynamic Entity definitions I created -|* PUT /my/dynamic-entities/DYNAMIC_ENTITY_ID - Update a definition I created -| -|**Discovery endpoint (available from v6.0.0):** -| -|* GET /personal-dynamic-entities/available - Discover all Dynamic Entities that support personal data storage -| -|This endpoint allows regular users (without admin roles) to discover which dynamic entities they can interact with for storing personal data via the /my/ENTITY_NAME endpoints. No special roles required - just needs to be logged in. -| -|**Response format for GET /my/dynamic-entities and GET /personal-dynamic-entities/available:** -| -|**v6.0.0 format (recommended):** -| -|The v6.0.0 response uses snake_case field names and an explicit `entity_name` field: -| -|```json -|{ -| "dynamic_entities": [ -| { -| "dynamic_entity_id": "abc-123-def", -| "entity_name": "CustomerPreferences", -| "user_id": "user-456", -| "bank_id": null, -| "has_personal_entity": true, -| "definition": { -| "description": "User preferences", -| "required": ["theme"], -| "properties": { -| "theme": {"type": "string"}, -| "language": {"type": "string"} -| } -| } -| } -| ] -|} -|``` -| -|**v4.0.0 format (legacy):** -| -|The v4.0.0 response uses camelCase field names and the **entity name is a dynamic key** (not a fixed property name): -| -|```json -|{ -| "dynamic_entities": [ -| { -| "CustomerPreferences": { -| "description": "User preferences", -| "required": ["theme"], -| "properties": { -| "theme": {"type": "string"}, -| "language": {"type": "string"} -| } -| }, -| "dynamicEntityId": "abc-123-def", -| "userId": "user-456", -| "hasPersonalEntity": true, -| "bankId": null -| } -| ] -|} -|``` -| -|To extract the entity name from the v4.0.0 format programmatically, find the key that is NOT one of the standard properties: dynamicEntityId, userId, hasPersonalEntity, bankId. -| -|**Required roles:** -| -|* CanCreateSystemLevelDynamicEntity - To create system level dynamic entities -|* CanCreateBankLevelDynamicEntity - To create bank level dynamic entities -| -|For general information about Dynamic Entities, see ${getGlossaryItemLink("Dynamic-Entities")} -| -""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Dynamic Endpoint Manage", - description = - s""" -| -|If you want to create endpoints from Swagger / Open API specification files, use Dynamic Endpoints. -| -|We use the term "Dynamic" because these Endpoints persist in the OBP database and are served from real time generated Scala code. -| -|This contrasts to the "Static" endpoints (see the Static glossary item) which are served from static Scala code. -| -|Dynamic endpoints can be changed in real-time and do not require an OBP instance restart. -| -|When you POST a swagger file, all the endpoints defined in the swagger file, will be created in this OBP instance. -| -|You can create a set of endpoints in three different modes: -| -|1) If the *host* field in the Swagger file is set to "dynamic_entity", then you should link the swagger JSON fields to Dynamic Entity fields. To do this use the *Endpoint Mapping* endpoints. -| -|2) If the *host* field in the Swagger file is set to "obp_mock", the Dynamic Endpoints created will return *example responses defined in the swagger file*. -| -|3) If you need to link the responses to external resource, use the *Method Routing* endpoints. -| -| -|Dynamic Endpoints can be created at the System level (bank_id is null) or Bank / Space level (bank_id is NOT null). -|You might want to create Bank level Dynamic Entities in order to grant automated roles based on user email domain. See the OBP-API sample.props.template -| -|Upon the successful creation of each Dynamic Endpoint, OBP will automatically: -| -|*Create a Guard with a named Role on the Endpoint to protect it from unauthorised users. -|*Grant you an Entitlement to the required Role so you can call the endpoint and pass its Guard. -| -|The following videos are available: -| -| * [Introduction to Dynamic Endpoints](https://vimeo.com/426235612) -| * [Features of Dynamic Endpoints](https://vimeo.com/444133309) -| -""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Endpoint Mapping", - description = - s""" - |Endpoint Mapping can be used to map each JSON field in a Dynamic Endpoint to different Dynamic Entity fields. - | - |This document assumes you already have some knowledge of OBP Dynamic Endpoints and Dynamic Entities. - | - |To enable Endpoint Mapping for your Dynamic Endpoints, either set the `host` in the swagger file to "dynamic_entity" upon creation of the Dynamic Endpoints - or update the host using the Update Dynamic Endpoint Host endpoints. - | - |Once the `host` is thus set, you can use the Endpoint Mapping endpoints to map the Dynamic Endpoint fields to Dynamic Entity data. - | - |See the [Create Endpoint Mapping](/index#OBPv4.0.0-createEndpointMapping) JSON body. You will need to know the operation_id in advance and you can prepare the request_mapping and response_mapping objects. You can get the operation ID from the API Explorer or Get Dynamic Endpoints endpoints. - | - |For more details and a walk through, please see the following video: - | - | * [Endpoint Mapping](https://vimeo.com/553369108) - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Branch", - description = - s"""The bank branches, it contains the address, location, lobby, drive_up of the Branch. - """.stripMargin) - - glossaryItems += GlossaryItem( - title = "API", - description = - s"""|The terms `API` (Application Programming Interface) and `Endpoint` are used somewhat interchangeably. -| -|However, an API normally refers to a group of Endpoints. -| -|An endpoint has a unique URL path and HTTP verb (GET, POST, PUT, DELETE etc). -| -|When we POST a Swagger file to the Create Endpoint endpoint, we are in fact creating a set of Endpoints that have a common Tag. Tags are used to group Endpoints in the API Explorer and filter the Endpoints in the Resource Doc endpoints. -| -|Endpoints can also be grouped together in Collections. -| -|See also [Endpoint](/glossary#Endpoint) -| - """.stripMargin) - - glossaryItems += GlossaryItem( - title = "Endpoint", - description = - s""" -|The terms `Endpoint` and `API` (Application Programming Interface) are used somewhat interchangeably. However, an Endpoint is a specific URL defined by its path (eg. /obp/v4.0/root) and its http verb (e.g. GET, POST, PUT, DELETE etc). -|Endpoints are like arrows into a system. Like any good computer function, endpoints should expect much and offer little in return. They should fail early and be clear about any reason for failure. In other words each endpoint should have a tight and limited contract with any caller - and especially the outside world! -| -|In OBP, all system endpoints are RESTful - and most Open Banking Standards are RESTful. However, it is possible to create non-RESTful APIs in OBP using the Create Endpoint endpoints. -| -|You can immediately tell if an endpoint is not RESTful by seeing a verb in the URL. For example: -| -|POST /customers is RESTful = GOOD -|POST /create-customer is NOT RESTful (due to the word "create") = BAD -| -|RESTful APIs use resource names in URL paths. You can think of RESTful resources like database tables. You wouldn't name a database table "create-customer", so don't use that in a URL path. -| -|If we consider interacting with a Customers table, we read the data using GET /Customers and write to the table using POST /Customers. This model keeps the names clear and predictable. -|Note that we are only talking about the front end interface here - anything could be happening in the backend - and that is one of the beauties of APIs. For instance GET /Customers could call 5 different databases and 3 XML services in the background. Similarly POST /Customers could insert into various different tables and backend services. The important thing is that the user of the API (The Consumer or Client in OAuth parlance) has a simple and consistent experience. -| -|In OBP, all Endpoints are implemented by `Partial Functions`. A Partial Function is a function which only accepts (and responds) to calls with certain parameter values. In the case of API Endpoints the inputs to the Partial Functions are the URL path and http verb. Note that it would be possible to have different Partial Functions respond even to different query parameters, but for OBP static endpoints at least, we take the approach of URL path + http Verb is handled by one Partial Function. -|Each Partial Function is identified by an Operation ID which uniquely identifies the endpoint in the system. Having an Operation ID allows us to decorate the Endpoint with metadata (e.g. Tags) and surround the Endpoint with behaviour such as JSON Schema Validation. -| -|See also [API](/glossary#API) -| -""".stripMargin) - - - - glossaryItems += GlossaryItem( - title = "API Tag", - description = - s"""All OBP API relevant docs, eg: API configuration, JSON Web Key, Adapter Info, Rate Limiting - """.stripMargin) - - - - glossaryItems += GlossaryItem( - title = "Account Access", - description = - s""" - |Account Access governs access to Bank Accounts by end Users. It is an intersecting entity between the User and the View Definition. - |A User must have at least one Account Access record record in order to interact with a Bank Account over the OBP API. - |""".stripMargin) - -// val allTagNames: Set[String] = ApiTag.allDisplayTagNames -// val existingItems: Set[String] = glossaryItems.map(_.title).toSet -// allTagNames.diff(existingItems).map(title => glossaryItems += GlossaryItem(title, title)) - - glossaryItems += GlossaryItem( - title = "Static Endpoint", - description = - s""" -|Static endpoints are served from static Scala source code which is contained in (public) Git repositories. -| -|Static endpoints cover all the OBP API and User management functionality as well as the Open Bank Project banking APIs and other Open Banking standards such as UK Open Banking, Berlin Group and STET etc.. - |In short, Static (standard) endpoints are defined in Git as Scala source code, where as Dynamic (custom) endpoints are defined in the OBP database. - | -|Modifications to Static endpoint core properties such as URLs and response bodies require source code changes and an instance restart. However, JSON Schema Validation and Dynamic Connector changes can be applied in real-time. -""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Message Doc", - description = - s""" -|OBP can communicate with core banking systems (CBS) and other back end services using a "Connector -> Adapter" approach. -| -|The OBP Connector is a core part of the OBP-API and is written in Scala / Java and potentially other JVM languages. -| -|The OBP Connector implements multiple functions / methods in a style that satisfies a particular transport / protocol such as HTTP REST, Akka or RabbitMq. -| -|An OBP Adapter is a separate software component written in any programming language that responds to requests from the OBP Connector. -| -|Requests are sent by the Connector to the Adapter (or a message queue). -| -|The Adapter must satisfy the Connector method's request for data (or return an error). -| -|"Message Docs" are used to define and document the request / response structure. -| -|Message Docs are visible in the API Explorer. -| -|Message Docs are also available over the Message Doc endpoints. -| -|Each Message Doc relates to one OBP function / method. -| -|The Message Doc includes: -| -| 1) The Name of the internal OBP function / method e.g. getAccountsForUser -| 2) The Outbound Message structure. -| 3) The Inbound Message structure. -| 4) The Connector name which denotes the protocol / transport used (e.g. REST, Akka, RabbitMq etc) -| 5) Outbound / Inbound Topic -| 6) A list of required Inbound fields -| 7) A list of dependent endpoints. -| -|The perspective is that of the OBP-API Connector i.e. the OBP Connector sends the message Out, and it receives the answer In. -| -|The Outbound message contains several top level data structures: -| -| 1) The outboundAdapterCallContext -| -| This tells the Adapter about the specific REST call that triggered the request and contains the correlationId to uniquely identify the REST call, the consumerId to identify the API Consumer (App) and a generalContext which is a list of key / value pairs that give the Adapter additional custom information about the call. -| -| 2) outboundAdapterAuthInfo -| -|This tells the Adapter about the authenticated User that is making the call including: the userId, the userName, the userAuthContext (a list of key / value pairs that have been validated using SCA (see the UserAuthContext endpoints)) and other optional structures such as linked Customers and Views on Accounts to further identify the User. -| -|3) The body -| -|The body contains named fields that are specific to each Function / Message Doc. -| -|For instance, getTransaction might send the bankId, accountId and transactionId so the Adapter can route the request based on bankId and check User permissions on the AccountId before retrieving a Transaction. -| -|The Inbound message -| -|The Inbound message is the reply or response from the Adapter and has the following structure: -| -|1) The inboundAdapterCallContext -| -|This is generally an echo of the outboundAdapterCallContext so the Connector can double check the target destination of the response. -| -|2) The status -| -|This contains information about status of the response including any errorCode and a list of backendMessages. -| -|3) The data -| -|This contains the named fields and their values which are specific to each Function / Message Doc. -| -| -|The Outbound / Inbound Topics are used for routing in multi OBP instance / RabbitMq installations. (so OBP nodes only listen only to the correct Topics). -| -|The dependent endpoints are listed to facilitate navigation in the API Explorer so integrators can test endpoints during integration. -| -|Message Docs can be generated automatically using OBP code tools. Thus, it's possible to create custom connectors that follow specific protocol and structural patterns e.g. for message queue X over XML format Y. -| -|""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Method Routing", - description = - s""" - | - | Open Bank Project can have different connectors, to connect difference data sources. - | We support several sources at the moment, eg: databases, rest services, stored procedures and RabbitMq. - | - | If OBP set connector=star, then you can use this method routing to switch the sources. - | And we also provide the fields mapping in side the endpoints. If the fields in the source are different from connector, - | then you can map the fields yourself. - | - | The following videos are available: - | - | *[Method Routing Endpoints](https://vimeo.com/398973130) - | *[Method Routing Endpoints Mapping](https://vimeo.com/404983764) - | - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "JSON Schema Validation", - description = - s""" - | - |JSON Schema is "a vocabulary that allows you to annotate and validate JSON documents". - | - |By applying JSON Schema Validation to your OBP endpoints you can constrain POST and PUT request bodies. For example, you can set minimum / maximum lengths of fields and constrain values to certain lists or regular expressions. - | - |See [JSONSchema.org](https://json-schema.org/) for more information about the JSON Schema standard. -| -|To create a JSON Schema from an any JSON Request body you can use [JSON Schema Net](https://jsonschema.net/app/schemas/0) -| -|(The video link below shows how to use that) - | - |Note: OBP Dynamic Entities also use JSON Schema Validation so you don't need to additionally wrap the resulting endpoints with extra JSON Schema Validation but you could do. - | - | You can apply JSON schema validations to any OBP endpoint's request body using the POST and PUT endpoints listed in the link below. - | - |PLEASE SEE the following video explanation: [JSON schema validation of request for Static and Dynamic Endpoints and Entities](https://vimeo.com/485287014) - | - |""".stripMargin) - - - glossaryItems += GlossaryItem( - title = "Connector Method", - description = - s""" - | Developers can override all the existing Connector methods. - | This function needs to be used together with the Method Routing. - | When we set "connector = internal", then the developer can call their own method body at API level. - | - |For example, the GetBanks endpoint calls the connector "getBanks" method. Then, developers can use these endpoints to modify the business logic in the getBanks method body. - | - | The following videos are available: - |* [Introduction for Connector Method] (https://vimeo.com/507795470) - |* [Introduction 2 for Connector Method] (https://vimeo.com/712557419) - | - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Dynamic Message Doc", - description = - s""" - | In OBP we represent messages sent by a Connector method / function as MessageDocs. - | A MessageDoc defines the message the Connector sends to an Adapter and the response it expects from the Adapter. - | - | Using this endpoint, developers can create their own scala methods aka Connectors in OBP code. - | These endpoints are designed for extending the current connector methods. - | - | When you call the Dynamic Resource Doc endpoints, sometimes you need to call internal Scala methods which - |don't yet exist in the OBP code. In this case you can use these endpoints to create your own internal Scala methods. - | - |You can also use these endpoints to create your own helper methods in OBP code. - | - | This feature is somewhat work in progress (WIP). -| - |The following videos are available: - |* [Introduction to Dynamic Message Doc] (https://vimeo.com/623317747) - | - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "QWAC", - description = - s"""A Qualified Website Authentication Certificate is a qualified digital certificate under the trust services defined in the European Union eIDAS Regulation. - |A website authentication certificate makes it possible to establish a Transport Layer Security channel with the subject of the certificate, which secures data transferred through the channel.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Dynamic linking (PSD2 context)", - description = - s"""Dynamic linking is a security requirement under PSD2's Strong Customer Authentication (SCA) rules. - | - |When a payer initiates an electronic payment transaction, the authentication code must be dynamically linked to: - | - |1. **The amount** of the transaction - |2. **The payee** (recipient) of the transaction - | - |This means if either the amount or payee is modified after authentication, the authentication code becomes invalid. This protects against man-in-the-middle attacks where an attacker might try to redirect funds or change the payment amount after the user has authenticated. - | - |The requirement is specified in Article 97(2) of PSD2 and further detailed in the Regulatory Technical Standards (RTS) on SCA (Articles 5 and 6). - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "TPP", - description = - s"""(TPP) Third Party Providers are authorised/registered organisations or natural persons that use APIs developed to Standards to access customer’s accounts, in order to provide account information services and/or to initiate payments. - |Third Party Providers are either/both Payment Initiation Service Providers (PISPs) and/or Account Information Service Providers (AISPs).""".stripMargin) - - glossaryItems += GlossaryItem( - title = "QSealC", - description = - s"""Qualified electronic Seal Certificate. - |A certificate for electronic seals allows the relying party to validate the identity of the subject of the certificate, - |as well as the authenticity and integrity of the sealed data, and also prove it to third parties. - |The electronic seal provides strong evidence, capable of having legal effect, that given data is originated by the legal entity identified in the certificate.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "CRL", - description = - s"""Certificate Revocation List. - |CRL issuers issue CRLs. The CRL issuer is either the CA (certification authority) or an entity that has been authorized by the CA to issue CRLs. - |CAs publish CRLs to provide status information about the certificates they issued. - |However, a CA may delegate this responsibility to another trusted authority. - |It is described in RFC 5280.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "OCSP", - description = - s"""The Online Certificate Status Protocol (OCSP) is an Internet protocol used for obtaining the revocation status of an X.509 digital certificate. - |It is described in RFC 6960 and is on the Internet standards track. It was created as an alternative to certificate revocation lists (CRL),""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Cross-Device Authorization", - description = - s""" - |Cross-device authorization flows enable a user to initiate an authorization flow on one device - |(the Consumption Device) and then use a second, personally trusted, device (Authorization Device) to - |authorize the Consumption Device to access a resource (e.g., access to a service). - |Two examples of popular cross-device authorization flows are: - | - The Device Authorization Grant [RFC8628](https://datatracker.ietf.org/doc/html/rfc8628) - | - Client-Initiated Backchannel Authentication [CIBA]((https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)) - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Consumption Device (CD)", - description = - s"""The Consumption Device is the device that helps the user consume the service. In the [CIBA]((https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html)) use case, the user is not necessarily in control of the CD. For example, the CD may be in the control of an RP agent (e.g. at a bank teller) or might be a device controlled by the RP (e.g. a petrol pump)|""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Authentication Device (AD)", - description = - s"""The device on which the user will authenticate and authorize the request, often a smartphone.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Risk-based authentication", - description = - s"""Please take a look at "Adaptive authentication" glossary item.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Adaptive authentication", - description = - s"""Adaptive authentication, also known as risk-based authentication, is dynamic in a way it automatically triggers additional authentication factors, usually via MFA factors, depending on a user's risk profile. - |An example of this authentication at OBP-API side is the feature "Transaction request challenge threshold". - | - - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Transaction request challenge threshold", - description = - s"""Is an example of "Adaptive authentication" where, in a dynamic way, we get challenge threshold via CBS depending on a user's risk profile. - |It implies that in a case of risky transaction request, over a certain amount, a user is prompted to answer the challenge.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Multi-factor authentication (MFA)", - description = - s"""Multi-factor authentication (MFA) is a multi-step account login process that requires users to enter more information than just a password. For example, along with the password, users might be asked to enter a code sent to their email, answer a secret question, or scan a fingerprint.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "CIBA", - description = - s"""An acronym for Client-Initiated Backchannel Authentication. - |For more details about it please take a look at the official specification: [OpenID Connect Client Initiated Backchannel Authentication Flow](https://openid.net/specs/openid-client-initiated-backchannel-authentication-core-1_0.html) - |Please note it is a cross-device protocol and SHOULD not be used for same-device scenarios. - |If the Consumption Device and Authorization Device are the same device, protocols like OpenID Connect Core [OpenID.Core](https://openid.net/specs/openid-connect-core-1_0.html) and OAuth 2.0 Authorization Code Grant as defined in [RFC6749](https://www.rfc-editor.org/info/rfc6749) are more appropriate.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "OIDC", - description = - s"""An acronym for OpenID Connect (OIDC) is an identity authentication protocol that is an extension of open authorization (OAuth) 2.0 to standardize the process for authenticating and authorizing users when they sign in to access digital services.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "How OpenID Connect Works", - description = - s"""The OpenID Connect protocol, in abstract, follows these steps: - | - |* End user navigates to a website or web application via a browser. - |* End user clicks sign-in and types their username and password. - |* The RP (Client) sends a request to the OpenID Provider (OP). - |* The OP authenticates the User and obtains authorization. - |* The OP responds with an Identity Token and usually an Access Token. - |* The RP can send a request with the Access Token to the User device. - |* The UserInfo Endpoint returns Claims about the End-User. - |### Terminology - |#### Authentication - |The secure process of establishing and communicating that the person operating an application or browser is who they claim to be. - |#### Client - |A client is a piece of software that requests tokens either for authenticating a user or for accessing a resource (also often called a relying party or RP). - |A client must be registered with the OP. Clients can be web applications, native mobile and desktop applications, etc. - |#### Relying Party (RP) - |RP stands for Relying Party, an application or website that outsources its - |user authentication function to an IDP. - |#### OpenID Provider (OP) or Identity Provider (IDP) - |An OpenID Provider (OP) is an entity that has implemented the OpenID Connect and OAuth 2.0 protocols, - |OP’s can sometimes be referred to by the role it plays, such as: a security token service, - |an identity provider (IDP), or an authorization server. - |#### Identity Token - |An identity token represents the outcome of an authentication process. - |It contains at a bare minimum an identifier for the user (called the sub aka subject claim) - |and information about how and when the user authenticated. It can contain additional identity data. - |#### User - |A user is a person that is using a registered client to access resources. - | """.stripMargin) - - glossaryItems += GlossaryItem( - title = "Authentication: OAuth 2.0", - description = - s"""OAuth 2.0, is a framework, specified by the IETF in RFCs 6749 and 6750 (published in 2012) designed to support the development of authentication and authorization protocols. It provides a variety of standardized message flows based on JSON and HTTP.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "FAPI", - description = - s"""An acronym for Financial-grade API.""".stripMargin) - - glossaryItems += GlossaryItem( - title = "FAPI 1.0", - description = - s"""The Financial-grade API is a highly secured OAuth profile that aims to provide specific implementation guidelines for security and interoperability. - |The Financial-grade API security profile can be applied to APIs in any market area that requires a higher level of security than provided by standard [OAuth](https://datatracker.ietf.org/doc/html/rfc6749) or [OpenID Connect](https://openid.net/specs/openid-connect-core-1_0.html). - |Financial-grade API Security Profile 1.0 consists of the following parts: - | - |* Financial-grade API Security Profile 1.0 - Part 1: Baseline - |* Financial-grade API Security Profile 1.0 - Part 2: Advanced - | - |These parts are intended to be used with RFC6749, RFC6750, RFC7636, and OIDC. - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Transaction-Request-Introduction", - description = - s""" - |In OBP we initiate a Payment by creating a Transaction Request. - | - |An OBP `transaction request` may or may not result in a `transaction`. However, a `transaction` only has one possible state: completed. - | - |A `Transaction Request` can have one of several states: INITIATED, NEXT_CHALLENGE_PENDING etc. - | - |`Transactions` are modeled on items in a bank statement that represent the movement of money. - | - |`Transaction Requests` are requests to move money which may or may not succeed and thus result in a `Transaction`. - | - |A `Transaction Request` might create a security challenge that needs to be answered before the `Transaction Request` proceeds. - |In case 1 person needs to answer security challenge we have next flow of state of an `transaction request`: - | INITIATED => COMPLETED - |In case n persons needs to answer security challenge we have next flow of state of an `transaction request`: - | INITIATED => NEXT_CHALLENGE_PENDING => ... => NEXT_CHALLENGE_PENDING => COMPLETED - | - |The security challenge is bound to a user i.e. in case of right answer and the user is different than expected one the challenge will fail. - | - |Rule for calculating number of security challenges: - |If product Account attribute REQUIRED_CHALLENGE_ANSWERS=N then create N challenges - |(one for every user that has a View where permission $CAN_ADD_TRANSACTION_REQUEST_TO_ANY_ACCOUNT=true) - |In case REQUIRED_CHALLENGE_ANSWERS is not defined as an account attribute default value is 1. - | - |Transaction Requests contain charge information giving the client the opportunity to proceed or not (as long as the challenge level is appropriate). - | - |Transaction Requests can have one of several Transaction Request Types which expect different bodies. The escaped body is returned in the details key of the GET response. - |This provides some commonality and one URL for many different payment or transfer types with enough flexibility to validate them differently. - | - |The payer is set in the URL. Money comes out of the BANK_ID and ACCOUNT_ID specified in the URL. - | - |In sandbox mode, TRANSACTION_REQUEST_TYPE is commonly set to ACCOUNT. See getTransactionRequestTypesSupportedByBank for all supported types. - | - |In sandbox mode, if the amount is less than 1000 EUR (any currency, unless it is set differently on this server), the transaction request will create a transaction without a challenge, else the Transaction Request will be set to INITIALISED and a challenge will need to be answered. - | - |If a challenge is created you must answer it using Answer Transaction Request Challenge before the Transaction is created. - | - |You can transfer between different currency accounts. (new in 2.0.0). The currency in body must match the sending account. - | - |For exchange rates in this sandbox see here: ${Glossary.getGlossaryItemLink("FX-Rates")} - | - |Transaction Requests satisfy PSD2 requirements thus: - | - |1) A transaction can be initiated by a third party application. - | - |2) The customer is informed of the charge that will incurred. - | - |3) The call supports delegated authentication (OAuth) - | - |See [this python code](https://github.com/OpenBankProject/Hello-OBP-DirectLogin-Python/blob/master/hello_payments.py) for a complete example of this flow. - | - |There is further documentation [here](https://github.com/OpenBankProject/OBP-API/wiki/Transaction-Requests) - | - | - | - |""".stripMargin) - -// val exchangeRates = -// APIUtil.getPropsValue("webui_api_explorer_url", "") + -// "/more?version=OBPv4.0.0&list-all-banks=false&core=&psd2=&obwg=#OBPv2_2_0-getCurrentFxRate" - - glossaryItems += GlossaryItem( - title = "FX-Rates", - description = - s"""You can use the following endpoint to get the FX Rates available on this OBP instance: ${getApiExplorerLink("Get FX Rates", "OBPv2.2.0-getCurrentFxRate")} -| -|""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Counterparty-Limits", - description = - s"""Counterparty Limits can be used to restrict payment (Transaction Request) amounts and frequencies (per month, year, total) that can be made to a Counterparty (Beneficiary). - | -|Counterparty Limits can be used to limit both single or repeated payments (VRPs) to a Counterparty Beneficiary. -| -|Counterparty Limits reference a counterparty_id (a UUID) rather an an IBAN or Account Number. -|This means it is possible to have multiple Counterparties that refer to the same external bank account. -|In other words, a Counterparty Limit restricts an OBP Counterparty rather than a certain IBAN or other Bank Account Number. -| -|Since Counterparties are bound to OBP Views it is possible to create similar Counterparties used by different Views. This is by design i.e. a Two Users called Accountant1 could Accountant2 could create their own Views and Counterparties referencing the same corporation but still have their own limits say for different cost centers. -| -|To manually create and use a Counterparty Limit via a Consent for Variable Recurring Payments (VRP) you would: - |1) Create a Custom View named e.g. VRP1. - |2) Place a Beneficiary Counterparty on that view. - |3) Add Counterparty Limits for that Counterparty. - |4) Generate a Consent containing the bank, account and view (e.g. VRP1) - |5) Let the App use the consent to trigger Transaction Requests. -| -|However, you can use the following ${Glossary.getApiExplorerLink("endpoint", "OBPv5.1.0-createVRPConsentRequest")} to automate the above steps. -| - |""".stripMargin) - - - - - - - glossaryItems += GlossaryItem( - title = "FAPI 2.0", - description = - s"""FAPI 2.0 has a broader scope than FAPI 1.0. - |It aims for complete interoperability at the interface between client and authorization server as well as interoperable security mechanisms at the interface between client and resource server. - |It also has a more clearly defined attacker model to aid formal analysis. - |Please note that FAPI 2.0 is still in draft.""".stripMargin) - - - glossaryItems += GlossaryItem( - title = "Available FAPI profiles", - description = - s"""The following are the FAPI profiles which are either in use by multiple implementers or which are being actively developed by the OpenID Foundation’s FAPI working group: - | - |* FAPI 1 Implementers Draft 6 (OBIE Profile) - |* FAPI 1 Baseline - |* FAPI 1 Advanced - |* Brazil Security Standard - |* FAPI 2 - |* FAPI 2 Message Signing: - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Counterparties", - description = - s""" -| -|In OBP, there are two types of Counterparty: -| -|* Explicit Counterparties are created by calling an OBP endpoint - mainly for the purpose of creating a payment or variable recurring payments (VRPs) via Transaction Requests. -| -|* Implicit Counterparties (or "Other Accounts") are generated automatically from transactions - mainly for the purpose of tagging or adding other metadata. -| -|Counterparties always bound to a "View" on an Account. In this way, different managers of an account can use different sets of beneficiaries. -| -|Counterparties can be thought of the other side of of a transaction i.e. the other account or other party. -| -|Common fields in a Counterparty are: -| -|- id : A UUID which references it. -| -|- name : the human readable name (e.g. Piano teacher) -| -|- description : the human readable name (e.g. Piano teacher) -| -|- currency : account currency (e.g. EUR, GBP, USD, ...) -| -|- other_bank_routing_scheme : eg: 'OBP', 'BIC', 'bankCode' etc -| -|- other_bank_routing_address : eg: `gh.29.uk` - it must be a valid example of the scheme and may be validated for existance. -| -|- other_account_routing_scheme : eg: 'OBP', 'IBAN', 'AccountNumber' etc. -| -|- other_account_routing_address : eg: `1d65db7c-a7b2-4839-af41-95` - a valid example of the scheme which may be validated for existance. -| -|The above fields describe how the backend can route payments to the counterparty. -| -|Alternative routings might be useful as well: -| -|- other_account_secondary_routing_scheme : An alternative routing scheme -| -|- other_account_secondary_routing_address : If it is an IBAN value, it should be unique for each counterparty. -| -|- other_branch_routing_scheme : eg: OBP or other branch scheme -| -|- other_branch_routing_address : eg: `branch-id-123. Unlikely to be used in sandbox mode. -| -|In order to send payments to a counterparty: -| -|- is_beneficiary : must be set to `true` -| -|If the backend wants to transmit other information we can use: -| -| - bespoke: A list of key-value pairs can be added to the counterparty. -| -|Note: In order to add a Counterparty to a View, the view must have the canAddCounterparty permission -| -|Counterparties may have Limits have setup for them which constrain payments made to them through Variable Recurring Payments (VRP). - | - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Regulated-Entities", - description = - s""" - |In the context of the Open Bank Project (OBP), a "Regulated Entity" refers to organizations that are recognized and authorized to provide financial services under regulatory frameworks. These entities are overseen by regulatory authorities to ensure compliance with financial regulations and standards. - | - |## Key Points About Regulated Entities in OBP: - | - |**Endpoint for Retrieval**: You can retrieve information about regulated entities using the ${getApiExplorerLink("Get Regulated Entities", "OBPv5.1.0-regulatedEntities")} endpoint. This does not require authentication and provides data on various regulated entities, including their services, entity details, and more. - | - |**Creating a Regulated Entity**: The API also allows for the creation of a regulated entity using the ${getApiExplorerLink("Create Regulated Entity", "OBPv5.1.0-createRegulatedEntity")} endpoint. User authentication is required for this operation. - | - |**Retrieving Specific Entity Details**: To get details of a specific regulated entity, you can use the ${getApiExplorerLink("Get Regulated Entity by Id", "OBPv5.1.0-getRegulatedEntityById")} endpoint, where you need to specify the entity ID. No authentication is needed. - | - |**Deleting a Regulated Entity**: If you need to remove a regulated entity, the ${getApiExplorerLink("Delete Regulated Entity", "OBPv5.1.0-deleteRegulatedEntity")} endpoint is available, but it requires authentication. - | - |## Entity Information: - | - |Each regulated entity has several attributes, including: - | - |* **Entity Code**: A unique identifier for the entity - |* **Website**: The entitys official website URL - |* **Country and Address Details**: Location information for the entity - |* **Certificate Public Key**: Public key used for digital certificates - |* **Entity Type and Name**: Classification and official name of the entity - |* **Services offered**: List of financial services provided by the entity - | - |Regulated entities play a crucial role in maintaining trust and compliance within the financial ecosystem managed through the OBP platform. - | - |## Configuration Properties: - | - |Regulated entities functionality is supported by several configuration properties in OBP: - | - |**Certificate and Signature Verification** (for Berlin Group/PSD2 TPP authentication): - | - |* `truststore.path.tpp_signature` - Path to the truststore containing TPP certificates - |* `truststore.password.tpp_signature` - Password for the TPP signature truststore - |* `truststore.alias.tpp_signature` - Alias for the TPP signature certificate - | - |**Fallback Certificate Configuration**: - | - |* `truststore.path` - General truststore path (fallback if TPP-specific not set) - |* `keystore.path` - Path to the keystore for certificate operations - |* `keystore.password` - Password for the keystore - |* `keystore.passphrase` - Passphrase for keystore private keys - |* `keystore.alias` - Alias for certificate entries in keystore - | - |These properties are used for TPP (Third Party Provider) certificate validation in PSD2/Berlin Group implementations, where regulated entities authenticate using QWAC (Qualified Website Authentication Certificate) or other qualified certificates. - | - |## Internal Usage by OBP: - | - |OBP internally uses regulated entities for several authentication and authorization functions: - | - |**Certificate-Based Authentication**: When the property `requirePsd2Certificates=ONLINE` is set, OBP automatically validates incoming API requests against registered regulated entities using their certificate information. - | - |**Automatic Consumer Creation**: For Berlin Group/PSD2 compliance, OBP automatically creates API consumers for TPPs based on their regulated entity registration and certificate validation. - | - |**Service Provider Authorization**: OBP checks if regulated entities have the required service provider roles (PSP_AI, PSP_PI, PSP_IC, PSP_AS) before granting access to specific API endpoints. - | - |**Berlin Group/UK Open Banking Integration**: Many Berlin Group (v1.3) and UK Open Banking (v3.1.0) API endpoints automatically call `passesPsd2Aisp()` and related functions to validate regulated entity certificates. - | - |This integration ensures that only properly registered and certificated Third Party Providers can access sensitive banking data and payment initiation services in compliance with PSD2 regulations. - | - |## Real-Time Entity / Certificate Retrieval: - | - |Regulated Entities can be retrieved in real time from the National Authority / National Bank through the following data flow patterns: - | - |**Direct National Authority Connection**: - | - |`OBP BG API instance -> getRegulatedEntities -> Connector -> National Authority` - | - |**Via OBP Regulated Entities API Instance**: - | - |`OBP BG API instance -> getRegulatedEntities -> Connector -> OBP Regulated Entities API instance -> Connector -> National Authority` - | - |This real-time integration ensures that regulated entity information is always current and reflects the latest regulatory status and certifications from official national sources. - | - | - |**RabbitMQ Message Documentation** (other connectors are also available): - | - |* ${messageDocLinkRabbitMQ("obp.getRegulatedEntities")} - Retrieve all regulated entities - |* ${messageDocLinkRabbitMQ("obp.getRegulatedEntityByEntityId")} - Retrieve a specific regulated entity by ID - | For instance, a National Authority might publish: - |{ -| "comercialName": "BANK_X_TPP_AISP", -| "idno": "1234567890123", -| "licenseNumber": "123456_bank_x", -| "roles": [ -| "PISP" -| ], -| "certificate": { -| "snCert": "117", -| "caCert": "Bank (test)" -| } -|} -| -| -|and the Bank's OBP Adapter converts this and returns it to the connector like so: -| -|{ -| "inboundAdapterCallContext": { -| "correlationId": "f347feb7-0c25-4a2f-8a40-d853917d0ccd" -| }, -| "status": { -| "errorCode": "", -| "backendMessages": [] -| }, -| "data": [ -| { -| "entityName": "BANCA COM S.A.", -| "entityCode": "198762948", -| "attributes": [ -| { -| "attributeType": "STRING", -| "name": "CERTIFICATE_SERIAL_NUMBER", -| "value": "1082" -| }, -| { -| "attributeType": "STRING", -| "name": "CERTIFICATE_CA_NAME", -| "value": "BANK CA (test)" -| } -| ], -| "services": [ -| { -| "roles": [ -| "PSP_PI", -| "PSP_AI" -| ] -| } -| ] -| }, -| { -| "entityName": "Bank Y S.A.", -| "entityCode": "1029876963", -| "attributes": [ -| { -| "attributeType": "STRING", -| "name": "CERTIFICATE_SERIAL_NUMBER", -| "value": "1135" -| }, -| { -| "attributeType": "STRING", -| "name": "CERTIFICATE_CA_NAME", -| "value": "BANK CA (test)" -| } -| ], -| "services": [ -| { -| "roles": [ -| "PSP_PI", -| "PSP_AI" -| ] -| } -| ] -| } -| ] -|} -| -| Note the use of Regulated Entity Attribute Names to handle different data types from the national authority. - | - |Note: You can / should run a separate instance of OBP for surfacing the Regulated Entities endpoints. - |""".stripMargin) - - - glossaryItems += GlossaryItem( - title = "ABAC_Simple_Guide", - description = - s""" - |# ABAC Rules Engine - Simple Guide - | - |## Overview - | - |The ABAC (Attribute-Based Access Control) Rules Engine allows you to create dynamic access control rules in Scala that evaluate whether a user should have access to a resource. - | - |## API Usage - | - |### Endpoint - |``` - |POST $getObpApiRoot/v6.0.0/management/abac-rules/{RULE_ID}/execute - |``` - | - |### Request Example - |```bash - |curl -X POST \\ - | '$getObpApiRoot/v6.0.0/management/abac-rules/admin-only-rule/execute' \\ - | -H 'Authorization: DirectLogin token=eyJhbGciOiJIUzI1...' \\ - | -H 'Content-Type: application/json' \\ - | -d '{ - | "bank_id": "gh.29.uk", - | "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0" - | }' - |``` - | - |## Understanding the Three User Parameters - | - |### 1. `authenticatedUserId` (Required) - |**The person actually logged in and making the API call** - | - |- The real user who authenticated - |- Retrieved from the authentication token - | - |### 2. `onBehalfOfUserId` (Optional) - |**When someone acts on behalf of another user (delegation)** - | - |- Used for delegation scenarios - |- The authenticated user is acting for someone else - |- Common in customer service, admin tools, power of attorney - | - |### 3. `userId` (Optional) - |**The target user being evaluated by the rule** - | - |- Defaults to `authenticatedUserId` if not provided - |- The user whose permissions/attributes are being checked - |- Useful for testing rules for different users - | - |## Writing ABAC Rules - | - |### Simple Rule Examples - | - |**Rule 1: User Must Own Account** - |```scala - |accountOpt.exists(account => - | account.owners.exists(owner => owner.userId == user.userId) - |) - |``` - | - |**Rule 2: Admin or Owner** - |```scala - |val isAdmin = authenticatedUser.emailAddress.endsWith("@admin.com") - |val isOwner = accountOpt.exists(account => - | account.owners.exists(owner => owner.userId == user.userId) - |) - | - |isAdmin || isOwner - |``` - | - |**Rule 3: Account Balance Check** - |```scala - |accountOpt.exists(account => account.balance.toDouble >= 1000.0) - |``` - | - |## Available Objects in Rules - | - |```scala - |authenticatedUser: User // The logged in user - |onBehalfOfUserOpt: Option[User] // User being acted on behalf of (if provided) - |user: User // The target user being evaluated - |bankOpt: Option[Bank] // Bank context (if bank_id provided) - |accountOpt: Option[BankAccount] // Account context (if account_id provided) - |transactionOpt: Option[Transaction] // Transaction context (if transaction_id provided) - |customerOpt: Option[Customer] // Customer context (if customer_id provided) - |``` - | - |**Related Documentation:** - |- ABAC_Parameters_Summary - Complete list of all 18 parameters - |- ABAC_Object_Properties_Reference - Detailed property reference - |- ABAC_Testing_Examples - More testing examples - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "ABAC_Parameters_Summary", - description = - s""" - |# ABAC Rule Parameters Summary - | - |The ABAC Rules Engine provides 18 parameters to your rule function, organized into three categories: - | - |## User Parameters (6 parameters) - | - |1. **authenticatedUser: User** - The logged-in user - |2. **authenticatedUserAttributes: List[UserAttributeTrait]** - Non-personal attributes of authenticated user (IsPersonal=false) - |3. **authenticatedUserAuthContext: List[UserAuthContext]** - Auth context of authenticated user - |4. **onBehalfOfUserOpt: Option[User]** - User being acted on behalf of (if provided) - |5. **onBehalfOfUserAttributes: List[UserAttributeTrait]** - Non-personal attributes of on-behalf-of user (IsPersonal=false) - |6. **onBehalfOfUserAuthContext: List[UserAuthContext]** - Auth context of on-behalf-of user - | - |## Target User Parameters (3 parameters) - | - |7. **userOpt: Option[User]** - Target user being evaluated - |8. **userAttributes: List[UserAttributeTrait]** - Non-personal attributes of target user (IsPersonal=false) - |9. **user: User** - Resolved target user (defaults to authenticatedUser) - | - |## Resource Context Parameters (9 parameters) - | - |10. **bankOpt: Option[Bank]** - Bank context (if bank_id provided) - |11. **bankAttributes: List[BankAttributeTrait]** - Bank attributes - |12. **accountOpt: Option[BankAccount]** - Account context (if account_id provided) - |13. **accountAttributes: List[AccountAttribute]** - Account attributes - |14. **transactionOpt: Option[Transaction]** - Transaction context (if transaction_id provided) - |15. **transactionAttributes: List[TransactionAttribute]** - Transaction attributes - |16. **transactionRequestOpt: Option[TransactionRequest]** - Transaction request context - |17. **transactionRequestAttributes: List[TransactionRequestAttributeTrait]** - Transaction request attributes - |18. **customerOpt: Option[Customer]** - Customer context (if customer_id provided) - |19. **customerAttributes: List[CustomerAttribute]** - Customer attributes - | - |## Usage in Rules - | - |```scala - |// Access user email - |authenticatedUser.emailAddress - | - |// Check if account exists and has sufficient balance - |accountOpt.exists(account => account.balance.toDouble >= 1000.0) - | - |// Check user attributes (non-personal only) - |authenticatedUserAttributes.exists(attr => - | attr.name == "role" && attr.value == "admin" - |) - | - |// Note: Only non-personal attributes (IsPersonal=false) are included - | - |// Check delegation - |onBehalfOfUserOpt.isDefined - |``` - | - |**Related Documentation:** - |- ABAC_Simple_Guide - Getting started guide - |- ABAC_Object_Properties_Reference - Detailed property reference - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "ABAC_Object_Properties_Reference", - description = - s""" - |# ABAC Object Properties Reference - | - |This document lists all properties available on objects passed to ABAC rules. - | - |## User Object - | - |Available as: `authenticatedUser`, `user`, `onBehalfOfUserOpt.get` - | - |### Core Properties - | - |```scala - |user.userId // String - Unique user ID - |user.emailAddress // String - User's email - |user.name // String - Display name - |user.provider // String - Auth provider - |user.providerId // String - Provider's user ID - |``` - | - |### Usage Examples - | - |```scala - |// Check if user is admin - |user.emailAddress.endsWith("@admin.com") - | - |// Check specific user - |user.userId == "alice@example.com" - |``` - | - |## BankAccount Object - | - |Available as: `accountOpt.get` - | - |### Core Properties - | - |```scala - |account.accountId // AccountId - Account identifier - |account.bankId // BankId - Bank identifier - |account.accountType // String - Account type - |account.balance // BigDecimal - Current balance - |account.currency // String - Currency code (e.g., "EUR") - |account.name // String - Account name - |account.label // String - Account label - |account.owners // List[User] - Account owners - |``` - | - |### Usage Examples - | - |```scala - |// Check balance - |accountOpt.exists(_.balance.toDouble >= 1000.0) - | - |// Check ownership - |accountOpt.exists(account => - | account.owners.exists(owner => owner.userId == user.userId) - |) - | - |// Check currency - |accountOpt.exists(_.currency == "EUR") - |``` - | - |## Bank Object - | - |Available as: `bankOpt.get` - | - |### Core Properties - | - |```scala - |bank.bankId // BankId - Bank identifier - |bank.shortName // String - Short name - |bank.fullName // String - Full legal name - |bank.logoUrl // String - URL to bank logo - |bank.websiteUrl // String - Bank website URL - |bank.bankRoutingScheme // String - Routing scheme - |bank.bankRoutingAddress // String - Routing address - |``` - | - |### Usage Examples - | - |```scala - |// Check specific bank - |bankOpt.exists(_.bankId.value == "gh.29.uk") - | - |// Check bank by routing - |bankOpt.exists(_.bankRoutingScheme == "SWIFT_BIC") - |``` - | - |## Transaction Object - | - |Available as: `transactionOpt.get` - | - |### Core Properties - | - |```scala - |transaction.id // TransactionId - Transaction ID - |transaction.amount // BigDecimal - Transaction amount - |transaction.currency // String - Currency code - |transaction.description // String - Description - |transaction.startDate // Option[Date] - Posted date - |transaction.finishDate // Option[Date] - Completed date - |transaction.transactionType // String - Transaction type - |``` - | - |### Usage Examples - | - |```scala - |// Check transaction amount - |transactionOpt.exists(tx => tx.amount.abs.toDouble < 100.0) - | - |// Check transaction type - |transactionOpt.exists(_.transactionType == "SEPA") - |``` - | - |## Customer Object - | - |Available as: `customerOpt.get` - | - |### Core Properties - | - |```scala - |customer.customerId // String - Customer ID - |customer.customerNumber // String - Customer number - |customer.legalName // String - Legal name - |customer.mobileNumber // String - Mobile number - |customer.email // String - Email address - |customer.dateOfBirth // Date - Date of birth - |``` - | - |### Usage Examples - | - |```scala - |// Check customer email domain - |customerOpt.exists(_.email.endsWith("@company.com")) - |``` - | - |## Attribute Objects - | - |### UserAttributeTrait - | - |```scala - |attr.name // String - Attribute name - |attr.value // String - Attribute value - |attr.attributeType // UserAttributeType - Type of attribute - |``` - | - |### Usage Example - | - |```scala - |// Check for specific non-personal attribute - |authenticatedUserAttributes.exists(attr => - | attr.name == "department" && attr.value == "finance" - |) - | - |// Note: User attributes in ABAC rules only include non-personal attributes - |// (where IsPersonal=false). Personal attributes are not available for - |// privacy and GDPR compliance reasons. - |``` - | - |**Related Documentation:** - |- ABAC_Simple_Guide - Getting started guide - |- ABAC_Parameters_Summary - Complete parameter list - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "ABAC_Testing_Examples", - description = - s""" - |# ABAC Testing Examples - | - |## API Endpoint - | - |``` - |POST $getObpApiRoot/v6.0.0/management/abac-rules/{RULE_ID}/execute - |``` - | - |## Example 1: Admin Only Rule - | - |**Rule Code:** - |```scala - |authenticatedUser.emailAddress.endsWith("@admin.com") - |``` - | - |**Test Request:** - |```bash - |curl -X POST \\ - | '$getObpApiRoot/v6.0.0/management/abac-rules/admin-only-rule/execute' \\ - | -H 'Authorization: DirectLogin token=YOUR_TOKEN' \\ - | -H 'Content-Type: application/json' \\ - | -d '{}' - |``` - | - |**Expected Result:** - |- Admin user → `{"result": true}` - |- Regular user → `{"result": false}` - | - |## Example 2: Account Owner Check - | - |**Rule Code:** - |```scala - |accountOpt.exists(account => - | account.owners.exists(owner => owner.userId == user.userId) - |) - |``` - | - |**Test Request:** - |```bash - |curl -X POST \\ - | '$getObpApiRoot/v6.0.0/management/abac-rules/account-owner-only/execute' \\ - | -H 'Authorization: DirectLogin token=YOUR_TOKEN' \\ - | -H 'Content-Type: application/json' \\ - | -d '{ - | "user_id": "alice@example.com", - | "bank_id": "gh.29.uk", - | "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0" - | }' - |``` - | - |## Example 3: Balance Check - | - |**Rule Code:** - |```scala - |accountOpt.exists(account => account.balance.toDouble >= 1000.0) - |``` - | - |**Test Request:** - |```bash - |curl -X POST \\ - | '$getObpApiRoot/v6.0.0/management/abac-rules/high-balance-only/execute' \\ - | -H 'Authorization: DirectLogin token=YOUR_TOKEN' \\ - | -H 'Content-Type: application/json' \\ - | -d '{ - | "bank_id": "gh.29.uk", - | "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0" - | }' - |``` - | - |## Example 4: Transaction Amount Check - | - |**Rule Code:** - |```scala - |transactionOpt.exists(tx => tx.amount.abs.toDouble < 100.0) - |``` - | - |**Test Request:** - |```bash - |curl -X POST \\ - | '$getObpApiRoot/v6.0.0/management/abac-rules/small-transactions/execute' \\ - | -H 'Authorization: DirectLogin token=YOUR_TOKEN' \\ - | -H 'Content-Type: application/json' \\ - | -d '{ - | "bank_id": "gh.29.uk", - | "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", - | "transaction_id": "trans-123" - | }' - |``` - | - |## Testing Patterns - | - |### Pattern 1: Test Different Users - | - |```bash - |# Test for admin - |curl -X POST '$getObpApiRoot/v6.0.0/management/abac-rules/RULE_ID/execute' \\ - | -d '{"user_id": "admin@admin.com", "bank_id": "gh.29.uk"}' - | - |# Test for regular user - |curl -X POST '$getObpApiRoot/v6.0.0/management/abac-rules/RULE_ID/execute' \\ - | -d '{"user_id": "alice@example.com", "bank_id": "gh.29.uk"}' - |``` - | - |### Pattern 2: Test Edge Cases - | - |```bash - |# No context (minimal) - |curl -X POST '$getObpApiRoot/v6.0.0/management/abac-rules/RULE_ID/execute' -d '{}' - | - |# Full context - |curl -X POST '$getObpApiRoot/v6.0.0/management/abac-rules/RULE_ID/execute' -d '{ - | "user_id": "alice@example.com", - | "bank_id": "gh.29.uk", - | "account_id": "8ca8a7e4-6d02-48e3-a029-0b2bf89de9f0", - | "transaction_id": "trans-123", - | "customer_id": "cust-456" - |}' - |``` - | - |## Common Errors - | - |### Error 1: Rule Not Found - | - |```bash - |curl -X POST '$getObpApiRoot/v6.0.0/management/abac-rules/nonexistent-rule/execute' \\ - | -H 'Authorization: DirectLogin token=YOUR_TOKEN' \\ - | -d '{}' - |``` - | - |**Response:** `{"error": "ABAC Rule not found with ID: nonexistent-rule"}` - | - |### Error 2: Invalid Context - | - |**Response:** Objects will be `None` if IDs are invalid, rule should handle gracefully - | - |**Related Documentation:** - |- ABAC_Simple_Guide - Getting started guide - |- ABAC_Parameters_Summary - Complete parameter list - |- ABAC_Object_Properties_Reference - Property reference - |""".stripMargin) - - glossaryItems += GlossaryItem( - title = "Tenancy-Model-Open-Bank-Project", - description = - s""" - |The Open Bank Project (OBP) supports multi-bank operation within a single deployment, with banks acting as the primary domain and isolation boundary. Integration behaviour can be configured per bank, including connector routing based on bank_id. - | - |For SaaS deployments requiring a "dedicated tenant", OBP typically applies tenancy at the deployment level, using separate runtimes, databases, and secrets to meet regulatory and operational isolation requirements common in banking environments. - | - |Centralised operations across multiple deployments are achieved through automated platform tooling (e.g. CI/CD, configuration management, monitoring, logging, and backups), providing a unified operational experience even when tenants are deployed separately. - |""".stripMargin) - - private def getContentFromMarkdownFile(path: String): String = { - val source = scala.io.Source.fromFile(path) - val lines: String = try source.mkString finally source.close() - lines - .replaceAll("getServerUrl", getServerUrl) - .replaceAll("getObpApiRoot", getObpApiRoot) - } - - private def getListOfFiles(): List[File] = { - import java.net.URLDecoder - import java.nio.charset.StandardCharsets - val resourceUrl = getClass.getClassLoader.getResource("docs/glossary") - val resourcePath = URLDecoder.decode(resourceUrl.getPath, StandardCharsets.UTF_8.name()) - val glossaryPath = new File(resourcePath) - logger.info(s"|---> Glossary path: $glossaryPath") - - if (glossaryPath.exists && glossaryPath.isDirectory) { - Option(glossaryPath.listFiles()) - .getOrElse(Array.empty) // Avoid NullPointerException - .filter(_.isFile) - .filter(_.getName.endsWith(".md")) - .toList - } else { - logger.error(s"There are no glossary files under the path ($glossaryPath), please double check the folder path: $glossaryPath") - List.empty[File] - } - } - - // Append all files from /OBP-API/docs/glossary as items - // File name is used as a title - // File content is used as a description - glossaryItems.appendAll( - getListOfFiles().map(file => - GlossaryItem( - title = file.getName.replace(".md", "").replace("_", " "), - description = getContentFromMarkdownFile(file.getPath) - ) - ) - ) - - glossaryItems += GlossaryItem( - title = "Email Validation for OBP Local Users", - description = - s""" - |### Overview - | - |When a new OBP local user is created, they may be required to validate their email address before they can log in. - |This is controlled by the `authUser.skipEmailValidation` property (default: `false`). - | - |When email validation is enabled, the user receives an email containing a signed JWT token with a validation link. - |The user clicks the link, and the App (portal) extracts the token and calls the API to complete the validation. - | - |### Props - | - |The following properties are involved: - | - |- `authUser.skipEmailValidation` — Set to `true` to skip email validation entirely (default: `false`). Currently: `${APIUtil.getPropsAsBoolValue("authUser.skipEmailValidation", false)}` - |- `portal_external_url` — **Required.** The base URL of your frontend/portal application. Used to construct the validation link in the email. For example: `portal_external_url=https://your-portal.example.com`. Currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}` - |- `email_validation_token_expiry_minutes` — Expiry time for the validation JWT token in minutes (default: `1440` i.e. 24 hours). Currently: `${APIUtil.getPropsAsIntValue("email_validation_token_expiry_minutes", 1440)}` - | - |### Step 1: User Creation - | - |A user can be created via: - | - |**POST /obp/v6.0.0/users** (no authentication required) - | - |Request body: - | - | { - | "username": "user@example.com", - | "password": "Str0ng!Password", - | "first_name": "Jane", - | "last_name": "Doe", - | "email": "user@example.com" - | } - | - |If `authUser.skipEmailValidation=false`, the API will: - | - |1. Create the user with `validated=false` - |2. Generate a signed JWT token containing the user's unique ID as the subject, with a configurable expiry - |3. Construct a validation link: `{portal_external_url}/user-validation?token={JWT}` - |4. Send an email to the user with the validation link - | - |The user or the legacy Lift signup form can also trigger validation emails. In all cases, the same JWT-based token is used. - | - |### Step 2: Email Validation - | - |**POST /obp/v6.0.0/users/email-validation** (no authentication required) - | - |Request body: - | - | { - | "token": "eyJhbGciOiJIUzI1NiJ9..." - | } - | - |Response (201): - | - | { - | "user_id": "5995d6a2-01b3-423c-a173-5481df49bdaf", - | "email": "user@example.com", - | "username": "user@example.com", - | "provider": "https://your-api.example.com", - | "validated": true, - | "message": "Email validated successfully" - | } - | - |Error responses: - | - |- **400** — Invalid JSON format or empty token - |- **404** — Invalid or expired JWT token (bad signature, expired, or user not found) - |- **400** — User email is already validated - | - |This endpoint: - | - |1. Verifies the JWT signature (HMAC) and checks the expiry time - |2. Extracts the unique ID from the JWT subject - |3. Looks up the user by unique ID - |4. Sets the user's validated status to `true` - |5. Resets the unique ID (invalidating the token — it is single-use) - |6. Grants default entitlements to the user - | - |### Token Security - | - |- The token is a **signed JWT** (HMAC-SHA256) — it cannot be forged without the server's shared secret. - |- The token has a **configurable expiry** (default: 24 hours) set via `email_validation_token_expiry_minutes`. - |- The token is **single-use** — after validation, the unique ID is reset, so the same token cannot be used again. - | - |### Typical App Flow - | - |1. User submits registration form - |2. App calls POST /obp/v6.0.0/users - |3. App shows "Check your email for a validation link" - |4. User clicks link in email, App opens at `/user-validation?token={JWT}` - |5. App extracts the token from the URL query parameter - |6. App calls POST /obp/v6.0.0/users/email-validation with the token - |7. App shows "Email validated successfully. Please log in." - | - |""") - - glossaryItems += GlossaryItem( - title = "Password Reset for OBP Local Users", - description = - s""" - |### Overview - | - |The password reset flow allows a user who has forgotten their password to request a reset email and then set a new password. There are two steps: - | - |1. **Request a password reset email** (anonymous — no login required) - |2. **Set the new password** using the token from the email (anonymous — no login required) - | - |There is also an admin endpoint for requesting a reset on behalf of a user (requires authentication and the `CanCreateResetPasswordUrl` role). - | - |### Step 1: Request Password Reset Email - | - |**POST /obp/v6.0.0/users/password-reset-url** - | - |No authentication required. - | - |Request body: - | - | { - | "username": "user@example.com", - | "email": "user@example.com" - | } - | - |Response (201): - | - | { - | "message": "If the account exists, a password reset email has been sent." - | } - | - |Notes: - | - |- The response is always the same whether or not the user exists. This prevents user enumeration. - |- If the user exists, is validated, and the email matches, a reset email is sent containing a link with a reset token. - |- The reset link base URL is constructed from the `portal_external_url` props value (currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}`). This must be set to your frontend/portal URL so that reset emails contain the correct link. - |- The App should present a form asking for username and email, call this endpoint, and then show a message saying "Check your email for a reset link." - | - |### Step 2: Complete Password Reset - | - |**POST /obp/v6.0.0/users/password** - | - |No authentication required. - | - |Request body: - | - | { - | "token": "a1b2c3d4e5f67890abcdef1234567890", - | "new_password": "NewStr0ng!Password" - | } - | - |Response (201): - | - | { - | "message": "Password has been reset successfully." - | } - | - |Error responses: - | - |- **400** — Invalid or expired token - |- **400** — Weak password - | - |Notes: - | - |- The token is a signed JWT with a configurable expiry (default: 120 minutes). The server-side expiry can be configured with the `password_reset_token_expiry_minutes` property (currently: `${APIUtil.getPropsAsIntValue("password_reset_token_expiry_minutes", 120)}` minutes). - |- The token comes from the reset email URL. The App should extract the token from the URL path (everything after `/user_mgt/reset_password/`) and URL-decode it before sending it to this endpoint. - |- The token is single-use. Once the password is reset, the token is invalidated. An expired token will also be rejected. - | - |### Admin Endpoint (Optional) - | - |**POST /obp/v6.0.0/management/user/reset-password-url** - | - |Authentication required. Requires the `CanCreateResetPasswordUrl` role. - | - |Request body: - | - | { - | "username": "user@example.com", - | "email": "user@example.com", - | "user_id": "9ca9a7e4-6d02-40e3-a129-0b2bf89de9b1" - | } - | - |Response (201): - | - | { - | "reset_password_url": "https://your-obp-instance.com/user_mgt/reset_password/TOKEN" - | } - | - |This endpoint returns the reset URL directly (for logging/admin purposes) and also sends the email. It requires all three fields: `username`, `email`, and `user_id`. - | - |### Typical App Flow - | - |1. User clicks "Forgot Password" - |2. App shows form with username and email fields - |3. App calls POST /obp/v6.0.0/users/password-reset-url - |4. App shows "Check your email for a reset link" - |5. User clicks link in email, App opens reset page and extracts token from URL - |6. App shows form with new password field - |7. App calls POST /obp/v6.0.0/users/password with token and new_password - |8. App shows "Password has been reset successfully. Please log in." - | - |### Password Requirements - | - |The new password must meet one of these criteria: - | - |- **10-16 characters:** Must contain at least one uppercase letter, one lowercase letter, one digit, and one special character - |- **17-512 characters:** No additional complexity requirements (length alone is sufficient) - | -""") - - glossaryItems += GlossaryItem( - title = "Authentication: Credential Checking Flow", - description = - s""" - |### Overview - | - |OBP supports both **local** and **external** credential checking. Local credentials are verified against the AuthUser table (bcrypt). External credentials are delegated to a core banking system or identity provider via the Connector. - | - |### Login Flow (Web Form and DirectLogin) - | - |``` - | ┌─────────────────────────┐ - | │ LOGIN REQUEST │ - | │ (username + password) │ - | │ │ - | │ Via: Web Form login() │ - | │ or DirectLogin header │ - | └────────────┬─────────────┘ - | │ - | ▼ - | ┌─────────────────────────┐ - | │ Look up AuthUser by │ - | │ username in local DB │ - | └────────────┬─────────────┘ - | │ - | ┌─────────────────┼─────────────────┐ - | │ │ │ - | ▼ ▼ ▼ - | ┌──────────┐ ┌─────────────┐ ┌───────────┐ - | │ FOUND │ │ FOUND │ │ NOT FOUND │ - | │ Local │ │ External │ │ │ - | │ Provider │ │ Provider │ │ │ - | └────┬─────┘ └──────┬──────┘ └─────┬─────┘ - | │ │ │ - | ▼ ▼ ▼ - | ┌────────────┐ ┌─────────────┐ ┌──────────────┐ - | │ Validated? │ │ Validated? │ │ Props: │ - | │ Locked? │ │ Locked? │ │ connector. │ - | └─────┬──────┘ └──────┬──────┘ │ user.auth │ - | │ │ │ == true? │ - | ┌──Yes─┘ │ └──────┬───────┘ - | │ │ No┌──┘Yes - | ▼ │ ▼ │ - | ┌───────────────┐ │ ┌──────┐ │ - | │ testPassword() │ │ │REJECT│ │ - | │ (local bcrypt │ │ └──────┘ │ - | │ check) │ │ │ - | └───────┬────────┘ │ │ - | │ ▼ ▼ - | │ ┌─────────────┐ ┌──────────────────┐ - | │ │ Props: │ │ │ - | │ │ connector. │ │ externalUser │ - | │ │ user.auth │ │ Helper() │ - | │ │ == true? │ │ │ - | │ └──────┬──────┘ └────────┬─────────┘ - | │ No┌──┘Yes │ - | │ ▼ │ │ - | │ ┌──────┐ │ │ - | │ │REJECT│ │ │ - | │ └──────┘ │ │ - | │ ▼ │ - | │ ┌──────────────────────────────┘ - | │ │ - | │ ▼ - | │ ╔══════════════════════════════════════════════════╗ - | │ ║ checkExternalUserViaConnector() ║ - | │ ║ ║ - | │ ║ Connector.checkExternalUserCredentials ║ - | │ ║ (username, password) ║ - | │ ║ ║ - | │ ║ ┌──────────────┬──────────────┬──────────────┐ ║ - | │ ║ │ Akka │ StoredProc │ LocalMapped │ ║ - | │ ║ │ Connector │ Connector │ Connector │ ║ - | │ ║ │ │ │ │ ║ - | │ ║ │ southSide │ HTTP call to │ Returns │ ║ - | │ ║ │ Actor msg │ stored proc │ Failure("") │ ║ - | │ ║ │ "obp.check │ "obp_check_ │ (N/A) │ ║ - | │ ║ │ External │ external_ │ │ ║ - | │ ║ │ UserCreds" │ user_creds" │ │ ║ - | │ ║ └──────┬───────┴──────┬───────┴──────────────┘ ║ - | │ ║ │ │ ║ - | │ ║ ▼ ▼ ║ - | │ ║ ┌──────────────────────────┐ ║ - | │ ║ │ External System / │ ║ - | │ ║ │ Core Banking Adapter │ ║ - | │ ║ │ │ ║ - | │ ║ │ Validates credentials │ ║ - | │ ║ │ Returns: │ ║ - | │ ║ │ InboundExternalUser │ ║ - | │ ║ │ - sub (user id) │ ║ - | │ ║ │ - iss (provider) │ ║ - | │ ║ │ - email │ ║ - | │ ║ │ - emailVerified │ ║ - | │ ║ │ - name │ ║ - | │ ║ │ - userAuthContext │ ║ - | │ ║ └────────────┬─────────────┘ ║ - | │ ╚════════════════╪═════════════════════════════════╝ - | │ │ - | │ ┌─────┴──────┐ - | │ │ │ - | │ Success Failure - | │ │ │ - | │ ▼ ▼ - | │ ┌────────────────┐ ┌────────────┐ - | │ │ User exists │ │ Increment │ - | │ │ locally by │ │ bad login │ - | │ │ (sub, iss)? │ │ attempts │ - | │ └───┬────────┬───┘ │ → REJECT │ - | │ │ │ └────────────┘ - | │ Yes No - | │ │ │ - | │ ▼ ▼ - | │ ┌───────┐ ┌──────────────────┐ - | │ │ Use │ │ Create new │ - | │ │ exist-│ │ AuthUser + │ - | │ │ ing │ │ ResourceUser │ - | │ │ Auth │ │ user = sub │ - | │ │ User │ │ provider = iss │ - | │ │ │ │ password = UUID │ - | │ │ │ │ (dummy, unused) │ - | │ └───┬───┘ └────────┬─────────┘ - | │ └──────┬───────┘ - | │ │ - | ┌─────┴──────────────┘ - | │ - | ▼ - |┌─────────────┐ ┌──────────────┐ - |│ SUCCESS │ │ FAILURE │ - |│ │ │ │ - |│ Reset bad │ │ Increment │ - |│ login │ │ bad login │ - |│ attempts │ │ attempts │ - |│ │ │ │ - |│ Establish │ │ Lock if max │ - |│ session │ │ exceeded │ - |│ │ │ │ - |│ Redirect │ │ Return error │ - |└─────────────┘ └──────────────┘ - |``` - | - |### Decision Logic - | - |The **provider** field on the AuthUser record determines which path is taken: - | - |- **Local provider** (e.g. the OBP instance URL) → bcrypt password check via `testPassword()` - |- **External provider** (e.g. `google.com`) → delegated to the Connector via `checkExternalUserCredentials()` - |- **User not found locally** → can still succeed if `connector.user.authentication=true` is set. The system creates a new AuthUser + ResourceUser on the fly from the adapter response. - | - |The property `connector.user.authentication=true` must be set to enable external credential checking. Without it, external auth is rejected. - | - |### Verify Credentials Endpoint (POST /users/verify-credentials) - | - |In addition to the login flows above, OBP v6.0.0 provides a **credential verification endpoint** that validates credentials **without** creating a session or token. - | - |``` - | ┌──────────────────────────────────────────────┐ - | │ POST /obp/v6.0.0/users/verify-credentials │ - | │ │ - | │ Body: { username, password, provider } │ - | │ │ - | │ → Does NOT create session/token │ - | │ → Just validates and returns user info │ - | │ → For external systems to verify creds │ - | └────────────────────┬─────────────────────────┘ - | │ - | ▼ - | ┌─────────────────────┐ - | │ authenticatedAccess │ - | │ (caller must already │ - | │ be logged in) │ - | └──────────┬──────────┘ - | │ - | ▼ - | ┌─────────────────────┐ - | │ Check role: │ - | │ isSuperAdmin? │ - | │ OR has │ - | │ canVerifyUserCreds? │ - | └──────────┬──────────┘ - | │ - | ▼ - | ┌────────────────────────────────────────┐ - | │ AuthUser.getResourceUserId │ - | │ (username, password) │ - | │ │ - | │ Same method used by DirectLogin and │ - | │ the login flows above │ - | └──────────────────┬─────────────────────┘ - | │ - | (same local / external / not-found - | branching as the login flow above) - | │ - | ▼ - | ┌───────────────────┐ - | │ Locked? │──Yes──▶ 401 - | └────────┬──────────┘ - | │ No - | ▼ - | ┌───────────────────┐ - | │ Valid userId? │──No───▶ 401 - | └────────┬──────────┘ - | │ Yes - | ▼ - | ┌───────────────────┐ - | │ Provider matches │ - | │ posted provider? │──No───▶ 401 - | │ (if non-empty) │ - | └────────┬──────────┘ - | │ Yes - | ▼ - | ┌───────────────────┐ - | │ 200 OK │ - | │ Return UserJson │ - | │ │ - | │ NO token created │ - | │ NO session created│ - | └───────────────────┘ - |``` - | - |**Key differences from the login flows:** - | - |1. **Check only** — validates credentials and returns user info, but does not create a session or token - |2. **Requires an already-authenticated caller** with `canVerifyUserCredentials` role (or SuperAdmin) - |3. **Does not auto-provision users** — unlike `externalUserHelper()` in the web login flow, this endpoint will not create a new AuthUser if the user doesn't exist locally - |4. **Provider matching** — optionally verifies the user's provider matches what was posted (skipped if provider is empty) - | - |### Key Source Files - | - |- `AuthUser.scala` — `login()` entry point, `getResourceUserId()`, `checkExternalUserViaConnector()` - |- `directlogin.scala` — `getUserId()` with local-then-external fallback - |- `Connector.scala` — `checkExternalUserCredentials()` abstract method - |- `AkkaConnector_vDec2018.scala` — Akka connector implementation - |- `StoredProcedureConnector_vDec2019.scala` — Stored procedure connector implementation - |- `APIMethods600.scala` — `verifyUserCredentials` endpoint definition - | -""") - - /////////////////////////////////////////////////////////////////// - // NOTE! Some glossary items are generated in ExampleValue.scala -////////////////////////////////////////////////////////////////// - -} diff --git a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala index 82e7bec744..209a87f396 100644 --- a/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala +++ b/obp-api/src/main/scala/code/api/util/WriteMetricUtil.scala @@ -1,7 +1,7 @@ package code.api.util import code.api.DirectLogin -import code.api.util.APIUtil.{ResourceDoc, buildOperationId, getCorrelationId, getPropsAsBoolValue, getPropsValue, hasAnOAuthHeader, hasDirectLoginHeader} +import code.api.util.APIUtil.{ResourceDoc, buildOperationId, getCorrelationId, getPropsAsBoolValue, getPropsValue, hasDirectLoginHeader} import code.api.util.ErrorMessages.attemptedToOpenAnEmptyBox import code.metrics.APIMetrics import code.metricsstream.MetricsEventBus 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 373ab006ed..83c0060a39 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 @@ -106,6 +106,7 @@ object Http4sApp { corsHandler.run(req) .orElse(AppsPage.routes.run(req)) .orElse(StatusPage.routes.run(req)) + .orElse(Http4sResourceDocs.routes.run(req)) .orElse(v510Routes.run(req)) .orElse(v600Routes.run(req)) .orElse(v500Routes.run(req)) @@ -120,6 +121,9 @@ object Http4sApp { .orElse(v140Routes.run(req)) .orElse(v130Routes.run(req)) .orElse(v121Routes.run(req)) + .orElse(code.api.DirectLoginRoutes.routes.run(req)) + .orElse(code.api.AliveCheckRoutes.routes.run(req)) + .orElse(code.management.ImporterAPIRoutes.routes.run(req)) .orElse(Http4sLiftWebBridge.routes.run(req)) } } diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala new file mode 100644 index 0000000000..8bde7e3dfd --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sResourceDocs.scala @@ -0,0 +1,708 @@ +package code.api.util.http4s + +import cats.effect.IO +import code.api.Constant.HostName +import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocs300, ResourceDocsAPIMethodsUtil} +import code.api.ResponseHeader +import code.api.cache.Caching +import code.api.util.ApiRole.{canReadDynamicResourceDocsAtOneBank, canReadResourceDoc} +import code.api.util.ErrorMessages._ +import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CustomJsonFormats, YAMLUtils} +import code.api.v1_4_0.JSONFactory1_4_0 +import code.apicollectionendpoint.MappedApiCollectionEndpointsProvider +import code.bankconnectors.rest.RestConnector_vMar2019 +import code.util.Helper.{MdcLoggable, SILENCE_IS_GOLDEN} +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.BankId +import com.openbankproject.commons.model.enums.ContentParam +import com.openbankproject.commons.model.enums.ContentParam.{DYNAMIC, STATIC} +import com.openbankproject.commons.util.ApiVersion +import net.liftweb.common.{Box, Empty, Full} +import net.liftweb.json +import net.liftweb.json.JsonAST.{JField, JObject, JString, JValue} +import net.liftweb.json.{Extraction, prettyRender} +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` +import org.http4s._ +import org.typelevel.ci.CIString + +import scala.concurrent.{Future, ExecutionContext => ScalaEC} +import code.api.util.ApiTag.ResourceDocTag + +/** + * Centralised native http4s service for OBP resource-docs / swagger / openapi / + * openapi.yaml / message-docs traffic. + * + * Replaces the 10 per-version Lift singletons (`ResourceDocs140`..`ResourceDocs600`) + * registered in `Boot.scala` via `LiftRules.statelessDispatch.append`. The version + * prefix in the URL (`/obp/v6.0.0/resource-docs/...`) is functionally irrelevant + * because the response content is determined by the `API_VERSION` path segment. + * So one service handles all version prefixes for the following routes: + * + * GET /obp/*/resource-docs/{API_VERSION}/obp + * GET /obp/*/resource-docs/{API_VERSION}/swagger + * GET /obp/*/resource-docs/{API_VERSION}/openapi + * GET /obp/*/resource-docs/{API_VERSION}/openapi.yaml + * GET /obp/*/banks/{BANK_ID}/resource-docs/{API_VERSION}/obp + * GET /obp/*/message-docs/{CONNECTOR}/swagger2.0 + * + * Wired into `Http4sApp.baseServices` BEFORE the Lift bridge, so requests are + * served natively instead of falling through to Lift. + * + * Business logic is delegated to `ResourceDocs140.ImplementationsResourceDocs` + * (and `ResourceDocsAPIMethodsUtil`) so caching / content-param / locale / + * api-collection-id semantics stay identical to the Lift handlers. + */ +object Http4sResourceDocs extends MdcLoggable { + + // Two ImplementationsResourceDocs instances are exposed because they differ on + // `includeTechnologyInResponse`. The Lift dispatchers preserved per-prefix + // behavior — ResourceDocs600 includes the `technology` field in responses, all + // other ResourceDocs* leave it as None. Tests assert both shapes (v6 prefix → + // Some("liftweb"); v5 prefix → None), so the centralized service picks Impl + // based on the URL prefix segment. + private val ImplDefault = ResourceDocs140.ImplementationsResourceDocs + private val ImplV600 = ResourceDocs300.ResourceDocs600.ImplementationsResourceDocs + + private def implForPrefix(prefix: String) = prefix match { + case "v6.0.0" => ImplV600 + case _ => ImplDefault + } + + private def includeTechnologyForPrefix(prefix: String): Boolean = prefix == "v6.0.0" + private val jsonContentType: `Content-Type` = + `Content-Type`(MediaType.application.json, Charset.`UTF-8`) + private val plainTextContentType: `Content-Type` = + `Content-Type`(MediaType.text.plain, Charset.`UTF-8`) + + private def resourceDocsRequireRole: Boolean = + APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) + + // ─── Query-parameter parsing ───────────────────────────────────────────── + // Mirrors `ResourceDocsAPIMethodsUtil.getParams()` but reads from + // `req.uri.query.params` instead of Lift's `ObpS.param` / `S.request`. + + private final case class ParsedParams( + tags: Option[List[ResourceDocTag]], + partialFunctions: Option[List[String]], + locale: Option[String], + contentParam: Option[ContentParam], + apiCollectionId: Option[String], + rawTags: Option[String], + rawFunctions: Option[String], + rawApiCollectionId: Option[String], + rawContent: Option[String] + ) + + private def parseParams(req: Request[IO]): ParsedParams = { + def first(k: String): Option[String] = req.uri.query.params.get(k) + val rawTags = first("tags") + val rawFunctions = first("functions") + val rawApiCollectionId = first("api-collection-id") + val rawContent = first("content") + + val tags = rawTags match { + case None | Some("") => None + case Some(s) => + val list = s.trim.split(",").toList.map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)) + if (list.nonEmpty) Some(list) else None + } + val partialFunctions = rawFunctions match { + case None | Some("") => None + case Some(s) => + val list = s.trim.split(",").toList.map(_.trim).filter(_.nonEmpty) + if (list.nonEmpty) Some(list) else None + } + val locale = first("locale").orElse(first("language")).filter(_.trim.nonEmpty).map(_.trim) + val contentParam = rawContent.flatMap { v => + v.toLowerCase.trim match { + case "dynamic" => Some(DYNAMIC) + case "static" => Some(STATIC) + case "all" => Some(com.openbankproject.commons.model.enums.ContentParam.ALL) + case _ => None + } + } + val apiCollectionId = rawApiCollectionId.map(_.trim).filter(_.nonEmpty) + + ParsedParams( + tags, partialFunctions, locale, contentParam, apiCollectionId, + rawTags, rawFunctions, rawApiCollectionId, rawContent + ) + } + + // ─── Response helpers ──────────────────────────────────────────────────── + + private def jsonResponse(status: Status, body: JValue, extraHeaders: List[(String, String)] = Nil): IO[Response[IO]] = { + val rendered = json.compactRender(body) + val withHeaders = extraHeaders.foldLeft( + Response[IO](status).withEntity(rendered).withContentType(jsonContentType) + ) { case (resp, (n, v)) => resp.putHeaders(Header.Raw(CIString(n), v)) } + IO.pure(withHeaders) + } + + private def errorJson(status: Status, message: String): IO[Response[IO]] = { + val body = Extraction.decompose(code.tesobe.ErrorMessage(message))(CustomJsonFormats.formats) + jsonResponse(status, body) + } + + private def plainTextResponse(status: Status, body: String): IO[Response[IO]] = + IO.pure(Response[IO](status).withEntity(body).withContentType(plainTextContentType)) + + private def yamlResponse(body: String): IO[Response[IO]] = { + val bytes = body.getBytes("UTF-8") + val correlationId = + try APIUtil.getCorrelationId() + catch { case _: Throwable => "" } + val resp = Response[IO](Status.Ok) + .withEntity(bytes) + .putHeaders( + Header.Raw(CIString("Content-Type"), YAMLUtils.getYAMLContentType), + Header.Raw(CIString(ResponseHeader.`Correlation-Id`), correlationId) + ) + IO.pure(resp) + } + + // ─── Authentication helper ─────────────────────────────────────────────── + // When `resource_docs_requires_role=true`, look up the caller via the + // existing Http4s authentication paths the per-version services already use. + // We mimic the Lift handlers' check: extract user via authReq, then check + // entitlement; if either fails return 401/403 as appropriate. + + private def withOptionalRoleCheck[A]( + req: Request[IO], + prefix: String, + bankId: String, + roles: List[ApiRole], + failMsg: String + )(body: => IO[Response[IO]]): IO[Response[IO]] = { + if (!resourceDocsRequireRole) body + else { + // Pass the URL prefix as the apiVersion so getUserAndSessionContextFuture's + // S.request fallback (used for `implementedInVersion` / `verb` / `url`) is not + // reached — that fallback throws when not under a Lift dispatch, which would + // surface as a 500 instead of the intended 401/403. + val ccBuild: IO[code.api.util.CallContext] = + Http4sCallContextBuilder.fromRequest(req, apiVersion = prefix) + ccBuild.flatMap { cc => + IO.fromFuture(IO(APIUtil.getUserAndSessionContextFuture(cc))).attempt.flatMap { + case Right((Full(user), _)) => + val hasRole = + try APIUtil.hasAtLeastOneEntitlement(bankId, user.userId, roles) + catch { case _: Throwable => false } + if (hasRole) body + else errorJson(Status.Forbidden, failMsg) + case _ => + // Box-Empty/Failure OR thrown auth exception → unauthenticated. + errorJson(Status.Unauthorized, AuthenticatedUserIsRequired) + } + } + } + } + + // ─── Common parameter validation ───────────────────────────────────────── + // Mirrors the parameter-validation branches in the Lift handlers. + + private def validateBasicParams(params: ParsedParams): Option[(Status, String)] = { + if (params.rawTags.exists(_.trim.isEmpty)) Some(Status.BadRequest -> InvalidTagsParameter) + else if (params.rawFunctions.exists(_.trim.isEmpty)) Some(Status.BadRequest -> InvalidFunctionsParameter) + else if (params.rawApiCollectionId.exists(_.trim.isEmpty)) Some(Status.BadRequest -> InvalidApiCollectionIdParameter) + else if (params.rawContent.isDefined && params.contentParam.isEmpty) Some(Status.BadRequest -> InvalidContentParameter) + else None + } + + private def validateVersionAndLocale( + requestedApiVersionString: String, + locale: Option[String] + ): Either[(Status, String), ApiVersion] = { + val versionEither = + try Right(ApiVersionUtils.valueOf(requestedApiVersionString)) + catch { case _: Throwable => Left(Status.BadRequest -> s"$InvalidApiVersionString Current Version is $requestedApiVersionString") } + versionEither match { + case Left(err) => Left(err) + case Right(v) if !APIUtil.versionIsAllowed(v) => + Left(Status.BadRequest -> s"$ApiVersionNotSupported Current Version is $requestedApiVersionString") + case Right(v) => + locale match { + case Some(l) if APIUtil.obpLocaleValidation(l) != SILENCE_IS_GOLDEN => + Left(Status.BadRequest -> s"$InvalidLocale Current Locale is $l") + case _ => Right(v) + } + } + } + + // ─── JSON transformation for OBP format ────────────────────────────────── + // Inlined copy of the file-private `resourceDocsJsonToJsonResponse` in + // `ResourceDocsAPIMethods.scala`. Renames `jsonClass` → `role`, + // unwraps the `jvalueToCaseclass` wrapper, and strips the `ApiRole$` prefix. + + private def resourceDocsJsonToJsonResponse(rdJson: JSONFactory1_4_0.ResourceDocsJson): JValue = { + val decomposed = Extraction.decompose(rdJson)(CustomJsonFormats.formats) + val unwrapped = decomposed transform { + case JObject(List(JField("jvalueToCaseclass", JObject(x)))) => JObject(x) + } + val renamed = unwrapped transformField { + case JField("jsonClass", x) => JField("role", x) + case JField("requiresBankId", x) => JField("requires_bank_id", x) + } + renamed transformField { + case JField("role", JString(s)) => JField("role", JString(s.replace("ApiRole$", ""))) + } + } + + // ─── Handler: GET /obp/*/resource-docs/{API_VERSION}/obp ───────────────── + + private def handleGetResourceDocsObp( + req: Request[IO], + prefix: String, + requestedApiVersionString: String, + isVersion4OrHigher: Boolean + ): IO[Response[IO]] = { + val params = parseParams(req) + validateBasicParams(params) match { + case Some((status, msg)) => errorJson(status, msg) + case None => + withOptionalRoleCheck(req, prefix, "", canReadResourceDoc :: Nil, + UserHasMissingRoles + canReadResourceDoc.toString) { + validateVersionAndLocale(requestedApiVersionString, params.locale) match { + case Left((s, m)) => errorJson(s, m) + case Right(_) => + IO(buildObpResourceDocsJson(params, prefix, requestedApiVersionString, isVersion4OrHigher)).flatMap { + case Right(body) => jsonResponse(Status.Ok, body) + case Left((s, m)) => errorJson(s, m) + } + } + } + } + } + + private def buildObpResourceDocsJson( + params: ParsedParams, + prefix: String, + requestedApiVersionString: String, + isVersion4OrHigher: Boolean + ): Either[(Status, String), JValue] = { + try { + val impl = implForPrefix(prefix) + val includeTech = includeTechnologyForPrefix(prefix) + val cacheKey = APIUtil.createResourceDocCacheKey( + None, + requestedApiVersionString, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + Some(isVersion4OrHigher) + ) + val jvalue: JValue = (params.apiCollectionId, params.contentParam) match { + case (Some(_), _) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(params.apiCollectionId.getOrElse("")) + .map(_.operationId).map(APIUtil.getObpFormatOperationId) + val resourceDocs = APIUtil.ResourceDoc.getResourceDocs(operationIds) + val rdJson = JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, params.locale, includeTechnology = includeTech) + resourceDocsJsonToJsonResponse(rdJson) + case (None, Some(DYNAMIC)) => + val cached = Caching.getDynamicResourceDocCache(cacheKey) + if (cached.isDefined) json.parse(cached.get) + else { + val rdJson = impl.getResourceDocsObpDynamicCached(params.tags, params.partialFunctions, params.locale, None, isVersion4OrHigher = false).head + val jv = resourceDocsJsonToJsonResponse(rdJson) + Caching.setDynamicResourceDocCache(cacheKey, json.compactRender(jv)) + jv + } + case (None, Some(STATIC)) => + val cached = Caching.getStaticResourceDocCache(cacheKey) + if (cached.isDefined) json.parse(cached.get) + else { + val rdJson = impl.getStaticResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, isVersion4OrHigher).head + val jv = resourceDocsJsonToJsonResponse(rdJson) + Caching.setStaticResourceDocCache(cacheKey, json.compactRender(jv)) + jv + } + case (None, _) => + val cached = Caching.getAllResourceDocCache(cacheKey) + if (cached.isDefined) json.parse(cached.get) + else { + val rdJson = impl.getAllResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, params.contentParam, isVersion4OrHigher).head + val jv = resourceDocsJsonToJsonResponse(rdJson) + Caching.setAllResourceDocCache(cacheKey, json.compactRender(jv)) + jv + } + } + Right(jvalue) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildObpResourceDocsJson failed: ${e.getMessage}", e) + Left(Status.InternalServerError -> s"$UnknownError Can not prepare OBP resource docs.") + } + } + + // ─── Handler: GET /obp/*/resource-docs/{API_VERSION}/swagger ───────────── + + private def handleGetResourceDocsSwagger( + req: Request[IO], + prefix: String, + requestedApiVersionString: String + ): IO[Response[IO]] = { + val params = parseParams(req) + withOptionalRoleCheck(req, prefix, "", canReadResourceDoc :: Nil, + UserHasMissingRoles + canReadResourceDoc.toString) { + validateVersionAndLocale(requestedApiVersionString, params.locale) match { + case Left((s, m)) => errorJson(s, m) + case Right(_) => + IO(buildSwaggerJson(params, prefix, requestedApiVersionString)).flatMap { + case Right(body) => jsonResponse(Status.Ok, body) + case Left((s, m)) => errorJson(s, m) + } + } + } + } + + private def buildSwaggerJson( + params: ParsedParams, + prefix: String, + requestedApiVersionString: String + ): Either[(Status, String), JValue] = { + try { + val impl = implForPrefix(prefix) + val isVersion4OrHigher = true + val cacheKey = APIUtil.createResourceDocCacheKey( + None, + requestedApiVersionString, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + Some(isVersion4OrHigher) + ) + val cached = Caching.getStaticSwaggerDocCache(cacheKey) + val jv: JValue = + if (cached.isDefined) json.parse(cached.get) + else { + val resourceDocsJsonFiltered: List[JSONFactory1_4_0.ResourceDocJson] = params.apiCollectionId match { + case Some(_) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(params.apiCollectionId.getOrElse("")) + .map(_.operationId).map(APIUtil.getObpFormatOperationId) + val resourceDocs = APIUtil.ResourceDoc.getResourceDocs(operationIds) + JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, params.locale, includeTechnology = true).resource_docs + case None => + params.contentParam match { + case Some(DYNAMIC) => + impl.getResourceDocsObpDynamicCached(params.tags, params.partialFunctions, params.locale, None, isVersion4OrHigher).head.resource_docs + case Some(STATIC) => + impl.getStaticResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, isVersion4OrHigher).head.resource_docs + case _ => + impl.getAllResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, params.contentParam, isVersion4OrHigher).head.resource_docs + } + } + impl.convertResourceDocsToSwaggerJvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered) + } + Right(jv) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildSwaggerJson failed: ${e.getMessage}", e) + Left(Status.BadRequest -> s"$UnknownError Can not convert internal swagger file.") + } + } + + // ─── Handler: GET /obp/*/resource-docs/{API_VERSION}/openapi ───────────── + + private def handleGetResourceDocsOpenAPI31( + req: Request[IO], + prefix: String, + requestedApiVersionString: String + ): IO[Response[IO]] = { + val params = parseParams(req) + validateBasicParams(params) match { + case Some((status, msg)) => errorJson(status, msg) + case None => + withOptionalRoleCheck(req, prefix, "", canReadResourceDoc :: Nil, + UserHasMissingRoles + canReadResourceDoc.toString) { + validateVersionAndLocale(requestedApiVersionString, params.locale) match { + case Left((s, m)) => errorJson(s, m) + case Right(_) => + IO(buildOpenApi31Json(params, prefix, requestedApiVersionString)).flatMap { + case Right(body) => jsonResponse(Status.Ok, body) + case Left((s, m)) => errorJson(s, m) + } + } + } + } + } + + private def buildOpenApi31Json( + params: ParsedParams, + prefix: String, + requestedApiVersionString: String + ): Either[(Status, String), JValue] = { + try { + val impl = implForPrefix(prefix) + val isVersion4OrHigher = true + val cacheKey = APIUtil.createResourceDocCacheKey( + Some("openapi31"), + requestedApiVersionString, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + Some(isVersion4OrHigher) + ) + val cached = Caching.getStaticSwaggerDocCache(cacheKey) + val jv: JValue = + if (cached.isDefined) json.parse(cached.get) + else { + val resourceDocsJsonFiltered: List[JSONFactory1_4_0.ResourceDocJson] = params.apiCollectionId match { + case Some(_) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(params.apiCollectionId.getOrElse("")) + .map(_.operationId).map(APIUtil.getObpFormatOperationId) + val resourceDocs = APIUtil.ResourceDoc.getResourceDocs(operationIds) + JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, params.locale, includeTechnology = true).resource_docs + case None => + params.contentParam match { + case Some(DYNAMIC) => + impl.getResourceDocsObpDynamicCached(params.tags, params.partialFunctions, params.locale, None, isVersion4OrHigher).head.resource_docs + case Some(STATIC) => + impl.getStaticResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, isVersion4OrHigher).head.resource_docs + case _ => + impl.getAllResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, params.contentParam, isVersion4OrHigher).head.resource_docs + } + } + impl.convertResourceDocsToOpenAPI31JvalueAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered) + } + Right(jv) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildOpenApi31Json failed: ${e.getMessage}", e) + Left(Status.BadRequest -> s"$UnknownError Can not convert internal openapi file.") + } + } + + // ─── Handler: GET /obp/*/resource-docs/{API_VERSION}/openapi.yaml ──────── + + private def handleGetResourceDocsOpenAPI31Yaml( + req: Request[IO], + prefix: String, + requestedApiVersionString: String + ): IO[Response[IO]] = { + val params = parseParams(req) + validateBasicParams(params) match { + case Some((_, msg)) => plainTextResponse(Status.BadRequest, msg) + case None => + validateVersionAndLocale(requestedApiVersionString, params.locale) match { + case Left((_, msg)) => plainTextResponse(Status.BadRequest, msg) + case Right(_) => + IO(buildOpenApi31Yaml(params, prefix, requestedApiVersionString)).flatMap { + case Right(yamlString) => yamlResponse(yamlString) + case Left((_, msg)) => plainTextResponse(Status.BadRequest, msg) + } + } + } + } + + private def buildOpenApi31Yaml( + params: ParsedParams, + prefix: String, + requestedApiVersionString: String + ): Either[(Status, String), String] = { + try { + val impl = implForPrefix(prefix) + val isVersion4OrHigher = true + val cacheKey = APIUtil.createResourceDocCacheKey( + Some("openapi31yaml"), + requestedApiVersionString, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + Some(isVersion4OrHigher) + ) + val cached = Caching.getStaticSwaggerDocCache(cacheKey) + val yamlString: String = + if (cached.isDefined) cached.get + else { + val resourceDocsJsonFiltered: List[JSONFactory1_4_0.ResourceDocJson] = params.apiCollectionId match { + case Some(_) => + val operationIds = MappedApiCollectionEndpointsProvider.getApiCollectionEndpoints(params.apiCollectionId.getOrElse("")) + .map(_.operationId).map(APIUtil.getObpFormatOperationId) + val resourceDocs = APIUtil.ResourceDoc.getResourceDocs(operationIds) + JSONFactory1_4_0.createResourceDocsJson(resourceDocs, isVersion4OrHigher, params.locale, includeTechnology = true).resource_docs + case None => + params.contentParam match { + case Some(DYNAMIC) => + impl.getResourceDocsObpDynamicCached(params.tags, params.partialFunctions, params.locale, None, isVersion4OrHigher).head.resource_docs + case Some(STATIC) => + impl.getStaticResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, isVersion4OrHigher).head.resource_docs + case _ => + impl.getAllResourceDocsObpCached(requestedApiVersionString, params.tags, params.partialFunctions, params.locale, params.contentParam, isVersion4OrHigher).head.resource_docs + } + } + impl.convertResourceDocsToOpenAPI31YAMLAndSetCache(cacheKey, requestedApiVersionString, resourceDocsJsonFiltered) + } + Right(yamlString) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildOpenApi31Yaml failed: ${e.getMessage}", e) + Left(Status.BadRequest -> s"Invalid API version: $requestedApiVersionString") + } + } + + // ─── Handler: GET /obp/*/banks/{BANK_ID}/resource-docs/{API_VERSION}/obp ─ + + private def handleGetBankLevelDynamicResourceDocsObp( + req: Request[IO], + prefix: String, + bankIdStr: String, + requestedApiVersionString: String + ): IO[Response[IO]] = { + val params = parseParams(req) + withOptionalRoleCheck(req, prefix, bankIdStr, canReadDynamicResourceDocsAtOneBank :: Nil, + UserHasMissingRoles + canReadDynamicResourceDocsAtOneBank.toString) { + // Bank-level handler ALWAYS requires the bank to exist. Use the legacy connector lookup + // synchronously; it does its own 404 if absent. + val bankBox: Box[com.openbankproject.commons.model.Bank] = + code.bankconnectors.Connector.connector.vend.getBankLegacy(BankId(bankIdStr), None).map(_._1) + if (bankBox.isEmpty) errorJson(Status.NotFound, s"$BankNotFound Current BANK_ID = $bankIdStr") + else { + val localeError: Option[String] = params.locale match { + case Some(l) if APIUtil.obpLocaleValidation(l) != SILENCE_IS_GOLDEN => + Some(s"$InvalidLocale Current Locale is $l") + case _ => None + } + val versionError: Option[String] = + try { ApiVersionUtils.valueOf(requestedApiVersionString); None } + catch { case _: Throwable => Some(s"$InvalidApiVersionString $requestedApiVersionString") } + localeError.orElse(versionError) match { + case Some(msg) => errorJson(Status.BadRequest, msg) + case None => + IO(buildBankLevelResourceDocsJson(params, prefix, bankIdStr, requestedApiVersionString)).flatMap { + case Right(body) => jsonResponse(Status.Ok, body) + case Left((s, m)) => errorJson(s, m) + } + } + } + } + } + + private def buildBankLevelResourceDocsJson( + params: ParsedParams, + prefix: String, + bankIdStr: String, + requestedApiVersionString: String + ): Either[(Status, String), JValue] = { + try { + val impl = implForPrefix(prefix) + val cacheKey = APIUtil.createResourceDocCacheKey( + Some(bankIdStr), + requestedApiVersionString, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + None + ) + val cached = Caching.getDynamicResourceDocCache(cacheKey) + val jv: JValue = + if (cached.isDefined) json.parse(cached.get) + else { + val rdJson = impl.getResourceDocsObpDynamicCached(params.tags, params.partialFunctions, params.locale, None, isVersion4OrHigher = false).head + val response = resourceDocsJsonToJsonResponse(rdJson) + Caching.setDynamicResourceDocCache(cacheKey, json.compactRender(response)) + response + } + Right(jv) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildBankLevelResourceDocsJson failed: ${e.getMessage}", e) + Left(Status.BadRequest -> s"$UnknownError Can not create dynamic resource docs.") + } + } + + // ─── Handler: GET /obp/*/message-docs/{CONNECTOR}/swagger2.0 ───────────── + + private def handleGetMessageDocsSwagger( + req: Request[IO], + connector: String + ): IO[Response[IO]] = { + val params = parseParams(req) + IO(buildMessageDocsSwagger(params, connector)).flatMap { + case Right(body) => jsonResponse(Status.Ok, body) + case Left((s, m)) => errorJson(s, m) + } + } + + private def buildMessageDocsSwagger(params: ParsedParams, connector: String): Either[(Status, String), JValue] = { + try { + val cacheKey = APIUtil.createResourceDocCacheKey( + None, + connector, + params.tags, + params.partialFunctions, + params.locale, + params.contentParam, + params.apiCollectionId, + None + ) + val cached = Caching.getStaticSwaggerDocCache(cacheKey) + val jv: JValue = + if (cached.isDefined) json.parse(cached.get) + else { + val convertedToResourceDocs = RestConnector_vMar2019.messageDocs.map(APIUtil.toResourceDoc).toList + val resourceDocListFiltered = ResourceDocsAPIMethodsUtil.filterResourceDocs(convertedToResourceDocs, params.tags, params.partialFunctions) + val resourceDocJsonList = JSONFactory1_4_0.createResourceDocsJson(resourceDocListFiltered, isVersion4OrHigher = true, None).resource_docs + val swaggerResourceDoc = code.api.ResourceDocs1_4_0.SwaggerJSONFactory.createSwaggerResourceDoc(resourceDocJsonList, ApiVersion.v3_1_0) + val allSwaggerDefinitionCaseClasses = + code.api.ResourceDocs1_4_0.MessageDocsSwaggerDefinitions.allFields ++ + code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON.allFields + val jsonAST = code.api.ResourceDocs1_4_0.SwaggerJSONFactory.loadDefinitions(resourceDocJsonList, allSwaggerDefinitionCaseClasses) + val swaggerDocJsonJValue = Extraction.decompose(swaggerResourceDoc)(CustomJsonFormats.formats) merge jsonAST + Caching.setStaticSwaggerDocCache(cacheKey, json.compactRender(swaggerDocJsonJValue)) + swaggerDocJsonJValue + } + Right(jv) + } catch { + case e: Throwable => + logger.error(s"Http4sResourceDocs.buildMessageDocsSwagger failed: ${e.getMessage}", e) + Left(Status.BadRequest -> s"$UnknownError Can not convert internal swagger file.") + } + } + + // ─── Routes ────────────────────────────────────────────────────────────── + // The version prefix segment (`obp` / `vX.Y.Z`) is captured but ignored — + // the requested API version comes from the `{API_VERSION}` segment further + // along the path. The Lift dispatch did the same thing (one dispatcher per + // version prefix, all calling the same handlers). + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "obp" => + // Match the Lift dispatchers' `isVersion4OrHigher` setting per prefix — + // ResourceDocs400/500/510/600 used the V400-shaped output, all earlier + // (v1.4.0/v2.0.0/v2.1.0/v2.2.0/v3.0.0/v3.1.0) used the pre-V400 shape. + val isV4OrHigher = prefix match { + case "v4.0.0" | "v5.0.0" | "v5.1.0" | "v6.0.0" => true + case _ => false + } + handleGetResourceDocsObp(req, prefix, requestedApiVersionString, isVersion4OrHigher = isV4OrHigher) + + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "swagger" => + handleGetResourceDocsSwagger(req, prefix, requestedApiVersionString) + + // OpenAPI 3.1 JSON was only registered by ResourceDocs600 (v6.0.0 prefix); guard the + // route so requests under other prefixes still 404-fall-through, matching old behaviour. + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi" if prefix == "v6.0.0" => + handleGetResourceDocsOpenAPI31(req, prefix, requestedApiVersionString) + + // openapi.yaml was likewise only on v6.0.0. + case req @ GET -> Root / "obp" / prefix / "resource-docs" / requestedApiVersionString / "openapi.yaml" if prefix == "v6.0.0" => + handleGetResourceDocsOpenAPI31Yaml(req, prefix, requestedApiVersionString) + + case req @ GET -> Root / "obp" / prefix / "banks" / bankIdStr / "resource-docs" / requestedApiVersionString / "obp" => + handleGetBankLevelDynamicResourceDocsObp(req, prefix, bankIdStr, requestedApiVersionString) + + case req @ GET -> Root / "obp" / _ / "message-docs" / connector / "swagger2.0" => + handleGetMessageDocsSwagger(req, connector) + } +} diff --git a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala index 6262c6246a..d6584cb6f7 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sSupport.scala @@ -526,7 +526,6 @@ object Http4sCallContextBuilder { httpBody = body, authReqHeaderField = extractAuthHeader(request), directLoginParams = extractDirectLoginParams(request), - oAuthParams = extractOAuthParams(request), startTime = Some(new Date()) ) } @@ -592,15 +591,6 @@ object Http4sCallContextBuilder { } } - /** - * Extract OAuth 1.0a parameters from the Authorization header if it uses the OAuth scheme. - */ - private def extractOAuthParams(request: Request[IO]): Map[String, String] = { - request.headers.get(CIString("Authorization")) - .filter(_.head.value.startsWith("OAuth ")) - .map(h => AuthHeaderParser.parseOAuthHeader(h.head.value)) - .getOrElse(Map.empty) - } } /** diff --git a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala index c9de2d16c1..4de860ab2a 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/ResourceDocMiddleware.scala @@ -3,7 +3,6 @@ package code.api.util.http4s import cats.data.{EitherT, Kleisli, OptionT} import cats.effect._ import code.api.Constant -import code.api.v7_0_0.Http4s700 import code.api.APIFailureNewStyle import code.api.util.APIUtil.ResourceDoc import code.api.util.ErrorMessages._ @@ -12,7 +11,6 @@ import code.api.util.{APIUtil, ApiRole, CallContext, NewStyle} import code.util.Helper.MdcLoggable import com.openbankproject.commons.model._ import com.openbankproject.commons.util.{ApiShortVersions, ScannedApiVersion} -import com.github.dwickern.macros.NameOf.nameOf import net.liftweb.common.{Box, Empty, Failure, Full} import org.http4s._ import org.http4s.headers.`Content-Type` @@ -78,14 +76,9 @@ object ResourceDocMiddleware extends MdcLoggable { * Authentication is required if: * - ResourceDoc errorResponseBodies contains $AuthenticatedUserIsRequired * - ResourceDoc has roles (roles always require authenticated user) - * - Special case: resource-docs endpoint checks resource_docs_requires_role property */ private def needsAuthentication(resourceDoc: ResourceDoc): Boolean = { - if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700)) { - APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false) - } else { - resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) - } + resourceDoc.errorResponseBodies.contains($AuthenticatedUserIsRequired) || resourceDoc.roles.exists(_.nonEmpty) } /** @@ -363,14 +356,7 @@ object ResourceDocMiddleware extends MdcLoggable { private def authorizeRoles(resourceDoc: ResourceDoc, pathParams: Map[String, String], ctx: ValidationContext): Validation[ValidationContext] = { import DSL._ - val rolesToCheck: Option[List[ApiRole]] = - if (resourceDoc.partialFunctionName == nameOf(Http4s700.Implementations7_0_0.getResourceDocsObpV700) && APIUtil.getPropsAsBoolValue("resource_docs_requires_role", false)) { - Some(List(ApiRole.canReadResourceDoc)) - } else { - resourceDoc.roles - } - - rolesToCheck match { + resourceDoc.roles match { case Some(roles) if roles.nonEmpty => ctx.user match { case Full(user) => diff --git a/obp-api/src/main/scala/code/api/v1_4_0/APIMethods140.scala b/obp-api/src/main/scala/code/api/v1_4_0/APIMethods140.scala index aa36e92439..d135f952f6 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/APIMethods140.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/APIMethods140.scala @@ -547,60 +547,10 @@ trait APIMethods140 extends MdcLoggable with APIMethods130 with APIMethods121{ - if (Props.devMode) { - resourceDocs += ResourceDoc( - testResourceDoc, - apiVersion, - nameOf(testResourceDoc), - "GET", - "/dummy", - "I am only a test resource Doc", - """ - | - |#This should be H1 - | - |##This should be H2 - | - |###This should be H3 - | - |####This should be H4 - | - |Here is a list with two items: - | - |* One - |* Two - | - |There are underscores by them selves _ - | - |There are _underscores_ around a word - | - |There are underscores_in_words - | - |There are 'underscores_in_words_inside_quotes' - | - |There are (underscores_in_words_in_brackets) - | - |_etc_...""", - EmptyBody, - apiInfoJSON, - List(UnknownError), - List(apiTagDocumentation, apiTagOldStyle)) - } - - - - lazy val testResourceDoc : OBPEndpoint = { - case "dummy" :: Nil JsonGet req => { - cc => - val apiDetails: JValue = { - val hostedBy = new HostedBy("Dummy Org", "contact@example.com", "12345", "https://www.example.com") - val apiInfoJSON = new APIInfoJSON(apiVersion.vDottedApiVersion, apiVersionStatus, gitCommit, "DUMMY", hostedBy) - Extraction.decompose(apiInfoJSON) - } - - Full(successJsonResponse(apiDetails, 200)) - } - } + // `testResourceDoc` (dev-mode-only `GET /dummy` stub) deleted in the auth-stack / + // bridge-removal cleanup sweep. It returned a dummy `APIInfoJSON`, had no production + // behaviour, and its sole purpose was exercising the resource-doc renderer's + // description-markdown handling — covered today by real endpoints. } } diff --git a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala index d3a4f345ed..3428153c80 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala @@ -539,7 +539,13 @@ object JSONFactory1_4_0 extends MdcLoggable{ jsonRequestBodyFieldsI18n:String, jsonResponseBodyFieldsI18n:String ): ResourceDocJson = { - val cacheKey = LOCALISED_RESOURCE_DOC_PREFIX + s"operationId:${operationId}-locale:$locale- isVersion4OrHigher:$isVersion4OrHigher- includeTechnology:$includeTechnology-specifiedUrl:${resourceDocUpdatedTags.specifiedUrl.getOrElse("")}".intern() + // requestUrl and specifiedUrl belong in the cache key because both are rewritten + // per requested API version (see ResourceDocsAPIMethods.getResourceDocsList: requestUrl gets + // /obp// prefixed, specifiedUrl gets /obp// prefixed). + // Without them, a request for /obp/v7.0.0/resource-docs hits cache entries warmed by an + // earlier /obp/dynamic-endpoint/resource-docs call and returns the wrong specified_url. + // (Superset of upstream's specifiedUrl-only fix in 17faa09ac.) + val cacheKey = LOCALISED_RESOURCE_DOC_PREFIX + s"operationId:${operationId}-locale:$locale- isVersion4OrHigher:$isVersion4OrHigher- includeTechnology:$includeTechnology-requestUrl:${resourceDocUpdatedTags.requestUrl}-specifiedUrl:${resourceDocUpdatedTags.specifiedUrl.getOrElse("")}".intern() Caching.memoizeSyncWithImMemory(Some(cacheKey))(CREATE_LOCALISED_RESOURCE_DOC_JSON_TTL.seconds) { val fieldsDescription = if (resourceDocUpdatedTags.tags.toString.contains("Dynamic-Entity") diff --git a/obp-api/src/main/scala/code/api/v1_4_0/OBPAPI1_4_0.scala b/obp-api/src/main/scala/code/api/v1_4_0/OBPAPI1_4_0.scala index 86c2827573..de40c8e33a 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/OBPAPI1_4_0.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/OBPAPI1_4_0.scala @@ -104,8 +104,7 @@ object OBPAPI1_4_0 extends OBPRestHelper with APIMethods140 with MdcLoggable wit Implementations1_4_0.getAtms, Implementations1_4_0.getProducts, Implementations1_4_0.getCrmEvents, - Implementations1_4_0.getTransactionRequestTypes, - Implementations1_4_0.testResourceDoc + Implementations1_4_0.getTransactionRequestTypes ) 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 5f13faa786..b207ba46da 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 @@ -5,9 +5,8 @@ import cats.effect._ import code.api.Constant import code.api.Constant._ import code.api.ResourceDocs1_4_0.SwaggerDefinitionsJSON._ -import code.api.ResourceDocs1_4_0.{ResourceDocs140, ResourceDocsAPIMethodsUtil} import code.api.util.APIUtil.{EmptyBody, _} -import code.api.util.{APIUtil, ApiRole, ApiVersionUtils, CallContext, CustomJsonFormats, Glossary, NewStyle} +import code.api.util.{APIUtil, ApiRole, CallContext, CustomJsonFormats, Glossary, NewStyle} import code.api.util.ApiRole.{canCreateEntitlementAtAnyBank, canCreateEntitlementAtOneBank, canCreateOrganisation, canCreateRoutingScheme, canDeleteEntitlementAtAnyBank, canDeleteOrganisation, canDeleteRoutingScheme, canGetAccountAccessTrace, canGetAnyOrganisation, canGetAnyUser, canGetCacheConfig, canGetCacheInfo, canGetCacheNamespaces, canGetConnectorHealth, canGetCustomersAtOneBank, canGetDatabasePoolInfo, canGetMigrations, canUpdateBankSupportedRoutingScheme, canUpdateOrganisation, canUpdateRoutingScheme, canUpdateSystemView} import code.api.util.ApiTag._ import code.api.util.ErrorMessages._ @@ -233,74 +232,12 @@ object Http4s700 { http4sPartialFunction = Some(getBanks) ) - // Route: GET /obp/v7.0.0/resource-docs/API_VERSION/obp - val getResourceDocsObpV700: HttpRoutes[IO] = HttpRoutes.of[IO] { - case req @ GET -> `prefixPath` / "resource-docs" / requestedApiVersionString / "obp" => - implicit val cc: CallContext = req.callContext - val queryParams = req.uri.query.multiParams - val tags = queryParams - .get("tags") - .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).map(ResourceDocTag(_)).toList) - val functions = queryParams - .get("functions") - .map(_.flatMap(_.split(",").toList).map(_.trim).filter(_.nonEmpty).toList) - val localeParam = queryParams - .get("locale") - .flatMap(_.headOption) - .orElse(queryParams.get("language").flatMap(_.headOption)) - .map(_.trim) - .filter(_.nonEmpty) - - EndpointHelpers.executeAndRespond(req) { _ => - for { - requestedApiVersion <- NewStyle.function.tryons( - failMsg = s"$InvalidApiVersionString Current value: $requestedApiVersionString", - failCode = 400, - callContext = Some(cc) - ) { - ApiVersionUtils.valueOf(requestedApiVersionString) - } - allDocs = { - val raw = ResourceDocs140.ImplementationsResourceDocs.getResourceDocsList(requestedApiVersion).getOrElse(Nil) - val seen = scala.collection.mutable.HashSet[(String, String)]() - raw.filter(doc => seen.add((doc.requestVerb, doc.requestUrl))) - } - filteredDocs = ResourceDocsAPIMethodsUtil.filterResourceDocs(allDocs, tags, functions) - } yield JSONFactory1_4_0.createResourceDocsJson(filteredDocs, isVersion4OrHigher = true, localeParam, includeTechnology = true) - } - } - - resourceDocs += ResourceDoc( - null, - implementedInApiVersion, - nameOf(getResourceDocsObpV700), - "GET", - "/resource-docs/API_VERSION/obp", - "Get Resource Docs", - s"""Get documentation about the RESTful resources on this server including example body payloads. - | - |* API_VERSION: The version of the API for which you want documentation - | - |Returns JSON containing information about the endpoints including: - |* Method (GET, POST, etc.) - |* URL path - |* Summary and description - |* Example request and response bodies - |* Required roles and permissions - | - |Optional query parameters: - |* tags - filter by API tags - |* functions - filter by function names - |* locale - specify language for descriptions - |* content - filter by content type""", - EmptyBody, - EmptyBody, - List( - UnknownError - ), - List(apiTagDocumentation, apiTagApi), - http4sPartialFunction = Some(getResourceDocsObpV700) - ) + // Note: resource-docs requests (`GET /obp/v7.0.0/resource-docs/...`) are intercepted by + // `Http4sResourceDocs.routes`, which is registered earlier in `Http4sApp.baseServices` + // (line 109, ahead of `v700Routes` at line 113). The ResourceDoc metadata for that URL + // is contributed by `ResourceDocs1_4_0.ResourceDocsAPIMethods.localResourceDocs` and + // surfaces through `getResourceDocsList`'s localResourceDocs append for the obp standard. + // There is intentionally no v7-specific handler here. // ── POC endpoints — one per EndpointHelper category ──────────────────── diff --git a/obp-api/src/main/scala/code/management/ImporterAPIRoutes.scala b/obp-api/src/main/scala/code/management/ImporterAPIRoutes.scala new file mode 100644 index 0000000000..63dadf8af4 --- /dev/null +++ b/obp-api/src/main/scala/code/management/ImporterAPIRoutes.scala @@ -0,0 +1,129 @@ +package code.management + +import cats.effect.IO +import code.api.util.{APIUtil, CustomJsonFormats} +import code.api.util.ErrorMessages._ +import code.bankconnectors.LocalMappedConnectorInternal +import code.management.ImporterAPI._ +import code.tesobe.ErrorMessage +import code.util.Helper.MdcLoggable +import com.openbankproject.commons.model.Transaction +import net.liftweb.common.Full +import net.liftweb.json.Extraction +import net.liftweb.json.JsonAST.{JArray, JValue} +import net.liftweb.util.Helpers._ +import org.http4s.{Charset, HttpRoutes, MediaType, Request, Response, Status} +import org.http4s.dsl.io._ +import org.http4s.headers.`Content-Type` + +/** + * Native http4s route for the legacy `POST /obp_transactions_saver/api/transactions` endpoint. + * + * Mirrors `code.management.ImporterAPI`'s Lift `serve` block. Replaces + * `LiftRules.statelessDispatch.append(ImporterAPI)` in `Boot.scala`. + * + * Status-code parity (preserved verbatim from the Lift handler): + * - secret query param missing → 400 "secret missing" + * - secret wrong → 401 "wrong secret" + * - `importer_secret` prop not set on server → 400 "importer_secret not set on the server." + * - secret correct → 200 with JArray of inserted transactions + * - actor returns no envelopes → 500 + */ +object ImporterAPIRoutes extends MdcLoggable { + + private val jsonContentType: `Content-Type` = + `Content-Type`(MediaType.application.json, Charset.`UTF-8`) + + private def errorResponse(message: String, httpCode: Int): IO[Response[IO]] = { + val body = net.liftweb.json.compactRender( + Extraction.decompose(ErrorMessage(message))(CustomJsonFormats.formats)) + IO.pure( + Response[IO](Status.fromInt(httpCode).getOrElse(Status.BadRequest)) + .withEntity(body) + .withContentType(jsonContentType) + ) + } + + val routes: HttpRoutes[IO] = HttpRoutes.of[IO] { + case req @ POST -> Root / "obp_transactions_saver" / "api" / "transactions" => + req.uri.query.params.get("secret") match { + case None => errorResponse("secret missing", 400) + case Some(provided) => + APIUtil.getPropsValue("importer_secret") match { + case Full(expected) if expected == provided => saveTransactions(req) + case Full(_) => errorResponse("wrong secret", 401) + case _ => errorResponse("importer_secret not set on the server.", 400) + } + } + } + + private def saveTransactions(req: Request[IO]): IO[Response[IO]] = { + val ipAddress = req.remoteAddr.map(_.toUriString).getOrElse("") + req.bodyText.compile.string.flatMap { bodyText => + IO.blocking(processBody(bodyText, ipAddress)) + } + } + + // Synchronous: parses JSON, sends to TransactionInserter LiftActor via `!?` + // (blocking ask), then updates account balance / last-updated timestamps. + // Mirrors the Lift handler body in ImporterAPI.scala so behaviour + // (status codes, response shape, side-effects) is preserved verbatim. + private def processBody(bodyText: String, ipAddress: String): Response[IO] = { + val parsedJson: JValue = + scala.util.Try(net.liftweb.json.parse(bodyText)).getOrElse(JArray(Nil)) + val rawTransactions = parsedJson.children + + logger.info( + "Received " + rawTransactions.size + + " json transactions to insert from ip address " + ipAddress) + + val losslessFormats = CustomJsonFormats.losslessFormats + val mf = implicitly[Manifest[ImporterTransaction]] + val importerTransactions = + rawTransactions.flatMap(j => j.extractOpt[ImporterTransaction](losslessFormats, mf)) + + logger.info( + "Received " + importerTransactions.size + + " valid json transactions to insert from ip address " + ipAddress) + + if (importerTransactions.isEmpty) logger.warn("no transactions found to insert") + + val toInsert = TransactionsToInsert(importerTransactions) + val createdEnvelopes = TransactionInserter !? (3.minutes, toInsert) + + createdEnvelopes match { + case Full(inserted: InsertedTransactions) => + val insertedTs = inserted.l + logger.info("inserted " + insertedTs.size + " transactions") + updateBankAccountBalance(insertedTs) + if (insertedTs.isEmpty && importerTransactions.nonEmpty) { + // refresh account lastUpdate in case transactions were duplicates + val mostRecentTransaction = + importerTransactions.maxBy(t => t.obp_transaction.details.completed) + val account = mostRecentTransaction.obp_transaction.this_account + LocalMappedConnectorInternal + .setBankAccountLastUpdated(account.bank.national_identifier, account.number, now) + .openOrThrowException(attemptedToOpenAnEmptyBox) + } + val jsonList = insertedTs.map(whenAddedJson) + Response[IO](Status.Ok) + .withEntity(net.liftweb.json.compactRender(JArray(jsonList))) + .withContentType(jsonContentType) + case _ => + logger.warn("no envelopes inserted") + Response[IO](Status.InternalServerError) + } + } + + private def updateBankAccountBalance(insertedTransactions: List[Transaction]): Unit = { + if (insertedTransactions.nonEmpty) { + val mostRecentTransaction = insertedTransactions.maxBy(t => t.finishDate) + LocalMappedConnectorInternal + .updateAccountBalance( + mostRecentTransaction.bankId, + mostRecentTransaction.accountId, + mostRecentTransaction.balance) + .openOrThrowException(attemptedToOpenAnEmptyBox) + } + } +} diff --git a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala index ce77396bca..4df86868cd 100644 --- a/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala +++ b/obp-api/src/main/scala/code/model/dataAccess/AuthUser.scala @@ -490,10 +490,9 @@ import net.liftweb.util.Helpers._ /** - * Find current ResourceUser from the server. + * Find current ResourceUser from the server. * This method has no parameters, it depends on different login types: * AuthUser: AuthUser.currentUser - * OAuthHandshake: OAuthHandshake.getUser * DirectLogin: DirectLogin.getUser * to get the current Resourceuser . * diff --git a/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala b/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala index 2cdcf22d77..fbcb7ba9ac 100644 --- a/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala +++ b/obp-api/src/main/scala/code/obp/grpc/chat/AuthInterceptor.scala @@ -56,13 +56,12 @@ class AuthInterceptor extends ServerInterceptor with MdcLoggable { // Populate the auth-related CallContext fields via the shared parser // so the gRPC auth path matches what the REST (http4s) path produces. // The downstream auth chain reads authReqHeaderField / directLoginParams - // / oAuthParams — not requestHeaders — to pick a scheme. + // — not requestHeaders — to pick a scheme. val parsed = AuthHeaderParser.parseAuthorizationHeader(Some(authValue)) val cc = CallContext( requestHeaders = List(HTTPParam("Authorization", List(authValue))), authReqHeaderField = parsed.authReqHeaderField, directLoginParams = parsed.directLoginParams, - oAuthParams = parsed.oAuthParams, verb = "GET", url = "/grpc/chat", implementedInVersion = "v6.0.0", diff --git a/obp-api/src/main/scala/code/util/Helper.scala b/obp-api/src/main/scala/code/util/Helper.scala index 51802a0ac4..190bfa56f9 100644 --- a/obp-api/src/main/scala/code/util/Helper.scala +++ b/obp-api/src/main/scala/code/util/Helper.scala @@ -174,49 +174,33 @@ object Helper extends Loggable { /** - * - * @param redirectUrl eg: http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018 - * @return http://localhost:8082/oauthcallback + * @param redirectUrl eg: http://localhost:8082/callback?foo=bar + * @return http://localhost:8082/callback */ def getStaticPortionOfRedirectURL(redirectUrl: String): Box[String] = { tryo(redirectUrl.split("\\?")(0)) //return everything before the "?" } /** - * extract clean redirect url from input value, because input may have some parameters, such as the following examples
- * eg1: http://localhost:8082/oauthcallback?....--> http://localhost:8082
- * eg2: http://localhost:8016?oautallback?=3NLMGV ...--> http://localhost:8016 + * extract the host-only portion of a redirect URL. * - * @param redirectUrl -> http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018 - * @return hostOnlyOfRedirectURL-> http://localhost:8082 + * @param redirectUrl -> http://localhost:8082/callback?foo=bar + * @return hostOnlyOfRedirectURL -> http://localhost:8082 */ @deprecated("We can not only use hostname as the redirectUrl, now add new method `getStaticPortionOfRedirectURL` ","05.12.2023") def getHostOnlyOfRedirectURL(redirectUrl: String): Box[String] = { - val url = new URL(redirectUrl) //eg: http://localhost:8082/oauthcallback?oauth_token=G5AEA2U1WG404EGHTIGBHKRR4YJZAPPHWKOMNEEV&oauth_verifier=53018 + val url = new URL(redirectUrl) val protocol = url.getProtocol() // http val authority = url.getAuthority()// localhost:8082, this will contain the port. tryo(s"$protocol://$authority") // http://localhost:8082 } - /** - * extract Oauth Token String from input value, because input may have some parameters, such as the following examples
- * http://localhost:8082/oauthcallback?oauth_token=DKR242MB3IRCUVG35UZ0QQOK3MBS1G2HL2ZIKK2O&oauth_verifier=64465 - * --> DKR242MB3IRCUVG35UZ0QQOK3MBS1G2HL2ZIKK2O - * - * @param input a long url with parameters - * @return Oauth Token String - */ - def extractOauthToken(input: String): Box[String] = { - Full(input.split("oauth_token=")(1).split("&")(0)) - } - /** * check the redirect url is valid with default values. */ def isValidInternalRedirectUrl(url: String) : Boolean = { - //set the default value is "/" and "/oauth/authorize" val internalRedirectUrlsWhiteList = List( - "/","/oauth/authorize", + "/", "/dummy-user-tokens","/create-sandbox-account", "/add-user-auth-context-update-request","/otp", "/terms-and-conditions", "/privacy-policy", @@ -228,8 +212,6 @@ object Helper extends Loggable { "/consent", ) - //case1: OBP-API login: url = "/" - //case2: API-Explore oauth login: url = "/oauth/authorize?oauth_token=V0JTCDYXWUNTXDZ3VUDNM1HE3Q1PZR2WJ4PURXQA&logUserOut=false" val extractCleanURL = StringUtils.substringBefore(url, "?") internalRedirectUrlsWhiteList.contains(extractCleanURL) diff --git a/obp-api/src/main/scala/code/util/SecureLogging.scala b/obp-api/src/main/scala/code/util/SecureLogging.scala index a5e72d2e0b..522ad2d02b 100644 --- a/obp-api/src/main/scala/code/util/SecureLogging.scala +++ b/obp-api/src/main/scala/code/util/SecureLogging.scala @@ -218,7 +218,6 @@ object SecureLogging { "directLoginParams=Map(password -> secret123, api_key -> sk_live_key)", "client_secret -> my_client_secret", "client_secret->my_client_secret", - "CallContext(oAuthParams=Map(access_token -> bearer_token, client_secret->sensitive_key))", "Map(token->private_token, password -> supersecret, api_key->sk_live_123)" ) testMessages.map(msg => (msg, maskSensitive(msg))) 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 2ffc8504a2..4505fb3c97 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 @@ -242,246 +242,6 @@ class Http4s700RoutesTest extends ServerSetupWithTestData { } } - // ─── resource-docs ─────────────────────────────────────────────────────────── - - feature("Http4s700 resource-docs endpoint") { - - scenario("Allow public access when resource docs role is not required", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp") - - Then("Response is 200 OK with resource_docs array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(_)) => succeed - case _ => - fail("Expected resource_docs field to be an array") - } - case _ => - fail("Expected JSON object for resource-docs endpoint") - } - } - - scenario("Reject unauthenticated access when resource docs role is required", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request without auth headers and role required") - setPropsValues("resource_docs_requires_role" -> "true") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp") - - Then("Response is 401 Unauthorized") - statusCode shouldBe 401 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(AuthenticatedUserIsRequired) - case _ => - fail("Expected message field as JSON string for resource-docs unauthorized response") - } - case _ => - fail("Expected JSON object for resource-docs unauthorized response") - } - } - - scenario("Reject access when authenticated but missing canReadResourceDoc role", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth but no canReadResourceDoc role") - setPropsValues("resource_docs_requires_role" -> "true") - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp", headers) - - Then("Response is 403 Forbidden") - statusCode shouldBe 403 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include(UserHasMissingRoles) - message should include(canReadResourceDoc.toString) - case _ => - fail("Expected message field as JSON string for missing-role response") - } - case _ => - fail("Expected JSON object for missing-role response") - } - } - - scenario("Return docs when authenticated and entitled with canReadResourceDoc", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp request with auth and canReadResourceDoc role") - setPropsValues("resource_docs_requires_role" -> "true") - addEntitlement("", resourceUser1.userId, canReadResourceDoc.toString) - - When("Making HTTP request to server") - val headers = Map("DirectLogin" -> s"token=${token1.value}") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp", headers) - - Then("Response is 200 OK with resource_docs array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(_)) => succeed - case _ => - fail("Expected resource_docs field to be an array") - } - case _ => - fail("Expected JSON object for resource-docs endpoint") - } - } - - scenario("Filter docs by tags parameter", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card request") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp?tags=Card") - - Then("Response is 200 OK and all returned docs contain Card tag") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(resourceDocs)) => - resourceDocs.foreach { - case JObject(rdFields) => - toFieldMap(rdFields).get("tags") match { - case Some(JArray(tags)) => - tags.exists { - case JString(tag) => tag == "Card" - case _ => false - } shouldBe true - case _ => - fail("Expected tags field to be an array") - } - case _ => - fail("Expected resource doc to be a JSON object") - } - case _ => - fail("Expected resource_docs field to be an array") - } - case _ => - fail("Expected JSON object for resource-docs endpoint") - } - } - - scenario("Filter docs by functions parameter", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks request") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp?functions=getBanks") - - Then("Response is 200 OK and includes GET /banks") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(resourceDocs)) => - resourceDocs.foreach { - case JObject(rdFields) => - val fieldMap = toFieldMap(rdFields) - (fieldMap.get("request_verb"), fieldMap.get("request_url")) match { - case (Some(JString(verb)), Some(JString(url))) => - verb shouldBe "GET" - url should endWith("/banks") - case _ => - fail("Expected request_verb and request_url fields as JSON strings") - } - case _ => - fail("Expected resource doc to be a JSON object") - } - case _ => - fail("Expected resource_docs field to be an array") - } - case _ => - fail("Expected JSON object for resource-docs endpoint") - } - } - - scenario("Serve v6.0.0 resource docs when v6.0.0 requested via v7 endpoint", Http4s700RoutesTag) { - // Previously returned 400 — fixed by delegating to ImplementationsResourceDocs.getResourceDocsList - Given("GET /obp/v7.0.0/resource-docs/v6.0.0/obp?functions=getBanks — filtered to avoid timeout") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v6.0.0/obp?functions=getBanks") - - Then("Response is 200 OK with resource_docs array") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(_)) => succeed - case _ => - fail("Expected resource_docs field to be an array") - } - case _ => - fail("Expected JSON object") - } - } - - scenario("Return 400 for an unrecognised API version string", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/not-a-version/obp") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/not-a-version/obp") - - Then("Response is 400 with error message containing the bad version string") - statusCode shouldBe 400 - json match { - case JObject(fields) => - toFieldMap(fields).get("message") match { - case Some(JString(message)) => - message should include("not-a-version") - case _ => - fail("Expected message field") - } - case _ => - fail("Expected JSON object") - } - } - - scenario("Resource doc entries contain required fields", Http4s700RoutesTag) { - Given("GET /obp/v7.0.0/resource-docs/v7.0.0/obp") - setPropsValues("resource_docs_requires_role" -> "false") - - When("Making HTTP request to server") - val (statusCode, json, _) = makeHttpRequest("/obp/v7.0.0/resource-docs/v7.0.0/obp") - - Then("Each resource doc has operation_id, request_verb, request_url, summary, tags") - statusCode shouldBe 200 - json match { - case JObject(fields) => - toFieldMap(fields).get("resource_docs") match { - case Some(JArray(docs)) => - docs.headOption match { - case Some(JObject(docFields)) => - val keys = docFields.map(_.name) - keys should contain("operation_id") - keys should contain("request_verb") - keys should contain("request_url") - keys should contain("summary") - keys should contain("tags") - case _ => - fail("Expected resource doc to be a JSON object") - } - case _ => - fail("Expected resource_docs array") - } - case _ => - fail("Expected JSON object") - } - } - } - // ─── cross-cutting middleware ───────────────────────────────────────────────── feature("Http4s700 response headers") { diff --git a/obp-api/src/test/scala/code/setup/PropsReset.scala b/obp-api/src/test/scala/code/setup/PropsReset.scala index dd92d097bc..dda2af4d4d 100644 --- a/obp-api/src/test/scala/code/setup/PropsReset.scala +++ b/obp-api/src/test/scala/code/setup/PropsReset.scala @@ -6,34 +6,150 @@ import org.scalatest.{BeforeAndAfterAll, BeforeAndAfterEach, Suite} /** - * Any unit test that extends this trait, have a chance to set new Props value, - * after each test rollback original Props values + * Test isolation for `Props.lockedProviders`. + * + * Problem this trait solves + * ------------------------- + * + * `Props.lockedProviders` is a process-global stack of Map overrides on top of + * the property files. Any test mutation to it leaks across the JVM. In + * single-fork test runs (Jenkins `forkMode=once`, ~2800 suites in one JVM) the + * leak is permanent for the rest of the run unless explicitly unwound. + * + * ScalaTest instantiates every test suite up-front, before running any test. + * Code at trait-body or feature-body level (outside any `scenario {}`) thus + * mutates `Props.lockedProviders` *before any beforeAll fires*. Two consequences: + * + * 1. Any "snapshot at trait construction" approach captures pollution made by + * other suites' construction-time pushes — the pollution is then encoded + * as the captured baseline and resurrected on every afterEach. (This is + * the bug Jenkins #7861 hit: APIUtilHeavyTest pushed + * `api_enabled_versions=[OBPv4.0.0]` at feature-body level, every + * subsequent suite's snapshot captured it, every v6/v7 request 404'd.) + * + * 2. Simple per-suite tracking ("undo what I pushed") doesn't fix it either, + * because suite N cannot remove suite M's tagged pushes — suite M hasn't + * run yet, so N's afterEach sees M's pollution and is powerless. + * + * Design + * ------ + * + * Process-global ownership tracking + per-suite snapshot at first beforeEach. + * + * - Every `setPropsValues` push is registered in a JVM-global identity set + * (`PropsReset.ownedMaps`). The set is shared across all suite instances. + * - `beforeAll` (PropsReset side): WIPE all owned maps from + * `Props.lockedProviders` **before** chaining `super.beforeAll()`. This + * removes every construction-time push made by every suite (including this + * one) and every prior-suite baseline. The wipe runs early in the super- + * chain because PropsReset is the rightmost trait in `ServerSetup`'s mixin + * order; `super.beforeAll()` then unwinds to parent traits' beforeAll + * overrides, including `ServerSetup.beforeAll` which (re-)pushes this + * suite's baselines. + * - `beforeEach` (PropsReset side): on first invocation, capture + * `lockedProviders` as the per-suite snapshot. By this point all + * beforeAll-chain pushes (e.g. ServerSetup's baselines) have happened, so + * the snapshot is "clean pristine + this suite's baselines". + * - `afterEach`: restore to snapshot. Removes any scenario-local mutations. + * - `afterAll`: wipe owned maps. Ensures the next suite's `beforeAll` doesn't + * have to re-wipe (defensive — even if a future suite is misbuilt and + * skips PropsReset, the previous suite cleans up after itself). + * + * Why "owned" identity tracking (not structural equality) and not size-based + * tracking: + * - Two suites may push the same key=value content. Removing by structural + * equality would clobber pushes that other suites still need. + * - Size-based tracking assumes only this suite mutates the stack, which is + * not true under cross-suite contamination. + * - Identity tracking with `IdentityHashMap` is the only invariant that + * uniquely identifies a push's origin. + * + * Constraints on callers + * ---------------------- + * + * - **`setPropsValues` is the only sanctioned writer.** Anything that writes + * `Props.lockedProviders` directly (via reflection or otherwise) won't be + * in `ownedMaps`, so the wipe won't touch it. That's intended — true + * baselines (e.g. from property files via Lift's own loader) live outside + * this set and are preserved. + * - **Baseline pushes that should persist for a suite's duration MUST go in + * `beforeAll`** (not in trait/class body). See `ServerSetup.beforeAll`. + * Body-level pushes get wiped by this trait's beforeAll before they have + * any effect. */ +object PropsReset { + // JVM-global set of every Map ever pushed via `setPropsValues`. Identity-based + // membership: each push is a fresh Map instance, so adding the same key=value + // twice creates two separate entries (and both can be tracked/removed + // independently). + private val ownedMaps: java.util.Set[Map[String, String]] = { + val backing = new java.util.IdentityHashMap[Map[String, String], java.lang.Boolean]() + java.util.Collections.synchronizedSet(java.util.Collections.newSetFromMap(backing)) + } + + private[setup] def registerOwned(m: Map[String, String]): Unit = ownedMaps.add(m) + + private[setup] def isOwned(m: Map[String, String]): Boolean = ownedMaps.contains(m) +} + trait PropsReset extends BeforeAndAfterAll with BeforeAndAfterEach { this: Suite => + private var snapshot: List[Map[String, String]] = Nil + private var snapshotTaken: Boolean = false + + override def beforeAll(): Unit = { + // STEP 1: wipe pollution. This runs BEFORE super.beforeAll() chains to + // ServerSetup/other parent beforeAll overrides. Removes every owned map + // currently on Props.lockedProviders — i.e. every construction-time push + // from any suite, plus any leftover from a previous suite's baselines. + val current = getLockedProviders + writeLockedProviders(current.filterNot(PropsReset.isOwned)) + + // STEP 2: chain. ServerSetup.beforeAll (and any other ancestor) will push + // this suite's baselines onto the now-clean stack. + super.beforeAll() + } + + override def beforeEach(): Unit = { + super.beforeEach() + // Snapshot once, after the first beforeAll-chain has completed and all + // beforeEach pushes (e.g. ServerSetup's berlin_group re-application) have + // fired. afterEach restores to this state. + if (!snapshotTaken) { + snapshot = getLockedProviders + snapshotTaken = true + } + } + override def afterEach(): Unit = { super.afterEach() - resetLockedProviders() + if (snapshotTaken) writeLockedProviders(snapshot) } + override def afterAll(): Unit = { - super.afterEach() - resetLockedProviders() + super.afterAll() + // Defensive: wipe everything this trait class registered, so the next suite + // starts from a known state regardless of what we left behind. + val current = getLockedProviders + writeLockedProviders(current.filterNot(PropsReset.isOwned)) } - private val lockedProviders: List[Map[String, String]] = getLockedProviders - - private def getLockedProviders = { + private def getLockedProviders: List[Map[String, String]] = { FieldUtils.readDeclaredField(Props, "net$liftweb$util$Props$$lockedProviders", true) .asInstanceOf[List[Map[String, String]]] } - private def resetLockedProviders(): Unit = { - FieldUtils.writeDeclaredField(Props, "net$liftweb$util$Props$$lockedProviders", lockedProviders, true) + private def writeLockedProviders(value: List[Map[String, String]]): Unit = { + FieldUtils.writeDeclaredField(Props, "net$liftweb$util$Props$$lockedProviders", value, true) } def setPropsValues(keyValues: (String, String)*): Unit = { - val newLockedProviders = keyValues.toMap :: getLockedProviders - FieldUtils.writeDeclaredField(Props, "net$liftweb$util$Props$$lockedProviders", newLockedProviders, true) + // Always create a fresh Map instance — identity-based tracking requires + // every push to be a distinct object so that removing it later cannot + // accidentally affect another caller's push of the same content. + val m: Map[String, String] = Map(keyValues: _*) + PropsReset.registerOwned(m) + writeLockedProviders(m :: getLockedProviders) } } diff --git a/obp-api/src/test/scala/code/setup/ServerSetup.scala b/obp-api/src/test/scala/code/setup/ServerSetup.scala index 26d3b1aaa7..aed9ed62c6 100644 --- a/obp-api/src/test/scala/code/setup/ServerSetup.scala +++ b/obp-api/src/test/scala/code/setup/ServerSetup.scala @@ -47,32 +47,63 @@ trait ServerSetup extends FeatureSpec with SendServerRequests with BeforeAndAfterAll with Matchers with MdcLoggable with CustomJsonFormats with PropsReset{ - setPropsValues("migration_scripts.execute_all" -> "true") - setPropsValues("migration_scripts.execute" -> "true") - setPropsValues("allow_dauth" -> "true") - setPropsValues("dauth.host" -> "127.0.0.1") - setPropsValues("jwt_token_secret"->"your-at-least-256-bit-secret-token") - setPropsValues("jwt.public_key_rsa" -> "src/test/resources/cert/public_dauth.pem") - setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD,AGENT_CASH_WITHDRAWAL,CARDANO") - setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") - setPropsValues("AGENT_CASH_WITHDRAWAL_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") - setPropsValues("api_instance_id" -> "1_final") - setPropsValues("starConnector_supported_types" -> "mapped,internal,cardano_vJun2025") - setPropsValues("connector" -> "star") - - // Berlin Group - set in trait body for initial setup - setPropsValues("berlin_group_mandatory_headers" -> "") - setPropsValues("berlin_group_mandatory_header_consent" -> "") + // Baseline props pushed twice — once at trait construction, once in beforeAll. + // + // Trait-body pushes are needed because `val server = TestServer` below is + // evaluated at suite construction and triggers Lift Boot + http4s server + // startup. Migrations and the connector are wired from Props at that + // moment, so `migration_scripts.execute_all`, `connector`, etc. MUST be in + // place before TestServer.init. In environments where the props file + // already carries these values (the source-controlled test.default.props), + // omitting trait-body pushes goes unnoticed. In GitHub Actions, where the + // workflow writes a minimal props file from scratch, missing trait-body + // pushes cause Lift to skip migrations and tests get an empty DB. + // + // beforeAll pushes are needed because PropsReset.beforeAll wipes every + // setPropsValues-owned map from Props.lockedProviders before chaining + // super — to purge cross-suite contamination from other suites' + // construction-time pushes. The same wipe also removes our own trait-body + // baselines (TestServer is already past Boot at that point and doesn't + // care, but the test scenarios do). Re-pushing in beforeAll restores them + // onto the now-clean stack for this suite's tests. + private def pushBaselineProps(): Unit = { + setPropsValues("migration_scripts.execute_all" -> "true") + setPropsValues("migration_scripts.execute" -> "true") + setPropsValues("allow_dauth" -> "true") + setPropsValues("dauth.host" -> "127.0.0.1") + setPropsValues("jwt_token_secret" -> "your-at-least-256-bit-secret-token") + setPropsValues("jwt.public_key_rsa" -> "src/test/resources/cert/public_dauth.pem") + setPropsValues("transactionRequests_supported_types" -> "SEPA,SANDBOX_TAN,FREE_FORM,COUNTERPARTY,ACCOUNT,ACCOUNT_OTP,SIMPLE,CARD,AGENT_CASH_WITHDRAWAL,CARDANO") + setPropsValues("CARD_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") + setPropsValues("AGENT_CASH_WITHDRAWAL_OTP_INSTRUCTION_TRANSPORT" -> "DUMMY") + setPropsValues("api_instance_id" -> "1_final") + setPropsValues("starConnector_supported_types" -> "mapped,internal,cardano_vJun2025") + setPropsValues("connector" -> "star") + setPropsValues("berlin_group_mandatory_headers" -> "") + setPropsValues("berlin_group_mandatory_header_consent" -> "") + } + + // Trait-body push: needed before `val server = TestServer` triggers Lift Boot. + pushBaselineProps() + + override def beforeAll(): Unit = { + super.beforeAll() // PropsReset.beforeAll wipes owned maps first + pushBaselineProps() // re-push for this suite's tests + resetDatabaseForTestClass() + } override def beforeEach(): Unit = { super.beforeEach() - // Re-apply Berlin Group props after each PropsReset.afterEach() restores lockedProviders + // Re-apply Berlin Group props on every scenario — PropsReset.afterEach + // restores to the post-beforeEach snapshot, which after the first scenario + // is the same as post-beforeAll. Subsequent scenarios push fresh berlin_group + // entries; afterEach trims them back to that snapshot. setPropsValues( "berlin_group_mandatory_headers" -> "", "berlin_group_mandatory_header_consent" -> "" ) } - + // Set system properties to force Pekko to use random available ports // This prevents conflicts when both RunWebApp and tests are running System.setProperty("pekko.remote.artery.canonical.port", "0") @@ -88,10 +119,6 @@ trait ServerSetup extends FeatureSpec with SendServerRequests * We preserve only the essential OAuth/auth tables (Nonce, Token, Consumer, AuthUser, ResourceUser) * as these are needed for test authentication and are managed by DefaultUsers trait. */ - override def beforeAll(): Unit = { - super.beforeAll() - resetDatabaseForTestClass() - } /** * Resets database tables to ensure clean state for each test class. diff --git a/obp-api/src/test/scala/code/util/ApiSessionTest.scala b/obp-api/src/test/scala/code/util/ApiSessionTest.scala index b8eb4564d1..1ae7e702ff 100644 --- a/obp-api/src/test/scala/code/util/ApiSessionTest.scala +++ b/obp-api/src/test/scala/code/util/ApiSessionTest.scala @@ -62,23 +62,22 @@ class ApiSessionTest extends FeatureSpec with Matchers with GivenWhenThen with M scenario("toString should mask sensitive data") { val callContextWithSensitiveData = CallContext( - directLoginParams = Map("password" -> "supersecret"), - oAuthParams = Map("client_secret" -> "my_client_secret") + directLoginParams = Map("password" -> "supersecret", "client_secret" -> "my_client_secret") ) - + val toStringResult = callContextWithSensitiveData.toString - + // Verify that sensitive data is masked - should NOT contain the actual sensitive values toStringResult should not contain "supersecret" toStringResult should not contain "my_client_secret" - + // Verify that the result contains the case class structure (not just object reference) toStringResult should include("CallContext") - + // Verify that masking occurs by checking for masked patterns or lack of sensitive data val containsActualSensitiveData = toStringResult.contains("supersecret") || toStringResult.contains("my_client_secret") - + containsActualSensitiveData should be (false) } }