From 2e8e938d81b90c06cdb2151647556d931e7fef79 Mon Sep 17 00:00:00 2001 From: Prince Mathew Date: Wed, 30 Sep 2026 10:17:57 +0530 Subject: [PATCH] refactor : Remove the discovery API from EmbeddedAuthClient --- EXAMPLES.md | 3 +- .../android/embedded/EmbeddedAuthClient.kt | 34 --- .../authorize/EmbeddedAuthAdapters.kt | 15 - .../embedded/discovery/DiscoveryMapper.kt | 62 ---- .../embedded/discovery/DiscoveryResponse.kt | 45 --- .../embedded/discovery/DiscoveryResult.kt | 50 ---- .../android/embedded/discovery/LoginOption.kt | 149 ---------- .../embedded/EmbeddedAuthClientTest.kt | 142 --------- .../embedded/discovery/DiscoveryMapperTest.kt | 280 ------------------ .../android/util/EmbeddedAuthMockServer.kt | 62 ---- examples/embedded-auth/discovery.md | 90 ------ .../auth0/sample/embedded/DiscoveryScreen.kt | 225 -------------- .../auth0/sample/embedded/DiscoveryUiState.kt | 11 - .../auth0/sample/embedded/EmbeddedScreen.kt | 81 +++++ .../sample/embedded/EmbeddedViewModel.kt | 18 -- .../com/auth0/sample/embedded/MainActivity.kt | 1 - 16 files changed, 82 insertions(+), 1186 deletions(-) delete mode 100644 auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt delete mode 100644 auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt delete mode 100644 auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResult.kt delete mode 100644 auth0/src/main/java/com/auth0/android/embedded/discovery/LoginOption.kt delete mode 100644 auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt delete mode 100644 examples/embedded-auth/discovery.md delete mode 100644 sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt delete 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/EmbeddedScreen.kt diff --git a/EXAMPLES.md b/EXAMPLES.md index bc3b961ce..de8539d1a 100644 --- a/EXAMPLES.md +++ b/EXAMPLES.md @@ -28,9 +28,8 @@ Each topic lives in its own file under [`examples/`](examples). - [Pushed Authorization Requests (PAR)](examples/authentication-api/pushed-authorization-requests.md) - [DPoP](examples/authentication-api/dpop.md) -## Embedded Authentication (EA) +## Embedded Authentication (Beta) -- [Discovery](examples/embedded-auth/discovery.md) - [Authorize (email OTP flow)](examples/embedded-auth/authorize.md) ## Other APIs and features diff --git a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt index c56c04348..56fc8adb7 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt @@ -10,9 +10,7 @@ import com.auth0.android.embedded.authorize.IdentifierType import com.auth0.android.embedded.authorize.OtpType import com.auth0.android.embedded.authorize.StepRequest import com.auth0.android.embedded.authorize.authorizeCodeAdapter -import com.auth0.android.embedded.authorize.discoveryAdapter import com.auth0.android.embedded.authorize.embeddedAuthErrorAdapter -import com.auth0.android.embedded.discovery.DiscoveryResult import com.auth0.android.request.Request import com.auth0.android.request.internal.GsonAdapter import com.auth0.android.request.internal.GsonProvider @@ -47,37 +45,6 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { @Volatile private var transactionState: EmbeddedAuthState? = null - /** - * - * Returns the grant types a client can use, derived from the client's enabled grants, - * its enabled connections, and each connection's configured authentication methods. - * - * 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 [com.auth0.android.embedded.discovery.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)) - } - /** * Begins an embedded authorization flow, abandoning any flow already in progress. * @@ -235,7 +202,6 @@ public class EmbeddedAuthClient(private val auth0: Auth0) { private companion object { private const val EMBEDDED_PATH = "e" - private const val DISCOVERY_PATH = "discovery" private const val AUTHORIZE_PATH = "authorize" private const val OAUTH_PATH = "oauth" private const val TOKEN_PATH = "token" diff --git a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt index 7f3e3db87..5d7fb8ca1 100644 --- a/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt +++ b/auth0/src/main/java/com/auth0/android/embedded/authorize/EmbeddedAuthAdapters.kt @@ -3,9 +3,6 @@ package com.auth0.android.embedded.authorize import com.auth0.android.Auth0Exception import com.auth0.android.NetworkErrorException import com.auth0.android.embedded.EmbeddedAuthException -import com.auth0.android.embedded.discovery.DiscoveryResponse -import com.auth0.android.embedded.discovery.DiscoveryResult -import com.auth0.android.embedded.discovery.toDiscoveryResult import com.auth0.android.request.ErrorAdapter import com.auth0.android.request.JsonAdapter import com.auth0.android.request.internal.GsonAdapter @@ -23,18 +20,6 @@ private const val AUTH_SESSION_KEY = "auth_session" private const val DEFAULT_DESCRIPTION = "An error occurred when trying to authenticate with the server." -/** Parses the discovery payload and translates it into the public [DiscoveryResult]. */ -internal 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() - } -} - /** Parses the `200` body of `/e/authorize` into the code to exchange for tokens. */ internal fun authorizeCodeAdapter(gson: Gson): JsonAdapter = GsonAdapter(AuthorizeCode::class.java, gson) diff --git a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt deleted file mode 100644 index b695cb2e3..000000000 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryMapper.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.auth0.android.embedded.discovery - -/** - * Translates the `GET /e/discovery` wire payload into the public [DiscoveryResult]. - * - * An entry the SDK does not recognize becomes [LoginOption.Unknown] rather than being dropped, and - * no single entry can fail the whole response. - */ -internal fun DiscoveryResponse.toDiscoveryResult(): DiscoveryResult = - DiscoveryResult(alternatives.mapNotNull { it.toLoginOption() }) - -/** - * Maps one wire entry to its [LoginOption], or `null` if it omitted a property its variant needs - * to be usable. - */ -internal fun Alternative.toLoginOption(): LoginOption? { - 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 -> - LoginOption.AuthorizationCode(connection = connection, type = type.orEmpty()) - - 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/discovery/DiscoveryResponse.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt deleted file mode 100644 index c522e8054..000000000 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResponse.kt +++ /dev/null @@ -1,45 +0,0 @@ -package com.auth0.android.embedded.discovery - -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/discovery/DiscoveryResult.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResult.kt deleted file mode 100644 index 7dd015f2f..000000000 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/DiscoveryResult.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.auth0.android.embedded.discovery - -public class DiscoveryResult internal constructor( - public val options: List -) { - - /** Lists the unique grant-types available for a client */ - public val types: Set = options.mapTo(LinkedHashSet()) { it.grantType } - - - /** - * Lists the connection names supporting password-realm - */ - public val passwordRealms: List = - options.filterIsInstance().map { it.realm } - - /** - * Lists the connection names supporting webauthn - */ - 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 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 grant-type is supported or not. */ - public fun supports(grantType: GrantType): Boolean = grantType in types -} diff --git a/auth0/src/main/java/com/auth0/android/embedded/discovery/LoginOption.kt b/auth0/src/main/java/com/auth0/android/embedded/discovery/LoginOption.kt deleted file mode 100644 index 8433b2031..000000000 --- a/auth0/src/main/java/com/auth0/android/embedded/discovery/LoginOption.kt +++ /dev/null @@ -1,149 +0,0 @@ -package com.auth0.android.embedded.discovery - -/** - * 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. - */ - public object Password : LoginOption { - override val connection: String? = null - override val grantType: GrantType = GrantType.PASSWORD - } - - /** - * Username and password login against a named 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. - * @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 - } - - /** - * 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 AuthorizationCode internal constructor( - override val connection: String? = null, - public val type: 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 -} diff --git a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt index 5070770fb..e94587ebb 100644 --- a/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt +++ b/auth0/src/test/java/com/auth0/android/embedded/EmbeddedAuthClientTest.kt @@ -6,13 +6,11 @@ import com.auth0.android.embedded.authorize.IdentifierType import com.auth0.android.embedded.authorize.NextAction import com.auth0.android.embedded.authorize.OtpChannel import com.auth0.android.embedded.authorize.OtpType -import com.auth0.android.embedded.discovery.GrantType import com.auth0.android.util.EmbeddedAuthMockServer import com.auth0.android.util.SSLTestUtils.testClient import kotlinx.coroutines.test.runTest import okhttp3.mockwebserver.RecordedRequest import org.hamcrest.MatcherAssert.assertThat -import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.not import org.hamcrest.Matchers.hasSize @@ -53,146 +51,6 @@ public class EmbeddedAuthClientTest { 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)) - } - - @Test - public fun `discover await should parse a full response into a DiscoveryResult`(): Unit = - runTest { - mockAPI.willReturnFullDiscovery() - - val result = client.discover().await() - - 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 `authorize should POST the authorize endpoint with the default scope and no audience`() { mockAPI.willReturnJsonError() diff --git a/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt b/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt deleted file mode 100644 index 297e0761e..000000000 --- a/auth0/src/test/java/com/auth0/android/embedded/discovery/DiscoveryMapperTest.kt +++ /dev/null @@ -1,280 +0,0 @@ -package com.auth0.android.embedded.discovery - -import com.auth0.android.embedded.discovery.Alternative -import com.auth0.android.embedded.discovery.DiscoveryResponse -import com.auth0.android.embedded.discovery.GrantType -import com.auth0.android.embedded.discovery.LoginOption -import com.auth0.android.embedded.discovery.PasswordlessIdentifier -import com.auth0.android.embedded.discovery.PasswordlessType -import com.auth0.android.embedded.discovery.toDiscoveryResult -import com.auth0.android.embedded.discovery.toLoginOption -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 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)) - } - - @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`() { - 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 `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_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 `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 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( - 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 = - 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/util/EmbeddedAuthMockServer.kt b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt index 384cb904b..249e2d201 100644 --- a/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt +++ b/auth0/src/test/java/com/auth0/android/util/EmbeddedAuthMockServer.kt @@ -4,61 +4,6 @@ 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", - "type": "embedded_authorize", - "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() @@ -185,14 +130,7 @@ $actionsJson } 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." diff --git a/examples/embedded-auth/discovery.md b/examples/embedded-auth/discovery.md deleted file mode 100644 index 09fe8c5a0..000000000 --- a/examples/embedded-auth/discovery.md +++ /dev/null @@ -1,90 +0,0 @@ -### Discovery (EA) - -> [!IMPORTANT] -> Discovery API is currently in [Early Access](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#early-access). Please reach out to Auth0 support to get it enabled for your tenant. - -The discovery endpoint (`/e/discovery`) reports which grant types a client can use, derived from the client's enabled grants, its enabled connections, and each connection's configured authentication methods. - -Discovery is served by the `EmbeddedAuthClient`, which you create from an `Auth0` instance: - -```kotlin -val account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN") -val client = EmbeddedAuthClient(account) -``` - -#### Discover the supported grant types - -Call `discover()` to query the tenant. The `connection` argument is optional — omit it to discover everything the application supports, or pass a connection name to scope the result to that connection. - -```kotlin -val result = client.discover().await() - -if (result.hasEmbeddedAuthorization) { - // Use embedded authorization flow to login -} else { - // Use the existing AuthenticationAPI client for login -} -``` - -To scope discovery to a single connection, pass its name: - -```kotlin -val result = client.discover("Username-Password-Authentication").await() -``` - -#### Inspect the result - -`DiscoveryResult` exposes the raw list of `options` alongside convenience accessors that group them by kind, so you can build your UI without inspecting each `LoginOption` yourself: - -```kotlin -result.hasEmbeddedAuthorization // true if embedded authorization support is available -result.types // Set of every supported grant -result.passwordRealms // List of connections that accept password login -result.passkeyConnections // List of connections that support passkeys -result.otpOptions // List -result.socialProviders // List of native social subject-token types -result.options // the full List - -// Check for a specific grant type -if (result.supports(GrantType.PASSKEY)) { - // show the passkey button -} -``` - -
- Using callbacks - -```kotlin -client - .discover() - .start(object : Callback { - override fun onFailure(exception: EmbeddedAuthException) { } - - override fun onSuccess(result: DiscoveryResult) { - } - }) -``` -
- -
- Using Java - -```java -Auth0 account = Auth0.getInstance("YOUR_CLIENT_ID", "YOUR_DOMAIN"); -EmbeddedAuthClient client = new EmbeddedAuthClient(account); - -client - .discover() - .start(new Callback() { - @Override - public void onSuccess(DiscoveryResult result) { - //Discovered! - } - - @Override - public void onFailure(@NonNull EmbeddedAuthException error) { - //Error! - } - }); -``` -
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 deleted file mode 100644 index 9f8ecb40c..000000000 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryScreen.kt +++ /dev/null @@ -1,225 +0,0 @@ -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.discovery.DiscoveryResult -import com.auth0.android.embedded.EmbeddedAuthException - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -public fun EmbeddedScreen( - viewModel: EmbeddedViewModel = viewModel(), - onAuthorize: (connection: String) -> Unit = {}, -) { - val state by viewModel.uiState.collectAsStateWithLifecycle() - var connection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } - val isLoading = state is DiscoveryUiState.Loading - - val runDiscovery = { viewModel.discover(connection.trim().ifBlank { null }) } - - Scaffold( - topBar = { - TopAppBar( - title = { Text(stringResource(R.string.title_screen)) }, - 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, - connection = connection.trim(), - onAuthorize = onAuthorize, - modifier = Modifier - .fillMaxWidth() - .weight(1f), - ) - } - } -} - -@Composable -private fun ResultCard( - state: DiscoveryUiState, - modifier: Modifier = Modifier, - connection: String = "", - onAuthorize: (String) -> Unit = {}, -) { - // When discovery confirms embedded authorization is available, offer the authorize flow. - if (state is DiscoveryUiState.Success -// && state.result.hasEmbeddedAuthorization - ) { - OutlinedCard(modifier = modifier) { - Column( - modifier = Modifier - .fillMaxSize() - .padding(20.dp), - verticalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(R.string.status_authorize_available), - style = MaterialTheme.typography.bodyMedium, - ) - Spacer(Modifier.height(20.dp)) - Button( - onClick = { onAuthorize(connection) }, - shape = RoundedCornerShape(12.dp), - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - ) { - Text(stringResource(R.string.action_start_authorize)) - } - } - } - return - } - - 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 deleted file mode 100644 index 81bfa2106..000000000 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/DiscoveryUiState.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.auth0.sample.embedded - -import com.auth0.android.embedded.discovery.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/EmbeddedScreen.kt b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedScreen.kt new file mode 100644 index 000000000..ac0d671ab --- /dev/null +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedScreen.kt @@ -0,0 +1,81 @@ +package com.auth0.sample.embedded + +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.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +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.input.ImeAction +import androidx.compose.ui.unit.dp + +/** Landing screen: collects a connection and starts the embedded authorization flow. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +public fun EmbeddedScreen( + onAuthorize: (connection: String) -> Unit = {}, +) { + var connection by rememberSaveable { mutableStateOf("Username-Password-Authentication") } + val startAuthorize = { onAuthorize(connection.trim()) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.title_screen)) }, + 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_required)) }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { startAuthorize() }), + ) + + // Give the button some breathing room below the input. + Spacer(Modifier.height(24.dp)) + + Button( + onClick = startAuthorize, + enabled = connection.isNotBlank(), + shape = RoundedCornerShape(12.dp), + modifier = Modifier + .fillMaxWidth() + .height(52.dp), + ) { + Text(stringResource(R.string.action_start_authorize)) + } + } + } +} 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 index 62bcddbc3..dd8368172 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/EmbeddedViewModel.kt @@ -29,24 +29,6 @@ class EmbeddedViewModel(application: Application) : AndroidViewModel(application ) } - // region Discovery (/e/discovery) — retained for reference; not shown in the UI. - - 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) - } - } - } - - // endregion - // region Authorize (/e/authorize) — the interactive multi-step flow. private val _authorizeState = MutableStateFlow(AuthorizeUiState.Idle) 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 index ba6434557..0eff1ea21 100644 --- a/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt +++ b/sample-embedded/src/main/java/com/auth0/sample/embedded/MainActivity.kt @@ -38,7 +38,6 @@ private fun EmbeddedApp(viewModel: EmbeddedViewModel = viewModel()) { ) } else { EmbeddedScreen( - viewModel = viewModel, onAuthorize = { connection -> authorizeConnection = connection showAuthorize = true