Skip to content

Commit

Permalink
Implement DPoP
Browse files Browse the repository at this point in the history
ref DEV-1446
  • Loading branch information
louischan-oursky committed Jul 23, 2024
2 parents e2669cb + 5f26918 commit fa608c4
Show file tree
Hide file tree
Showing 13 changed files with 287 additions and 32 deletions.
6 changes: 4 additions & 2 deletions sdk/src/main/java/com/oursky/authgear/AuthenticateOptions.kt
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,8 @@ data class AuthenticateOptions @JvmOverloads constructor(

internal fun AuthenticateOptions.toRequest(
isSsoEnabled: Boolean,
preAuthenticatedURLEnabled: Boolean
preAuthenticatedURLEnabled: Boolean,
dpopJKT: String?
): OidcAuthenticationRequest {
return OidcAuthenticationRequest(
redirectUri = this.redirectUri,
Expand All @@ -93,6 +94,7 @@ internal fun AuthenticateOptions.toRequest(
colorScheme = this.colorScheme,
wechatRedirectURI = this.wechatRedirectURI,
page = this.page,
authenticationFlowGroup = this.authenticationFlowGroup
authenticationFlowGroup = this.authenticationFlowGroup,
dpopJKT = dpopJKT
)
}
16 changes: 13 additions & 3 deletions sdk/src/main/java/com/oursky/authgear/Authgear.kt
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.oursky.authgear.app2app.App2AppOptions
import com.oursky.authgear.data.assetlink.AssetLinkRepoHttp
import com.oursky.authgear.data.key.KeyRepoKeystore
import com.oursky.authgear.data.oauth.OAuthRepoHttp
import com.oursky.authgear.dpop.DefaultDPoPProvider
import kotlinx.coroutines.*
import java.util.*

Expand All @@ -38,6 +39,14 @@ constructor(
internal val core: AuthgearCore

init {
val name = name ?: "default"
val keyRepo = KeyRepoKeystore()
val sharedStorage = PersistentInterAppSharedStorage(application)
val dpopProvider = DefaultDPoPProvider(
namespace = name,
keyRepo = keyRepo,
sharedStorage = sharedStorage,
)
this.core = AuthgearCore(
this,
application,
Expand All @@ -46,12 +55,13 @@ constructor(
isSsoEnabled,
preAuthenticatedURLEnabled,
app2AppOptions,
dpopProvider,
tokenStorage,
uiImplementation,
PersistentContainerStorage(application),
PersistentInterAppSharedStorage(application),
OAuthRepoHttp(),
KeyRepoKeystore(),
sharedStorage,
OAuthRepoHttp(dPoPProvider = dpopProvider),
keyRepo,
AssetLinkRepoHttp(),
name
)
Expand Down
10 changes: 7 additions & 3 deletions sdk/src/main/java/com/oursky/authgear/AuthgearCore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.oursky.authgear.app2app.App2AppOptions
import com.oursky.authgear.data.assetlink.AssetLinkRepo
import com.oursky.authgear.data.key.KeyRepo
import com.oursky.authgear.data.oauth.OAuthRepo
import com.oursky.authgear.dpop.DPoPProvider
import com.oursky.authgear.net.toQueryParameter
import com.oursky.authgear.oauth.OidcAuthenticationRequest
import com.oursky.authgear.oauth.OidcTokenRequest
Expand Down Expand Up @@ -58,14 +59,15 @@ internal class AuthgearCore(
private val isSsoEnabled: Boolean,
private val preAuthenticatedURLEnabled: Boolean,
private val app2AppOptions: App2AppOptions,
private val dPoPProvider: DPoPProvider,
private val tokenStorage: TokenStorage,
private val uiImplementation: UIImplementation,
private val storage: ContainerStorage,
private val sharedStorage: InterAppSharedStorage,
private val oauthRepo: OAuthRepo,
private val keyRepo: KeyRepo,
private val assetLinkRepo: AssetLinkRepo,
name: String? = null
private val name: String
) {
companion object {
@Suppress("unused")
Expand Down Expand Up @@ -145,7 +147,6 @@ internal class AuthgearCore(
val challenge: String
)

private val name = name ?: "default"
private var isInitialized = false
private var refreshToken: String? = null
var accessToken: String? = null
Expand Down Expand Up @@ -285,7 +286,10 @@ internal class AuthgearCore(
verifier: Verifier = generateCodeVerifier()
): AuthenticationRequest {
requireIsInitialized()
val request = options.toRequest(this.isSsoEnabled, this.preAuthenticatedURLEnabled)
val request = options.toRequest(
this.isSsoEnabled,
this.preAuthenticatedURLEnabled,
dpopJKT = dPoPProvider.computeJKT())
val authorizeUri = authorizeEndpoint(this.clientId, request, verifier)
return AuthenticationRequest(authorizeUri, request.redirectUri, verifier)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,9 @@ internal interface InterAppSharedStorage {
fun getDeviceSecret(namespace: String): String?
fun deleteDeviceSecret(namespace: String)

fun setDPoPKeyId(namespace: String, keyId: String)
fun getDPoPKeyId(namespace: String): String?
fun deleteDPoPKeyId(namespace: String)

fun onLogout(namespace: String)
}
}
44 changes: 42 additions & 2 deletions sdk/src/main/java/com/oursky/authgear/JWK.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
package com.oursky.authgear

import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import java.math.BigInteger
import java.security.MessageDigest
import java.security.PublicKey
import java.security.interfaces.RSAPublicKey
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
import kotlin.math.ceil

internal data class JWK(
val kid: String,
Expand All @@ -24,11 +31,44 @@ internal fun JWK.toJsonObject(): JsonObject {
return JsonObject(m)
}

@OptIn(ExperimentalEncodingApi::class)
internal fun JWK.toSHA256Thumbprint(): String {
val p = mutableMapOf<String, String>()
when (kty) {
"RSA" -> {
// required members for an RSA public key are e, kty, n
// in lexicographic order
p["e"] = e
p["kty"] = kty
p["n"] = n
}
else -> {
throw NotImplementedError("unknown kty")
}
}
val jsonBytes = Json.encodeToString(p).toByteArray()
val digest: MessageDigest = MessageDigest.getInstance("SHA-256")
val hashBytes = digest.digest(jsonBytes)

return Base64.UrlSafe.encode(hashBytes).removeSuffix("=")
}

internal fun BigInteger.toUnsignedByteArray(): ByteArray {
// BigInteger always include a bit to represent the sign
// So the array length is ceil((this.bitLength() + 1)/8)
// This sign bit causes an extra byte to be added to the ByteArray when bitLength is just divisible by 8
// We want to exclude that extra byte in some cases, such as sending the bytes in a JWK as Base64urlUInt
val expectedLength = ceil(this.bitLength() / 8.0).toInt()
val bytes = this.toByteArray()
val startIdx = bytes.size - expectedLength
return bytes.sliceArray(IntRange(startIdx, bytes.size - 1))
}

internal fun publicKeyToJWK(kid: String, publicKey: PublicKey): JWK {
val rsaPublicKey = publicKey as RSAPublicKey
return JWK(
kid = kid,
n = base64UrlEncode(rsaPublicKey.modulus.toByteArray()),
e = base64UrlEncode(rsaPublicKey.publicExponent.toByteArray())
n = base64UrlEncode(rsaPublicKey.modulus.toUnsignedByteArray()),
e = base64UrlEncode(rsaPublicKey.publicExponent.toUnsignedByteArray())
)
}
39 changes: 32 additions & 7 deletions sdk/src/main/java/com/oursky/authgear/JWT.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import java.time.Instant
internal enum class JWTHeaderType(val value: String) {
ANONYMOUS("vnd.authgear.anonymous-request"),
BIOMETRIC("vnd.authgear.biometric-request"),
APP2APP("vnd.authgear.app2app-request")
APP2APP("vnd.authgear.app2app-request"),
DPOPJWT("dpop+jwt")
}

internal data class JWTHeader(
Expand All @@ -33,9 +34,12 @@ internal fun JWTHeader.toJsonObject(): JsonObject {
internal data class JWTPayload(
val iat: Long,
val exp: Long,
val challenge: String,
val action: String,
val deviceInfo: DeviceInfoRoot?
val jti: String? = null,
val htm: String? = null,
val htu: String? = null,
val challenge: String? = null,
val action: String? = null,
val deviceInfo: DeviceInfoRoot? = null
) {
constructor(now: Instant, challenge: String, action: String, deviceInfo: DeviceInfoRoot? = null) : this(
iat = now.epochSecond,
Expand All @@ -44,17 +48,38 @@ internal data class JWTPayload(
action = action,
deviceInfo = deviceInfo
)

constructor(now: Instant, jti: String, htu: String, htm: String) : this(
iat = now.epochSecond,
exp = now.epochSecond + 60,
jti = jti,
htu = htu,
htm = htm
)
}

internal fun JWTPayload.toJsonObject(): JsonObject {
val m = mutableMapOf<String, JsonElement>()
m["iat"] = JsonPrimitive(iat)
m["exp"] = JsonPrimitive(exp)
m["challenge"] = JsonPrimitive(challenge)
m["action"] = JsonPrimitive(action)
if (deviceInfo != null) {
challenge?.let {
m["challenge"] = JsonPrimitive(challenge)
}
action?.let {
m["action"] = JsonPrimitive(action)
}
deviceInfo?.let {
m["device_info"] = deviceInfo.toJsonObject()
}
jti?.let {
m["jti"] = JsonPrimitive(jti)
}
htu?.let {
m["htu"] = JsonPrimitive(htu)
}
htm?.let {
m["htm"] = JsonPrimitive(htm)
}
return JsonObject(m)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal class PersistentInterAppSharedStorage(val context: Context) : InterAppS
private const val LOGTAG = "Authgear"
private const val IDToken = "idToken"
private const val DeviceSecret = "deviceSecret"
private const val DPoPKeyID = "dpopKeyId"
}

private val masterKey = MasterKey.Builder(context)
Expand Down Expand Up @@ -84,9 +85,22 @@ internal class PersistentInterAppSharedStorage(val context: Context) : InterAppS
deleteItem(namespace, DeviceSecret)
}

override fun setDPoPKeyId(namespace: String, keyId: String) {
setItem(namespace, DPoPKeyID, keyId)
}

override fun getDPoPKeyId(namespace: String): String? {
return getItem(namespace, DPoPKeyID)
}

override fun deleteDPoPKeyId(namespace: String) {
deleteItem(namespace, DPoPKeyID)
}

override fun onLogout(namespace: String) {
deleteDeviceSecret(namespace)
deleteIDToken(namespace)
deleteDPoPKeyId(namespace)
}

private fun handleBackupProblem(e: Exception, namespace: String): Boolean {
Expand Down Expand Up @@ -139,4 +153,4 @@ internal class PersistentInterAppSharedStorage(val context: Context) : InterAppS
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
}
}
3 changes: 3 additions & 0 deletions sdk/src/main/java/com/oursky/authgear/data/key/KeyRepo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,7 @@ internal interface KeyRepo {

fun generateApp2AppDeviceKey(kid: String): KeyPair
fun getApp2AppDeviceKey(kid: String): KeyPair?

fun generateDPoPKey(kid: String): KeyPair
fun getDPoPKey(kid: String): KeyPair?
}
36 changes: 36 additions & 0 deletions sdk/src/main/java/com/oursky/authgear/data/key/KeyRepoKeystore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,40 @@ internal class KeyRepoKeystore : KeyRepo {

return KeyPair(entry.certificate.publicKey, entry.privateKey)
}

@RequiresApi(api = Build.VERSION_CODES.M)
override fun generateDPoPKey(kid: String): KeyPair {
val alias = formatApp2AppDeviceKeyAlias(kid)
val builder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_SIGN or KeyProperties.PURPOSE_VERIFY
)
.setDigests(KeyProperties.DIGEST_SHA256)
.setSignaturePaddings(KeyProperties.SIGNATURE_PADDING_RSA_PKCS1)
.setUserAuthenticationRequired(false)

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
builder.setInvalidatedByBiometricEnrollment(false)
}
val spec = builder.build()
val kpg =
KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA, "AndroidKeyStore")
kpg.initialize(spec)
return kpg.generateKeyPair()
}

@RequiresApi(api = Build.VERSION_CODES.M)
override fun getDPoPKey(kid: String): KeyPair? {
val alias = formatApp2AppDeviceKeyAlias(kid)

val ks = KeyStore.getInstance("AndroidKeyStore")
ks.load(null)

val entry = ks.getEntry(alias, null)
if (entry !is KeyStore.PrivateKeyEntry) {
return null
}

return KeyPair(entry.certificate.publicKey, entry.privateKey)
}
}
Loading

0 comments on commit fa608c4

Please sign in to comment.