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)
+ }
+ }
+}