Skip to content

Recovery-factor verification, factor-aware endpoints, and the cookbook#9

Merged
igorbenav merged 9 commits into
mainfrom
recovery-verification
Jun 20, 2026
Merged

Recovery-factor verification, factor-aware endpoints, and the cookbook#9
igorbenav merged 9 commits into
mainfrom
recovery-verification

Conversation

@igorbenav

Copy link
Copy Markdown
Contributor

Recovery-factor verification, factor-aware endpoints, and the cookbook

This branch finishes the identity-and-recovery arc the model-driven identity contract started.
"Verified" and account recovery are now factor-agnostic, with email as the special case rather than
the concept, so a phone-recovery app verifies and resets over SMS through the built-in HTTP
endpoints. The public surface is renamed to stop saying "email" where it means "the recovery
factor," the verify/reset request bodies are shaped to the factor, and the release ships a
ten-recipe cookbook, an Identity API reference page, and refreshed architecture docs.


Recovery-factor verification

Before this branch, "verified" was hardcoded to email: the gate read repo.email_verified(user),
the column was email_verified, and current_user(verified=True) raised at construction unless the
model had an email column. The identity contract (already in main) let an app declare
recovery="phone", but there was no way to actually prove control of that phone, so the recovery
factor was only half real.

Now the repository resolves the factor. recovery_verified(user) reads email_verified for an
email-recovery app and {factor}_verified otherwise (and is always False when recovery=None);
mark_recovery_verified(db, user) writes the matching column. Principal gains a recovery_verified
field that build_principal fills, and current_user(verified=True) gates on it, raising at
construction only when the contract has no recovery factor (nothing to prove control of).

The proof machinery itself did not change. The signed, single-use, TTL'd token
(create_signed_token / verify_signed_token / _consume) has a zero-line diff; the only changes
in confirm_* are the verified read and write switching to the factor-aware path, and on the
request side the delivery recipient is now repo.get(user, recovery), which finally points the
delivery channel at the recovery factor instead of always the email.

Why: the contract already knows the recovery factor, so "verified" should mean that factor is
proven, not that an email is proven. email_verified stays the column for email apps, and the
concept generalizes above it without a second source of truth.


The verified flag stays unsettable, on every write path

A non-email {factor}_verified column (e.g. phone_verified) is not a LOGICAL_FIELDS member, so
it had to be gated explicitly or it would be a settable-without-proof downgrade. repo._recovery_verified_col()
is unioned into both the register allowlist and the provisioning filter, so the flag is dropped on
all three write paths: registration (even if opted into register_extra_fields), new_user_defaults
(gated at construction), and new_user_fields (gated through filter_provisioning_data). The only
way to set it remains redeeming the delivered token, exactly as email_verified always behaved.


Factor-aware recovery endpoints (phone recovery over HTTP)

The verify and reset flows generalized at the service layer, but the HTTP bodies of
/email/verify-request and /password/reset-request still validated EmailStr, so a phone app
could only drive recovery from Python, not over the built-in endpoints. That was the last open seam
in the arc.

The two request bodies are now generated from the recovery factor: an email-recovery app keeps
{"email": ...} validated as an address (byte-identical, so existing clients and the whole email
test suite are unaffected), and a phone-recovery app gets {"phone": ...} validated as a string.
The change-email endpoints, which prove a real email address, now mount only when the model actually
has an email column, so an email-less phone app no longer exposes broken change-email routes.


Factor-neutral naming

The behavior generalized but the names had not, and one of them is on the public AuthHooks surface.
The verify delivery kind is now verify_recovery for a non-email factor (email keeps verify_email,
so existing EmailSender implementations that switch on it are unaffected); the service methods are
request_recovery_verification / confirm_recovery_verification; and the hook
on_after_email_verified is now on_after_recovery_verified. Names that are genuinely
email-specific keep their names: on_after_email_changed and OAuth both prove a real email, and the
/verify-email path is user-facing config. These are real renames, not deprecated aliases, done
while the project is pre-1.0 and nobody depends on the old names, so the breaking-change cost is at
its lowest.


Documentation: a cookbook, an identity reference, refreshed architecture

The identity-and-recovery work needed narrative docs, and the cookbook was the natural home for the
account-shapes story. This branch adds a Cookbook of ten from-scratch recipes (email + password,
username-only, phone recovery, sign in with Google, email + password + Google, a token API, web and
API in one backend, server-set fields, onboarding an existing users table, and going to production),
each ending on a principle with runnable code. It adds an Identity API reference page
(make_auth_identity + IdentityConfig), refreshes the architecture page (the spine now lists
identity, plus a new identity-contract diagram regenerated through the SVG-to-PNG build script),
and capitalizes the CRUDAuth brand in prose across all docs while leaving the lowercase package
identifier in imports and install commands untouched.


Documentation Updates

docs/cookbook/: new section, ten recipes plus an index, wired into the nav.

docs/api/identity.md: new reference page for IdentityConfig and make_auth_identity.

docs/architecture.md: added the "identity contract" section, identity in the spine, and a
new identity-contract diagram; regenerated the architecture layer diagram.

docs/guides/accounts/registration.md, docs/guides/auth/sessions.md: note the account shape
is configurable, so they no longer imply email is mandatory.

docs/guides/infra/hooks.md, docs/guides/accounts/email.md: the verify hook is now
on_after_recovery_verified.

docs/changelog.md: 0.3.0 entry.

readme-assets/build-diagrams.cjs: the new identity-contract diagram and the identity spine
fix, plus an arg filter to render a subset.


Test Plan

Automated

  • uv run ruff check crudauth tests — clean
  • uv run mypy crudauth tests --config-file pyproject.toml — clean (100 files)
  • uv run pytest — 297 passing
  • uv run --group docs zensical build — no issues

Recovery-factor verification

  • Phone verify requires the delivered token; invalid and replayed tokens do not verify
  • recovery_verified reads the right column per factor (email, phone, None)
  • current_user(verified=True) passes when verified, 403s when not, and raises at construction for recovery=None
  • The factory emits {factor}_verified for a non-email factor, never double-emits for email

The flag stays unsettable

  • {factor}_verified is dropped from registration, new_user_defaults, and new_user_fields

Factor-aware endpoints

  • Phone verify and reset work over the built-in HTTP endpoints, delivered to the phone
  • A phone app's request body is {"phone": ...}; posting {"email": ...} is a 422
  • An email-less phone app mounts no change-email endpoints

Back-compat anchor

  • The existing email verify/reset/change suite passes unchanged (email apps still post {"email": ...})

Dependencies

None new. (sharp is installed locally only to regenerate diagram PNGs from the build script; it is
not a package dependency.)

Breaking Changes

  • AuthHooks.on_after_email_verified renamed to on_after_recovery_verified (and the internal
    run_after_email_verified to run_after_recovery_verified). An app registering
    AuthHooks(on_after_email_verified=...) must rename the keyword. on_after_email_changed is
    unchanged.
  • EmailFlowService.request_email_verification / confirm_email_verification renamed to
    request_recovery_verification / confirm_recovery_verification, and the verify and reset request
    methods now take a value parameter (the recovery-factor value) instead of email. The service is
    constructed by CRUDAuth internally, so most apps are unaffected; direct callers must update.
  • A non-email recovery factor emits a {factor}_verified column (e.g. phone_verified). The app
    declares the factor column itself; the verified flag rides alongside it, set only by token redemption.

@igorbenav
igorbenav merged commit 8c660c0 into main Jun 20, 2026
16 checks passed
@igorbenav
igorbenav deleted the recovery-verification branch June 20, 2026 08:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant