Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ 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)

- [Discovery](examples/embedded-auth/discovery.md)

## Other APIs and features

- [My Account API](examples/my-account-api.md)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
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 ->
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<String>?.toOtpIdentifiers(): Set<PasswordlessIdentifier> =
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"
Original file line number Diff line number Diff line change
@@ -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<Alternative>
)

/**
* 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<String>? = null,

@SerializedName("subject_token_type")
val subjectTokenType: String? = null
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.auth0.android.embedded

public class DiscoveryResult internal constructor(
public val options: List<LoginOption>
) {

/** Lists the unique grant-types available for a client */
public val types: Set<GrantType> = options.mapTo(LinkedHashSet()) { it.grantType }


/**
* Lists the connection names supporting password-realm
*/
public val passwordRealms: List<String> =
options.filterIsInstance<LoginOption.PasswordRealm>().map { it.realm }

/**
* Lists the connection names supporting webauthn
*/
public val passkeyConnections: List<String> =
options.filterIsInstance<LoginOption.Passkey>().map { it.connection }

/** One-time-code logins on offer, in the order the server returned them. */
public val otpOptions: List<LoginOption.PasswordlessOtp> =
options.filterIsInstance<LoginOption.PasswordlessOtp>()

/**
* `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<String> =
options.filterIsInstance<LoginOption.NativeSocial>().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<LoginOption.AuthorizationCode>()
.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
}
144 changes: 144 additions & 0 deletions auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
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<EmbeddedAuthException> =
RequestFactory(auth0.networkingClient, createErrorAdapter())

private val gson: Gson = GsonProvider.gson

private val clientId: String
get() = auth0.clientId

/**
*
* 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<DiscoveryResult, EmbeddedAuthException> {
* 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<DiscoveryResult, EmbeddedAuthException> {
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<DiscoveryResult> {
val adapter = GsonAdapter(DiscoveryResponse::class.java, gson)
return object : JsonAdapter<DiscoveryResult> {
@Throws(IOException::class)
override fun fromJson(
reader: Reader,
metadata: Map<String, Any>
): DiscoveryResult = adapter.fromJson(reader, metadata).toDiscoveryResult()
}
}

private fun createErrorAdapter(): ErrorAdapter<EmbeddedAuthException> {
val mapAdapter = forMap(GsonProvider.gson)
return object : ErrorAdapter<EmbeddedAuthException> {

override fun fromRawResponse(
statusCode: Int,
bodyText: String,
headers: Map<String, List<String>>
): 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)
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading