From 6db2e03d1bb9901cb914bf58d89042c987f6ac36 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 1 Sep 2026 14:50:32 +0530 Subject: [PATCH 1/8] feat : Added the support for embedded discovery API --- .../auth0/android/embedded/DiscoveryMapper.kt | 62 +++++++ .../android/embedded/DiscoveryResponse.kt | 45 ++++++ .../auth0/android/embedded/DiscoveryResult.kt | 39 +++++ .../android/embedded/EmbeddedAuthClient.kt | 146 +++++++++++++++++ .../android/embedded/EmbeddedAuthException.kt | 23 +++ .../com/auth0/android/embedded/LoginOption.kt | 151 ++++++++++++++++++ 6 files changed, 466 insertions(+) create mode 100644 auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/DiscoveryResponse.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt create mode 100644 auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt new file mode 100644 index 00000000..74a90956 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt @@ -0,0 +1,62 @@ +package com.auth0.android.embedded + +/** + * Translates the `GET /e/discovery` wire payload into the public [DiscoveryResult]. + * + * An entry the SDK does not recognise becomes [LoginOption.Unknown] rather than being dropped, and + * no single entry can fail the whole response. + */ +internal fun DiscoveryResponse.toDiscoveryResult(): DiscoveryResult = + DiscoveryResult(alternatives.orEmpty().mapNotNull { it.toLoginOption() }) + +/** + * Maps one wire entry to its [LoginOption], or `null` if it named no grant type or omitted a + * property its variant needs to be usable. + */ +internal fun Alternative.toLoginOption(): LoginOption? { + val grantType = grantType ?: return null + return when (grantType) { + GRANT_PASSWORD -> LoginOption.Password + + GRANT_PASSWORD_REALM -> realm?.let { LoginOption.PasswordRealm(realm = it) } + + GRANT_WEBAUTHN -> connection?.let { LoginOption.Passkey(connection = it) } + + GRANT_PASSWORDLESS_OTP -> connection?.let { + LoginOption.PasswordlessOtp( + connection = it, + identifiers = identifierTypes.toOtpIdentifiers(), + type = if (type == TYPE_AUTH0) PasswordlessType.AUTH0 else PasswordlessType.LEGACY + ) + } + + GRANT_AUTHORIZATION_CODE -> connection?.let { LoginOption.EmbeddedAuthorize(connection = it) } + + GRANT_TOKEN_EXCHANGE -> subjectTokenType?.let { + LoginOption.NativeSocial(subjectTokenType = it) + } + + else -> LoginOption.Unknown(rawGrantType = grantType, connection = connection ?: realm) + } +} + +private fun List?.toOtpIdentifiers(): Set = + orEmpty().mapNotNullTo(LinkedHashSet()) { + when (it) { + IDENTIFIER_EMAIL -> PasswordlessIdentifier.EMAIL + IDENTIFIER_PHONE_NUMBER -> PasswordlessIdentifier.PHONE_NUMBER + else -> null + } + } + +private const val GRANT_PASSWORD = "password" +private const val GRANT_PASSWORD_REALM = "http://auth0.com/oauth/grant-type/password-realm" +private const val GRANT_WEBAUTHN = "urn:okta:params:oauth:grant-type:webauthn" +private const val GRANT_PASSWORDLESS_OTP = "http://auth0.com/oauth/grant-type/passwordless/otp" +private const val GRANT_AUTHORIZATION_CODE = "authorization_code" +private const val GRANT_TOKEN_EXCHANGE = "urn:ietf:params:oauth:grant-type:token-exchange" + +private const val TYPE_AUTH0 = "auth0" + +private const val IDENTIFIER_EMAIL = "email" +private const val IDENTIFIER_PHONE_NUMBER = "phone_number" diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResponse.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResponse.kt new file mode 100644 index 00000000..59a812d6 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResponse.kt @@ -0,0 +1,45 @@ +package com.auth0.android.embedded + +import com.google.gson.annotations.SerializedName + +/** + * Response of the `GET /e/discovery` endpoint. + * + * Internal on purpose: [DiscoveryResult] is the public model, built from this by + * [DiscoveryResponse.toDiscoveryResult]. + */ +internal data class DiscoveryResponse( + @SerializedName("alternatives") + val alternatives: List +) + +/** + * One entry of the wire's `alternatives` array. + * + * The wire is a discriminated union keyed by `grant_type`, each variant carrying only the properties + * its grant needs. Modeled here as one lenient shape so that an unrecognized variant deserializes + * instead of failing the whole response; [Alternative.toLoginOption] interprets it. + * + * `type` means different things per variant with disjoint value sets — `legacy`/`auth0` on the + * passwordless variant, `embedded_authorize` on the authorization-code one — so it is read only + * after selecting on [grantType], and never surfaces as a single public field. + */ +internal data class Alternative( + @SerializedName("grant_type") + val grantType: String?, + + @SerializedName("type") + val type: String? = null, + + @SerializedName("connection") + val connection: String? = null, + + @SerializedName("realm") + val realm: String? = null, + + @SerializedName("identifier_types") + val identifierTypes: List? = null, + + @SerializedName("subject_token_type") + val subjectTokenType: String? = null +) diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt new file mode 100644 index 00000000..1611fba9 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt @@ -0,0 +1,39 @@ +package com.auth0.android.embedded + +/** + * What a client can currently use to log a user in, as reported by the embedded discovery endpoint. + * + * @param options every login available, in the order the server returned them — which is a + * reasonable default order to render. An empty list is a valid result: it means this client has + * nothing enabled, not that the call failed. + */ +public class DiscoveryResult internal constructor( + public val options: List +) { + + /** The kinds of grant-types available. */ + public val types: Set = options.mapTo(LinkedHashSet()) { it.grantType } + + + /** Realm names of the password-realm logins on offer, in the order the server returned them. */ + public val passwordRealms: List = + options.filterIsInstance().map { it.realm } + + /** Connections holding a passkey credential, in the order the server returned them. */ + public val passkeyConnections: List = + options.filterIsInstance().map { it.connection } + + /** One-time-code logins on offer, in the order the server returned them. */ + public val otpOptions: List = + options.filterIsInstance() + + /** + * `subject_token_type` values of the native social logins on offer, in the order the server + * returned them. Each identifies which provider's native SDK to obtain a token from. + */ + public val socialProviders: List = + options.filterIsInstance().map { it.subjectTokenType } + + /** Whether a given kind of login is available. */ + public fun supports(grantType: GrantType): Boolean = grantType in types +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt new file mode 100644 index 00000000..aa8f8db4 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -0,0 +1,146 @@ +package com.auth0.android.embedded + +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException +import com.auth0.android.request.ErrorAdapter +import com.auth0.android.request.JsonAdapter +import com.auth0.android.request.Request +import com.auth0.android.request.internal.GsonAdapter +import com.auth0.android.request.internal.GsonAdapter.Companion.forMap +import com.auth0.android.request.internal.GsonProvider +import com.auth0.android.request.internal.RequestFactory +import com.auth0.android.request.internal.ResponseUtils.isNetworkError +import com.google.gson.Gson +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.io.IOException +import java.io.Reader + +/** + * API client for Auth0's embedded authentication API. + * + * ``` + * val auth0 = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") + * val client = EmbeddedAuthClient(auth0) + * ``` + * + */ +public class EmbeddedAuthClient(private val auth0: Auth0) { + + private val factory: RequestFactory = + RequestFactory(auth0.networkingClient, createErrorAdapter()) + + private val gson: Gson = GsonProvider.gson + + private val clientId: String + get() = auth0.clientId + + /** + * + * Fetches the list of login grant types enabled for the client + * + * Example usage: + * + * ``` + * client.discover("my-connection") + * .start(object : Callback { + * override fun onSuccess(result: DiscoveryResult) { } + * override fun onFailure(error: EmbeddedAuthException) { } + * }) + * ``` + * + * @param connection name of the connection to limit the results to. When omitted, all the + * client's enabled connections are considered. + * @return a request to configure and start that will yield a [DiscoveryResult] + */ + @JvmOverloads + public fun discover(connection: String? = null): Request { + val url = auth0.getDomainUrl().toHttpUrl().newBuilder() + .addPathSegment(EMBEDDED_PATH) + .addPathSegment(DISCOVERY_PATH) + .addQueryParameter(CLIENT_ID_KEY, clientId) + .apply { connection?.let { addQueryParameter(CONNECTION_KEY, it) } } + .build() + + return factory.get(url.toString(), discoveryAdapter(gson)) + } + + private companion object { + private const val EMBEDDED_PATH = "e" + private const val DISCOVERY_PATH = "discovery" + private const val CLIENT_ID_KEY = "client_id" + private const val CONNECTION_KEY = "connection" + private const val ERROR_KEY = "error" + private const val ERROR_DESCRIPTION_KEY = "error_description" + private const val DEFAULT_DESCRIPTION = + "An error occurred when trying to authenticate with the server." + + /** + * Parses the wire payload and translates it into the public [DiscoveryResult]. + */ + private fun discoveryAdapter(gson: Gson): JsonAdapter { + val adapter = GsonAdapter(DiscoveryResponse::class.java, gson) + return object : JsonAdapter { + @Throws(IOException::class) + override fun fromJson( + reader: Reader, + metadata: Map + ): DiscoveryResult = adapter.fromJson(reader, metadata).toDiscoveryResult() + } + } + + private fun createErrorAdapter(): ErrorAdapter { + val mapAdapter = forMap(GsonProvider.gson) + return object : ErrorAdapter { + /** + * The response body was not JSON. Notably the case for the `404` returned when + * embedded authentication is not enabled for the tenant, whose body is empty. + */ + override fun fromRawResponse( + statusCode: Int, + bodyText: String, + headers: Map> + ): EmbeddedAuthException { + return if (bodyText.isBlank()) EmbeddedAuthException( + Auth0Exception.EMPTY_BODY_ERROR, + Auth0Exception.EMPTY_RESPONSE_BODY_DESCRIPTION, + statusCode + ) else EmbeddedAuthException( + Auth0Exception.NON_JSON_ERROR, + bodyText, + statusCode + ) + } + + @Throws(IOException::class) + override fun fromJsonResponse( + statusCode: Int, + reader: Reader + ): EmbeddedAuthException { + val values = mapAdapter.fromJson(reader) + return EmbeddedAuthException( + values[ERROR_KEY] as? String ?: Auth0Exception.UNKNOWN_ERROR, + values[ERROR_DESCRIPTION_KEY] as? String ?: DEFAULT_DESCRIPTION, + statusCode + ) + } + + override fun fromException(cause: Throwable): EmbeddedAuthException { + return if (isNetworkError(cause)) EmbeddedAuthException( + Auth0Exception.UNKNOWN_ERROR, + "Failed to execute the network request", + cause = NetworkErrorException(cause) + ) else EmbeddedAuthException( + Auth0Exception.UNKNOWN_ERROR, + DEFAULT_DESCRIPTION, + cause = Auth0Exception(DEFAULT_DESCRIPTION, cause) + ) + } + } + } + } + + init { + factory.setAuth0ClientInfo(auth0.auth0UserAgent.value) + } +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt new file mode 100644 index 00000000..c675ed13 --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthException.kt @@ -0,0 +1,23 @@ +package com.auth0.android.embedded + +import com.auth0.android.Auth0Exception +import com.auth0.android.NetworkErrorException + +/** + * Represents an error raised by Auth0's embedded authentication API. + */ +public class EmbeddedAuthException internal constructor( + + public val code: String, + + public val description: String, + + /** HTTP status code of the response, or `0` when no response was received. */ + public val statusCode: Int = 0, + + cause: Throwable? = null +) : Auth0Exception(description, cause) { + + public val isNetworkError: Boolean + get() = cause is NetworkErrorException +} diff --git a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt new file mode 100644 index 00000000..8ac8168b --- /dev/null +++ b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt @@ -0,0 +1,151 @@ +package com.auth0.android.embedded + +/** + * The kinds of grant-types available for a client. + */ +public enum class GrantType { + PASSWORD, + PASSWORD_REALM, + PASSKEY, + PASSWORDLESS_OTP, + NATIVE_SOCIAL, + AUTHORIZATION_CODE, + + /** A grant-type this version of the SDK does not model. See [LoginOption.Unknown]. */ + UNKNOWN +} + +/** + * A single way a client can log a user in, as reported by the embedded discovery endpoint. Switch + * on the subtype to read what each option needs in order to be acted on: + * + */ +public sealed interface LoginOption { + + public val grantType: GrantType + + /** + * Connection this option authenticates against, or `null` when the server resolves it — the + * case for a password login against the tenant's default directory, and for native social. + */ + public val connection: String? + + /** + * Username and password login against the tenant's default directory. + * + * Carries no connection: the server resolves it from the tenant's default directory. Log in + * with `AuthenticationAPIClient.login(email, password)`. See [PasswordRealm] for the same login + * against a named realm; a tenant can advertise both. + */ + public object Password : LoginOption { + override val connection: String? = null + override val grantType: GrantType = GrantType.PASSWORD + } + + /** + * Username and password login against a named realm. Log in with + * `AuthenticationAPIClient.login(email, password, realm)`. + * + * @param realm connection to authenticate against. + */ + @ConsistentCopyVisibility + public data class PasswordRealm internal constructor( + public val realm: String + ) : LoginOption { + override val connection: String = realm + override val grantType: GrantType = GrantType.PASSWORD_REALM + } + + /** + * Passkey login. + * + * @param connection connection holding the credential. + */ + @ConsistentCopyVisibility + public data class Passkey internal constructor( + override val connection: String + ) : LoginOption { + override val grantType: GrantType = GrantType.PASSKEY + } + + /** + * Login with a one-time code. + * + * @param connection connection to challenge. + * @param identifiers what the connection accepts. Offer one entry point per identifier — a + * connection accepting both is two ways to sign in, not one. + * @param type which challenge call this connection needs, and which parameters the token + * exchange then takes. See [PasswordlessType]. + */ + @ConsistentCopyVisibility + public data class PasswordlessOtp internal constructor( + override val connection: String, + public val identifiers: Set, + public val type: PasswordlessType + ) : LoginOption { + override val grantType: GrantType = GrantType.PASSWORDLESS_OTP + } + + /** + * Login by exchanging a token obtained from a social provider's own native SDK. + * + * @param subjectTokenType the `subject_token_type` the server advertised, identifying which + * provider's token to exchange — e.g. `http://auth0.com/oauth/token-type/google-id-token`. + */ + @ConsistentCopyVisibility + public data class NativeSocial internal constructor( + public val subjectTokenType: String + ) : LoginOption { + override val connection: String? = null + override val grantType: GrantType = GrantType.NATIVE_SOCIAL + } + + /** + * Interactive login through the embedded authorize endpoint. + * + * @param connection connection to authenticate against. + */ + @ConsistentCopyVisibility + public data class EmbeddedAuthorize internal constructor( + override val connection: String + ) : LoginOption { + override val grantType: GrantType = GrantType.AUTHORIZATION_CODE + } + + /** + * A login the server advertised that this version of the SDK does not model. Reported rather + * than dropped, so the result stays a truthful account of what the server said. + * + * @param rawGrantType the `grant_type` the server sent. + * @param connection connection the server sent, if any. + */ + @ConsistentCopyVisibility + public data class Unknown internal constructor( + public val rawGrantType: String, + override val connection: String? + ) : LoginOption { + override val grantType: GrantType = GrantType.UNKNOWN + } +} + +/** + * An identifier a passwordless connection accepts for a one-time code. + */ +public enum class PasswordlessIdentifier { + EMAIL, + PHONE_NUMBER +} + +/** + * Which passwordless flow a connection uses. + * + * The two need different challenge calls and take mutually exclusive parameters on the token + * exchange, so this is what decides how to act on a [LoginOption.PasswordlessOtp]. + */ +public enum class PasswordlessType { + /** Legacy passwordless, on an email or SMS connection. */ + LEGACY, + + /** Passwordless on a database connection. */ + AUTH0 +} From 2705e21b88a9aa4d1513614ad86378e85a86022b Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 1 Sep 2026 15:35:34 +0530 Subject: [PATCH 2/8] Added UTs for the discovery API --- .../android/embedded/DiscoveryMapperTest.kt | 202 ++++++++++++++++++ .../embedded/EmbeddedAuthClientTest.kt | 166 ++++++++++++++ .../android/util/EmbeddedAuthMockServer.kt | 95 ++++++++ 3 files changed, 463 insertions(+) create mode 100644 auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt create mode 100644 auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt create mode 100644 auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt diff --git a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt new file mode 100644 index 00000000..b13f36e9 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt @@ -0,0 +1,202 @@ +package com.auth0.android.embedded + +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.instanceOf +import org.hamcrest.Matchers.nullValue +import org.junit.Test + +public class DiscoveryMapperTest { + + @Test + public fun `password maps to the Password object`() { + val option = Alternative(grantType = GRANT_PASSWORD).toLoginOption() + + assertThat(option, `is`(LoginOption.Password)) + } + + @Test + public fun `password-realm maps its realm onto realm and connection`() { + val option = Alternative(grantType = GRANT_PASSWORD_REALM, realm = "db").toLoginOption() + + val realm = option as LoginOption.PasswordRealm + assertThat(realm.realm, `is`("db")) + assertThat(realm.connection, `is`("db")) + assertThat(realm.grantType, `is`(GrantType.PASSWORD_REALM)) + } + + @Test + public fun `webauthn maps its connection to a Passkey`() { + val option = Alternative(grantType = GRANT_WEBAUTHN, connection = "passkeys").toLoginOption() + + assertThat(option, `is`(instanceOf(LoginOption.Passkey::class.java))) + assertThat((option as LoginOption.Passkey).connection, `is`("passkeys")) + } + + @Test + public fun `passwordless otp reads its connection, type and identifiers`() { + val option = Alternative( + grantType = GRANT_PASSWORDLESS_OTP, + connection = "email", + type = "auth0", + identifierTypes = listOf("email", "phone_number") + ).toLoginOption() + + val otp = option as LoginOption.PasswordlessOtp + assertThat(otp.connection, `is`("email")) + assertThat(otp.type, `is`(PasswordlessType.AUTH0)) + assertThat( + otp.identifiers, + contains(PasswordlessIdentifier.EMAIL, PasswordlessIdentifier.PHONE_NUMBER) + ) + } + + @Test + public fun `passwordless otp without the auth0 type defaults to legacy`() { + val option = Alternative( + grantType = GRANT_PASSWORDLESS_OTP, + connection = "sms" + ).toLoginOption() + + assertThat((option as LoginOption.PasswordlessOtp).type, `is`(PasswordlessType.LEGACY)) + } + + @Test + public fun `passwordless otp drops identifier types it does not recognise`() { + val option = Alternative( + grantType = GRANT_PASSWORDLESS_OTP, + connection = "email", + identifierTypes = listOf("email", "carrier_pigeon") + ).toLoginOption() + + assertThat( + (option as LoginOption.PasswordlessOtp).identifiers, + contains(PasswordlessIdentifier.EMAIL) + ) + } + + @Test + public fun `token-exchange maps its subject token type to NativeSocial`() { + val option = Alternative( + grantType = GRANT_TOKEN_EXCHANGE, + subjectTokenType = "google-id-token" + ).toLoginOption() + + val social = option as LoginOption.NativeSocial + assertThat(social.subjectTokenType, `is`("google-id-token")) + assertThat(social.connection, `is`(nullValue())) + } + + @Test + public fun `authorization_code maps its connection to EmbeddedAuthorize`() { + val option = + Alternative(grantType = GRANT_AUTHORIZATION_CODE, connection = "google").toLoginOption() + + assertThat(option, `is`(instanceOf(LoginOption.EmbeddedAuthorize::class.java))) + assertThat((option as LoginOption.EmbeddedAuthorize).connection, `is`("google")) + } + + @Test + public fun `an unrecognised grant becomes Unknown carrying the raw grant type`() { + val option = + Alternative(grantType = "urn:future", connection = "c").toLoginOption() + + val unknown = option as LoginOption.Unknown + assertThat(unknown.rawGrantType, `is`("urn:future")) + assertThat(unknown.connection, `is`("c")) + assertThat(unknown.grantType, `is`(GrantType.UNKNOWN)) + } + + @Test + public fun `an unknown grant falls back to realm when it has no connection`() { + val option = Alternative(grantType = "urn:future", realm = "r").toLoginOption() + + assertThat((option as LoginOption.Unknown).connection, `is`("r")) + } + + @Test + public fun `an entry without a grant type is dropped`() { + assertThat(Alternative(grantType = null).toLoginOption(), `is`(nullValue())) + } + + @Test + public fun `a known grant missing a required property is dropped`() { + assertThat(Alternative(grantType = GRANT_PASSWORD_REALM).toLoginOption(), `is`(nullValue())) + assertThat(Alternative(grantType = GRANT_WEBAUTHN).toLoginOption(), `is`(nullValue())) + assertThat(Alternative(grantType = GRANT_PASSWORDLESS_OTP).toLoginOption(), `is`(nullValue())) + assertThat(Alternative(grantType = GRANT_AUTHORIZATION_CODE).toLoginOption(), `is`(nullValue())) + assertThat(Alternative(grantType = GRANT_TOKEN_EXCHANGE).toLoginOption(), `is`(nullValue())) + } + + @Test + public fun `toDiscoveryResult preserves the server order and drops unusable entries`() { + val response = DiscoveryResponse( + listOf( + Alternative(grantType = GRANT_PASSWORD_REALM), // unusable: no realm + Alternative(grantType = GRANT_PASSWORD), + Alternative(grantType = GRANT_WEBAUTHN, connection = "passkeys") + ) + ) + + val options = response.toDiscoveryResult().options + + assertThat(options.map { it.grantType }, contains(GrantType.PASSWORD, GrantType.PASSKEY)) + } + + @Test + public fun `toDiscoveryResult on no alternatives yields an empty result`() { + val result = DiscoveryResponse(emptyList()).toDiscoveryResult() + + assertThat(result.options, `is`(empty())) + assertThat(result.types, `is`(empty())) + } + + @Test + public fun `result projections group options by kind`() { + val result = DiscoveryResponse( + listOf( + Alternative(grantType = GRANT_PASSWORD_REALM, realm = "db"), + Alternative(grantType = GRANT_WEBAUTHN, connection = "passkeys"), + Alternative(grantType = GRANT_TOKEN_EXCHANGE, subjectTokenType = "google-id-token"), + Alternative(grantType = GRANT_PASSWORDLESS_OTP, connection = "email") + ) + ).toDiscoveryResult() + + assertThat(result.passwordRealms, contains("db")) + assertThat(result.passkeyConnections, contains("passkeys")) + assertThat(result.socialProviders, contains("google-id-token")) + assertThat(result.otpOptions.map { it.connection }, contains("email")) + assertThat( + result.types, + containsInAnyOrder( + GrantType.PASSWORD_REALM, + GrantType.PASSKEY, + GrantType.NATIVE_SOCIAL, + GrantType.PASSWORDLESS_OTP + ) + ) + } + + @Test + public fun `supports reflects the grant types present`() { + val result = + DiscoveryResponse(listOf(Alternative(grantType = GRANT_PASSWORD))).toDiscoveryResult() + + assertThat(result.supports(GrantType.PASSWORD), `is`(true)) + assertThat(result.supports(GrantType.PASSKEY), `is`(false)) + } + + private companion object { + private const val GRANT_PASSWORD = "password" + private const val GRANT_PASSWORD_REALM = "http://auth0.com/oauth/grant-type/password-realm" + private const val GRANT_WEBAUTHN = "urn:okta:params:oauth:grant-type:webauthn" + private const val GRANT_PASSWORDLESS_OTP = + "http://auth0.com/oauth/grant-type/passwordless/otp" + private const val GRANT_AUTHORIZATION_CODE = "authorization_code" + private const val GRANT_TOKEN_EXCHANGE = + "urn:ietf:params:oauth:grant-type:token-exchange" + } +} diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt new file mode 100644 index 00000000..df889845 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -0,0 +1,166 @@ +package com.auth0.android.embedded + +import com.auth0.android.Auth0 +import com.auth0.android.Auth0Exception +import com.auth0.android.util.EmbeddedAuthMockServer +import com.auth0.android.util.SSLTestUtils.testClient +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.`is` +import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +public class EmbeddedAuthClientTest { + + private lateinit var mockAPI: EmbeddedAuthMockServer + private lateinit var client: EmbeddedAuthClient + + private val auth0: Auth0 + get() { + val auth0 = Auth0.getInstance(CLIENT_ID, mockAPI.domain, mockAPI.domain) + auth0.networkingClient = testClient + return auth0 + } + + @Before + public fun setUp() { + mockAPI = EmbeddedAuthMockServer() + client = EmbeddedAuthClient(auth0) + } + + @After + public fun tearDown() { + mockAPI.shutdown() + } + + @Test + public fun `discover should GET the discovery endpoint with the client id`() { + mockAPI.willReturnEmptyDiscovery() + + client.discover().execute() + + val request = mockAPI.takeRequest() + assertThat(request.method, `is`("GET")) + assertThat(request.requestUrl?.encodedPath, `is`("/e/discovery")) + assertThat(request.requestUrl?.queryParameter("client_id"), `is`(CLIENT_ID)) + assertThat(request.requestUrl?.queryParameter("connection"), `is`(nullValue())) + } + + @Test + public fun `discover should add the connection query parameter when given`() { + mockAPI.willReturnEmptyDiscovery() + + client.discover("my-connection").execute() + + val request = mockAPI.takeRequest() + assertThat(request.requestUrl?.queryParameter("connection"), `is`("my-connection")) + } + + @Test + public fun `discover should send the Auth0-Client header`() { + mockAPI.willReturnEmptyDiscovery() + + client.discover().execute() + + val request = mockAPI.takeRequest() + assertThat(request.getHeader("Auth0-Client"), `is`(notNullValue())) + } + + @Test + public fun `discover should parse a full response into a DiscoveryResult`() { + mockAPI.willReturnFullDiscovery() + + val result = client.discover().execute() + + assertThat( + result.types, + containsInAnyOrder( + GrantType.PASSWORD, + GrantType.PASSWORD_REALM, + GrantType.PASSKEY, + GrantType.PASSWORDLESS_OTP, + GrantType.NATIVE_SOCIAL, + GrantType.AUTHORIZATION_CODE, + GrantType.UNKNOWN + ) + ) + } + + @Test + public fun `discover should surface an empty body 404 as an embedded auth error`() { + mockAPI.willReturnNotEnabled() + + var error: EmbeddedAuthException? = null + try { + client.discover().execute() + } catch (ex: EmbeddedAuthException) { + error = ex + } + + assertThat(error, `is`(notNullValue())) + assertThat(error?.code, `is`(Auth0Exception.EMPTY_BODY_ERROR)) + assertThat(error?.statusCode, `is`(404)) + assertThat(error?.isNetworkError, `is`(false)) + } + + @Test + public fun `discover should surface a non JSON error body`() { + mockAPI.willReturnPlainTextError() + + var error: EmbeddedAuthException? = null + try { + client.discover().execute() + } catch (ex: EmbeddedAuthException) { + error = ex + } + + assertThat(error, `is`(notNullValue())) + assertThat(error?.code, `is`(Auth0Exception.NON_JSON_ERROR)) + assertThat(error?.description, `is`(EmbeddedAuthMockServer.PLAIN_TEXT_ERROR)) + assertThat(error?.statusCode, `is`(500)) + } + + @Test + public fun `discover should surface a JSON error envelope`() { + mockAPI.willReturnJsonError() + + var error: EmbeddedAuthException? = null + try { + client.discover().execute() + } catch (ex: EmbeddedAuthException) { + error = ex + } + + assertThat(error, `is`(notNullValue())) + assertThat(error?.code, `is`(EmbeddedAuthMockServer.ERROR_CODE)) + assertThat(error?.description, `is`(EmbeddedAuthMockServer.ERROR_DESCRIPTION)) + assertThat(error?.statusCode, `is`(400)) + } + + @Test + public fun `discover should surface a network failure as a network error`() { + mockAPI.shutdown() + + var error: EmbeddedAuthException? = null + try { + client.discover().execute() + } catch (ex: EmbeddedAuthException) { + error = ex + } + + assertThat(error, `is`(notNullValue())) + assertThat(error?.isNetworkError, `is`(true)) + } + + private companion object { + private const val CLIENT_ID = "CLIENT_ID" + } +} diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt new file mode 100644 index 00000000..f271ca36 --- /dev/null +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -0,0 +1,95 @@ +package com.auth0.android.util + +import okhttp3.mockwebserver.MockResponse + +internal class EmbeddedAuthMockServer : APIMockServer() { + + fun willReturnFullDiscovery(): EmbeddedAuthMockServer { + val json = """ + { + "alternatives": [ + { "grant_type": "password" }, + { + "grant_type": "http://auth0.com/oauth/grant-type/password-realm", + "realm": "$PASSWORD_REALM" + }, + { + "grant_type": "urn:okta:params:oauth:grant-type:webauthn", + "connection": "$PASSKEY_CONNECTION" + }, + { + "grant_type": "http://auth0.com/oauth/grant-type/passwordless/otp", + "connection": "$OTP_EMAIL_CONNECTION", + "type": "auth0", + "identifier_types": ["email"] + }, + { + "grant_type": "http://auth0.com/oauth/grant-type/passwordless/otp", + "connection": "$OTP_SMS_CONNECTION", + "identifier_types": ["phone_number"] + }, + { + "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", + "subject_token_type": "$SUBJECT_TOKEN_TYPE" + }, + { + "grant_type": "authorization_code", + "connection": "$AUTHORIZE_CONNECTION" + }, + { + "grant_type": "$UNKNOWN_GRANT", + "connection": "$UNKNOWN_CONNECTION" + } + ] + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 200)) + return this + } + + fun willReturnEmptyDiscovery(): EmbeddedAuthMockServer { + server.enqueue(responseWithJSON("""{ "alternatives": [] }""", 200)) + return this + } + + /** The empty-body 404 returned when embedded authentication is not enabled for the tenant. */ + fun willReturnNotEnabled(): EmbeddedAuthMockServer { + server.enqueue(MockResponse().setResponseCode(404)) + return this + } + + fun willReturnPlainTextError(): EmbeddedAuthMockServer { + server.enqueue( + MockResponse() + .setResponseCode(500) + .addHeader("Content-Type", "text/plain") + .setBody(PLAIN_TEXT_ERROR) + ) + return this + } + + fun willReturnJsonError(): EmbeddedAuthMockServer { + val json = """ + { + "error": "$ERROR_CODE", + "error_description": "$ERROR_DESCRIPTION" + } + """.trimIndent() + server.enqueue(responseWithJSON(json, 400)) + return this + } + + companion object { + const val PASSWORD_REALM = "Username-Password-Authentication" + const val PASSKEY_CONNECTION = "passkey-connection" + const val OTP_EMAIL_CONNECTION = "email" + const val OTP_SMS_CONNECTION = "sms" + const val SUBJECT_TOKEN_TYPE = "http://auth0.com/oauth/token-type/google-id-token" + const val AUTHORIZE_CONNECTION = "google-oauth2" + const val UNKNOWN_GRANT = "urn:example:params:oauth:grant-type:future" + const val UNKNOWN_CONNECTION = "future-connection" + const val PLAIN_TEXT_ERROR = "Internal Server Error" + const val ERROR_CODE = "invalid_request" + const val ERROR_DESCRIPTION = "The connection was not found." + } +} From 94f4d5fb3dcfb9aa81336c945a5503db3a4868b5 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 4 Sep 2026 11:00:32 +0530 Subject: [PATCH 3/8] Updated the logic to check the embedded authorize flow --- .../auth0/android/embedded/DiscoveryMapper.kt | 4 +- .../auth0/android/embedded/DiscoveryResult.kt | 14 ++++ .../com/auth0/android/embedded/LoginOption.kt | 8 +- .../android/embedded/DiscoveryMapperTest.kt | 74 +++++++++++++++++-- .../android/util/EmbeddedAuthMockServer.kt | 1 + 5 files changed, 92 insertions(+), 9 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt index 74a90956..2d1b5790 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt @@ -30,7 +30,9 @@ internal fun Alternative.toLoginOption(): LoginOption? { ) } - GRANT_AUTHORIZATION_CODE -> connection?.let { LoginOption.EmbeddedAuthorize(connection = it) } + GRANT_AUTHORIZATION_CODE -> connection?.let { + LoginOption.AuthorizationCode(connection = it, type = type) + } GRANT_TOKEN_EXCHANGE -> subjectTokenType?.let { LoginOption.NativeSocial(subjectTokenType = it) diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt index 1611fba9..2a2d4841 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryResult.kt @@ -34,6 +34,20 @@ public class DiscoveryResult internal constructor( public val socialProviders: List = options.filterIsInstance().map { it.subjectTokenType } + /** + * Whether the new embedded-authorize flow is available for this client. + * + * `true` when the discovery response contains an `authorization_code` entry with + * `type == "embedded_authorize"`. + */ + public val hasEmbeddedAuthorization: Boolean = + options.filterIsInstance() + .any { it.type == EMBEDDED_AUTHORIZE_TYPE } + + private companion object { + private const val EMBEDDED_AUTHORIZE_TYPE = "embedded_authorize" + } + /** Whether a given kind of login is available. */ public fun supports(grantType: GrantType): Boolean = grantType in types } diff --git a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt index 8ac8168b..fc76f84d 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt @@ -101,13 +101,15 @@ public sealed interface LoginOption { } /** - * Interactive login through the embedded authorize endpoint. + * Login by exchanging an authorization code for a valid set of Credentials. * * @param connection connection to authenticate against. + * @param type the `type` value the server sent, e.g. `"embedded_authorize"`. */ @ConsistentCopyVisibility - public data class EmbeddedAuthorize internal constructor( - override val connection: String + public data class AuthorizationCode internal constructor( + override val connection: String, + public val type: String? ) : LoginOption { override val grantType: GrantType = GrantType.AUTHORIZATION_CODE } diff --git a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt index b13f36e9..f8520960 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt @@ -91,12 +91,28 @@ public class DiscoveryMapperTest { } @Test - public fun `authorization_code maps its connection to EmbeddedAuthorize`() { - val option = - Alternative(grantType = GRANT_AUTHORIZATION_CODE, connection = "google").toLoginOption() + public fun `authorization_code maps its connection and type to EmbeddedAuthorize`() { + val option = Alternative( + grantType = GRANT_AUTHORIZATION_CODE, + type = "embedded_authorize", + connection = "google" + ).toLoginOption() + + val authorize = option as LoginOption.AuthorizationCode + assertThat(authorize.connection, `is`("google")) + assertThat(authorize.type, `is`("embedded_authorize")) + assertThat(authorize.grantType, `is`(GrantType.AUTHORIZATION_CODE)) + } - assertThat(option, `is`(instanceOf(LoginOption.EmbeddedAuthorize::class.java))) - assertThat((option as LoginOption.EmbeddedAuthorize).connection, `is`("google")) + @Test + public fun `authorization_code without a type still maps to EmbeddedAuthorize`() { + val option = Alternative( + grantType = GRANT_AUTHORIZATION_CODE, + connection = "google" + ).toLoginOption() + + val authorize = option as LoginOption.AuthorizationCode + assertThat(authorize.type, `is`(nullValue())) } @Test @@ -180,6 +196,54 @@ public class DiscoveryMapperTest { ) } + @Test + public fun `hasEmbeddedAuthorize is true when authorization_code with embedded_authorize type is present`() { + val result = DiscoveryResponse( + listOf( + Alternative( + grantType = GRANT_AUTHORIZATION_CODE, + type = "embedded_authorize", + connection = "my-db" + ) + ) + ).toDiscoveryResult() + + assertThat(result.hasEmbeddedAuthorization, `is`(true)) + } + + @Test + public fun `hasEmbeddedAuthorize is false when authorization_code has no type`() { + val result = DiscoveryResponse( + listOf(Alternative(grantType = GRANT_AUTHORIZATION_CODE, connection = "my-db")) + ).toDiscoveryResult() + + assertThat(result.hasEmbeddedAuthorization, `is`(false)) + } + + @Test + public fun `hasEmbeddedAuthorize is false when authorization_code has an unrecognised type`() { + val result = DiscoveryResponse( + listOf( + Alternative( + grantType = GRANT_AUTHORIZATION_CODE, + type = "future_type", + connection = "my-db" + ) + ) + ).toDiscoveryResult() + + assertThat(result.hasEmbeddedAuthorization, `is`(false)) + } + + @Test + public fun `hasEmbeddedAuthorize is false when no authorization_code entry is present`() { + val result = DiscoveryResponse( + listOf(Alternative(grantType = GRANT_PASSWORD)) + ).toDiscoveryResult() + + assertThat(result.hasEmbeddedAuthorization, `is`(false)) + } + @Test public fun `supports reflects the grant types present`() { val result = diff --git a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index f271ca36..55a50e1d 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -34,6 +34,7 @@ internal class EmbeddedAuthMockServer : APIMockServer() { }, { "grant_type": "authorization_code", + "type": "embedded_authorize", "connection": "$AUTHORIZE_CONNECTION" }, { From 25371d91ec370af6ed795ab0742db6ab54b5b473 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Fri, 4 Sep 2026 11:12:09 +0530 Subject: [PATCH 4/8] Made type as non-null in the Authorization code grant type --- .../java/com/auth0/android/embedded/DiscoveryMapper.kt | 2 +- .../java/com/auth0/android/embedded/LoginOption.kt | 2 +- .../com/auth0/android/embedded/DiscoveryMapperTest.kt | 10 ---------- 3 files changed, 2 insertions(+), 12 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt index 2d1b5790..87408bf6 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt @@ -31,7 +31,7 @@ internal fun Alternative.toLoginOption(): LoginOption? { } GRANT_AUTHORIZATION_CODE -> connection?.let { - LoginOption.AuthorizationCode(connection = it, type = type) + LoginOption.AuthorizationCode(connection = it, type = type.orEmpty()) } GRANT_TOKEN_EXCHANGE -> subjectTokenType?.let { diff --git a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt index fc76f84d..f7ed963d 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt @@ -109,7 +109,7 @@ public sealed interface LoginOption { @ConsistentCopyVisibility public data class AuthorizationCode internal constructor( override val connection: String, - public val type: String? + public val type: String ) : LoginOption { override val grantType: GrantType = GrantType.AUTHORIZATION_CODE } diff --git a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt index f8520960..f37eed8e 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt @@ -104,16 +104,6 @@ public class DiscoveryMapperTest { assertThat(authorize.grantType, `is`(GrantType.AUTHORIZATION_CODE)) } - @Test - public fun `authorization_code without a type still maps to EmbeddedAuthorize`() { - val option = Alternative( - grantType = GRANT_AUTHORIZATION_CODE, - connection = "google" - ).toLoginOption() - - val authorize = option as LoginOption.AuthorizationCode - assertThat(authorize.type, `is`(nullValue())) - } @Test public fun `an unrecognised grant becomes Unknown carrying the raw grant type`() { From 57a274a80d4c85ed92edcfc15e8f0175e7bb8de7 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 9 Sep 2026 19:54:59 +0530 Subject: [PATCH 5/8] Made connection an optional property in the authorization_code grant type --- .../auth0/android/embedded/DiscoveryMapper.kt | 5 ++-- .../com/auth0/android/embedded/LoginOption.kt | 2 +- .../android/embedded/DiscoveryMapperTest.kt | 23 ++++++++++++++++++- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt index 87408bf6..61a8391d 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/DiscoveryMapper.kt @@ -30,9 +30,8 @@ internal fun Alternative.toLoginOption(): LoginOption? { ) } - GRANT_AUTHORIZATION_CODE -> connection?.let { - LoginOption.AuthorizationCode(connection = it, type = type.orEmpty()) - } + GRANT_AUTHORIZATION_CODE -> + LoginOption.AuthorizationCode(connection = connection, type = type.orEmpty()) GRANT_TOKEN_EXCHANGE -> subjectTokenType?.let { LoginOption.NativeSocial(subjectTokenType = it) diff --git a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt index f7ed963d..64f5c49d 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/LoginOption.kt @@ -108,7 +108,7 @@ public sealed interface LoginOption { */ @ConsistentCopyVisibility public data class AuthorizationCode internal constructor( - override val connection: String, + override val connection: String? = null, public val type: String ) : LoginOption { override val grantType: GrantType = GrantType.AUTHORIZATION_CODE diff --git a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt index f37eed8e..0b0ad1b0 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/DiscoveryMapperTest.kt @@ -104,6 +104,19 @@ public class DiscoveryMapperTest { assertThat(authorize.grantType, `is`(GrantType.AUTHORIZATION_CODE)) } + @Test + public fun `authorization_code without a connection is kept with a null connection`() { + val option = Alternative( + grantType = GRANT_AUTHORIZATION_CODE, + type = "embedded_authorize" + ).toLoginOption() + + val authorize = option as LoginOption.AuthorizationCode + assertThat(authorize.connection, `is`(nullValue())) + assertThat(authorize.type, `is`("embedded_authorize")) + assertThat(authorize.grantType, `is`(GrantType.AUTHORIZATION_CODE)) + } + @Test public fun `an unrecognised grant becomes Unknown carrying the raw grant type`() { @@ -133,7 +146,6 @@ public class DiscoveryMapperTest { assertThat(Alternative(grantType = GRANT_PASSWORD_REALM).toLoginOption(), `is`(nullValue())) assertThat(Alternative(grantType = GRANT_WEBAUTHN).toLoginOption(), `is`(nullValue())) assertThat(Alternative(grantType = GRANT_PASSWORDLESS_OTP).toLoginOption(), `is`(nullValue())) - assertThat(Alternative(grantType = GRANT_AUTHORIZATION_CODE).toLoginOption(), `is`(nullValue())) assertThat(Alternative(grantType = GRANT_TOKEN_EXCHANGE).toLoginOption(), `is`(nullValue())) } @@ -201,6 +213,15 @@ public class DiscoveryMapperTest { assertThat(result.hasEmbeddedAuthorization, `is`(true)) } + @Test + public fun `hasEmbeddedAuthorize is true when embedded_authorize is present without a connection`() { + val result = DiscoveryResponse( + listOf(Alternative(grantType = GRANT_AUTHORIZATION_CODE, type = "embedded_authorize")) + ).toDiscoveryResult() + + assertThat(result.hasEmbeddedAuthorization, `is`(true)) + } + @Test public fun `hasEmbeddedAuthorize is false when authorization_code has no type`() { val result = DiscoveryResponse( From 145efbbbe19e92904ac3a202f64009dcb49cdddf Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Tue, 15 Sep 2026 09:09:12 +0530 Subject: [PATCH 6/8] Added a new sample app module --- sample-embedded/build.gradle | 68 +++++++ sample-embedded/src/main/AndroidManifest.xml | 20 ++ .../auth0/sample/embedded/DiscoveryScreen.kt | 185 ++++++++++++++++++ .../auth0/sample/embedded/DiscoveryUiState.kt | 11 ++ .../sample/embedded/EmbeddedViewModel.kt | 43 ++++ .../com/auth0/sample/embedded/MainActivity.kt | 17 ++ .../auth0/sample/embedded/ui/theme/Color.kt | 7 + .../auth0/sample/embedded/ui/theme/Theme.kt | 25 +++ .../src/main/res/values/strings.xml | 15 ++ .../src/main/res/values/themes.xml | 6 + settings.gradle | 3 +- 11 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 sample-embedded/build.gradle create mode 100644 sample-embedded/src/main/AndroidManifest.xml create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryUiState.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Color.kt create mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Theme.kt create mode 100644 sample-embedded/src/main/res/values/strings.xml create mode 100644 sample-embedded/src/main/res/values/themes.xml diff --git a/sample-embedded/build.gradle b/sample-embedded/build.gradle new file mode 100644 index 00000000..d09a2341 --- /dev/null +++ b/sample-embedded/build.gradle @@ -0,0 +1,68 @@ +// Pull in the Compose compiler plugin classpath for this module only — not the root project. +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "org.jetbrains.kotlin:compose-compiler-gradle-plugin:2.0.21" + } +} + +plugins { + id 'com.android.application' + id 'kotlin-android' +} + +// Applied here (not in plugins{}) because the classpath is in this module's own buildscript block +// above, which is resolved after the plugins{} DSL but before apply plugin: calls. +apply plugin: 'org.jetbrains.kotlin.plugin.compose' + +android { + namespace 'com.auth0.sample.embedded' + compileSdk 36 + + defaultConfig { + applicationId 'com.auth0.sample.embedded' + minSdk 26 + targetSdk 36 + versionCode 1 + versionName "1.0" + + // The embedded flow is browserless, but the :auth0 library manifest still contributes a + // redirect activity that requires these placeholders to be present for the manifest merge. + manifestPlaceholders = [auth0Domain: "@string/com_auth0_domain", auth0Scheme: "@string/com_auth0_scheme"] + } + + buildFeatures { + compose true + } + buildTypes { + release { + minifyEnabled false + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } +} + +dependencies { + implementation project(':auth0') + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + + implementation platform('androidx.compose:compose-bom:2024.09.00') + implementation 'androidx.compose.material3:material3' + implementation 'androidx.compose.ui:ui' + implementation 'androidx.compose.ui:ui-tooling-preview' + debugImplementation 'androidx.compose.ui:ui-tooling' + + implementation 'androidx.activity:activity-compose:1.9.2' + implementation 'androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6' + implementation 'androidx.lifecycle:lifecycle-runtime-compose:2.8.6' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.6' +} diff --git a/sample-embedded/src/main/AndroidManifest.xml b/sample-embedded/src/main/AndroidManifest.xml new file mode 100644 index 00000000..b37ff09d --- /dev/null +++ b/sample-embedded/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt new file mode 100644 index 00000000..792e83d4 --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt @@ -0,0 +1,185 @@ +package com.auth0.sample.embedded + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel +import com.auth0.android.embedded.DiscoveryResult +import com.auth0.android.embedded.EmbeddedAuthException + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun EmbeddedScreen(viewModel: EmbeddedViewModel = viewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + var connection by rememberSaveable { mutableStateOf("") } + val isLoading = state is DiscoveryUiState.Loading + + val runDiscovery = { viewModel.discover(connection.trim().ifBlank { null }) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_discovery)) }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primary, + titleContentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + .padding(horizontal = 20.dp, vertical = 16.dp), + ) { + OutlinedTextField( + value = connection, + onValueChange = { connection = it }, + label = { Text(stringResource(R.string.hint_connection)) }, + singleLine = true, + enabled = !isLoading, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { runDiscovery() }), + ) + + // Give the button some breathing room below the input. + Spacer(Modifier.height(24.dp)) + + Button( + onClick = runDiscovery, + enabled = !isLoading, + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + if (isLoading) { + CircularProgressIndicator( + modifier = Modifier.size(18.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + Spacer(Modifier.width(12.dp)) + Text(stringResource(R.string.status_discovering)) + } else { + Text(stringResource(R.string.action_discover)) + } + } + + Spacer(Modifier.height(28.dp)) + + Text( + text = stringResource(R.string.label_result), + style = MaterialTheme.typography.titleMedium, + ) + Spacer(Modifier.height(8.dp)) + + ResultCard( + state = state, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + ) + } + } +} + +@Composable +private fun ResultCard(state: DiscoveryUiState, modifier: Modifier = Modifier) { + OutlinedCard(modifier = modifier) { + val text = when (state) { + DiscoveryUiState.Idle -> stringResource(R.string.status_idle_discovery) + DiscoveryUiState.Loading -> stringResource(R.string.status_discovering) + is DiscoveryUiState.Success -> formatResult(state.result) + is DiscoveryUiState.Failure -> formatError(state.error) + } + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.Top, + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + ) + } + } +} + +private fun formatResult(result: DiscoveryResult): String = buildString { + appendLine("Discovery succeeded.") + appendLine() + appendLine("Grant types:") + appendList(result.types.map { it.name }) + appendLine() + appendLine("Embedded authorization supported: ${result.hasEmbeddedAuthorization}") + appendLine() + appendLine("Password realms:") + appendList(result.passwordRealms) + appendLine() + appendLine("Passkey connections:") + appendList(result.passkeyConnections) + appendLine() + appendLine("Social providers:") + appendList(result.socialProviders) + appendLine() + appendLine("Passwordless OTP:") + appendList(result.otpOptions.map { "${it.connection} (${it.type})" }) + appendLine() + appendLine("All options (raw):") + appendList(result.options.map { it.grantType.name }) +} + +private fun formatError(error: EmbeddedAuthException): String = buildString { + appendLine("Discovery failed.") + appendLine() + appendLine("code: ${error.code}") + appendLine("description: ${error.description}") + appendLine("HTTP status: ${error.statusCode}") + appendLine("network error: ${error.isNetworkError}") +} + +private fun StringBuilder.appendList(items: List) { + if (items.isEmpty()) { + appendLine(" • (none)") + } else { + items.forEach { appendLine(" • $it") } + } +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryUiState.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryUiState.kt new file mode 100644 index 00000000..e264a65e --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryUiState.kt @@ -0,0 +1,11 @@ +package com.auth0.sample.embedded + +import com.auth0.android.embedded.DiscoveryResult +import com.auth0.android.embedded.EmbeddedAuthException + +sealed interface DiscoveryUiState { + data object Idle : DiscoveryUiState + data object Loading : DiscoveryUiState + data class Success(val result: DiscoveryResult) : DiscoveryUiState + data class Failure(val error: EmbeddedAuthException) : DiscoveryUiState +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt new file mode 100644 index 00000000..8fbee6c1 --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt @@ -0,0 +1,43 @@ +package com.auth0.sample.embedded + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.auth0.android.Auth0 +import com.auth0.android.embedded.EmbeddedAuthClient +import com.auth0.android.embedded.EmbeddedAuthException +import com.auth0.android.request.DefaultClient +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +class EmbeddedViewModel(application: Application) : AndroidViewModel(application) { + + private val client: EmbeddedAuthClient by lazy { + EmbeddedAuthClient( + Auth0.getInstance( + application.getString(R.string.com_auth0_client_id), + application.getString(R.string.com_auth0_domain) + ).apply { + networkingClient = DefaultClient.Builder() + .enableLogging(true) + .build() + } + ) + } + + private val _uiState = MutableStateFlow(DiscoveryUiState.Idle) + val uiState: StateFlow = _uiState.asStateFlow() + + fun discover(connection: String? = null) { + _uiState.value = DiscoveryUiState.Loading + viewModelScope.launch { + _uiState.value = try { + DiscoveryUiState.Success(client.discover(connection).await()) + } catch (error: EmbeddedAuthException) { + DiscoveryUiState.Failure(error) + } + } + } +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt new file mode 100644 index 00000000..ccf2f508 --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt @@ -0,0 +1,17 @@ +package com.auth0.sample.embedded + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import com.auth0.sample.embedded.ui.theme.EmbeddedDiscoveryTheme + +public class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + EmbeddedDiscoveryTheme { + EmbeddedScreen() + } + } + } +} diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Color.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Color.kt new file mode 100644 index 00000000..b86c76bb --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Color.kt @@ -0,0 +1,7 @@ +package com.auth0.sample.embedded.ui.theme + +import androidx.compose.ui.graphics.Color + +// Plain black-and-white palette — no brand colors or shades. +val Black = Color(0xFF000000) +val White = Color(0xFFFFFFFF) diff --git a/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Theme.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Theme.kt new file mode 100644 index 00000000..0efb445b --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/ui/theme/Theme.kt @@ -0,0 +1,25 @@ +package com.auth0.sample.embedded.ui.theme + +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable + +// Simple black-on-white scheme; no dark variant, no accent shades. +private val BlackWhiteColors = lightColorScheme( + primary = Black, + onPrimary = White, + background = White, + onBackground = Black, + surface = White, + onSurface = Black, +) + +@Composable +public fun EmbeddedDiscoveryTheme( + content: @Composable () -> Unit, +) { + MaterialTheme( + colorScheme = BlackWhiteColors, + content = content, + ) +} diff --git a/sample-embedded/src/main/res/values/strings.xml b/sample-embedded/src/main/res/values/strings.xml new file mode 100644 index 00000000..20e4ac81 --- /dev/null +++ b/sample-embedded/src/main/res/values/strings.xml @@ -0,0 +1,15 @@ + + + Auth0 Embedded Discovery + + DOMAIN + CLIENT_ID + demo + + Embedded (/e/discovery) + Connection (optional) + Discover + Result + Enter a connection (optional) and tap Discover. + Discovering… + diff --git a/sample-embedded/src/main/res/values/themes.xml b/sample-embedded/src/main/res/values/themes.xml new file mode 100644 index 00000000..84c55edd --- /dev/null +++ b/sample-embedded/src/main/res/values/themes.xml @@ -0,0 +1,6 @@ + + + +