From ddbd57af442759aed6bf69b0bd11cf96da8084f8 Mon Sep 17 00:00:00 2001 From: jey Date: Mon, 3 Jun 2024 18:30:31 -0500 Subject: [PATCH] OIDC ping discovery --- .../org/forgerock/android/auth/FRAuth.java | 2 - .../org/forgerock/android/auth/FROptions.kt | 86 ++++++++++++++++--- .../forgerock/android/auth/OAuth2Client.java | 44 ++++++++-- .../forgerock/android/auth/UserService.java | 13 ++- .../forgerock/android/auth/FROptionTest.kt | 64 ++++++++------ .../src/test/resources/discovery.json | 29 +++++++ .../android/auth/KotlinExtensions.kt | 15 +++- .../forgerock/android/auth/ExtensionTest.kt | 70 +++++++++++++++ .../main/java/com/example/app/AppNavHost.kt | 6 +- .../com/example/app/centralize/Centralize.kt | 10 ++- .../centralize/CentralizeLoginViewModel.kt | 37 +++++--- .../java/com/example/app/env/EnvViewModel.kt | 16 +++- 12 files changed, 323 insertions(+), 69 deletions(-) create mode 100644 forgerock-auth/src/test/resources/discovery.json create mode 100644 forgerock-core/src/test/java/org/forgerock/android/auth/ExtensionTest.kt diff --git a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java index 52db439d..795321b3 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/FRAuth.java @@ -47,8 +47,6 @@ public static synchronized void start(Context context, @Nullable FROptions optio if(!started || !FROptions.equals(cachedOptions, options)) { started = true; FROptions currentOptions = ConfigHelper.load(context, options); - //Validate (AM URL, Realm, CookieName) is not Empty. If its empty will throw IllegalArgumentException. - currentOptions.validateConfig(); if (ConfigHelper.isConfigDifferentFromPersistedValue(context, currentOptions)) { SessionManager sessionManager = ConfigHelper.getPersistedConfig(context, cachedOptions).getSessionManager(); sessionManager.close(); 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..242df57b 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,12 +1,21 @@ /* - * 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. */ package org.forgerock.android.auth +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.Interceptor import okhttp3.OkHttpClient +import okhttp3.Request +import org.forgerock.android.auth.OkHttpClientProvider.Companion.getInstance +import org.forgerock.android.auth.exception.ApiException +import org.json.JSONObject +import java.net.URL +import java.util.concurrent.TimeUnit /** * Manages SDK configuration information @@ -22,7 +31,7 @@ data class FROptions(val server: Server, @JvmStatic fun equals(old: FROptions?, new: FROptions?): Boolean { // do the referential check first - if(old === new) { + if (old === new) { return true } // if there is a change in reference then check the value @@ -34,13 +43,68 @@ data class FROptions(val server: Server, && old?.logger == new?.logger } } - @Throws(IllegalArgumentException::class) - @JvmName("validateConfig") - internal fun validateConfig() { - require(server.url.isNotBlank()) { "AM URL cannot be blank" } - require(server.realm.isNotBlank()) { "Realm cannot be blank" } - require(server.cookieName.isNotBlank()) { "cookieName cannot be blank" } + private fun getOkHttpClient(url: URL): OkHttpClient { + val networkConfig = NetworkConfig.networkBuilder() + .timeUnit(TimeUnit.SECONDS) + .host(url.authority) + .interceptorSupplier { + listOf( + OkHttpRequestInterceptor() + ) + } + .build() + + // Obtain instance of OkHttp client + val httpClient = getInstance().lookup(networkConfig) + return httpClient + } + + /** + * Discover the OpenID Configuration + * + * @param discoverUrl The OpenID Configuration URL + * @return The SDK configuration information + */ + suspend fun discover(discoverUrl: String): FROptions { + return withContext(Dispatchers.IO) { + try { + val url = URL(discoverUrl) + val httpClient = getOkHttpClient(url) + val request = Request.Builder() + .url(discoverUrl) + .get().build() + + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw ApiException( + response.code, + response.message, + response.body?.string() + ) + } + + val openIdConfiguration = response.body?.string()?.let { + JSONObject(it) + } + + val server = this@FROptions.server.copy(url = openIdConfiguration?.getString("issuer") ?: "") + + val urlPath = this@FROptions.urlPath.copy( + authorizeEndpoint = openIdConfiguration?.getString("authorization_endpoint"), + tokenEndpoint = openIdConfiguration?.getString("token_endpoint"), + userinfoEndpoint = openIdConfiguration?.getString("userinfo_endpoint"), + revokeEndpoint = openIdConfiguration?.getString("revocation_endpoint"), + endSessionEndpoint = openIdConfiguration?.getString("end_session_endpoint")) + + this@FROptions.copy(urlPath = urlPath, server = server) + + } + } catch (e: Exception) { + throw e + } + } } + } /** @@ -48,7 +112,7 @@ data class FROptions(val server: Server, */ class FROptionsBuilder { - private lateinit var server: Server + private var server: Server = Server("", "root", 30, "iPlanetDirectoryPro", 0) private var oauth: OAuth = OAuth() private var service: Service = Service() private var urlPath: UrlPath = UrlPath() @@ -123,7 +187,7 @@ class FROptionsBuilder { /** * Data class for the server configurations */ -data class Server(val url: String, +data class Server(val url: String = "", val realm: String = "root", val timeout: Int = 30, val cookieName: String = "iPlanetDirectoryPro", @@ -133,7 +197,7 @@ data class Server(val url: String, * Server builder to build the SDK configuration information specific to server */ class ServerBuilder { - lateinit var url: String + var url: String = "" var realm: String = "root" var timeout: Int = 30 var cookieName: String = "iPlanetDirectoryPro" 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 index 8cc4b039..d4916271 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.java @@ -9,6 +9,7 @@ import android.net.Uri; import android.util.Base64; +import android.util.Patterns; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -33,6 +34,7 @@ import okhttp3.RequestBody; import okhttp3.Response; +import static org.forgerock.android.auth.KotlinExtensionsKt.isAbsoluteUrl; import static org.forgerock.android.auth.ServerConfig.ACCEPT_API_VERSION; import static org.forgerock.android.auth.StringUtils.isNotEmpty; @@ -382,9 +384,16 @@ private URL getAuthorizeUrl(Token token, PKCE pkce, String state, Map() + NavHost(navController = navController, startDestination = startDestination) { composable(Destinations.ENV_ROUTE) { - val envViewModel = viewModel() + val preferenceViewModel = viewModel( factory = PreferenceViewModel.factory(LocalContext.current) ) @@ -69,7 +71,7 @@ fun AppNavHost(navController: NavHostController, } composable(Destinations.CENTRALIZE_ROUTE) { val centralizeLoginViewModel = viewModel() - Centralize(centralizeLoginViewModel) + Centralize(centralizeLoginViewModel, envViewModel.current) } composable(Destinations.MANAGE_WEBAUTHN_KEYS) { val webAuthnViewModel = viewModel( diff --git a/samples/app/src/main/java/com/example/app/centralize/Centralize.kt b/samples/app/src/main/java/com/example/app/centralize/Centralize.kt index 716f9c1a..37cd65a6 100644 --- a/samples/app/src/main/java/com/example/app/centralize/Centralize.kt +++ b/samples/app/src/main/java/com/example/app/centralize/Centralize.kt @@ -20,17 +20,20 @@ import androidx.compose.ui.unit.dp import androidx.fragment.app.FragmentActivity import androidx.lifecycle.viewmodel.compose.viewModel import com.example.app.Error +import com.example.app.env.EnvViewModel import com.example.app.userprofile.UserProfile import com.example.app.userprofile.UserProfileViewModel +import org.forgerock.android.auth.FROptions @Composable -fun Centralize(centralizeLoginViewModel: CentralizeLoginViewModel) { +fun Centralize(centralizeLoginViewModel: CentralizeLoginViewModel, + options: FROptions) { val activity = LocalContext.current as FragmentActivity LaunchedEffect(true) { //Not relaunch when recomposition - centralizeLoginViewModel.login(activity) + centralizeLoginViewModel.login(activity, options) } val state by centralizeLoginViewModel.state.collectAsState() @@ -48,5 +51,4 @@ fun Centralize(centralizeLoginViewModel: CentralizeLoginViewModel) { Error(exception = this) } } -} - +} \ No newline at end of file diff --git a/samples/app/src/main/java/com/example/app/centralize/CentralizeLoginViewModel.kt b/samples/app/src/main/java/com/example/app/centralize/CentralizeLoginViewModel.kt index 62efa894..34a89c77 100644 --- a/samples/app/src/main/java/com/example/app/centralize/CentralizeLoginViewModel.kt +++ b/samples/app/src/main/java/com/example/app/centralize/CentralizeLoginViewModel.kt @@ -10,35 +10,50 @@ package com.example.app.centralize import androidx.browser.customtabs.CustomTabsIntent import androidx.fragment.app.FragmentActivity import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.forgerock.android.auth.FRAuth import org.forgerock.android.auth.FRListener +import org.forgerock.android.auth.FROptions import org.forgerock.android.auth.FRUser class CentralizeLoginViewModel : ViewModel() { var state = MutableStateFlow(CentralizeState()) private set + fun login(fragmentActivity: FragmentActivity, options: FROptions) { + if(options.server.url.contains("pingone")) { + viewModelScope.launch { + val option = options.discover(options.server.url + "/.well-known/openid-configuration") + FRAuth.start(fragmentActivity, option) + launch(fragmentActivity) + } + } else { + launch(fragmentActivity) + } + } - fun login(fragmentActivity: FragmentActivity) { + private fun launch(fragmentActivity: FragmentActivity) { FRUser.browser().appAuthConfigurer().customTabsIntent { it.setColorScheme(CustomTabsIntent.COLOR_SCHEME_DARK) }.done() .login(fragmentActivity, - object : FRListener { - override fun onSuccess(result: FRUser) { - state.update { - it.copy(user = result, exception = null) + object : FRListener { + override fun onSuccess(result: FRUser) { + state.update { + it.copy(user = result, exception = null) + } } - } - override fun onException(e: Exception) { - state.update { - it.copy(user = null, exception = e) + override fun onException(e: Exception) { + state.update { + it.copy(user = null, exception = e) + } } - } - }) + }) } } \ No newline at end of file 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..176105a7 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 @@ -135,6 +135,17 @@ class EnvViewModel : ViewModel() { } } + val pingOIdc = FROptionsBuilder.build { + server { + url = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as" + } + oauth { + oauthClientId = "c12743f9-08e8-4420-a624-71bbb08e9fe1" + oauthRedirectUri = "org.forgerock.demo://oauth2redirect" + oauthScope = "openid email address phone profile" + } + } + var current by mutableStateOf(dbind) private set @@ -145,10 +156,13 @@ class EnvViewModel : ViewModel() { servers.add(local) servers.add(ops) servers.add(forgeblock) + servers.add(pingOIdc) } fun select(context: Context, options: FROptions) { - FRAuth.start(context, options) + if(options.server.url.contains("pingone").not()) { + FRAuth.start(context, options) + } current = options }