Skip to content

feat(embedded): add /e/authorize email OTP flow - #1075

Open
pmathew92 wants to merge 18 commits into
v5_developmentfrom
feat/SDK-11336
Open

pmathew92 wants to merge 18 commits into
v5_developmentfrom
feat/SDK-11336

Conversation

@pmathew92

@pmathew92 pmathew92 commented Sep 21, 2026 •

Copy link
Copy Markdown
Member

Summary

  • Adds EmbeddedAuthClient with the full multi-step embedded /e/authorize flow: authorize, identifyEmail, identifyPhone, challengeEmail, verifyOtp
  • Implements the continuation-as-error model — non-terminal steps complete through EmbeddedAuthException carrying a rotated auth_session and nextActions; verifyOtp is the terminal step that yields Credentials
  • Fixes scope omission: authorize() now accepts scope (default "openid profile email offline_access") and optional audience so the token exchange returns an id_token
  • Converts authorizeUrl to a lazy property to avoid recomputing the URL on every step

@coderabbitai

coderabbitai Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: b2040283-ffd0-4460-8a16-d14c91dc7e71

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@pmathew92
pmathew92 changed the base branch from v5_development to SDK-11058 September 21, 2026 10:31
@pmathew92
pmathew92 added this pull request to stack #1076 September 21, 2026 15:32
@pmathew92
pmathew92 marked this pull request as ready for review September 22, 2026 04:45
@pmathew92
pmathew92 requested a review from a team as a code owner September 22, 2026 04:45
@pmathew92 pmathew92 changed the title feat(embedded): add /e/authorize email OTP flow with sample app feat(embedded): add /e/authorize email OTP flow Sep 22, 2026
Base automatically changed from SDK-11058 to v5_development September 24, 2026 08:23
internal data class Alternative(
@SerializedName("grant_type")
val grantType: String?,
val grantType: String,

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.

Making grantType non-null looks risky. If the server sends an alternative without a grant_type, Gson will still shove null in here, and we end up building LoginOption.Unknown(rawGrantType = null) even though that field is non-null → NPE for consumers. We also lost the "just drop the entry" behavior (and its test). Can we keep String? and the null guard? Also this seems unrelated to the email-OTP feature.

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.

grant_type is a required field as per the spec. If it returns null value , thats a deviation from what the server is supposed to send

}
private const val DEFAULT_SCOPE = "openid profile email offline_access"

private val DEFAULT_CAPABILITIES: Set<EmbeddedCapability> =

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 capabilities include identify:phone, but identifyPhone is internal and there's no phone flow yet. So the server could hand back a phone step the app literally can't act on. Can we default to just the email-OTP actions for this milestone?

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.

Removed phone for now


import com.auth0.android.embedded.EmbeddedAuthClient

/** How a phone one-time code is delivered, chosen when calling [EmbeddedAuthClient.challengePhone]. */

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.

This is public API for Flow 2, which isn't shipped yet — and the KDoc links to challengePhone, which doesn't exist. Once it's public we can't change it without a breaking release. Suggest keeping the phone/MFA types internal until that flow lands.

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.

User can't use this till the next change lands. Keeping this class as it is for now shouldn'tbe a problem for the beta changes

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.

Made this internal for now

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

private var transactionState: EmbeddedAuthState? = 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.

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

override val capability: EmbeddedCapability = EmbeddedCapability.IDENTIFY_EMAIL
}

/** Continue by submitting a phone number. Act on it with [EmbeddedAuthClient.identifyPhone]. */

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.

public type's KDoc points at identifyPhone, which is internal. (Goes away if we internalize the phone stuff.)

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.

Removed the reference for now

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.

All the new tests use .execute() — the await() (coroutine) branches in StepRequest/AdvancingRequest aren't covered. runTest is already imported; could we add one continuation + one terminal await() case?

}
}
}
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

EmbeddedCapability.IDENTIFY_EMAIL -> NextAction.IdentifyEmail
EmbeddedCapability.IDENTIFY_PHONE -> NextAction.IdentifyPhone
EmbeddedCapability.CHALLENGE_EMAIL -> {
val index = (this[INDEX_KEY] as? Number)?.toInt() ?: return dropped(raw, INDEX_KEY)

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.

If a required field is missing we drop the whole action — but if it was the only one, nextActions comes back empty and the user is stuck. Maybe surface it as Unknown instead so the menu still reflects what the server sent?

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. Returning an Unknown in this scenario can cause confusion whne we have multiple item with the incorrect field.

.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

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

@utkrishtsahu

Copy link
Copy Markdown
Contributor

StepRequest.kt and AdvancingRequest.kt forward addParameter(name, String) to the inner request but don't override the addParameter(name, Any) overload, so it hits the interface's no-op default and silently drops the param. FailedRequest overrides it — worth making these consistent.

* to be usable.
*/
internal fun Alternative.toLoginOption(): LoginOption? {
val grantType = grantType ?: return 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.

in which case now login option can be null since we made grantType as non optional

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.

It can be null when other properties for a grant type return null when it is not expected to return null

.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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants