fix(auth): correct the Kratos recovery -> password-reset handoff
Android UI Tests / ui-tests (push) Has been cancelled
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:
@@ -35,6 +35,12 @@ data class KratosFlow(
|
||||
val ui: KratosUi,
|
||||
/** Present on a verification/recovery flow that is already complete. */
|
||||
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
|
||||
* verification flow after registration.
|
||||
* A `continue_with` item. `action` is one of `show_verification_ui`,
|
||||
* `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
|
||||
data class KratosContinueWith(
|
||||
val action: String? = null,
|
||||
val flow: KratosContinueWithFlow? = null,
|
||||
@SerialName("ory_session_token") val orySessionToken: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -50,6 +50,15 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -363,6 +372,9 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
return when (result) {
|
||||
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
|
||||
ApiResult.Success(
|
||||
ForgotPasswordResponse(
|
||||
@@ -378,36 +390,50 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
/**
|
||||
* Submit the recovery code the user received by email.
|
||||
*
|
||||
* On success Kratos returns a privileged session and a redirect to a
|
||||
* settings flow. For the native client we surface the recovery flow id
|
||||
* back as the "reset token" so the subsequent [resetPassword] call can
|
||||
* target the same flow.
|
||||
* The code is submitted back to the SAME recovery flow [forgotPassword]
|
||||
* started ([pendingRecoveryAction]) — Kratos binds the emailed code to that
|
||||
* flow. A valid code drives the flow to `passed_challenge`, and Kratos then
|
||||
* 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> {
|
||||
// Re-init a recovery flow and submit email+code together. (Kratos keeps
|
||||
// the address bound to the flow; for the native client we start a
|
||||
// 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 action = pendingRecoveryAction
|
||||
?: return ApiResult.Error("Your recovery session expired. Request a new code.")
|
||||
val body = json.encodeToString(
|
||||
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()
|
||||
}
|
||||
return when (result) {
|
||||
is ApiResult.Success -> ApiResult.Success(
|
||||
// The recovery flow id doubles as the reset token: resetPassword
|
||||
// uses it to drive the settings flow that follows.
|
||||
VerifyResetCodeResponse(
|
||||
message = "Code verified.",
|
||||
resetToken = flow.id,
|
||||
),
|
||||
)
|
||||
is ApiResult.Success -> {
|
||||
val flow = result.data
|
||||
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(
|
||||
message = "Code verified.",
|
||||
// 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
|
||||
else -> ApiResult.Error("Invalid or expired code")
|
||||
}
|
||||
@@ -416,24 +442,26 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
/**
|
||||
* Complete a password reset.
|
||||
*
|
||||
* After [verifyResetCode] Kratos hands the client a privileged session via
|
||||
* a settings flow. This submits the new password to that settings flow.
|
||||
*
|
||||
* TODO(kratos): the native recovery → settings handoff returns the
|
||||
* 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.
|
||||
* [verifyResetCode] packed the settings flow id and the privileged session
|
||||
* token Kratos issued into [ResetPasswordRequest.resetToken] as
|
||||
* `"<settingsFlowId>|<sessionToken>"`. This submits the new password to
|
||||
* that settings flow, authenticating with the privileged session token.
|
||||
*/
|
||||
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 {
|
||||
// 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") {
|
||||
contentType(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(
|
||||
json.encodeToString(
|
||||
KratosSettingsPasswordBody.serializer(),
|
||||
|
||||
Reference in New Issue
Block a user