Registration via API + client-owned email verification
Android UI Tests / ui-tests (push) Has been cancelled
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:
@@ -109,6 +109,24 @@ object DataManager : IDataManager {
|
||||
private val _currentUser = MutableStateFlow<User?>(null)
|
||||
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 ====================
|
||||
|
||||
private val _themeId = MutableStateFlow("default")
|
||||
@@ -302,6 +320,16 @@ object DataManager : IDataManager {
|
||||
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 ====================
|
||||
|
||||
fun setThemeId(id: String) {
|
||||
|
||||
@@ -135,11 +135,21 @@ data class KratosSession(
|
||||
/**
|
||||
* Success body of a native login flow submission
|
||||
* (`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
|
||||
data class KratosLoginSuccess(
|
||||
val session: KratosSession,
|
||||
@SerialName("session_token") val sessionToken: String,
|
||||
@SerialName("continue_with") val continueWith: List<KratosContinueWith> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
|
||||
@@ -1320,6 +1320,15 @@ object APILayer {
|
||||
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> {
|
||||
return authApi.verifyEmail(token, request)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.tt.honeyDue.network
|
||||
|
||||
import com.tt.honeyDue.data.DataManager
|
||||
import com.tt.honeyDue.models.*
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.*
|
||||
@@ -59,6 +60,24 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
*/
|
||||
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 ====================
|
||||
|
||||
/**
|
||||
@@ -165,7 +184,13 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
json.decodeFromString(KratosLoginSuccess.serializer(), it)
|
||||
}
|
||||
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
|
||||
else -> ApiResult.Error("Login failed")
|
||||
}
|
||||
@@ -174,49 +199,55 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
// ==================== Registration ====================
|
||||
|
||||
/**
|
||||
* Native password registration against Kratos.
|
||||
* Password registration.
|
||||
*
|
||||
* Kratos identity traits are `{ email, name: { first, last } }`. The
|
||||
* legacy [RegisterRequest.username] is preserved for the UI but is not a
|
||||
* Kratos trait — the email is the identifier.
|
||||
* Registration does NOT go through Kratos' self-service registration flow.
|
||||
* That flow auto-emails a verification code to a flow whose id Kratos never
|
||||
* 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> {
|
||||
val flow = when (val f = initFlow("registration")) {
|
||||
is ApiResult.Success -> f.data
|
||||
is ApiResult.Error -> return f
|
||||
else -> return ApiResult.Error("Could not start registration")
|
||||
// 1. Create the identity via the honeyDue API (admin-create in Kratos).
|
||||
when (val created = createAccount(request)) {
|
||||
is ApiResult.Success -> Unit
|
||||
is ApiResult.Error -> return created
|
||||
else -> return ApiResult.Error("Registration failed")
|
||||
}
|
||||
val traits = KratosTraits(
|
||||
email = request.email.trim(),
|
||||
name = KratosName(
|
||||
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)
|
||||
// 2. Log in immediately to get a session token. The identifier is the
|
||||
// email (the Kratos credential identifier), not the display username.
|
||||
return login(LoginRequest(username = request.email.trim(), password = request.password))
|
||||
}
|
||||
return when (success) {
|
||||
is ApiResult.Success -> {
|
||||
val token = success.data.sessionToken
|
||||
if (token.isNullOrBlank()) {
|
||||
// Kratos was configured without the `session` after-hook —
|
||||
// the identity exists but no session was issued. The caller
|
||||
// must complete a verification flow then log in.
|
||||
ApiResult.Error(
|
||||
"Account created. Please verify your email, then sign in.",
|
||||
200,
|
||||
)
|
||||
|
||||
/**
|
||||
* Create a password account via `POST {api}/auth/register/`. The honeyDue
|
||||
* API admin-creates the Kratos identity (unverified, no email sent) and
|
||||
* returns 201 with no token. Maps 409 → email taken, 400 → weak password.
|
||||
*/
|
||||
private suspend fun createAccount(request: RegisterRequest): ApiResult<Unit> {
|
||||
return try {
|
||||
val response = client.post("$apiBaseUrl/auth/register/") {
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}
|
||||
if (response.status.isSuccess()) {
|
||||
ApiResult.Success(Unit)
|
||||
} else {
|
||||
resolveSession(token, success.data.session)
|
||||
ApiResult.Error(ErrorParser.parseError(response), response.status.value)
|
||||
}
|
||||
}
|
||||
is ApiResult.Error -> success
|
||||
else -> ApiResult.Error("Registration failed")
|
||||
} catch (e: Exception) {
|
||||
ApiResult.Error(e.message ?: "Could not create account")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +284,12 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
json.decodeFromString(KratosLoginSuccess.serializer(), it)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -271,6 +308,10 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
return when (regResult) {
|
||||
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
|
||||
if (token.isNullOrBlank()) {
|
||||
(loginResult as? ApiResult.Error)
|
||||
@@ -483,13 +524,28 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
// ==================== 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
|
||||
* verification flow — verification is anonymous and keyed by the code —
|
||||
* but the parameter is kept so [APILayer]/`AuthViewModel` need no change.
|
||||
* Why this exists — the hard-won root cause (2026-06-03): Kratos's native
|
||||
* registration response does NOT return the auto-created verification
|
||||
* 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")) {
|
||||
is ApiResult.Success -> f.data
|
||||
is ApiResult.Error -> return f
|
||||
@@ -497,20 +553,66 @@ class AuthApi(private val client: HttpClient = ApiClient.httpClient) {
|
||||
}
|
||||
val body = json.encodeToString(
|
||||
KratosVerificationBody.serializer(),
|
||||
KratosVerificationBody(code = request.code.trim()),
|
||||
KratosVerificationBody(email = email.trim()),
|
||||
)
|
||||
val result = submitFlow(flow.ui.action, body) {
|
||||
runCatching { json.decodeFromString(KratosFlow.serializer(), it) }.getOrNull()
|
||||
}
|
||||
return when (result) {
|
||||
is ApiResult.Success -> {
|
||||
val verified = result.data?.state == "passed_challenge"
|
||||
ApiResult.Success(
|
||||
VerifyEmailResponse(
|
||||
message = if (verified) "Email verified." else "Verification submitted.",
|
||||
verified = verified,
|
||||
),
|
||||
// Persist the flow id BEFORE the user enters the code — this is
|
||||
// the flow Kratos just bound the emailed code to.
|
||||
DataManager.setPendingVerificationFlowId(flow.id)
|
||||
ApiResult.Success(Unit)
|
||||
}
|
||||
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
|
||||
else -> ApiResult.Error("Verification failed")
|
||||
|
||||
@@ -243,6 +243,10 @@ struct OnboardingVerifyEmailContent: View {
|
||||
.onAppear {
|
||||
print("🏠 ONBOARDING: OnboardingVerifyEmailContent appeared")
|
||||
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) {
|
||||
isCodeFieldFocused = true
|
||||
}
|
||||
|
||||
@@ -165,6 +165,17 @@ struct VerifyEmailView: View {
|
||||
.disabled(viewModel.code.count != 6 || viewModel.isLoading)
|
||||
.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
|
||||
Text(L10n.Auth.verifyHelpText)
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
@@ -202,6 +213,11 @@ struct VerifyEmailView: View {
|
||||
}
|
||||
.onAppear {
|
||||
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
|
||||
if isVerified {
|
||||
|
||||
@@ -26,6 +26,30 @@ class VerifyEmailViewModel: ObservableObject {
|
||||
}
|
||||
|
||||
// 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() {
|
||||
// Validation using ValidationRules
|
||||
if let error = ValidationRules.validateCode(code, expectedLength: 6) {
|
||||
|
||||
Reference in New Issue
Block a user