From 82c50b7c1dc04ef419196d8bcfd485650c663cba Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 19 May 2021 18:19:49 +0200 Subject: [PATCH] Jitsi auth: introduce JitsiService and JWT token creation --- vector/build.gradle | 16 ++- .../java/im/vector/app/core/network/OkHttp.kt | 47 +++++++ .../java/im/vector/app/core/utils/Base32.kt | 28 ++++ .../features/call/conference/JitsiService.kt | 120 ++++++++++++++++++ .../call/conference/JitsiWellKnown.kt | 25 ++++ .../call/conference/jwt/JitsiJWTFactory.kt | 58 +++++++++ 6 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 vector/src/main/java/im/vector/app/core/network/OkHttp.kt create mode 100644 vector/src/main/java/im/vector/app/core/utils/Base32.kt create mode 100644 vector/src/main/java/im/vector/app/features/call/conference/JitsiService.kt create mode 100644 vector/src/main/java/im/vector/app/features/call/conference/JitsiWellKnown.kt create mode 100644 vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt diff --git a/vector/build.gradle b/vector/build.gradle index a9a8ba0924..21c4e38de9 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -303,6 +303,7 @@ dependencies { def arch_version = '2.1.0' def lifecycle_version = '2.2.0' def rxbinding_version = '3.1.0' + def jjwt_version = '0.11.2' // Tests def kluent_version = '1.65' @@ -444,9 +445,9 @@ dependencies { // Jitsi implementation('org.jitsi.react:jitsi-meet-sdk:3.1.0') { - exclude group: 'com.google.firebase' - exclude group: 'com.google.android.gms' - exclude group: 'com.android.installreferrer' + exclude group: 'com.google.firebase' + exclude group: 'com.google.android.gms' + exclude group: 'com.android.installreferrer' } // QR-code @@ -460,6 +461,15 @@ dependencies { implementation 'im.dlg:android-dialer:1.2.5' + // JWT + api "io.jsonwebtoken:jjwt-api:$jjwt_version" + runtimeOnly "io.jsonwebtoken:jjwt-impl:$jjwt_version" + runtimeOnly("io.jsonwebtoken:jjwt-orgjson:$jjwt_version") { + exclude group: 'org.json', module: 'json' //provided by Android natively + } + implementation 'commons-codec:commons-codec:1.15' + + // TESTS testImplementation 'junit:junit:4.13.2' testImplementation "org.amshove.kluent:kluent-android:$kluent_version" diff --git a/vector/src/main/java/im/vector/app/core/network/OkHttp.kt b/vector/src/main/java/im/vector/app/core/network/OkHttp.kt new file mode 100644 index 0000000000..338ebab0b4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/network/OkHttp.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.core.network + +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +suspend fun Call.await(): Response { + return suspendCancellableCoroutine { continuation -> + enqueue(object : Callback { + override fun onResponse(call: Call, response: Response) { + continuation.resume(response) + } + + override fun onFailure(call: Call, e: IOException) { + if (continuation.isCancelled) return + continuation.resumeWithException(e) + } + }) + continuation.invokeOnCancellation { + try { + cancel() + } catch (ex: Throwable) { + //Ignore cancel exception + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/utils/Base32.kt b/vector/src/main/java/im/vector/app/core/utils/Base32.kt new file mode 100644 index 0000000000..4a42a252a1 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/Base32.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package im.vector.app.core.utils + +import org.apache.commons.codec.binary.Base32 + +fun String.toBase32String(padding: Boolean = true): String { + val base32 = Base32().encodeAsString(toByteArray()) + return if (padding) { + base32 + } else { + base32.replace("=", "") + } +} + diff --git a/vector/src/main/java/im/vector/app/features/call/conference/JitsiService.kt b/vector/src/main/java/im/vector/app/features/call/conference/JitsiService.kt new file mode 100644 index 0000000000..c3632d282a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/conference/JitsiService.kt @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import im.vector.app.R +import im.vector.app.core.network.await +import im.vector.app.core.resources.StringProvider +import im.vector.app.core.utils.toBase32String +import im.vector.app.features.raw.wellknown.getElementWellknown +import im.vector.app.features.settings.VectorLocale +import okhttp3.Request +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.raw.RawService +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.widgets.model.Widget +import org.matrix.android.sdk.api.session.widgets.model.WidgetType +import org.matrix.android.sdk.api.util.appendParamToUrl +import org.matrix.android.sdk.internal.di.MoshiProvider +import java.util.UUID +import javax.inject.Inject + +class JitsiService @Inject constructor( + private val session: Session, + private val rawService: RawService, + private val stringProvider: StringProvider) { + + companion object { + const val JITSI_OPEN_ID_TOKEN_JWT_AUTH = "openidtoken-jwt" + } + + suspend fun createJitsiWidget(roomId: String, withVideo: Boolean): Widget { + // Build data for a jitsi widget + val widgetId: String = WidgetType.Jitsi.preferred + "_" + session.myUserId + "_" + System.currentTimeMillis() + val preferredJitsiDomain = tryOrNull { + rawService.getElementWellknown(session.myUserId) + ?.jitsiServer + ?.preferredDomain + } + val jitsiDomain = preferredJitsiDomain ?: stringProvider.getString(R.string.preferred_jitsi_domain) + val jitsiAuth = getJitsiAuth(jitsiDomain) + val confId = createConferenceId(roomId, jitsiAuth) + + // We use the default element wrapper for this widget + // https://github.com/vector-im/element-web/blob/develop/docs/jitsi-dev.md + // https://github.com/matrix-org/matrix-react-sdk/blob/develop/src/utils/WidgetUtils.ts#L469 + val url = buildString { + append("https://app.element.io/jitsi.html") + appendParamToUrl("confId", confId) + append("#conferenceDomain=\$domain") + append("&conferenceId=\$conferenceId") + append("&isAudioOnly=\$isAudioOnly") + append("&displayName=\$matrix_display_name") + append("&avatarUrl=\$matrix_avatar_url") + append("&userId=\$matrix_user_id") + append("&roomId=\$matrix_room_id") + append("&theme=\$theme") + if (jitsiAuth != null) { + append("&auth=$jitsiAuth") + } + } + val widgetEventContent = mapOf( + "url" to url, + "type" to WidgetType.Jitsi.legacy, + "data" to mapOf( + "conferenceId" to confId, + "domain" to jitsiDomain, + "isAudioOnly" to !withVideo, + "authenticationType" to jitsiAuth + ), + "creatorUserId" to session.myUserId, + "id" to widgetId, + "name" to "jitsi" + ) + + return session.widgetService().createRoomWidget(roomId, widgetId, widgetEventContent) + } + + private fun createConferenceId(roomId: String, jitsiAuth: String?): String { + return if (jitsiAuth == JITSI_OPEN_ID_TOKEN_JWT_AUTH) { + // Create conference ID from room ID + // For compatibility with Jitsi, use base32 without padding. + // More details here: + // https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + roomId.toBase32String(padding = false) + } else { + // Create a random conference ID + // Create a random enough jitsi conference id + // Note: the jitsi server automatically creates conference when the conference + // id does not exist yet + var widgetSessionId = UUID.randomUUID().toString() + if (widgetSessionId.length > 8) { + widgetSessionId = widgetSessionId.substring(0, 7) + } + roomId.substring(1, roomId.indexOf(":") - 1) + widgetSessionId.lowercase(VectorLocale.applicationLocale) + } + } + + private suspend fun getJitsiAuth(jitsiDomain: String): String? { + val request = Request.Builder().url("https://$jitsiDomain/.well-known/element/jitsi").build() + return tryOrNull { + val response = session.getOkHttpClient().newCall(request).await() + val json = response.body?.string() ?: return null + MoshiProvider.providesMoshi().adapter(JitsiWellKnown::class.java).fromJson(json)?.auth + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/call/conference/JitsiWellKnown.kt b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWellKnown.kt new file mode 100644 index 0000000000..b18831f050 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/conference/JitsiWellKnown.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference + +import com.squareup.moshi.Json +import com.squareup.moshi.JsonClass + +@JsonClass(generateAdapter = true) +data class JitsiWellKnown( + @Json(name = "auth") val auth: String +) diff --git a/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt b/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt new file mode 100644 index 0000000000..7e9458841a --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/call/conference/jwt/JitsiJWTFactory.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package im.vector.app.features.call.conference.jwt + +import io.jsonwebtoken.Jwts +import io.jsonwebtoken.SignatureAlgorithm +import io.jsonwebtoken.security.Keys +import javax.inject.Inject + +class JitsiJWTFactory @Inject constructor() { + + /** + * Create a JWT token for jitsi openidtoken-jwt authentication + * See https://github.com/matrix-org/prosody-mod-auth-matrix-user-verification + */ + fun create(jitsiServerDomain: String, + openIdAccessToken: String, + roomId: String, + userAvatarUrl: String, + userDisplayName: String): String { + + // The secret key here is irrelevant, we're only using the JWT to transport data to Prosody in the Jitsi stack. + val key = Keys.secretKeyFor(SignatureAlgorithm.HS256) + val context = mapOf( + "user" to mapOf( + "name" to userDisplayName, + "avatar" to userAvatarUrl + ), + "matrix" to mapOf( + "token" to openIdAccessToken, + "room_id" to roomId + ) + ) + return Jwts.builder() + .setIssuer(jitsiServerDomain) + .setSubject(jitsiServerDomain) + .setAudience("https://$jitsiServerDomain") + // room is not used at the moment, a * works here. + .claim("room", "*") + .claim("context", context) + .signWith(key) + .compact() + } +}