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,
|
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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user