diff --git a/obp-api/src/main/resources/props/sample.props.template b/obp-api/src/main/resources/props/sample.props.template index 78c4fc975d..82ba3abbfb 100644 --- a/obp-api/src/main/resources/props/sample.props.template +++ b/obp-api/src/main/resources/props/sample.props.template @@ -903,6 +903,20 @@ dauth.host=127.0.0.1 # -------------------------------------- DAuth-- +# -- SIWE (Sign-In With Ethereum, EIP-4361) ----- +# A wallet-proves-possession auth method, sibling to DAuth. Phase 1 supports +# Externally-Owned Accounts (EOA) only — pure local ecrecover, no RPC dependency. +# Master on/off switch. Default is false (feature OFF) when not defined. +# allow_siwe=false +# Expected `domain` field of the SIWE message (anti-phishing). Required when allow_siwe=true. +# siwe.domain=example.com +# Comma-separated EVM chain IDs accepted (e.g. 1 = mainnet, 11155111 = sepolia). Required when allow_siwe=true. +# siwe.allowed_chain_ids=1,11155111 +# Challenge nonce lifetime in seconds. Default is 300 (5 minutes). +# siwe.nonce.ttl=300 +# -------------------------------------- SIWE-- + + # -- Display internal errors -------------------------------------- # Enable/Disable showing of nested/chained error messages to an end user diff --git a/obp-api/src/main/scala/code/api/SIWERoutes.scala b/obp-api/src/main/scala/code/api/SIWERoutes.scala new file mode 100644 index 0000000000..1a207f46f7 --- /dev/null +++ b/obp-api/src/main/scala/code/api/SIWERoutes.scala @@ -0,0 +1,128 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + + This product includes software developed at + TESOBE (http://www.tesobe.com/) + + */ +package code.api + +import java.time.Instant + +import cats.effect.IO +import code.api.SIWE.{SiweChallengeRequest, SiweChallengeResponse, SiweLoginRequest} +import code.api.util.ErrorMessages._ +import code.api.util.http4s.Http4sRequestAttributes.EndpointHelpers +import code.api.util.http4s.{Http4sCallContextBuilder, Http4sRequestAttributes} +import code.api.util.{CallContext, CustomJsonFormats} +import code.util.Helper.booleanToFuture +import com.openbankproject.commons.ExecutionContext.Implicits.global +import org.http4s._ +import org.http4s.dsl.io._ +import org.json4s.Formats + +/** + * Native http4s routes for the non-versioned SIWE (Sign-In With Ethereum) endpoints. + * + * POST /my/logins/siwe/challenge (anonymous) → { nonce, message, expires_at } + * POST /my/logins/siwe (anonymous) → { token } + * + * Sibling to [[code.api.DirectLoginRoutes]]; wired into `Http4sApp.baseServices`. + * Both endpoints are anonymous (no SIWE header is carried), so the central auth + * dispatch in `APIUtil.getUserAndSessionContextFuture` skips them — exactly as it + * skips `/my/logins/direct`. + * + * The whole route is inert (`HttpRoutes.empty`) unless `allow_siwe=true`, so the + * feature is OFF by default. The verification logic lives in [[code.api.SIWE]]. + */ +object SIWERoutes { + + private implicit val formats: Formats = CustomJsonFormats.formats + + val routes: HttpRoutes[IO] = if (!SIWE.isEnabled) HttpRoutes.empty[IO] else HttpRoutes.of[IO] { + + // ---- 1. CHALLENGE: issue a nonce + a ready-to-sign EIP-4361 message ----- + case req @ POST -> Root / "my" / "logins" / "siwe" / "challenge" => + Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc => + val reqWithCC = req.withAttribute(Http4sRequestAttributes.callContextKey, cc) + EndpointHelpers.executeFutureWithBody[SiweChallengeRequest, SiweChallengeResponse](reqWithCC) { + (body, cc) => + val domainBox = SIWE.domainProp + for { + _ <- booleanToFuture(SiweNotEnabled, cc = Some(cc)) { SIWE.isEnabled } + _ <- booleanToFuture(SiweConfigMissing + "siwe.domain", cc = Some(cc)) { domainBox.isDefined } + _ <- booleanToFuture(SiweInvalidAddress, cc = Some(cc)) { SIWE.isValidEthAddress(body.address) } + _ <- booleanToFuture(SiweChainIdNotAllowed, cc = Some(cc)) { SIWE.allowedChainIds.contains(body.chain_id) } + } yield { + val domain = domainBox.openOr("") + val checksumAddress = SIWE.toChecksum(body.address) + val nonce = SIWE.generateNonce() + SIWE.storeNonce(nonce, checksumAddress) + val issuedAt = Instant.now() + val expiresAt = issuedAt.plusSeconds(SIWE.nonceTtlSeconds.toLong) + val uri = body.uri.filter(_.trim.nonEmpty).getOrElse(s"https://$domain") + val statement = body.statement.filter(_.trim.nonEmpty) + .getOrElse("Sign in to the Open Bank Project with your Ethereum account.") + val message = SIWE.buildMessage( + domain, checksumAddress, body.chain_id, nonce, uri, statement, + issuedAt.toString, expiresAt.toString) + SiweChallengeResponse(nonce, message, expiresAt.toString) + } + } + } + + // ---- 2. LOGIN: verify the signed message, then mint a token ------------- + case req @ POST -> Root / "my" / "logins" / "siwe" => + Http4sCallContextBuilder.fromRequest(req, apiVersion = "").flatMap { cc => + val reqWithCC = req.withAttribute(Http4sRequestAttributes.callContextKey, cc) + EndpointHelpers.executeFutureWithBodyCreated[SiweLoginRequest, JSONFactory.TokenJSON](reqWithCC) { + (body, cc) => + val domainBox = SIWE.domainProp + val parsedBox = SIWE.parseMessage(body.message) + for { + _ <- booleanToFuture(SiweNotEnabled, cc = Some(cc)) { SIWE.isEnabled } + _ <- booleanToFuture(SiweConfigMissing + "siwe.domain", cc = Some(cc)) { domainBox.isDefined } + _ <- booleanToFuture(SiweMessageMalformed, cc = Some(cc)) { parsedBox.isDefined } + parsed = parsedBox.openOrThrowException(SiweMessageMalformed) + _ <- booleanToFuture(SiweInvalidAddress, cc = Some(cc)) { SIWE.isValidEthAddress(parsed.address) } + _ <- booleanToFuture(SiweDomainMismatch, cc = Some(cc)) { parsed.domain.equalsIgnoreCase(domainBox.openOr("")) } + _ <- booleanToFuture(SiweChainIdNotAllowed, cc = Some(cc)) { SIWE.allowedChainIds.contains(parsed.chainId) } + _ <- booleanToFuture(SiweMessageExpired, cc = Some(cc)) { !SIWE.isExpired(parsed.expirationTime) } + // Recover the signer BEFORE consuming the nonce, so a bad signature + // doesn't burn a still-valid nonce. + recoveredBox = SIWE.recoverEoaAddress(body.message, body.signature) + _ <- booleanToFuture(SiweSignatureInvalid, cc = Some(cc)) { + recoveredBox.toList.exists(_.equalsIgnoreCase(parsed.address)) } + // Single-use: consume now. The nonce must have been issued for this address. + consumedAddress = SIWE.consumeNonce(parsed.nonce) + _ <- booleanToFuture(SiweNonceInvalid, cc = Some(cc)) { + consumedAddress.exists(_.equalsIgnoreCase(parsed.address)) } + userBox = SIWE.getOrCreateUser(parsed.chainId, SIWE.toChecksum(parsed.address)) + _ <- booleanToFuture(CannotGetOrCreateUser, cc = Some(cc)) { userBox.isDefined } + user = userBox.openOrThrowException(CannotGetOrCreateUser) + tokenBox = SIWE.mintToken(user.userPrimaryKey.value, body.consumer_key) + _ <- booleanToFuture(InvalidDirectLoginParameters, cc = Some(cc)) { tokenBox.isDefined } + } yield JSONFactory.createTokenJSON(tokenBox.openOrThrowException(InvalidDirectLoginParameters)) + } + } + } +} diff --git a/obp-api/src/main/scala/code/api/siwe.scala b/obp-api/src/main/scala/code/api/siwe.scala new file mode 100644 index 0000000000..c5d29fa202 --- /dev/null +++ b/obp-api/src/main/scala/code/api/siwe.scala @@ -0,0 +1,315 @@ +/** +Open Bank Project - API +Copyright (C) 2011-2026, TESOBE GmbH + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Affero General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Affero General Public License for more details. + +You should have received a copy of the GNU Affero General Public License +along with this program. If not, see . + +Email: contact@tesobe.com +TESOBE GmbH. +Osloer Strasse 16/17 +Berlin 13359, Germany + + This product includes software developed at + TESOBE (http://www.tesobe.com/) + + */ +package code.api + +import java.nio.charset.StandardCharsets +import java.time.Instant +import java.util.Date + +import code.api.cache.Redis +import code.api.util.APIUtil.HTTPParam +import code.api.util._ +import code.consumer.Consumers +import code.model.{TokenType, UserX} +import code.token.Tokens +import code.util.Helper.MdcLoggable +import com.nimbusds.jwt.JWTClaimsSet +import com.openbankproject.commons.ExecutionContext.Implicits.global +import com.openbankproject.commons.model.User +import net.liftweb.common._ +import net.liftweb.util.Helpers +import net.liftweb.util.Helpers.tryo +import org.web3j.crypto.{Keys, Sign} +import org.web3j.utils.Numeric + +import scala.concurrent.Future + +/** + * SIWE — Sign-In With Ethereum (EIP-4361) authentication method. + * + * A new sibling to DAuth. Where DAuth's relay merely *vouches for* an asserted + * smart-contract address, SIWE makes the live caller *cryptographically prove* + * control of a wallet. Phase 1 supports Externally-Owned Accounts (EOA) only — + * verification is pure local `ecrecover` with zero RPC dependency. Smart-contract + * / multisig wallets (EIP-1271) are a planned, additive Phase 2. + * + * Flow (modelled on DirectLogin's exchange-once-for-a-token shape): + * 1. POST /my/logins/siwe/challenge → server issues { nonce, message, expires_at } + * 2. client signs the EIP-4361 message off-OBP + * 3. POST /my/logins/siwe → server verifies + mints an OBP token + * 4. subsequent calls carry `SIWE: token=...` (validated here) + * + * The feature is OFF by default; everything below is inert unless `allow_siwe=true`. + * + * Routes live in [[code.api.SIWERoutes]]; the subsequent-request branch is wired + * into `APIUtil.getUserAndSessionContextFuture`. + */ +object SIWE extends MdcLoggable { + + // ---- request / response bodies ------------------------------------------- + + /** POST /my/logins/siwe/challenge body. */ + case class SiweChallengeRequest( + address: String, + chain_id: Long, + uri: Option[String], + statement: Option[String] + ) + + /** POST /my/logins/siwe/challenge response. */ + case class SiweChallengeResponse( + nonce: String, + message: String, + expires_at: String + ) + + /** POST /my/logins/siwe body. */ + case class SiweLoginRequest( + message: String, + signature: String, + consumer_key: Option[String] + ) + + /** Fields extracted from a parsed EIP-4361 message. */ + case class SiweParsedMessage( + domain: String, + address: String, + chainId: Long, + nonce: String, + issuedAt: Option[String], + expirationTime: Option[String], + uri: Option[String] + ) + + // ---- props --------------------------------------------------------------- + + // OFF by default. Nothing in this object does anything until this is true. + def isEnabled: Boolean = APIUtil.getPropsAsBoolValue("allow_siwe", false) + + /** Expected `domain` field of the SIWE message (anti-phishing). Required when enabled. */ + def domainProp: Box[String] = APIUtil.getPropsValue("siwe.domain").filter(_.trim.nonEmpty) + + /** Comma-separated EVM chain IDs accepted, e.g. "1,11155111". */ + def allowedChainIds: List[Long] = + APIUtil.getPropsValue("siwe.allowed_chain_ids", "") + .split(",").map(_.trim).filter(_.nonEmpty) + .flatMap(s => tryo(s.toLong).toList).toList + + /** Challenge nonce lifetime in seconds (default 300 = 5 min). */ + def nonceTtlSeconds: Int = APIUtil.getPropsAsIntValue("siwe.nonce.ttl", 300) + + // ---- header constants & parsing ------------------------------------------ + + val SiweHeaderKey = "SIWE" + + def hasSiweHeader(requestHeaders: List[HTTPParam]): Boolean = + requestHeaders.exists(_.name.equalsIgnoreCase(SiweHeaderKey)) + + /** Parse `SIWE: token=` → Some(key). Mirrors DirectLogin's `token=` parsing. */ + def getSiweToken(requestHeaders: List[HTTPParam]): Option[String] = { + val raw = requestHeaders + .find(_.name.equalsIgnoreCase(SiweHeaderKey)) + .flatMap(_.values.headOption) + .getOrElse("") + raw.split(",").map(_.trim).flatMap { entry => + if (entry.startsWith("token")) { + val parts = entry.split("=", 2) + if (parts.length == 2) Some(parts(1).replaceAll("^\"|\"$", "")) else None + } else None + }.headOption.filter(_.nonEmpty) + } + + // ---- validation helpers -------------------------------------------------- + + private val ethAddressRegex = "^0x[0-9a-fA-F]{40}$".r + + def isValidEthAddress(address: String): Boolean = + Option(address).exists(a => ethAddressRegex.findFirstIn(a.trim).isDefined) + + /** EIP-55 checksummed form of an address (stable, canonical username). */ + def toChecksum(address: String): String = Keys.toChecksumAddress(address.trim) + + /** True only when an expiration time is present AND already in the past. */ + def isExpired(expirationTime: Option[String]): Boolean = expirationTime match { + case Some(s) => tryo(Instant.parse(s.trim)) match { + case Full(instant) => instant.isBefore(Instant.now()) + case _ => false // unparseable → leave to message-malformed handling, don't reject here + } + case None => false + } + + // ---- nonce lifecycle (Redis, single-use) --------------------------------- + + private def nonceKey(nonce: String): String = s"siwe:nonce:$nonce" + + def generateNonce(): String = Helpers.randomString(32) + + /** Store nonce → claimed address (checksummed) with a short TTL. */ + def storeNonce(nonce: String, checksumAddress: String): Unit = + Redis.use(JedisMethod.SET, nonceKey(nonce), Some(nonceTtlSeconds), Some(checksumAddress)) + + /** Consume a nonce single-use: return the address it was issued for, then delete it. */ + def consumeNonce(nonce: String): Option[String] = { + val stored = Redis.use(JedisMethod.GET, nonceKey(nonce)) + stored.foreach(_ => Redis.use(JedisMethod.DELETE, nonceKey(nonce))) + stored + } + + // ---- EIP-4361 message build & parse -------------------------------------- + + def buildMessage( + domain: String, + checksumAddress: String, + chainId: Long, + nonce: String, + uri: String, + statement: String, + issuedAt: String, + expirationTime: String + ): String = + s"""$domain wants you to sign in with your Ethereum account: + |$checksumAddress + | + |$statement + | + |URI: $uri + |Version: 1 + |Chain ID: $chainId + |Nonce: $nonce + |Issued At: $issuedAt + |Expiration Time: $expirationTime""".stripMargin + + def parseMessage(message: String): Box[SiweParsedMessage] = tryo { + def field(label: String): Option[String] = + s"(?m)^${java.util.regex.Pattern.quote(label)}: (.+)$$".r + .findFirstMatchIn(message).map(_.group(1).trim) + + val firstLine = message.split("\n", -1).headOption.getOrElse("") + val domain = firstLine.replace(" wants you to sign in with your Ethereum account:", "").trim + val address = "(?m)^(0x[0-9a-fA-F]{40})\\s*$".r + .findFirstMatchIn(message).map(_.group(1)) + .getOrElse(throw new RuntimeException("SIWE message has no address line")) + val chainId = field("Chain ID").map(_.toLong) + .getOrElse(throw new RuntimeException("SIWE message has no Chain ID")) + val nonce = field("Nonce") + .getOrElse(throw new RuntimeException("SIWE message has no Nonce")) + + SiweParsedMessage( + domain = domain, + address = address, + chainId = chainId, + nonce = nonce, + issuedAt = field("Issued At"), + expirationTime = field("Expiration Time"), + uri = field("URI") + ) + } + + // ---- EOA signature recovery (Phase 1) ------------------------------------ + + /** + * Recover the EIP-191 personal_sign signer from a 65-byte secp256k1 signature. + * `signedPrefixedMessageToKey` applies the "\x19Ethereum Signed Message:\n" + * prefix, matching what wallets sign for an EIP-4361 message. + * @return the recovered address, EIP-55 checksummed. + */ + def recoverEoaAddress(message: String, signatureHex: String): Box[String] = tryo { + val sig = Numeric.hexStringToByteArray(signatureHex.trim) + if (sig.length != 65) throw new RuntimeException("signature must be 65 bytes") + val r = java.util.Arrays.copyOfRange(sig, 0, 32) + val s = java.util.Arrays.copyOfRange(sig, 32, 64) + val vRaw = sig(64) & 0xff + val v = (if (vRaw < 27) vRaw + 27 else vRaw).toByte + val signatureData = new Sign.SignatureData(v, r, s) + val publicKey = Sign.signedPrefixedMessageToKey( + message.getBytes(StandardCharsets.UTF_8), signatureData) + Keys.toChecksumAddress("0x" + Keys.getAddress(publicKey)) + } + + // ---- user + token -------------------------------------------------------- + + /** Get-or-create the OBP user for this wallet, namespaced by chain so SIWE and + * DAuth identities (and different chains) never collide. */ + def getOrCreateUser(chainId: Long, checksumAddress: String): Box[User] = { + val provider = "siwe." + chainId + AuthRateLimiter.check(APIUtil.getRemoteIpAddress(), provider, checksumAddress) match { + case Left(_) => Failure(ErrorMessages.TooManyRequests) + case Right(_) => UserX.getOrCreateDauthResourceUser(provider, checksumAddress) + } + } + + /** Mint an OBP token using the same store DirectLogin uses (token table + JWT/HMAC). */ + def mintToken(userId: Long, consumerKey: Option[String]): Box[String] = tryo { + val secret = Helpers.randomString(40) + val jwtClaims = JWTClaimsSet.parse("""{"":""}""") + val tokenKey = CertificateUtil.jwtWithHmacProtection(jwtClaims, secret) + val consumerId = consumerKey.flatMap { key => + Consumers.consumers.vend.getConsumerByConsumerKey(key) match { + case Full(consumer) => Some(consumer.id.get) + case _ => None + } + } + val currentTime = System.currentTimeMillis() + val tokenDuration = Helpers.weeks(APIUtil.getPropsAsIntValue("token_expiration_weeks", 4)) + Tokens.tokens.vend.createToken( + TokenType.Access, + consumerId, + Some(userId), + Some(tokenKey), + Some(secret), + Some(tokenDuration), + Some(new Date(currentTime + tokenDuration)), + Some(new Date(currentTime)), + None + ) match { + case Full(_) => tokenKey + case _ => throw new RuntimeException("Could not persist SIWE token") + } + } + + // ---- subsequent-request validation (the `SIWE: token=...` header) --------- + + /** + * Resolve the User behind a SIWE-minted session token. The token lives in the + * shared `token` table (same machinery as DirectLogin), so we look it up by key + * and resolve the user already created at login. + */ + def getUserFromSiweHeaderFuture(cc: CallContext): Future[(Box[User], Option[CallContext])] = { + getSiweToken(cc.requestHeaders) match { + case Some(tokenKey) => + Tokens.tokens.vend.getTokenByKeyAndTypeFuture(tokenKey, TokenType.Access) map { + case Full(token) => + val user = token.user + (user, Some(cc.copy(user = user, consumer = token.consumer))) + case _ => + (Failure(ErrorMessages.DirectLoginInvalidToken + tokenKey), Some(cc)) + } + case None => + Future.successful((Failure(ErrorMessages.DirectLoginInvalidToken), Some(cc))) + } + } +} 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 fe93b9a8d1..d605a71435 100644 --- a/obp-api/src/main/scala/code/api/util/APIUtil.scala +++ b/obp-api/src/main/scala/code/api/util/APIUtil.scala @@ -2904,6 +2904,9 @@ object APIUtil extends MdcLoggable with CustomJsonFormats{ case _ => Future { (Failure(ErrorMessages.DAuthUnknownError), None) } } + } // SIWE (Sign-In With Ethereum). Token minted at POST /my/logins/siwe; carried as `SIWE: token=...`. + else if (getPropsAsBoolValue("allow_siwe", false) && code.api.SIWE.hasSiweHeader(cc.requestHeaders) && !url.contains("/my/logins/siwe")) { + code.api.SIWE.getUserFromSiweHeaderFuture(cc) } else if(Option(cc).flatMap(_.user).isDefined) { Future{(cc.user, Some(cc))} diff --git a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala index 7fcf4e3d3b..9ca3ec4dcc 100644 --- a/obp-api/src/main/scala/code/api/util/ErrorMessages.scala +++ b/obp-api/src/main/scala/code/api/util/ErrorMessages.scala @@ -272,6 +272,17 @@ object ErrorMessages { val BerlinGroupConsentAccessFrequencyPerDay = s"OBP-20090: Frequency per day must be 1 when availableAccounts is used." val BerlinGroupConsentAccessAvailableAccounts = s"OBP-20091: availableAccounts must be exactly 'allAccounts'." + // SIWE (Sign-In With Ethereum, EIP-4361). Feature is off by default (allow_siwe=false). + val SiweNotEnabled = "OBP-20092: SIWE authentication is not enabled on this instance." + val SiweConfigMissing = "OBP-20093: SIWE is enabled but not fully configured. Missing required prop: " + val SiweMessageMalformed = "OBP-20094: The SIWE message could not be parsed as an EIP-4361 message." + val SiweInvalidAddress = "OBP-20095: The address in the SIWE message is not a valid Ethereum address." + val SiweDomainMismatch = "OBP-20096: The domain in the SIWE message does not match the configured siwe.domain." + val SiweChainIdNotAllowed = "OBP-20097: The Chain ID in the SIWE message is not in siwe.allowed_chain_ids." + val SiweNonceInvalid = "OBP-20098: The nonce is unknown, already used, or expired. Request a new challenge." + val SiweMessageExpired = "OBP-20099: The SIWE message has expired (Expiration Time is in the past)." + val SiweSignatureInvalid = "OBP-20100: The signature does not match the address claimed in the SIWE message." + val UserNotSuperAdminOrMissRole = "OBP-20101: Current User is not super admin or is missing entitlements:" val CannotGetOrCreateUser = "OBP-20102: Cannot get or create user." val InvalidUserProvider = "OBP-20103: Invalid DAuth User Provider." 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 f678a66bf1..ed7f5c6f78 100644 --- a/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala +++ b/obp-api/src/main/scala/code/api/util/http4s/Http4sApp.scala @@ -144,6 +144,7 @@ object Http4sApp extends MdcLoggable { .orElse(dynamicEntityRoutes.run(req)) .orElse(dynamicEndpointRoutes.run(req)) .orElse(code.api.DirectLoginRoutes.routes.run(req)) + .orElse(code.api.SIWERoutes.routes.run(req)) .orElse(code.api.AliveCheckRoutes.routes.run(req)) .orElse(notFoundCatchAll.run(req)) } diff --git a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala index 2240f9bc1f..9dab4dfe61 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala @@ -4376,7 +4376,7 @@ trait APIMethods600 // nameOf(createUser), // "POST", // "/users", -// "Create User (v6.0.0)", +// "Create User (self-registration)", // s"""Creates OBP user. // | No authorisation required. // | @@ -4392,7 +4392,7 @@ trait APIMethods600 // | Email validation behavior: // | - Controlled by property 'authUser.skipEmailValidation' (default: false) // | - When false: User is created with validated=false and a validation email is sent to the user's email address -// | - The validation link is constructed using the `portal_external_url` property which must be set +// | - The validation link is constructed using the `portal_external_url` property which must be set (currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}`). // | - When true: User is created with validated=true and no validation email is sent // | - Default entitlements are granted immediately regardless of validation status // | diff --git a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala index 512ed6a8b9..92bcc12ec6 100644 --- a/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala +++ b/obp-api/src/main/scala/code/api/v6_0_0/Http4s600.scala @@ -6796,7 +6796,7 @@ object Http4s600 { | "description": "User preferences", | "required": ["theme"], | "properties": { - | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, + | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (auto-generated per-field role)", "write_role_required": true}, | "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role", "write_role": "CanWriteCustomerPreferencesAudit"}, @@ -6811,6 +6811,7 @@ object Http4s600 { |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property MUST include an `example` field with a valid example value. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. + |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). @@ -6823,7 +6824,7 @@ object Http4s600 { has_public_access = Some(false), has_community_access = Some(false), personal_requires_role = Some(false), - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -6834,7 +6835,7 @@ object Http4s600 { has_public_access = false, has_community_access = false, personal_requires_role = false, - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, @@ -6864,7 +6865,7 @@ object Http4s600 { | "description": "User preferences", | "required": ["theme"], | "properties": { - | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, + | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, | "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (auto-generated per-field role)", "write_role_required": true}, | "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role", "write_role": "CanWriteCustomerPreferencesAudit"}, @@ -6879,6 +6880,7 @@ object Http4s600 { |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property MUST include an `example` field with a valid example value. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. + |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). @@ -6891,7 +6893,7 @@ object Http4s600 { has_public_access = Some(false), has_community_access = Some(false), personal_requires_role = Some(false), - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -6902,7 +6904,7 @@ object Http4s600 { has_public_access = false, has_community_access = false, personal_requires_role = false, - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "internal_note": {"type": "string", "example": "set by a privileged service", "description": "Field-level write-restricted (writeRoleRequired)", "write_role_required": true}, "audit_ref": {"type": "string", "example": "AUD-0001", "description": "Field-level write-restricted via an explicit, shareable role (writeRole)", "write_role": "CanWriteCustomerPreferencesAudit"}, "ssn": {"type": "string", "example": "123-45-6789", "description": "Field-level read-restricted (readRoleRequired)", "read_role_required": true}, "risk_score": {"type": "string", "example": "low", "description": "Field-level read-restricted via an explicit, shareable role (readRole)", "read_role": "CanReadCustomerPreferencesRisk"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), List( $BankNotFound, @@ -6938,7 +6940,7 @@ object Http4s600 { | "description": "User preferences updated", | "required": ["theme"], | "properties": { - | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, + | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, | "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"} | } @@ -6949,6 +6951,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. + |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). @@ -6959,7 +6962,7 @@ object Http4s600 { entity_name = "customer_preferences", has_personal_entity = Some(true), has_public_access = Some(false), - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -6968,7 +6971,7 @@ object Http4s600 { bank_id = None, has_personal_entity = true, has_public_access = false, - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), List($AuthenticatedUserIsRequired, UserHasMissingRoles, InvalidJsonFormat, UnknownError), apiTagManageDynamicEntity :: apiTagApi :: Nil, @@ -6997,7 +7000,7 @@ object Http4s600 { | "description": "User preferences updated", | "required": ["theme"], | "properties": { - | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, + | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, | "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"} | } @@ -7008,6 +7011,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. + |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). @@ -7018,7 +7022,7 @@ object Http4s600 { entity_name = "customer_preferences", has_personal_entity = Some(true), has_public_access = Some(false), - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -7027,7 +7031,7 @@ object Http4s600 { bank_id = Some("gh.29.uk"), has_personal_entity = true, has_public_access = false, - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), List( $BankNotFound, @@ -7062,7 +7066,7 @@ object Http4s600 { | "description": "User preferences updated", | "required": ["theme"], | "properties": { - | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, + | "theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, | "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, | "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"} | } @@ -7073,6 +7077,7 @@ object Http4s600 { |**Note:** |* The `entity_name` must be lowercase with underscores (snake_case), e.g. `customer_preferences`. No uppercase letters or spaces allowed. |* Each property can optionally include `description` (markdown text), and for string types: `minLength` and `maxLength`. + |* Each property can optionally be marked queryable with `"indexed": true` — only indexed fields may be used in the list endpoint's filter/sort query parameters (and a `reference:` field must be indexed to form a join edge). Add `"index": "spatial"` for a GeoJSON geometry index (only valid on a `json` field); the default when omitted is `"index": "scalar"` (B-tree). |* Each property can optionally declare **field-level access control**: `write_role_required`/`read_role_required` (booleans — auto-generate a per-field role) or `write_role`/`read_role` (name an explicit, shareable role). Write-restricted fields are not set via POST/PUT (their existing value is preserved) and are written only via the role-gated PATCH path; read-restricted fields are omitted from GET for callers lacking the read role. |* Set `has_public_access` to `true` to generate read-only public endpoints (GET only, no authentication required) under `/public/`. |* Set `has_community_access` to `true` to generate read-only community endpoints (GET only, authentication required + CanGet role) under `/community/`. Community endpoints return ALL records (personal + non-personal from all users). @@ -7083,7 +7088,7 @@ object Http4s600 { entity_name = "customer_preferences", has_personal_entity = Some(true), has_public_access = Some(false), - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), DynamicEntityDefinitionJsonV600( dynamic_entity_id = "abc-123-def", @@ -7092,7 +7097,7 @@ object Http4s600 { bank_id = None, has_personal_entity = true, has_public_access = false, - schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference"}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] + schema = com.openbankproject.commons.util.JsonAliases.parse("""{"description": "User preferences updated", "required": ["theme"], "properties": {"theme": {"type": "string", "minLength": 1, "maxLength": 20, "example": "dark", "description": "The UI theme preference", "indexed": true}, "language": {"type": "string", "minLength": 2, "maxLength": 5, "example": "en", "description": "ISO language code"}, "notifications_enabled": {"type": "boolean", "example": "true", "description": "Whether to send notifications"}}}""").asInstanceOf[org.json4s.JsonAST.JObject] ), List( $AuthenticatedUserIsRequired, @@ -7777,7 +7782,7 @@ object Http4s600 { nameOf(createUser), "POST", "/users", - "Create User (v6.0.0)", + "Create User (self-registration)", s"""Creates OBP user. | No authorisation required. | @@ -7793,7 +7798,7 @@ object Http4s600 { | Email validation behavior: | - Controlled by property 'authUser.skipEmailValidation' (default: false) | - When false: User is created with validated=false and a validation email is sent to the user's email address - | - The validation link is constructed using the `portal_external_url` property which must be set + | - The validation link is constructed using the `portal_external_url` property which must be set (currently: `${APIUtil.getPropsValue("portal_external_url", "not set")}`). | - When true: User is created with validated=true and no validation email is sent | - Default entitlements are granted immediately regardless of validation status | diff --git a/obp-api/src/test/scala/code/api/SIWETest.scala b/obp-api/src/test/scala/code/api/SIWETest.scala new file mode 100644 index 0000000000..2e5722b6d3 --- /dev/null +++ b/obp-api/src/test/scala/code/api/SIWETest.scala @@ -0,0 +1,153 @@ +package code.api + +import java.nio.charset.StandardCharsets +import java.time.Instant + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import code.api.util.APIUtil.HTTPParam +import org.http4s.{Method, Request, Uri} +import org.scalatest.{FeatureSpec, GivenWhenThen, Matchers} +import org.web3j.crypto.{Keys, Sign} +import org.web3j.utils.Numeric + +/** + * Pure unit tests for the SIWE (Sign-In With Ethereum, EIP-4361) auth method. + * + * These exercise the genuinely-new logic — message build/parse, EOA `ecrecover`, + * and header parsing — with NO running server, DB, or Redis. The signature is + * produced in-test with a freshly generated web3j keypair, then recovered, so the + * crypto path is verified end-to-end without any external dependency (Phase 1: EOA only). + */ +class SIWETest extends FeatureSpec with Matchers with GivenWhenThen { + + // Sign an EIP-4361 message exactly the way a wallet would (EIP-191 personal_sign), + // returning the 0x-prefixed 65-byte signature hex. + private def signMessage(message: String, keyPair: org.web3j.crypto.ECKeyPair): String = { + val sig = Sign.signPrefixedMessage(message.getBytes(StandardCharsets.UTF_8), keyPair) + Numeric.toHexString(sig.getR ++ sig.getS ++ sig.getV) + } + + feature("EIP-4361 message build + parse") { + + scenario("a built message round-trips through parseMessage") { + Given("a message built by SIWE.buildMessage") + val address = Keys.toChecksumAddress("0x" + "a" * 40) + val issuedAt = Instant.parse("2026-06-24T10:00:00Z") + val expiresAt = issuedAt.plusSeconds(300) + val message = SIWE.buildMessage( + domain = "example.com", + checksumAddress = address, + chainId = 11155111L, + nonce = "abc123XYZ", + uri = "https://example.com", + statement = "Sign in to OBP.", + issuedAt = issuedAt.toString, + expirationTime = expiresAt.toString) + + When("it is parsed") + val parsed = SIWE.parseMessage(message).openOrThrowException("should parse") + + Then("every field is recovered") + parsed.domain should equal("example.com") + parsed.address should equal(address) + parsed.chainId should equal(11155111L) + parsed.nonce should equal("abc123XYZ") + parsed.uri should equal(Some("https://example.com")) + parsed.issuedAt should equal(Some(issuedAt.toString)) + parsed.expirationTime should equal(Some(expiresAt.toString)) + } + + scenario("a message with no address line fails to parse") { + val garbage = "this is not a SIWE message" + SIWE.parseMessage(garbage).isDefined should equal(false) + } + } + + feature("EOA signature recovery (ecrecover)") { + + scenario("recovers the exact signer of a built message") { + Given("a fresh keypair and a message signed by it") + val keyPair = Keys.createEcKeyPair() + val address = Keys.toChecksumAddress(Keys.getAddress(keyPair)) + val message = SIWE.buildMessage( + "example.com", address, 1L, "nonce0001", + "https://example.com", "Sign in.", + Instant.parse("2026-06-24T10:00:00Z").toString, + Instant.parse("2026-06-24T10:05:00Z").toString) + val signature = signMessage(message, keyPair) + + When("the signature is recovered") + val recovered = SIWE.recoverEoaAddress(message, signature).openOrThrowException("should recover") + + Then("it equals the signer's checksummed address") + recovered.equalsIgnoreCase(address) should equal(true) + } + + scenario("a signature over a different message does not recover the claimed address") { + val keyPair = Keys.createEcKeyPair() + val address = Keys.toChecksumAddress(Keys.getAddress(keyPair)) + val signed = SIWE.buildMessage("example.com", address, 1L, "n1", "https://example.com", "a", + "2026-06-24T10:00:00Z", "2026-06-24T10:05:00Z") + val tampered = signed.replace("n1", "n2") // change the nonce after signing + val signature = signMessage(signed, keyPair) + + val recovered = SIWE.recoverEoaAddress(tampered, signature).openOrThrowException("recovers some addr") + recovered.equalsIgnoreCase(address) should equal(false) + } + + scenario("a malformed signature yields a Failure, not an exception") { + SIWE.recoverEoaAddress("any message", "0xdeadbeef").isDefined should equal(false) + } + } + + feature("address + expiry helpers") { + + scenario("isValidEthAddress") { + SIWE.isValidEthAddress("0x" + "A" * 40) should equal(true) + SIWE.isValidEthAddress("0x" + "A" * 39) should equal(false) + SIWE.isValidEthAddress("nope") should equal(false) + } + + scenario("isExpired is false for None and future, true for past") { + SIWE.isExpired(None) should equal(false) + SIWE.isExpired(Some(Instant.now().plusSeconds(60).toString)) should equal(false) + SIWE.isExpired(Some(Instant.now().minusSeconds(60).toString)) should equal(true) + } + } + + feature("SIWE header parsing (subsequent requests)") { + + scenario("hasSiweHeader + getSiweToken extract token=...") { + val headers = List(HTTPParam("SIWE", List("token=abc.def.ghi"))) + SIWE.hasSiweHeader(headers) should equal(true) + SIWE.getSiweToken(headers) should equal(Some("abc.def.ghi")) + } + + scenario("quoted token value is unquoted") { + val headers = List(HTTPParam("SIWE", List("""token="abc.def.ghi""""))) + SIWE.getSiweToken(headers) should equal(Some("abc.def.ghi")) + } + + scenario("no SIWE header → None") { + val headers = List(HTTPParam("DirectLogin", List("token=xyz"))) + SIWE.hasSiweHeader(headers) should equal(false) + SIWE.getSiweToken(headers) should equal(None) + } + } + + feature("feature is OFF by default") { + + scenario("with allow_siwe unset, the routes do not match (HttpRoutes.empty)") { + Given("the default test environment has no allow_siwe prop") + SIWE.isEnabled should equal(false) + + When("a POST /my/logins/siwe/challenge is dispatched to the route") + val req = Request[IO](method = Method.POST, uri = Uri.unsafeFromString("/my/logins/siwe/challenge")) + val matched = SIWERoutes.routes.run(req).value.unsafeRunSync() + + Then("nothing matches — the feature is inert until allow_siwe=true") + matched should equal(None) + } + } +}