Skip to content

Commit

Permalink
add OneDrive support
Browse files Browse the repository at this point in the history
  • Loading branch information
avan1235 committed Mar 6, 2024
1 parent 64a6181 commit 908a931
Show file tree
Hide file tree
Showing 7 changed files with 329 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import kotlinx.serialization.json.Json
import ml.dev.kotlin.openotp.component.UserLinkedAccountsModel
import ml.dev.kotlin.openotp.util.createJsonHttpClient
import ml.dev.kotlin.openotp.util.randomBytesChallenge
import ml.dev.kotlin.openotp.util.safHttpRequest
import ml.dev.kotlin.openotp.util.safeHttpRequest
import ml.dev.kotlin.openotp.util.safeRequest
import org.kotlincrypto.hash.sha2.SHA256
import kotlin.math.min
Expand Down Expand Up @@ -99,7 +99,7 @@ sealed class DropboxService : OAuth2AccountService {

override suspend fun downloadBackupData(): ByteArray? {
val apiArg = DropboxJson.encodeToString(DropboxDownloadArg())
return client.safHttpRequest {
return client.safeHttpRequest {
method = HttpMethod.Post
url("https://content.dropboxapi.com/2/files/download")
header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
package ml.dev.kotlin.openotp.backup

import io.ktor.client.*
import io.ktor.client.request.*
import io.ktor.client.request.forms.*
import io.ktor.client.statement.*
import io.ktor.http.*
import io.ktor.http.content.*
import io.ktor.util.*
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import ml.dev.kotlin.openotp.component.UserLinkedAccountsModel
import ml.dev.kotlin.openotp.util.createJsonHttpClient
import ml.dev.kotlin.openotp.util.randomBytesChallenge
import ml.dev.kotlin.openotp.util.safeHttpRequest
import ml.dev.kotlin.openotp.util.safeRequest
import org.kotlincrypto.hash.sha2.SHA256
import kotlin.time.Duration.Companion.seconds

sealed class OneDriveService : OAuth2AccountService {

protected val client: HttpClient by lazy(::createJsonHttpClient)

data object Initialized : OneDriveService(), OAuth2AccountService.Initialized {
override fun requestPermissions(): RequestedPermissions? {
val bytes = randomBytesChallenge(count = 32) ?: return null
val codeVerifier = bytes.oneDriveEncodeBase64()
val codeChallenge = SHA256().digest(codeVerifier.encodeToByteArray()).oneDriveEncodeBase64()
val accessData = OneDriveAccessData(codeVerifier, codeChallenge)
return RequestedPermissions(accessData)
}
}

class RequestedPermissions(
private val accessData: OneDriveAccessData,
) : OneDriveService(), OAuth2AccountService.RequestedPermissions {

override fun generateVerifyUri(): String =
"https://login.microsoftonline.com/common/oauth2/v2.0/authorize" +
"?client_id=$CLIENT_ID" +
"&response_type=code" +
"&redirect_uri=https%3A%2F%2Fopen-otp.procyk.in" +
"&response_mode=fragment" +
"&scope=offline_access+files.readwrite.all" +
"&code_challenge=${accessData.codeChallenge}" +
"&code_challenge_method=S256"

override suspend fun authenticateUser(userCode: String): Result<Authenticated> =
client.safeRequest<OneDriveOAuth2RefreshableTokenResponse> {
method = HttpMethod.Post
url("https://login.microsoftonline.com/common/oauth2/v2.0/token")
setBody(FormDataContent(parameters {
append("code", userCode)
append("grant_type", "authorization_code")
append("code_verifier", accessData.codeVerifier)
append("client_id", CLIENT_ID)
append("redirect_uri", "https://open-otp.procyk.in")
append("scope", "files.readwrite.all")
}))
}.map { response ->
val refreshableAccessData = response.toOneDriveRefreshableAccessData()
Authenticated(refreshableAccessData)
}

}

class Authenticated(
private val refreshableAccessData: OneDriveRefreshableAccessData,
) : OneDriveService(), OAuth2AccountService.Authenticated {

override val isExpired: Boolean
get() = Clock.System.now() >= refreshableAccessData.expiresAt

override suspend fun refreshUserAccessToken(): Result<Authenticated> =
client.safeRequest<OneDriveOAuth2TokenResponse> {
method = HttpMethod.Post
url("https://login.microsoftonline.com/common/oauth2/v2.0/token")
setBody(FormDataContent(parameters {
append("grant_type", "refresh_token")
append("refresh_token", refreshableAccessData.refreshToken)
append("client_id", CLIENT_ID)
append("scope", "files.readwrite.all")
}))
}.map { response ->
val refreshableAccessData = response.toOneDriveRefreshableAccessData(refreshableAccessData.refreshToken)
Authenticated(refreshableAccessData)
}

override suspend fun uploadBackupData(data: ByteArray): Result<Boolean> =
client.safeRequest<OneDriveUploadResponse> {
method = HttpMethod.Put
url("https://graph.microsoft.com/v1.0/me/drive/root:/$BACKUP_PATH:/content")
header(HttpHeaders.ContentType, ContentType.Application.OctetStream)
header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}")
setBody(ByteArrayContent(data))
}.map { response ->
response.file.hashes.sha256Hash == data.oneDriveContentHash()
}

override suspend fun downloadBackupData(): ByteArray? =
client.safeHttpRequest {
method = HttpMethod.Get
url("https://graph.microsoft.com/v1.0/me/drive/root:/$BACKUP_PATH:/content")
header(HttpHeaders.Authorization, "Bearer ${refreshableAccessData.accessToken}")
}.map { response ->
val downloadLocation = response.headers[HttpHeaders.ContentLocation] ?: return@map null
client.safeHttpRequest {
method = HttpMethod.Get
url(downloadLocation)
}
.map { it.readBytes() }
.getOrNull()
}.getOrNull()

override fun updateUserLinkedAccounts(linkedAccounts: UserLinkedAccountsModel): UserLinkedAccountsModel =
linkedAccounts.copy(onedrive = refreshableAccessData)
}
}

data class OneDriveAccessData(
val codeVerifier: String,
val codeChallenge: String,
)

@Serializable
data class OneDriveRefreshableAccessData(
val expiresAt: Instant,
val accessToken: String,
val refreshToken: String,
)

@Serializable
private data class OneDriveOAuth2RefreshableTokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("expires_in") val expiresInSeconds: Int,
@SerialName("token_type") val tokenType: TokenType,
@SerialName("refresh_token") val refreshToken: String,
) {
@Serializable
enum class TokenType { Bearer }
}

@Serializable
private data class OneDriveOAuth2TokenResponse(
@SerialName("access_token") val accessToken: String,
@SerialName("expires_in") val expiresInSeconds: Int,
@SerialName("token_type") val tokenType: TokenType,
) {
@Serializable
enum class TokenType { Bearer }
}

@Serializable
private data class OneDriveUploadResponse(
@SerialName("file") val file: File,
) {
@Serializable
data class File(
@SerialName("hashes") val hashes: Hashes,
) {
@Serializable
data class Hashes(
@SerialName("sha256Hash") val sha256Hash: String,
)
}
}

private fun OneDriveOAuth2RefreshableTokenResponse.toOneDriveRefreshableAccessData(): OneDriveRefreshableAccessData =
OneDriveRefreshableAccessData(
expiresAt = Clock.System.now() + expiresInSeconds.seconds / 2,
accessToken = accessToken,
refreshToken = refreshToken,
)

private fun OneDriveOAuth2TokenResponse.toOneDriveRefreshableAccessData(refreshToken: String): OneDriveRefreshableAccessData =
OneDriveRefreshableAccessData(
expiresAt = Clock.System.now() + expiresInSeconds.seconds / 2,
accessToken = accessToken,
refreshToken = refreshToken,
)

private const val CLIENT_ID: String = "8612f175-11d3-4dea-960a-d2cb89867a33"

private const val BACKUP_PATH: String = "OpenOTP/OpenOTP.backup"

private fun ByteArray.oneDriveEncodeBase64(): String = encodeBase64()
.replace('+', '-')
.replace('/', '_')
.replace("=", "")

private fun ByteArray.oneDriveContentHash(): String =
SHA256().digest(this).toHexString(format = HexFormat.UpperCase)
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,17 @@ package ml.dev.kotlin.openotp.component
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.vector.ImageVector
import kotlinx.serialization.Serializable
import ml.dev.kotlin.openotp.backup.DropboxRefreshableAccessData
import ml.dev.kotlin.openotp.backup.DropboxService
import ml.dev.kotlin.openotp.backup.OAuth2AccountService
import ml.dev.kotlin.openotp.backup.*
import ml.dev.kotlin.openotp.shared.OpenOtpResources
import ml.dev.kotlin.openotp.ui.OtpIcons
import ml.dev.kotlin.openotp.ui.icons.Dropbox
import ml.dev.kotlin.openotp.ui.icons.OneDrive
import org.koin.compose.koinInject

@Serializable
data class UserLinkedAccountsModel(
val dropbox: DropboxRefreshableAccessData? = null,
val onedrive: OneDriveRefreshableAccessData? = null,
)

enum class UserLinkedAccountType {
Expand All @@ -34,6 +34,24 @@ enum class UserLinkedAccountType {

override fun createAuthenticatedService(linkedAccounts: UserLinkedAccountsModel): DropboxService.Authenticated? =
linkedAccounts.dropbox?.let(DropboxService::Authenticated)
},
OneDrive {
override val icon: ImageVector = OtpIcons.OneDrive

override val OpenOtpAppComponentContext.iconContentDescription: String
get() = stringResource(OpenOtpResources.strings.onedrive_name)

override fun reset(model: UserLinkedAccountsModel) =
model.copy(onedrive = null)

override fun isLinked(model: UserLinkedAccountsModel) =
model.onedrive != null

override fun createService() =
OneDriveService.Initialized

override fun createAuthenticatedService(linkedAccounts: UserLinkedAccountsModel): OneDriveService.Authenticated? =
linkedAccounts.onedrive?.let(OneDriveService::Authenticated)
}
;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package ml.dev.kotlin.openotp.ui.icons

import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.PathFillType.Companion.NonZero
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap.Companion.Butt
import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.ImageVector.Builder
import androidx.compose.ui.graphics.vector.path
import androidx.compose.ui.unit.dp
import ml.dev.kotlin.openotp.ui.OtpIcons

val OtpIcons.OneDrive: ImageVector
get() {
if (_onedrive != null) {
return _onedrive!!
}
_onedrive = Builder(
name = "OneDrive", defaultWidth = 24.0.dp,
defaultHeight = 24.0.dp, viewportWidth = 24.0f, viewportHeight = 24.0f
).apply {
path(
fill = SolidColor(Color(0xFF000000)), stroke = null, strokeLineWidth = 0.0f,
strokeLineCap = Butt, strokeLineJoin = Miter, strokeLineMiter = 4.0f,
pathFillType = NonZero
) {
moveTo(19.453f, 9.95f)
quadToRelative(0.961f, 0.058f, 1.787f, 0.468f)
quadToRelative(0.826f, 0.41f, 1.442f, 1.066f)
quadToRelative(0.615f, 0.657f, 0.966f, 1.512f)
quadToRelative(0.352f, 0.856f, 0.352f, 1.816f)
quadToRelative(0.0f, 1.008f, -0.387f, 1.893f)
quadToRelative(-0.386f, 0.885f, -1.049f, 1.547f)
quadToRelative(-0.662f, 0.662f, -1.546f, 1.049f)
quadToRelative(-0.885f, 0.387f, -1.893f, 0.387f)
lineTo(6.0f, 19.688f)
quadToRelative(-1.242f, 0.0f, -2.332f, -0.475f)
quadToRelative(-1.09f, -0.475f, -1.904f, -1.29f)
quadToRelative(-0.815f, -0.814f, -1.29f, -1.903f)
quadTo(0.0f, 14.93f, 0.0f, 13.688f)
quadToRelative(0.0f, -0.985f, 0.31f, -1.887f)
quadToRelative(0.311f, -0.903f, 0.862f, -1.658f)
quadToRelative(0.55f, -0.756f, 1.324f, -1.325f)
quadToRelative(0.774f, -0.568f, 1.711f, -0.861f)
quadToRelative(0.434f, -0.129f, 0.85f, -0.187f)
quadToRelative(0.416f, -0.06f, 0.861f, -0.082f)
horizontalLineToRelative(0.012f)
quadToRelative(0.515f, -0.786f, 1.207f, -1.413f)
quadToRelative(0.691f, -0.627f, 1.5f, -1.066f)
quadToRelative(0.808f, -0.44f, 1.705f, -0.668f)
quadToRelative(0.896f, -0.229f, 1.845f, -0.229f)
quadToRelative(1.278f, 0.0f, 2.456f, 0.417f)
quadToRelative(1.177f, 0.416f, 2.144f, 1.16f)
quadToRelative(0.967f, 0.744f, 1.658f, 1.78f)
quadToRelative(0.692f, 1.038f, 1.008f, 2.28f)
close()
moveTo(12.188f, 5.813f)
quadToRelative(-1.325f, 0.0f, -2.52f, 0.544f)
quadToRelative(-1.195f, 0.545f, -2.04f, 1.565f)
quadToRelative(0.446f, 0.117f, 0.85f, 0.299f)
quadToRelative(0.405f, 0.181f, 0.792f, 0.416f)
lineToRelative(4.78f, 2.86f)
lineToRelative(2.731f, -1.15f)
quadToRelative(0.27f, -0.117f, 0.545f, -0.204f)
quadToRelative(0.276f, -0.088f, 0.58f, -0.147f)
quadToRelative(-0.293f, -0.937f, -0.855f, -1.705f)
quadToRelative(-0.563f, -0.768f, -1.319f, -1.318f)
quadToRelative(-0.755f, -0.551f, -1.658f, -0.856f)
quadToRelative(-0.902f, -0.304f, -1.886f, -0.304f)
close()
moveTo(2.414f, 16.395f)
lineToRelative(9.914f, -4.184f)
lineToRelative(-3.832f, -2.297f)
quadToRelative(-0.586f, -0.351f, -1.23f, -0.539f)
quadToRelative(-0.645f, -0.188f, -1.325f, -0.188f)
quadToRelative(-0.914f, 0.0f, -1.722f, 0.364f)
quadToRelative(-0.809f, 0.363f, -1.412f, 0.978f)
quadToRelative(-0.604f, 0.616f, -0.955f, 1.436f)
quadToRelative(-0.352f, 0.82f, -0.352f, 1.723f)
quadToRelative(0.0f, 0.703f, 0.234f, 1.423f)
quadToRelative(0.235f, 0.721f, 0.68f, 1.284f)
close()
moveTo(19.125f, 18.188f)
quadToRelative(0.563f, 0.0f, 1.078f, -0.176f)
quadToRelative(0.516f, -0.176f, 0.961f, -0.516f)
lineToRelative(-7.23f, -4.324f)
lineToRelative(-10.301f, 4.336f)
quadToRelative(0.527f, 0.328f, 1.13f, 0.504f)
quadToRelative(0.604f, 0.175f, 1.237f, 0.175f)
close()
moveTo(22.137f, 16.336f)
quadToRelative(0.363f, -0.727f, 0.363f, -1.523f)
quadToRelative(0.0f, -0.774f, -0.293f, -1.407f)
reflectiveQuadToRelative(-0.791f, -1.072f)
quadToRelative(-0.498f, -0.44f, -1.166f, -0.68f)
quadToRelative(-0.668f, -0.24f, -1.406f, -0.24f)
quadToRelative(-0.422f, 0.0f, -0.838f, 0.1f)
reflectiveQuadToRelative(-0.815f, 0.252f)
quadToRelative(-0.398f, 0.152f, -0.785f, 0.334f)
quadToRelative(-0.386f, 0.181f, -0.761f, 0.345f)
close()
}
}
.build()
return _onedrive!!
}

private var _onedrive: ImageVector? = null
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ suspend inline fun <reified T> HttpClient.safeRequest(
Result.failure(e)
}

suspend inline fun HttpClient.safHttpRequest(
suspend inline fun HttpClient.safeHttpRequest(
block: HttpRequestBuilder.() -> Unit,
): Result<HttpResponse> = try {
val response = request { block() }
Expand Down
1 change: 1 addition & 0 deletions shared/src/commonMain/resources/MR/base/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
<string name="authenticate_request_description">Authenticate to get access to application codes.</string>
<string name="app_icon">App icon</string>
<string name="dropbox_name">Dropbox</string>
<string name="onedrive_name">OneDrive</string>
<string name="synced_all">Synced successfully all backups destinations</string>
<string name="failed_some">Some backups destinations have failed</string>
<string name="failed_all">All backups destinations have failed</string>
Expand Down
1 change: 1 addition & 0 deletions shared/src/commonMain/resources/MR/pl/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<string name="authenticate_request_description">Uwierzytelnij, aby uzyskać dostęp do kodów aplikacji.</string>
<string name="app_icon">Ikona aplikacji</string>
<string name="dropbox_name">Dropbox</string>
<string name="onedrive_name">OneDrive</string>
<string name="synced_all">Pomyślnie stworzono kopię zapasową</string>
<string name="failed_some">Niektóre kopie zapasowe nie mogły zostać ukończone poprawnie</string>
<string name="failed_all">Wszystkie kopie zapasowe nie mogły zostać ukończone poprawnie</string>
Expand Down

0 comments on commit 908a931

Please sign in to comment.