diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt index c12b180..da2daee 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/models/Kratos.kt @@ -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 = 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 diff --git a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt index d17b355..e50ce0e 100644 --- a/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt +++ b/composeApp/src/commonMain/kotlin/com/tt/honeyDue/network/AuthApi.kt @@ -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 { - // 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 "|". + 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 + * `"|"`. This submits the new password to + * that settings flow, authenticating with the privileged session token. */ suspend fun resetPassword(request: ResetPasswordRequest): ApiResult { + 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(),