Registration via API + client-owned email verification
Android UI Tests / ui-tests (push) Has been cancelled

register() now calls POST /auth/register (admin-create) then logs in for a
session, replacing Kratos self-service registration — which never returns the
verification flow id, so the emailed code could never be matched. The verify
screen now starts its own verification flow and sends the single code on
appear; verifyEmail submits the code to that exact stored flow.

- AuthApi: register -> our API + immediate login; startEmailVerification;
  verifyEmail targets DataManager.pendingVerificationFlowId (no codeless fallback)
- DataManager.pendingVerificationFlowId; KratosLoginSuccess.continue_with
- iOS verify screens (standalone + onboarding) send the code on appear + Resend

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-06-03 17:46:43 -05:00
parent 90a1d98322
commit 7c892d2bb6
7 changed files with 244 additions and 51 deletions
@@ -109,6 +109,24 @@ object DataManager : IDataManager {
private val _currentUser = MutableStateFlow<User?>(null) private val _currentUser = MutableStateFlow<User?>(null)
override val currentUser: StateFlow<User?> = _currentUser.asStateFlow() override val currentUser: StateFlow<User?> = _currentUser.asStateFlow()
/**
* Flow ID of a pending Kratos verification flow.
*
* Set in [AuthApi] right after a registration response carries a
* `continue_with` item of action `show_verification_ui`. Cleared on
* successful verification. While set, [AuthApi.verifyEmail] posts the
* 6-digit code to this exact flow rather than initialising a fresh one
* (the auto-emailed code is bound to a specific flow ID — posting it
* to a new flow always fails).
*
* In-memory only. Kratos's default verification flow lifespan is 1h,
* which is plenty for the user to receive the email and enter the
* code. If they kill the app between sign-up and entering the code
* they'll need a fresh code (handled by the resend path).
*/
private val _pendingVerificationFlowId = MutableStateFlow<String?>(null)
val pendingVerificationFlowId: StateFlow<String?> = _pendingVerificationFlowId.asStateFlow()
// ==================== APP PREFERENCES ==================== // ==================== APP PREFERENCES ====================
private val _themeId = MutableStateFlow("default") private val _themeId = MutableStateFlow("default")
@@ -302,6 +320,16 @@ object DataManager : IDataManager {
persistToDisk() persistToDisk()
} }
/**
* Set the in-flight Kratos verification flow ID. AuthApi calls this with
* the value pulled out of the registration response's `continue_with`
* `show_verification_ui` item. Pass `null` to clear (e.g. on successful
* verification or sign-out).
*/
fun setPendingVerificationFlowId(flowId: String?) {
_pendingVerificationFlowId.value = flowId
}
// ==================== THEME UPDATE METHODS ==================== // ==================== THEME UPDATE METHODS ====================
fun setThemeId(id: String) { fun setThemeId(id: String) {
@@ -135,11 +135,21 @@ data class KratosSession(
/** /**
* Success body of a native login flow submission * Success body of a native login flow submission
* (`POST .../self-service/login/api`). * (`POST .../self-service/login/api`).
*
* `continue_with` is normally empty on a login response. The one case it
* isn't: Kratos v26 transparently registers a previously unseen OIDC
* identity when an `oidc` method is submitted to the login flow and no
* matching identity exists. In that scenario Kratos runs the registration
* flow internally and returns the result through the **login** endpoint —
* with `continue_with` carrying the `show_verification_ui` item that
* normal registration would have surfaced. We capture it here so the
* email-verification screen can submit the code to the correct flow.
*/ */
@Serializable @Serializable
data class KratosLoginSuccess( data class KratosLoginSuccess(
val session: KratosSession, val session: KratosSession,
@SerialName("session_token") val sessionToken: String, @SerialName("session_token") val sessionToken: String,
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
) )
/** /**
@@ -1320,6 +1320,15 @@ object APILayer {
return result return result
} }
/**
* Start a client-owned email-verification flow and send the code.
* Call from the verification screen on appear and on "resend". See
* [AuthApi.startEmailVerification] for why the screen must own the flow.
*/
suspend fun startEmailVerification(email: String): ApiResult<Unit> {
return authApi.startEmailVerification(email)
}
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> { suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
return authApi.verifyEmail(token, request) return authApi.verifyEmail(token, request)
} }
@@ -1,5 +1,6 @@
package com.tt.honeyDue.network package com.tt.honeyDue.network
import com.tt.honeyDue.data.DataManager
import com.tt.honeyDue.models.* import com.tt.honeyDue.models.*
import io.ktor.client.* import io.ktor.client.*
import io.ktor.client.call.* import io.ktor.client.call.*
@@ -59,6 +60,24 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
*/ */
private var pendingRecoveryAction: String? = null private var pendingRecoveryAction: String? = null
/**
* Scan a registration response's `continue_with` for a
* `show_verification_ui` item and persist the embedded flow id in
* [DataManager.pendingVerificationFlowId]. Kratos auto-emails the
* verification code as soon as registration completes, and the code is
* bound to **that** flow — submitting it to a fresh `initFlow("verification")`
* is rejected. [verifyEmail] reads the persisted id and posts to it
* directly.
*/
private fun capturePendingVerificationFlow(continueWith: List<KratosContinueWith>) {
val flowId = continueWith
.firstOrNull { it.action == "show_verification_ui" }
?.flow?.id
if (!flowId.isNullOrBlank()) {
DataManager.setPendingVerificationFlowId(flowId)
}
}
// ==================== Kratos flow plumbing ==================== // ==================== Kratos flow plumbing ====================
/** /**
@@ -165,7 +184,13 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
json.decodeFromString(KratosLoginSuccess.serializer(), it) json.decodeFromString(KratosLoginSuccess.serializer(), it)
} }
return when (success) { return when (success) {
is ApiResult.Success -> resolveSession(success.data.sessionToken, success.data.session) is ApiResult.Success -> {
// Defensive: password login normally doesn't return a
// verification continue_with, but Kratos surfacing one here
// means the verification screen should target that flow.
capturePendingVerificationFlow(success.data.continueWith)
resolveSession(success.data.sessionToken, success.data.session)
}
is ApiResult.Error -> success is ApiResult.Error -> success
else -> ApiResult.Error("Login failed") else -> ApiResult.Error("Login failed")
} }
@@ -174,49 +199,55 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
// ==================== Registration ==================== // ==================== Registration ====================
/** /**
* Native password registration against Kratos. * Password registration.
* *
* Kratos identity traits are `{ email, name: { first, last } }`. The * Registration does NOT go through Kratos' self-service registration flow.
* legacy [RegisterRequest.username] is preserved for the UI but is not a * That flow auto-emails a verification code to a flow whose id Kratos never
* Kratos trait — the email is the identifier. * returns to the client, so the client could never submit the user's code
* to the right flow (verified 2026-06-03). Instead this calls the honeyDue
* API `POST /auth/register/`, which admin-creates the Kratos identity with
* an unverified email and sends NO email.
*
* The new identity is immediately usable, so we log in right away to obtain
* a session token — Kratos permits login for unverified identities; app
* access is gated on the `verified` flag, not on Kratos login. The user
* lands authenticated-but-unverified, the verification screen sends the one
* code (via [startEmailVerification]) and completes the email check.
*
* [RegisterRequest.username] is kept for the UI but the email is the Kratos
* identifier, so the follow-up login authenticates by email.
*/ */
suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> { suspend fun register(request: RegisterRequest): ApiResult<AuthResponse> {
val flow = when (val f = initFlow("registration")) { // 1. Create the identity via the honeyDue API (admin-create in Kratos).
is ApiResult.Success -> f.data when (val created = createAccount(request)) {
is ApiResult.Error -> return f is ApiResult.Success -> Unit
else -> return ApiResult.Error("Could not start registration") is ApiResult.Error -> return created
else -> return ApiResult.Error("Registration failed")
} }
val traits = KratosTraits( // 2. Log in immediately to get a session token. The identifier is the
email = request.email.trim(), // email (the Kratos credential identifier), not the display username.
name = KratosName( return login(LoginRequest(username = request.email.trim(), password = request.password))
first = request.firstName ?: "",
last = request.lastName ?: "",
),
)
val body = json.encodeToString(
KratosPasswordRegistrationBody.serializer(),
KratosPasswordRegistrationBody(traits = traits, password = request.password),
)
val success = submitFlow(flow.ui.action, body) {
json.decodeFromString(KratosRegistrationSuccess.serializer(), it)
} }
return when (success) {
is ApiResult.Success -> { /**
val token = success.data.sessionToken * Create a password account via `POST {api}/auth/register/`. The honeyDue
if (token.isNullOrBlank()) { * API admin-creates the Kratos identity (unverified, no email sent) and
// Kratos was configured without the `session` after-hook — * returns 201 with no token. Maps 409 → email taken, 400 → weak password.
// the identity exists but no session was issued. The caller */
// must complete a verification flow then log in. private suspend fun createAccount(request: RegisterRequest): ApiResult<Unit> {
ApiResult.Error( return try {
"Account created. Please verify your email, then sign in.", val response = client.post("$apiBaseUrl/auth/register/") {
200, contentType(ContentType.Application.Json)
) accept(ContentType.Application.Json)
setBody(request)
}
if (response.status.isSuccess()) {
ApiResult.Success(Unit)
} else { } else {
resolveSession(token, success.data.session) ApiResult.Error(ErrorParser.parseError(response), response.status.value)
} }
} } catch (e: Exception) {
is ApiResult.Error -> success ApiResult.Error(e.message ?: "Could not create account")
else -> ApiResult.Error("Registration failed")
} }
} }
@@ -253,6 +284,12 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
json.decodeFromString(KratosLoginSuccess.serializer(), it) json.decodeFromString(KratosLoginSuccess.serializer(), it)
} }
if (loginResult is ApiResult.Success) { if (loginResult is ApiResult.Success) {
// Kratos v26 transparently registers a previously-unseen OIDC
// identity through the login endpoint and returns the
// verification continue_with on the login response. Capture it
// here so verifyEmail() targets the correct (already-emailed)
// flow rather than spinning up a fresh, codeless one.
capturePendingVerificationFlow(loginResult.data.continueWith)
return resolveSession(loginResult.data.sessionToken, loginResult.data.session) return resolveSession(loginResult.data.sessionToken, loginResult.data.session)
} }
@@ -271,6 +308,10 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
return when (regResult) { return when (regResult) {
is ApiResult.Success -> { is ApiResult.Success -> {
// Same pattern as password registration: Kratos has already
// emailed the verification code bound to the flow it surfaces
// here. Stash it for verifyEmail.
capturePendingVerificationFlow(regResult.data.continueWith)
val token = regResult.data.sessionToken val token = regResult.data.sessionToken
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {
(loginResult as? ApiResult.Error) (loginResult as? ApiResult.Error)
@@ -483,13 +524,28 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
// ==================== Email verification ==================== // ==================== Email verification ====================
/** /**
* Submit an email-verification code to Kratos' verification flow. * Start (or restart) an email-verification flow that THIS client owns.
* *
* Note: the [token] parameter (a session token) is unused for the Kratos * Why this exists — the hard-won root cause (2026-06-03): Kratos's native
* verification flow — verification is anonymous and keyed by the code — * registration response does NOT return the auto-created verification
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change. * flow's id. With the `session` after-hook enabled, `continue_with` carries
* only `set_ory_session_token` — never `show_verification_ui`. Kratos still
* emails a code server-side, but the client has no way to learn which flow
* that code is bound to. Submitting it to any other flow is rejected. So
* the client can never reuse the registration-time flow.
*
* The fix: the verification SCREEN owns the flow. This call
* 1. inits a fresh verification flow,
* 2. submits the user's [email] to it, which makes Kratos send a code
* bound to THIS flow,
* 3. stores the flow id in [DataManager.pendingVerificationFlowId].
* [verifyEmail] then posts the code the user receives back to this exact
* flow. Robust across app restarts, re-sign-ins, and both password and
* OIDC sign-ups — the screen is the single source of truth for the flow.
*
* Call this when the verification screen appears and on every "resend".
*/ */
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> { suspend fun startEmailVerification(email: String): ApiResult<Unit> {
val flow = when (val f = initFlow("verification")) { val flow = when (val f = initFlow("verification")) {
is ApiResult.Success -> f.data is ApiResult.Success -> f.data
is ApiResult.Error -> return f is ApiResult.Error -> return f
@@ -497,20 +553,66 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
} }
val body = json.encodeToString( val body = json.encodeToString(
KratosVerificationBody.serializer(), KratosVerificationBody.serializer(),
KratosVerificationBody(code = request.code.trim()), KratosVerificationBody(email = email.trim()),
) )
val result = submitFlow(flow.ui.action, body) { val result = submitFlow(flow.ui.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 -> { is ApiResult.Success -> {
val verified = result.data?.state == "passed_challenge" // Persist the flow id BEFORE the user enters the code — this is
ApiResult.Success( // the flow Kratos just bound the emailed code to.
VerifyEmailResponse( DataManager.setPendingVerificationFlowId(flow.id)
message = if (verified) "Email verified." else "Verification submitted.", ApiResult.Success(Unit)
verified = verified, }
), is ApiResult.Error -> result
else -> ApiResult.Error("Could not send verification code")
}
}
/**
* Submit an email-verification code to the flow the client started via
* [startEmailVerification].
*
* The code is bound to a specific Kratos flow id (held in
* [DataManager.pendingVerificationFlowId]). We POST it to exactly that
* flow — never a fresh one, because a fresh flow has no code attached and
* the submission would always fail. If there is no pending flow id the
* screen hasn't sent a code yet; surface that so the UI can call
* [startEmailVerification] first.
*
* Note: the [token] parameter (a session token) is unused for the Kratos
* verification flow — verification is keyed by the code/flow — but the
* parameter is kept so [APILayer]/`AuthViewModel` need no change.
*/
suspend fun verifyEmail(token: String, request: VerifyEmailRequest): ApiResult<VerifyEmailResponse> {
val pendingFlowId = DataManager.pendingVerificationFlowId.value
if (pendingFlowId.isNullOrBlank()) {
return ApiResult.Error("Please request a verification code first.")
}
val body = json.encodeToString(
KratosVerificationBody.serializer(),
KratosVerificationBody(code = request.code.trim()),
) )
val result = submitFlow("$kratosBaseUrl/self-service/verification?flow=$pendingFlowId", body) {
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
}
return when (result) {
is ApiResult.Success -> {
val verified = result.data?.state == "passed_challenge"
if (verified) {
// One-shot — drop the cached flow so a future verification
// (different account, address change, etc.) starts clean.
DataManager.setPendingVerificationFlowId(null)
ApiResult.Success(VerifyEmailResponse(message = "Email verified.", verified = true))
} else {
// 200 with a re-rendered flow == wrong/expired code. Surface
// Kratos' own message so the user knows to retry/resend.
val msg = result.data?.ui?.messages?.firstOrNull { it.type == "error" }?.text
?: result.data?.ui?.nodes?.flatMap { it.messages }
?.firstOrNull { it.type == "error" }?.text
ApiResult.Error(msg ?: "Invalid or expired code. Tap resend for a new one.")
}
} }
is ApiResult.Error -> result is ApiResult.Error -> result
else -> ApiResult.Error("Verification failed") else -> ApiResult.Error("Verification failed")
@@ -243,6 +243,10 @@ struct OnboardingVerifyEmailContent: View {
.onAppear { .onAppear {
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared") print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
isAnimating = true isAnimating = true
// Establish the client-owned verification flow + send the code.
// Kratos binds the code to this flow; verifyEmail submits it back
// here. Without this the screen has no flow to verify against.
viewModel.sendCode(silent: true)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
isCodeFieldFocused = true isCodeFieldFocused = true
} }
@@ -165,6 +165,17 @@ struct VerifyEmailView: View {
.disabled(viewModel.code.count != 6 || viewModel.isLoading) .disabled(viewModel.code.count != 6 || viewModel.isLoading)
.accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verifyButton) .accessibilityIdentifier(AccessibilityIdentifiers.Authentication.verifyButton)
// Resend code
Button(action: {
viewModel.code = ""
viewModel.sendCode()
}) {
Text("Resend code")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(Color.appPrimary)
}
.disabled(viewModel.isLoading)
// Help Text // Help Text
Text(L10n.Auth.verifyHelpText) Text(L10n.Auth.verifyHelpText)
.font(.system(size: 12, weight: .medium)) .font(.system(size: 12, weight: .medium))
@@ -202,6 +213,11 @@ struct VerifyEmailView: View {
} }
.onAppear { .onAppear {
isFocused = true isFocused = true
// Establish the client-owned verification flow and send the
// code. Kratos binds the code to this flow; verifyEmail submits
// it back here. Without this the screen has no flow to verify
// against. Re-running on every appear also covers expired codes.
viewModel.sendCode(silent: true)
} }
.onChange(of: viewModel.isVerified) { _, isVerified in .onChange(of: viewModel.isVerified) { _, isVerified in
if isVerified { if isVerified {
@@ -26,6 +26,30 @@ class VerifyEmailViewModel: ObservableObject {
} }
// MARK: - Public Methods // MARK: - Public Methods
/// Start a client-owned verification flow and have Kratos send a code.
///
/// MUST run before the user can verify: Kratos binds each emailed code to a
/// specific flow id, and the registration response never exposes the
/// auto-created flow's id (see AuthApi.startEmailVerification). The screen
/// establishes its own flow here; verifyEmail() submits the code back to it.
/// Called on screen appear and on "Resend code".
func sendCode(silent: Bool = false) {
guard let email = dataManager.currentUser?.email, !email.isEmpty else {
errorMessage = "We couldn't determine your email. Please sign in again."
return
}
if !silent { isLoading = true }
errorMessage = nil
Task {
let result = try? await APILayer.shared.startEmailVerification(email: email)
if let result = result, let error = ApiResultBridge.error(from: result) {
self.errorMessage = ErrorMessageParser.parse(error.message)
}
self.isLoading = false
}
}
func verifyEmail() { func verifyEmail() {
// Validation using ValidationRules // Validation using ValidationRules
if let error = ValidationRules.validateCode(code, expectedLength: 6) { if let error = ValidationRules.validateCode(code, expectedLength: 6) {