Skip to content

audit follow-ups: password schema cleanup, hook isolation, polish#22

Merged
mdfarhankc merged 7 commits into
mainfrom
fix/audit-followups
May 19, 2026
Merged

audit follow-ups: password schema cleanup, hook isolation, polish#22
mdfarhankc merged 7 commits into
mainfrom
fix/audit-followups

Conversation

@mdfarhankc
Copy link
Copy Markdown
Owner

@mdfarhankc mdfarhankc commented May 19, 2026

Six commits addressing audit findings on the 0.10.0 branch.

Breaking

  • hashed_password is nullable on UserMixin. OAuth-only users get NULL instead of a fake random hash. has_usable_password column dropped — hashed_password IS NOT NULL is the single signal.
  • /auth/set-password route and flows.set_password removed. /change-password accepts optional current_password; only skipped when the stored hash is NULL.
  • AbstractUserAdapter.create_user: hashed_password is now str | None.
  • flows.oauth.link_or_create_user and oauth_callback no longer take hash_algorithm (no password to hash).
  • ChallengeStore moved from core.challenges to protection.challenges. Also re-exported from fastapi_fullauth.protection.

Fixed

  • Hook exception isolation: a raising hook is logged via fastapi_fullauth.hooks and the next hook still runs. Previously one failing hook 500'd the request.
  • SQLAlchemyAdapter eager-loads roles via a new _user_query() helper mirroring the SQLModel adapter. User models with default-lazy roles no longer raise MissingGreenlet.
  • Passkey router uses logger.exception so tracebacks land in the logger instead of just str(e).
  • SA UserMixin.email is now String(320) matching the SQLModel side; no more silent VARCHAR truncation on MySQL/MSSQL.

Changed

  • AuthRateLimiter.check() sets X-RateLimit-Limit/Remaining/Reset + Retry-After on its 429s, matching the global RateLimitMiddleware.
  • flows.passkey._b64_decode padding idiom ((-len(data)) % 4).

Tests

All 202 pass, mypy --strict clean. New tests:

  • /change-password without current_password succeeds for users with no stored hash, still rejected when a hash exists.
  • A raising hook is isolated; subsequent hooks for the same event still run and the route returns its normal status.
  • SQLAlchemyAdapter user-model with default-lazy roles loads roles correctly.
  • Auth rate-limit 429 response carries the expected headers.

Summary by CodeRabbit

  • New Features

    • Rate-limit responses now include X-RateLimit-Limit, X-RateLimit-Remaining, and Retry-After headers for better client guidance.
  • Bug Fixes

    • Hook exceptions are now caught and logged without breaking authentication flows; subsequent hooks continue executing.
    • SQLAlchemy adapter now eagerly loads user roles to prevent async session errors.
    • Passkey base64 padding computation corrected.
  • Breaking Changes

    • OAuth-only users now created with nullable password field; /set-password endpoint removed and functionality merged into /change-password.
    • change_password flow now optional for first-time password setup on OAuth-only accounts.

Review Change Stack

- hashed_password is nullable on UserMixin (both SA + SM). OAuth users get
  NULL instead of a fake random hash.
- has_usable_password column dropped. hashed_password IS NOT NULL is the
  single signal.
- /auth/set-password route + flows/set_password.py removed. /change-password
  now accepts optional current_password — only skipped when the stored hash
  is NULL.
- AbstractUserAdapter.create_user: hashed_password is str | None.
- flows.oauth.link_or_create_user and oauth_callback no longer take
  hash_algorithm (no password to hash).
- Docs, agent skill notes, and CHANGELOG updated.
- Tests added: first-time set via /change-password without current_password,
  and the regression case where current_password is still required when a
  hash exists.
Wrap each hook call in try/except inside EventHooks.emit. A raising hook
is logged via fastapi_fullauth.hooks and the next hook still runs.

Previously a single failing hook aborted the chain and surfaced as a 500
even though the primary side effect (user created, password reset, etc.)
had already committed. Hooks fire after that commit, so a notification
failure shouldn't undo the operation the response reports.

Added test: three hooks registered, middle one raises, the first and
third both still fire and the HTTP request returns its normal status.
Add _user_query() helper to SQLAlchemyAdapter that calls
selectinload(user_model.roles) when the user model has a roles attribute.
Used by get_user_by_id, get_user_by_field, update_user, create_user, and
get_user_roles.

Previously the bare select(user_model) relied on the app to declare
lazy="selectin" on the relationship. If left default, _to_schema's
[r.name for r in user.roles] triggered an async lazy-load outside the
session and raised MissingGreenlet. The SQLModel adapter already had
this helper; the two now behave the same.

Added test: a User model with default-lazy roles relationship, run
through create_user -> assign_role -> get_user_by_email, asserts roles
populate without error.
Stale text the previous two commits missed:

- skill/hooks.md said a raising hook becomes a 500 and propagates.
  Flipped to describe the new behavior: caught, logged via
  fastapi_fullauth.hooks, next hook still runs, route returns normally.
- docs/auth/hooks.md and llms-full.txt got a matching one-paragraph note
  in the "Multiple hooks per event" section.
- skill/adapters.md and skill/troubleshooting.md previously said a custom
  user model with default-lazy roles would throw MissingGreenlet. The
  adapter now handles roles via selectinload itself; only other custom
  relationships need lazy="selectin" or selectinload at the call site.
…64 padding

Four small follow-ups from the audit.

- Passkey router: broad except now uses logger.exception so the
  traceback lands in fastapi_fullauth.routers.passkey instead of just
  the str(e). Affects /passkeys/register/complete and
  /passkeys/authenticate/complete.
- SA UserMixin.email is String(320), matching the SQLModel mixin's
  max_length and the OAuthAccountMixin.provider_email column. Avoids
  silent VARCHAR truncation on MySQL/MSSQL default lengths.
- AuthRateLimiter.check() now sets X-RateLimit-Limit /
  X-RateLimit-Remaining / X-RateLimit-Reset / Retry-After on its 429
  responses, matching what the global RateLimitMiddleware does.
- flows.passkey._b64_decode: padding is now (-len(data)) % 4 instead
  of 4 - len(data) % 4 so multiples of 4 don't get four trailing
  '=' bytes appended.

Test added: extended test_login_rate_limited to assert the X-RateLimit-*
and Retry-After headers on the 429.
ChallengeStore (memory + redis backends + factory) lives in
fastapi_fullauth/protection/challenges.py now. It's a stateful
anti-replay defence for WebAuthn — closer in nature to lockout and
ratelimit than to TokenEngine or the password-hashing primitives.

Old imports `from fastapi_fullauth.core.challenges import ...` need
to update to `fastapi_fullauth.protection.challenges`. Also re-exported
from the fastapi_fullauth.protection package for symmetry with the
other defensive stores.

Cleaned up a stale section in the agent-skill api-reference: the Core
block referenced core.redis_blacklist (gone in 0.10) and listed the
challenge imports under "Core". Now lists the real core imports
(TokenEngine, crypto, TokenBlacklist) and moves the challenge imports
into the Protection block.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Warning

Rate limit exceeded

@mdfarhankc has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 53 minutes and 14 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 0ad96189-002b-4b33-a862-ffbbccad6b00

📥 Commits

Reviewing files that changed from the base of the PR and between c3812b1 and b8260a8.

📒 Files selected for processing (2)
  • fastapi_fullauth/flows/change_password.py
  • fastapi_fullauth/routers/passkey.py
📝 Walkthrough

Walkthrough

This PR implements major breaking changes for version 0.10.0 centered on OAuth-first user handling. It makes password hashes optional for OAuth-only users, consolidates password-setting into a single flow, adds graceful hook error handling, improves SQLAlchemy async safety, and relocates challenge store exports to the protection module.

Changes

OAuth-First User Model & Password Unification

Layer / File(s) Summary
User Model Schema: Nullable Password & Field Removal
fastapi_fullauth/models/sqlalchemy/base.py, fastapi_fullauth/models/sqlmodel/base.py, fastapi_fullauth/protection/__init__.py, fastapi_fullauth/types.py
UserMixin.hashed_password becomes nullable (str | None, default None) in both adapters; has_usable_password field is removed; SQLAlchemy email column resized to String(320).
Adapter Contract: Nullable hashed_password Parameter
fastapi_fullauth/adapters/base.py, fastapi_fullauth/adapters/sqlalchemy.py, fastapi_fullauth/adapters/sqlmodel.py
AbstractUserAdapter.create_user signature updated to hashed_password: str | None, with concrete implementations updated to match.
Password Flow Consolidation: Remove set-password, Unify change-password
fastapi_fullauth/flows/change_password.py, fastapi_fullauth/routers/_schemas.py, fastapi_fullauth/routers/profile.py
set_password module removed entirely; change_password now accepts optional current_password (reordered after new_password), enabling first-time setup for users with hashed_password=NULL; /set-password endpoint removed.
OAuth User Creation: Hashed Password = None
fastapi_fullauth/flows/oauth.py, fastapi_fullauth/routers/oauth.py
OAuth user creation now passes hashed_password=None instead of hashing a random password; hash_algorithm parameter removed from link_or_create_user and oauth_callback signatures.

Runtime Behavior: Hooks, Storage, Rate Limiting

Layer / File(s) Summary
Hook Error Isolation: Catch & Log, Continue Execution
fastapi_fullauth/hooks.py
Adds module-level logger; wraps each hook in try/except to log failures via logger.exception without propagating or interrupting remaining hooks.
SQLAlchemy Adapter: Eager-Load Roles via _user_query Helper
fastapi_fullauth/adapters/sqlalchemy.py
Introduces _user_query() helper that conditionally eager-loads roles via selectinload; all user retrieval paths now use this helper to prevent MissingGreenlet errors when accessing roles outside the session.
Rate Limiter: Add Standard 429 Response Headers
fastapi_fullauth/protection/ratelimit.py, tests/test_auth.py
Computes reset time and returns 429 with X-RateLimit-* and Retry-After headers; test assertions updated to validate headers.

Infrastructure: Challenge Store & Passkey Fixes

Layer / File(s) Summary
Challenge Store Relocation: core → protection.challenges
fastapi_fullauth/protection/__init__.py, fastapi_fullauth/flows/passkey.py, fastapi_fullauth/routers/passkey.py, fastapi_fullauth/fullauth.py, tests/test_passkey.py
Moves ChallengeStore and related exports from core.challenges to protection.challenges and re-exports from protection/__init__.py; all consumer imports updated.
Passkey Operations: Base64 Padding Correction
fastapi_fullauth/flows/passkey.py, fastapi_fullauth/routers/passkey.py
Fixes _b64_decode padding from 4 - len(data) % 4 to (-len(data)) % 4; updates passkey error logging to use logger.exception for stack trace preservation.

Documentation Updates: Schema, Flows & Troubleshooting

Layer / File(s) Summary
Docs: User Model & Adapter Schema Changes
docs/adapters/sqlalchemy.md, docs/adapters/sqlmodel.md, docs/getting-started.md, docs/llms-full.txt
Updates all references to describe hashed_password as nullable (NULL for OAuth-only users); removes mention of has_usable_password.
Docs: Password Flows & OAuth Behavior
fastapi_fullauth/.agents/skills/fastapi-fullauth/references/api-reference.md, fastapi_fullauth/.agents/skills/fastapi-fullauth/references/composable-design.md, fastapi_fullauth/.agents/skills/fastapi-fullauth/references/oauth.md
Removes documented set_password flow; replaces /set-password route with /change-password; updates OAuth and password flow descriptions for first-time setup without current_password.
Docs: Hook Error Handling & Storage Fixes
docs/auth/hooks.md, fastapi_fullauth/.agents/skills/fastapi-fullauth/references/hooks.md, fastapi_fullauth/.agents/skills/fastapi-fullauth/references/adapters.md, fastapi_fullauth/.agents/skills/fastapi-fullauth/references/troubleshooting.md
Documents hook exception handling (logged, non-propagating), SQLAlchemy eager-loading of roles, and MissingGreenlet resolution guidance.
CHANGELOG: Version 0.10.0 Breaking Changes & Fixes
CHANGELOG.md
Records all breaking schema/API changes, fixed behaviors, and incremental improvements for version 0.10.0.

Test Coverage: New Scenarios & Updated Assertions

Layer / File(s) Summary
Tests: OAuth User Password Workflow
tests/test_profile.py
Adds two tests verifying /change-password for OAuth-only users: first-time setup without current_password when hash is NULL, and rejection when hash exists but current_password omitted.
Tests: Hook Exception Isolation
tests/test_hooks.py
Adds test confirming register succeeds despite a failing after_register hook and subsequent hooks still execute after a prior exception.
Tests: SQLAlchemy Role Eager Loading & Rate Limits
tests/test_sqlalchemy_adapter.py, tests/test_auth.py
Adds integration test verifying SQLAlchemyAdapter eager-loads roles with default lazy config; updates rate limit test to assert standard headers.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 No more random passwords for OAuth souls—
just NULL hashes and unified goals.
Hooks fail gracefully, roles load with flair,
headers flow freely through the 429 air! ✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 16.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately summarizes the main changes: audit follow-ups addressing password schema cleanup, hook isolation, and polishing improvements.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering breaking changes, fixes, and testing. However, it does not follow the provided template structure with explicit Summary/Changes/Test plan sections.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/audit-followups

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

- change_password.py: SIM102 — merged the nested `if hashed is not None`
  + `if not current_password or not verify_password(...)` into a single
  `and` condition.
- routers/passkey.py: I001 — moved `protection.challenges` import after
  `dependencies.current_user` so the alphabetical order is correct.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
fastapi_fullauth/routers/passkey.py (1)

8-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Fix import ordering to unblock lint CI.

The import block is not sorted/formatted per Ruff I001, which is currently failing CI.

Suggested fix
 from fastapi_fullauth.adapters.base import PasskeyAdapterMixin
-from fastapi_fullauth.protection.challenges import ChallengeStore
 from fastapi_fullauth.dependencies.current_user import CurrentUser, get_fullauth
+from fastapi_fullauth.protection.challenges import ChallengeStore
 from fastapi_fullauth.routers._schemas import build_login_response_model
 from fastapi_fullauth.types import TokenPair, UserSchema, UserSchemaType
 from fastapi_fullauth.utils import get_client_ip
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastapi_fullauth/routers/passkey.py` around lines 8 - 13, Reorder the import
block to satisfy Ruff I001 by grouping and alphabetizing imports: place
standard-library imports first (none here), then third-party packages, then
local package imports; within each group sort alphabetically. Update the import
lines referencing PasskeyAdapterMixin, ChallengeStore, CurrentUser and
get_fullauth, build_login_response_model, TokenPair/UserSchema/UserSchemaType,
and get_client_ip so they follow that ordering and are alphabetized to unblock
lint CI.
🧹 Nitpick comments (1)
fastapi_fullauth/adapters/sqlalchemy.py (1)

144-148: 💤 Low value

Consider replacing assert with explicit exception.

The assert user is not None on line 147 is defensive programming for a should-never-happen scenario, but asserts can be disabled with Python's -O flag. For production robustness, consider:

♻️ Proposed refactor
 # Re-fetch with roles eager-loaded so _to_schema works outside the session.
 result = await session.execute(self._user_query().where(self._user_model.id == user.id))
 user = result.scalars().first()
-assert user is not None
+if user is None:
+    raise RuntimeError(f"User {user.id} not found immediately after creation")
 return self._to_schema(user)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@fastapi_fullauth/adapters/sqlalchemy.py` around lines 144 - 148, The code
uses assert user is not None after re-fetching the user (in the block calling
self._user_query(), session.execute(... where self._user_model.id == user.id))
which can be disabled with Python -O; replace the assert with an explicit
exception (e.g., raise RuntimeError or a custom UserNotFoundError) that includes
context (user id and that eager-load fetch failed) before returning
self._to_schema(user) so the error cannot be skipped in production.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@fastapi_fullauth/flows/change_password.py`:
- Around line 24-27: Collapse the nested guards in the change password check by
merging the two ifs into one: replace the nested "if hashed is not None: if not
current_password or not verify_password(current_password, hashed):" with a
single conditional that checks both hashed is not None and the combined failure
condition, then call logger.warning(...) and raise AuthenticationError("Current
password is incorrect"); refer to the variables and functions hashed,
current_password, verify_password, logger, and AuthenticationError in the
change_password flow.

---

Outside diff comments:
In `@fastapi_fullauth/routers/passkey.py`:
- Around line 8-13: Reorder the import block to satisfy Ruff I001 by grouping
and alphabetizing imports: place standard-library imports first (none here),
then third-party packages, then local package imports; within each group sort
alphabetically. Update the import lines referencing PasskeyAdapterMixin,
ChallengeStore, CurrentUser and get_fullauth, build_login_response_model,
TokenPair/UserSchema/UserSchemaType, and get_client_ip so they follow that
ordering and are alphabetized to unblock lint CI.

---

Nitpick comments:
In `@fastapi_fullauth/adapters/sqlalchemy.py`:
- Around line 144-148: The code uses assert user is not None after re-fetching
the user (in the block calling self._user_query(), session.execute(... where
self._user_model.id == user.id)) which can be disabled with Python -O; replace
the assert with an explicit exception (e.g., raise RuntimeError or a custom
UserNotFoundError) that includes context (user id and that eager-load fetch
failed) before returning self._to_schema(user) so the error cannot be skipped in
production.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 71d74988-6c02-4dde-9792-605ee9eaff59

📥 Commits

Reviewing files that changed from the base of the PR and between 0f4af1b and c3812b1.

📒 Files selected for processing (36)
  • CHANGELOG.md
  • docs/adapters/sqlalchemy.md
  • docs/adapters/sqlmodel.md
  • docs/auth/hooks.md
  • docs/getting-started.md
  • docs/llms-full.txt
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/adapters.md
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/api-reference.md
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/composable-design.md
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/hooks.md
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/oauth.md
  • fastapi_fullauth/.agents/skills/fastapi-fullauth/references/troubleshooting.md
  • fastapi_fullauth/adapters/base.py
  • fastapi_fullauth/adapters/sqlalchemy.py
  • fastapi_fullauth/adapters/sqlmodel.py
  • fastapi_fullauth/flows/change_password.py
  • fastapi_fullauth/flows/oauth.py
  • fastapi_fullauth/flows/passkey.py
  • fastapi_fullauth/flows/set_password.py
  • fastapi_fullauth/fullauth.py
  • fastapi_fullauth/hooks.py
  • fastapi_fullauth/models/sqlalchemy/base.py
  • fastapi_fullauth/models/sqlmodel/base.py
  • fastapi_fullauth/protection/__init__.py
  • fastapi_fullauth/protection/challenges.py
  • fastapi_fullauth/protection/ratelimit.py
  • fastapi_fullauth/routers/_schemas.py
  • fastapi_fullauth/routers/oauth.py
  • fastapi_fullauth/routers/passkey.py
  • fastapi_fullauth/routers/profile.py
  • fastapi_fullauth/types.py
  • tests/test_auth.py
  • tests/test_hooks.py
  • tests/test_passkey.py
  • tests/test_profile.py
  • tests/test_sqlalchemy_adapter.py
💤 Files with no reviewable changes (3)
  • fastapi_fullauth/types.py
  • fastapi_fullauth/flows/set_password.py
  • fastapi_fullauth/routers/oauth.py

Comment thread fastapi_fullauth/flows/change_password.py Outdated
@mdfarhankc mdfarhankc merged commit 7e058a1 into main May 19, 2026
7 checks passed
@mdfarhankc mdfarhankc deleted the fix/audit-followups branch May 19, 2026 11:33
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