Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions app/src/main/java/com/chargebee/example/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,42 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener {
builder.show()
}

/*
* Example: configure the SDK with a mobile token fetched from your backend instead of
* embedding a publishable API key in the app. The tokenProvider is invoked now and again
* whenever a request is rejected with a 401, so the SDK can refresh the token.
*/
private fun configureWithMobileToken() {
Chargebee.configure(
site = "cb-abc-test",
sdkKey = "SDK-KEY",
packageName = this.packageName,
tokenProvider = { completion ->
// Ask your backend for a fresh mobile token (it mints one via
// `create_mobile_token`), then hand the raw token back to the SDK.
// Pass null if the token could not be obtained.
fetchMobileToken(completion)
}
) {
when (it) {
is ChargebeeResult.Success -> {
Log.i(javaClass.simpleName, "Configured with mobile token")
}
is ChargebeeResult.Error -> {
Log.e(javaClass.simpleName, "Configuration failed: ${it.exp.message}")
}
}
}
}

/*
* Stand-in for the call to your own backend that returns a Chargebee mobile token.
* Replace the body with a real network request to your server.
*/
private fun fetchMobileToken(completion: (String?) -> Unit) {
completion("cb_mob_replace_with_token_from_your_backend")
Comment on lines +199 to +227

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Make the mobile-token example functional before merge.

fetchMobileToken always returns a placeholder token. Any request using configureWithMobileToken() will fail authentication, including the one-time refresh retry. The visible Configure flow still uses publishableApiKey, so the new path is not integrated into the example. Replace the placeholder with an asynchronous backend request and invoke this configuration path from the example flow.

As per path instructions, this is a functionality-breaking issue that must be resolved before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/src/main/java/com/chargebee/example/MainActivity.kt` around lines 199 -
227, Make configureWithMobileToken functional by replacing fetchMobileToken’s
placeholder completion with an asynchronous request to the example backend that
returns a real mobile token, passing null on failure and completing exactly
once. Update the visible Configure flow to invoke configureWithMobileToken
instead of the publishableApiKey path, while preserving the existing
configuration result handling and one-time token refresh behavior.

Source: Path instructions

}

private fun getProductIdFromCustomer() {
val dialog = Dialog(this)
dialog.setContentView(R.layout.dialog_input_layout)
Expand Down
115 changes: 112 additions & 3 deletions chargebee/src/main/java/com/chargebee/android/Chargebee.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,17 @@ import com.chargebee.android.resources.PlanResource
import com.chargebee.android.resources.SubscriptionResource
import okhttp3.Credentials

/**
* Closure that returns a fresh mobile token from the backend.
* The SDK invokes it during [Chargebee.configure] and whenever a request fails with a 401.
* The provided callback must be invoked with a token, or with null when one cannot be obtained.
*/
typealias CBMobileTokenProvider = (completion: (String?) -> Unit) -> Unit

object Chargebee {
var site: String = ""
var publishableApiKey: String = ""
var encodedApiKey: String = ""
private var encodedPublishableApiKey: String = ""
var sdkKey: String = ""
var baseUrl: String = ""
var allowErrorLogging: Boolean = true
Expand All @@ -37,6 +44,23 @@ object Chargebee {
const val platform: String = "Android"
const val sdkVersion: String = "2.0.0-beta-5"
const val limit: String = "100"

/*
* Mobile token auth. When a token is present the SDK sends it as the Authorization header
* instead of the publishable key. If empty, we fall back to using publishable-key.
*/
var mobileToken: String = ""
var tokenProvider: CBMobileTokenProvider? = null

val encodedMobileToken: String
get() = if (mobileToken.isNotEmpty()) Credentials.basic(mobileToken, "") else ""

/*
* The Authorization header the SDK sends on every request: the mobile token when one is configured,
* otherwise the publishable key. Resolved lazily so the value picks up a refreshed token.
*/
val encodedApiKey: String
get() = if (mobileToken.isNotEmpty()) encodedMobileToken else encodedPublishableApiKey
private const val PLAY_STORE_SUBSCRIPTION_URL =
"https://play.google.com/store/account/subscriptions"
private const val SUBSCRIPTION_URL =
Expand All @@ -53,7 +77,9 @@ object Chargebee {
this.applicationId = packageName
this.publishableApiKey = publishableApiKey
this.site = site
this.encodedApiKey = Credentials.basic(publishableApiKey, "")
this.encodedPublishableApiKey = Credentials.basic(publishableApiKey, "")
this.mobileToken = ""
this.tokenProvider = null
this.baseUrl = "https://${site}.chargebee.com/api/"
this.allowErrorLogging = allowErrorLogging
this.sdkKey = sdkKey
Expand Down Expand Up @@ -89,7 +115,9 @@ object Chargebee {
this.applicationId = packageName
this.publishableApiKey = publishableApiKey
this.site = site
this.encodedApiKey = Credentials.basic(publishableApiKey, "")
this.encodedPublishableApiKey = Credentials.basic(publishableApiKey, "")
this.mobileToken = ""
this.tokenProvider = null
this.baseUrl = "https://${site}.chargebee.com/api/"
this.allowErrorLogging = allowErrorLogging
this.sdkKey = sdkKey
Expand All @@ -115,6 +143,87 @@ object Chargebee {
}
}

/*
* Configure the SDK using a mobile token instead of a publishable API key. The [tokenProvider] is
* invoked to obtain a token from the merchant's backend, both now and whenever a request is
* rejected with a 401 (expired/revoked token).
*/
fun configure(
site: String,
sdkKey: String = "",
packageName: String = "",
allowErrorLogging: Boolean = true,
tokenProvider: CBMobileTokenProvider,
completion: (ChargebeeResult<Any>) -> Unit
) {
this.site = site
this.publishableApiKey = ""
this.encodedPublishableApiKey = ""
this.baseUrl = "https://${site}.chargebee.com/api/"
this.allowErrorLogging = allowErrorLogging
this.sdkKey = sdkKey
this.applicationId = packageName
this.tokenProvider = tokenProvider

Comment on lines +159 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Chargebee.kt relevant declarations and methods ---'
rg -n -C 8 'mobileToken|encodedApiKey|configure|refreshMobileToken|tokenProvider|publishableApiKey|baseUrl' chargebee/src/main/java/com/chargebee/android/Chargebee.kt
printf '%s\n' '--- related usages ---'
rg -n -C 3 'encodedApiKey|mobileToken|refreshMobileToken|CBMobileTokenProvider' chargebee/src test* 2>/dev/null || true
printf '%s\n' '--- file outline ---'
ast-grep outline chargebee/src/main/java/com/chargebee/android/Chargebee.kt 2>/dev/null || true

Repository: chargebee/chargebee-android

Length of output: 31015


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- MobileTokenAuthenticator ---'
cat -n chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt
printf '%s\n' '--- bounded interleaving verifier ---'
python3 - <<'PY'
import base64

state = {
    "site": "",
    "mobileToken": "",
    "tokenProvider": None,
    "encodedPublishableApiKey": "",
}

def configure_token(site, provider):
    state["site"] = site
    state["encodedPublishableApiKey"] = ""
    state["tokenProvider"] = provider
    # The implementation calls refreshMobileToken, which captures provider.
    return provider

def callback(provider, token):
    # Exact behavior of Chargebee.refreshMobileToken's callback.
    if token:
        state["mobileToken"] = token
        return True
    return False

def encoded_api_key():
    token = state["mobileToken"]
    if token:
        raw = f"{token}:".encode()
        return "Basic " + base64.b64encode(raw).decode()
    return state["encodedPublishableApiKey"]

provider_a = lambda: None
provider_b = lambda: None

captured_a = configure_token("site-a", provider_a)
captured_b = configure_token("site-b", provider_b)
assert state["site"] == "site-b"
assert captured_a is provider_a and captured_b is provider_b

callback(captured_a, "token-a")
assert state["mobileToken"] == "token-a"
print({
    "current_site": state["site"],
    "token_after_old_callback": state["mobileToken"],
    "authorization_header": encoded_api_key(),
    "stale_token_selected": state["site"] == "site-b" and state["mobileToken"] == "token-a",
})
PY

Repository: chargebee/chargebee-android

Length of output: 2567


Sensitive Data Exposure (CWE-362): Concurrent Execution using Shared Resource with Improper Synchronization ('Race Condition')

Reachability: Internal · Exploitability: Moderate

Scope token refresh results to their originating configuration.

An older provider callback can overwrite mobileToken after a new configure call. Requests can then send the previous tenant's token to the new site's endpoint. The token-provider overload also does not clear the existing token before refresh. Reject stale refresh and authentication callbacks with a configuration generation, and add a delayed-provider regression test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@chargebee/src/main/java/com/chargebee/android/Chargebee.kt` around lines 159
- 167, Scope token refresh and authentication callbacks to the active
configuration so callbacks from an older configure call cannot update
mobileToken or authenticate requests for the new site. Add a configuration
generation/version, capture it when starting each refresh, and ignore callbacks
whose generation no longer matches; also clear mobileToken before the
token-provider overload refreshes. Add a delayed-provider regression test
covering reconfiguration and stale callback delivery.

Source: Path instructions

refreshMobileToken { success ->
if (!success) {
completion(
ChargebeeResult.Error(
exp = CBException(
error = ErrorDetail(
message = "Unable to fetch a mobile token from the token provider",
apiErrorCode = "401",
httpStatusCode = 401
)
)
)
)
return@refreshMobileToken
}
// Nothing to verify without an SDK key; the environment is ready.
if (TextUtils.isEmpty(sdkKey)) {
completion(ChargebeeResult.Success("Environment Setup Completed"))
return@refreshMobileToken
}
val auth = Auth(sdkKey, applicationId, appName, channel)
CBAuthentication.authenticate(auth) {
when (it) {
is ChargebeeResult.Success -> {
val response = it.data as CBAuthResponse
this.version = response.in_app_detail.product_catalog_version
this.applicationId = response.in_app_detail.app_id
this.appName = response.in_app_detail.app_name
completion(ChargebeeResult.Success(response))
}
is ChargebeeResult.Error -> {
this.version = CatalogVersion.Unknown.value
completion(it)
}
}
}
}
}

/*
* Fetches a fresh token from the merchant-supplied provider and stores it. Invokes [completion]
* with false when no provider is configured or the provider returns an empty token.
*/
fun refreshMobileToken(completion: (Boolean) -> Unit) {
val provider = tokenProvider
if (provider == null) {
completion(false)
return
}
provider { token ->
if (!token.isNullOrEmpty()) {
this.mobileToken = token
completion(true)
} else {
completion(false)
}
}
}

/* Get the subscription details from chargebee system */
@Throws(InvalidRequestException::class, OperationFailedException::class)
fun retrieveSubscription(subscriptionId: String, completion: (ChargebeeResult<Any>) -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.chargebee.android.network

import com.chargebee.android.Chargebee
import okhttp3.Authenticator
import okhttp3.Request
import okhttp3.Response
import okhttp3.Route
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit

/**
* Handles expired/revoked mobile tokens: when a request authenticated with a mobile token comes back
* 401, a fresh token is fetched from the provider and the request is retried once
* with it. Requests that do not use a mobile token (no provider configured) are left untouched.
*/
internal class MobileTokenAuthenticator : Authenticator {

override fun authenticate(route: Route?, response: Response): Request? {
if (Chargebee.tokenProvider == null) {
return null
}
// Retry only once: a non-null priorResponse means we already refreshed and retried.
if (response.priorResponse() != null) {
return null
}
val refreshedHeader = refreshTokenBlocking() ?: return null
return response.request().newBuilder()
.header("Authorization", refreshedHeader)
.build()
Comment on lines +22 to +29

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- candidate file ---'
fd -i 'MobileTokenAuthenticator.kt' . -x sh -c 'cat -n "$1"' sh {}
printf '%s\n' '--- OkHttp declarations and authenticator usage ---'
rg -n -S 'okhttp|Authenticator|MobileTokenAuthenticator|priorResponse|refreshTokenBlocking' \
  -g '!build' -g '!**/node_modules/**' .
printf '%s\n' '--- relevant dependency files ---'
fd -i '(build.gradle|build.gradle.kts|libs.versions.toml|gradle.properties|pom.xml)' . -x sh -c '
  if rg -n -i "okhttp|com.squareup.okhttp" "$1" >/dev/null; then
    echo "### $1"
    rg -n -i -C 2 "okhttp|com.squareup.okhttp" "$1"
  fi
' sh {}

Repository: chargebee/chargebee-android

Length of output: 4446


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- project files related to dependencies ---'
git ls-files | rg '(^|/)(build\.gradle(\.kts)?|settings\.gradle(\.kts)?|libs\.versions\.toml|gradle\.properties|pom\.xml)$|(^|/)gradle/'
printf '%s\n' '--- dependency declarations ---'
rg -n -i -C 3 'okhttp|com\.squareup\.okhttp|com\.squareup\.okhttp3' \
  $(git ls-files | rg '(^|/)(build\.gradle(\.kts)?|settings\.gradle(\.kts)?|libs\.versions\.toml|gradle\.properties|pom\.xml)$') || true
printf '%s\n' '--- client construction ---'
cat -n chargebee/src/main/java/com/chargebee/android/resources/BaseResource.kt

Repository: chargebee/chargebee-android

Length of output: 1613


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- root build configuration ---'
cat -n build.gradle
printf '%s\n' '--- chargebee module build configuration ---'
cat -n chargebee/build.gradle
printf '%s\n' '--- settings and properties ---'
cat -n settings.gradle
cat -n gradle.properties
printf '%s\n' '--- tests and mocks related to authentication or redirects ---'
rg -n -i -C 4 'MobileTokenAuthenticator|authenticator|401|redirect|tokenProvider|refreshMobileToken' \
  chargebee app -g '*Test*' -g '*test*' -g '*.kt' -g '*.java' || true

Repository: chargebee/chargebee-android

Length of output: 21784


🌐 Web query:

OkHttp Response.priorResponse redirects authenticator 401 documentation RetryAndFollowUpInterceptor

💡 Result:

In OkHttp, the Response.priorResponse property and the Authenticator interface are key components for handling authentication challenges (such as 401 Unauthorized or 407 Proxy Authentication Required) within the RetryAndFollowUpInterceptor [1][2][3]. 1. Response.priorResponse The priorResponse property returns the previous response that triggered the current request (e.g., a 401 challenge) [1][4]. If the current request was not triggered by an automatic retry, this returns null [1]. This property is primarily used to detect previous attempts to authenticate or follow redirects, helping prevent infinite request-response loops [2][5]. 2. Authenticator and 401 Handling When a server returns a 401 (Unauthorized) status code, OkHttp calls the registered Authenticator implementation [2][6]. The authenticator is responsible for providing a new request with the appropriate "Authorization" header or returning null to decline the challenge [2][7]. To prevent infinite loops when credentials are invalid or expired, the standard practice is to check the current request's headers before attempting to re-authenticate [2][7][8]. If the request already contains an "Authorization" header, the authenticator should return null [2][8]. 3. RetryAndFollowUpInterceptor This internal interceptor is responsible for coordinating redirects, retries, and authentication challenges [9][3][6]. When it receives a 401 response, it calls the Authenticator [3][6]. If the authenticator returns a new, non-null request, the interceptor retries the call with this new request [3][6]. 4. Detecting Attempt Counts If you need to limit the number of authentication retries, you can calculate the current attempt count by traversing the chain of prior responses [2][5]. The following common pattern is used for this purpose: private fun responseCount(response: Response): Int { var result = 1 var prior = response.priorResponse while (prior!= null) { result++ prior = prior.priorResponse } return result } By checking this count or simply verifying if an "Authorization" header is already present, you can safely abort authentication attempts if they are not successful [2][7][8].

Citations:


🏁 Script executed:

#!/bin/bash
set -eu
printf '%s\n' '--- Retrofit 2.9.0 resolved OkHttp dependency ---'
curl -fsSL https://repo1.maven.org/maven2/com/squareup/retrofit2/retrofit/2.9.0/retrofit-2.9.0.pom |
  sed -n '/<artifactId>okhttp<\/artifactId>/,/<\/dependency>/p'
printf '%s\n' '--- OkHttp 3.14.9 redirect and authentication response-chain logic ---'
curl -fsSL https://raw.githubusercontent.com/square/okhttp/okhttp_3.14.x/okhttp/src/main/java/okhttp3/internal/http/RetryAndFollowUpInterceptor.java |
  rg -n -C 5 'priorResponse|followUpRequest|followRedirects|401|Authenticator'
printf '%s\n' '--- behavioral probe for the guard ---'
python3 - <<'PY'
class Response:
    def __init__(self, code, prior=None):
        self.code = code
        self.prior = prior

def authenticate(response, provider_configured=True):
    if not provider_configured:
        return None
    if response.prior is not None:
        return None
    return "retry-with-refreshed-header"

cases = {
    "direct 401": Response(401),
    "redirect then 401": Response(401, Response(302)),
    "redirect chain then 401": Response(401, Response(302, Response(301))),
}
for name, response in cases.items():
    print(f"{name}: {authenticate(response)!r}")
PY

Repository: chargebee/chargebee-android

Length of output: 4553


Track this authenticator’s retry separately from priorResponse().

When OkHttp follows a redirect before returning 401, priorResponse() is non-null. authenticate() then returns null and skips token refresh. Use a request-scoped retry marker and add a redirect-then-401 test. The provider must run once, followed by one retry.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@chargebee/src/main/java/com/chargebee/android/network/MobileTokenAuthenticator.kt`
around lines 22 - 29, Update the authenticator logic around refreshTokenBlocking
so retry tracking uses a request-scoped marker rather than
response.priorResponse(), allowing token refresh after a redirect-then-401 while
preventing a second authenticator retry. Mark the rebuilt request before
returning it, preserve null when that marker is already present, and add
coverage verifying the provider runs once followed by exactly one retry.

Source: Path instructions

}

// The token provider is asynchronous while the authenticator runs synchronously on OkHttp's
// background thread, so we bridge the callback with a short-lived latch.
private fun refreshTokenBlocking(): String? {
val latch = CountDownLatch(1)
var refreshedHeader: String? = null
Chargebee.refreshMobileToken { success ->
if (success) {
refreshedHeader = Chargebee.encodedApiKey
}
latch.countDown()
}
latch.await(TOKEN_REFRESH_TIMEOUT_SECONDS, TimeUnit.SECONDS)
return refreshedHeader
}

private companion object {
private const val TOKEN_REFRESH_TIMEOUT_SECONDS = 30L
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package com.chargebee.android.resources

import com.chargebee.android.network.MobileTokenAuthenticator
import com.google.gson.FieldNamingPolicy
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory

Expand All @@ -15,8 +17,14 @@ internal open class BaseResource(baseUrl: String) {
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
.create()

// Refreshes and retries once when a mobile-token request is rejected with a 401.
val httpClient = OkHttpClient.Builder()
.authenticator(MobileTokenAuthenticator())
.build()

apiClient = Retrofit.Builder()
.baseUrl(baseUrl)
.client(httpClient)
.addConverterFactory(GsonConverterFactory.create(gson))
.build()
}
Expand Down
Loading