-
Notifications
You must be signed in to change notification settings - Fork 7
Adds a mobile token provider to the SDK #123
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
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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 = | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 || trueRepository: 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",
})
PYRepository: 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 🤖 Prompt for AI AgentsSource: 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) { | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.ktRepository: 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' || trueRepository: chargebee/chargebee-android Length of output: 21784 🌐 Web query:
💡 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}")
PYRepository: chargebee/chargebee-android Length of output: 4553 Track this authenticator’s retry separately from When OkHttp follows a redirect before returning 401, 🤖 Prompt for AI AgentsSource: 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 | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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.
fetchMobileTokenalways returns a placeholder token. Any request usingconfigureWithMobileToken()will fail authentication, including the one-time refresh retry. The visible Configure flow still usespublishableApiKey, 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
Source: Path instructions