Bug: feat: Add new auth type to allow both SPTs and Login Tokens for 2FA APIs



The following APIs are needed in both with Sign In and in the dashboard.
- `/2fa/totp/verify`
- `/2fa/recovery_code/verify`
- `/2fa/recovery_code/generate`

Currently these APIs use SPT as auth, which makes them unable to access using Login Tokens.

We need a new Auth type which allows us to do this.
diff --git a/crates/router/src/consts/user.rs b/crates/router/src/consts/user.rs
index c7615aa4be4..331c256e8a4 100644
--- a/crates/router/src/consts/user.rs
+++ b/crates/router/src/consts/user.rs
@@ -18,4 +18,4 @@ pub const MIN_PASSWORD_LENGTH: usize = 8;
 pub const REDIS_TOTP_PREFIX: &str = "TOTP_";
 pub const REDIS_RECOVERY_CODE_PREFIX: &str = "RC_";
 pub const REDIS_TOTP_SECRET_PREFIX: &str = "TOTP_SEC_";
-pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 5 * 60; // 5 minutes
+pub const REDIS_TOTP_SECRET_TTL_IN_SECS: i64 = 15 * 60; // 15 minutes
diff --git a/crates/router/src/core/user.rs b/crates/router/src/core/user.rs
index 69a4cfc8a61..cfc01afb91b 100644
--- a/crates/router/src/core/user.rs
+++ b/crates/router/src/core/user.rs
@@ -1237,11 +1237,11 @@ pub async fn create_merchant_account(
 
 pub async fn list_merchants_for_user(
     state: SessionState,
-    user_from_token: Box<dyn auth::GetUserIdFromAuth>,
+    user_from_token: auth::UserIdFromAuth,
 ) -> UserResponse<Vec<user_api::UserMerchantAccount>> {
     let user_roles = state
         .store
-        .list_user_roles_by_user_id(user_from_token.get_user_id().as_str())
+        .list_user_roles_by_user_id(user_from_token.user_id.as_str())
         .await
         .change_context(UserErrors::InternalServerError)?;
 
@@ -1697,7 +1697,7 @@ pub async fn reset_totp(
 
 pub async fn verify_totp(
     state: SessionState,
-    user_token: auth::UserFromSinglePurposeToken,
+    user_token: auth::UserIdFromAuth,
     req: user_api::VerifyTotpRequest,
 ) -> UserResponse<user_api::TokenResponse> {
     let user_from_db: domain::UserFromStorage = state
@@ -1737,7 +1737,7 @@ pub async fn verify_totp(
 
 pub async fn update_totp(
     state: SessionState,
-    user_token: auth::UserFromSinglePurposeToken,
+    user_token: auth::UserIdFromAuth,
     req: user_api::VerifyTotpRequest,
 ) -> UserResponse<()> {
     let user_from_db: domain::UserFromStorage = state
@@ -1806,7 +1806,7 @@ pub async fn update_totp(
 
 pub async fn generate_recovery_codes(
     state: SessionState,
-    user_token: auth::UserFromSinglePurposeToken,
+    user_token: auth::UserIdFromAuth,
 ) -> UserResponse<user_api::RecoveryCodes> {
     if !tfa_utils::check_totp_in_redis(&state, &user_token.user_id).await? {
         return Err(UserErrors::TotpRequired.into());
@@ -1838,7 +1838,7 @@ pub async fn generate_recovery_codes(
 
 pub async fn verify_recovery_code(
     state: SessionState,
-    user_token: auth::UserFromSinglePurposeToken,
+    user_token: auth::UserIdFromAuth,
     req: user_api::VerifyRecoveryCodeRequest,
 ) -> UserResponse<user_api::TokenResponse> {
     let user_from_db: domain::UserFromStorage = state
diff --git a/crates/router/src/routes/app.rs b/crates/router/src/routes/app.rs
index 5af3a2bef25..457e232ebd1 100644
--- a/crates/router/src/routes/app.rs
+++ b/crates/router/src/routes/app.rs
@@ -1316,7 +1316,7 @@ impl User {
             // The route is utilized to select an invitation from a list of merchants in an intermediate state
             .service(
                 web::resource("/merchants_select/list")
-                    .route(web::get().to(list_merchants_for_user_with_spt)),
+                    .route(web::get().to(list_merchants_for_user)),
             )
             .service(web::resource("/permission_info").route(web::get().to(get_authorization_info)))
             .service(web::resource("/update").route(web::post().to(update_user_account_details)))
diff --git a/crates/router/src/routes/user.rs b/crates/router/src/routes/user.rs
index e5cc7e4e294..5325cbe437b 100644
--- a/crates/router/src/routes/user.rs
+++ b/crates/router/src/routes/user.rs
@@ -318,24 +318,7 @@ pub async fn list_merchants_for_user(state: web::Data<AppState>, req: HttpReques
         &req,
         (),
         |state, user, _, _| user_core::list_merchants_for_user(state, user),
-        &auth::DashboardNoPermissionAuth,
-        api_locking::LockAction::NotApplicable,
-    ))
-    .await
-}
-
-pub async fn list_merchants_for_user_with_spt(
-    state: web::Data<AppState>,
-    req: HttpRequest,
-) -> HttpResponse {
-    let flow = Flow::UserMerchantAccountList;
-    Box::pin(api::server_wrap(
-        flow,
-        state,
-        &req,
-        (),
-        |state, user, _, _| user_core::list_merchants_for_user(state, user),
-        &auth::SinglePurposeJWTAuth(TokenPurpose::AcceptInvite),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::AcceptInvite),
         api_locking::LockAction::NotApplicable,
     ))
     .await
@@ -674,7 +657,7 @@ pub async fn totp_verify(
         &req,
         json_payload.into_inner(),
         |state, user, req_body, _| user_core::verify_totp(state, user, req_body),
-        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
         api_locking::LockAction::NotApplicable,
     ))
     .await
@@ -692,7 +675,7 @@ pub async fn verify_recovery_code(
         &req,
         json_payload.into_inner(),
         |state, user, req_body, _| user_core::verify_recovery_code(state, user, req_body),
-        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
         api_locking::LockAction::NotApplicable,
     ))
     .await
@@ -710,7 +693,7 @@ pub async fn totp_update(
         &req,
         json_payload.into_inner(),
         |state, user, req_body, _| user_core::update_totp(state, user, req_body),
-        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
         api_locking::LockAction::NotApplicable,
     ))
     .await
@@ -724,7 +707,7 @@ pub async fn generate_recovery_codes(state: web::Data<AppState>, req: HttpReques
         &req,
         (),
         |state, user, _, _| user_core::generate_recovery_codes(state, user),
-        &auth::SinglePurposeJWTAuth(TokenPurpose::TOTP),
+        &auth::SinglePurposeOrLoginTokenAuth(TokenPurpose::TOTP),
         api_locking::LockAction::NotApplicable,
     ))
     .await
diff --git a/crates/router/src/services/authentication.rs b/crates/router/src/services/authentication.rs
index 28f9ccd1a77..72d0860eed9 100644
--- a/crates/router/src/services/authentication.rs
+++ b/crates/router/src/services/authentication.rs
@@ -64,10 +64,15 @@ pub enum AuthenticationType {
     UserJwt {
         user_id: String,
     },
-    SinglePurposeJWT {
+    SinglePurposeJwt {
         user_id: String,
         purpose: TokenPurpose,
     },
+    SinglePurposeOrLoginJwt {
+        user_id: String,
+        purpose: Option<TokenPurpose>,
+        role_id: Option<String>,
+    },
     MerchantId {
         merchant_id: String,
     },
@@ -107,7 +112,8 @@ impl AuthenticationType {
             | Self::WebhookAuth { merchant_id } => Some(merchant_id.as_ref()),
             Self::AdminApiKey
             | Self::UserJwt { .. }
-            | Self::SinglePurposeJWT { .. }
+            | Self::SinglePurposeJwt { .. }
+            | Self::SinglePurposeOrLoginJwt { .. }
             | Self::NoAuth => None,
         }
     }
@@ -189,6 +195,19 @@ pub struct UserFromToken {
     pub org_id: String,
 }
 
+pub struct UserIdFromAuth {
+    pub user_id: String,
+}
+
+#[cfg(feature = "olap")]
+#[derive(serde::Serialize, serde::Deserialize)]
+pub struct SinglePurposeOrLoginToken {
+    pub user_id: String,
+    pub role_id: Option<String>,
+    pub purpose: Option<TokenPurpose>,
+    pub exp: u64,
+}
+
 pub trait AuthInfo {
     fn get_merchant_id(&self) -> Option<&str>;
 }
@@ -205,23 +224,6 @@ impl AuthInfo for AuthenticationData {
     }
 }
 
-pub trait GetUserIdFromAuth {
-    fn get_user_id(&self) -> String;
-}
-
-impl GetUserIdFromAuth for UserFromToken {
-    fn get_user_id(&self) -> String {
-        self.user_id.clone()
-    }
-}
-
-#[cfg(feature = "olap")]
-impl GetUserIdFromAuth for UserFromSinglePurposeToken {
-    fn get_user_id(&self) -> String {
-        self.user_id.clone()
-    }
-}
-
 #[async_trait]
 pub trait AuthenticateAndFetch<T, A>
 where
@@ -355,7 +357,7 @@ where
                 user_id: payload.user_id.clone(),
                 origin: payload.origin.clone(),
             },
-            AuthenticationType::SinglePurposeJWT {
+            AuthenticationType::SinglePurposeJwt {
                 user_id: payload.user_id,
                 purpose: payload.purpose,
             },
@@ -363,9 +365,13 @@ where
     }
 }
 
+#[cfg(feature = "olap")]
+#[derive(Debug)]
+pub struct SinglePurposeOrLoginTokenAuth(pub TokenPurpose);
+
 #[cfg(feature = "olap")]
 #[async_trait]
-impl<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for SinglePurposeJWTAuth
+impl<A> AuthenticateAndFetch<UserIdFromAuth, A> for SinglePurposeOrLoginTokenAuth
 where
     A: SessionStateInfo + Sync,
 {
@@ -373,26 +379,35 @@ where
         &self,
         request_headers: &HeaderMap,
         state: &A,
-    ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> {
-        let payload = parse_jwt_payload::<A, SinglePurposeToken>(request_headers, state).await?;
+    ) -> RouterResult<(UserIdFromAuth, AuthenticationType)> {
+        let payload =
+            parse_jwt_payload::<A, SinglePurposeOrLoginToken>(request_headers, state).await?;
         if payload.check_in_blacklist(state).await? {
             return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
         }
 
-        if self.0 != payload.purpose {
-            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
-        }
+        let is_purpose_equal = payload
+            .purpose
+            .as_ref()
+            .is_some_and(|purpose| purpose == &self.0);
 
-        Ok((
-            Box::new(UserFromSinglePurposeToken {
-                user_id: payload.user_id.clone(),
-                origin: payload.origin.clone(),
-            }),
-            AuthenticationType::SinglePurposeJWT {
-                user_id: payload.user_id,
-                purpose: payload.purpose,
-            },
-        ))
+        let purpose_exists = payload.purpose.is_some();
+        let role_id_exists = payload.role_id.is_some();
+
+        if is_purpose_equal && !role_id_exists || role_id_exists && !purpose_exists {
+            Ok((
+                UserIdFromAuth {
+                    user_id: payload.user_id.clone(),
+                },
+                AuthenticationType::SinglePurposeOrLoginJwt {
+                    user_id: payload.user_id,
+                    purpose: payload.purpose,
+                    role_id: payload.role_id,
+                },
+            ))
+        } else {
+            Err(errors::ApiErrorResponse::InvalidJwtToken.into())
+        }
     }
 }
 
@@ -864,37 +879,6 @@ where
     }
 }
 
-#[cfg(feature = "olap")]
-#[async_trait]
-impl<A> AuthenticateAndFetch<Box<dyn GetUserIdFromAuth>, A> for DashboardNoPermissionAuth
-where
-    A: SessionStateInfo + Sync,
-{
-    async fn authenticate_and_fetch(
-        &self,
-        request_headers: &HeaderMap,
-        state: &A,
-    ) -> RouterResult<(Box<dyn GetUserIdFromAuth>, AuthenticationType)> {
-        let payload = parse_jwt_payload::<A, AuthToken>(request_headers, state).await?;
-        if payload.check_in_blacklist(state).await? {
-            return Err(errors::ApiErrorResponse::InvalidJwtToken.into());
-        }
-
-        Ok((
-            Box::new(UserFromToken {
-                user_id: payload.user_id.clone(),
-                merchant_id: payload.merchant_id.clone(),
-                org_id: payload.org_id,
-                role_id: payload.role_id,
-            }),
-            AuthenticationType::MerchantJwt {
-                merchant_id: payload.merchant_id,
-                user_id: Some(payload.user_id),
-            },
-        ))
-    }
-}
-
 #[cfg(feature = "olap")]
 #[async_trait]
 impl<A> AuthenticateAndFetch<(), A> for DashboardNoPermissionAuth
diff --git a/crates/router/src/services/authentication/blacklist.rs b/crates/router/src/services/authentication/blacklist.rs
index 8a3aefd8fc0..0ac8c419ef6 100644
--- a/crates/router/src/services/authentication/blacklist.rs
+++ b/crates/router/src/services/authentication/blacklist.rs
@@ -7,7 +7,7 @@ use redis_interface::RedisConnectionPool;
 
 use super::AuthToken;
 #[cfg(feature = "olap")]
-use super::SinglePurposeToken;
+use super::{SinglePurposeOrLoginToken, SinglePurposeToken};
 #[cfg(feature = "email")]
 use crate::consts::{EMAIL_TOKEN_BLACKLIST_PREFIX, EMAIL_TOKEN_TIME_IN_SECS};
 use crate::{
@@ -166,3 +166,14 @@ impl BlackList for SinglePurposeToken {
         check_user_in_blacklist(state, &self.user_id, self.exp).await
     }
 }
+
+#[cfg(feature = "olap")]
+#[async_trait::async_trait]
+impl BlackList for SinglePurposeOrLoginToken {
+    async fn check_in_blacklist<A>(&self, state: &A) -> RouterResult<bool>
+    where
+        A: SessionStateInfo + Sync,
+    {
+        check_user_in_blacklist(state, &self.user_id, self.exp).await
+    }
+}

## Type of Change
<!-- Put an `x` in the boxes that apply -->

- [ ] Bugfix
- [x] New feature
- [ ] Enhancement
- [ ] Refactoring
- [ ] Dependency updates
- [ ] Documentation
- [ ] CI/CD

## Description
<!-- Describe your changes in detail -->
This PR adds new auth type `SinglePurposeOrLoginTokenAuth`, which will allow both SPTs and Login Tokens to be used as auth token for APIs.

### Additional Changes

- [ ] This PR modifies the API contract
- [ ] This PR modifies the database schema
- [ ] This PR modifies application configuration/environment variables

<!--
Provide links to the files with corresponding changes.

Following are the paths where you can find config files:
1. `config`
2. `crates/router/src/configs`
3. `loadtest/config`
-->


## Motivation and Context
<!--
Why is this change required? What problem does it solve?
If it fixes an open issue, please link to the issue here.

If you don't have an issue, we'd recommend starting with one first so the PR
can focus on the implementation (unless it is an obvious bug or documentation fix
that will have little conversation).
-->
Closes #4829.

## How did you test it?
<!--
Did you write an integration/unit/API test to verify the code changes?
Or did you test this change manually (provide relevant screenshots)?
-->
1. Verify TOTP
	```
	curl --location 'http://localhost:8080/user/2fa/totp/verify' \
	--header 'Content-Type: application/json' \
	--header 'Authorization: Bearer SPT with purpose as TOTP' \
	--data '{
	    "totp": "597760"
	}'
	```
	```
	curl --location 'http://localhost:8080/user/2fa/totp/verify' \
	--header 'Content-Type: application/json' \
	--header 'Authorization: Bearer Login Token' \
	--data '{
	    "totp": "597760"
	}'
	```
	
	In both the cases this API should give 200 OK.

2. Verify Recovery Code
	```
	curl --location 'http://localhost:8080/user/2fa/recovery_code/verify' \
	--header 'Content-Type: application/json' \
	--header 'Authorization: Bearer SPT with purpose as TOTP' \
	--data '{
	    "recovery_code": "recovery_code"
	}'
	```
	```
	curl --location 'http://localhost:8080/user/2fa/recovery_code/verify' \
	--header 'Content-Type: application/json' \
	--header 'Authorization: Bearer Login Token' \
	--data '{
	    "recovery_code": "recovery_code"
	}'
	```

	In both the cases this API should give 200 OK.

3. Generate Recovery Codes
	```
	curl --location 'http://localhost:8080/user/2fa/recovery_code/generate' \
	--header 'Authorization: Bearer SPT with purpose as TOTP' \
	```
	```
	curl --location 'http://localhost:8080/user/2fa/recovery_code/generate' \
	--header 'Authorization: Bearer Login Token' \
	```
	In both the cases, API should respond like this
	```
	{
	    "recovery_codes": [
	        "w5fM-NLm0",
	        "TumH-oyXE",
	        "wDIr-zEmu",
	        "HZjm-e16M",
	        "Q4qB-MupL",
	        "5BtN-vRuY",
	        "4vG6-URGf",
	        "Dbq8-n5V1"
	    ]
	}
	```
4. Merchant select List
	```
	curl --location 'http://localhost:8080/user/merchants_select/list' \
	--header 'Authorization: Bearer SPT with Purpose as AcceptInvite'
	```
	```
	curl --location 'http://localhost:8080/user/merchants_select/list' \
	--header 'Authorization: Bearer Login Token'
	```
	In both the cases, API should respond like this
	```
	[
	    {
	        "merchant_id": "merchant_1681110944832",
	        "merchant_name": "sdjkfl",
	        "is_active": true,
	        "role_id": "merchant_admin",
	        "role_name": "admin",
	        "org_id": "org_LoNX0cc4lNZVgJrPV65QY"
	    },
	    {
	        "merchant_id": "merchant_1704717001",
	        "merchant_name": null,
	        "is_active": true,
	        "role_id": "merchant_customer_support",
	        "role_name": "customer_support",
	        "org_id": "org_7yULICVqnwmSX5PgW5zK"
	    }
	]
	```

5. Switch List
	```
	curl --location 'http://localhost:8080/user/switch/list' \
	--header 'Authorization: Bearer SPT with Purpose as AcceptInvite'
	```
	```
	curl --location 'http://localhost:8080/user/switch/list' \
	--header 'Authorization: Bearer Login Token'
	```
	In both the cases, API should respond like this
	```
	[
	    {
	        "merchant_id": "merchant_1681110944832",
	        "merchant_name": "sdjkfl",
	        "is_active": true,
	        "role_id": "merchant_admin",
	        "role_name": "admin",
	        "org_id": "org_LoNX0cc4lNZVgJrPV65QY"
	    },
	    {
	        "merchant_id": "merchant_1704717001",
	        "merchant_name": null,
	        "is_active": true,
	        "role_id": "merchant_customer_support",
	        "role_name": "customer_support",
	        "org_id": "org_7yULICVqnwmSX5PgW5zK"
	    }
	]
	```

## Checklist
<!-- Put an `x` in the boxes that apply -->

- [x] I formatted the code `cargo +nightly fmt --all`
- [x] I addressed lints thrown by `cargo clippy`
- [x] I reviewed the submitted code
- [ ] I added unit tests for my changes where possible
