diff --git a/CHANGELOG.md b/CHANGELOG.md index e4a7f871..4f876891 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## [4.X.X] #### Added - Self device management [SDKS-3408] +- Support Sign out with ID Token for PingOne Platform [SDKS-3423] ## [4.6.0] #### Added 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 5fe6c712..d2be6071 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 @@ -105,8 +105,13 @@ data class FROptions(val server: Server, tokenEndpoint = openIdConfiguration?.getString("token_endpoint"), userinfoEndpoint = openIdConfiguration?.getString("userinfo_endpoint"), revokeEndpoint = openIdConfiguration?.getString("revocation_endpoint"), - endSessionEndpoint = openIdConfiguration?.getString("end_session_endpoint")) - + endSessionEndpoint = if (oauth.oauthSignOutRedirectUri.isEmpty()) { + openIdConfiguration?.optString("ping_end_idp_session_endpoint", + openIdConfiguration.getString("end_session_endpoint")) + } else { + openIdConfiguration?.getString("end_session_endpoint") + } + ) this@FROptions.copy(urlPath = urlPath, server = server) } 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 index 9d885fa0..94836f08 100644 --- a/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt +++ b/forgerock-auth/src/main/java/org/forgerock/android/auth/OAuth2Client.kt @@ -218,6 +218,7 @@ class OAuth2Client( try { request = Request.Builder() .url(getEndSessionUrl(clientId, idToken)) + .header("Accept", "application/json") .get() .tag(END_SESSION) .build() diff --git a/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt b/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt index 08c112c1..3273990f 100644 --- a/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt +++ b/forgerock-auth/src/test/java/org/forgerock/android/auth/FROptionTest.kt @@ -7,7 +7,7 @@ package org.forgerock.android.auth import androidx.test.ext.junit.runners.AndroidJUnit4 -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import org.forgerock.android.auth.exception.ApiException @@ -19,11 +19,16 @@ import org.junit.runner.RunWith import java.net.HttpURLConnection @RunWith(AndroidJUnit4::class) -class FROptionTest: BaseTest() { +class FROptionTest : BaseTest() { @Test fun testDefaultBuilderOption() { - class TestCustomLogger: FRLogger { - override fun error(tag: String?, t: Throwable?, message: String?, vararg values: Any?) {} + class TestCustomLogger : FRLogger { + override fun error(tag: String?, + t: Throwable?, + message: String?, + vararg values: Any?) { + } + override fun error(tag: String?, message: String?, vararg values: Any?) {} override fun warn(tag: String?, message: String?, vararg values: Any?) {} override fun warn(tag: String?, t: Throwable?, message: String?, vararg values: Any?) {} @@ -31,7 +36,8 @@ class FROptionTest: BaseTest() { override fun info(tag: String?, message: String?, vararg values: Any?) {} override fun network(tag: String?, message: String?, vararg values: Any?) {} } - val logger:FRLogger = TestCustomLogger() + + val logger: FRLogger = TestCustomLogger() val option = FROptionsBuilder.build { server { url = "https://andy.com" @@ -69,150 +75,205 @@ class FROptionTest: BaseTest() { @Test(expected = IllegalArgumentException::class) fun testInValidConfigRealm() { - val option1 = FROptionsBuilder.build { server { - url = "https://stoyan.com" - realm = "" - }} + val option1 = FROptionsBuilder.build { + server { + url = "https://stoyan.com" + realm = "" + } + } option1.validateConfig() } @Test(expected = IllegalArgumentException::class) fun testInValidConfigCookieName() { - val option1 = FROptionsBuilder.build { server { - url = "https://stoyan.com" - cookieName = "" - }} + val option1 = FROptionsBuilder.build { + server { + url = "https://stoyan.com" + cookieName = "" + } + } option1.validateConfig() } @Test(expected = IllegalArgumentException::class) fun testInValidConfigUrl() { - val option1 = FROptionsBuilder.build { server { - url = "" - }} + val option1 = FROptionsBuilder.build { + server { + url = "" + } + } option1.validateConfig() } @Test fun testReferenceAndValue() { - val option1 = FROptionsBuilder.build { server { - url = "https://stoyan.com" - }} - var option2 = FROptionsBuilder.build { server { - url = "https://stoyan.com" - }} + val option1 = FROptionsBuilder.build { + server { + url = "https://stoyan.com" + } + } + var option2 = FROptionsBuilder.build { + server { + url = "https://stoyan.com" + } + } assertTrue(FROptions.equals(option1, option2)) - option2 = FROptionsBuilder.build { server { - url = "https://andy.com" - realm = "" - }} + option2 = FROptionsBuilder.build { + server { + url = "https://andy.com" + realm = "" + } + } assertFalse(FROptions.equals(option1, option2)) assertTrue(FROptions.equals(option2, option2)) } @Test - fun testDiscoverEndpointFailure() { - runBlocking { - val option1 = FROptionsBuilder.build { - server { - url = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as" - } - urlPath { - authenticateEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" - sessionEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" - } - oauth { - oauthClientId = "AndroidTest" - oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" - oauthScope = "openid profile email address phone" - } - } - - server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_FORBIDDEN)) - - try { - val copied = - option1.discover(url) - FRAuth.start(context, copied) - fail() - } - catch (e: ApiException) { - assertTrue(e.statusCode == HttpURLConnection.HTTP_FORBIDDEN) + fun testDiscoverEndpointFailure() = runTest { + val option1 = FROptionsBuilder.build { + server { + url = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as" + } + urlPath { + authenticateEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" + sessionEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" + } + oauth { + oauthClientId = "AndroidTest" + oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthScope = "openid profile email address phone" } } + + server.enqueue(MockResponse().setResponseCode(HttpURLConnection.HTTP_FORBIDDEN)) + + try { + val copied = + option1.discover(url) + FRAuth.start(context, copied) + fail() + } catch (e: ApiException) { + assertTrue(e.statusCode == HttpURLConnection.HTTP_FORBIDDEN) + } + } + + @Test + fun testDiscoverEndpointWithURLProvided() = runTest { + server.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader("Content-Type", "application/json") + .setBody(getJson("/discovery.json"))) + + val option1 = FROptionsBuilder.build { + server { + url = "https://auth.pingone.us/02fb4743-189a-4bc7-9d6c-a919edfe6447/as" + } + urlPath { + authenticateEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" + sessionEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" + } + oauth { + oauthClientId = "AndroidTest" + oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthScope = "openid profile email address phone" + } + } + val copied = option1.discover(url) + assertTrue(copied.urlPath.authorizeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authorize") + assertTrue(copied.server.url == "https://auth.pingone.us/02fb4743-189a-4bc7-9d6c-a919edfe6447/as") + assertTrue(copied.urlPath.revokeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/revoke") + assertTrue(copied.urlPath.tokenEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/token") + assertTrue(copied.urlPath.userinfoEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo") + assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff") + assertTrue(copied.urlPath.sessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session") + assertTrue(copied.urlPath.authenticateEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate") + assertTrue(copied.oauth.oauthClientId == "AndroidTest") + assertTrue(copied.oauth.oauthRedirectUri == "org.forgerock.demo:/oauth2redirect") + assertTrue(copied.oauth.oauthScope == "openid profile email address phone") } + @Test - fun testDiscoverEndpointWithURLProvided() { - runBlocking { - server.enqueue( - MockResponse() + fun testDiscoverEndpointWithURLNotProvided() = runTest { + server.enqueue( + MockResponse() .setResponseCode(HttpURLConnection.HTTP_OK) .addHeader("Content-Type", "application/json") .setBody(getJson("/discovery.json"))) - val option1 = FROptionsBuilder.build { - server { - url = "https://auth.pingone.us/02fb4743-189a-4bc7-9d6c-a919edfe6447/as" - } - urlPath { - authenticateEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" - sessionEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" - } - oauth { - oauthClientId = "AndroidTest" - oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" - oauthScope = "openid profile email address phone" - } - } - val copied = option1.discover(url) - assertTrue(copied.urlPath.authorizeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authorize") - assertTrue(copied.server.url == "https://auth.pingone.us/02fb4743-189a-4bc7-9d6c-a919edfe6447/as") - assertTrue(copied.urlPath.revokeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/revoke") - assertTrue(copied.urlPath.tokenEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/token") - assertTrue(copied.urlPath.userinfoEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo") - assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff") - assertTrue(copied.urlPath.sessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session") - assertTrue(copied.urlPath.authenticateEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate") - assertTrue(copied.oauth.oauthClientId == "AndroidTest") - assertTrue(copied.oauth.oauthRedirectUri == "org.forgerock.demo:/oauth2redirect") - assertTrue(copied.oauth.oauthScope == "openid profile email address phone") + val option1 = FROptionsBuilder.build { + urlPath { + authenticateEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" + sessionEndpoint = + "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" + } + oauth { + oauthClientId = "AndroidTest" + oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthScope = "openid profile email address phone" + } } + val copied = option1.discover(url) + assertTrue(copied.urlPath.authorizeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authorize") + assertTrue(copied.server.url == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as") + assertTrue(copied.urlPath.revokeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/revoke") + assertTrue(copied.urlPath.tokenEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/token") + assertTrue(copied.urlPath.userinfoEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo") + assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff") + assertTrue(copied.urlPath.sessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session") + assertTrue(copied.urlPath.authenticateEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate") + assertTrue(copied.oauth.oauthClientId == "AndroidTest") + assertTrue(copied.oauth.oauthRedirectUri == "org.forgerock.demo:/oauth2redirect") + assertTrue(copied.oauth.oauthScope == "openid profile email address phone") } @Test - fun testDiscoverEndpointWithURLNotProvided() { - runBlocking { - server.enqueue( - MockResponse() - .setResponseCode(HttpURLConnection.HTTP_OK) - .addHeader("Content-Type", "application/json") - .setBody(getJson("/discovery.json"))) - - val option1 = FROptionsBuilder.build { - urlPath { - authenticateEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate" - sessionEndpoint = "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session" - } - oauth { - oauthClientId = "AndroidTest" - oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" - oauthScope = "openid profile email address phone" - } - } - val copied = option1.discover(url) - assertTrue(copied.urlPath.authorizeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authorize") - assertTrue(copied.server.url == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as") - assertTrue(copied.urlPath.revokeEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/revoke") - assertTrue(copied.urlPath.tokenEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/token") - assertTrue(copied.urlPath.userinfoEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo") - assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff") - assertTrue(copied.urlPath.sessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/session") - assertTrue(copied.urlPath.authenticateEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authenticate") - assertTrue(copied.oauth.oauthClientId == "AndroidTest") - assertTrue(copied.oauth.oauthRedirectUri == "org.forgerock.demo:/oauth2redirect") - assertTrue(copied.oauth.oauthScope == "openid profile email address phone") + fun testDiscoverEndpointWithPingEndIdpSession() = runTest { + //The discovery endpoint returns with ping_end_idp_session_endpoint + server.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader("Content-Type", "application/json") + .setBody(getJson("/discoveryWithPingEndIdp.json"))) + + //Since config without oauthSignOutRedirectUri, use ping_end_idp_session_endpoint instead of end_session_endpoint if exists + val option1 = FROptionsBuilder.build { + oauth { + oauthClientId = "AndroidTest" + oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthScope = "openid profile email address phone" + } } + val copied = option1.discover(url) + //The ping_end_idp_session_endpoint is used instead of end_session_endpoint + assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/idp/signoff") } + @Test + fun testDiscoverEndpointWithPingEndIdpSessionSignOutRedirect() = runTest { + //The discovery endpoint returns with ping_end_idp_session_endpoint + server.enqueue( + MockResponse() + .setResponseCode(HttpURLConnection.HTTP_OK) + .addHeader("Content-Type", "application/json") + .setBody(getJson("/discoveryWithPingEndIdp.json"))) -} \ No newline at end of file + val option1 = FROptionsBuilder.build { + oauth { + oauthClientId = "AndroidTest" + oauthRedirectUri = "org.forgerock.demo:/oauth2redirect" + oauthScope = "openid profile email address phone" + //Since config with oauthSignOutRedirectUri, use end_session_endpoint instead of ping_end_idp_session_endpoint + oauthSignOutRedirectUri = "org.forgerock.demo:/oauth2redirect" + } + } + //The end_session_endpoint is used instead of ping_end_idp_session_endpoint + val copied = option1.discover(url) + assertTrue(copied.urlPath.endSessionEndpoint == "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff") + } +} diff --git a/forgerock-auth/src/test/resources/discoveryWithPingEndIdp.json b/forgerock-auth/src/test/resources/discoveryWithPingEndIdp.json new file mode 100644 index 00000000..a6f47bb4 --- /dev/null +++ b/forgerock-auth/src/test/resources/discoveryWithPingEndIdp.json @@ -0,0 +1,31 @@ +{ + "issuer" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as", + "authorization_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/authorize", + "pushed_authorization_request_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/par", + "token_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/token", + "userinfo_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/userinfo", + "jwks_uri" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/jwks", + "end_session_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/signoff", + "ping_end_idp_session_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/idp/signoff", + "check_session_iframe" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/checksession", + "introspection_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/introspect", + "revocation_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/revoke", + "device_authorization_endpoint" : "https://auth.pingone.ca/02fb4743-189a-4bc7-9d6c-a919edfe6447/as/device_authorization", + "claims_parameter_supported" : false, + "request_parameter_supported" : true, + "request_uri_parameter_supported" : false, + "require_pushed_authorization_requests" : false, + "scopes_supported" : [ "openid", "profile", "email", "address", "phone", "offline_access" ], + "response_types_supported" : [ "code", "id_token", "token id_token", "code id_token", "code token", "code token id_token" ], + "response_modes_supported" : [ "pi.flow", "query", "fragment", "form_post" ], + "grant_types_supported" : [ "authorization_code", "implicit", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code" ], + "subject_types_supported" : [ "public" ], + "id_token_signing_alg_values_supported" : [ "RS256" ], + "userinfo_signing_alg_values_supported" : [ "none" ], + "request_object_signing_alg_values_supported" : [ "none", "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ], + "token_endpoint_auth_methods_supported" : [ "client_secret_basic", "client_secret_post", "client_secret_jwt", "private_key_jwt" ], + "token_endpoint_auth_signing_alg_values_supported" : [ "HS256", "HS384", "HS512", "RS256", "RS384", "RS512" ], + "claim_types_supported" : [ "normal" ], + "claims_supported" : [ "sub", "iss", "auth_time", "acr", "name", "given_name", "family_name", "middle_name", "preferred_username", "profile", "picture", "zoneinfo", "phone_number", "updated_at", "address", "email", "locale" ], + "code_challenge_methods_supported" : [ "plain", "S256" ] +} \ No newline at end of file