feature(auth): Support token-based login for MDM.

VPNAND-2466
This commit is contained in:
Mateusz Markowicz
2025-12-12 17:37:16 +01:00
parent 1fcdf313fc
commit 1cbc2f8a93
8 changed files with 283 additions and 0 deletions
@@ -24,6 +24,7 @@ import me.proton.core.auth.data.api.request.ForkSessionRequest
import me.proton.core.auth.data.api.request.LoginLessRequest
import me.proton.core.auth.data.api.request.LoginRequest
import me.proton.core.auth.data.api.request.LoginSsoRequest
import me.proton.core.auth.data.api.request.LoginTokenMdmRequest
import me.proton.core.auth.data.api.request.PhoneValidationRequest
import me.proton.core.auth.data.api.request.RefreshSessionRequest
import me.proton.core.auth.data.api.request.RequestSessionRequest
@@ -68,6 +69,9 @@ interface AuthenticationApi : BaseRetrofitApi {
@POST("auth/v4/credentialless")
suspend fun performLoginLess(@Body request: LoginLessRequest): LoginResponse
@POST("vpn/v1/business/mdm-login")
suspend fun performLoginTokenMdm(@Body request: LoginTokenMdmRequest): LoginResponse
@POST("auth/v4/2fa")
suspend fun performSecondFactor(@Body request: SecondFactorRequest): SecondFactorResponse
@@ -0,0 +1,32 @@
/*
* Copyright (c) 2020 Proton Technologies AG
* This file is part of Proton Technologies AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.data.api.request
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class LoginTokenMdmRequest(
@SerialName("MdmToken")
val token: String,
@SerialName("Group")
val group: String,
@SerialName("DeviceUniqId")
val deviceId: String? = null,
)
@@ -32,6 +32,7 @@ import me.proton.core.auth.data.api.request.ForkSessionRequest
import me.proton.core.auth.data.api.request.LoginLessRequest
import me.proton.core.auth.data.api.request.LoginRequest
import me.proton.core.auth.data.api.request.LoginSsoRequest
import me.proton.core.auth.data.api.request.LoginTokenMdmRequest
import me.proton.core.auth.data.api.request.PhoneValidationRequest
import me.proton.core.auth.data.api.request.RefreshSessionRequest
import me.proton.core.auth.data.api.request.RequestSessionRequest
@@ -142,6 +143,18 @@ class AuthRepositoryImpl @Inject constructor(
}.valueOrThrow
}
override suspend fun performLoginTokenMdm(
token: String,
group: String,
deviceId: String?
): SessionInfo = result("performLoginTokenMDM") {
provider.get<AuthenticationApi>().invoke {
val request = LoginTokenMdmRequest(token, group, deviceId)
val response = performLoginTokenMdm(request)
response.toSessionInfo(username = null)
}.valueOrThrow
}
private suspend fun getFrameMap(frames: List<ChallengeFrameDetails>): Map<String, ChallengeFrame?> {
val name = "${product.framePrefix()}-0"
val frame = when {
@@ -482,6 +482,26 @@ class AuthRepositoryImplTest {
repository.performLoginLess()
}
@Test
fun `performLoginTokenMdm return SessionInfo`() = runTest(testDispatcherProvider.Main) {
// GIVEN
coEvery { apiManager.invoke<SessionInfo>(any()) } returns ApiResult.Success(mockk())
// WHEN
repository.performLoginTokenMdm("token", "group", "deviceId")
}
@Test(expected = ApiException::class)
fun `performLoginTokenMdm return error`() = runTest(testDispatcherProvider.Main) {
// GIVEN
coEvery { apiManager.invoke<SessionInfo>(any()) } returns ApiResult.Error.Http(
httpCode = 401,
message = "test http error",
proton = ApiResult.Error.ProtonData(1, "test login error")
)
// WHEN
repository.performLoginTokenMdm("token", "group", "deviceId")
}
@Test
fun `requestSession success`() = runTest(testDispatcherProvider.Main) {
// GIVEN
@@ -88,6 +88,15 @@ interface AuthRepository {
frames: List<ChallengeFrameDetails> = emptyList()
): SessionInfo
/**
* Perform token-based MDM Login to create a session (accessToken, refreshToken, sessionId, ...).
*/
suspend fun performLoginTokenMdm(
token: String,
group: String,
deviceId: String?
): SessionInfo
/**
* Perform Two Factor for the Login process for a given [SessionId].
*/
@@ -0,0 +1,85 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase
import me.proton.core.account.domain.entity.Account
import me.proton.core.account.domain.entity.AccountDetails
import me.proton.core.account.domain.entity.AccountState
import me.proton.core.account.domain.entity.AccountType
import me.proton.core.account.domain.entity.SessionDetails
import me.proton.core.account.domain.entity.SessionState
import me.proton.core.accountmanager.domain.AccountWorkflowHandler
import me.proton.core.auth.domain.entity.SessionInfo
import me.proton.core.auth.domain.entity.getFido2AuthOptions
import me.proton.core.network.domain.session.Session
import me.proton.core.util.kotlin.serialize
import javax.inject.Inject
/**
* Create a token-based MDM Session.
*
* Note: Next step should be PostLoginTokenMdmAccountSetup.
*/
class CreateLoginTokenMdmSession @Inject constructor(
private val requiredAccountType: AccountType,
private val accountWorkflow: AccountWorkflowHandler,
private val performLoginTokenMdm: PerformLoginTokenMdm
) {
suspend operator fun invoke(token: String, group: String, deviceId: String?): SessionInfo {
val sessionInfo = performLoginTokenMdm(token, group, deviceId)
handleSessionInfo(sessionInfo)
return sessionInfo
}
private suspend fun handleSessionInfo(sessionInfo: SessionInfo) {
val sessionState = if (sessionInfo.isSecondFactorNeeded) {
SessionState.SecondFactorNeeded
} else {
SessionState.Authenticated
}
val account = Account(
userId = sessionInfo.userId,
username = null,
email = null,
sessionId = sessionInfo.sessionId,
state = AccountState.NotReady,
sessionState = sessionState,
details = AccountDetails(
session = SessionDetails(
initialEventId = sessionInfo.eventId,
requiredAccountType = requiredAccountType,
secondFactorEnabled = sessionInfo.isSecondFactorNeeded,
twoPassModeEnabled = sessionInfo.isTwoPassModeNeeded,
passphrase = null,
password = null,
fido2AuthenticationOptionsJson = sessionInfo.getFido2AuthOptions()?.serialize()
)
)
)
val session = Session.Authenticated(
userId = sessionInfo.userId,
sessionId = sessionInfo.sessionId,
accessToken = sessionInfo.accessToken,
refreshToken = sessionInfo.refreshToken,
scopes = sessionInfo.scopes,
)
accountWorkflow.handleSession(account, session)
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase
import me.proton.core.auth.domain.entity.SessionInfo
import me.proton.core.auth.domain.repository.AuthRepository
import javax.inject.Inject
/**
* Performs token-based login for MDM.
*/
class PerformLoginTokenMdm @Inject constructor(
private val authRepository: AuthRepository
) {
suspend operator fun invoke(token: String, group: String, deviceId: String?): SessionInfo =
authRepository.performLoginTokenMdm(token, group, deviceId)
}
@@ -0,0 +1,87 @@
/*
* Copyright (c) 2024 Proton AG
* This file is part of Proton AG and ProtonCore.
*
* ProtonCore is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* ProtonCore 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 General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with ProtonCore. If not, see <https://www.gnu.org/licenses/>.
*/
package me.proton.core.auth.domain.usecase
import me.proton.core.accountmanager.domain.SessionManager
import me.proton.core.accountmanager.domain.AccountWorkflowHandler
import me.proton.core.domain.entity.UserId
import me.proton.core.network.domain.server.ServerClock
import me.proton.core.user.domain.UserManager
import me.proton.core.user.domain.entity.Type
import me.proton.core.user.domain.entity.User
import me.proton.core.user.domain.extension.USER_SERVICE_MASK_VPN
import javax.inject.Inject
class PostLoginTokenMdmAccountSetup @Inject constructor(
private val accountWorkflow: AccountWorkflowHandler,
private val userCheck: PostLoginAccountSetup.UserCheck,
private val userManager: UserManager,
private val sessionManager: SessionManager,
private val serverClock: ServerClock,
) {
suspend operator fun invoke(
userId: UserId,
): PostLoginAccountSetup.UserCheckResult {
// Refresh scopes.
sessionManager.refreshScopes(checkNotNull(sessionManager.getSessionId(userId)))
// First, create the User to invoke UserCheck.
val user = makeUser(userId)
userManager.addUser(user, emptyList())
val userCheckResult = userCheck.invoke(user)
when (userCheckResult) {
is PostLoginAccountSetup.UserCheckResult.Error -> {
// Disable account and prevent login.
accountWorkflow.handleAccountDisabled(userId)
}
is PostLoginAccountSetup.UserCheckResult.Success -> {
// Last step, change account state to Ready.
accountWorkflow.handleAccountReady(userId)
}
}
return userCheckResult
}
private fun makeUser(userId: UserId): User = User(
userId = userId,
email = null,
name = null,
displayName = null,
currency = "",
credit = 0,
type = Type.CredentialLess,
createdAtUtc = serverClock.getCurrentTimeUTC().toEpochMilli(),
usedSpace = 0,
maxSpace = 0,
maxUpload = 0,
role = null,
private = true,
services = USER_SERVICE_MASK_VPN,
subscribed = 0,
delinquent = null,
recovery = null,
keys = emptyList(),
flags = emptyMap(),
maxBaseSpace = 0,
maxDriveSpace = 0,
usedBaseSpace = 0,
usedDriveSpace = 0,
)
}