Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes #7758: Fixed JWT token for Jitsi openidtoken-jwt authentication #8341

Merged
merged 1 commit into from
Aug 28, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/7758.bugfix
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fixed JWT token for Jitsi openidtoken-jwt authentication
1 change: 1 addition & 0 deletions vector/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ dependencies {
runtimeOnly(libs.jsonwebtoken.jjwtOrgjson) {
exclude group: 'org.json', module: 'json' //provided by Android natively
}
testImplementation(libs.jsonwebtoken.jjwtOrgjson)
implementation 'commons-codec:commons-codec:1.15'

// MapTiler
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ package im.vector.app.features.call.conference.jwt
import im.vector.app.core.utils.ensureProtocol
import io.jsonwebtoken.Jwts
import io.jsonwebtoken.SignatureAlgorithm
import io.jsonwebtoken.io.Encoders
import io.jsonwebtoken.security.Keys
import org.matrix.android.sdk.api.session.openid.OpenIdToken
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec
import javax.inject.Inject

class JitsiJWTFactory @Inject constructor() {
Expand All @@ -37,7 +40,12 @@ class JitsiJWTFactory @Inject constructor() {
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)
// In the PR https://github.com/jitsi/luajwtjitsi/pull/3 the function `luajwtjitsi.decode` was removed and
// we cannot use random secret keys anymore. But the JWT library `jjwt` doesn't accept the hardcoded key `notused`
// from the module `prosody-mod-auth-matrix-user-verification` since it's too short and thus insecure. So, we
// create a new token using a random key and then re-sign the token manually with the 'weak' key.
val signatureAlgorithm = SignatureAlgorithm.HS256
val key = Keys.secretKeyFor(signatureAlgorithm)
val context = mapOf(
"matrix" to mapOf(
"token" to openIdToken.accessToken,
Expand All @@ -52,7 +60,8 @@ class JitsiJWTFactory @Inject constructor() {
// As per Jitsi token auth, `iss` needs to be set to something agreed between
// JWT generating side and Prosody config. Since we have no configuration for
// the widgets, we can't set one anywhere. Using the Jitsi domain here probably makes sense.
return Jwts.builder()
val token = Jwts.builder()
.setHeaderParam("typ", "JWT")
.setIssuer(jitsiServerDomain)
.setSubject(jitsiServerDomain)
.setAudience(jitsiServerDomain.ensureProtocol())
Expand All @@ -61,5 +70,11 @@ class JitsiJWTFactory @Inject constructor() {
.claim("context", context)
.signWith(key)
.compact()
// Re-sign token with the hardcoded key
val toSign = token.substring(0, token.lastIndexOf('.'))
val mac = Mac.getInstance(signatureAlgorithm.jcaName)
mac.init(SecretKeySpec("notused".toByteArray(), mac.algorithm))
val prosodySignature = Encoders.BASE64URL.encode(mac.doFinal(toSign.toByteArray()))
return "$toSign.$prosodySignature"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
/*
* Copyright (c) 2023 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 com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.matrix.android.sdk.api.session.openid.OpenIdToken
import java.lang.reflect.ParameterizedType
import java.util.Base64
import kotlin.streams.toList

class JitsiJWTFactoryTest {
private val base64Decoder = Base64.getUrlDecoder()
private val moshi = Moshi.Builder().build()
private val stringToString = Types.newParameterizedType(Map::class.java, String::class.java, String::class.java)
private val stringToAny = Types.newParameterizedType(Map::class.java, String::class.java, Any::class.java)
private lateinit var factory: JitsiJWTFactory

@Before
fun init() {
factory = JitsiJWTFactory()
}

@Test
fun `token contains 3 encoded parts`() {
val token = createToken()

val parts = token.split(".")
assertEquals(3, parts.size)
parts.forEach {
assertTrue("Non-empty array", base64Decoder.decode(it).isNotEmpty())
}
}

@Test
fun `token contains unique signature`() {
val signatures = listOf("one", "two").stream()
.map { createToken(it) }
.map { it.split(".")[2] }
.map { base64Decoder.decode(it) }
.toList()

assertEquals(2, signatures.size)
signatures.forEach {
assertEquals(32, it.size)
}
assertFalse("Unique", signatures[0].contentEquals(signatures[1]))
}

@Test
fun `token header contains algorithm`() {
val token = createToken()

assertEquals("HS256", parseTokenHeader(token)["alg"])
}

@Test
fun `token header contains type`() {
val token = createToken()

assertEquals("JWT", parseTokenHeader(token)["typ"])
}

@Test
fun `token body contains subject`() {
val token = createToken()

assertEquals("jitsi-server-domain", parseTokenBody(token)["sub"])
}

@Test
fun `token body contains issuer`() {
val token = createToken()

assertEquals("jitsi-server-domain", parseTokenBody(token)["iss"])
}

@Test
fun `token body contains audience`() {
val token = createToken()

assertEquals("https://jitsi-server-domain", parseTokenBody(token)["aud"])
}

@Test
fun `token body contains room claim`() {
val token = createToken()

assertEquals("*", parseTokenBody(token)["room"])
}

@Test
fun `token body contains matrix data`() {
val token = createToken()

assertEquals(mutableMapOf("room_id" to "room-id", "server_name" to "matrix-server-name", "token" to "matrix-token"), parseMatrixData(token))
}

@Test
fun `token body contains user data`() {
val token = createToken()

assertEquals(mutableMapOf("name" to "user-display-name", "avatar" to "user-avatar-url"), parseUserData(token))
}

private fun createToken(): String {
return createToken("matrix-token")
}

private fun createToken(accessToken: String): String {
val openIdToken = OpenIdToken(accessToken, "matrix-token-type", "matrix-server-name", -1)
return factory.create(openIdToken, "jitsi-server-domain", "room-id", "user-avatar-url", "user-display-name")
}

private fun parseTokenHeader(token: String): Map<String, String> {
return parseTokenPart(token.split(".")[0], stringToString)
}

private fun parseTokenBody(token: String): Map<String, Any> {
return parseTokenPart(token.split(".")[1], stringToAny)
}

private fun parseMatrixData(token: String): Map<*, *> {
return (parseTokenBody(token)["context"] as Map<*, *>)["matrix"] as Map<*, *>
}

private fun parseUserData(token: String): Map<*, *> {
return (parseTokenBody(token)["context"] as Map<*, *>)["user"] as Map<*, *>
}

private fun <T> parseTokenPart(value: String, type: ParameterizedType): T {
val decoded = String(base64Decoder.decode(value))
val adapter: JsonAdapter<T> = moshi.adapter(type)
return adapter.fromJson(decoded)!!
}
}