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

API support for fetching session token #802

Merged
merged 3 commits into from
Feb 24, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import com.auth0.android.result.Credentials
import com.auth0.android.result.DatabaseUser
import com.auth0.android.result.PasskeyChallenge
import com.auth0.android.result.PasskeyRegistrationChallenge
import com.auth0.android.result.SSOCredentials
import com.auth0.android.result.UserProfile
import com.google.gson.Gson
import okhttp3.HttpUrl.Companion.toHttpUrl
Expand Down Expand Up @@ -921,6 +922,44 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
return factory.get(url.toString(), jwksAdapter)
}

/**
* Creates a new request to fetch a session token in exchange for a refresh token.
*
* @param refreshToken A valid refresh token obtained as part of Auth0 authentication
* @return a request to fetch a session token
*/
public fun fetchSessionToken(refreshToken: String): Request<SSOCredentials, AuthenticationException> {
val params = ParameterBuilder.newBuilder()
.setClientId(clientId)
.setGrantType(ParameterBuilder.GRANT_TYPE_TOKEN_EXCHANGE)
.set(SUBJECT_TOKEN_KEY, refreshToken)
.set(SUBJECT_TOKEN_TYPE_KEY, ParameterBuilder.TOKEN_TYPE_REFRESH_TOKEN)
.set(REQUESTED_TOKEN_TYPE_KEY, ParameterBuilder.TOKEN_TYPE_SESSION_TOKEN)
.asDictionary()
return loginWithTokenGeneric<SSOCredentials>(params)
}

/**
* Helper function to make a request to the /oauth/token endpoint with a custom response type.
*/
private inline fun <reified T> loginWithTokenGeneric(parameters: Map<String, String>): Request<T,AuthenticationException> {
val url = auth0.getDomainUrl().toHttpUrl().newBuilder()
.addPathSegment(OAUTH_PATH)
.addPathSegment(TOKEN_PATH)
.build()
val requestParameters =
ParameterBuilder.newBuilder()
.setClientId(clientId)
.addAll(parameters)
.asDictionary()
val adapter: JsonAdapter<T> = GsonAdapter(
T::class.java, gson
)
val request = factory.post(url.toString(), adapter)
request.addParameters(requestParameters)
return request
}

/**
* Helper function to make a request to the /oauth/token endpoint.
*/
Expand Down Expand Up @@ -989,6 +1028,7 @@ public class AuthenticationAPIClient @VisibleForTesting(otherwise = VisibleForTe
private const val RECOVERY_CODE_KEY = "recovery_code"
private const val SUBJECT_TOKEN_KEY = "subject_token"
private const val SUBJECT_TOKEN_TYPE_KEY = "subject_token_type"
private const val REQUESTED_TOKEN_TYPE_KEY = "requested_token_type"
private const val USER_METADATA_KEY = "user_metadata"
private const val AUTH_SESSION_KEY = "auth_session"
private const val AUTH_RESPONSE_KEY = "authn_response"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ public class ParameterBuilder private constructor(parameters: Map<String, String
public const val GRANT_TYPE_TOKEN_EXCHANGE: String =
"urn:ietf:params:oauth:grant-type:token-exchange"
public const val GRANT_TYPE_PASSKEY :String = "urn:okta:params:oauth:grant-type:webauthn"
public const val TOKEN_TYPE_REFRESH_TOKEN :String = "urn:ietf:params:oauth:token-type:refresh_token"
public const val TOKEN_TYPE_SESSION_TOKEN :String = "urn:auth0:params:oauth:token-type:session_token"
public const val SCOPE_OPENID: String = "openid"
public const val SCOPE_OFFLINE_ACCESS: String = "openid offline_access"
public const val SCOPE_KEY: String = "scope"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.annotation.VisibleForTesting
import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.callback.Callback
import com.auth0.android.result.Credentials
import com.auth0.android.result.SSOCredentials
import com.auth0.android.util.Clock
import java.util.*

Expand All @@ -29,7 +30,9 @@ public abstract class BaseCredentialsManager internal constructor(

@Throws(CredentialsManagerException::class)
public abstract fun saveCredentials(credentials: Credentials)
public abstract fun saveSsoCredentials(ssoCredentials: SSOCredentials)
Copy link
Contributor

@Widcket Widcket Feb 20, 2025

Choose a reason for hiding this comment

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

I'm not sure this method is needed. We're already providing a method for getting fresh SSOCredentials, which automaticaly saves the refresh token.

Copy link
Contributor

Choose a reason for hiding this comment

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

The same functionality can also be achieved by simply retrieving the credentials, replacing the refresh token, and saving them back.

Copy link
Contributor

Choose a reason for hiding this comment

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

In the case we have it for internal code reuse, then we should not have it be public.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Added this method for the scenario where we get a new refresh token when calling the API directly from the AuthenticationAPI class. The user would have to call this to replace the existing one else the following getSsoCredentials call will fail.

public abstract fun getCredentials(callback: Callback<Credentials, CredentialsManagerException>)
public abstract fun getSsoCredentials(callback: Callback<SSOCredentials, CredentialsManagerException>)
public abstract fun getCredentials(
scope: String?,
minTtl: Int,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.auth0.android.authentication.AuthenticationAPIClient
import com.auth0.android.authentication.AuthenticationException
import com.auth0.android.callback.Callback
import com.auth0.android.result.Credentials
import com.auth0.android.result.SSOCredentials
import kotlinx.coroutines.suspendCancellableCoroutine
import java.util.*
import java.util.concurrent.Executor
Expand Down Expand Up @@ -53,6 +54,57 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
storage.store(LEGACY_KEY_CACHE_EXPIRES_AT, credentials.expiresAt.time)
}


/**
* Stores the given [SSOCredentials] refresh token in the storage. Must have a refresh_token value.
* @param ssoCredentials the credentials to save in the storage.
*/
override fun saveSsoCredentials(ssoCredentials: SSOCredentials) {
if (ssoCredentials.refreshToken.isNullOrEmpty()) return // No refresh token to save
serialExecutor.execute {
val existingRefreshToken = storage.retrieveString(KEY_REFRESH_TOKEN)
// Checking if the existing one needs to be replaced with the new one
if (ssoCredentials.refreshToken == existingRefreshToken)
return@execute
storage.store(KEY_REFRESH_TOKEN, ssoCredentials.refreshToken)
}
}

/**
* Retrieves a new [SSOCredentials] . It will fail with [CredentialsManagerException]
* if the saved refresh_token is null or no longer valid.
*/
override fun getSsoCredentials(callback: Callback<SSOCredentials, CredentialsManagerException>) {
serialExecutor.execute {
val refreshToken = storage.retrieveString(KEY_REFRESH_TOKEN)
if (refreshToken.isNullOrEmpty()) {
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
return@execute
}

try {
val sessionCredentials = authenticationClient.fetchSessionToken(refreshToken)
.execute()
saveSsoCredentials(sessionCredentials)
callback.onSuccess(sessionCredentials)
} catch (error: AuthenticationException) {
val exception = when {
error.isRefreshTokenDeleted ||
error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED

error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
else -> CredentialsManagerException.Code.API_ERROR
}
callback.onFailure(
CredentialsManagerException(
exception,
error
)
)
}
}
}

/**
* Retrieves the credentials from the storage and refresh them if they have already expired.
* It will throw [CredentialsManagerException] if the saved access_token or id_token is null,
Expand Down Expand Up @@ -328,6 +380,7 @@ public class CredentialsManager @VisibleForTesting(otherwise = VisibleForTesting
val exception = when {
error.isRefreshTokenDeleted ||
error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED

error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
else -> CredentialsManagerException.Code.API_ERROR
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import com.auth0.android.callback.Callback
import com.auth0.android.request.internal.GsonProvider
import com.auth0.android.result.Credentials
import com.auth0.android.result.OptionalCredentials
import com.auth0.android.result.SSOCredentials
import com.google.gson.Gson
import kotlinx.coroutines.suspendCancellableCoroutine
import java.lang.ref.WeakReference
Expand Down Expand Up @@ -127,6 +128,60 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
}
}

override fun saveSsoCredentials(ssoCredentials: SSOCredentials) {
if (ssoCredentials.refreshToken.isNullOrEmpty()) return // No refresh token to save
serialExecutor.execute {
val existingCredentials = getExistingCredentials()
existingCredentials ?: return@execute
// Checking if the existing one needs to be replaced with the new one
if (existingCredentials.refreshToken == ssoCredentials.refreshToken)
return@execute
val newCredentials =
existingCredentials.copy(refreshToken = ssoCredentials.refreshToken)
saveCredentials(newCredentials)
}
}

override fun getSsoCredentials(callback: Callback<SSOCredentials, CredentialsManagerException>) {
serialExecutor.execute {
val existingCredentials = getExistingCredentials()
existingCredentials ?: run {
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
return@execute
}
if (existingCredentials.refreshToken.isNullOrEmpty()) {
callback.onFailure(CredentialsManagerException.NO_REFRESH_TOKEN)
return@execute
}
try {
val sessionCredentials =
authenticationClient.fetchSessionToken(existingCredentials.refreshToken)
.execute()
saveSsoCredentials(sessionCredentials)
callback.onSuccess(sessionCredentials)
} catch (error: AuthenticationException) {
val exception = when {
error.isRefreshTokenDeleted ||
error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED

error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
else -> CredentialsManagerException.Code.API_ERROR
}
callback.onFailure(
CredentialsManagerException(
exception,
error
)
)
} catch (error: CredentialsManagerException) {
val exception = CredentialsManagerException(
CredentialsManagerException.Code.STORE_FAILED, error
)
callback.onFailure(exception)
}
}
}

/**
* Tries to obtain the credentials from the Storage. The method will return [Credentials].
* If something unexpected happens, then [CredentialsManagerException] exception will be thrown. Some devices are not compatible
Expand Down Expand Up @@ -589,6 +644,7 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
val exception = when {
error.isRefreshTokenDeleted ||
error.isInvalidRefreshToken -> CredentialsManagerException.Code.RENEW_FAILED

error.isNetworkError -> CredentialsManagerException.Code.NO_NETWORK
else -> CredentialsManagerException.Code.API_ERROR
}
Expand Down Expand Up @@ -616,6 +672,36 @@ public class SecureCredentialsManager @VisibleForTesting(otherwise = VisibleForT
}
}

/**
* Helper method to fetch existing credentials from the storage.
* This method is not thread safe
*/
private fun getExistingCredentials(): Credentials? {
val encryptedEncoded = storage.retrieveString(KEY_CREDENTIALS)
if (encryptedEncoded.isNullOrBlank()) {
return null
}
val encrypted = Base64.decode(encryptedEncoded, Base64.DEFAULT)
val json: String
try {
json = String(crypto.decrypt(encrypted))
} catch (e: IncompatibleDeviceException) {
return null
} catch (e: CryptoException) {
return null
}
val bridgeCredentials = gson.fromJson(json, OptionalCredentials::class.java)
val existingCredentials = Credentials(
bridgeCredentials.idToken.orEmpty(),
bridgeCredentials.accessToken.orEmpty(),
bridgeCredentials.type.orEmpty(),
bridgeCredentials.refreshToken,
bridgeCredentials.expiresAt ?: Date(),
bridgeCredentials.scope
)
return existingCredentials
}

@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun clearFragmentActivity() {
fragmentActivity!!.clear()
Expand Down
54 changes: 54 additions & 0 deletions auth0/src/main/java/com/auth0/android/result/SSOCredentials.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.auth0.android.result

import com.google.gson.annotations.SerializedName

/**
* Holds the session token credentials required for SSO .
*
* * *sessionToken*: Session Token for SSO
* * *refreshToken*: Refresh Token that can be used to request new tokens without signing in again
* * *tokenType*: Contains information about how the token should be used.
* * *expiresIn*: The token expiration duration.
* * *issuedTokenType*: Type of the token issued.
*
*/
public data class SSOCredentials(
/**
* The Session Token used for SSO .
*
* @return the Session Token.
*/
@field:SerializedName("access_token") public val sessionToken: String,

/**
* Type of the token issued.In this case, an Auth0 session token
*
* @return the issued token type.
*/
@field:SerializedName("issued_token_type") public val issuedTokenType: String,

/**
* Contains information about how the token should be used.
* If the issued token is not an access token or usable as an access token, then the token_type
* value N_A is used to indicate that an OAuth 2.0 token_type identifier is not applicable in that context
*
* @return the token type.
*/
@field:SerializedName("token_type") public val tokenType: String,

/**
* Expiration duration of the session token in seconds. Session tokens are short-lived and expire after a few minutes.
* Once expired, the Session Token can no longer be used for SSO.
*
* @return the expiration duration of this Session Token
*/
@field:SerializedName("expires_in") public val expiresIn: Int,


/**
* Refresh Token that can be used to request new tokens without signing in again.
*
* @return the Refresh Token.
*/
@field:SerializedName("refresh_token") public val refreshToken: String? = null
)
Loading