Skip to content

Commit

Permalink
MBL-1168: PKCE class (#1943)
Browse files Browse the repository at this point in the history
  • Loading branch information
Arkariang committed Feb 6, 2024
1 parent b9634bf commit dbc59a7
Show file tree
Hide file tree
Showing 3 changed files with 305 additions and 2 deletions.
165 changes: 165 additions & 0 deletions app/src/main/java/com/kickstarter/libs/utils/CodeVerifier.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
package com.kickstarter.libs.utils

import android.util.Base64
import timber.log.Timber
import java.io.UnsupportedEncodingException
import java.security.MessageDigest
import java.security.NoSuchAlgorithmException
import java.security.SecureRandom
import java.util.regex.Pattern

/**
* Generates code verifiers and challenges for PKCE exchange.
*
* @see [Proof Key for Code Exchange by OAuth Public Clients](https://datatracker.ietf.org/doc/html/rfc7636)
*/
class CodeVerifier {
companion object {
/**
* The minimum permitted length for a code verifier.
*
* @see "Proof Key for Code Exchange by OAuth Public Clients"
*/
const val MIN_CODE_VERIFIER_LENGTH = 43

/**
* The maximum permitted length for a code verifier.
*
* @see "Proof Key for Code Exchange by OAuth Public Clients"
*/
const val MAX_CODE_VERIFIER_LENGTH = 128

/**
* The default entropy (in bytes) used for the code verifier.
*/
const val DEFAULT_CODE_VERIFIER_ENTROPY = 64

/**
* The minimum permitted entropy (in bytes) for use with
* [.generateRandomCodeVerifier].
*/
const val MIN_CODE_VERIFIER_ENTROPY = 32

/**
* The maximum permitted entropy (in bytes) for use with
* [.generateRandomCodeVerifier].
*/
const val MAX_CODE_VERIFIER_ENTROPY = 96

/**
* Base64 encoding settings used for generated code verifiers.
*/
private const val PKCE_BASE64_ENCODE_SETTINGS: Int =
Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE

/**
* Regex for legal code verifier strings, as defined in the spec.
*
* @see "Proof Key for Code Exchange by OAuth Public Clients"
*/
private val REGEX_CODE_VERIFIER: Pattern =
Pattern.compile("^[0-9a-zA-Z\\-._~]{43,128}$")

/**
* SHA-256 based code verifier challenge method.
*
* @see "Proof Key for Code Exchange by OAuth Public Clients"
*/
const val CODE_CHALLENGE_METHOD_S256 = "S256"

/**
* Plain-text code verifier challenge method. This is only used by AppAuth for Android if
* SHA-256 is not supported on this platform.
*
* @see "Proof Key for Code Exchange by OAuth Public Clients"
*/
const val CODE_CHALLENGE_METHOD_PLAIN = "plain"

const val ERROR_TOO_SHORT = "codeVerifier length is shorter than allowed by the PKCE specification"

const val ERROR_TOO_LONG = "codeVerifier length is longer than allowed by the PKCE specification"

const val ERROR_DO_NOT_MATCH = "codeVerifier string does not match legal code verifier strings REGEX"

/**
* Throws an IllegalArgumentException if the provided code verifier is invalid.
*
* @see [4.1. Client Creates a Code Verifier](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)
*/
fun checkCodeVerifier(codeVerifier: String) {
require(
MIN_CODE_VERIFIER_LENGTH <= codeVerifier.length
) { ERROR_TOO_SHORT }
require(
codeVerifier.length <= MAX_CODE_VERIFIER_LENGTH
) { ERROR_TOO_LONG }
require(
REGEX_CODE_VERIFIER.matcher(codeVerifier).matches()
) { ERROR_DO_NOT_MATCH }
}

/**
* Generates a random code verifier string using the provided entropy source and the specified
* number of bytes of entropy.
*/
/**
* Generates a random code verifier string using [SecureRandom] as the source of
* entropy, with the default entropy quantity as defined by
* [.DEFAULT_CODE_VERIFIER_ENTROPY].
*
* @see [Client Creates a Code Verifier](https://datatracker.ietf.org/doc/html/rfc7636#section-4.1)
*/
fun generateRandomCodeVerifier(
entropySource: SecureRandom = SecureRandom(),
entropyBytes: Int = DEFAULT_CODE_VERIFIER_ENTROPY
): String {
require(
MIN_CODE_VERIFIER_ENTROPY <= entropyBytes
) { "entropyBytes is less than the minimum permitted" }
require(
entropyBytes <= MAX_CODE_VERIFIER_ENTROPY
) { "entropyBytes is greater than the maximum permitted" }

val randomBytes = ByteArray(entropyBytes)
entropySource.nextBytes(randomBytes)
return Base64.encodeToString(randomBytes, PKCE_BASE64_ENCODE_SETTINGS)
}

/**
* Produces a challenge from a code verifier, using SHA-256 as the challenge method if the
* system supports it (all Android devices _should_ support SHA-256), and falls back
* to the [&quot;plain&quot; challenge type][CODE_CHALLENGE_METHOD_PLAIN] if
* unavailable.
*
* See [Example for the S256 code_challenge_method](https://datatracker.ietf.org/doc/html/rfc7636#appendix-B)
*/
fun generateCodeChallenge(codeVerifier: String): String {
return try {
val sha256Digester = MessageDigest.getInstance("SHA-256")
sha256Digester.update(codeVerifier.toByteArray(charset("ISO_8859_1")))
val digestBytes = sha256Digester.digest()
Base64.encodeToString(digestBytes, PKCE_BASE64_ENCODE_SETTINGS)
} catch (e: NoSuchAlgorithmException) {
Timber.w("SHA-256 is not supported on this device! Using plain challenge", e)
codeVerifier
} catch (e: UnsupportedEncodingException) {
Timber.e("ISO-8859-1 encoding not supported on this device!", e)
throw IllegalStateException("ISO-8859-1 encoding not supported", e)
}
}

private val codeVerifierChallengeMethod: String
/**
* Returns the challenge method utilized on this system: typically
* [SHA-256][CODE_CHALLENGE_METHOD_S256] if supported by
* the system, [plain][CODE_CHALLENGE_METHOD_PLAIN] otherwise.
*/
get() = try {
MessageDigest.getInstance("SHA-256")
// no exception, so SHA-256 is supported
CODE_CHALLENGE_METHOD_S256
} catch (e: NoSuchAlgorithmException) {
CODE_CHALLENGE_METHOD_PLAIN
}
}
}
13 changes: 11 additions & 2 deletions app/src/main/java/com/kickstarter/ui/activities/OAuthActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import androidx.lifecycle.lifecycleScope
import com.kickstarter.libs.utils.CodeVerifier
import com.kickstarter.libs.utils.TransitionUtils
import com.kickstarter.models.chrome.ChromeTabsHelperActivity
import com.kickstarter.ui.IntentKey
Expand All @@ -17,13 +18,21 @@ class OAuthActivity : AppCompatActivity() {

private lateinit var helper: ChromeTabsHelperActivity.CustomTabSessionAndClientHelper

val redirectUri = "ksrauth2://authorize"
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

setUpConnectivityStatusCheck(lifecycle)

// TODO MBL-1168 the url will be retrieved from the VM,on MBL-1168 alongside PKCE paramenters
val uri = Uri.parse("https://www.kickstarter.com/oauth/authorizations")
// TODO: Will be moved to VM all the URI parameters building on MBL-1169
val codeVerifier = CodeVerifier.generateRandomCodeVerifier(entropyBytes = CodeVerifier.MAX_CODE_VERIFIER_ENTROPY)
val authParams = mapOf(
"redirect_uri" to redirectUri,
"response_type" to "code",
"code_challenge" to CodeVerifier.generateCodeChallenge(codeVerifier), // Set the code challenge
"code_challenge_method" to "S256"
).map { (k, v) -> "${(k)}=$v" }.joinToString("&")
val uri = Uri.parse("https://www.kickstarter.com/oauth/authorizations?$authParams")

// BindCustomTabsService, obtain CustomTabsClient and Client, listens to navigation events
helper = ChromeTabsHelperActivity.CustomTabSessionAndClientHelper(this, uri) {
Expand Down
129 changes: 129 additions & 0 deletions app/src/test/java/com/kickstarter/libs/utils/CodeVerifierTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.kickstarter.libs.utils

import com.kickstarter.KSRobolectricTestCase
import com.kickstarter.libs.utils.CodeVerifier.Companion.ERROR_DO_NOT_MATCH
import com.kickstarter.libs.utils.CodeVerifier.Companion.ERROR_TOO_LONG
import com.kickstarter.libs.utils.CodeVerifier.Companion.ERROR_TOO_SHORT
import com.kickstarter.libs.utils.CodeVerifier.Companion.MAX_CODE_VERIFIER_ENTROPY
import com.kickstarter.libs.utils.CodeVerifier.Companion.MIN_CODE_VERIFIER_ENTROPY
import com.kickstarter.libs.utils.CodeVerifier.Companion.generateCodeChallenge
import com.kickstarter.libs.utils.CodeVerifier.Companion.generateRandomCodeVerifier
import org.junit.Assert.assertThrows
import org.junit.Test

class CodeVerifierTest : KSRobolectricTestCase() {

@Test
fun checkCodeVerifier_tooShort_throwsException() {
val codeVerifier = createString(CodeVerifier.MIN_CODE_VERIFIER_LENGTH - 1)
val exception = assertThrows(IllegalArgumentException::class.java) {
CodeVerifier.checkCodeVerifier(codeVerifier)
}

assertEquals(exception.message, ERROR_TOO_SHORT)
}

@Test
fun checkCodeVerifier_tooLong_throwsException() {
val codeVerifier = createString(CodeVerifier.MAX_CODE_VERIFIER_LENGTH + 1)
val exception = assertThrows(IllegalArgumentException::class.java) {
CodeVerifier.checkCodeVerifier(codeVerifier)
}

assertEquals(exception.message, ERROR_TOO_LONG)
}

@Test
fun checkCodeVerifier_languageSentence_notValid() {
val sentence = "Hello, world. I am a string. Hello, world. I am a string."
val exception = assertThrows(IllegalArgumentException::class.java) {
CodeVerifier.checkCodeVerifier(sentence)
}

assertEquals(exception.message, ERROR_DO_NOT_MATCH)
}

@Test
fun generateRandomCodeVerifier_tooLittleEntropy_throwsException() {
val exception = assertThrows(IllegalArgumentException::class.java) {
CodeVerifier.generateRandomCodeVerifier(
entropyBytes = MIN_CODE_VERIFIER_ENTROPY - 1
)
}
assertEquals(exception.message, "entropyBytes is less than the minimum permitted")
}

@Test
fun generateRandomCodeVerifier_tooMuchEntropy_throwsException() {
val exception = assertThrows(IllegalArgumentException::class.java) {
generateRandomCodeVerifier(
entropyBytes = MAX_CODE_VERIFIER_ENTROPY + 1
)
}
assertEquals(exception.message, "entropyBytes is greater than the maximum permitted")
}

/**
* Generates random String with @param length
*/
private fun createString(length: Int): String {
val strChars = CharArray(length)
for (i in strChars.indices) {
strChars[i] = 'a'
}
return String(strChars)
}

@Test
fun givenSentence_generateCodeChallengeWithSHA256Hash() {
// - Use https://oauth.school/exercise/refresh/ to obtain givenCodeChallenge
val givenCodeChallenge = "wcaGQDnzgCSNMKc1Jcg1FCfH-0aNWLexAF8-NyegQqE"

val givenCodeVerifier = "Hello, world. I am a string."
val generatedChallenge = generateCodeChallenge(givenCodeVerifier)
assertEquals(givenCodeChallenge, generatedChallenge)
}

@Test
fun givenCodeVerifierMinEntropy_generateCodeChallengeWithSHA256Hash() {
// - [givenVerifier] generated using generateRandomCodeVerifier(MIN_CODE_VERIFIER_ENTROPY)
// - Use https://oauth.school/exercise/refresh/ to obtain [givenCodeChallenge]
val givenVerifier = "HaTkldnGaT3PcENU5EAY8rtDDNIikQSvBXFFEYBa3MA"
val codeChallenge = generateCodeChallenge(givenVerifier)

val givenCodeChallenge = "khL4OfhvX-uphctb0gMMmE_O5xNX-MfjMPvHxAbpsZk"
assertEquals(codeChallenge, givenCodeChallenge)
}

@Test
fun givenCodeVerifierDefaultEntropy_generateCodeChallengeWithSHA256Hash() {
// - [givenVerifier] generated using generateRandomCodeVerifier(DEFAULT_CODE_VERIFIER_ENTROPY)
// - Use https://oauth.school/exercise/refresh/ to obtain [givenCodeChallenge]
val givenVerifier = "BAxigyqguFpLKXnGiqc0iabt-Epr3YL-wJvPL0CfDSTGB45_jOwrSrFa0_T4FK5y9amhhYQAk-Bkr2zpD8Gpxw"
val codeChallenge = generateCodeChallenge(givenVerifier)

val givenCodeChallenge = "DcimCRjKEAmp3cl0mFMc12oCsHfN931jzpot2HCkBNo"
assertEquals(codeChallenge, givenCodeChallenge)
}
@Test
fun givenCodeVerifierMaxEntropy_generateCodeChallengeWithSHA256Hash() {
// - [givenVerifier] generated using generateRandomCodeVerifier(MAX_CODE_VERIFIER_ENTROPY)
// - Use https://oauth.school/exercise/refresh/ to obtain [givenCodeChallenge]
val givenVerifier = "YfZIzxXTx7Dc58fLNl2uO6cRzWSevpEPeKSXGBFhN8fisOA3XjV_AF0Buz2ZjYxu7S30j15dlzPCzbtHZEHCqAo94YcaZV4JNfJYWCi1jWavu8UUSdCw9n6Y3dinTRfe"
val codeChallenge = generateCodeChallenge(givenVerifier)

val givenCodeChallenge = "cJXeRcpWhJLlsCD8DG3OLkLtjGF8yip6Hf0Jd560Pgg"
assertEquals(codeChallenge, givenCodeChallenge)
}

@Test
fun generateSeveralCodeVerifiers_checkDoNotMatch() {
val codeVerifierA = generateRandomCodeVerifier()
val codeVerifierB = generateRandomCodeVerifier()
val codeVerifierD = generateRandomCodeVerifier()

assertTrue(codeVerifierA != codeVerifierB)
assertTrue(codeVerifierA != codeVerifierD)
assertTrue(codeVerifierB != codeVerifierD)
}
}

0 comments on commit dbc59a7

Please sign in to comment.