Skip to content
Merged
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
1 change: 1 addition & 0 deletions EXAMPLES.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Each topic lives in its own file under [`examples/`](examples).
## Embedded Authentication (EA)

- [Discovery](examples/embedded-auth/discovery.md)
- [Authorize (email OTP flow)](examples/embedded-auth/authorize.md)

## Other APIs and features

Expand Down
275 changes: 200 additions & 75 deletions auth0/src/main/java/com/auth0/android/embedded/EmbeddedAuthClient.kt
Original file line number Diff line number Diff line change
@@ -1,42 +1,51 @@
package com.auth0.android.embedded

import com.auth0.android.Auth0
import com.auth0.android.Auth0Exception
import com.auth0.android.NetworkErrorException
import com.auth0.android.embedded.discovery.DiscoveryResponse
import com.auth0.android.embedded.authorize.AdvancingRequest
import com.auth0.android.embedded.authorize.AuthorizeCode
import com.auth0.android.embedded.authorize.EmbeddedAuthState
import com.auth0.android.embedded.authorize.EmbeddedCapability
import com.auth0.android.embedded.authorize.FailedRequest
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.embedded.discovery.toDiscoveryResult
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.auth0.android.result.Credentials
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)
* ```
*
* **Note**
* Embedded Authorization is currently in [Beta](https://auth0.com/docs/troubleshoot/product-lifecycle/product-release-stages#beta). Please reach out to Auth0 support to get it enabled for your tenant.
*/
public class EmbeddedAuthClient(private val auth0: Auth0) {

private val factory: RequestFactory<EmbeddedAuthException> =
RequestFactory(auth0.networkingClient, createErrorAdapter())
RequestFactory(auth0.networkingClient, embeddedAuthErrorAdapter())

private val gson: Gson = GsonProvider.gson

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

@Volatile
private var transactionState: EmbeddedAuthState? = null
Comment thread
NandanPrabhu marked this conversation as resolved.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

transactionState gets written on the network callback thread and read when the next step is built (possibly a different thread), with no @volatile or sync. That can leave us reading a stale/null session. At minimum @volatile; ideally a note that one client = one flow at a time.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Callbacks are usually returned on the main thread for us. But good to keep a safety check.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added @volatile annotation to make this visible across threads


/**
*
Expand Down Expand Up @@ -69,76 +78,192 @@ public class EmbeddedAuthClient(private val auth0: Auth0) {
return factory.get(url.toString(), discoveryAdapter(gson))
}

/**
* Begins an embedded authorization flow, abandoning any flow already in progress.
*
* This call never resolves successfully: the server always answers with a continuation, so the
* request completes through [EmbeddedAuthException]. Inspect
* [EmbeddedAuthException.isInsufficientAuthorization] and [EmbeddedAuthException.nextActions] to
* learn which step to call next. A terminal error is reported on the same failure channel.
*
* @param connection name of the connection to authenticate against.
* @param scope space-separated scopes to request. Must include `openid` for the terminal token
* exchange to return an ID token; defaults to `"openid profile email offline_access"`.
* @param audience optional API audience to request an access token for.
* @param capabilities the set of steps this client can handle in the flow.
*/
@JvmOverloads
public fun authorize(
connection: String,
scope: String = DEFAULT_SCOPE,
audience: String? = null,
capabilities: Set<EmbeddedCapability> = DEFAULT_CAPABILITIES
): Request<Void?, EmbeddedAuthException> {
transactionState = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Session gets read/cleared at construction, not execution. authorize() nulls the session the moment you build the request, and the step methods snapshot it at build time too. So client.authorize(...) kills an in-progress flow even if you never start it. Safer to do this when the request actually runs.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each authorize call is a start of a new login flow. So irrespective of it returns us a valid auth_session or not, we will clear the existing session to mark the termination of any ongoing flow

val request = factory.post(authorizeUrl)
.addParameters(buildMap {
put(CLIENT_ID_KEY, clientId)
put(CONNECTION_KEY, connection)
put(SCOPE_KEY, scope)
audience?.let { put(AUDIENCE_KEY, it) }
})
.addParameter(CAPABILITIES_KEY, capabilities.map { it.value })
return stepping(request)
}

/**
* Continues the flow by submitting an identifier of the given [type].
*
* This call never resolves successfully; it completes through [EmbeddedAuthException] whose
* [EmbeddedAuthException.nextActions] carry the next step to call.
*/
public fun identify(
identifier: String,
type: IdentifierType,
): Request<Void?, EmbeddedAuthException> = when (type) {
IdentifierType.EMAIL ->
continueStep(EmbeddedCapability.IDENTIFY_EMAIL) { addParameter(EMAIL_KEY, identifier) }
}

/**
* Continues the flow by requesting an email challenge for the authenticator at [index].
*
* This call never resolves successfully; it completes through [EmbeddedAuthException] whose
* [EmbeddedAuthException.nextActions] carry the next step to call.
*/
@JvmOverloads
public fun challengeEmail(index: Int = 0): Request<Void?, EmbeddedAuthException> =
continueStep(EmbeddedCapability.CHALLENGE_EMAIL) {
addParameter(INDEX_KEY, index)
}

/**
* Verifies a one-time [code] of the given [type]. This is the terminal step of the flow.
*
* On success, it yields the [Credentials]. If the server requires further steps the request
* completes through [EmbeddedAuthException] instead, with the next step on
* [EmbeddedAuthException.nextActions].
*/
@JvmOverloads
public fun verifyOtp(
code: String,
type: OtpType = OtpType.OOB
): Request<Credentials, EmbeddedAuthException> {
val session = transactionState?.authSession ?: return noActiveSession()
val request = factory.post(authorizeUrl, authorizeCodeAdapter(gson))
.addParameters(
mapOf(
AUTH_SESSION_KEY to session,
ACTION_KEY to EmbeddedCapability.VERIFY_OTP.value,
CLIENT_ID_KEY to clientId
)
)
.addParameter(OTP_KEY, code)
.addParameter(TYPE_KEY, type.value)
return advancing(request)
}

private fun continueStep(
action: EmbeddedCapability,
addPayload: Request<Void?, EmbeddedAuthException>.() -> Unit = {}
): Request<Void?, EmbeddedAuthException> {
val session = transactionState?.authSession ?: return noActiveSession()
val request = factory.post(authorizeUrl)
.addParameters(
mapOf(
AUTH_SESSION_KEY to session,
ACTION_KEY to action.value,
CLIENT_ID_KEY to clientId
)
)
request.addPayload()
return stepping(request)
}

private fun <T> noActiveSession(): Request<T, EmbeddedAuthException> = FailedRequest<T>(
EmbeddedAuthException(
NO_ACTIVE_SESSION_ERROR,
"No embedded authentication flow is in progress. Call authorize() first."
)
)

private fun stepping(
request: Request<Void?, EmbeddedAuthException>
): Request<Void?, EmbeddedAuthException> = StepRequest(request, ::updateSessionFromFailure)

private fun advancing(
request: Request<AuthorizeCode, EmbeddedAuthException>
): Request<Credentials, EmbeddedAuthException> = AdvancingRequest(
authorize = request,
exchange = ::exchange,
onStepFailure = ::updateSessionFromFailure,
onFlowComplete = { transactionState = null }
)

private fun exchange(authorizationCode: String): Request<Credentials, EmbeddedAuthException> {
val url = auth0.getDomainUrl().toHttpUrl().newBuilder()
.addPathSegment(OAUTH_PATH)
.addPathSegment(TOKEN_PATH)
.build()
return factory.post(url.toString(), GsonAdapter(Credentials::class.java, gson))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@pmathew92 in exchange dont we need to add id token verification?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. We can take it as a follow up separate PR and not club with this

.addParameters(
mapOf(
CLIENT_ID_KEY to clientId,
GRANT_TYPE_KEY to GRANT_TYPE_AUTHORIZATION_CODE,
CODE_KEY to authorizationCode
)
)
}

private val authorizeUrl: String by lazy {
auth0.getDomainUrl().toHttpUrl().newBuilder()
.addPathSegment(EMBEDDED_PATH)
.addPathSegment(AUTHORIZE_PATH)
.build()
.toString()
}

private fun updateSessionFromFailure(error: EmbeddedAuthException) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A network error wipes the session here. Network failures come through as unknown_error, not insufficient_authorization, so they hit the else and clear transactionState. But the spec (and our own authorize.md example) says network errors are safe to retry the same step — after this, the retry fails with no_active_session and forces a full restart. Can we only clear on terminal access_denied and keep the session on transient errors?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. We can have check for transient errors for the same to ensure user is able to retry

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the same

when {
error.isInsufficientAuthorization && error.authSession != null ->
transactionState = EmbeddedAuthState(error.authSession)

error.isAccessDenied || error.isTooManyAttempts || error.isTooManyLogins ->

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we should probably narrow it down to non retryable errors? @pmathew92

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this will be revised with the error class refactoring changes

transactionState = null
}
}

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"

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 const val SCOPE_KEY = "scope"
private const val AUDIENCE_KEY = "audience"
private const val CAPABILITIES_KEY = "capabilities"
private const val AUTH_SESSION_KEY = "auth_session"
private const val ACTION_KEY = "action"
private const val EMAIL_KEY = "email"
private const val PHONE_KEY = "phone"
private const val OTP_KEY = "otp"
private const val INDEX_KEY = "index"
private const val TYPE_KEY = "type"
private const val GRANT_TYPE_KEY = "grant_type"
private const val CODE_KEY = "code"
private const val GRANT_TYPE_AUTHORIZATION_CODE = "authorization_code"
private const val NO_ACTIVE_SESSION_ERROR = "no_active_session"

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)
)
}
}
}
private const val DEFAULT_SCOPE = "openid profile email offline_access"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default scope requests offline_access (i.e. a refresh token by default), but the spec has scope as optional. Just confirming that's intended.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intended


private val DEFAULT_CAPABILITIES: Set<EmbeddedCapability> = setOf(
EmbeddedCapability.IDENTIFY_EMAIL,
EmbeddedCapability.CHALLENGE_EMAIL,
EmbeddedCapability.VERIFY_OTP
)
}

init {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.auth0.android.embedded

import com.auth0.android.Auth0Exception
import com.auth0.android.NetworkErrorException
import com.auth0.android.embedded.authorize.NextAction

/**
* Represents an error raised by Auth0's embedded authentication API.
Expand All @@ -15,9 +16,57 @@ public class EmbeddedAuthException internal constructor(
/** HTTP status code of the response, or `0` when no response was received. */
public val statusCode: Int = 0,

/** When the attempt is not finished, the menu of actions the server will accept next. */
public val nextActions: List<NextAction> = emptyList(),

internal val authSession: String? = null,

cause: Throwable? = null
) : Auth0Exception(description, cause) {

public val isNetworkError: Boolean
get() = cause is NetworkErrorException

/** The attempt isn't done: the server returned a continuation. Read [nextActions] for what to call next. */
public val isInsufficientAuthorization: Boolean
get() = code == INSUFFICIENT_AUTHORIZATION

/** Recoverable: the submitted code or identifier was wrong — let the user retry with [nextActions]. */
public val isInvalidCode: Boolean
get() = code == INSUFFICIENT_AUTHORIZATION &&
description in INVALID_CODE_DESCRIPTIONS

/** Terminal: the attempt was denied and must not be retried. */
public val isAccessDenied: Boolean
get() = code == ACCESS_DENIED

/** Terminal: the challenge was denied after too many wrong one-time-code attempts. */
public val isTooManyWrongOtpAttempts: Boolean
get() = code == ACCESS_DENIED && description == TOO_MANY_WRONG_OTP_ATTEMPTS

/** Terminal: the challenge expired before it was verified. */
public val isChallengeExpired: Boolean
get() = code == ACCESS_DENIED && description == CHALLENGE_EXPIRED

/** Terminal: too many failed verification attempts. */
public val isTooManyAttempts: Boolean
get() = statusCode == TOO_MANY_REQUESTS_STATUS &&
code == TOO_MANY_REQUESTS && description == TOO_MANY_ATTEMPTS

/** Terminal: too many login attempts. */
public val isTooManyLogins: Boolean
get() = statusCode == TOO_MANY_REQUESTS_STATUS &&
code == TOO_MANY_REQUESTS && description == TOO_MANY_LOGINS

private companion object {
private const val INSUFFICIENT_AUTHORIZATION = "insufficient_authorization"
private const val ACCESS_DENIED = "access_denied"
private const val TOO_MANY_WRONG_OTP_ATTEMPTS = "too_many_wrong_otp_attempts"
private const val CHALLENGE_EXPIRED = "challenge_expired"
private val INVALID_CODE_DESCRIPTIONS = setOf("invalid_code", "invalid_identifier_or_code")
private const val TOO_MANY_REQUESTS = "too_many_requests"
private const val TOO_MANY_ATTEMPTS = "too_many_attempts"
private const val TOO_MANY_LOGINS = "too_many_logins"
private const val TOO_MANY_REQUESTS_STATUS = 429
}
}
Loading
Loading