commit 3a107bb8b15e04c50f9b5a9f41ab64ec6dc24d48
parent c98095703f95682e87ffe8a57a5434e1e97c4b40
Author: Florian Dold <dold@taler.net>
Date: Mon, 24 Aug 2026 01:19:40 +0200
libeufin-bank: various fixes and tests for token, history, and transfer APIs
Diffstat:
9 files changed, 288 insertions(+), 42 deletions(-)
diff --git a/database-versioning/libeufin-bank-procedures.sql b/database-versioning/libeufin-bank-procedures.sql
@@ -1507,8 +1507,12 @@ SELECT
amount_local.val, amount_local.frac,
out_missing_amount,
out_amount_differs
- FROM taler_withdrawal_operations
- WHERE withdrawal_uuid=in_withdrawal_uuid;
+ FROM taler_withdrawal_operations AS op
+ WHERE op.withdrawal_uuid=in_withdrawal_uuid
+ -- Prepared-transfer withdrawals are intentionally unbound until the
+ -- first confirmation; ordinary withdrawals are bound at creation.
+ AND (op.wallet_bank_account IS NULL
+ OR op.wallet_bank_account=wallet_bank_account_local);
out_no_op=NOT FOUND;
IF out_no_op OR already_confirmed OR out_aborted OR out_not_selected OR out_missing_amount OR out_amount_differs OR out_tan_required THEN
RETURN;
@@ -1539,7 +1543,9 @@ END IF;
-- Confirm operation and update amount
UPDATE taler_withdrawal_operations
- SET amount=amount_local, confirmation_done=true
+ SET amount=amount_local,
+ wallet_bank_account=COALESCE(wallet_bank_account, wallet_bank_account_local),
+ confirmation_done=true
WHERE withdrawal_uuid=in_withdrawal_uuid;
-- Notify status change
@@ -1695,6 +1701,7 @@ SELECT bank_account_id
-- Check for idempotence and conflict
SELECT (amount_debit != in_amount_debit
+ OR amount_credit != in_amount_credit
OR subject != in_subject
OR bank_account != account_id)
, cashout_id
@@ -2452,4 +2459,4 @@ out_found = FOUND;
-- TODO abort withdrawal
END $$;
-COMMIT;
-\ No newline at end of file
+COMMIT;
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/TalerMessage.kt
@@ -470,7 +470,7 @@ data class TokenInfo(
@Description("Permission scope of the token")
val scope: TokenScope,
@Description("Whether the token can be refreshed")
- val isRefreshable: Boolean,
+ val refreshable: Boolean,
@Description("Human-readable token description")
val description: String? = null,
@Description("Timestamp of last token usage")
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/api/CoreBankApi.kt
@@ -118,6 +118,7 @@ fun Routing.coreBankApi(db: Database, cfg: BankConfig) {
private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
val TOKEN_DEFAULT_DURATION: Duration = Duration.ofDays(1L)
+ val TOKEN_REFRESH_OVERLAP: Duration = Duration.ofMinutes(5L)
auth(db, cfg.pwCrypto, TokenLogicalScope.refreshable, cfg.basicAuthCompat, allowPw = true) {
post("/accounts/{USERNAME}/token", {
operationId = "createToken"
@@ -161,16 +162,35 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
throw badRequest("Bad token duration: ${e.message}")
}
}
- when (db.token.create(
- username = call.pathUsername,
- content = token.raw,
- creationTime = creationTime,
- expirationTime = expirationTimestamp,
- scope = req.scope,
- isRefreshable = req.refreshable,
- description = req.description,
- is2fa = existingToken != null || challenge != null
- )) {
+ val creationResult = if (existingToken == null) {
+ db.token.create(
+ username = call.pathUsername,
+ content = token.raw,
+ creationTime = creationTime,
+ expirationTime = expirationTimestamp,
+ scope = req.scope,
+ isRefreshable = req.refreshable,
+ description = req.description,
+ is2fa = challenge != null
+ )
+ } else {
+ val refreshed = db.token.refresh(
+ username = call.pathUsername,
+ source = existingToken,
+ content = token.raw,
+ creationTime = creationTime,
+ expirationTime = expirationTimestamp,
+ sourceExpirationCap = creationTime.plus(TOKEN_REFRESH_OVERLAP),
+ scope = req.scope,
+ isRefreshable = req.refreshable,
+ description = req.description
+ )
+ if (!refreshed) throw internalServerError(
+ "Token used to authenticate refresh disappeared"
+ )
+ TokenCreationResult.Success
+ }
+ when (creationResult) {
TokenCreationResult.TanRequired -> call.respondMfa(db, Operation.create_token)
TokenCreationResult.Success -> call.respond(
TokenSuccessResponse(
@@ -198,7 +218,7 @@ private fun Routing.coreBankTokenApi(db: Database, cfg: BankConfig) {
}
}) {
val id = call.longPath("TOKEN_ID")
- if (db.token.deleteById(id)) {
+ if (db.token.deleteById(id, call.pathUsername)) {
call.respond(HttpStatusCode.NoContent)
} else {
throw notFound(
@@ -686,12 +706,12 @@ private fun Routing.coreBankAccountsApi(db: Database, cfg: BankConfig) {
}
private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
- auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
+ optAuth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
get("/accounts/{USERNAME}/transactions", {
operationId = "getTransactions"
description = "Get transaction history for an account"
tags = listOf("Core Bank - Transactions")
- protected = true
+ protected = false
securitySchemeNames("bearerAuth", "basicAuth")
request {
pathParameter<String>("USERNAME") { description = "Account username" }
@@ -714,7 +734,13 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
}
}) {
val params = HistoryParams.extract(call.request.queryParameters)
- val bankAccount = call.bankInfo(db)
+ call.response.header(HttpHeaders.CacheControl, "no-store")
+ val bankAccount = if (call.isAuthenticated) {
+ call.bankInfo(db)
+ } else {
+ db.account.publicBankInfo(call.pathUsername)
+ ?: throw unknownAccount(call.pathUsername)
+ }
val history: List<BankAccountTransactionInfo> =
db.transaction.pollHistory(params, bankAccount.bankAccountId)
@@ -724,6 +750,8 @@ private fun Routing.coreBankTransactionsApi(db: Database, cfg: BankConfig) {
call.respond(BankAccountTransactionsResponse(history))
}
}
+ }
+ auth(db, cfg.pwCrypto, TokenLogicalScope.readonly, cfg.basicAuthCompat, allowAdmin = true) {
get("/accounts/{USERNAME}/transactions/{T_ID}", {
operationId = "getTransaction"
description = "Get a specific transaction by ID"
@@ -1420,4 +1448,4 @@ private fun Routing.coreBankConversionApi(db: Database, cfg: BankConfig) = condi
}
}
}
-}
-\ No newline at end of file
+}
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/AccountDAO.kt
@@ -538,6 +538,36 @@ class AccountDAO(private val db: Database) {
}
}
+ /** Get bank info only when [username] identifies a public account. */
+ suspend fun publicBankInfo(username: String): BankInfo? = db.serializable(
+ """
+ SELECT
+ bank_account_id,
+ internal_payto,
+ name,
+ is_taler_exchange,
+ tan_channels,
+ email,
+ phone
+ FROM bank_accounts
+ JOIN customers ON customer_id=owning_customer_id
+ WHERE username=? AND deleted_at IS NULL AND is_public=true
+ """
+ ) {
+ bind(username)
+ oneOrNull {
+ BankInfo(
+ username = username,
+ payto = it.getBankPayto("internal_payto", "name", db.ctx),
+ bankAccountId = it.getLong("bank_account_id"),
+ isTalerExchange = it.getBoolean("is_taler_exchange"),
+ channels = it.getEnumSet<TanChannel>("tan_channels"),
+ phone = it.getString("phone"),
+ email = it.getString("email")
+ )
+ }
+ }
+
/** Check bank info of account [payto] */
suspend fun checkInfo(payto: IbanPayto): AccountInfo? = db.serializable(
"""
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TanDAO.kt
@@ -120,6 +120,7 @@ class TanDAO(private val db: Database) {
,code
,tan_channel
,tan_info
+ ,retry_counter
-- If this is the first time we submit this challenge check there is not too many active challenges
,(retransmission_date = 0 AND (
SELECT count(*) >= ?
@@ -139,6 +140,7 @@ class TanDAO(private val db: Database) {
oneOrNull {
when {
it.getBoolean("solved") -> TanSendResult.Solved
+ it.getInt("retry_counter") <= 0 -> TanSendResult.Expired
it.getBoolean("too_many") -> TanSendResult.TooMany
else -> {
val retransmission = it.getTalerTimestamp("retransmission_date")
@@ -244,4 +246,4 @@ class TanDAO(private val db: Database) {
)
}
}
-}
-\ No newline at end of file
+}
diff --git a/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt b/libeufin-bank/src/main/kotlin/tech/libeufin/bank/db/TokenDAO.kt
@@ -66,6 +66,57 @@ class TokenDAO(private val db: Database) {
}
}
}
+
+ /**
+ * Atomically create a replacement for [source] and shorten the source
+ * token's lifetime to [sourceExpirationCap]. LEAST makes the overlap
+ * deadline non-sliding when the same source is refreshed more than once.
+ */
+ suspend fun refresh(
+ username: String,
+ source: ByteArray,
+ content: ByteArray,
+ creationTime: Instant,
+ expirationTime: Instant,
+ sourceExpirationCap: Instant,
+ scope: TokenScope,
+ isRefreshable: Boolean,
+ description: String?
+ ): Boolean = db.serializable(
+ """
+ WITH source_token AS (
+ UPDATE bearer_tokens
+ SET expiration_time=LEAST(expiration_time, ?)
+ WHERE content=?
+ AND bank_customer=(
+ SELECT customer_id FROM customers
+ WHERE username=? AND deleted_at IS NULL
+ )
+ RETURNING bank_customer
+ ), replacement AS (
+ INSERT INTO bearer_tokens (
+ content, creation_time, expiration_time, scope, bank_customer,
+ is_refreshable, description, last_access
+ )
+ SELECT ?, ?, ?, ?::token_scope_enum, bank_customer, ?, ?, ?
+ FROM source_token
+ RETURNING bearer_token_id
+ )
+ SELECT EXISTS(SELECT FROM replacement)
+ """
+ ) {
+ bind(sourceExpirationCap)
+ bind(source)
+ bind(username)
+ bind(content)
+ bind(creationTime)
+ bind(expirationTime)
+ bind(scope)
+ bind(isRefreshable)
+ bind(description)
+ bind(creationTime)
+ one { it.getBoolean(1) }
+ }
/** Get info for [token] */
suspend fun access(token: ByteArray, accessTime: Instant): BearerToken? = db.serializable(
@@ -147,11 +198,19 @@ class TokenDAO(private val db: Database) {
executeUpdate()
}
- /** Delete token [id] */
- suspend fun deleteById(id: Long) = db.serializable(
- "DELETE FROM bearer_tokens WHERE bearer_token_id = ?"
+ /** Delete token [id] owned by the account identified by [username]. */
+ suspend fun deleteById(id: Long, username: String) = db.serializable(
+ """
+ DELETE FROM bearer_tokens
+ WHERE bearer_token_id = ?
+ AND bank_customer=(
+ SELECT customer_id FROM customers
+ WHERE username=? AND deleted_at IS NULL
+ )
+ """
) {
bind(id)
+ bind(username)
executeUpdateCheck()
}
@@ -184,11 +243,11 @@ class TokenDAO(private val db: Database) {
creation_time = it.getTalerTimestamp("creation_time"),
expiration = it.getTalerTimestamp("expiration_time"),
scope = it.getEnum("scope"),
- isRefreshable = it.getBoolean("is_refreshable"),
+ refreshable = it.getBoolean("is_refreshable"),
description = it.getString("description"),
last_access = it.getTalerTimestamp("last_access"),
row_id = it.getLong("bearer_token_id"),
token_id = it.getLong("bearer_token_id")
)
}
-}
-\ No newline at end of file
+}
diff --git a/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt b/libeufin-bank/src/test/kotlin/CoreBankApiTest.kt
@@ -350,6 +350,74 @@ class CoreBankTokenApiTest {
client.delete("/accounts/merchant/token") {
headers[HttpHeaders.Authorization] = "Bearer $token"
}.assertUnauthorized(TalerErrorCode.GENERIC_TOKEN_UNKNOWN)
+
+ // A user cannot delete another account's token by putting its ID
+ // below a path the user does own.
+ val customerToken = client.postPw("/accounts/customer/token") {
+ json { "scope" to "readonly" }
+ }.assertOkJson<TokenSuccessResponse>().access_token
+ val customerTokenId = client.getA("/accounts/customer/tokens")
+ .assertOkJson<TokenInfos>().tokens.first().token_id
+ client.deleteA("/accounts/merchant/tokens/$customerTokenId")
+ .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+ client.get("/accounts/customer") {
+ headers[HttpHeaders.Authorization] = "Bearer $customerToken"
+ }.assertOk()
+
+ // Administrators can delete it when the owning account is explicit.
+ client.deleteAdmin("/accounts/customer/tokens/$customerTokenId")
+ .assertNoContent()
+ }
+
+ @Test
+ fun refreshOverlapIsFixedAndNonSliding() = bankSetup { db ->
+ val source = client.postPw("/accounts/merchant/token") {
+ json {
+ "scope" to "readonly"
+ "refreshable" to true
+ "duration" to obj { "d_us" to 86_400_000_000L }
+ "description" to "refresh source"
+ }
+ }.assertOkJson<TokenSuccessResponse>().access_token
+ val sourceId = client.getA("/accounts/merchant/tokens")
+ .assertOkJson<TokenInfos>().tokens
+ .single { it.description == "refresh source" }.token_id
+
+ suspend fun sourceExpiration(): Instant = db.serializable(
+ "SELECT expiration_time FROM bearer_tokens WHERE bearer_token_id=?"
+ ) {
+ bind(sourceId)
+ one { it.getLong(1).asInstant() }
+ }
+
+ val beforeRefresh = Instant.now()
+ repeat(2) {
+ client.post("/accounts/merchant/token") {
+ headers[HttpHeaders.Authorization] = "Bearer $source"
+ json {
+ "scope" to "readonly"
+ "description" to "replacement-$it"
+ }
+ }.assertOk()
+ }
+ val firstDeadline = sourceExpiration()
+ assertFalse(firstDeadline.isBefore(beforeRefresh + Duration.ofMinutes(5)))
+ assertFalse(firstDeadline.isAfter(Instant.now() + Duration.ofMinutes(5)))
+
+ // A retry after a lost response may create another replacement, but
+ // must not extend the original token's overlap deadline.
+ client.post("/accounts/merchant/token") {
+ headers[HttpHeaders.Authorization] = "Bearer $source"
+ json {
+ "scope" to "readonly"
+ "description" to "replacement-lost-response-retry"
+ }
+ }.assertOk()
+ assertEquals(firstDeadline, sourceExpiration())
+ val replacements = client.getA("/accounts/merchant/tokens")
+ .assertOkJson<TokenInfos>().tokens
+ .count { it.description?.startsWith("replacement-") == true }
+ assertEquals(3, replacements)
}
// GET /accounts/USERNAME/tokens
@@ -383,6 +451,10 @@ class CoreBankTokenApiTest {
assertEquals(2, it.tokens.size)
assertEquals("description", it.tokens[0].description)
}
+ val serialized = client.getA("/accounts/customer/tokens")
+ .assertOk().bodyAsText()
+ assertContains(serialized, "\"refreshable\"")
+ assertFalse(serialized.contains("\"isRefreshable\""))
}
}
@@ -1259,7 +1331,6 @@ class CoreBankTransactionsApiTest {
// GET /transactions
@Test
fun history() = bankSetup {
- authRoutine(HttpMethod.Get, "/accounts/merchant/transactions", allowAdmin = true)
historyRoutine<BankAccountTransactionsResponse>(
url = "/accounts/customer/transactions",
ids = { it.transactions.map { it.row_id } },
@@ -1294,6 +1365,35 @@ class CoreBankTransactionsApiTest {
)
}
+ @Test
+ fun publicHistoryIsAnonymousAndPrivateHistoryIsHidden() = bankSetup {
+ tx("merchant", "KUDOS:0.1", "customer", "public subject")
+ client.patchAdmin("/accounts/merchant") {
+ json { "is_public" to true }
+ }.assertNoContent()
+
+ val authenticated = client.getA("/accounts/merchant/transactions")
+ .assertOkJson<BankAccountTransactionsResponse>()
+ val anonymousResponse = client.get("/accounts/merchant/transactions")
+ .assertOk()
+ assertEquals("no-store", anonymousResponse.headers[HttpHeaders.CacheControl])
+ val anonymous = anonymousResponse.json<BankAccountTransactionsResponse>()
+ assertEquals(authenticated, anonymous)
+ assertEquals("public subject", anonymous.transactions.single().subject)
+
+ client.get("/accounts/customer/transactions")
+ .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+ client.get("/accounts/missing/transactions")
+ .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+
+ // Making an account private takes effect immediately.
+ client.patchAdmin("/accounts/merchant") {
+ json { "is_public" to false }
+ }.assertNoContent()
+ client.get("/accounts/merchant/transactions")
+ .assertNotFound(TalerErrorCode.BANK_UNKNOWN_ACCOUNT)
+ }
+
// GET /transactions/T_ID
@Test
fun testById() = bankSetup {
@@ -1743,6 +1843,18 @@ class CoreBankWithdrawalApiTest {
}.assertConflict(TalerErrorCode.BANK_AMOUNT_DIFFERS)
}
+ // A selected operation is bound to the account that created it.
+ client.postA("/accounts/merchant/withdrawals") {
+ json { "amount" to "KUDOS:1" }
+ }.assertOkJson<BankAccountCreateWithdrawalResponse> {
+ val uuid = it.withdrawal_id
+ withdrawalSelect(uuid)
+ client.postA("/accounts/customer/withdrawals/$uuid/confirm")
+ .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
+ client.postA("/accounts/merchant/withdrawals/$uuid/abort")
+ .assertNoContent()
+ }
+
// Check confirm with amount
client.postA("/accounts/merchant/withdrawals") {
json {}
@@ -1882,7 +1994,7 @@ class CoreBankWithdrawalApiTest {
class CoreBankCashoutApiTest {
// POST /accounts/{USERNAME}/cashouts
@Test
- fun create() = bankSetup {
+ fun create() = bankSetup { db ->
authRoutine(HttpMethod.Post, "/accounts/merchant/cashouts")
val req = obj {
@@ -1918,6 +2030,19 @@ class CoreBankCashoutApiTest {
}
}.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
+ // Every monetary field belongs to the idempotency identity. Simulate
+ // an inconsistent stored credit amount to isolate this comparison
+ // from conversion validation.
+ db.serializable(
+ "UPDATE cashout_operations SET amount_credit=(999,0)::taler_amount WHERE cashout_id=?"
+ ) {
+ bind(id)
+ executeUpdate()
+ }
+ client.postA("/accounts/customer/cashouts") {
+ json(req)
+ }.assertConflict(TalerErrorCode.BANK_TRANSFER_REQUEST_UID_REUSED)
+
// Check exchange account
client.postA("/accounts/exchange/cashouts") {
json(req)
@@ -2456,4 +2581,4 @@ class CoreBankConversionApiTest {
client.getAdmin("conversion-rate-classes/1").assertNotImplemented()
client.getAdmin("conversion-rate-classes").assertNotImplemented()
}
-}
-\ No newline at end of file
+}
diff --git a/libeufin-bank/src/test/kotlin/DatabaseTest.kt b/libeufin-bank/src/test/kotlin/DatabaseTest.kt
@@ -107,8 +107,8 @@ class DatabaseTest {
assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "bad-code", now))
// Good code fail
assertEquals(TanSolveResult.NoRetry, db.tan.solve(this, "good-code", now))
- // New code
- assertIs<TanSendResult.Success>(db.tan.send(this, now, 10))
+ // Exhausted challenge IDs are terminal and cannot be resent.
+ assertEquals(TanSendResult.Expired, db.tan.send(this, now, 10))
}
// Check retransmission
@@ -122,8 +122,8 @@ class DatabaseTest {
assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10))
// Good code fail because expired
assertEquals(TanSolveResult.Expired, db.tan.solve(this, "good-code", expired))
- // No code because expired
- assertIs<TanSendResult.Send>(db.tan.send(this, retransmit, 10))
+ // Expired challenge IDs are terminal and cannot be resent.
+ assertEquals(TanSendResult.Expired, db.tan.send(this, expired, 10))
}
}}
-}
-\ No newline at end of file
+}
diff --git a/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt b/libeufin-bank/src/test/kotlin/PreparedTransferApiTest.kt
@@ -105,6 +105,8 @@ class PreparedTransferApiTest {
}.assertOkJson<SubjectResult> {
val uuid = (it.subjects[0] as? TransferSubject.Uri)!!.uri.substringAfterLast('/')
client.postA("/accounts/customer/withdrawals/$uuid/confirm").assertNoContent() // reserve
+ client.postA("/accounts/merchant/withdrawals/$uuid/confirm")
+ .assertNotFound(TalerErrorCode.BANK_TRANSACTION_NOT_FOUND)
tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
tx("customer", "KUDOS:1", "exchange", "Taler MAP:$pub") // bounce
assertBalance("customer", "-KUDOS:1")
@@ -283,4 +285,4 @@ class PreparedTransferApiTest {
assertBalance("customer", "-KUDOS:1")
assertBalance("exchange", "+KUDOS:1")
}
-}
-\ No newline at end of file
+}