From 3ed82d67e5559c20e5f89828adb34334f28d3d22 Mon Sep 17 00:00:00 2001 From: Andy Witrisna Date: Tue, 4 Jun 2024 10:43:10 -0700 Subject: [PATCH] [SDKS-3020] Support Centralize Oidc Signoff with PingOne --- CHANGELOG.md | 5 +- build.gradle.kts | 4 +- buildSrc/build.gradle.kts | 2 +- forgerock-auth/build.gradle.kts | 2 +- .../forgerock/android/auth/AccessToken.java | 174 ------- .../org/forgerock/android/auth/AccessToken.kt | 276 ++++++++++ .../android/auth/AppAuthConfigurer.java | 124 ----- .../android/auth/AppAuthConfigurer.kt | 103 ++++ .../android/auth/AppAuthFragment.java | 133 ----- .../android/auth/AppAuthFragment2.kt | 96 ---- .../android/auth/AuthorizeContract.kt | 66 --- .../org/forgerock/android/auth/Config.java | 7 +- .../android/auth/DefaultTokenManager.java | 7 +- .../org/forgerock/android/auth/FRListener.kt | 44 +- .../org/forgerock/android/auth/FROptions.kt | 7 +- .../org/forgerock/android/auth/FRUser.java | 24 +- .../forgerock/android/auth/OAuth2Client.java | 477 ------------------ .../forgerock/android/auth/OAuth2Client.kt | 472 +++++++++++++++++ .../android/auth/OAuth2ResponseHandler.java | 4 +- .../java/org/forgerock/android/auth/PKCE.java | 25 - .../java/org/forgerock/android/auth/PKCE.kt | 14 + .../java/org/forgerock/android/auth/Result.kt | 30 ++ .../android/auth/SessionManager.java | 19 + .../forgerock/android/auth/StringUtils.java | 4 +- .../java/org/forgerock/android/auth/Token.kt | 4 +- .../forgerock/android/auth/TokenManager.java | 8 +- .../auth/centralize/AppAuthFragment2.kt | 174 +++++++ .../auth/centralize/AuthorizeContract.kt | 109 ++++ .../auth/centralize/BrowserLauncher.kt | 86 ++-- .../auth/centralize/EndSessionContract.kt | 76 +++ .../android/auth/centralize/Launcher.kt | 69 ++- .../auth/exception/AuthorizeException.java | 18 - .../auth/exception/AuthorizeException.kt | 15 + .../BrowserAuthenticationException.java | 15 - .../BrowserAuthenticationException.kt | 12 + .../forgerock/android/auth/AccessTokenTest.kt | 6 +- .../android/auth/BrowserLoginTest.kt | 421 ---------------- .../android/auth/ConfigHelperTest.kt | 8 +- .../android/auth/DefaultTokenManagerTest.kt | 4 +- .../SSOBroadcastReceiverIntegrationTests.kt | 2 +- .../android/auth/OkHttpClientProvider.kt | 21 +- gradle/libs.versions.toml | 5 +- gradle/wrapper/gradle-wrapper.properties | 4 +- samples/app/build.gradle.kts | 4 +- .../java/com/example/app/env/EnvViewModel.kt | 15 +- .../forgerock/kotlinapp/FRSessionActivity.kt | 2 +- .../com/forgerock/kotlinapp/MainActivity.kt | 6 +- .../forgerock/kotlinapp/UserInfoFragment.kt | 4 +- 48 files changed, 1522 insertions(+), 1685 deletions(-) delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment.java delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment2.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/AuthorizeContract.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.kt create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/Result.kt create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AppAuthFragment2.kt create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AuthorizeContract.kt create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/EndSessionContract.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.kt delete mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.java create mode 100644 forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.kt delete mode 100644 forgerock-auth/src/test/java/org/forgerock/android/auth/BrowserLoginTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c1e6659c..7dfa113c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,7 @@ -## [4.4.2] +## [4.5.0] +#### Added +- Support Centralize Oidc Signoff with PingOne [SDKS-3020] + #### Fixed - Resolve commons-io-2.6.jar and bcprov-jdk15on-1.68.jar vulnerability warning [SDKS-3073] - Use English locale when generating certificate for Application Pin [SDKS-3221] diff --git a/build.gradle.kts b/build.gradle.kts index 862a9e28..728a5424 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -27,8 +27,8 @@ plugins { id("io.github.gradle-nexus.publish-plugin") version "1.1.0" id("org.sonatype.gradle.plugins.scan") version "2.4.0" id("org.jetbrains.dokka") version "1.9.10" - id("com.android.application") version "8.2.2" apply false - id("com.android.library") version "8.2.2" apply false + id("com.android.application") version "8.3.2" apply false + id("com.android.library") version "8.3.2" apply false id("org.jetbrains.kotlin.android") version "1.9.22" apply false } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 0068e591..af3c4a62 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -15,5 +15,5 @@ repositories { } dependencies { - implementation("com.android.tools.build:gradle-api:8.2.2") + implementation("com.android.tools.build:gradle-api:8.3.2") } \ No newline at end of file diff --git a/forgerock-auth/build.gradle.kts b/forgerock-auth/build.gradle.kts index aa8b9eb4..2507f4af 100644 --- a/forgerock-auth/build.gradle.kts +++ b/forgerock-auth/build.gradle.kts @@ -61,7 +61,6 @@ dependencies { implementation(libs.org.jetbrains.kotlinx) implementation(libs.jetbrains.kotlinx.coroutines.play.services) implementation(libs.androidx.appcompat) - implementation(libs.androidx.fragment.ktx) //Make it optional for developer compileOnly(libs.play.services.location) @@ -142,4 +141,5 @@ dependencies { delombok(libs.projectlombok.lombok) annotationProcessor(libs.projectlombok.lombok) + } \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.java deleted file mode 100644 index bed1501e..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.java +++ /dev/null @@ -1,174 +0,0 @@ -/* - * Copyright (c) 2019 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import lombok.Getter; -import lombok.Setter; - -import org.json.JSONArray; -import org.json.JSONException; -import org.json.JSONObject; - -import java.io.Serializable; -import java.util.*; - -/** - * Models an OAuth2 access token. - */ -@Getter -public class AccessToken extends Token implements Serializable { - - private long expiresIn; - private String refreshToken; - private String idToken; - private String tokenType; - private Scope scope; - private Date expiration; - private SSOToken sessionToken; - @Setter - private boolean persisted; - - @lombok.Builder - public AccessToken(String value, long expiresIn, Date expiration, String refreshToken, String idToken, String tokenType, Scope scope, SSOToken sessionToken) { - super(value); - this.expiresIn = expiresIn; - if (expiration == null) { - this.expiration = new Date(System.currentTimeMillis() + (expiresIn * 1000L)); - } else { - this.expiration = expiration; - } - this.refreshToken = refreshToken; - this.idToken = idToken; - this.tokenType = tokenType; - this.scope = scope; - this.sessionToken = sessionToken; - } - - /** - * Convenience method for checking expiration - * - * @return true if the expiration is before the current time - */ - public boolean isExpired() { - return isExpired(0); - } - - /** - * Convenience method for checking expiration - * - * @param threshold Threshold in Seconds - * @return true if the expiration is before the current time - */ - public boolean isExpired(long threshold) { - Date now = new Date(System.currentTimeMillis() + (threshold * 1000L)); - return expiration != null && expiration.before(now); - } - - - /** - * Authorization Scope - */ - public static class Scope extends HashSet { - - public Scope(Set stringSet) { - super(stringSet); - } - - public Scope() { - super(); - } - - JSONArray toJsonArray() { - JSONArray result = new JSONArray(); - for (String s : this) { - result.put(s); - } - return result; - } - - static Scope fromJsonArray(JSONArray array) throws JSONException { - if (array == null) { - return null; - } - Scope s = new Scope(); - for (int i = 0; i < array.length(); i++) { - s.add(array.getString(i)); - } - return s; - } - - /** - * Parses a scope from the specified string representation - * - * @param s The scope string - * @return The scope. - */ - public static Scope parse(final String s) { - - if (s == null) - return null; - - Scope scope = new Scope(); - - if (s.trim().isEmpty()) - return scope; - - StringTokenizer st = new StringTokenizer(s, " "); - - while (st.hasMoreTokens()) { - scope.add(st.nextToken()); - } - - return scope; - } - } - - public String toJson() { - JSONObject result = new JSONObject(); - try { - result.put("value", getValue()); - result.put("expiresIn", getExpiresIn()); - result.put("refreshToken", getRefreshToken()); - result.put("idToken", getIdToken()); - result.put("tokenType", getTokenType()); - result.put("scope", getScope() == null ? null : getScope().toJsonArray()); - result.put("expiration", getExpiration().getTime()); - result.put("sessionToken", getSessionToken() == null ? null : getSessionToken().getValue()); - } catch (JSONException e) { - throw new RuntimeException(e); - } - return result.toString(); - } - - public static AccessToken fromJson(String str) { - try { - JSONObject result = new JSONObject(str); - return AccessToken.builder() - .value(result.getString("value")) - .expiresIn(result.optLong("expiresIn", -1)) - .refreshToken(result.has("refreshToken") ? result.getString("refreshToken"): null) - .idToken(result.has("idToken") ? result.getString("idToken"): null) - .tokenType(result.has("tokenType") ? result.getString("tokenType"): null) - .scope(Scope.fromJsonArray(result.optJSONArray("scope"))) - .expiration(expiration(result.optLong("expiration", -1))) - .sessionToken(result.has("sessionToken") ? new SSOToken(result.optString("sessionToken")) : null) - .build(); - } catch (JSONException e) { - return null; - } - } - - private static Date expiration(long expiration) { - if (expiration == -1) { - return null; - } - return new Date(expiration); - } - - -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.kt new file mode 100644 index 00000000..46785d2a --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/AccessToken.kt @@ -0,0 +1,276 @@ +/* + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.Serializable +import java.util.Date +import java.util.StringTokenizer + +/** + * Models an OAuth2 access token. + * + * @property value The value of the access token. + * @property expiresIn The duration (in seconds) for which the access token is valid. + * @property expiration The date and time when the access token expires. + * @property refreshToken The refresh token which can be used to obtain new access tokens. + * @property idToken The ID token associated with the access token. + * @property tokenType The type of the token (usually "Bearer"). + * @property scope The scope of the access token which defines the resources that the access token can access. + * @property sessionToken The session token associated with the access token. + */ +class AccessToken(override val value: String, + val expiresIn: Long, + var expiration: Date?, + val refreshToken: String?, + val idToken: String?, + val tokenType: String?, + val scope: Scope?, + val sessionToken: SSOToken?) : Token( + value), Serializable { + var isPersisted: Boolean = false + + init { + expiration = expiration ?: Date(System.currentTimeMillis() + (expiresIn * 1000L)) + } + + /** + * Checks if the access token is expired. + * + * @return true if the expiration is before the current time, false otherwise. + */ + val isExpired: Boolean + get() = isExpired(0) + + /** + * Checks if the access token is expired. + * + * @param threshold Threshold in Seconds + * @return true if the expiration is before the current time, false otherwise. + */ + fun isExpired(threshold: Long): Boolean { + val now = Date(System.currentTimeMillis() + (threshold * 1000L)) + return expiration?.before(now) ?: false + } + + /** + * Represents the authorization scope of the access token. + */ + class Scope : HashSet { + constructor(stringSet: Set) : super(stringSet) + + constructor() : super() + + /** + * Converts the scope to a JSON array. + * + * @return A JSON array representing the scope. + */ + fun toJsonArray(): JSONArray { + val result = JSONArray() + for (s in this) { + result.put(s) + } + return result + } + + companion object { + /** + * Creates a Scope object from a JSON array. + * + * @param array The JSON array representing the scope. + * @return A Scope object. + * @throws JSONException If the JSON array is malformed. + */ + @Throws(JSONException::class) + fun fromJsonArray(array: JSONArray?): Scope? { + if (array == null) { + return null + } + val s = Scope() + for (i in 0 until array.length()) { + s.add(array.getString(i)) + } + return s + } + + /** + * Parses a scope from the specified string representation. + * + * @param s The scope string. + * @return The scope. + */ + @JvmStatic + fun parse(s: String?): Scope? { + if (s == null) return null + + val scope = Scope() + + if (s.trim { it <= ' ' }.isEmpty()) return scope + + val st = StringTokenizer(s, " ") + + while (st.hasMoreTokens()) { + scope.add(st.nextToken()) + } + + return scope + } + } + } + + /** + * Converts the access token to a JSON string. + * + * @return A JSON string representing the access token. + * @throws RuntimeException If the JSON object is malformed. + */ + fun toJson(): String { + val result = JSONObject() + try { + result.put("value", value) + result.put("expiresIn", expiresIn) + result.put("refreshToken", refreshToken) + result.put("idToken", idToken) + result.put("tokenType", tokenType) + result.put("scope", scope?.toJsonArray()) + expiration?.let { + result.put("expiration", it.time) + } + result.put("sessionToken", sessionToken?.value) + } catch (e: JSONException) { + throw RuntimeException(e) + } + return result.toString() + } + + /** + * Builder class for AccessToken. + */ + class AccessTokenBuilder internal constructor() { + private var value: String = "" + private var expiresIn: Long = 0 + private var expiration: Date? = null + private var refreshToken: String? = null + private var idToken: String? = null + private var tokenType: String? = null + private var scope: Scope? = null + private var sessionToken: SSOToken? = null + + fun value(value: String): AccessTokenBuilder { + this.value = value + return this + } + + fun expiresIn(expiresIn: Long): AccessTokenBuilder { + this.expiresIn = expiresIn + return this + } + + fun expiration(expiration: Date?): AccessTokenBuilder { + this.expiration = expiration + return this + } + + fun refreshToken(refreshToken: String?): AccessTokenBuilder { + this.refreshToken = refreshToken + return this + } + + fun idToken(idToken: String?): AccessTokenBuilder { + this.idToken = idToken + return this + } + + fun tokenType(tokenType: String?): AccessTokenBuilder { + this.tokenType = tokenType + return this + } + + fun scope(scope: Scope?): AccessTokenBuilder { + this.scope = scope + return this + } + + fun sessionToken(sessionToken: SSOToken?): AccessTokenBuilder { + this.sessionToken = sessionToken + return this + } + + /** + * Builds an AccessToken object. + * + * @return An AccessToken object. + */ + fun build(): AccessToken { + return AccessToken(this.value, + this.expiresIn, + this.expiration, + this.refreshToken, + this.idToken, + this.tokenType, + this.scope, + this.sessionToken) + } + + override fun toString(): String { + return "AccessToken.AccessTokenBuilder(value=" + this.value + ", expiresIn=" + this.expiresIn + ", expiration=" + this.expiration + ", refreshToken=" + this.refreshToken + ", idToken=" + this.idToken + ", tokenType=" + this.tokenType + ", scope=" + this.scope + ", sessionToken=" + this.sessionToken + ")" + } + } + + companion object { + /** + * Creates a new AccessTokenBuilder. + * + * @return An AccessTokenBuilder object. + */ + @JvmStatic + fun builder(): AccessTokenBuilder { + return AccessTokenBuilder() + } + + /** + * Creates an AccessToken object from a JSON string. + * + * @param str The JSON string representing the access token. + * @return An AccessToken object. + */ + @JvmStatic + fun fromJson(str: String): AccessToken? { + try { + val result = JSONObject(str) + return builder() + .value(result.getString("value")) + .expiresIn(result.optLong("expiresIn", -1)) + .refreshToken(if (result.has("refreshToken")) result.getString("refreshToken") else null) + .idToken(if (result.has("idToken")) result.getString("idToken") else null) + .tokenType(if (result.has("tokenType")) result.getString("tokenType") else null) + .scope(Scope.fromJsonArray(result.optJSONArray("scope"))) + .expiration(expiration(result.optLong("expiration", -1))) + .sessionToken(if (result.has("sessionToken")) SSOToken(result.optString("sessionToken")) else null) + .build() + } catch (e: JSONException) { + return null + } + } + + /** + * Converts a timestamp to a Date object. + * + * @param expiration The timestamp. + * @return A Date object. + */ + private fun expiration(expiration: Long): Date? { + if (expiration == -1L) { + return null + } + return Date(expiration) + } + } +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.java deleted file mode 100644 index dbf30085..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (c) 2020-2024 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import android.net.Uri; - -import androidx.annotation.NonNull; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.core.util.Consumer; - -import net.openid.appauth.AppAuthConfiguration; -import net.openid.appauth.AuthorizationRequest; -import net.openid.appauth.AuthorizationServiceConfiguration; -import net.openid.appauth.browser.CustomTabManager; - -import java.net.MalformedURLException; - -import lombok.RequiredArgsConstructor; - -/** - * AppAuth Integration, https://github.com/openid/AppAuth-Android - * this class provides AppAuth integration and customization - */ -@RequiredArgsConstructor -public class AppAuthConfigurer { - - private final FRUser.Browser parent; - - private Consumer authorizationRequestBuilder = builder -> { - }; - private androidx.core.util.Consumer appAuthConfigurationBuilder = builder -> { - }; - private Consumer customTabsIntentBuilder = builder -> { - }; - private Supplier authorizationServiceConfigurationSupplier = () -> { - OAuth2Client oAuth2Client = Config.getInstance().getOAuth2Client(); - try { - return new AuthorizationServiceConfiguration( - Uri.parse(oAuth2Client.getAuthorizeUrl().toString()), - Uri.parse(oAuth2Client.getTokenUrl().toString())); - } catch (MalformedURLException e) { - throw new RuntimeException(e); - } - }; - - /** - * Override the default OAuth2 endpoint defined under the String.xml forgerock_url. - * - * @param authorizationServiceConfiguration {@link Supplier} that provide the {@link AuthorizationServiceConfiguration}, - * oauth2 endpoint. - * @return This AppAuthConfigurer - */ - - public AppAuthConfigurer authorizationServiceConfiguration(@NonNull Supplier authorizationServiceConfiguration) { - this.authorizationServiceConfigurationSupplier = authorizationServiceConfiguration; - return this; - } - - /** - * Override the {@link AuthorizationRequest} that was prepared by the SDK. - * The client_id, response type (code), redirect uri, scope are populated by the configuration defined under - * string.xml forgerock_oauth_client_id, forgerock_oauth_redirect_uri, forgerock_oauth_scope. Developer can provide more - * customization on the {@link AuthorizationRequest} object, for example {@link AuthorizationRequest.Builder#setPrompt(String)} - * - * @param authorizationRequest {@link java.util.function.Consumer} that override the {@link AuthorizationRequest}, - * some attributes are pre-populated by the provided - * {@link AuthorizationRequest} - * @return This AppAuthConfigurer - */ - public AppAuthConfigurer authorizationRequest(@NonNull Consumer authorizationRequest) { - this.authorizationRequestBuilder = authorizationRequest; - return this; - } - - /** - * Override the {@link AppAuthConfiguration} that was prepared by the SDK. - * - * @param appAuthConfiguration {@link java.util.function.Consumer} that override the {@link AppAuthConfiguration} - */ - public AppAuthConfigurer appAuthConfiguration(@NonNull Consumer appAuthConfiguration) { - this.appAuthConfigurationBuilder = appAuthConfiguration; - return this; - } - - /** - * Override the {@link CustomTabsIntent} that was prepared by the SDK. - * - * @param customTabsIntent {@link java.util.function.Consumer} that override the {@link CustomTabsIntent}, - * possibleUris ({@link CustomTabManager#createTabBuilder(android.net.Uri...)}) is - * pre-populated by the provided {@link CustomTabsIntent} - */ - public AppAuthConfigurer customTabsIntent(@NonNull Consumer customTabsIntent) { - this.customTabsIntentBuilder = customTabsIntent; - return this; - } - - /** - * Finish up the AppAuth customization. - */ - public FRUser.Browser done() { - return parent; - } - - Consumer getAuthorizationRequestBuilder() { - return this.authorizationRequestBuilder; - } - - Consumer getAppAuthConfigurationBuilder() { - return this.appAuthConfigurationBuilder; - } - - Consumer getCustomTabsIntentBuilder() { - return this.customTabsIntentBuilder; - } - - Supplier getAuthorizationServiceConfigurationSupplier() { - return this.authorizationServiceConfigurationSupplier; - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.kt new file mode 100644 index 00000000..b6454e2c --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthConfigurer.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2020 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import android.net.Uri +import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.util.Consumer +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationServiceConfiguration +import java.net.MalformedURLException + +/** + * AppAuth Integration, https://github.com/openid/AppAuth-Android + * this class provides AppAuth integration and customization + */ +class AppAuthConfigurer(private val parent: FRUser.Browser) { + + var authorizationRequestBuilder: Consumer = + Consumer { builder: AuthorizationRequest.Builder? -> } + private set + var appAuthConfigurationBuilder: Consumer = + Consumer { builder: AppAuthConfiguration.Builder? -> } + private set + var customTabsIntentBuilder: Consumer = + Consumer { builder: CustomTabsIntent.Builder? -> } + private set + var authorizationServiceConfigurationSupplier: Supplier = + Supplier { + val oAuth2Client = Config.getInstance().oAuth2Client + try { + return@Supplier AuthorizationServiceConfiguration( + Uri.parse(oAuth2Client.authorizeUrl.toString()), + Uri.parse(oAuth2Client.tokenUrl.toString()), + null, + Uri.parse(oAuth2Client.endSessionUrl.toString())) + } catch (e: MalformedURLException) { + throw RuntimeException(e) + } + } + private set + + /** + * Override the default OAuth2 endpoint defined under the String.xml forgerock_url. + * + * @param authorizationServiceConfiguration [Supplier] that provide the [AuthorizationServiceConfiguration], + * oauth2 endpoint. + * @return This AppAuthConfigurer + */ + fun authorizationServiceConfiguration(authorizationServiceConfiguration: Supplier): AppAuthConfigurer { + this.authorizationServiceConfigurationSupplier = authorizationServiceConfiguration + return this + } + + /** + * Override the [AuthorizationRequest] that was prepared by the SDK. + * The client_id, response type (code), redirect uri, scope are populated by the configuration defined under + * string.xml forgerock_oauth_client_id, forgerock_oauth_redirect_uri, forgerock_oauth_scope. Developer can provide more + * customization on the [AuthorizationRequest] object, for example [AuthorizationRequest.Builder.setPrompt] + * + * @param authorizationRequest [java.util.function.Consumer] that override the [AuthorizationRequest], + * some attributes are pre-populated by the provided + * [AuthorizationRequest] + * @return This AppAuthConfigurer + */ + fun authorizationRequest(authorizationRequest: Consumer): AppAuthConfigurer { + this.authorizationRequestBuilder = authorizationRequest + return this + } + + /** + * Override the [AppAuthConfiguration] that was prepared by the SDK. + * + * @param appAuthConfiguration [java.util.function.Consumer] that override the [AppAuthConfiguration] + */ + fun appAuthConfiguration(appAuthConfiguration: Consumer): AppAuthConfigurer { + this.appAuthConfigurationBuilder = appAuthConfiguration + return this + } + + /** + * Override the [CustomTabsIntent] that was prepared by the SDK. + * + * @param customTabsIntent [java.util.function.Consumer] that override the [CustomTabsIntent], + * possibleUris ([CustomTabManager.createTabBuilder]) is + * pre-populated by the provided [CustomTabsIntent] + */ + fun customTabsIntent(customTabsIntent: Consumer): AppAuthConfigurer { + this.customTabsIntentBuilder = customTabsIntent + return this + } + + /** + * Finish up the AppAuth customization. + */ + fun done(): FRUser.Browser { + return parent + } +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment.java deleted file mode 100644 index 42b041ba..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment.java +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2020 -2022 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import static net.openid.appauth.AuthorizationException.EXTRA_EXCEPTION; - -import android.content.ActivityNotFoundException; -import android.content.Intent; -import android.net.Uri; -import android.os.Bundle; - -import androidx.annotation.Nullable; -import androidx.annotation.RestrictTo; -import androidx.annotation.VisibleForTesting; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.fragment.app.Fragment; -import androidx.fragment.app.FragmentManager; - -import net.openid.appauth.AppAuthConfiguration; -import net.openid.appauth.AuthorizationRequest; -import net.openid.appauth.AuthorizationResponse; -import net.openid.appauth.AuthorizationService; -import net.openid.appauth.AuthorizationServiceConfiguration; - -import org.forgerock.android.auth.exception.BrowserAuthenticationException; - -/** - * Headless Fragment to receive callback result from AppAuth library - */ -@Deprecated -@RestrictTo(RestrictTo.Scope.LIBRARY) -public class AppAuthFragment extends Fragment { - - static final String TAG = AppAuthFragment.class.getName(); - static final int AUTH_REQUEST_CODE = 100; - - private FRUser.Browser browser; - private AuthorizationService authorizationService; - - /** - * Initialize the Fragment to receive AppAuth callback event. - */ - static void launch(FragmentManager fragmentManager, FRUser.Browser browser) { - AppAuthFragment existing = (AppAuthFragment) fragmentManager.findFragmentByTag(TAG); - if (existing != null) { - existing.browser = null; - fragmentManager.beginTransaction().remove(existing).commitNow(); - } - - AppAuthFragment fragment = new AppAuthFragment(); - fragment.browser = browser; - fragmentManager.beginTransaction().add(fragment, AppAuthFragment.TAG).commit(); - } - - @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - - OAuth2Client oAuth2Client = Config.getInstance().getOAuth2Client(); - AppAuthConfigurer configurer = browser.getAppAuthConfigurer(); - - //Allow caller to override Authorization Service Configuration setting - AuthorizationServiceConfiguration configuration = configurer.getAuthorizationServiceConfigurationSupplier().get(); - AuthorizationRequest.Builder authRequestBuilder = new AuthorizationRequest.Builder(configuration, - oAuth2Client.getClientId(), - oAuth2Client.getResponseType(), - Uri.parse(oAuth2Client.getRedirectUri())) - .setScope(oAuth2Client.getScope()); - - //Allow caller to override Authorization Request setting - configurer.getAuthorizationRequestBuilder().accept(authRequestBuilder); - AuthorizationRequest authorizationRequest = authRequestBuilder.build(); - - //Allow caller to override AppAuth default setting - AppAuthConfiguration.Builder appAuthConfigurationBuilder = new AppAuthConfiguration.Builder(); - configurer.getAppAuthConfigurationBuilder().accept(appAuthConfigurationBuilder); - authorizationService = new AuthorizationService(getContext(), - appAuthConfigurationBuilder.build()); - - //Allow caller to override custom tabs default setting - CustomTabsIntent.Builder intentBuilder = - authorizationService.createCustomTabsIntentBuilder(authorizationRequest.toUri()); - configurer.getCustomTabsIntentBuilder().accept(intentBuilder); - - try { - Intent intent = authorizationService.getAuthorizationRequestIntent( - authorizationRequest, intentBuilder.build()); - startActivityForResult(intent, AUTH_REQUEST_CODE); - } catch (ActivityNotFoundException e) { - if (browser.isFailedOnNoBrowserFound()) { - Listener.onException(browser.getListener(), e); - } - } - } - - @Override - public void onActivityResult(int requestCode, int resultCode, @Nullable Intent data) { - if (getActivity() != null) { - getActivity().getSupportFragmentManager().beginTransaction().remove(this).commitNow(); - } - if (data != null) { - String error = data.getStringExtra(EXTRA_EXCEPTION); - if (error != null) { - Listener.onException(browser.getListener(), - new BrowserAuthenticationException(error)); - } else { - Listener.onSuccess(browser.getListener(), AuthorizationResponse.fromIntent(data)); - } - } else { - //Not expected - Listener.onException(browser.getListener(), new BrowserAuthenticationException("No response data")); - } - browser = null; - } - - @VisibleForTesting - void setBrowser(FRUser.Browser userBrowser) { - browser = userBrowser; - } - - @Override - public void onDestroy() { - super.onDestroy(); - if (authorizationService != null) { - authorizationService.dispose(); - } - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment2.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment2.kt deleted file mode 100644 index 84ad7333..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/AppAuthFragment2.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2024 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ -package org.forgerock.android.auth - -import android.content.Intent -import android.os.Bundle -import androidx.annotation.RestrictTo -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.FragmentManager -import kotlinx.coroutines.flow.MutableStateFlow -import net.openid.appauth.AuthorizationResponse -import org.forgerock.android.auth.centralize.BrowserLauncher -import org.forgerock.android.auth.centralize.Launcher - -private const val PENDING = "pending" - -/** - * Headless Fragment to receive callback result from AppAuth library - */ -@RestrictTo(RestrictTo.Scope.LIBRARY) -class AppAuthFragment2 : Fragment() { - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val state: MutableStateFlow = MutableStateFlow(null) - val delegate = - registerForActivityResult(AuthorizeContract()) { - state.value = it - } - - val pending = savedInstanceState?.getBoolean(PENDING, false) ?: false - - BrowserLauncher.init(Launcher(delegate, state, pending)) - } - - override fun onSaveInstanceState(outState: Bundle) { - outState.putBoolean(PENDING, true) - super.onSaveInstanceState(outState) - } - - override fun onDestroy() { - super.onDestroy() - BrowserLauncher.reset() - } - - companion object { - val TAG: String = AppAuthFragment2::class.java.name - - /** - * Initialize the Fragment to receive AppAuth callback event. - */ - @Synchronized - @JvmStatic - fun launch(activity: FragmentActivity, browser: FRUser.Browser) { - val fragmentManager: FragmentManager = activity.supportFragmentManager - var current = fragmentManager.findFragmentByTag(TAG) as? AppAuthFragment2 - if (current == null) { - current = AppAuthFragment2() - fragmentManager.beginTransaction().add(current, TAG).commitNow() - } - - BrowserLauncher.authorize(browser, object : FRListener { - override fun onSuccess(result: AuthorizationResponse) { - reset(activity, current) - browser.listener.onSuccess(result) - } - - override fun onException(e: Exception) { - reset(activity, current) - browser.listener.onException(e) - } - - /** - * Once receive the result, reset state. - */ - private fun reset(activity: FragmentActivity, fragment: Fragment?) { - activity.runOnUiThread { - BrowserLauncher.reset() - } - fragment?.let { - activity.runOnUiThread { - fragmentManager.beginTransaction().remove(it).commitNow() - } - } - } - }) - - - } - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/AuthorizeContract.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/AuthorizeContract.kt deleted file mode 100644 index 1c4bbed8..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/AuthorizeContract.kt +++ /dev/null @@ -1,66 +0,0 @@ -/* - * Copyright (c) 2024 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth - -import android.content.Context -import android.content.Intent -import android.net.Uri -import androidx.activity.result.contract.ActivityResultContract -import androidx.browser.customtabs.CustomTabsIntent -import net.openid.appauth.AppAuthConfiguration -import net.openid.appauth.AuthorizationRequest -import net.openid.appauth.AuthorizationService -import org.forgerock.android.auth.FRUser.Browser - -/** - * This class is an implementation of the ActivityResultContract. - * It is used to handle the OAuth2 authorization process. - */ -internal class AuthorizeContract : ActivityResultContract() { - override fun createIntent( - context: Context, - input: Browser, - ): Intent { - val configurer: AppAuthConfigurer = input.appAuthConfigurer - val oAuth2Client = Config.getInstance().oAuth2Client - - val configuration = configurer.authorizationServiceConfigurationSupplier.get() - val authRequestBuilder = - AuthorizationRequest.Builder( - configuration, - oAuth2Client.clientId, - oAuth2Client.responseType, - Uri.parse(oAuth2Client.redirectUri), - ).setScope(oAuth2Client.scope) - - //Allow caller to override Authorization Request setting - configurer.authorizationRequestBuilder.accept(authRequestBuilder) - val authorizationRequest = authRequestBuilder.build() - - //Allow caller to override AppAuth default setting - val appAuthConfigurationBuilder = AppAuthConfiguration.Builder() - configurer.appAuthConfigurationBuilder.accept(appAuthConfigurationBuilder) - val authorizationService = AuthorizationService(context, appAuthConfigurationBuilder.build()) - - //Allow caller to override custom tabs default setting - val intentBuilder: CustomTabsIntent.Builder = - authorizationService.createCustomTabsIntentBuilder(authorizationRequest.toUri()) - configurer.customTabsIntentBuilder.accept(intentBuilder) - - val request = authRequestBuilder.build() - val service = AuthorizationService(context, AppAuthConfiguration.DEFAULT) - return service.getAuthorizationRequestIntent(request, intentBuilder.build()) - } - - override fun parseResult( - resultCode: Int, - intent: Intent?, - ): Intent? { - return intent - } -} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/Config.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/Config.java index 7195eedf..87e79a1a 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/Config.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/Config.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -35,6 +35,7 @@ public class Config { //OAuth2 private String clientId; private String redirectUri; + private String signOutRedirectUri; private String scope; private Long oauthCacheMillis; private Long oauthThreshold; @@ -127,6 +128,7 @@ public synchronized void init(Context context, @Nullable FROptions frOptions) { FROptions options = (frOptions == null) ? ConfigHelper.load(context, null) : frOptions; clientId = options.getOauth().getOauthClientId(); redirectUri = options.getOauth().getOauthRedirectUri(); + signOutRedirectUri = options.getOauth().getOauthSignOutRedirectUri(); scope = options.getOauth().getOauthScope(); oauthCacheMillis = options.getOauth().getOauthCacheSeconds() * 1000; oauthThreshold = options.getOauth().getOauthThresholdSeconds(); @@ -186,11 +188,12 @@ ServerConfig getServerConfig() { .build(); } - OAuth2Client getOAuth2Client() { + public OAuth2Client getOAuth2Client() { return OAuth2Client.builder() .clientId(clientId) .scope(scope) .redirectUri(redirectUri) + .signOutRedirectUri(signOutRedirectUri) .serverConfig(getServerConfig()) .build(); } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultTokenManager.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultTokenManager.java index 9e482b38..bafb7fd6 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultTokenManager.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/DefaultTokenManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -150,6 +150,11 @@ public void onException(Exception e) { } } + @Override + public AccessToken getAccessToken() { + return getAccessTokenLocally(); + } + @Override public boolean hasToken() { //Consider null if Access token does not exists diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRListener.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRListener.kt index db5a9a9d..5f7b7ffc 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRListener.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRListener.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -7,24 +7,54 @@ package org.forgerock.android.auth /** - * Listener to listen for event + * A listener interface for receiving completion events from asynchronous operations. * - * @param The type of the result + * The class that is interested in processing a completion event implements this interface, + * and the object created with that class is registered with the asynchronous operation, + * using the operation's `setListener` method. When the operation completes, + * that object's `onSuccess` or `onException` method is invoked. + * + * @param The type of the result. */ interface FRListener { /** - * Called when an asynchronous call completes successfully. + * Invoked when an asynchronous operation completes successfully. * - * @param result the value returned + * @param result The result of the operation. */ fun onSuccess(result: T) /** - * Called when an asynchronous call fails to complete. + * Invoked when an asynchronous operation fails. * - * @param e the reason for failure + * @param e The exception that caused the operation to fail. */ fun onException(e: Exception) +} +/** + * A no-op implementation of the FRListener interface that does nothing when + * the asynchronous operation completes. This can be used when you want to start + * an asynchronous operation, but don't care about the result. + * + * @param The type of the result. + */ +class DoNothingListener : FRListener { + /** + * Does nothing when the operation completes successfully. + * + * @param result The result of the operation. + */ + override fun onSuccess(result: T) { + // Do nothing + } + /** + * Does nothing when the operation fails. + * + * @param e The exception that caused the operation to fail. + */ + override fun onException(e: Exception) { + // Do nothing + } } \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt index 35f7df57..39cc2abf 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FROptions.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -148,6 +148,7 @@ class ServerBuilder { */ data class OAuth(val oauthClientId: String = "", val oauthRedirectUri: String = "", + val oauthSignOutRedirectUri: String = "", val oauthScope: String = "", val oauthThresholdSeconds: Long = 0, val oauthCacheSeconds: Long = 0) @@ -158,11 +159,13 @@ data class OAuth(val oauthClientId: String = "", class OAuthBuilder { var oauthClientId: String = "" var oauthRedirectUri: String = "" + var oauthSignOutRedirectUri: String = "" var oauthScope: String = "" var oauthThresholdSeconds: Long = 0 var oauthCacheSeconds: Long = 0 - fun build() : OAuth = OAuth(oauthClientId, oauthRedirectUri, oauthScope, oauthThresholdSeconds, oauthCacheSeconds) + fun build() : OAuth = OAuth(oauthClientId, oauthRedirectUri, oauthSignOutRedirectUri, + oauthScope, oauthThresholdSeconds, oauthCacheSeconds) } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRUser.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRUser.java index aa9cb636..9387da5f 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRUser.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRUser.java @@ -15,7 +15,6 @@ import android.net.Uri; import androidx.annotation.NonNull; -import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; @@ -23,7 +22,7 @@ import net.openid.appauth.AuthorizationResponse; import net.openid.appauth.RedirectUriReceiverActivity; -import org.forgerock.android.auth.centralize.BrowserLauncher; +import org.forgerock.android.auth.centralize.AppAuthFragment2; import org.forgerock.android.auth.exception.AlreadyAuthenticatedException; import org.forgerock.android.auth.exception.AuthenticationRequiredException; import org.forgerock.android.auth.exception.InvalidRedirectUriException; @@ -258,9 +257,7 @@ public static Browser browser() { public static class Browser { private static final String TAG = Browser.class.getName(); - private FRListener listener; - private AppAuthConfigurer appAuthConfigurer = new AppAuthConfigurer(this); - private boolean failedOnNoBrowserFound = true; + private final AppAuthConfigurer appAuthConfigurer = new AppAuthConfigurer(this); public AppAuthConfigurer appAuthConfigurer() { return appAuthConfigurer; @@ -311,7 +308,7 @@ public void login(FragmentActivity activity, FRListener listener) { return; } - this.listener = new FRListener<>() { + FRListener responseFRListener = new FRListener<>() { @Override public void onSuccess(AuthorizationResponse result) { InterceptorHandler interceptorHandler = InterceptorHandler.builder() @@ -331,7 +328,7 @@ public void onException(@NonNull Exception e) { } }; - AppAuthFragment2.launch(activity, this); + AppAuthFragment2.authorize(activity, this, responseFRListener); } private void validateRedirectUri(Context context) throws InvalidRedirectUriException { @@ -358,22 +355,9 @@ private void validateRedirectUri(Context context) throws InvalidRedirectUriExcep throw new InvalidRedirectUriException("No App is registered to capture the authorization code"); } - @VisibleForTesting - Browser failedOnNoBrowserFound(boolean failedOnNoBrowserFound) { - this.failedOnNoBrowserFound = failedOnNoBrowserFound; - return this; - } - - FRListener getListener() { - return this.listener; - } - AppAuthConfigurer getAppAuthConfigurer() { return this.appAuthConfigurer; } - boolean isFailedOnNoBrowserFound() { - return this.failedOnNoBrowserFound; - } } } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java deleted file mode 100644 index 8cc4b039..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java +++ /dev/null @@ -1,477 +0,0 @@ -/* - * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import android.net.Uri; -import android.util.Base64; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; - -import org.forgerock.android.auth.exception.AuthorizeException; -import org.jetbrains.annotations.NotNull; - -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.net.MalformedURLException; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.security.SecureRandom; -import java.util.Map; - -import okhttp3.Call; -import okhttp3.Callback; -import okhttp3.FormBody; -import okhttp3.OkHttpClient; -import okhttp3.RequestBody; -import okhttp3.Response; - -import static org.forgerock.android.auth.ServerConfig.ACCEPT_API_VERSION; -import static org.forgerock.android.auth.StringUtils.isNotEmpty; - -/** - * Class to handle OAuth2 related endpoint - */ -public class OAuth2Client { - - private static final String TAG = "OAuth2Client"; - private static final String CONTENT_TYPE = "Content-Type"; - private static final String APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"; - private static final Action AUTHORIZE = new Action(Action.AUTHORIZE); - private static final Action EXCHANGE_TOKEN = new Action(Action.EXCHANGE_TOKEN); - private static final Action REFRESH_TOKEN = new Action(Action.REFRESH_TOKEN); - private static final Action REVOKE_TOKEN = new Action(Action.REVOKE_TOKEN); - private static final Action END_SESSION = new Action(Action.END_SESSION); - private static final int STATE_LENGTH = 16; - - /** - * The registered client identifier - */ - private String clientId; - - private String scope; - private String redirectUri; - private String responseType = OAuth2.CODE; - - private ServerConfig serverConfig; - private OkHttpClient okHttpClient; - - @lombok.Builder - public OAuth2Client( - @NonNull String clientId, - @NonNull String scope, - @NonNull String redirectUri, - @NonNull ServerConfig serverConfig) { - - this.clientId = clientId; - this.scope = scope; - this.redirectUri = redirectUri; - this.serverConfig = serverConfig; - } - - /** - * Sends an authorization request to the authorization service. - * - * @param token The SSO Token received with the result of {@link AuthService} - * @param additionalParameters Additional parameters for inclusion in the authorization endpoint - * request - * @param listener Listener that listens to changes resulting from OAuth endpoints . - */ - public void exchangeToken(@NonNull SSOToken token, - @NonNull Map additionalParameters, - final FRListener listener) { - Logger.debug(TAG, "Exchanging Access Token with SSO Token."); - final OAuth2ResponseHandler handler = new OAuth2ResponseHandler(); - try { - FormBody.Builder builder = new FormBody.Builder(); - - if (scope != null) { - builder.add(OAuth2.SCOPE, scope); - } - - final PKCE pkce = generateCodeChallenge(); - final String state = generateState(); - - Logger.debug(TAG, "Exchanging Authorization Code with SSO Token."); - - okhttp3.Request request = new okhttp3.Request.Builder() - .url(getAuthorizeUrl(token, pkce, state, additionalParameters)) - .get() - .header(ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) - .header(serverConfig.getCookieName(), token.getValue()) - .tag(AUTHORIZE) - .build(); - - getOkHttpClient().newCall(request).enqueue(new okhttp3.Callback() { - - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - Logger.debug(TAG, "Failed to exchange for Authorization Code: %s", e.getMessage()); - listener.onException(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - handler.handleAuthorizeResponse(response, state, new FRListener() { - @Override - public void onException(Exception e) { - Logger.debug(TAG, "Failed to exchange for Authorization Code: %s", e.getMessage()); - listener.onException(new AuthorizeException("Failed to exchange authorization code with sso token", e)); - } - - @Override - public void onSuccess(String code) { - Logger.debug(TAG, "Authorization Code received."); - token(token, code, pkce, additionalParameters, handler, listener); - } - }); - } - - }); - - } catch (IOException e) { - listener.onException(e); - } - } - - static String generateState() { - SecureRandom secureRandom = new SecureRandom(); - byte[] random = new byte[STATE_LENGTH]; - secureRandom.nextBytes(random); - return Base64.encodeToString(random, Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE); - } - - - /** - * Refresh the Access Token with the provided Refresh Token - * - * @param sessionToken The Session Token that bind to existing AccessToken - * @param refreshToken The Refresh Token that use to refresh the Access Token - * @param listener Listen for endpoint event - */ - public void refresh(@Nullable SSOToken sessionToken, @NonNull String refreshToken, final FRListener listener) { - Logger.debug(TAG, "Refreshing Access Token"); - - final OAuth2ResponseHandler handler = new OAuth2ResponseHandler(); - try { - FormBody.Builder builder = new FormBody.Builder(); - - if (scope != null) { - builder.add(OAuth2.SCOPE, scope); - } - - RequestBody body = builder.add(OAuth2.CLIENT_ID, clientId) - .add(OAuth2.GRANT_TYPE, OAuth2.REFRESH_TOKEN) - .add(OAuth2.RESPONSE_TYPE, responseType) - .add(OAuth2.REFRESH_TOKEN, refreshToken) - .build(); - - okhttp3.Request request = new okhttp3.Request.Builder() - .url(getTokenUrl()) - .post(body) - .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) - .header(ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) - .tag(REFRESH_TOKEN) - .build(); - - - getOkHttpClient().newCall(request).enqueue(new okhttp3.Callback() { - - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - listener.onException(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - handler.handleTokenResponse(sessionToken, response, refreshToken, listener); - } - }); - - } catch (IOException e) { - listener.onException(e); - } - } - - /** - * Revoke the AccessToken, to revoke the access token, first look for refresh token to revoke, if - * not provided, will revoke with the access token. - * - * @param accessToken The AccessToken to be revoked - * @param listener Listener to listen for revoke event - */ - public void revoke(@NonNull AccessToken accessToken, final FRListener listener) { - revoke(accessToken, true, listener); - } - - /** - * Revoke the AccessToken, to revoke the access token, first look for refresh token to revoke, if - * not provided or useRefreshToken = false, will revoke with the access token. - * - * @param accessToken The AccessToken to be revoked - * @param useRefreshToken If true, revoke with refresh token, otherwise revoke access token - * @param listener Listener to listen for revoke event - */ - public void revoke(@NonNull AccessToken accessToken, boolean useRefreshToken, final FRListener listener) { - Logger.debug(TAG, "Revoking Access Token & Refresh Token"); - final OAuth2ResponseHandler handler = new OAuth2ResponseHandler(); - try { - FormBody.Builder builder = new FormBody.Builder(); - - String token = accessToken.getRefreshToken() == null || !useRefreshToken - ? accessToken.getValue() : accessToken.getRefreshToken(); - - RequestBody body = builder - .add(OAuth2.CLIENT_ID, clientId) - .add(OAuth2.TOKEN, token) - .build(); - - okhttp3.Request request = new okhttp3.Request.Builder() - .url(getRevokeUrl()) - .post(body) - .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) - .header(ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) - .tag(REVOKE_TOKEN) - .build(); - - - getOkHttpClient().newCall(request).enqueue(new okhttp3.Callback() { - - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - Listener.onException(listener, e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - handler.handleRevokeResponse(response, listener); - } - }); - - } catch (IOException e) { - Listener.onException(listener, e); - } - } - - /** - * End the user session with end session endpoint. - * - * @param idToken The ID_TOKEN which associated with the user session. - * @param listener Listener to listen for end session event. - */ - public void endSession(@NonNull String idToken, FRListener listener) { - - okhttp3.Request request = null; - try { - request = new okhttp3.Request.Builder() - .url(getEndSessionUrl(clientId, idToken)) - .get() - .tag(END_SESSION) - .build(); - } catch (MalformedURLException e) { - Listener.onException(listener, e); - return; - } - - final OAuth2ResponseHandler handler = new OAuth2ResponseHandler(); - Logger.debug(TAG, "End session with id token"); - getOkHttpClient().newCall(request).enqueue(new okhttp3.Callback() { - - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - Logger.debug(TAG, "Revoke session with id token failed: %s", e.getMessage()); - Listener.onException(listener, e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - handler.handleRevokeResponse(response, listener); - } - }); - } - - private OkHttpClient getOkHttpClient() { - if (okHttpClient == null) { - okHttpClient = OkHttpClientProvider.getInstance().lookup(serverConfig); - } - return okHttpClient; - } - - /** - * Sends an token request to the authorization service. - * - * @param sessionToken The Session Token - * @param code The Authorization code. - * @param pkce The Proof Key for Code Exchange - * @param additionalParameters Additional parameters for inclusion in the token endpoint - * request - * @param handler Handle changes resulting from OAuth endpoints. - */ - public void token(@Nullable SSOToken sessionToken, - @NonNull String code, - final PKCE pkce, - final Map additionalParameters, - final OAuth2ResponseHandler handler, - final FRListener listener) { - Logger.debug(TAG, "Exchange Access Token with Authorization Code"); - try { - FormBody.Builder builder = new FormBody.Builder(); - - for (Map.Entry entry : additionalParameters.entrySet()) { - builder.add(entry.getKey(), entry.getValue()); - } - - RequestBody body = builder - .add(OAuth2.CLIENT_ID, clientId) - .add(OAuth2.CODE, code) - .add(OAuth2.REDIRECT_URI, redirectUri) - .add(OAuth2.GRANT_TYPE, OAuth2.AUTHORIZATION_CODE) - .add(OAuth2.CODE_VERIFIER, pkce.getCodeVerifier()) - .build(); - - okhttp3.Request request = new okhttp3.Request.Builder() - .url(getTokenUrl()) - .post(body) - .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) - .header(ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) - .tag(EXCHANGE_TOKEN) - .build(); - - getOkHttpClient().newCall(request).enqueue(new Callback() { - @Override - public void onFailure(@NotNull Call call, @NotNull IOException e) { - Logger.debug(TAG, "Exchange Access Token with Authorization Code failed: %s", e.getMessage()); - listener.onException(e); - } - - @Override - public void onResponse(@NotNull Call call, @NotNull Response response) { - handler.handleTokenResponse(sessionToken, response, null, listener); - } - - }); - - } catch (IOException e) { - listener.onException(e); - } - } - - private URL getAuthorizeUrl(Token token, PKCE pkce, String state, Map additionalParameters) - throws MalformedURLException, UnsupportedEncodingException { - - Uri.Builder builder = Uri.parse(getAuthorizeUrl().toString()).buildUpon(); - for (Map.Entry entry : additionalParameters.entrySet()) { - builder.appendQueryParameter(entry.getKey(), entry.getValue()); - } - return new URL(builder - .appendQueryParameter(OAuth2.CLIENT_ID, clientId) - .appendQueryParameter(OAuth2.SCOPE, scope) - .appendQueryParameter(OAuth2.RESPONSE_TYPE, responseType) - .appendQueryParameter(OAuth2.REDIRECT_URI, redirectUri) - .appendQueryParameter(OAuth2.CODE_CHALLENGE, pkce.getCodeChallenge()) - .appendQueryParameter(OAuth2.CODE_CHALLENGE_METHOD, pkce.getCodeChallengeMethod()) - .appendQueryParameter(OAuth2.STATE, state) - .build().toString()); - } - - URL getAuthorizeUrl() throws MalformedURLException { - Uri.Builder builder = Uri.parse(serverConfig.getUrl()).buildUpon(); - if (isNotEmpty(serverConfig.getAuthorizeEndpoint())) { - builder.appendEncodedPath(serverConfig.getAuthorizeEndpoint()); - } else { - builder.appendPath("oauth2") - .appendPath("realms") - .appendPath(serverConfig.getRealm()) - .appendPath("authorize"); - } - return new URL(builder.build().toString()); - } - - URL getTokenUrl() throws MalformedURLException { - Uri.Builder builder = Uri.parse(serverConfig.getUrl()).buildUpon(); - if (isNotEmpty(serverConfig.getTokenEndpoint())) { - builder.appendEncodedPath(serverConfig.getTokenEndpoint()); - } else { - builder.appendPath("oauth2") - .appendPath("realms") - .appendPath(serverConfig.getRealm()) - .appendPath("access_token"); - } - return new URL(builder.build().toString()); - } - - URL getRevokeUrl() throws MalformedURLException { - - Uri.Builder builder = Uri.parse(serverConfig.getUrl()).buildUpon(); - if (isNotEmpty(serverConfig.getRevokeEndpoint())) { - builder.appendEncodedPath(serverConfig.getRevokeEndpoint()); - } else { - builder.appendPath("oauth2") - .appendPath("realms") - .appendPath(serverConfig.getRealm()) - .appendPath("token") - .appendPath("revoke"); - } - return new URL(builder.build().toString()); - } - - URL getEndSessionUrl(String clientId, String idToken) throws MalformedURLException { - - Uri.Builder builder = Uri.parse(serverConfig.getUrl()).buildUpon(); - if (isNotEmpty(serverConfig.getEndSessionEndpoint())) { - builder.appendEncodedPath(serverConfig.getEndSessionEndpoint()); - } else { - builder.appendPath("oauth2") - .appendPath("realms") - .appendPath(serverConfig.getRealm()) - .appendPath("connect") - .appendPath("endSession"); - } - builder.appendQueryParameter("id_token_hint", idToken); - builder.appendQueryParameter("client_id", clientId); - return new URL(builder.build().toString()); - } - - - private PKCE generateCodeChallenge() throws UnsupportedEncodingException { - int encodeFlags = Base64.NO_WRAP | Base64.NO_PADDING | Base64.URL_SAFE; - byte[] randomBytes = new byte[64]; - new SecureRandom().nextBytes(randomBytes); - String codeVerifier = Base64.encodeToString(randomBytes, encodeFlags); - try { - MessageDigest messageDigest = MessageDigest.getInstance("SHA-256"); - messageDigest.update(codeVerifier.getBytes(StandardCharsets.ISO_8859_1)); - byte[] digestBytes = messageDigest.digest(); - return new PKCE(Base64.encodeToString(digestBytes, encodeFlags), "S256", codeVerifier); - } catch (NoSuchAlgorithmException e) { - return new PKCE("plain", codeVerifier, codeVerifier); - } - } - - String getClientId() { - return this.clientId; - } - - String getScope() { - return this.scope; - } - - String getRedirectUri() { - return this.redirectUri; - } - - String getResponseType() { - return this.responseType; - } - - ServerConfig getServerConfig() { - return this.serverConfig; - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt new file mode 100644 index 00000000..626c5be5 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt @@ -0,0 +1,472 @@ +/* + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +import android.net.Uri +import android.util.Base64 +import okhttp3.Call +import okhttp3.Callback +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody +import okhttp3.Response +import org.forgerock.android.auth.Logger.Companion.debug +import org.forgerock.android.auth.exception.AuthorizeException +import java.io.IOException +import java.io.UnsupportedEncodingException +import java.net.MalformedURLException +import java.net.URL +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.security.NoSuchAlgorithmException +import java.security.SecureRandom + +/** + * Class to handle OAuth2 related endpoint + */ +class OAuth2Client( + /** + * The registered client identifier + */ + val clientId: String, + val scope: String, + val redirectUri: String, + val signOutRedirectUri: String, + val serverConfig: ServerConfig) { + + val responseType: String = OAuth2.CODE + + private val okHttpClient: OkHttpClient by lazy { + OkHttpClientProvider.lookup(serverConfig) + } + + /** + * Sends an authorization request to the authorization service. + * + * @param token The SSO Token received with the result of [AuthService] + * @param additionalParameters Additional parameters for inclusion in the authorization endpoint + * request + * @param listener Listener that listens to changes resulting from OAuth endpoints . + */ + fun exchangeToken(token: SSOToken, + additionalParameters: Map, + listener: FRListener) { + debug(TAG, "Exchanging Access Token with SSO Token.") + val handler = OAuth2ResponseHandler() + try { + val builder = FormBody.Builder() + + builder.add(OAuth2.SCOPE, scope) + + val pkce = generateCodeChallenge() + val state = generateState() + + debug(TAG, "Exchanging Authorization Code with SSO Token.") + + val request = Request.Builder() + .url(getAuthorizeUrl(token, pkce, state, additionalParameters)) + .get() + .header(ServerConfig.ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) + .header(serverConfig.cookieName, token.value) + .tag(AUTHORIZE) + .build() + + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + debug(TAG, "Failed to exchange for Authorization Code: %s", e.message) + listener.onException(e) + } + + override fun onResponse(call: Call, response: Response) { + handler.handleAuthorizeResponse(response, state, object : FRListener { + override fun onException(e: Exception) { + debug(TAG, "Failed to exchange for Authorization Code: %s", e.message) + listener.onException(AuthorizeException("Failed to exchange authorization code with sso token", + e)) + } + + override fun onSuccess(result: String) { + debug(TAG, "Authorization Code received.") + token(token, result, pkce, additionalParameters, handler, listener) + } + }) + } + }) + } catch (e: IOException) { + listener.onException(e) + } + } + + /** + * Refresh the Access Token with the provided Refresh Token + * + * @param sessionToken The Session Token that bind to existing AccessToken + * @param refreshToken The Refresh Token that use to refresh the Access Token + * @param listener Listen for endpoint event + */ + fun refresh(sessionToken: SSOToken?, refreshToken: String, listener: FRListener) { + debug(TAG, "Refreshing Access Token") + + val handler = OAuth2ResponseHandler() + try { + val builder = FormBody.Builder() + + builder.add(OAuth2.SCOPE, scope) + + val body: RequestBody = builder.add(OAuth2.CLIENT_ID, clientId) + .add(OAuth2.GRANT_TYPE, OAuth2.REFRESH_TOKEN) + .add(OAuth2.RESPONSE_TYPE, responseType) + .add(OAuth2.REFRESH_TOKEN, refreshToken) + .build() + + val request: Request = Request.Builder() + .url(tokenUrl) + .post(body) + .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) + .header(ServerConfig.ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) + .tag(REFRESH_TOKEN) + .build() + + + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + listener.onException(e) + } + + override fun onResponse(call: Call, response: Response) { + handler.handleTokenResponse(sessionToken, response, refreshToken, listener) + } + }) + } catch (e: IOException) { + listener.onException(e) + } + } + + /** + * Revoke the AccessToken, to revoke the access token, first look for refresh token to revoke, if + * not provided, will revoke with the access token. + * + * @param accessToken The AccessToken to be revoked + * @param listener Listener to listen for revoke event + */ + fun revoke(accessToken: AccessToken, listener: FRListener?) { + revoke(accessToken, true, listener) + } + + /** + * Revoke the AccessToken, to revoke the access token, first look for refresh token to revoke, if + * not provided or useRefreshToken = false, will revoke with the access token. + * + * @param accessToken The AccessToken to be revoked + * @param useRefreshToken If true, revoke with refresh token, otherwise revoke access token + * @param listener Listener to listen for revoke event + */ + fun revoke(accessToken: AccessToken, useRefreshToken: Boolean, listener: FRListener?) { + debug(TAG, "Revoking Access Token & Refresh Token") + val handler = OAuth2ResponseHandler() + try { + val builder = FormBody.Builder() + + val token = if (accessToken.refreshToken == null || !useRefreshToken + ) accessToken.value else accessToken.refreshToken + + val body: RequestBody = builder + .add(OAuth2.CLIENT_ID, clientId) + .add(OAuth2.TOKEN, token) + .build() + + val request: Request = Request.Builder() + .url(revokeUrl) + .post(body) + .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) + .header(ServerConfig.ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) + .tag(REVOKE_TOKEN) + .build() + + + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Listener.onException(listener, e) + } + + override fun onResponse(call: Call, response: Response) { + handler.handleRevokeResponse(response, listener) + } + }) + } catch (e: IOException) { + Listener.onException(listener, e) + } + } + + /** + * End the user session with end session endpoint. + * + * @param idToken The ID_TOKEN which associated with the user session. + * @param listener Listener to listen for end session event. + */ + fun endSession(idToken: String, listener: FRListener?) { + val request: Request? + try { + request = Request.Builder() + .url(getEndSessionUrl(clientId, idToken)) + .get() + .tag(END_SESSION) + .build() + } catch (e: MalformedURLException) { + Listener.onException(listener, e) + return + } + + val handler = OAuth2ResponseHandler() + debug(TAG, "End session with id token") + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + debug(TAG, "Revoke session with id token failed: %s", e.message) + Listener.onException(listener, e) + } + + override fun onResponse(call: Call, response: Response) { + handler.handleRevokeResponse(response, listener) + } + }) + } + + /** + * Sends an token request to the authorization service. + * + * @param sessionToken The Session Token + * @param code The Authorization code. + * @param pkce The Proof Key for Code Exchange + * @param additionalParameters Additional parameters for inclusion in the token endpoint + * request + * @param handler Handle changes resulting from OAuth endpoints. + */ + fun token(sessionToken: SSOToken?, + code: String, + pkce: PKCE, + additionalParameters: Map, + handler: OAuth2ResponseHandler, + listener: FRListener) { + debug(TAG, "Exchange Access Token with Authorization Code") + try { + val builder = FormBody.Builder() + + for ((key, value) in additionalParameters) { + builder.add(key, value) + } + + val body: RequestBody = builder + .add(OAuth2.CLIENT_ID, clientId) + .add(OAuth2.CODE, code) + .add(OAuth2.REDIRECT_URI, redirectUri) + .add(OAuth2.GRANT_TYPE, OAuth2.AUTHORIZATION_CODE) + .add(OAuth2.CODE_VERIFIER, pkce.codeVerifier) + .build() + + val request: Request = Request.Builder() + .url(tokenUrl) + .post(body) + .header(CONTENT_TYPE, APPLICATION_X_WWW_FORM_URLENCODED) + .header(ServerConfig.ACCEPT_API_VERSION, ServerConfig.API_VERSION_2_1) + .tag(EXCHANGE_TOKEN) + .build() + + okHttpClient.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + debug(TAG, + "Exchange Access Token with Authorization Code failed: %s", + e.message) + listener.onException(e) + } + + override fun onResponse(call: Call, response: Response) { + handler.handleTokenResponse(sessionToken, response, null, listener) + } + }) + } catch (e: IOException) { + listener.onException(e) + } + } + + @Throws(MalformedURLException::class, UnsupportedEncodingException::class) + private fun getAuthorizeUrl(token: Token, + pkce: PKCE, + state: String, + additionalParameters: Map): URL { + val builder = Uri.parse(authorizeUrl.toString()).buildUpon() + for ((key, value) in additionalParameters) { + builder.appendQueryParameter(key, value) + } + return URL(builder + .appendQueryParameter(OAuth2.CLIENT_ID, clientId) + .appendQueryParameter(OAuth2.SCOPE, scope) + .appendQueryParameter(OAuth2.RESPONSE_TYPE, responseType) + .appendQueryParameter(OAuth2.REDIRECT_URI, redirectUri) + .appendQueryParameter(OAuth2.CODE_CHALLENGE, pkce.codeChallenge) + .appendQueryParameter(OAuth2.CODE_CHALLENGE_METHOD, pkce.codeChallengeMethod) + .appendQueryParameter(OAuth2.STATE, state) + .build().toString()) + } + + @get:Throws(MalformedURLException::class) + val authorizeUrl: URL + get() { + val builder = Uri.parse(serverConfig.url).buildUpon() + if (StringUtils.isNotEmpty(serverConfig.authorizeEndpoint)) { + builder.appendEncodedPath(serverConfig.authorizeEndpoint) + } else { + builder.appendPath("oauth2") + .appendPath("realms") + .appendPath(serverConfig.realm) + .appendPath("authorize") + } + return URL(builder.build().toString()) + } + + @get:Throws(MalformedURLException::class) + val tokenUrl: URL + get() { + val builder = Uri.parse(serverConfig.url).buildUpon() + if (StringUtils.isNotEmpty(serverConfig.tokenEndpoint)) { + builder.appendEncodedPath(serverConfig.tokenEndpoint) + } else { + builder.appendPath("oauth2") + .appendPath("realms") + .appendPath(serverConfig.realm) + .appendPath("access_token") + } + return URL(builder.build().toString()) + } + + @get:Throws(MalformedURLException::class) + val revokeUrl: URL + get() { + val builder = Uri.parse(serverConfig.url).buildUpon() + if (StringUtils.isNotEmpty(serverConfig.revokeEndpoint)) { + builder.appendEncodedPath(serverConfig.revokeEndpoint) + } else { + builder.appendPath("oauth2") + .appendPath("realms") + .appendPath(serverConfig.realm) + .appendPath("token") + .appendPath("revoke") + } + return URL(builder.build().toString()) + } + + @get:Throws(MalformedURLException::class) + val endSessionUrl: URL + get() { + val builder = Uri.parse(serverConfig.url).buildUpon() + if (StringUtils.isNotEmpty(serverConfig.endSessionEndpoint)) { + builder.appendEncodedPath(serverConfig.endSessionEndpoint) + } else { + builder.appendPath("oauth2") + .appendPath("realms") + .appendPath(serverConfig.realm) + .appendPath("connect") + .appendPath("endSession") + } + return URL(builder.build().toString()) + } + + + @Throws(MalformedURLException::class) + fun getEndSessionUrl(clientId: String?, idToken: String?): URL { + val builder = Uri.parse(endSessionUrl.toString()).buildUpon() + builder.appendQueryParameter("id_token_hint", idToken) + builder.appendQueryParameter("client_id", clientId) + return URL(builder.build().toString()) + } + + + @Throws(UnsupportedEncodingException::class) + private fun generateCodeChallenge(): PKCE { + val encodeFlags = Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE + val randomBytes = ByteArray(64) + SecureRandom().nextBytes(randomBytes) + val codeVerifier = Base64.encodeToString(randomBytes, encodeFlags) + try { + val messageDigest = MessageDigest.getInstance("SHA-256") + messageDigest.update(codeVerifier.toByteArray(StandardCharsets.ISO_8859_1)) + val digestBytes = messageDigest.digest() + return PKCE(Base64.encodeToString(digestBytes, encodeFlags), "S256", codeVerifier) + } catch (e: NoSuchAlgorithmException) { + return PKCE("plain", codeVerifier, codeVerifier) + } + } + + class OAuth2ClientBuilder internal constructor() { + private var clientId: String = "" + private var scope: String = "" + private var redirectUri: String = "" + private var signOutRedirectUri: String = "" + private var serverConfig: ServerConfig? = null + fun clientId(clientId: String): OAuth2ClientBuilder { + this.clientId = clientId + return this + } + + fun scope(scope: String): OAuth2ClientBuilder { + this.scope = scope + return this + } + + fun redirectUri(redirectUri: String): OAuth2ClientBuilder { + this.redirectUri = redirectUri + return this + } + + fun signOutRedirectUri(signOutRedirectUri: String): OAuth2ClientBuilder { + this.signOutRedirectUri = signOutRedirectUri + return this + } + + fun serverConfig(serverConfig: ServerConfig): OAuth2ClientBuilder { + this.serverConfig = serverConfig + return this + } + + fun build(): OAuth2Client { + return OAuth2Client(clientId, + scope, + redirectUri, + signOutRedirectUri, + serverConfig!!) + } + + override fun toString(): String { + return "OAuth2Client.OAuth2ClientBuilder(clientId=" + this.clientId + ", scope=" + this.scope + ", redirectUri=" + this.redirectUri + ", signOutRedirectUri=" + this.signOutRedirectUri + ", serverConfig=" + this.serverConfig + ")" + } + } + + companion object { + private const val TAG = "OAuth2Client" + private const val CONTENT_TYPE = "Content-Type" + private const val APPLICATION_X_WWW_FORM_URLENCODED = "application/x-www-form-urlencoded" + private val AUTHORIZE = Action(Action.AUTHORIZE) + private val EXCHANGE_TOKEN = Action(Action.EXCHANGE_TOKEN) + private val REFRESH_TOKEN = Action(Action.REFRESH_TOKEN) + private val REVOKE_TOKEN = Action(Action.REVOKE_TOKEN) + private val END_SESSION = Action(Action.END_SESSION) + private const val STATE_LENGTH = 16 + + @JvmStatic + fun builder(): OAuth2ClientBuilder { + return OAuth2ClientBuilder() + } + + fun generateState(): String { + val secureRandom = SecureRandom() + val random = ByteArray(STATE_LENGTH) + secureRandom.nextBytes(random) + return Base64.encodeToString(random, + Base64.NO_WRAP or Base64.NO_PADDING or Base64.URL_SAFE) + } + } +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2ResponseHandler.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2ResponseHandler.java index 85b0854b..50018b55 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2ResponseHandler.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2ResponseHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2022 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -25,7 +25,7 @@ /** * Implementation for handling {@link OAuth2Client} response, and provide feedback to the registered {@link FRListener} */ -class OAuth2ResponseHandler implements ResponseHandler { +public class OAuth2ResponseHandler implements ResponseHandler { private static final String TAG = OAuth2ResponseHandler.class.getSimpleName(); diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.java deleted file mode 100644 index 7f29d135..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.java +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2019 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth; - -import lombok.AccessLevel; -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -/** - * Domain object to store PKCE related data. - */ -@RequiredArgsConstructor(access = AccessLevel.PACKAGE) -@Getter(value = AccessLevel.PACKAGE) -class PKCE { - - private final String codeChallenge; - private final String codeChallengeMethod; - private final String codeVerifier; - -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.kt new file mode 100644 index 00000000..d30b6f9b --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/PKCE.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth + +/** + * Domain object to store PKCE related data. + */ +class PKCE(val codeChallenge: String, + val codeChallengeMethod: String, + val codeVerifier: String) diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/Result.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/Result.kt new file mode 100644 index 00000000..7058d4dd --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/Result.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth + +/** + * A sealed class representing a result, which can be either Success or Failure. + * + * @param Success The type of the success value. + * @param Failure The type of the failure value. + */ +internal sealed class Result { + /** + * Represents a failure result. + * + * @property value The failure value. + */ + data class Failure(val value: Failure) : Result() + + /** + * Represents a success result. + * + * @property value The success value. + */ + data class Success(val value: Success) : Result() +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/SessionManager.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/SessionManager.java index 216f9782..3666dfaa 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/SessionManager.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/SessionManager.java @@ -11,6 +11,7 @@ import androidx.annotation.VisibleForTesting; import androidx.annotation.WorkerThread; +import org.forgerock.android.auth.centralize.AppAuthFragment2; import org.forgerock.android.auth.exception.AuthenticationRequiredException; import java.util.Arrays; @@ -117,10 +118,28 @@ boolean hasSession() { * Close the session, all tokens will be removed. */ public void close() { + browserLogout(); tokenManager.revoke(null); singleSignOnManager.revoke(null); } + private void browserLogout() { + try { + OAuth2Client oAuth2Client = Config.getInstance().getOAuth2Client(); + if (StringUtils.isNotEmpty(oAuth2Client.getSignOutRedirectUri())) { + AccessToken accessToken = getTokenManager().getAccessToken(); + String idToken = ""; + if (accessToken != null && accessToken.getIdToken() != null) { + idToken = accessToken.getIdToken(); + } + AppAuthFragment2.endSession(oAuth2Client, idToken, new DoNothingListener<>()); + } + } catch (Exception e) { + //ignore + } + } + + /** * Calling TokenManager to revoke OAuth2.0 tokens * diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/StringUtils.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/StringUtils.java index 694a75a4..ca056dbc 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/StringUtils.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/StringUtils.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2020 - 2021 ForgeRock. All rights reserved. + * Copyright (c) 2020 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -21,7 +21,7 @@ private StringUtils() { * @param s string to test * @return test if the specified string is not null and not empty. */ - static boolean isNotEmpty(final String s) { + public static boolean isNotEmpty(final String s) { return (s != null && s.length() > 0); } diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/Token.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/Token.kt index d19ffe8f..d99165e8 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/Token.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/Token.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -11,7 +11,7 @@ import java.io.Serializable /** * Domain object to hold generic Token */ -open class Token(val value: String) : Serializable { +open class Token(open val value: String) : Serializable { override fun equals(other: Any?): Boolean { if (this === other) return true if (other !is Token) return false diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/TokenManager.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/TokenManager.java index 480ec570..f0f7b03a 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/TokenManager.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/TokenManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2019 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2019 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -63,6 +63,12 @@ public interface TokenManager { */ void getAccessToken(AccessTokenVerifier accessTokenVerifier, FRListener tokenListener); + /** + * Get the persisted {@link AccessToken}, no validation and no auto refresh, just return the stored {@link AccessToken} + * @return The AccessToken if exists, otherwise null + */ + AccessToken getAccessToken(); + /** * Check if token exists in the storage. * diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AppAuthFragment2.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AppAuthFragment2.kt new file mode 100644 index 00000000..5d343d0b --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AppAuthFragment2.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.centralize + +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.EndSessionResponse +import org.forgerock.android.auth.FRListener +import org.forgerock.android.auth.FRUser +import org.forgerock.android.auth.InitProvider +import org.forgerock.android.auth.OAuth2Client +import org.forgerock.android.auth.Result +import org.forgerock.android.auth.exception.BrowserAuthenticationException + +private const val PENDING = "pending" + +/** + * Headless Fragment to receive callback result from AppAuth library + */ +internal class AppAuthFragment2 : Fragment() { + + private var pending: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val state: MutableStateFlow?> = + MutableStateFlow(null) + + val delegate = + registerForActivityResult(AuthorizeContract()) { + when (it) { + is Result.Failure -> state.value = Result.Failure(it.value) + is Result.Success -> { + state.value = Result.Success(it.value) + } + + else -> { + state.value = + Result.Failure(BrowserAuthenticationException("Unknown Error")) + } + } + } + + val endSessionState: MutableStateFlow?> = + MutableStateFlow(null) + val endSession = + registerForActivityResult(EndSessionContract()) { + when (it) { + is Result.Failure -> endSessionState.value = Result.Failure(it.value) + is Result.Success -> { + endSessionState.value = Result.Success(it.value) + } + + else -> { + endSessionState.value = + Result.Failure(BrowserAuthenticationException("Unknown Error")) + } + } + } + + pending = savedInstanceState?.getBoolean(PENDING, false) ?: false + + BrowserLauncher.init( + Launcher( + Pair(delegate, state), + Pair(endSession, endSessionState), + ), + ) + + } + + override fun onSaveInstanceState(outState: Bundle) { + outState.putBoolean(PENDING, true) + super.onSaveInstanceState(outState) + } + + override fun onDestroy() { + super.onDestroy() + BrowserLauncher.reset() + } + + companion object { + val TAG: String = AppAuthFragment2::class.java.name + + /** + * Initialize the Fragment to receive AppAuth callback event. + */ + @Synchronized + @JvmStatic + fun authorize(activity: FragmentActivity, + browser: FRUser.Browser, + listener: FRListener) { + val fragmentManager: FragmentManager = activity.supportFragmentManager + var current = fragmentManager.findFragmentByTag(TAG) as? AppAuthFragment2 + if (current == null) { + current = AppAuthFragment2() + fragmentManager.beginTransaction().add(current, TAG).commitNow() + } + + CoroutineScope(Dispatchers.Default).launch { + try { + listener.onSuccess(BrowserLauncher.authorize(browser, current.pending)) + reset(activity, fragmentManager, current) + } catch (e: Exception) { + listener.onException(e) + reset(activity, fragmentManager, current) + } + } + } + + @Synchronized + @JvmStatic + fun endSession( + oAuth2Client: OAuth2Client, + idToken: String, + listener: FRListener) { + endSession(InitProvider.getCurrentActivityAsFragmentActivity(), oAuth2Client, idToken, listener) + } + + @Synchronized + @JvmStatic + fun endSession(activity: FragmentActivity, + oAuth2Client: OAuth2Client, + idToken: String, + listener: FRListener) { + val fragmentManager: FragmentManager = activity.supportFragmentManager + var current = fragmentManager.findFragmentByTag(TAG) as? AppAuthFragment2 + if (current == null) { + current = AppAuthFragment2() + fragmentManager.beginTransaction().add(current, TAG).commitNow() + } + + CoroutineScope(Dispatchers.Default).launch { + try { + listener.onSuccess(BrowserLauncher.endSession(oAuth2Client, + idToken, + current.pending)) + reset(activity, fragmentManager, current) + } catch (e: Exception) { + listener.onException(e) + reset(activity, fragmentManager, current) + } + } + } + + /** + * Once receive the result, reset state. + */ + private fun reset(activity: FragmentActivity, + fragmentManager: FragmentManager, + fragment: Fragment?) { + activity.runOnUiThread { + BrowserLauncher.reset() + } + fragment?.let { + activity.runOnUiThread { + fragmentManager.beginTransaction().remove(it).commitNow() + } + } + } + } +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AuthorizeContract.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AuthorizeContract.kt new file mode 100644 index 00000000..229608e6 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/AuthorizeContract.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ + +package org.forgerock.android.auth.centralize + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import androidx.browser.customtabs.CustomTabsIntent +import net.openid.appauth.AppAuthConfiguration +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import org.forgerock.android.auth.AppAuthConfigurer +import org.forgerock.android.auth.Config +import org.forgerock.android.auth.FRUser.Browser +import org.forgerock.android.auth.Result +import org.forgerock.android.auth.exception.BrowserAuthenticationException + +/** + * This class is an [ActivityResultContract] for the OpenID Connect authorization process. + * It creates an intent for the authorization request and parses the result of the authorization response. + */ +internal class AuthorizeContract : + ActivityResultContract>() { + /** + * Creates an intent for the authorization request. + * + * @param context The context to use for creating the intent. + * @param input The configuration for the OpenID Connect client. + * @return The intent for the authorization request. + */ + override fun createIntent( + context: Context, + input: Browser, + ): Intent { + val oAuth2Client = Config.getInstance().oAuth2Client + val configuration = + AuthorizationServiceConfiguration( + Uri.parse(oAuth2Client.authorizeUrl.toString()), + Uri.parse(oAuth2Client.tokenUrl.toString()), + ) + val builder = + AuthorizationRequest.Builder( + configuration, + oAuth2Client.clientId, + oAuth2Client.responseType, + Uri.parse(oAuth2Client.redirectUri), + ).setScope(oAuth2Client.scope) + + //Allow caller to override Authorization Request setting + val configurer: AppAuthConfigurer = input.appAuthConfigurer() + configurer.authorizationRequestBuilder.accept(builder) + val authorizationRequest = builder.build() + + //Allow caller to override AppAuth default setting + val appAuthConfigurationBuilder = AppAuthConfiguration.Builder() + configurer.appAuthConfigurationBuilder.accept(appAuthConfigurationBuilder) + val authorizationService = + AuthorizationService(context, appAuthConfigurationBuilder.build()) + + //Allow caller to override custom tabs default setting + val intentBuilder: CustomTabsIntent.Builder = + authorizationService.createCustomTabsIntentBuilder(authorizationRequest.toUri()) + configurer.customTabsIntentBuilder.accept(intentBuilder) + + val request = builder.build() + val service = AuthorizationService(context, AppAuthConfiguration.DEFAULT) + return service.getAuthorizationRequestIntent(request, intentBuilder.build()) + + } + + /** + * Parses the result of the authorization response. + * + * @param resultCode The result code of the authorization response. + * @param intent The intent of the authorization response. + * @return A Result containing the authorization response or an error. + */ + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): Result { + intent?.let { i -> + val error = AuthorizationException.fromIntent(i) + error?.let { + return Result.Failure( + BrowserAuthenticationException( + "Failed to retrieve authorization code. ${it.message}", + it, + ), + ) + } + val result = AuthorizationResponse.fromIntent(i) + result?.let { + return Result.Success(it) + } ?: return Result.Failure(BrowserAuthenticationException("Failed to retrieve authorization code")) + } + return Result.Failure(BrowserAuthenticationException("No response data")) + } + +} \ No newline at end of file diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/BrowserLauncher.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/BrowserLauncher.kt index ef4ffdc3..35135c18 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/BrowserLauncher.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/BrowserLauncher.kt @@ -7,62 +7,80 @@ package org.forgerock.android.auth.centralize -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import net.openid.appauth.AuthorizationResponse -import org.forgerock.android.auth.FRListener -import org.forgerock.android.auth.FRUser -import org.forgerock.android.auth.Listener +import net.openid.appauth.EndSessionResponse +import org.forgerock.android.auth.FRUser.Browser +import org.forgerock.android.auth.OAuth2Client /** - * Singleton class to launch the Browser - * Centralize login browser launcher. + * This object is responsible for launching the browser for OpenID Connect operations. */ -internal object BrowserLauncher { +object BrowserLauncher { + + private val isInitialized = MutableStateFlow(false) private var launcher: Launcher? = null /** - * Initialize the Launcher + * Initializes the launcher. + * @param launcher The launcher to initialize. */ - @Synchronized internal fun init(launcher: Launcher) { this.launcher = launcher + isInitialized.value = true } /** - * reset the Launcher state + * Resets the launcher. */ - @Synchronized internal fun reset() { - launcher?.authorize?.unregister() + launcher?.authorize?.first?.unregister() + launcher?.endSession?.first?.unregister() + isInitialized.value = false launcher = null } /** - * Authorize the user using the Browser + * Starts the authorization process. + * + * @param browser The configuration for the OpenID Connect client. + * @param pending A boolean indicating whether the authorization process is pending. + * @return The authorization code. + * @throws IllegalStateException If the BrowserLauncherActivity is not initialized. */ - suspend fun authorize(browser: FRUser.Browser): AuthorizationResponse { - return launcher?.authorize(browser) - ?: throw IllegalStateException("Launcher is not initialized") + suspend fun authorize( + browser: Browser, + pending: Boolean = false, + ): AuthorizationResponse { + // Wait until the launcher is initialized + // The launcher is initialized in the AppAuthFragment2 onCreate method + return isInitialized.first { it }.let { + launcher?.authorize(browser, pending) + ?: throw IllegalStateException("BrowserLauncherActivity not initialized") + } } /** - * Authorize the user using the Browser - */ - fun authorize(browser: FRUser.Browser, listener: FRListener): Boolean { - return launcher?.let { - val scope = CoroutineScope(Dispatchers.Default) - scope.launch { - try { - val result = authorize(browser) - Listener.onSuccess(listener, result) - } catch (e: Exception) { - Listener.onException(listener, e) - } - } - return true - } ?: false + * Ends the session. + * + * @param oauth2Client The configuration for the OpenID Connect client. + * @param idToken The ID token for the session. + * @param pending A boolean indicating whether the session end process is pending. + * @return A boolean indicating whether the session was ended successfully. + * @throws IllegalStateException If the BrowserLauncherActivity is not initialized. + */ + suspend fun endSession( + oauth2Client: OAuth2Client, + idToken: String, + pending: Boolean = false, + ): EndSessionResponse { + // Wait until the launcher is initialized + // The launcher is initialized in the AppAuthFragment2 onCreate method + return isInitialized.first { it }.let { + launcher?.endSession(Pair(idToken, oauth2Client), pending) + ?: throw IllegalStateException("BrowserLauncherActivity not initialized") + } } -} \ No newline at end of file +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/EndSessionContract.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/EndSessionContract.kt new file mode 100644 index 00000000..d2961106 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/EndSessionContract.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.centralize + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract +import org.forgerock.android.auth.Result +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.EndSessionRequest +import net.openid.appauth.EndSessionResponse +import org.forgerock.android.auth.OAuth2Client +import org.forgerock.android.auth.StringUtils + +/** + * This class is an ActivityResultContract for the OpenID Connect end session process. + * It creates an intent for the end session request and parses the result of the end session response. + */ +internal class EndSessionContract : + ActivityResultContract, Result>() { + /** + * Creates an intent for the end session request. + * @param context The context to use for creating the intent. + * @param input A pair containing the ID token for the session and the configuration for the OpenID Connect client. + * @return The intent for the end session request. + */ + override fun createIntent( + context: Context, + input: Pair, + ): Intent { + val configuration = + AuthorizationServiceConfiguration( + Uri.parse(input.second.authorizeUrl.toString()), + Uri.parse(input.second.tokenUrl.toString()), + null, + Uri.parse(input.second.endSessionUrl.toString()), + ) + + val builder = + EndSessionRequest.Builder(configuration) + .setPostLogoutRedirectUri(Uri.parse(input.second.signOutRedirectUri)) + + if (StringUtils.isNotEmpty(input.first)) { + builder.setIdTokenHint(input.first) + } + + val authService = AuthorizationService(context) + return authService.getEndSessionRequestIntent(builder.build()) + } + + /** + * Parses the result of the end session response. + * @param resultCode The result code from the activity result. + * @param intent The intent containing the end session response. + * @return A boolean indicating whether the session was ended successfully. + */ + override fun parseResult( + resultCode: Int, + intent: Intent?, + ): Result { + intent?.let { i -> + val resp = EndSessionResponse.fromIntent(i) + resp?.let { Result.Success(it) } + val ex = AuthorizationException.fromIntent(i) + ex?.let { Result.Failure(it) } + } + return Result.Failure(IllegalStateException("End session response is null")) + } +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/Launcher.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/Launcher.kt index c433dfc6..bfad207b 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/Launcher.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/centralize/Launcher.kt @@ -4,36 +4,65 @@ * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. */ - package org.forgerock.android.auth.centralize -import android.content.Intent import androidx.activity.result.ActivityResultLauncher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first -import net.openid.appauth.AuthorizationException import net.openid.appauth.AuthorizationResponse -import org.forgerock.android.auth.FRUser +import net.openid.appauth.EndSessionResponse +import org.forgerock.android.auth.FRUser.Browser +import org.forgerock.android.auth.OAuth2Client +import org.forgerock.android.auth.Result import org.forgerock.android.auth.exception.BrowserAuthenticationException -internal class Launcher(val authorize: ActivityResultLauncher, - val state: MutableStateFlow, - private val pending: Boolean = false) { - suspend fun authorize(request: FRUser.Browser): AuthorizationResponse { - //If waiting for response, we don't launch the browser again +/** + * This class is responsible for launching the browser for OpenID Connect operations. + */ +internal class Launcher( + val authorize: Pair, MutableStateFlow?>>, + val endSession: Pair>, MutableStateFlow?>>, +) { + /** + * Starts the authorization process. + * @param request The configuration for the OpenID Connect client. + * @param pending A boolean indicating whether the authorization process is pending. + */ + suspend fun authorize( + request: Browser, + pending: Boolean = false, + ): AuthorizationResponse { + if (!pending) { + authorize.first.launch(request) + } + + // drop the default value + when (val result = authorize.second.drop(1).filterNotNull().first()) { + is Result.Failure -> throw result.value + is Result.Success -> return result.value + else -> throw BrowserAuthenticationException("Unknown Error") + } + } + + /** + * Ends the session. + * @param request A pair containing the ID token for the session and the configuration for the OpenID Connect client. + * @param pending A boolean indicating whether the session end process is pending. + */ + suspend fun endSession( + request: Pair, + pending: Boolean = false, + ): EndSessionResponse { if (!pending) { - authorize.launch(request) + endSession.first.launch(request) } - //drop the default value - state.drop(1).first().let { - it?.let { i -> - val error = i.getStringExtra(AuthorizationException.EXTRA_EXCEPTION) - error?.let { e -> throw BrowserAuthenticationException(e) } - return AuthorizationResponse.fromIntent(i) - ?: throw BrowserAuthenticationException("Failed to retrieve authorization code") - } - throw BrowserAuthenticationException("No response data") + // drop the default value + when (val result = endSession.second.drop(1).filterNotNull().first()) { + is Result.Failure -> throw result.value + is Result.Success -> return result.value + else -> throw BrowserAuthenticationException("Unknown Error") } } -} \ No newline at end of file +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.java deleted file mode 100644 index c7082824..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.java +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright (c) 2020 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth.exception; - -/** - * Exception that is thrown when the authorize failed - */ -public class AuthorizeException extends Exception { - - public AuthorizeException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.kt new file mode 100644 index 00000000..16576d1d --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/AuthorizeException.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2020 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.exception + +/** + * Exception that is thrown when the authorize failed + */ +class AuthorizeException : Exception { + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) +} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.java deleted file mode 100644 index ac031725..00000000 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.java +++ /dev/null @@ -1,15 +0,0 @@ -/* - * Copyright (c) 2020 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ - -package org.forgerock.android.auth.exception; - -public class BrowserAuthenticationException extends Exception { - - public BrowserAuthenticationException(String message) { - super(message); - } -} diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.kt b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.kt new file mode 100644 index 00000000..3ebdac13 --- /dev/null +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/exception/BrowserAuthenticationException.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2020 - 2024 ForgeRock. All rights reserved. + * + * This software may be modified and distributed under the terms + * of the MIT license. See the LICENSE file for details. + */ +package org.forgerock.android.auth.exception + +class BrowserAuthenticationException : Exception { + constructor(message: String?) : super(message) + constructor(message: String?, cause: Throwable?) : super(message, cause) +} diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/AccessTokenTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/AccessTokenTest.kt index 99b5798c..3efa6dec 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/AccessTokenTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/AccessTokenTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 ForgeRock. All rights reserved. + * Copyright (c) 2023 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -30,7 +30,7 @@ class AccessTokenTest { Assert.assertEquals("{\"value\":\"MyAccessToken\",\"expiresIn\":30,\"refreshToken\":\"myRefreshToken\",\"idToken\":\"myIdToken\",\"tokenType\":\"myTokenType\",\"scope\":[\"test2\",\"test1\"],\"expiration\":1565825948754}", json) val newAccessToken = AccessToken.fromJson(json) - Assert.assertEquals(accessToken.value, newAccessToken.value) + Assert.assertEquals(accessToken.value, newAccessToken!!.value) Assert.assertEquals(accessToken.expiration, newAccessToken.expiration) Assert.assertEquals(accessToken.expiresIn, newAccessToken.expiresIn) Assert.assertEquals(accessToken.idToken, newAccessToken.idToken) @@ -42,7 +42,7 @@ class AccessTokenTest { @Test fun parseTest() { val scope = AccessToken.Scope.parse("openid email profile") - Assert.assertEquals(3, scope.size.toLong()) + Assert.assertEquals(3, scope!!.size.toLong()) Assert.assertTrue(scope.contains("openid")) Assert.assertTrue(scope.contains("email")) Assert.assertTrue(scope.contains("profile")) diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/BrowserLoginTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/BrowserLoginTest.kt deleted file mode 100644 index 0a450ab1..00000000 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/BrowserLoginTest.kt +++ /dev/null @@ -1,421 +0,0 @@ -/* - * Copyright (c) 2020 - 2024 ForgeRock. All rights reserved. - * - * This software may be modified and distributed under the terms - * of the MIT license. See the LICENSE file for details. - */ -package org.forgerock.android.auth - -import android.app.Activity -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri -import android.util.Pair -import androidx.browser.customtabs.CustomTabsIntent -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.testing.launchFragmentInContainer -import androidx.lifecycle.Lifecycle -import androidx.test.core.app.ActivityScenario -import net.openid.appauth.* -import okhttp3.mockwebserver.MockResponse -import okhttp3.mockwebserver.RecordedRequest -import org.assertj.core.api.Assertions -import org.forgerock.android.auth.exception.AlreadyAuthenticatedException -import org.forgerock.android.auth.exception.ApiException -import org.forgerock.android.auth.exception.AuthenticationRequiredException -import org.forgerock.android.auth.exception.BrowserAuthenticationException -import org.junit.Assert -import org.junit.Ignore -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import java.io.IOException -import java.net.HttpURLConnection -import java.net.MalformedURLException -import java.util.concurrent.ExecutionException -import java.util.concurrent.atomic.AtomicInteger - -@RunWith(RobolectricTestRunner::class) -class BrowserLoginTest : BaseTest() { - private fun getAppAuthFragment(activity: FragmentActivity): AppAuthFragment? { - val fragment = activity.supportFragmentManager - .findFragmentByTag(AppAuthFragment.TAG) - return fragment as? AppAuthFragment - } - - @Ignore - @Test - @Throws( - InterruptedException::class, - ExecutionException::class, - AuthenticationRequiredException::class, - IOException::class, - ApiException::class - ) - fun testHappyPath() { - enqueue("/authTreeMockTest_Authenticate_accessToken.json", HttpURLConnection.HTTP_OK) - Config.getInstance().sharedPreferences = context.getSharedPreferences( - DEFAULT_TOKEN_MANAGER_TEST, Context.MODE_PRIVATE - ) - Config.getInstance().ssoSharedPreferences = context.getSharedPreferences( - DEFAULT_SSO_TOKEN_MANAGER_TEST, Context.MODE_PRIVATE - ) - Config.getInstance().url = url - - val scenario: ActivityScenario = ActivityScenario.launch(DummyActivity::class.java) - scenario.onActivity { - InitProvider.setCurrentActivity(it) - } - val future = FRListenerFuture() - scenario.onActivity { - FRUser.browser().failedOnNoBrowserFound(false) - .login(it, future) - } - - // AppAuthFragment appAuthFragment = getAppAuthFragment(fragmentActivity); - val intent = Intent() - intent.putExtra( - AuthorizationResponse.EXTRA_RESPONSE, - "{\"request\":{\"configuration\":{\"authorizationEndpoint\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\\/realms\\/root\\/authorize\",\"tokenEndpoint\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\\/realms\\/root\\/access_token\"},\"clientId\":\"AndroidTest\",\"responseType\":\"code\",\"redirectUri\":\"net.openid.appauthdemo2:\\/oauth2redirect\",\"login_hint\":\"login\",\"scope\":\"openid profile email address phone\",\"state\":\"2v0SIhB7UAmsqvnvwR-IKQ\",\"codeVerifier\":\"qvCFoo3tqB-89lYOFjX2ZAMalkKgW_KIcc1tN3hmx3ygOHyYbWT9hKU7rhky6ivB-33exlhyyHHeSJtuVfOobg\",\"codeVerifierChallenge\":\"i-UW4h0UlD_pt1WCYGeP6prmtOkXhyQB_s1itrkV68k\",\"codeVerifierChallengeMethod\":\"S256\",\"additionalParameters\":{}},\"state\":\"2v0SIhB7UAmsqvnvwR-IKQ\",\"code\":\"roxwkG0TtooR2vzA6z0MT9xyJSQ\",\"additional_parameters\":{\"iss\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\",\"client_id\":\"andy_app\"}}" - ) - scenario.onActivity { - getAppAuthFragment(it)?.onActivityResult( - AppAuthFragment.AUTH_REQUEST_CODE, Activity.RESULT_OK, intent - ) - } - - // appAuthFragment.onActivityResult(AppAuthFragment.AUTH_REQUEST_CODE, Activity.RESULT_OK, intent); - val frUser = future.get() - Assertions.assertThat(frUser.accessToken).isNotNull - val rr = - server.takeRequest() //Start the Auth Service POST /json/realms/root/authenticate?authIndexType=service&authIndexValue=Test HTTP/1.1 - Assertions.assertThat(rr.path).isEqualTo("/oauth2/realms/root/access_token") - Assertions.assertThat(rr.method).isEqualTo("POST") - val body = parse(rr.body.readUtf8()) - Assertions.assertThat(body["client_id"]).isEqualTo("andy_app") - Assertions.assertThat(body["code_verifier"]) - .isEqualTo("qvCFoo3tqB-89lYOFjX2ZAMalkKgW_KIcc1tN3hmx3ygOHyYbWT9hKU7rhky6ivB-33exlhyyHHeSJtuVfOobg") - Assertions.assertThat(body["grant_type"]).isEqualTo("authorization_code") - Assertions.assertThat(body["code"]).isEqualTo("roxwkG0TtooR2vzA6z0MT9xyJSQ") - } - - @Ignore - @Test - @Throws( - InterruptedException::class, - ExecutionException::class, - AuthenticationRequiredException::class, - IOException::class, - ApiException::class - ) - fun testLogout() { - testHappyPath() - //Access Token Revoke - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader("Content-Type", "application/json") - .setBody("{}") - ) - //ID Token endsession - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT) - ) - FRUser.getCurrentUser().logout() - Assert.assertNull(FRUser.getCurrentUser()) - Assert.assertFalse(Config.getInstance().sessionManager.hasSession()) - val revoke1 = server.takeRequest() //Post to oauth2/realms/root/token/revoke - val revoke2 = server.takeRequest() //Post to /endSession - - //revoke Refresh Token and SSO Token are performed async - val refreshTokenRevoke = findRequest("/oauth2/realms/root/token/revoke", revoke1, revoke2) - val endSession = findRequest("/oauth2/realms/root/connect/endSession", revoke1, revoke2) - Assertions.assertThat(refreshTokenRevoke).isNotNull - Assertions.assertThat( - Uri.parse(endSession.path).getQueryParameter("id_token_hint") - ).isNotNull - } - - @Ignore - @Test - @Throws( - InterruptedException::class, - ExecutionException::class, - AuthenticationRequiredException::class, - IOException::class, - ApiException::class - ) - fun testRevokeTokenFailed() { - testHappyPath() - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_BAD_REQUEST) - .addHeader("Content-Type", "application/json") - .setBody( - """{ - "error_description": "Client authentication failed", - "error": "invalid_client" -}""" - ) - ) - server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_NO_CONTENT)) - FRUser.getCurrentUser().logout() - Assert.assertNull(FRUser.getCurrentUser()) - Assert.assertFalse(Config.getInstance().sessionManager.hasSession()) - val revoke1 = server.takeRequest() //Post to oauth2/realms/root/token/revoke - val revoke2 = server.takeRequest() //Post to /endSession - - //revoke Refresh Token and SSO Token are performed async - val refreshTokenRevoke = findRequest("/oauth2/realms/root/token/revoke", revoke1, revoke2) - val endSession = findRequest("/oauth2/realms/root/connect/endSession", revoke1, revoke2) - Assertions.assertThat(refreshTokenRevoke).isNotNull - - //Make sure we still invoke the endSession - Assertions.assertThat( - Uri.parse(endSession.path).getQueryParameter("id_token_hint") - ).isNotNull - } - - //It is running with JVM, no browser is expected - @Test - @Throws( - InterruptedException::class, - ExecutionException::class, - AuthenticationRequiredException::class - ) - fun testAppAuthConfigurer() { - enqueue("/authTreeMockTest_Authenticate_accessToken.json", HttpURLConnection.HTTP_OK) - - val invoked = AtomicInteger() - val browser = FRUser.browser().appAuthConfigurer() - .authorizationRequest { builder: AuthorizationRequest.Builder -> - //Test populated data - val request = builder.build() - Assertions.assertThat(request.clientId).isEqualTo(oAuth2Client.clientId) - Assertions.assertThat(request.redirectUri) - .isEqualTo(Uri.parse(oAuth2Client.redirectUri)) - Assertions.assertThat(request.scope).isEqualTo(oAuth2Client.scope) - Assertions.assertThat(request.responseType).isEqualTo(oAuth2Client.responseType) - invoked.getAndIncrement() - } - .appAuthConfiguration { builder: AppAuthConfiguration.Builder -> - Assertions.assertThat(builder).isNotNull - invoked.getAndIncrement() - } - .customTabsIntent { builder: CustomTabsIntent.Builder -> - Assertions.assertThat(builder).isNotNull - invoked.getAndIncrement() - } - .authorizationServiceConfiguration { - invoked.getAndIncrement() - try { - return@authorizationServiceConfiguration AuthorizationServiceConfiguration( - Uri.parse(oAuth2Client.authorizeUrl.toString()), - Uri.parse(oAuth2Client.tokenUrl.toString()) - ) - } catch (e: MalformedURLException) { - throw RuntimeException(e) - } - }.done() - - val scenario = launchFragmentInContainer( - initialState = Lifecycle.State.INITIALIZED - ) - - scenario.onFragment { - it.setBrowser(browser) - } - scenario.moveToState(Lifecycle.State.CREATED) - Assertions.assertThat(invoked.get()).isEqualTo(4) - - } - - @Ignore - @Test - @Throws(InterruptedException::class) - fun testOperationCancel() { - val scenario: ActivityScenario = ActivityScenario.launch(DummyActivity::class.java) - - val future = FRListenerFuture() - - scenario.onActivity { - FRUser.browser().failedOnNoBrowserFound(false) - .login(it, future) - } - - val intent = Intent() - intent.putExtra( - AuthorizationException.EXTRA_EXCEPTION, - "{\"type\":0,\"code\":1,\"errorDescription\":\"User cancelled flow\"}" - ) - - scenario.onActivity { - getAppAuthFragment(it)?.onActivityResult( - AppAuthFragment.AUTH_REQUEST_CODE, Activity.RESULT_CANCELED, intent - ) - } - - try { - future.get() - Assert.fail() - } catch (e: ExecutionException) { - Assertions.assertThat(e.cause).isInstanceOf(BrowserAuthenticationException::class.java) - Assertions.assertThat(e.cause?.message) - .isEqualTo("{\"type\":0,\"code\":1,\"errorDescription\":\"User cancelled flow\"}") - } - } - - @Ignore - @Test(expected = AlreadyAuthenticatedException::class) - @Throws( - Throwable::class - ) - fun testUserAlreadyAuthenticate() { - testHappyPath() - - val scenario: ActivityScenario = ActivityScenario.launch(DummyActivity::class.java) - scenario.onActivity { - val future = FRListenerFuture() - FRUser.browser().login(it, future) - try { - future.get() - } catch (e: ExecutionException) { - throw e.cause ?: Throwable("unknown error") - } - } - } - - @Ignore - @Test - @Throws(InterruptedException::class) - fun testInvalidScope() { - val scenario: ActivityScenario = ActivityScenario.launch(DummyActivity::class.java) - - val future = FRListenerFuture() - - scenario.onActivity { - FRUser.browser().failedOnNoBrowserFound(false) - .login(it, future) - } - - val intent = Intent() - intent.putExtra(AuthorizationException.EXTRA_EXCEPTION, INVALID_SCOPE) - scenario.onActivity { - getAppAuthFragment(it)?.onActivityResult( - AppAuthFragment.AUTH_REQUEST_CODE, Activity.RESULT_OK, intent - ) - } - - try { - future.get() - Assert.fail() - } catch (e: ExecutionException) { - Assertions.assertThat(e.cause).isInstanceOf(BrowserAuthenticationException::class.java) - Assertions.assertThat(e.cause?.message).isEqualTo(INVALID_SCOPE) - } - } - - @Ignore - @Test - @Throws( - InterruptedException::class, - ExecutionException::class, - AuthenticationRequiredException::class, - IOException::class, - ApiException::class - ) - fun testRequestInterceptor() { - val result = HashMap>() - RequestInterceptorRegistry.getInstance().register(RequestInterceptor { request: Request -> - val action = (request.tag() as Action).type - val pair = result[action] - if (pair == null) { - result[action] = Pair(request.tag() as Action, 1) - } else { - result[action] = Pair(request.tag() as Action, pair.second + 1) - } - request - }) - testHappyPath() - //Access Token Revoke - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader("Content-Type", "application/json") - .setBody("{}") - ) - //ID Token endsession - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_NO_CONTENT) - ) - FRUser.getCurrentUser().logout() - Assert.assertNull(FRUser.getCurrentUser()) - Assert.assertFalse(Config.getInstance().sessionManager.hasSession()) - val revoke1 = server.takeRequest() //Post to oauth2/realms/root/token/revoke - val revoke2 = server.takeRequest() //Post to /endSession - Assertions.assertThat(result["END_SESSION"]?.second).isEqualTo(1) - } - - @Test - fun testActivityNotFound() { - val scenario: ActivityScenario = - ActivityScenario.launch(DummyActivity::class.java) - scenario.onActivity { - InitProvider.setCurrentActivity(it) - } - val future = FRListenerFuture() - scenario.onActivity { - FRUser.browser().failedOnNoBrowserFound(true) - .login(it, future) - } - - val intent = Intent() - intent.putExtra( - AuthorizationResponse.EXTRA_RESPONSE, - "{\"request\":{\"configuration\":{\"authorizationEndpoint\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\\/realms\\/root\\/authorize\",\"tokenEndpoint\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\\/realms\\/root\\/access_token\"},\"clientId\":\"AndroidTest\",\"responseType\":\"code\",\"redirectUri\":\"net.openid.appauthdemo2:\\/oauth2redirect\",\"login_hint\":\"login\",\"scope\":\"openid profile email address phone\",\"state\":\"2v0SIhB7UAmsqvnvwR-IKQ\",\"codeVerifier\":\"qvCFoo3tqB-89lYOFjX2ZAMalkKgW_KIcc1tN3hmx3ygOHyYbWT9hKU7rhky6ivB-33exlhyyHHeSJtuVfOobg\",\"codeVerifierChallenge\":\"i-UW4h0UlD_pt1WCYGeP6prmtOkXhyQB_s1itrkV68k\",\"codeVerifierChallengeMethod\":\"S256\",\"additionalParameters\":{}},\"state\":\"2v0SIhB7UAmsqvnvwR-IKQ\",\"code\":\"roxwkG0TtooR2vzA6z0MT9xyJSQ\",\"additional_parameters\":{\"iss\":\"http:\\/\\/openam.example.com:8081\\/openam\\/oauth2\",\"client_id\":\"andy_app\"}}" - ) - scenario.onActivity { - getAppAuthFragment(it)?.onActivityResult( - AppAuthFragment.AUTH_REQUEST_CODE, Activity.RESULT_OK, intent - ) - } - - try { - future.get() - Assert.fail() - } catch (e: ExecutionException) { - Assertions.assertThat(e.cause).isInstanceOf(ActivityNotFoundException::class.java) - } - } - - private fun parse(encoded: String): Map { - val body = encoded.split("&").toTypedArray() - val result: MutableMap = HashMap() - for (s in body) { - val value = s.split("=").toTypedArray() - result[value[0]] = value[1] - } - return result - } - - private fun findRequest( - path: String, - vararg recordedRequests: RecordedRequest - ): RecordedRequest { - for (r in recordedRequests) { - if (r.path!!.startsWith(path)) { - return r - } - } - throw IllegalArgumentException() - } - - companion object { - private const val DEFAULT_TOKEN_MANAGER_TEST = "DefaultTokenManagerTest" - private const val DEFAULT_SSO_TOKEN_MANAGER_TEST = "DefaultSSOManagerTest" - const val INVALID_SCOPE = "Invalid Scope" - } -} \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt index 8643418d..cdb0dc65 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/ConfigHelperTest.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -214,8 +214,8 @@ class ConfigHelperTest { @Test fun loadDefaultFROptionWithNull() { val defaultOption = ConfigHelper.load(context, null) - val expectedResult = "FROptions(server=Server(url=https://openam.example.com:8081/openam, realm=root, timeout=30, cookieName=iPlanetDirectoryPro, cookieCacheSeconds=0), oauth=OAuth(oauthClientId=andy_app, oauthRedirectUri=https://www.example.com:8080/callback, oauthScope=openid email address, oauthThresholdSeconds=30, oauthCacheSeconds=0), service=Service(authServiceName=Test, registrationServiceName=Registration), urlPath=UrlPath(authenticateEndpoint=, revokeEndpoint=, sessionEndpoint=, tokenEndpoint=, userinfoEndpoint=, authorizeEndpoint=, endSessionEndpoint=), sslPinning=SSLPinning(buildSteps=[], pins=[9hNxmEFgLKGJXqgp61hyb8yIyiT9u0vgDZh4y8TmY/M=]), logger=Log(logLevel=null, customLogger=null))" - assertTrue(defaultOption.toString() == expectedResult) + val expectedResult = "FROptions(server=Server(url=https://openam.example.com:8081/openam, realm=root, timeout=30, cookieName=iPlanetDirectoryPro, cookieCacheSeconds=0), oauth=OAuth(oauthClientId=andy_app, oauthRedirectUri=https://www.example.com:8080/callback, oauthSignOutRedirectUri=, oauthScope=openid email address, oauthThresholdSeconds=30, oauthCacheSeconds=0), service=Service(authServiceName=Test, registrationServiceName=Registration), urlPath=UrlPath(authenticateEndpoint=, revokeEndpoint=, sessionEndpoint=, tokenEndpoint=, userinfoEndpoint=, authorizeEndpoint=, endSessionEndpoint=), sslPinning=SSLPinning(buildSteps=[], pins=[9hNxmEFgLKGJXqgp61hyb8yIyiT9u0vgDZh4y8TmY/M=]), logger=Log(logLevel=null, customLogger=null))" + assertTrue(defaultOption.toString() == expectedResult) } @Test @@ -235,7 +235,7 @@ class ConfigHelperTest { } } val defaultOption = ConfigHelper.load(context, frOptions) - val expectedResult = "FROptions(server=Server(url=https://dummy, realm=realm123, timeout=30, cookieName=cookieName, cookieCacheSeconds=0), oauth=OAuth(oauthClientId=client_id, oauthRedirectUri=, oauthScope=, oauthThresholdSeconds=0, oauthCacheSeconds=0), service=Service(authServiceName=Login, registrationServiceName=Registration), urlPath=UrlPath(authenticateEndpoint=null, revokeEndpoint=https://revoke, sessionEndpoint=null, tokenEndpoint=null, userinfoEndpoint=null, authorizeEndpoint=null, endSessionEndpoint=https://endsession), sslPinning=SSLPinning(buildSteps=[], pins=[]), logger=Log(logLevel=null, customLogger=null))" + val expectedResult = "FROptions(server=Server(url=https://dummy, realm=realm123, timeout=30, cookieName=cookieName, cookieCacheSeconds=0), oauth=OAuth(oauthClientId=client_id, oauthRedirectUri=, oauthSignOutRedirectUri=, oauthScope=, oauthThresholdSeconds=0, oauthCacheSeconds=0), service=Service(authServiceName=Login, registrationServiceName=Registration), urlPath=UrlPath(authenticateEndpoint=null, revokeEndpoint=https://revoke, sessionEndpoint=null, tokenEndpoint=null, userinfoEndpoint=null, authorizeEndpoint=null, endSessionEndpoint=https://endsession), sslPinning=SSLPinning(buildSteps=[], pins=[]), logger=Log(logLevel=null, customLogger=null))" assertTrue(defaultOption.toString() == expectedResult) } } \ No newline at end of file diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/DefaultTokenManagerTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/DefaultTokenManagerTest.kt index bc2a7e7e..d2bebfcd 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/DefaultTokenManagerTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/DefaultTokenManagerTest.kt @@ -56,8 +56,8 @@ class DefaultTokenManagerTest : BaseTest() { val storedAccessToken = getAccessToken(tokenManager) assertEquals("access token", storedAccessToken.value) assertEquals("id token", storedAccessToken.idToken) - assertTrue(storedAccessToken.scope.contains("openid")) - assertTrue(storedAccessToken.scope.contains("test")) + assertTrue(storedAccessToken.scope!!.contains("openid")) + assertTrue(storedAccessToken.scope!!.contains("test")) assertEquals("Bearer", storedAccessToken.tokenType) assertEquals("refresh token", storedAccessToken.refreshToken) } diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/SSOBroadcastReceiverIntegrationTests.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/SSOBroadcastReceiverIntegrationTests.kt index b337c39f..9dc56989 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/SSOBroadcastReceiverIntegrationTests.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/SSOBroadcastReceiverIntegrationTests.kt @@ -135,7 +135,7 @@ class SSOBroadcastReceiverIntegrationTests: BaseTest() { val body = refreshTokenRevoke.body.readUtf8() assertTrue(body.contains("token")) assertTrue(body.contains("client_id")) - assertTrue(body.contains(accessToken.refreshToken)) + assertTrue(body.contains(accessToken.refreshToken!!)) } } diff --git a/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpClientProvider.kt b/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpClientProvider.kt index 7932a5a2..c4cacc94 100644 --- a/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpClientProvider.kt +++ b/forgerock-core/src/main/java/org/forgerock/android/auth/OkHttpClientProvider.kt @@ -16,10 +16,15 @@ import java.util.concurrent.ConcurrentHashMap /** * Provider to Cache and provide OKHttpClient */ -class OkHttpClientProvider private constructor() { +object OkHttpClientProvider { private val cache: MutableMap = ConcurrentHashMap() private val interceptorProvider = InterceptorProvider() + private const val REQUESTED_WITH_KEY = "x-requested-with" + private const val REQUESTED_WITH_VALUE = "forgerock-sdk" + private const val REQUESTED_PLATFORM_KEY = "x-requested-platform" + private const val REQUESTED_PLATFORM_VALUE = "android" + init { CoreEventDispatcher.CLEAR_OKHTTP.addObserver { _, _ -> clear() } } @@ -93,16 +98,6 @@ class OkHttpClientProvider private constructor() { cache.clear() } - companion object { - private val providerInstance = OkHttpClientProvider() - private const val REQUESTED_WITH_KEY = "x-requested-with" - private const val REQUESTED_WITH_VALUE = "forgerock-sdk" - private const val REQUESTED_PLATFORM_KEY = "x-requested-platform" - private const val REQUESTED_PLATFORM_VALUE = "android" - - @JvmStatic - fun getInstance(): OkHttpClientProvider { - return providerInstance - } - } + @JvmStatic + fun getInstance() = this } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 10c51e82..80bbe854 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "8.2.2" +agp = "8.3.2" annotation = "1.6.0" appauth = "0.11.1" appcompat = "1.6.1" @@ -12,6 +12,7 @@ easy-random-core = "4.0.0" facebook-login = "16.0.0" firebase-messaging = "23.1.2" fragment-ktx = "1.6.1" +activity-ktx = "1.6.1" integrity = "1.3.0" kotlin = "1.9.22" junit = "4.13.2" @@ -49,6 +50,7 @@ androidx-biometric-ktx = { module = "androidx.biometric:biometric-ktx", version. androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-espresso-intents = { module = "androidx.test.espresso:espresso-intents", version.ref = "espresso-core" } androidx-fragment-ktx = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment-ktx" } +androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "activity-ktx" } androidx-fragment-testing = { module = "androidx.fragment:fragment-testing", version.ref = "fragment-ktx" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "security-crypto" } appauth = { module = "net.openid:appauth", version.ref = "appauth" } @@ -89,6 +91,7 @@ rules = { module = "androidx.test:rules", version.ref = "rules" } firebase-crashlytics-buildtools = { group = "com.google.firebase", name = "firebase-crashlytics-buildtools", version.ref = "firebase-crashlytics-buildtools" } [plugins] +androidApplication = { id = "com.android.application", version.ref = "agp" } androidLibrary = { id = "com.android.library", version.ref = "agp" } kotlinAndroid = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c702fbca..72ad0731 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Wed Feb 21 12:34:24 PST 2024 +#Mon Jun 03 12:27:16 PDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/samples/app/build.gradle.kts b/samples/app/build.gradle.kts index c85ba02d..8b312557 100644 --- a/samples/app/build.gradle.kts +++ b/samples/app/build.gradle.kts @@ -6,8 +6,8 @@ */ plugins { - id("com.android.application") - id("kotlin-android") + alias(libs.plugins.androidApplication) + alias(libs.plugins.kotlinAndroid) } android { diff --git a/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt b/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt index 9810ea64..b978b6e7 100644 --- a/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt +++ b/samples/app/src/main/java/com/example/app/env/EnvViewModel.kt @@ -22,24 +22,31 @@ class EnvViewModel : ViewModel() { val localhost = FROptionsBuilder.build { server { - url = "https://openam-protect2.forgeblocks.com/am" + url = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447" realm = "alpha" cookieName = "c1c805de4c9b333" timeout = 50 } oauth { - oauthClientId = "AndroidTest" - oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthClientId = "c12743f9-08e8-4420-a624-71bbb08e9fe1" + oauthRedirectUri = "org.forgerock.demo://oauth2redirect" oauthCacheSeconds = 0 oauthScope = "openid profile email address phone" oauthThresholdSeconds = 0 + oauthSignOutRedirectUri = "org.forgerock.demo://oauth2redirect" + } + urlPath { + authorizeEndpoint = "/as/authorize" + tokenEndpoint = "/as/token" + endSessionEndpoint = "/as/signoff" + revokeEndpoint = "/as/revoke" + userinfoEndpoint = "/as/userinfo" } service { authServiceName = "protect" } } - val dbind = FROptionsBuilder.build { server { url = "https://openam-updbind.forgeblocks.com/am" diff --git a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt index 6734d11f..a05bbb36 100644 --- a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt +++ b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/FRSessionActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. diff --git a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt index e87d8360..9e20b7ec 100644 --- a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt +++ b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/MainActivity.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 - 2023 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -142,8 +142,8 @@ class MainActivity : AppCompatActivity(), NodeListener, ActivityListener private fun launchUserInfoFragment(token: AccessToken, result: FRUser?) { val userInfoFragment = UserInfoFragment.newInstance(result?.accessToken?.value, - token.refreshToken, - token.idToken, + token.refreshToken!!, + token.idToken!!, this@MainActivity) userInfoFragment.let { supportFragmentManager.beginTransaction().add(R.id.container, it, UserInfoFragment.TAG).commit() diff --git a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/UserInfoFragment.kt b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/UserInfoFragment.kt index 286955ab..8d32e318 100644 --- a/samples/kotlin/src/main/java/com/forgerock/kotlinapp/UserInfoFragment.kt +++ b/samples/kotlin/src/main/java/com/forgerock/kotlinapp/UserInfoFragment.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 ForgeRock. All rights reserved. + * Copyright (c) 2022 - 2024 ForgeRock. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -83,7 +83,7 @@ class UserInfoFragment : Fragment() { */ // TODO: Rename and change types and number of parameters @JvmStatic - fun newInstance(param1: String?, param2: String,param3: String, listener: ActivityListener?) = + fun newInstance(param1: String?, param2: String?,param3: String?, listener: ActivityListener?) = UserInfoFragment().apply { this.listener = listener arguments = Bundle().apply {