fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled

The recovery code was submitted to a freshly-initialised recovery
flow, but Kratos binds the emailed code to the original flow, so
verification could never succeed. The settings step then ran with no
privileged session, so the password change would be rejected too.

- forgotPassword remembers its recovery flow action; verifyResetCode
  submits the code back to that SAME flow.
- verifyResetCode parses Kratos continue_with for the privileged
  session token + the settings flow id; resetPassword submits the new
  password to that settings flow authenticated with X-Session-Token.
- KratosFlow / KratosContinueWith models extended (continue_with,
  ory_session_token).

Resolves the TODO(kratos) in AuthApi.resetPassword.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-05-18 21:55:49 -05:00
parent 05cc4311a7
commit 90a1d98322
2 changed files with 73 additions and 36 deletions
@@ -35,6 +35,12 @@ data class KratosFlow(
val ui: KratosUi, val ui: KratosUi,
/** Present on a verification/recovery flow that is already complete. */ /** Present on a verification/recovery flow that is already complete. */
val state: String? = null, val state: String? = null,
/**
* Post-submission instructions. On a completed recovery flow this carries
* the privileged session token (`set_ory_session_token`) and the settings
* flow to finish the password change in (`show_settings_ui`).
*/
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
) )
/** /**
@@ -150,13 +156,16 @@ data class KratosRegistrationSuccess(
) )
/** /**
* A `continue_with` item — e.g. Kratos asking the client to show a * A `continue_with` item. `action` is one of `show_verification_ui`,
* verification flow after registration. * `show_settings_ui`, `set_ory_session_token`, etc. — see Kratos docs.
* `flow` is present for the `show_*_ui` actions; `orySessionToken` is present
* for `set_ory_session_token` (the privileged session a recovery flow issues).
*/ */
@Serializable @Serializable
data class KratosContinueWith( data class KratosContinueWith(
val action: String? = null, val action: String? = null,
val flow: KratosContinueWithFlow? = null, val flow: KratosContinueWithFlow? = null,
@SerialName("ory_session_token") val orySessionToken: String? = null,
) )
@Serializable @Serializable
@@ -50,6 +50,15 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
encodeDefaults = true encodeDefaults = true
} }
/**
* Action URL of the recovery flow started by [forgotPassword]. Kratos
* binds the emailed recovery code to that specific flow, so [verifyResetCode]
* must submit the code back to the SAME flow — not a fresh one. Held in
* memory only: a process restart between the two steps simply means the
* user requests a new code (recovery flows are short-lived regardless).
*/
private var pendingRecoveryAction: String? = null
// ==================== Kratos flow plumbing ==================== // ==================== Kratos flow plumbing ====================
/** /**
@@ -363,6 +372,9 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
return when (result) { return when (result) {
is ApiResult.Success -> { is ApiResult.Success -> {
// Remember this flow's action — verifyResetCode submits the
// emailed code back to the SAME flow Kratos bound it to.
pendingRecoveryAction = flow.ui.action
val info = result.data?.ui?.messages?.firstOrNull()?.text val info = result.data?.ui?.messages?.firstOrNull()?.text
ApiResult.Success( ApiResult.Success(
ForgotPasswordResponse( ForgotPasswordResponse(
@@ -378,36 +390,50 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
/** /**
* Submit the recovery code the user received by email. * Submit the recovery code the user received by email.
* *
* On success Kratos returns a privileged session and a redirect to a * The code is submitted back to the SAME recovery flow [forgotPassword]
* settings flow. For the native client we surface the recovery flow id * started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
* back as the "reset token" so the subsequent [resetPassword] call can * flow. A valid code drives the flow to `passed_challenge`, and Kratos then
* target the same flow. * returns, via `continue_with`, the privileged session token plus the
* settings flow to finish the password change in. Both are packed into the
* opaque `resetToken` that [resetPassword] consumes.
*/ */
suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> { suspend fun verifyResetCode(request: VerifyResetCodeRequest): ApiResult<VerifyResetCodeResponse> {
// Re-init a recovery flow and submit email+code together. (Kratos keeps val action = pendingRecoveryAction
// the address bound to the flow; for the native client we start a ?: return ApiResult.Error("Your recovery session expired. Request a new code.")
// fresh flow and submit both fields in one POST.)
val flow = when (val f = initFlow("recovery")) {
is ApiResult.Success -> f.data
is ApiResult.Error -> return f
else -> return ApiResult.Error("Could not verify the code")
}
val body = json.encodeToString( val body = json.encodeToString(
KratosRecoveryBody.serializer(), KratosRecoveryBody.serializer(),
KratosRecoveryBody(email = request.email.trim(), code = request.code.trim()), KratosRecoveryBody(code = request.code.trim()),
) )
val result = submitFlow(flow.ui.action, body) { val result = submitFlow(action, body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull() runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
} }
return when (result) { return when (result) {
is ApiResult.Success -> ApiResult.Success( is ApiResult.Success -> {
// The recovery flow id doubles as the reset token: resetPassword val flow = result.data
// uses it to drive the settings flow that follows. val settingsFlowId = flow?.continueWith
?.firstOrNull { it.action == "show_settings_ui" }?.flow?.id
val sessionToken = flow?.continueWith
?.firstOrNull { it.action == "set_ory_session_token" }?.orySessionToken
if (settingsFlowId != null && sessionToken != null) {
pendingRecoveryAction = null
ApiResult.Success(
VerifyResetCodeResponse( VerifyResetCodeResponse(
message = "Code verified.", message = "Code verified.",
resetToken = flow.id, // Opaque to the UI: carries the settings flow id and
// the privileged session token resetPassword needs,
// packed as "<settingsFlowId>|<sessionToken>".
resetToken = "$settingsFlowId|$sessionToken",
), ),
) )
} else {
// No continue_with → the code was wrong or the flow expired;
// surface Kratos' own validation message when present.
val msg = flow?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: flow?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code")
}
}
is ApiResult.Error -> result is ApiResult.Error -> result
else -> ApiResult.Error("Invalid or expired code") else -> ApiResult.Error("Invalid or expired code")
} }
@@ -416,24 +442,26 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
/** /**
* Complete a password reset. * Complete a password reset.
* *
* After [verifyResetCode] Kratos hands the client a privileged session via * [verifyResetCode] packed the settings flow id and the privileged session
* a settings flow. This submits the new password to that settings flow. * token Kratos issued into [ResetPasswordRequest.resetToken] as
* * `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
* TODO(kratos): the native recovery → settings handoff returns the * that settings flow, authenticating with the privileged session token.
* settings flow URL in the recovery response's `continue_with`. Wiring the
* full two-step handoff requires the privileged `session_token` Kratos
* issues on code verification; until the backend confirms the exact shape,
* [resetPassword] starts a settings flow keyed by the reset token as a
* best-effort and surfaces any Kratos validation message verbatim.
*/ */
suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> { suspend fun resetPassword(request: ResetPasswordRequest): ApiResult<ResetPasswordResponse> {
val parts = request.resetToken.split("|", limit = 2)
if (parts.size != 2 || parts[0].isBlank() || parts[1].isBlank()) {
return ApiResult.Error("This password reset session has expired. Request a new code.")
}
val settingsFlowId = parts[0]
val sessionToken = parts[1]
return try { return try {
// Settings flow keyed by the recovery flow id handed back from
// verifyResetCode. Kratos binds the privileged session server-side.
val response = client.post("$kratosBaseUrl/self-service/settings") { val response = client.post("$kratosBaseUrl/self-service/settings") {
contentType(ContentType.Application.Json) contentType(ContentType.Application.Json)
accept(ContentType.Application.Json) accept(ContentType.Application.Json)
parameter("flow", request.resetToken) // The privileged session Kratos issued on code verification
// authorizes the settings flow that changes the password.
header("X-Session-Token", sessionToken)
parameter("flow", settingsFlowId)
setBody( setBody(
json.encodeToString( json.encodeToString(
KratosSettingsPasswordBody.serializer(), KratosSettingsPasswordBody.serializer(),