libeufin

Integration and sandbox testing for FinTech APIs and data formats
Log | Files | Refs | Submodules | README | LICENSE

token.rs (26153B)


      1 /*
      2 * This file is part of LibEuFin.
      3 * Copyright (C) 2026 Taler Systems S.A.
      4 
      5 * LibEuFin is free software; you can redistribute it and/or modify
      6 * it under the terms of the GNU Affero General Public License as
      7 * published by the Free Software Foundation; either version 3, or
      8 * (at your option) any later version.
      9 
     10 * LibEuFin is distributed in the hope that it will be useful, but
     11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
     12 * or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Affero General
     13 * Public License for more details.
     14 
     15 * You should have received a copy of the GNU Affero General Public
     16 * License along with LibEuFin; see the file COPYING.  If not, see
     17 * <http://www.gnu.org/licenses/>
     18 */
     19 
     20 use std::{sync::Arc, time::Duration};
     21 
     22 use axum::{
     23     Json, Router,
     24     extract::State,
     25     response::{IntoResponse, NoContent},
     26     routing::{delete, get, post},
     27 };
     28 use jiff::Timestamp;
     29 use serde::{Deserialize, Serialize};
     30 use taler_api::{
     31     error::{ApiResult, bad_request, failure},
     32     extract::{Path, Query},
     33 };
     34 use taler_common::{
     35     api::params::PageParams,
     36     error_code::ErrorCode::{self},
     37     types::{
     38         base32::Base32,
     39         time::{RelativeTime, TalerTimestamp},
     40     },
     41 };
     42 
     43 use crate::{
     44     api::BankState,
     45     auth::{TOKEN_PREFIX, TokenScope, UserAuth, UserORWAuth, UserRAuth},
     46     db::{
     47         self,
     48         token::{TokenCreationResult, access, create, refresh},
     49     },
     50     mfa::{MfaReq, TokenOp},
     51 };
     52 
     53 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     54 pub struct TokenInfo {
     55     pub creation_time: TalerTimestamp,
     56     pub expiration: TalerTimestamp,
     57     pub scope: TokenScope,
     58     pub refreshable: bool,
     59     pub description: Option<String>,
     60     pub last_access: TalerTimestamp,
     61     pub row_id: u64,
     62     pub token_id: u64,
     63 }
     64 
     65 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     66 pub struct TokenInfos {
     67     pub tokens: Vec<TokenInfo>,
     68 }
     69 
     70 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     71 pub struct TokenRequest {
     72     pub scope: TokenScope,
     73     pub duration: Option<RelativeTime>,
     74     pub description: Option<String>,
     75     #[serde(default)]
     76     pub refreshable: bool,
     77 }
     78 
     79 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
     80 pub struct TokenSuccessResponse {
     81     pub access_token: String,
     82     pub expiration: TalerTimestamp,
     83     pub token_id: u64,
     84 }
     85 
     86 pub const TOKEN_DEFAULT_DURATION: Duration = Duration::from_hours(24);
     87 pub const TOKEN_REFRESH_OVERLAP: Duration = Duration::from_mins(5);
     88 
     89 pub fn token_api() -> Router<Arc<BankState>> {
     90     Router::new()
     91         .route(
     92             "/accounts/{username}/token",
     93             post(
     94                 async |State(state): State<Arc<BankState>>, req: MfaReq<TokenOp>| {
     95                     let (mut auth, req, mfa) = req.solve(&state, &[]).await?;
     96                     if let Some(token) = &auth.token {
     97                         // This block checks permissions ONLY IF the call was authenticated with a token
     98                         let token = access(&state.db, token, &Timestamp::now()).await?;
     99                         let Some(token) = token else {
    100                             return Err(failure(
    101                                 ErrorCode::BANK_UNMANAGED_EXCEPTION,
    102                                 "Token used to auth not found in the database",
    103                             ));
    104                         };
    105                         if !req.scope.logical().is_valid_scope(token.scope, true) {
    106                             return Err(failure(
    107                                 ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT,
    108                                 "Impossible to refresh a token with a larger scope",
    109                             ));
    110                         }
    111                     }
    112 
    113                     let new = Base32::<32>::secure_rand();
    114                     let creation = Timestamp::now();
    115                     let expiration = match req
    116                         .duration
    117                         .unwrap_or(RelativeTime::Duration(TOKEN_DEFAULT_DURATION))
    118                     {
    119                         RelativeTime::Forever => TalerTimestamp::Never,
    120                         RelativeTime::Duration(duration) => {
    121                             TalerTimestamp::Timestamp(creation.checked_add(duration).map_err(
    122                                 |e| bad_request(format_args!("Bad token duration: {}", e)),
    123                             )?)
    124                         }
    125                     };
    126 
    127                     let res = if let Some(existing) = &auth.token {
    128                         refresh(
    129                             &state.db,
    130                             &auth.username,
    131                             existing,
    132                             new.as_ref(),
    133                             &creation,
    134                             &expiration,
    135                             &(creation + TOKEN_REFRESH_OVERLAP),
    136                             &req.scope,
    137                             req.refreshable,
    138                             req.description.as_deref(),
    139                         )
    140                         .await?
    141                     } else {
    142                         create(
    143                             &state.db,
    144                             &auth.username,
    145                             new.as_ref(),
    146                             &creation,
    147                             &expiration,
    148                             &req.scope,
    149                             req.refreshable,
    150                             req.description.as_deref(),
    151                             mfa.is_2fa(),
    152                         )
    153                         .await?
    154                     };
    155                     match res {
    156                         TokenCreationResult::Success(token_id) => Ok(Json(TokenSuccessResponse {
    157                             access_token: format!("{TOKEN_PREFIX}{new}"),
    158                             expiration,
    159                             token_id,
    160                         })
    161                         .into_response()),
    162                         TokenCreationResult::TanRequired => {
    163                             mfa.response_mfa(&mut auth, &state.db, &state.cfg.ctx).await
    164                         }
    165                     }
    166                 },
    167             )
    168             .delete(
    169                 async |UserAuth { token, .. }: UserRAuth, State(state): State<Arc<BankState>>| {
    170                     if let Some(token) = token {
    171                         db::token::delete(&state.db, &token).await?;
    172                         ApiResult::Ok(NoContent)
    173                     } else {
    174                         ApiResult::Err(bad_request("Basic auth not supported here"))
    175                     }
    176                 },
    177             ),
    178         )
    179         .route(
    180             "/accounts/{username}/tokens/{id}",
    181             delete(
    182                 async |UserAuth { username, .. }: UserORWAuth,
    183                        Path((_, id)): Path<((), u64)>,
    184                        State(state): State<Arc<BankState>>| {
    185                     if db::token::delete_by_id(&state.db, &username, id).await? {
    186                         ApiResult::Ok(NoContent)
    187                     } else {
    188                         ApiResult::Err(failure(
    189                             ErrorCode::BANK_TRANSACTION_NOT_FOUND,
    190                             format_args!("Token '{id}' not found"),
    191                         ))
    192                     }
    193                 },
    194             ),
    195         )
    196         .route(
    197             "/accounts/{username}/tokens",
    198             get(
    199                 async |UserAuth { username, .. }: UserRAuth,
    200                        Query(params): Query<PageParams>,
    201                        State(state): State<Arc<BankState>>| {
    202                     let params = params.check()?;
    203                     let tokens =
    204                         db::token::page(&state.db, &params, &username, &Timestamp::now()).await?;
    205                     if tokens.is_empty() {
    206                         ApiResult::Ok(NoContent.into_response())
    207                     } else {
    208                         ApiResult::Ok(Json(TokenInfos { tokens }).into_response())
    209                     }
    210                 },
    211             ),
    212         )
    213 }
    214 
    215 #[cfg(test)]
    216 pub mod test {
    217 
    218     use std::{str::FromStr, time::Duration};
    219 
    220     use axum::http::{Method, header::AUTHORIZATION};
    221     use jiff::{SignedDuration, Timestamp};
    222     use sqlx::postgres::PgConnectOptions;
    223     use taler_api::db::TypeHelper;
    224     use taler_common::{
    225         api::ErrorDetail,
    226         error_code::ErrorCode,
    227         types::{base32::Base32, time::TalerTimestamp},
    228     };
    229     use taler_macros::db_test;
    230     use taler_test_utils::{json, server::TestServer};
    231 
    232     use crate::{
    233         api::{
    234             account::AccountData,
    235             tan::ChallengeResponse,
    236             test::{Auth, MfaRequest, bank_setup, tan_code},
    237             token::{TOKEN_DEFAULT_DURATION, TokenInfos, TokenSuccessResponse},
    238         },
    239         auth::TOKEN_PREFIX,
    240         db::{account::MAX_TOKEN_CREATION_ATTEMPTS, token::access},
    241         mfa::TALER_CHALLENGE_IDS,
    242     };
    243 
    244     #[db_test(raw)]
    245     async fn create_and_delete(db: PgConnectOptions) {
    246         let ctx = bank_setup(db).await;
    247 
    248         ctx.auth_routine(Method::POST, "/accounts/merchant/token", Auth::Token)
    249             .await;
    250         ctx.auth_routine(
    251             Method::DELETE,
    252             "/accounts/merchant/tokens/1",
    253             Auth::UserOnly,
    254         )
    255         .await;
    256 
    257         // Unknown account
    258         ctx.post("/accounts/merchant/token")
    259             .basic_auth("Unknown", "password")
    260             .await
    261             .assert_error(ErrorCode::GENERIC_UNAUTHORIZED);
    262 
    263         // Wrong account
    264         ctx.post("/accounts/merchant/token")
    265             .basic_auth("merchant", "wrong-password")
    266             .await
    267             .assert_error(ErrorCode::GENERIC_UNAUTHORIZED);
    268 
    269         // Wrong account
    270         ctx.post("/accounts/merchant/token")
    271             .basic_auth("exchange", "wrong-password")
    272             .await
    273             .assert_error(ErrorCode::GENERIC_UNAUTHORIZED);
    274 
    275         // Default token duration
    276         let res: TokenSuccessResponse = ctx
    277             .postpw("/accounts/merchant/token")
    278             .json(json!({ "scope": "readonly" }))
    279             .await
    280             .assert_ok_json();
    281         // Checking that the token lifetime defaulted to 24 hours
    282         let token = access(
    283             &ctx.state.db,
    284             Base32::<32>::from_str(res.access_token.strip_prefix(TOKEN_PREFIX).unwrap())
    285                 .unwrap()
    286                 .as_ref(),
    287             &Timestamp::now(),
    288         )
    289         .await
    290         .unwrap()
    291         .unwrap();
    292         match token.expiration {
    293             TalerTimestamp::Never => unreachable!(),
    294             TalerTimestamp::Timestamp(expiration) => {
    295                 let lifetime = token
    296                     .creation
    297                     .duration_until(expiration)
    298                     .max(SignedDuration::ZERO)
    299                     .unsigned_abs();
    300                 assert_eq!(lifetime, TOKEN_DEFAULT_DURATION);
    301             }
    302         }
    303 
    304         // Check valid refresh scope
    305         for (from_scope, to_scope) in [
    306             ("readwrite", "readwrite"),
    307             ("readonly", "readonly"),
    308             ("revenue", "revenue"),
    309             ("readwrite", "readonly"),
    310             ("readwrite", "revenue"),
    311             ("readonly", "revenue"),
    312         ] {
    313             let res: TokenSuccessResponse = ctx
    314                 .postpw("/accounts/merchant/token")
    315                 .json(json!({ "scope": from_scope, "refreshable": true }))
    316                 .await
    317                 .assert_ok_json();
    318             ctx.post("/accounts/merchant/token")
    319                 .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    320                 .json(json!({ "scope": to_scope }))
    321                 .await
    322                 .assert_ok();
    323         }
    324 
    325         // Check invalid refresh scope
    326         for (from_scope, to_scope) in [
    327             ("readonly", "readwrite"),
    328             ("revenue", "readonly"),
    329             ("revenue", "readwrite"),
    330         ] {
    331             let res: TokenSuccessResponse = ctx
    332                 .postpw("/accounts/merchant/token")
    333                 .json(json!({ "scope": from_scope, "refreshable": true }))
    334                 .await
    335                 .assert_ok_json();
    336             ctx.post("/accounts/merchant/token")
    337                 .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    338                 .json(json!({ "scope": to_scope }))
    339                 .await
    340                 .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT);
    341         }
    342 
    343         // Check no refreshable
    344         let res: TokenSuccessResponse = ctx
    345             .postpw("/accounts/merchant/token")
    346             .json(json!({ "scope": "readonly" }))
    347             .await
    348             .assert_ok_json();
    349         ctx.post("/accounts/merchant/token")
    350             .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    351             .json(json!({ "scope": "readonly" }))
    352             .await
    353             .assert_error(ErrorCode::GENERIC_TOKEN_PERMISSION_INSUFFICIENT);
    354 
    355         // Check 'forever' case
    356         let res: TokenSuccessResponse = ctx
    357             .postpw("/accounts/merchant/token")
    358             .json(json!({
    359                 "scope": "readonly",
    360                 "duration": {
    361                     "d_us": "forever"
    362                 }
    363             }))
    364             .await
    365             .assert_ok_json();
    366         assert_eq!(res.expiration, TalerTimestamp::Never);
    367 
    368         // Check too big or invalid durations
    369         ctx.postpw("/accounts/merchant/token")
    370             .json(json!({
    371                 "scope": "readonly",
    372                 "duration": {
    373                     "d_us": "invalid"
    374                 }
    375             }))
    376             .await
    377             .assert_bad_request();
    378 
    379         ctx.postpw("/accounts/merchant/token")
    380             .json(json!({
    381                 "scope": "readonly",
    382                 "duration": {
    383                     "d_us": i64::MAX
    384                 }
    385             }))
    386             .await
    387             .assert_bad_request();
    388         ctx.postpw("/accounts/merchant/token")
    389             .json(json!({
    390                 "scope": "readonly",
    391                 "duration": {
    392                     "d_us": -1
    393                 }
    394             }))
    395             .await
    396             .assert_bad_request();
    397 
    398         // Delete current token
    399         let res: TokenSuccessResponse = ctx
    400             .postpw("/accounts/merchant/token")
    401             .json(json!({ "scope": "readonly" }))
    402             .await
    403             .assert_ok_json();
    404         // Check OK
    405         ctx.delete("/accounts/merchant/token")
    406             .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    407             .json(json!({ "scope": "readonly" }))
    408             .await
    409             .assert_no_content();
    410         // Check token no longer work
    411         ctx.delete("/accounts/merchant/token")
    412             .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    413             .json(json!({ "scope": "readonly" }))
    414             .await
    415             .assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN);
    416 
    417         // Delete by id
    418         let res: TokenSuccessResponse = ctx
    419             .postpw("/accounts/merchant/token")
    420             .json(json!({ "scope": "readonly" }))
    421             .await
    422             .assert_ok_json();
    423         // Wrong account
    424         ctx.deletea(format!("/accounts/customer/tokens/{}", res.token_id))
    425             .await
    426             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    427         // Check OK
    428         ctx.deletea(format!("/accounts/merchant/tokens/{}", res.token_id))
    429             .await
    430             .assert_no_content();
    431         ctx.deletea(format!("/accounts/merchant/tokens/{}", res.token_id))
    432             .await
    433             .assert_error(ErrorCode::BANK_TRANSACTION_NOT_FOUND);
    434         // Check token no longer work
    435         ctx.post("/accounts/merchant/token")
    436             .header(AUTHORIZATION, format!("Bearer {}", res.access_token))
    437             .json(json!({ "scope": "readonly" }))
    438             .await
    439             .assert_error(ErrorCode::GENERIC_TOKEN_UNKNOWN);
    440 
    441         // Refresh overlap is fixed and non sliding
    442         let TokenSuccessResponse {
    443             access_token: source,
    444             expiration: _,
    445             token_id: source_id,
    446         } = ctx
    447             .postpw("/accounts/merchant/token")
    448             .json(json!({
    449                 "scope": "readonly",
    450                 "refreshable" : true,
    451                 "duration" :  { "d_us" : 86_400_000_000u64 },
    452                 "description" : "refresh source"
    453             }))
    454             .await
    455             .assert_ok_json::<TokenSuccessResponse>();
    456         let before_refresh = Timestamp::now();
    457         for i in 0..2 {
    458             ctx.post("/accounts/merchant/token")
    459                 .header(AUTHORIZATION, format!("Bearer {source}"))
    460                 .json(json!({
    461                     "scope": "readonly",
    462                     "description": format!("replacement-{i}")
    463                 }))
    464                 .await
    465                 .assert_ok();
    466         }
    467         let first_deadline: Timestamp =
    468             sqlx::query("SELECT expiration_time FROM bearer_tokens WHERE bearer_token_id=$1")
    469                 .bind(source_id as i64)
    470                 .try_map(|it| it.try_get_timestamp(0))
    471                 .fetch_one(&ctx.state.db)
    472                 .await
    473                 .unwrap();
    474         assert!(first_deadline > before_refresh + Duration::from_mins(5));
    475         assert!(first_deadline < Timestamp::now() + Duration::from_mins(5));
    476         // A retry after a lost response may create another replacement, but
    477         // must not extend the original token's overlap deadline
    478         ctx.post("/accounts/merchant/token")
    479             .header(AUTHORIZATION, format!("Bearer {source}"))
    480             .json(json!({
    481                 "scope": "readonly",
    482                 "description": "replacement-lost-response-retry"
    483             }))
    484             .await
    485             .assert_ok();
    486 
    487         assert!(
    488             sqlx::query_scalar::<_, bool>(
    489                 "SELECT expiration_time=$2 FROM bearer_tokens WHERE bearer_token_id=$1"
    490             )
    491             .bind(source_id as i64)
    492             .bind(first_deadline.as_microsecond())
    493             .fetch_one(&ctx.state.db)
    494             .await
    495             .unwrap()
    496         );
    497 
    498         // Kotlin compatibility
    499         {
    500             let TokenSuccessResponse {
    501                 access_token,
    502                 token_id,
    503                 ..
    504             } = ctx
    505                 .postpw("/accounts/customer/token")
    506                 .json(json!({
    507                     "scope": "readonly",
    508                     "refreshable" : true
    509                 }))
    510                 .await
    511                 .assert_ok_json();
    512             // Patch ID to use old sentinel value
    513             sqlx::query(
    514             "UPDATE bearer_tokens SET expiration_time=9223372036854775807 WHERE bearer_token_id=$1",
    515                 )
    516                 .bind(token_id as i64)
    517                 .execute(&ctx.state.db)
    518                 .await
    519                 .unwrap();
    520             // Check token works
    521             ctx.get("/accounts/customer/tokens")
    522                 .header(AUTHORIZATION, format!("Bearer {access_token}"))
    523                 .await
    524                 .assert_ok();
    525             // And can be refreshed
    526             ctx.post("/accounts/customer/token")
    527                 .header(AUTHORIZATION, format!("Bearer {access_token}"))
    528                 .json(json!({
    529                     "scope": "readonly"
    530                 }))
    531                 .await
    532                 .assert_ok();
    533         }
    534 
    535         // 2FA
    536         {
    537             // Setup a known phone 2FA
    538             ctx.patcha("/accounts/merchant")
    539                 .json(json!({
    540                     "contact_data": { "phone": "+12345" },
    541                     "tan_channel": "sms"
    542                 }))
    543                 .await
    544                 .assert_challenge(&ctx)
    545                 .await
    546                 .assert_no_content();
    547             // Check creating a token requires to solve an unauthenticated challenge
    548             let challenge = ctx
    549                 .postpw("/accounts/merchant/token")
    550                 .json(json!({ "scope": "readonly" }))
    551                 .await
    552                 .assert_accepted_json::<ChallengeResponse>()
    553                 .challenges
    554                 .pop()
    555                 .unwrap();
    556             ctx.post(format!(
    557                 "/accounts/merchant/challenge/{}",
    558                 challenge.challenge_id
    559             ))
    560             .await
    561             .assert_ok();
    562             assert_eq!("REDACTED", challenge.tan_info); // Check phone number is hidden
    563             let code = tan_code("+12345");
    564             ctx.post(format!(
    565                 "/accounts/merchant/challenge/{}/confirm",
    566                 challenge.challenge_id
    567             ))
    568             .json(json!({ "tan": code }))
    569             .await
    570             .assert_no_content();
    571             ctx.postpw("/accounts/merchant/token")
    572                 .header(TALER_CHALLENGE_IDS, challenge.challenge_id)
    573                 .json(json!({ "scope": "readonly" }))
    574                 .await
    575                 .assert_ok_json::<TokenSuccessResponse>();
    576         }
    577 
    578         // Locked
    579         {
    580             let lock_account = async || {
    581                 let mut counter = MAX_TOKEN_CREATION_ATTEMPTS + 1;
    582                 while counter > 0 {
    583                     let challenge = ctx
    584                         .postpw("/accounts/merchant/token")
    585                         .json(json!({ "scope": "readonly" }))
    586                         .await
    587                         .assert_accepted_json::<ChallengeResponse>()
    588                         .challenges
    589                         .pop()
    590                         .unwrap();
    591                     ctx.post(format!(
    592                         "/accounts/merchant/challenge/{}",
    593                         challenge.challenge_id
    594                     ))
    595                     .await
    596                     .assert_ok();
    597                     while counter > 0 {
    598                         let error: ErrorDetail = ctx
    599                             .post(format!(
    600                                 "/accounts/merchant/challenge/{}/confirm",
    601                                 challenge.challenge_id
    602                             ))
    603                             .json(json!({ "tan": "bad code" }))
    604                             .await
    605                             .json_parse();
    606                         counter -= 1;
    607                         if error.code == ErrorCode::BANK_TAN_CHALLENGE_FAILED as u16 {
    608                             continue;
    609                         } else if error.code == ErrorCode::BANK_TAN_RATE_LIMITED as u16
    610                             || error.code == ErrorCode::BANK_TAN_CHALLENGE_EXPIRED as u16
    611                         {
    612                             break;
    613                         } else {
    614                             unreachable!("{error:?}")
    615                         }
    616                     }
    617                 }
    618                 ctx.postpw("/accounts/merchant/token")
    619                     .json(json!({ "scope": "readonly" }))
    620                     .await
    621                     .assert_error(ErrorCode::BANK_ACCOUNT_LOCKED);
    622             };
    623 
    624             lock_account().await;
    625 
    626             // Check token still works
    627             assert!(
    628                 ctx.geta("/accounts/merchant")
    629                     .await
    630                     .assert_ok_json::<AccountData>()
    631                     .is_locked
    632             );
    633 
    634             // Check admin can unlock
    635             ctx.patch_admin("/accounts/merchant/auth")
    636                 .json(json!({
    637                     "new_password": "merchant-password"
    638                 }))
    639                 .await
    640                 .assert_no_content();
    641             assert!(
    642                 !ctx.geta("/accounts/merchant")
    643                     .await
    644                     .assert_ok_json::<AccountData>()
    645                     .is_locked
    646             );
    647             lock_account().await;
    648 
    649             // Check token can unlock
    650             ctx.patcha("/accounts/merchant/auth")
    651                 .json(json!({
    652                       "old_password": "merchant-password",
    653                     "new_password": "merchant-password"
    654                 }))
    655                 .await
    656                 .assert_challenge(&ctx)
    657                 .await
    658                 .assert_no_content();
    659             assert!(
    660                 !ctx.geta("/accounts/merchant")
    661                     .await
    662                     .assert_ok_json::<AccountData>()
    663                     .is_locked
    664             );
    665         }
    666     }
    667 
    668     #[db_test(raw)]
    669     async fn get(db: PgConnectOptions) {
    670         let ctx = bank_setup(db).await;
    671         ctx.auth_routine(Method::GET, "/accounts/merchant/tokens", Auth::UserOrAdmin)
    672             .await;
    673 
    674         // Check OK
    675         for account in ["merchant", "customer"] {
    676             let res = ctx
    677                 .geta(format!("/accounts/{account}/tokens"))
    678                 .await
    679                 .assert_ok_json::<TokenInfos>();
    680             assert_eq!(res.tokens.len(), 1);
    681         }
    682         for scope in ["readonly", "readwrite"] {
    683             ctx.postpw("/accounts/merchant/token")
    684                 .json(json!({ "scope": scope }))
    685                 .await
    686                 .assert_ok();
    687         }
    688         ctx.postpw("/accounts/merchant/token")
    689             .json(json!({
    690                 "scope": "revenue",
    691                 "duration": {
    692                    "d_us": "forever"
    693                 }
    694             }))
    695             .await
    696             .assert_ok();
    697         ctx.postpw("/accounts/merchant/token")
    698             .json(json!({
    699                 "scope": "revenue",
    700                 "duration": {
    701                    "d_us": 0
    702                 }
    703             }))
    704             .await
    705             .assert_ok();
    706         ctx.postpw("/accounts/customer/token")
    707             .json(json!({
    708                 "scope": "readonly",
    709                 "description": "description"
    710             }))
    711             .await
    712             .assert_ok();
    713         let res: TokenInfos = ctx.geta("/accounts/merchant/tokens").await.assert_ok_json();
    714         assert_eq!(res.tokens.len(), 4);
    715         for token in res.tokens {
    716             assert_eq!(token.description, None);
    717         }
    718         let res: TokenInfos = ctx.geta("/accounts/customer/tokens").await.assert_ok_json();
    719         assert_eq!(res.tokens.len(), 2);
    720         assert_eq!(res.tokens[0].description.as_deref(), Some("description"));
    721     }
    722 }