Skip to content

feat(auth): implement refresh token rotation and reuse detection#44

Merged
aniebietafia merged 3 commits intomainfrom
feat/auth-refresh-token
Mar 29, 2026
Merged

feat(auth): implement refresh token rotation and reuse detection#44
aniebietafia merged 3 commits intomainfrom
feat/auth-refresh-token

Conversation

@aniebietafia
Copy link
Copy Markdown
Contributor

@aniebietafia aniebietafia commented Mar 27, 2026

  • Implemented POST /api/v1/auth/refresh-token endpoint for secure token rotation via HttpOnly cookies.
  • Refactored TokenStoreService Redis architecture to use email-scoped keys (refresh_token:{email}:{jti}) to support efficient session revocation.
  • Added revoke_all_user_tokens mechanism to instantly tear down an entire user session family if a stolen (already revoked) refresh token is replayed.
  • Created decode_refresh_token wrapper returning a strictly typed RefreshTokenClaims schema to eliminate Optional type constraints from Mypy.
  • Added 9 comprehensive integration tests in test_auth_refresh.py covering valid rotation, tampered tokens, hijacked sessions, and deactivated accounts.
  • Resolved type-ignores in aiokafka integrations recognizing modern stubs.

Closes #25

Summary by CodeRabbit

Release Notes

  • New Features
    • Added secure token refresh endpoint to obtain new access tokens without re-authenticating.
    • Implemented automatic refresh token rotation on each use for enhanced security.
    • Added detection of token reuse attempts; suspicious replays automatically revoke all user sessions as a security measure.
    • Applied rate limiting (30 requests/minute) to the refresh endpoint.
    • Enhanced validation to prevent token operations for deactivated or deleted accounts.

- Implemented `POST /api/v1/auth/refresh-token` endpoint for secure token rotation via HttpOnly cookies.
- Refactored `TokenStoreService` Redis architecture to use email-scoped keys (`refresh_token:{email}:{jti}`) to support efficient session revocation.
- Added `revoke_all_user_tokens` mechanism to instantly tear down an entire user session family if a stolen (already revoked) refresh token is replayed.
- Created `decode_refresh_token` wrapper returning a strictly typed `RefreshTokenClaims` schema to eliminate `Optional` type constraints from Mypy.
- Added 9 comprehensive integration tests in `test_auth_refresh.py` covering valid rotation, tampered tokens, hijacked sessions, and deactivated accounts.
- Resolved type-ignores in `aiokafka` integrations recognizing modern stubs.

Signed-off-by: aniebietafia <aniebietafia87@gmail.com>
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Mar 27, 2026

Warning

Rate limit exceeded

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

Your organization is not enrolled in usage-based pricing. Contact your admin to enable usage-based pricing to continue reviews beyond the rate limit, or try again in 4 minutes and 42 seconds.

⌛ 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: df8cb45b-289d-49a9-962e-19921c47746d

📥 Commits

Reviewing files that changed from the base of the PR and between 19df2dc and e1c5056.

📒 Files selected for processing (58)
  • README.md
  • alembic/env.py
  • alembic/versions/092f1a489931_add_meeting_model.py
  • alembic_output.txt
  • app/api/v1/__init__.py
  • app/api/v1/endpoints/auth.py
  • app/auth/__init__.py
  • app/auth/account_lockout.py
  • app/auth/constants.py
  • app/auth/dependencies.py
  • app/auth/models.py
  • app/auth/router.py
  • app/auth/schemas.py
  • app/auth/service.py
  • app/auth/token_store.py
  • app/auth/utils.py
  • app/auth/verification.py
  • app/core/security.py
  • app/crud/__init__.py
  • app/crud/auth_verification_api.md
  • app/crud/user/user.py
  • app/crud/verification_token.py
  • app/external_services/__init__.py
  • app/external_services/cloudinary/__init__.py
  • app/external_services/mailgun/__init__.py
  • app/main.py
  • app/models/__init__.py
  • app/models/base.py
  • app/models/user.py
  • app/models/verification_token.py
  • app/routers/__init__.py
  • app/routers/api.py
  • app/schemas/auth.py
  • app/services/token_store.py
  • app/user/__init__.py
  • app/user/config.py
  • app/user/constants.py
  • app/user/dependencies.py
  • app/user/exceptions.py
  • app/user/models.py
  • app/user/router.py
  • app/user/schemas.py
  • app/user/service.py
  • app/user/utils.py
  • app/utils/__init__.py
  • app/utils/authentication.py
  • app/utils/validation.py
  • fail_log.txt
  • pyproject.toml
  • requirements/base.txt
  • requirements/dev.txt
  • requirements/prod.txt
  • tests/test_auth/test_auth_login.py
  • tests/test_auth/test_auth_refresh.py
  • tests/test_auth/test_auth_signup.py
  • tests/test_auth/test_email_verification.py
  • tests/test_auth/test_schemas_user.py
  • tests/test_auth/test_verification_token_crud.py
📝 Walkthrough

Walkthrough

This pull request implements a token rotation endpoint (POST /auth/refresh-token) alongside a major refactoring of authentication architecture. It consolidates auth-related models and services into a dedicated app/auth package, introduces Redis-backed refresh token validation and revocation, and reorganizes the router structure while adding comprehensive test coverage for the new endpoint.

Changes

Cohort / File(s) Summary
Token Rotation Endpoint
app/api/v1/endpoints/auth.py, app/routers/api.py, app/routers/__init__.py
New POST /auth/refresh-token endpoint with rate limiting (30/min), refresh token validation via Redis, reuse detection triggering full session revocation, and token rotation with new JTI persistence. Updated router imports to use app/routers instead of app/api/v1.
Security & Token Services
app/core/security.py, app/auth/token_store.py, app/auth/service.py
Added decode_refresh_token() to SecurityService for JWT validation. Introduced TokenStoreService with Redis operations for token JTI persistence, revocation, and bulk user-session clearing via SCAN patterns. New AuthService encapsulating signup, login, password reset, and refresh flows with proper error handling and email verification.
Auth Models & Schemas
app/auth/models.py, app/auth/schemas.py, app/schemas/auth.py
Moved User and VerificationToken ORM models to app/auth/models.py with audit timestamps and language preferences. Added RefreshTokenClaims, RefreshTokenResponse, and other auth request/response schemas. Removed legacy UserCreate schema.
Auth Verification & Dependencies
app/auth/verification.py, app/auth/dependencies.py, app/auth/router.py
Refactored AuthVerificationService to accept Session and EmailProducerService directly rather than repositories. New dependency providers in app/auth/dependencies.py for auth services. New consolidated app/auth/router.py with endpoints for signup, login, email verification, password reset, and refresh (moved from app/api/v1/endpoints/auth.py).
CRUD Cleanup
app/crud/__init__.py, app/crud/user/user.py, app/crud/verification_token.py, app/crud/auth_verification_api.md
Removed user creation/lookup CRUD functions (create_user, get_user_by_email) and entire VerificationTokenRepository class in favor of direct ORM operations within services. Deleted obsolete documentation.
ORM Reorganization
app/models/__init__.py, app/models/base.py, app/models/user.py, app/models/verification_token.py
Consolidated ORM models into app/auth/models.py. Created shared Base declarative class in app/models/base.py. Removed User and VerificationToken from app/models/__init__.py exports. Deleted legacy model definition files.
Main Application
app/main.py
Updated api_router import source from app.api.v1 to app.routers to reflect new router structure.
Auth Endpoint Tests
tests/test_auth/test_auth_login.py, tests/test_auth/test_auth_refresh.py
Updated login tests with new import paths and enhanced FakeRedis mock (added delete(*keys), scan(), pipeline()). New comprehensive test suite for refresh endpoint covering success, missing cookie, invalid token, reuse detection (with session revocation), and deactivated account scenarios.
Verification & Schema Tests
tests/test_auth/test_email_verification.py, tests/test_auth/test_schemas_user.py, tests/test_auth/test_verification_token_crud.py, tests/test_auth/test_auth_signup.py
Updated imports to use new model/schema locations (app.auth.models, app.auth.schemas). Modified verification token tests to use direct ORM operations instead of repository helpers.
Infrastructure & Migration
alembic/env.py, alembic/versions/..., README.md, pyproject.toml, app/services/email_consumer.py, fail_log.txt, alembic_output.txt
Updated Alembic to import auth models for autogeneration. Added migration placeholder for Meeting model. Minor config updates (Ruff formatting, pyproject ignore rules, email producer auth username). Cleaned up test logs and Alembic output records.

Sequence Diagram

sequenceDiagram
    participant Client
    participant AuthEndpoint as Auth Endpoint
    participant SecurityService
    participant TokenStore
    participant Database
    participant Session

    Client->>AuthEndpoint: POST /auth/refresh-token<br/>(refresh_token cookie)
    AuthEndpoint->>AuthEndpoint: Extract refresh_token<br/>from HttpOnly cookie
    
    alt Missing Cookie
        AuthEndpoint-->>Client: 401 MISSING_REFRESH_TOKEN
    else
        AuthEndpoint->>SecurityService: decode_refresh_token(token)
        SecurityService->>SecurityService: Verify JWT signature<br/>& expiry
        
        alt Invalid/Expired
            SecurityService-->>AuthEndpoint: ValueError
            AuthEndpoint-->>Client: 401 INVALID_REFRESH_TOKEN
        else
            SecurityService-->>AuthEndpoint: RefreshTokenClaims<br/>(email, jti)
        end
        
        AuthEndpoint->>TokenStore: is_refresh_token_valid<br/>(email, jti)
        TokenStore->>TokenStore: Check Redis<br/>refresh_token:{email}:{jti}
        
        alt Token Not Found (Revoked)
            TokenStore-->>AuthEndpoint: false
            AuthEndpoint->>TokenStore: revoke_all_user_tokens(email)
            TokenStore->>TokenStore: SCAN & DELETE all<br/>user tokens from Redis
            AuthEndpoint-->>Client: 401 REFRESH_TOKEN_REUSE
        else
            TokenStore-->>AuthEndpoint: true
        end
        
        AuthEndpoint->>Database: Query User(email)
        Database-->>AuthEndpoint: User record
        
        alt Deactivated/Deleted Account
            AuthEndpoint-->>Client: 403 ACCOUNT_DEACTIVATED
        else
            AuthEndpoint->>SecurityService: Generate new<br/>access_token + refresh_token
            SecurityService-->>AuthEndpoint: New tokens<br/>with fresh JTIs
            
            AuthEndpoint->>TokenStore: revoke_refresh_token<br/>(email, old_jti)
            TokenStore->>TokenStore: DELETE old token<br/>from Redis
            
            AuthEndpoint->>TokenStore: save_refresh_token<br/>(email, new_jti, ttl)
            TokenStore->>TokenStore: SET new token<br/>in Redis with TTL
            
            AuthEndpoint->>Client: 200 OK + new access_token<br/>+ new refresh_token cookie<br/>(HttpOnly, SameSite=Strict)
        end
    end
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly Related PRs

  • feat(auth): migrate user identities from integer to UUIDv4 #43: Introduces SecurityService token creation and login flow; this PR extends the same security module with decode_refresh_token(), adds TokenStoreService for Redis token persistence, and refactors auth endpoints to use email-keyed token storage, making these changes tightly coupled in the token authentication pipeline.

Poem

🐰 Refresh tokens spin in Redis so bright,
Old JTIs revoked, security tight,
One hop through the service, the rotation's set free,
New cookie baked fresh, HttpOnly three!
Sessions dance safer with reuse we detect,
No theft shall go unspotted—we've got it correct! 🔐✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR contains several out-of-scope structural changes unrelated to issue #25: migration to app/auth package structure, removal of old CRUD modules, router restructuring from app.api to app.routers, and test infrastructure updates. Consider isolating the refresh token feature (auth.py, security.py, token_store.py) from architectural refactoring. The auth package migration and router restructuring should be separate PRs to maintain focus and simplify review.
Docstring Coverage ⚠️ Warning Docstring coverage is 18.28% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title clearly and concisely summarizes the main change: implementing refresh token rotation and reuse detection, which aligns with the core feature described in the changeset.
Linked Issues check ✅ Passed The PR implements all key requirements from issue #25: POST /api/v1/auth/refresh-token endpoint with HttpOnly cookie handling, validation (missing/invalid/reuse), token rotation with revocation, account deactivation checks, 30/min rate limiting, and comprehensive integration tests.

✏️ 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 feat/auth-refresh-token

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.

Signed-off-by: aniebietafia <aniebietafia87@gmail.com>
Comment thread alembic/env.py
Comment on lines +11 to +14
from app.auth.models import (
User,
VerificationToken,
) # noqa: F401 — required for autogenerate

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'User' is not used.
Import of 'VerificationToken' is not used.

Copilot Autofix

AI 27 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'sa' is not used.

Copilot Autofix

AI 27 days ago

To fix the problem, remove the unused import so that every imported name is actually referenced in the file. This eliminates unnecessary dependencies and satisfies the static analysis rule.

Concretely, in alembic/versions/092f1a489931_add_meeting_model.py, delete the line import sqlalchemy as sa (line 12 in the snippet). No additional methods, imports, or definitions are required, since nothing in the file currently uses sa. If at a later time the upgrade or downgrade functions are filled in with SQLAlchemy constructs, the developer can re-add the import sqlalchemy as sa line as needed.

Suggested changeset 1
alembic/versions/092f1a489931_add_meeting_model.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/alembic/versions/092f1a489931_add_meeting_model.py b/alembic/versions/092f1a489931_add_meeting_model.py
--- a/alembic/versions/092f1a489931_add_meeting_model.py
+++ b/alembic/versions/092f1a489931_add_meeting_model.py
@@ -9,7 +9,6 @@
 from typing import Sequence, Union
 
 from alembic import op
-import sqlalchemy as sa
 
 # revision identifiers, used by Alembic.
 revision: str = "092f1a489931"
EOF
@@ -9,7 +9,6 @@
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "092f1a489931"
Copilot is powered by AI and may make mistakes. Always verify output.
import sqlalchemy as sa

# revision identifiers, used by Alembic.
revision: str = "092f1a489931"

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'revision' is not used.

Copilot Autofix

AI 27 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.


# revision identifiers, used by Alembic.
revision: str = "092f1a489931"
down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'down_revision' is not used.

Copilot Autofix

AI 27 days ago

Copilot could not generate an autofix suggestion

Copilot could not generate an autofix suggestion for this alert. Try pushing a new commit or if the problem persists contact support.

# revision identifiers, used by Alembic.
revision: str = "092f1a489931"
down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
branch_labels: Union[str, Sequence[str], None] = None

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'branch_labels' is not used.

Copilot Autofix

AI 27 days ago

To fix the problem, we should remove the unused global variable assignment if it has no side effects and is not required by external APIs. Here, branch_labels is assigned the literal None, which has no side effects, and in a typical Alembic migration this field is only necessary when branch labels are actually used. Removing the assignment does not change behavior when branch labels are not used.

Concretely, in alembic/versions/092f1a489931_add_meeting_model.py, delete the line that assigns branch_labels at the module level, keeping the other Alembic identifiers (revision, down_revision, depends_on) unchanged. No imports, methods, or additional definitions are needed.

Suggested changeset 1
alembic/versions/092f1a489931_add_meeting_model.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/alembic/versions/092f1a489931_add_meeting_model.py b/alembic/versions/092f1a489931_add_meeting_model.py
--- a/alembic/versions/092f1a489931_add_meeting_model.py
+++ b/alembic/versions/092f1a489931_add_meeting_model.py
@@ -14,7 +14,6 @@
 # revision identifiers, used by Alembic.
 revision: str = "092f1a489931"
 down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
-branch_labels: Union[str, Sequence[str], None] = None
 depends_on: Union[str, Sequence[str], None] = None
 
 
EOF
@@ -14,7 +14,6 @@
# revision identifiers, used by Alembic.
revision: str = "092f1a489931"
down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


Copilot is powered by AI and may make mistakes. Always verify output.
revision: str = "092f1a489931"
down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None

Check notice

Code scanning / CodeQL

Unused global variable Note

The global variable 'depends_on' is not used.

Copilot Autofix

AI 27 days ago

In general, unused global variables should either be removed (if they have no side effects and no external consumers) or renamed to clearly indicate that they are intentionally unused (for documentation or template purposes). Removing the left-hand side only is appropriate when the right-hand side has side effects that must be preserved; here the right-hand side is a literal None, so there are no side effects to worry about.

In this Alembic migration, depends_on is optional and is set to None, which is Alembic’s default value. To avoid changing existing functionality while keeping the template structure, the safest single change is to rename this variable to something that clearly indicates it is unused, such as _unused_depends_on. This preserves the information for future editors (they can see there is currently no dependency) and satisfies the static analysis rule that intentionally unused variables must have a special name. Concretely, in alembic/versions/092f1a489931_add_meeting_model.py, at line 18, change depends_on to _unused_depends_on, leaving the type annotation and value unchanged. No additional imports or helper methods are necessary.

Suggested changeset 1
alembic/versions/092f1a489931_add_meeting_model.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/alembic/versions/092f1a489931_add_meeting_model.py b/alembic/versions/092f1a489931_add_meeting_model.py
--- a/alembic/versions/092f1a489931_add_meeting_model.py
+++ b/alembic/versions/092f1a489931_add_meeting_model.py
@@ -15,7 +15,7 @@
 revision: str = "092f1a489931"
 down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
 branch_labels: Union[str, Sequence[str], None] = None
-depends_on: Union[str, Sequence[str], None] = None
+_unused_depends_on: Union[str, Sequence[str], None] = None
 
 
 def upgrade() -> None:
EOF
@@ -15,7 +15,7 @@
revision: str = "092f1a489931"
down_revision: Union[str, Sequence[str], None] = "7f066a8213a8"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
_unused_depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
Copilot is powered by AI and may make mistakes. Always verify output.
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: 10

Caution

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

⚠️ Outside diff range comments (2)
app/main.py (1)

59-59: ⚠️ Potential issue | 🔴 Critical

API base-path mismatch: configured as /routers/v1 but tests expect /api/v1.

The routing chain is:

  • settings.API_V1_STR = "/routers/v1" (app/core/config.py:19)
  • auth_router has prefix="/auth" (app/auth/router.py:26)
  • api_router includes auth_router with no additional prefix, then mounted with settings.API_V1_STR

This makes the effective route /routers/v1/auth/*, but all 40+ tests in tests/test_auth/ request /api/v1/auth/*, causing 404 responses. Either the config setting must be changed to "/api/v1" or all tests must be updated to use /routers/v1 paths.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/main.py` at line 59, The API base path is misconfigured:
settings.API_V1_STR is set to "/routers/v1" while routes (auth_router included
into api_router and mounted via app.include_router(api_router,
prefix=settings.API_V1_STR)) are expected by tests at "/api/v1"; update the
configuration by changing settings.API_V1_STR to "/api/v1" (so the effective
route becomes "/api/v1/auth/*") and keep auth_router's prefix="/auth" and the
app.include_router(api_router, prefix=settings.API_V1_STR) usage unchanged.
tests/test_auth/test_auth_login.py (1)

221-223: ⚠️ Potential issue | 🔴 Critical

Update test endpoints to use correct API route prefix.

The tests use /api/v1/auth/login but the configured route prefix in app/core/config.py is /routers/v1. This mismatch causes all requests to return 404 errors.

Update all test URLs from /api/v1/auth/login to /routers/v1/auth/login at lines 221, 238, 252, 269, 288, 302, 316, 319, 336, 358, 386, 401–402, 407–408, 425–426, and 431–432.

Example fix
-        response = client.post(
-            "/api/v1/auth/login",
+        response = client.post(
+            "/routers/v1/auth/login",
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_login.py` around lines 221 - 223, Tests are calling
the wrong route prefix; update all test request URLs that use "/api/v1/auth/..."
to the configured prefix "/routers/v1/auth/..." so requests hit the real routes;
search for occurrences in tests/test_auth/test_auth_login.py (examples around
the client.post calls shown with json={"email": "user@example.com", "password":
"MyStr0ngP@ss!"}) and replace "/api/v1/" with "/routers/v1/" for every listed
occurrence (lines referenced in the review: 221, 238, 252, 269, 288, 302, 316,
319, 336, 358, 386, 401–402, 407–408, 425–426, 431–432).
🧹 Nitpick comments (6)
pyproject.toml (1)

49-49: Avoid globally disabling B008; scope the suppression instead.

Turning off B008 repo-wide can hide real default-argument bugs. Prefer keeping B008 enabled and using targeted # noqa: B008 (or per-file ignore) only where framework patterns require it.

Suggested config change
 [tool.ruff.lint]
 select = ["B", "E", "F", "I", "W", "C90", "UP", "ASYNC", "PT", "ARG", "PTH", "SIM", "PLE", "PLW", "RUF"]
-ignore = ["B008"]
+ignore = []
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@pyproject.toml` at line 49, Remove the global ignore for B008 in
pyproject.toml (the line ignore = ["B008"]) and instead re-enable the B008 check
repository-wide; then add targeted suppressions (use per-file configuration or
inline comments like "# noqa: B008") only in the specific modules or function
definitions that legitimately require mutable default arguments (e.g., the
files/functions following your framework patterns), so B008 remains active
except where explicitly and locally suppressed.
app/auth/router.py (1)

199-206: Move import to top of file.

The lazy import of UnauthorizedException inside the function body is unconventional. Since this exception is used every time a cookie is missing, move it to the module-level imports for clarity.

Proposed fix

At the top of the file (after line 22):

+from app.core.exceptions import UnauthorizedException

Then in the function:

     if not raw_token:
-        from app.core.exceptions import UnauthorizedException
-
         raise UnauthorizedException(
             code="MISSING_REFRESH_TOKEN",
             message="No refresh token provided.",
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/router.py` around lines 199 - 206, The code lazily imports
UnauthorizedException inside the handler where raw_token =
request.cookies.get("refresh_token") leading to unconventional in-function
imports; move the import of UnauthorizedException to the module-level imports at
the top of app/auth/router.py (alongside other imports) and remove the inner
"from app.core.exceptions import UnauthorizedException" inside the function so
the raise UnauthorizedException(...) uses the top-level symbol.
app/auth/verification.py (1)

86-95: Consider bulk delete for efficiency.

The current approach fetches all unexpired tokens into memory and deletes them one by one. For users with multiple tokens, a bulk delete would be more efficient:

Proposed optimization
-        statement_del = select(VerificationToken).where(
-            VerificationToken.user_id == user.id,
-            VerificationToken.expires_at >= now,
-        )
-        tokens = self.db.execute(statement_del).scalars().all()
-        for t in tokens:
-            self.db.delete(t)
-        if tokens:
-            self.db.commit()
+        from sqlalchemy import delete
+
+        delete_stmt = delete(VerificationToken).where(
+            VerificationToken.user_id == user.id,
+            VerificationToken.expires_at >= now,
+        )
+        self.db.execute(delete_stmt)
+        self.db.commit()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/verification.py` around lines 86 - 95, Replace the fetch-and-loop
deletion with a single bulk DELETE: instead of executing the SELECT stored in
statement_del, build and execute a delete() against VerificationToken with the
same filters (VerificationToken.user_id == user.id and
VerificationToken.expires_at >= now), run it via self.db.execute(...) and call
self.db.commit() if the delete affected rows (use the returned result.rowcount
to decide). Update the code that currently uses tokens =
self.db.execute(statement_del).scalars().all() and the for t in tokens loop to
use this bulk delete approach.
tests/test_auth/test_auth_login.py (1)

34-100: Extract FakeRedis and FakePipeline to a shared test utility.

These test doubles are duplicated verbatim in test_auth_refresh.py. Consider moving them to tests/conftest.py or a dedicated tests/fakes.py module to reduce duplication and ensure consistent behavior across test files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_login.py` around lines 34 - 100, Move the FakeRedis
and FakePipeline classes out of tests/test_auth/test_auth_login.py into a shared
test utility (e.g., tests/conftest.py or tests/fakes.py), keeping all methods
and signatures (FakeRedis: __init__, set, get, delete, exists, incr, scan,
pipeline, reset; FakePipeline: __init__, delete, execute) intact; then update
test_auth_login.py and the duplicate file (test_auth_refresh.py) to import the
shared FakeRedis and FakePipeline instead of defining them inline so both tests
use the same, centralized test doubles.
tests/test_auth/test_auth_refresh.py (2)

312-335: Consider adding test for expired refresh token.

The test suite covers garbage tokens and access tokens misused as refresh tokens, but doesn't explicitly test an expired (but otherwise valid) refresh token. This edge case is worth covering to ensure proper 401 response with INVALID_REFRESH_TOKEN.

Would you like me to generate a test case for expired refresh tokens?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` around lines 312 - 335, Add a new test
in the TestRefreshTokenInvalid class that seeds a user with
_seed_user(db_session), then creates a refresh token that is already expired
(e.g., use _SECURITY.create_refresh_token with an expiry in the past or create
then tamper the exp claim) and POSTs it to _URL as the "refresh_token" cookie;
assert the response.status_code == 401 and response.json()["code"] ==
"INVALID_REFRESH_TOKEN". Reference TestRefreshTokenInvalid,
_SECURITY.create_refresh_token (or the helper that builds tokens), _seed_user,
and _URL when locating where to add the test.

234-238: Use asyncio.run() or pytest-asyncio for modern async test patterns.

asyncio.get_event_loop().run_until_complete() is not deprecated, but asyncio.run() is the recommended modern approach for running async code from synchronous contexts (Python 3.7+). For synchronous tests that need to call async methods, consider:

  1. asyncio.run() (simpler, cleaner), or
  2. Convert tests to async and use pytest-asyncio
Example using asyncio.run()
-        import asyncio
-
-        asyncio.get_event_loop().run_until_complete(
-            token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl)
-        )
+        import asyncio
+
+        asyncio.run(
+            token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl)
+        )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` around lines 234 - 238, Replace the
legacy asyncio.get_event_loop().run_until_complete call with asyncio.run to
execute the coroutine; specifically call
asyncio.run(token_store.save_refresh_token(email=email, jti=jti,
ttl_seconds=ttl)) in place of the current run_until_complete usage, or
alternatively convert the test to an async test using pytest-asyncio and await
token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl) inside the
async test function.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/issue-labeler.yml:
- Around line 36-37: The "api" label's regex was changed to
'(routers|endpoint|rest|controller)' and now no longer matches "api" or
"/api/v1"; update the api label pattern (the mapping keyed by api in the
issue-labeler config) to include both "api" and "routers" by adding "api" into
the alternation so it matches terms like "api" and "/api/v1" as well as
"routers" (e.g. change the pattern to include api alongside routers, endpoint,
rest, controller).

In `@alembic/versions/092f1a489931_add_meeting_model.py`:
- Around line 21-32: The revision currently does nothing: replace the pass in
upgrade() with a proper op.create_table call to create the meeting table (e.g.,
include at minimum an Integer primary key column "id", appropriate columns like
"title" (String), "start_time"/"end_time" (DateTime), audit timestamps and any
FK(s) if needed) and add the corresponding op.drop_table("meeting") in
downgrade(); ensure you import and use alembic.op and sqlalchemy types and match
your project's naming (refer to upgrade(), downgrade(), op.create_table and
op.drop_table to locate where to add these DDL changes).

In `@app/auth/service.py`:
- Around line 129-141: The login flow lacks the same active-account guard used
in refresh_token(), causing deactivated users to succeed at login but fail on
refresh; update the login function to check user.is_active after the existing
is_verified and deleted_at guards and raise a ForbiddenException (e.g.,
code="ACCOUNT_INACTIVE", message="This account is inactive.") when not active so
behavior matches refresh_token().
- Around line 170-177: The forgot_password flow is incomplete:
forgot_password(email, frontend_base_url) (which calls get_user_by_email)
generates a UUID token and embeds it in a link but never persists the token nor
provides a backend to redeem it; either implement token storage and a reset
endpoint or remove the method. Fix by persisting the token (e.g., in Redis or
DB) keyed to user.id with an expiry when generating the UUID in forgot_password,
send the same stored token in the reset link, and implement a POST
/reset-password handler (accepting user_id, token, new_password) that validates
the stored token (similar to verify-email token validation), updates the user
password, and invalidates/deletes the token; alternatively, if password reset is
out of scope, delete the forgot_password method and any email-sending that
references reset-password to avoid dangling links.

In `@app/auth/token_store.py`:
- Around line 95-99: The warning logs raw email when revoking tokens
(logger.warning in token_store.py using keys_to_delete and email); sanitize the
dynamic email field before logging to prevent log-injection and unsafe content —
implement or call a sanitizer (e.g., strip/control-char removal, newline
replacement, and optional masking/truncation) to produce sanitized_email and use
that in the logger.warning call instead of the raw email while keeping
len(keys_to_delete) unchanged; ensure the sanitizer is applied wherever email is
logged during revocation.

In `@app/core/config.py`:
- Line 19: The API prefix constant API_V1_STR in app/core/config.py is set to
"/routers/v1" which breaks the expected contract; change the value of API_V1_STR
to "/api/v1" so all route registration and auth cookie path scope use the
correct prefix (update any references to API_V1_STR if necessary to pick up the
new value).

In `@app/core/security.py`:
- Around line 135-136: Update the stale docstring reference that currently
points to app.schemas.user.RefreshTokenClaims: change it to
app.auth.schemas.RefreshTokenClaims in the docstring inside app/core/security.py
so the :class: cross-reference matches the new module layout and correctly links
the RefreshTokenClaims model.

In `@app/services/email_consumer.py`:
- Line 67: The Mailgun HTTP Basic Auth username is incorrect: replace the tuple
auth=("routers", settings.MAILGUN_API_KEY) with auth=("api",
settings.MAILGUN_API_KEY) where the Mailgun request is made (look for the
request in email_consumer.py — e.g., the function that posts to
/v3/{domain}/messages or any send_email/send_mail method) so the API uses the
required username "api" and the existing MAILGUN_API_KEY as the password.

In `@docker-compose.yml`:
- Line 42: The healthcheck command references a non-existent script name; update
the service healthcheck test that currently runs
"/opt/kafka/bin/kafka-broker-routers-versions.sh --bootstrap-server
localhost:9092" to use the correct Kafka script
"/opt/kafka/bin/kafka-broker-api-versions.sh --bootstrap-server localhost:9092"
(keep the CMD-SHELL wrapper and same bootstrap-server argument) so the Kafka
container can report healthy and allow dependent services like kafka-ui to
start.

In `@tests/test_auth/test_auth_refresh.py`:
- Line 185: The test constant _URL in tests/test_auth/test_auth_refresh.py is
set to "/api/v1/auth/refresh-token" but the test docstring and actual route
expect "/routers/v1/auth/refresh-token"; update the _URL value to
"/routers/v1/auth/refresh-token" so the test hits the correct endpoint (verify
other tests or fixtures don’t rely on the old path and update them if they do).

---

Outside diff comments:
In `@app/main.py`:
- Line 59: The API base path is misconfigured: settings.API_V1_STR is set to
"/routers/v1" while routes (auth_router included into api_router and mounted via
app.include_router(api_router, prefix=settings.API_V1_STR)) are expected by
tests at "/api/v1"; update the configuration by changing settings.API_V1_STR to
"/api/v1" (so the effective route becomes "/api/v1/auth/*") and keep
auth_router's prefix="/auth" and the app.include_router(api_router,
prefix=settings.API_V1_STR) usage unchanged.

In `@tests/test_auth/test_auth_login.py`:
- Around line 221-223: Tests are calling the wrong route prefix; update all test
request URLs that use "/api/v1/auth/..." to the configured prefix
"/routers/v1/auth/..." so requests hit the real routes; search for occurrences
in tests/test_auth/test_auth_login.py (examples around the client.post calls
shown with json={"email": "user@example.com", "password": "MyStr0ngP@ss!"}) and
replace "/api/v1/" with "/routers/v1/" for every listed occurrence (lines
referenced in the review: 221, 238, 252, 269, 288, 302, 316, 319, 336, 358, 386,
401–402, 407–408, 425–426, 431–432).

---

Nitpick comments:
In `@app/auth/router.py`:
- Around line 199-206: The code lazily imports UnauthorizedException inside the
handler where raw_token = request.cookies.get("refresh_token") leading to
unconventional in-function imports; move the import of UnauthorizedException to
the module-level imports at the top of app/auth/router.py (alongside other
imports) and remove the inner "from app.core.exceptions import
UnauthorizedException" inside the function so the raise
UnauthorizedException(...) uses the top-level symbol.

In `@app/auth/verification.py`:
- Around line 86-95: Replace the fetch-and-loop deletion with a single bulk
DELETE: instead of executing the SELECT stored in statement_del, build and
execute a delete() against VerificationToken with the same filters
(VerificationToken.user_id == user.id and VerificationToken.expires_at >= now),
run it via self.db.execute(...) and call self.db.commit() if the delete affected
rows (use the returned result.rowcount to decide). Update the code that
currently uses tokens = self.db.execute(statement_del).scalars().all() and the
for t in tokens loop to use this bulk delete approach.

In `@pyproject.toml`:
- Line 49: Remove the global ignore for B008 in pyproject.toml (the line ignore
= ["B008"]) and instead re-enable the B008 check repository-wide; then add
targeted suppressions (use per-file configuration or inline comments like "#
noqa: B008") only in the specific modules or function definitions that
legitimately require mutable default arguments (e.g., the files/functions
following your framework patterns), so B008 remains active except where
explicitly and locally suppressed.

In `@tests/test_auth/test_auth_login.py`:
- Around line 34-100: Move the FakeRedis and FakePipeline classes out of
tests/test_auth/test_auth_login.py into a shared test utility (e.g.,
tests/conftest.py or tests/fakes.py), keeping all methods and signatures
(FakeRedis: __init__, set, get, delete, exists, incr, scan, pipeline, reset;
FakePipeline: __init__, delete, execute) intact; then update test_auth_login.py
and the duplicate file (test_auth_refresh.py) to import the shared FakeRedis and
FakePipeline instead of defining them inline so both tests use the same,
centralized test doubles.

In `@tests/test_auth/test_auth_refresh.py`:
- Around line 312-335: Add a new test in the TestRefreshTokenInvalid class that
seeds a user with _seed_user(db_session), then creates a refresh token that is
already expired (e.g., use _SECURITY.create_refresh_token with an expiry in the
past or create then tamper the exp claim) and POSTs it to _URL as the
"refresh_token" cookie; assert the response.status_code == 401 and
response.json()["code"] == "INVALID_REFRESH_TOKEN". Reference
TestRefreshTokenInvalid, _SECURITY.create_refresh_token (or the helper that
builds tokens), _seed_user, and _URL when locating where to add the test.
- Around line 234-238: Replace the legacy
asyncio.get_event_loop().run_until_complete call with asyncio.run to execute the
coroutine; specifically call
asyncio.run(token_store.save_refresh_token(email=email, jti=jti,
ttl_seconds=ttl)) in place of the current run_until_complete usage, or
alternatively convert the test to an async test using pytest-asyncio and await
token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl) inside the
async test function.
🪄 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: 9b47e604-edeb-474b-a312-9991caf768a9

📥 Commits

Reviewing files that changed from the base of the PR and between 07b6985 and 29096fe.

📒 Files selected for processing (62)
  • .github/issue-labeler.yml
  • README.md
  • alembic/env.py
  • alembic/versions/092f1a489931_add_meeting_model.py
  • alembic_output.txt
  • app/api/v1/__init__.py
  • app/api/v1/endpoints/auth.py
  • app/auth/__init__.py
  • app/auth/account_lockout.py
  • app/auth/constants.py
  • app/auth/dependencies.py
  • app/auth/models.py
  • app/auth/router.py
  • app/auth/schemas.py
  • app/auth/service.py
  • app/auth/token_store.py
  • app/auth/utils.py
  • app/auth/verification.py
  • app/core/config.py
  • app/core/security.py
  • app/crud/__init__.py
  • app/crud/auth_verification_api.md
  • app/crud/user/user.py
  • app/crud/verification_token.py
  • app/external_services/__init__.py
  • app/external_services/cloudinary/__init__.py
  • app/external_services/mailgun/__init__.py
  • app/main.py
  • app/models/__init__.py
  • app/models/base.py
  • app/models/user.py
  • app/models/verification_token.py
  • app/routers/__init__.py
  • app/routers/api.py
  • app/schemas/auth.py
  • app/services/email_consumer.py
  • app/services/token_store.py
  • app/user/__init__.py
  • app/user/config.py
  • app/user/constants.py
  • app/user/dependencies.py
  • app/user/exceptions.py
  • app/user/models.py
  • app/user/router.py
  • app/user/schemas.py
  • app/user/service.py
  • app/user/utils.py
  • app/utils/__init__.py
  • app/utils/authentication.py
  • app/utils/validation.py
  • docker-compose.yml
  • fail_log.txt
  • pyproject.toml
  • requirements/base.txt
  • requirements/dev.txt
  • requirements/prod.txt
  • tests/test_auth/test_auth_login.py
  • tests/test_auth/test_auth_refresh.py
  • tests/test_auth/test_auth_signup.py
  • tests/test_auth/test_email_verification.py
  • tests/test_auth/test_schemas_user.py
  • tests/test_auth/test_verification_token_crud.py
💤 Files with no reviewable changes (12)
  • app/crud/init.py
  • alembic_output.txt
  • app/api/v1/init.py
  • app/crud/auth_verification_api.md
  • fail_log.txt
  • app/models/verification_token.py
  • app/crud/user/user.py
  • app/models/user.py
  • app/crud/verification_token.py
  • app/services/token_store.py
  • app/schemas/auth.py
  • app/api/v1/endpoints/auth.py

Comment thread .github/issue-labeler.yml Outdated
Comment on lines +21 to +32
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###


def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
pass
# ### end Alembic commands ###
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Migration is a no-op despite claiming to add a model.

Line 21–Line 32 contains only pass, so this revision applies no schema change. If a meeting model is intended, add explicit DDL; if not, remove this revision to avoid misleading migration history.

Would you like me to draft the op.create_table(...) / op.drop_table(...) skeleton for this migration?

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@alembic/versions/092f1a489931_add_meeting_model.py` around lines 21 - 32, The
revision currently does nothing: replace the pass in upgrade() with a proper
op.create_table call to create the meeting table (e.g., include at minimum an
Integer primary key column "id", appropriate columns like "title" (String),
"start_time"/"end_time" (DateTime), audit timestamps and any FK(s) if needed)
and add the corresponding op.drop_table("meeting") in downgrade(); ensure you
import and use alembic.op and sqlalchemy types and match your project's naming
(refer to upgrade(), downgrade(), op.create_table and op.drop_table to locate
where to add these DDL changes).

Comment thread app/auth/service.py
Comment on lines +129 to +141
# Guard: email verified?
if not user.is_verified:
raise ForbiddenException(
code="EMAIL_NOT_VERIFIED",
message="Please verify your email before logging in.",
)

# Guard: soft-deleted?
if user.deleted_at is not None:
raise ForbiddenException(
code="ACCOUNT_DELETED",
message="This account has been deleted.",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Missing is_active check creates inconsistency with refresh_token().

The login method checks is_verified and deleted_at but does not check is_active. However, refresh_token() at line 220 rejects users where not user.is_active. This inconsistency allows a deactivated user to log in successfully but fail on their first token refresh.

Consider adding the is_active check here for consistent behavior:

Proposed fix
         # Guard: soft-deleted?
         if user.deleted_at is not None:
             raise ForbiddenException(
                 code="ACCOUNT_DELETED",
                 message="This account has been deleted.",
             )
+
+        # Guard: deactivated?
+        if not user.is_active:
+            raise ForbiddenException(
+                code="ACCOUNT_DEACTIVATED",
+                message="This account has been deactivated.",
+            )
 
         # Reset failed-login counter on success
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 129 - 141, The login flow lacks the same
active-account guard used in refresh_token(), causing deactivated users to
succeed at login but fail on refresh; update the login function to check
user.is_active after the existing is_verified and deleted_at guards and raise a
ForbiddenException (e.g., code="ACCOUNT_INACTIVE", message="This account is
inactive.") when not active so behavior matches refresh_token().

Comment thread app/auth/service.py
Comment on lines +170 to +177
async def forgot_password(self, email: str, frontend_base_url: str) -> None:
user = self.get_user_by_email(email)
if not user:
return

reset_link = (
f"{frontend_base_url}/reset-password?user={user.id}&token={uuid.uuid4()}"
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check if there's a password reset endpoint that validates the token
rg -n "reset-password|reset_password" --type py -g '!*test*' -C 3

# Check if there's a password reset token storage mechanism
rg -n "reset.*token|password.*token" --type py -g '!*test*' -C 2

Repository: Brints/FluentMeet

Length of output: 1520


🏁 Script executed:

# Read the complete forgot_password method
head -n 195 app/auth/service.py | tail -n 30

Repository: Brints/FluentMeet

Length of output: 1042


🏁 Script executed:

# Search for password reset endpoint in router files
find app -name "*.py" -type f | xargs grep -l "reset.*password\|password.*reset" | grep -i router

Repository: Brints/FluentMeet

Length of output: 79


🏁 Script executed:

# Look for token validation or reset endpoint implementation
rg -n "@.*post|@.*put|@.*patch" app/auth/router.py -A 5 | head -n 100

Repository: Brints/FluentMeet

Length of output: 1032


🏁 Script executed:

# Check for any Redis or database storage of reset tokens
rg -n "redis|cache|token.*store|store.*token" --type py | grep -i reset

Repository: Brints/FluentMeet

Length of output: 43


🏁 Script executed:

# Look for all POST endpoints in auth router
rg -n "^@router\." app/auth/router.py -A 10 | grep -E "post|put|patch" -A 8

Repository: Brints/FluentMeet

Length of output: 1911


🏁 Script executed:

# Search for any endpoint that might handle the actual password reset
rg -n "reset|password" app/auth/router.py -B 2 -A 8

Repository: Brints/FluentMeet

Length of output: 1410


🏁 Script executed:

# Check the complete auth router file to see all endpoints
cat -n app/auth/router.py | head -n 250

Repository: Brints/FluentMeet

Length of output: 8263


🏁 Script executed:

# Look for token validation in the codebase
rg -n "uuid.*valid|valid.*uuid|token.*valid" --type py

Repository: Brints/FluentMeet

Length of output: 1437


🏁 Script executed:

# Check if there's a reset-password endpoint in other routers
find app -name "*.py" -type f | xargs grep -l "reset.*password\|password.*reset" | xargs basename -a

Repository: Brints/FluentMeet

Length of output: 91


🏁 Script executed:

# List all routers to ensure we haven't missed any
find app -name "*router*.py" -o -name "*route*.py" -type f

Repository: Brints/FluentMeet

Length of output: 98


🏁 Script executed:

# Check user router for password reset endpoint
rg -n "reset|password" app/user/router.py -B 2 -A 8

Repository: Brints/FluentMeet

Length of output: 43


🏁 Script executed:

# Double-check: search entire app for any endpoint that accepts reset token
rg -n "token.*query|Query.*token" --type py app/ -A 3

Repository: Brints/FluentMeet

Length of output: 43


🏁 Script executed:

# List all endpoints in auth router to be absolutely certain
rg -n "^@router\." app/auth/router.py | cut -d: -f2-

Repository: Brints/FluentMeet

Length of output: 143


🏁 Script executed:

# Check if there's any mention of password reset validation in the entire codebase
rg -n "reset.*valid|validate.*reset" --type py

Repository: Brints/FluentMeet

Length of output: 43


🏁 Script executed:

# Final check: verify all endpoints and their HTTP methods
cat -n app/auth/router.py | grep -E "@router\.|^[0-9]+-async def|^[0-9]+-def " | head -n 30

Repository: Brints/FluentMeet

Length of output: 185


Password reset feature is non-functional: token is not stored and no reset endpoint exists.

The UUID reset token at line 176 is embedded in the email link but is never persisted (no Redis, database, or cache storage). More critically, there is no backend endpoint to handle password reset form submission—users can receive the email but have nowhere to submit their new password.

Compare with /verify-email (which validates stored tokens) or refresh token rotation (which uses Redis): password reset is incomplete. Either implement token storage and a reset endpoint (e.g., POST /reset-password accepting user_id, token, and new_password), or if password reset is out of scope, remove the forgot_password method entirely to avoid confusion.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 170 - 177, The forgot_password flow is
incomplete: forgot_password(email, frontend_base_url) (which calls
get_user_by_email) generates a UUID token and embeds it in a link but never
persists the token nor provides a backend to redeem it; either implement token
storage and a reset endpoint or remove the method. Fix by persisting the token
(e.g., in Redis or DB) keyed to user.id with an expiry when generating the UUID
in forgot_password, send the same stored token in the reset link, and implement
a POST /reset-password handler (accepting user_id, token, new_password) that
validates the stored token (similar to verify-email token validation), updates
the user password, and invalidates/deletes the token; alternatively, if password
reset is out of scope, delete the forgot_password method and any email-sending
that references reset-password to avoid dangling links.

Comment thread app/auth/token_store.py
Comment thread app/core/config.py Outdated
Comment thread app/core/security.py Outdated
Comment thread app/services/email_consumer.py Outdated
Comment thread docker-compose.yml Outdated
Comment thread tests/test_auth/test_auth_refresh.py
Signed-off-by: aniebietafia <aniebietafia87@gmail.com>
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.

♻️ Duplicate comments (1)
tests/test_auth/test_auth_refresh.py (1)

185-185: ⚠️ Potential issue | 🔴 Critical

Fix stale refresh endpoint URL constant (currently causing suite-wide 404s).

Line 185 points tests to a route that is not mounted in the current app config, so the refresh handler never executes. This is the root cause of the 404 cascade (including the downstream “old JTI still valid” failure).

Proposed fix
-_URL = "/api/v1/auth/refresh-token"
+_URL = "/routers/v1/auth/refresh-token"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` at line 185, The test constant _URL
points at a stale route and must be updated to the app's actual refresh
endpoint: check the live route mounted by the auth refresh handler (e.g.,
inspect the app/router or the auth route declarations) and replace the _URL
value in tests/test_auth/test_auth_refresh.py with that exact path so the
refresh handler is invoked (ensure the string matches the handler's registered
path exactly).
🧹 Nitpick comments (1)
tests/test_auth/test_auth_refresh.py (1)

268-272: Strengthen cookie assertions to cover required security attributes.

The test currently checks HttpOnly and SameSite, but it should also assert Secure, Path, and Max-Age so regressions against the refresh-cookie contract are caught.

Suggested test enhancement
         set_cookie = response.headers.get("set-cookie", "")
         assert "refresh_token=" in set_cookie
         assert "HttpOnly" in set_cookie
         assert "SameSite=strict" in set_cookie
+        assert "Secure" in set_cookie
+        assert "Max-Age=604800" in set_cookie
+        expected_cookie_path = _URL.removesuffix("/refresh-token")
+        assert f"Path={expected_cookie_path}" in set_cookie
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` around lines 268 - 272, Extend the
cookie assertions in tests/test_auth/test_auth_refresh.py where set_cookie is
extracted (set_cookie = response.headers.get("set-cookie", "")) to also assert
the required security attributes: check that "Secure" is present, that the
cookie exposes a path (e.g. "Path=/"), and that it includes an expiration
directive by asserting "Max-Age=" appears; use the existing set_cookie string
(and consider case-insensitive matching if needed) rather than changing response
access code.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@tests/test_auth/test_auth_refresh.py`:
- Line 185: The test constant _URL points at a stale route and must be updated
to the app's actual refresh endpoint: check the live route mounted by the auth
refresh handler (e.g., inspect the app/router or the auth route declarations)
and replace the _URL value in tests/test_auth/test_auth_refresh.py with that
exact path so the refresh handler is invoked (ensure the string matches the
handler's registered path exactly).

---

Nitpick comments:
In `@tests/test_auth/test_auth_refresh.py`:
- Around line 268-272: Extend the cookie assertions in
tests/test_auth/test_auth_refresh.py where set_cookie is extracted (set_cookie =
response.headers.get("set-cookie", "")) to also assert the required security
attributes: check that "Secure" is present, that the cookie exposes a path (e.g.
"Path=/"), and that it includes an expiration directive by asserting "Max-Age="
appears; use the existing set_cookie string (and consider case-insensitive
matching if needed) rather than changing response access code.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: cb0d8f76-b4d1-4f67-b9ee-056a5c323b98

📥 Commits

Reviewing files that changed from the base of the PR and between 29096fe and 1ddeda3.

📒 Files selected for processing (3)
  • app/auth/token_store.py
  • app/core/security.py
  • tests/test_auth/test_auth_refresh.py

@aniebietafia aniebietafia force-pushed the feat/auth-refresh-token branch from 1ddeda3 to 19df2dc Compare March 29, 2026 15:49
Comment thread alembic/versions/7f066a8213a8_change_user_id_to_uuid.py Fixed
Comment thread alembic/versions/7f066a8213a8_change_user_id_to_uuid.py Fixed
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: 9

♻️ Duplicate comments (3)
app/services/email_consumer.py (1)

67-67: ⚠️ Potential issue | 🔴 Critical

Use Mailgun’s required Basic Auth username (api) on Line 67.

auth=("routers", settings.MAILGUN_API_KEY) will fail against Mailgun /v3/{domain}/messages; the username must be api.

Suggested fix
-                auth=("routers", settings.MAILGUN_API_KEY),
+                auth=("api", settings.MAILGUN_API_KEY),
For Mailgun API POST /v3/{domain}/messages, what HTTP Basic Auth username is required?
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/services/email_consumer.py` at line 67, The Mailgun API call in
email_consumer.py uses the wrong Basic Auth username; change the auth tuple used
when sending messages (currently auth=("routers", settings.MAILGUN_API_KEY)) to
use the required username "api" so it reads auth=("api",
settings.MAILGUN_API_KEY); update the auth tuple wherever send/post to Mailgun
is performed (look for the auth= argument in the Mailgun POST call) and run
tests or a quick send to verify the request is accepted.
app/auth/service.py (2)

136-145: ⚠️ Potential issue | 🟠 Major

Reject inactive users during login too.

refresh_token() already blocks not user.is_active, but login() still issues fresh tokens after the deleted check. A deactivated account can therefore authenticate successfully and keep using the new access token until it expires.

Suggested fix
         if user.deleted_at is not None:
             raise ForbiddenException(
                 code="ACCOUNT_DELETED",
                 message="This account has been deleted.",
             )
+
+        if not user.is_active:
+            raise ForbiddenException(
+                code="ACCOUNT_DEACTIVATED",
+                message="This account has been deactivated.",
+            )
 
         # Reset failed-login counter on success
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 136 - 145, In login(), add the same
active-account guard used in refresh_token(): after retrieving the user and
before resetting attempts or issuing tokens, check if not user.is_active and
raise a ForbiddenException (e.g., code "ACCOUNT_INACTIVE", message "This account
is deactivated.") so deactivated accounts cannot obtain new tokens; ensure this
check is placed alongside the existing deleted_at check and occurs before
calling lockout_svc.reset_attempts(email) and token creation.

170-177: ⚠️ Potential issue | 🔴 Critical

Persist the password-reset token before emailing it.

The UUID in reset_link is generated and discarded. Without storing it (or a hash of it) with an expiry, a later reset handler has nothing authoritative to validate, so this flow cannot be completed safely.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 170 - 177, In forgot_password (service.py)
you currently generate a UUID for the reset_link but never persist it; change
the flow to generate a cryptographically secure token (e.g.,
secrets.token_urlsafe or uuid4), store a hashed version plus an expiry timestamp
on the user record or a dedicated PasswordResetToken entity (fields like
reset_password_token_hash and reset_password_expires), persist the change
(save/commit) before sending the email, and invalidate any previous tokens for
that user; keep using the plain token in the emailed link but ensure the reset
handler (e.g., the function that handles reset-password) validates against the
stored hash and checks expiry.
🧹 Nitpick comments (1)
app/core/security.py (1)

14-23: Reasonable workaround for passlib/bcrypt compatibility.

The shim for bcrypt.__about__.__version__ addresses a known issue with passlib 1.7.4 not recognizing bcrypt 4.0+ versions. The try/except pattern safely injects the stub only when needed.

Consider adding a brief comment linking to the upstream issue or noting when this workaround can be removed (e.g., when passlib releases a fix).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/core/security.py` around lines 14 - 23, Add a short explanatory comment
above the bcrypt shim that references the upstream Passlib/bcrypt compatibility
issue and states the removal condition; specifically, mention passlib 1.7.4 and
bcrypt 4.0+ incompatibility, include a URL or issue number to the upstream
bugtracker, and note "remove when passlib fixed (>= version X or PR/issue
closed)"; place the comment next to the try/except and reference the injected
symbols bcrypt.__about__ and the _BcryptAbout stub so future maintainers know
why the shim exists and when it can be removed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@alembic/versions/7f066a8213a8_change_user_id_to_uuid.py`:
- Around line 56-72: The downgrade() is incomplete: before altering types back
to INTEGER you must drop the foreign-key constraint on
verification_tokens.user_id (the same FK removed in upgrade()), then alter
verification_tokens.user_id and users.id back to sa.INTEGER(), and restore the
integer sequence/default on users.id (recreate the users_id sequence if needed
and set users.id server_default to nextval('users_id_seq'::regclass) or
equivalent). After the column type changes, re-create the FK constraint linking
verification_tokens.user_id to users.id. Update the downgrade() function to
perform these steps in this order so it mirrors the upgrade() logic.

In `@app/api/v1/endpoints/auth.py`:
- Around line 489-491: The calls to token_store.is_refresh_token_valid(...) and
token_store.revoke_all_user_tokens(...) use a keyword argument email= but the
TokenStoreService methods likely don't accept that name; update the
TokenStoreService method signatures (is_refresh_token_valid and
revoke_all_user_tokens) to accept email as a keyword parameter (e.g., def
is_refresh_token_valid(self, *, email: str, jti: str) / def
revoke_all_user_tokens(self, *, email: str)) or modify the calls to pass email
as a positional argument (token_store.is_refresh_token_valid(email, old_jti) /
token_store.revoke_all_user_tokens(email)) so the parameter names and calling
style match between the caller and the TokenStoreService implementation.
- Around line 365-368: The import path and method signatures for the token store
are wrong: replace the import of TokenStoreService/get_token_store_service with
the correct module (app.auth.token_store) and then fix all calls that pass email
to token store methods (e.g., save_refresh_token, revoke_refresh_token,
is_refresh_token_valid) by removing the email argument to match their signatures
(save_refresh_token(jti, ttl_seconds), revoke_refresh_token(jti),
is_refresh_token_valid(jti)); also remove or implement any nonexistent method
calls such as revoke_all_user_tokens (either call an existing API or add a
correctly named method to TokenStoreService) and update usages at the identified
call sites (the token_store variable and affected lines like the
save_refresh_token and revoke calls).

In `@app/auth/router.py`:
- Around line 199-206: The inline import of UnauthorizedException inside the
function should be moved to module-level imports: add "from app.core.exceptions
import UnauthorizedException" to the top of app/auth/router.py and remove the
inline import from the body where raw_token =
request.cookies.get("refresh_token") is checked; then keep the existing raise
UnauthorizedException(...) call unchanged so the function uses the top-level
symbol instead of importing it each call.

In `@app/auth/service.py`:
- Around line 156-159: The calls in app/auth/service.py use an email-scoped
TokenStoreService API that doesn't exist; update the callers
(save_refresh_token, is_refresh_token_valid, revoke_refresh_token) to match the
actual TokenStoreService signatures by removing the email= keyword and passing
the expected positional/keyword args (e.g., pass jti and ttl_seconds or only jti
as the real API requires) and replace the non-existent revoke_all_user_tokens
call with either the service's provided method for bulk revocation or implement
a loop using the existing revoke_refresh_token(jti) API; locate usages by the
function names save_refresh_token, is_refresh_token_valid, revoke_refresh_token,
and revoke_all_user_tokens in app/auth/service.py (around the reported blocks)
and adapt each call to the real TokenStoreService method signatures.
- Around line 208-226: The refresh-token rotation is vulnerable because
is_refresh_token_valid(...) and revoke_refresh_token(...) are separate awaits;
replace them with a single atomic call on the token store (e.g., implement and
call consume_refresh_token(email, jti) on token_store) that performs the
check-and-delete in one Redis operation (Lua script/EVAL/EVALSHA) and returns
success/failure; update the service to call await
self.token_store.consume_refresh_token(email=email, jti=old_jti) and if it
returns False then call revoke_all_user_tokens(email) and raise the
REFRESH_TOKEN_REUSE UnauthorizedException, otherwise continue normal flow,
leaving get_user_by_email(...) and subsequent logic unchanged.

In `@tests/test_auth/test_auth_login.py`:
- Line 1: The module docstring currently says "POST /routers/v1/auth/login" but
the tests actually call "/api/v1/auth/login"; update the top-level docstring in
tests/test_auth/test_auth_login.py to state the correct endpoint
"/api/v1/auth/login" so it matches the requests used in the test functions
(e.g., the POST calls to "/api/v1/auth/login").

In `@tests/test_auth/test_auth_refresh.py`:
- Around line 312-334: Add a test method to TestRefreshTokenInvalid that seeds
the user, builds a validly signed refresh token whose exp is already past (e.g.
call _SECURITY.create_refresh_token(email="refresh@example.com",
expires_delta=timedelta(seconds=-1)) or otherwise inject an "exp" in the past),
send it as the "refresh_token" cookie to client.post(_URL), and assert
response.status_code == 401 and response.json()["code"] ==
"INVALID_REFRESH_TOKEN"; place the new test alongside
test_returns_401_for_garbage_token and
test_returns_401_for_access_token_used_as_refresh so it exercises the
expired-token decoder branch.
- Around line 248-272: The test test_sets_new_httponly_refresh_cookie only
asserts partial cookie attributes; update it to assert the full refresh cookie
contract by checking that response.headers.get("set-cookie") from
client.post(_URL, cookies={"refresh_token": raw_token}) contains
"refresh_token=", "HttpOnly", "SameSite=strict", "Secure", "Path=/api/v1/auth",
and "Max-Age=604800" (or the ttl variable if dynamic) so all required flags are
validated in a single assertion block.

---

Duplicate comments:
In `@app/auth/service.py`:
- Around line 136-145: In login(), add the same active-account guard used in
refresh_token(): after retrieving the user and before resetting attempts or
issuing tokens, check if not user.is_active and raise a ForbiddenException
(e.g., code "ACCOUNT_INACTIVE", message "This account is deactivated.") so
deactivated accounts cannot obtain new tokens; ensure this check is placed
alongside the existing deleted_at check and occurs before calling
lockout_svc.reset_attempts(email) and token creation.
- Around line 170-177: In forgot_password (service.py) you currently generate a
UUID for the reset_link but never persist it; change the flow to generate a
cryptographically secure token (e.g., secrets.token_urlsafe or uuid4), store a
hashed version plus an expiry timestamp on the user record or a dedicated
PasswordResetToken entity (fields like reset_password_token_hash and
reset_password_expires), persist the change (save/commit) before sending the
email, and invalidate any previous tokens for that user; keep using the plain
token in the emailed link but ensure the reset handler (e.g., the function that
handles reset-password) validates against the stored hash and checks expiry.

In `@app/services/email_consumer.py`:
- Line 67: The Mailgun API call in email_consumer.py uses the wrong Basic Auth
username; change the auth tuple used when sending messages (currently
auth=("routers", settings.MAILGUN_API_KEY)) to use the required username "api"
so it reads auth=("api", settings.MAILGUN_API_KEY); update the auth tuple
wherever send/post to Mailgun is performed (look for the auth= argument in the
Mailgun POST call) and run tests or a quick send to verify the request is
accepted.

---

Nitpick comments:
In `@app/core/security.py`:
- Around line 14-23: Add a short explanatory comment above the bcrypt shim that
references the upstream Passlib/bcrypt compatibility issue and states the
removal condition; specifically, mention passlib 1.7.4 and bcrypt 4.0+
incompatibility, include a URL or issue number to the upstream bugtracker, and
note "remove when passlib fixed (>= version X or PR/issue closed)"; place the
comment next to the try/except and reference the injected symbols
bcrypt.__about__ and the _BcryptAbout stub so future maintainers know why the
shim exists and when it can be removed.
🪄 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: 6db09f04-e81f-4df6-9ab8-1ba579cad085

📥 Commits

Reviewing files that changed from the base of the PR and between 1ddeda3 and 19df2dc.

📒 Files selected for processing (60)
  • README.md
  • alembic/env.py
  • alembic/versions/092f1a489931_add_meeting_model.py
  • alembic/versions/7f066a8213a8_change_user_id_to_uuid.py
  • alembic/versions/e1a664780dc6_add_meeting_model.py
  • alembic_output.txt
  • app/api/v1/__init__.py
  • app/api/v1/endpoints/auth.py
  • app/auth/__init__.py
  • app/auth/account_lockout.py
  • app/auth/constants.py
  • app/auth/dependencies.py
  • app/auth/models.py
  • app/auth/router.py
  • app/auth/schemas.py
  • app/auth/service.py
  • app/auth/token_store.py
  • app/auth/utils.py
  • app/auth/verification.py
  • app/core/security.py
  • app/crud/__init__.py
  • app/crud/auth_verification_api.md
  • app/crud/user/user.py
  • app/crud/verification_token.py
  • app/external_services/__init__.py
  • app/external_services/cloudinary/__init__.py
  • app/external_services/mailgun/__init__.py
  • app/main.py
  • app/models/__init__.py
  • app/models/base.py
  • app/models/user.py
  • app/models/verification_token.py
  • app/routers/__init__.py
  • app/routers/api.py
  • app/schemas/auth.py
  • app/services/email_consumer.py
  • app/user/__init__.py
  • app/user/config.py
  • app/user/constants.py
  • app/user/dependencies.py
  • app/user/exceptions.py
  • app/user/models.py
  • app/user/router.py
  • app/user/schemas.py
  • app/user/service.py
  • app/user/utils.py
  • app/utils/__init__.py
  • app/utils/authentication.py
  • app/utils/validation.py
  • fail_log.txt
  • pyproject.toml
  • requirements/base.txt
  • requirements/dev.txt
  • requirements/prod.txt
  • tests/test_auth/test_auth_login.py
  • tests/test_auth/test_auth_refresh.py
  • tests/test_auth/test_auth_signup.py
  • tests/test_auth/test_email_verification.py
  • tests/test_auth/test_schemas_user.py
  • tests/test_auth/test_verification_token_crud.py
💤 Files with no reviewable changes (9)
  • alembic_output.txt
  • app/crud/init.py
  • app/models/verification_token.py
  • app/crud/auth_verification_api.md
  • app/api/v1/init.py
  • app/crud/user/user.py
  • app/models/user.py
  • app/crud/verification_token.py
  • fail_log.txt
✅ Files skipped from review due to trivial changes (11)
  • README.md
  • app/models/base.py
  • app/routers/api.py
  • pyproject.toml
  • tests/test_auth/test_schemas_user.py
  • app/routers/init.py
  • app/auth/account_lockout.py
  • tests/test_auth/test_auth_signup.py
  • alembic/versions/e1a664780dc6_add_meeting_model.py
  • app/auth/schemas.py
  • app/auth/dependencies.py
🚧 Files skipped from review as they are similar to previous changes (3)
  • app/models/init.py
  • tests/test_auth/test_email_verification.py
  • app/auth/models.py

Comment on lines 56 to +72
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('verification_tokens', 'user_id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
op.alter_column('users', 'id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
op.alter_column(
"verification_tokens",
"user_id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
op.alter_column(
"users",
"id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Incomplete downgrade: FK constraint and default not handled.

The upgrade() function drops the FK constraint before altering column types and removes the integer default. However, downgrade() attempts to alter the columns back to INTEGER without:

  1. Dropping the FK constraint first (will fail due to type mismatch)
  2. Restoring the integer sequence/default on users.id

This will cause the downgrade to fail if ever executed.

🐛 Proposed fix to make downgrade symmetric with upgrade
 def downgrade() -> None:
     """Downgrade schema."""
     # ### commands auto generated by Alembic - please adjust! ###
+    # Drop foreign key constraint before altering column types
+    op.drop_constraint(
+        "verification_tokens_user_id_fkey", "verification_tokens", type_="foreignkey"
+    )
+
+    # Truncate to allow type conversion (UUIDs cannot be cast to integers)
+    op.execute("TRUNCATE TABLE users CASCADE")
+
     op.alter_column(
         "verification_tokens",
         "user_id",
         existing_type=sa.Uuid(),
         type_=sa.INTEGER(),
         existing_nullable=False,
     )
     op.alter_column(
         "users",
         "id",
         existing_type=sa.Uuid(),
         type_=sa.INTEGER(),
         existing_nullable=False,
     )
+
+    # Restore integer sequence default
+    op.execute("CREATE SEQUENCE IF NOT EXISTS users_id_seq OWNED BY users.id")
+    op.execute("ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq')")
+
+    # Re-add foreign key constraint
+    op.create_foreign_key(
+        "verification_tokens_user_id_fkey",
+        "verification_tokens",
+        "users",
+        ["user_id"],
+        ["id"],
+    )
     # ### end Alembic commands ###
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('verification_tokens', 'user_id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
op.alter_column('users', 'id',
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False)
op.alter_column(
"verification_tokens",
"user_id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
op.alter_column(
"users",
"id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
# Drop foreign key constraint before altering column types
op.drop_constraint(
"verification_tokens_user_id_fkey", "verification_tokens", type_="foreignkey"
)
# Truncate to allow type conversion (UUIDs cannot be cast to integers)
op.execute("TRUNCATE TABLE users CASCADE")
op.alter_column(
"verification_tokens",
"user_id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
op.alter_column(
"users",
"id",
existing_type=sa.Uuid(),
type_=sa.INTEGER(),
existing_nullable=False,
)
# Restore integer sequence default
op.execute("CREATE SEQUENCE IF NOT EXISTS users_id_seq OWNED BY users.id")
op.execute("ALTER TABLE users ALTER COLUMN id SET DEFAULT nextval('users_id_seq')")
# Re-add foreign key constraint
op.create_foreign_key(
"verification_tokens_user_id_fkey",
"verification_tokens",
"users",
["user_id"],
["id"],
)
# ### end Alembic commands ###
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@alembic/versions/7f066a8213a8_change_user_id_to_uuid.py` around lines 56 -
72, The downgrade() is incomplete: before altering types back to INTEGER you
must drop the foreign-key constraint on verification_tokens.user_id (the same FK
removed in upgrade()), then alter verification_tokens.user_id and users.id back
to sa.INTEGER(), and restore the integer sequence/default on users.id (recreate
the users_id sequence if needed and set users.id server_default to
nextval('users_id_seq'::regclass) or equivalent). After the column type changes,
re-create the FK constraint linking verification_tokens.user_id to users.id.
Update the downgrade() function to perform these steps in this order so it
mirrors the upgrade() logic.

Comment thread app/api/v1/endpoints/auth.py Outdated
Comment thread app/api/v1/endpoints/auth.py Outdated
Comment on lines +489 to +491
if not await token_store.is_refresh_token_valid(email=email, jti=old_jti):
# Potential theft: tear down ALL sessions for this user.
await token_store.revoke_all_user_tokens(email=email)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Same signature mismatch for is_refresh_token_valid and revoke_all_user_tokens.

These calls also pass email= as a keyword argument, but if the methods in TokenStoreService weren't updated to accept this parameter, they will fail at runtime.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/api/v1/endpoints/auth.py` around lines 489 - 491, The calls to
token_store.is_refresh_token_valid(...) and
token_store.revoke_all_user_tokens(...) use a keyword argument email= but the
TokenStoreService methods likely don't accept that name; update the
TokenStoreService method signatures (is_refresh_token_valid and
revoke_all_user_tokens) to accept email as a keyword parameter (e.g., def
is_refresh_token_valid(self, *, email: str, jti: str) / def
revoke_all_user_tokens(self, *, email: str)) or modify the calls to pass email
as a positional argument (token_store.is_refresh_token_valid(email, old_jti) /
token_store.revoke_all_user_tokens(email)) so the parameter names and calling
style match between the caller and the TokenStoreService implementation.

Comment thread app/auth/router.py
Comment on lines +199 to +206
raw_token = request.cookies.get("refresh_token")
if not raw_token:
from app.core.exceptions import UnauthorizedException

raise UnauthorizedException(
code="MISSING_REFRESH_TOKEN",
message="No refresh token provided.",
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Move inline import to module level.

The UnauthorizedException import is placed inside the function body at line 201. This should be moved to the top-level imports for consistency and to avoid repeated import overhead.

♻️ Suggested fix

Add to the imports at the top of the file:

from app.core.exceptions import UnauthorizedException

Then simplify the function:

     raw_token = request.cookies.get("refresh_token")
     if not raw_token:
-        from app.core.exceptions import UnauthorizedException
-
         raise UnauthorizedException(
             code="MISSING_REFRESH_TOKEN",
             message="No refresh token provided.",
         )
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/router.py` around lines 199 - 206, The inline import of
UnauthorizedException inside the function should be moved to module-level
imports: add "from app.core.exceptions import UnauthorizedException" to the top
of app/auth/router.py and remove the inline import from the body where raw_token
= request.cookies.get("refresh_token") is checked; then keep the existing raise
UnauthorizedException(...) call unchanged so the function uses the top-level
symbol instead of importing it each call.

Comment thread app/auth/service.py
Comment on lines +156 to +159
# Persist refresh JTI in Redis
await self.token_store.save_refresh_token(
email=email, jti=refresh_jti, ttl_seconds=refresh_ttl
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Align this caller with the actual TokenStoreService API.

CI is already failing here: the imported TokenStoreService does not accept email= on save_refresh_token(), is_refresh_token_valid(), or revoke_refresh_token(), and it does not define revoke_all_user_tokens(). Until app/auth/token_store.py is updated to the email-scoped contract—or these calls are switched to the real signature—login and refresh are blocked.

Also applies to: 208-209, 226-235

🧰 Tools
🪛 GitHub Actions: CI

[error] 157-157: mypy: Unexpected keyword argument "email" for "save_refresh_token" of "TokenStoreService" [call-arg]

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 156 - 159, The calls in app/auth/service.py
use an email-scoped TokenStoreService API that doesn't exist; update the callers
(save_refresh_token, is_refresh_token_valid, revoke_refresh_token) to match the
actual TokenStoreService signatures by removing the email= keyword and passing
the expected positional/keyword args (e.g., pass jti and ttl_seconds or only jti
as the real API requires) and replace the non-existent revoke_all_user_tokens
call with either the service's provided method for bulk revocation or implement
a loop using the existing revoke_refresh_token(jti) API; locate usages by the
function names save_refresh_token, is_refresh_token_valid, revoke_refresh_token,
and revoke_all_user_tokens in app/auth/service.py (around the reported blocks)
and adapt each call to the real TokenStoreService method signatures.

Comment thread app/auth/service.py
Comment on lines +208 to +226
if not await self.token_store.is_refresh_token_valid(email=email, jti=old_jti):
await self.token_store.revoke_all_user_tokens(email=email)
logger.warning(
"Refresh token reuse detected for %s — all sessions revoked.",
sanitize_log_args(email)[0],
)
raise UnauthorizedException(
code="REFRESH_TOKEN_REUSE",
message="Session has been invalidated. Please log in again.",
)

user = self.get_user_by_email(email)
if user is None or user.deleted_at is not None or not user.is_active:
raise ForbiddenException(
code="ACCOUNT_DEACTIVATED",
message="This account has been deactivated.",
)

await self.token_store.revoke_refresh_token(email=email, jti=old_jti)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Consume the old refresh token atomically.

is_refresh_token_valid() and revoke_refresh_token() are separate awaits. Two concurrent refresh requests using the same live token can both pass the validity check before either delete runs, producing two valid child refresh tokens from one parent and defeating single-use rotation.

Suggested direction
-        if not await self.token_store.is_refresh_token_valid(email=email, jti=old_jti):
+        if not await self.token_store.consume_refresh_token(email=email, jti=old_jti):
             await self.token_store.revoke_all_user_tokens(email=email)
             logger.warning(
                 "Refresh token reuse detected for %s — all sessions revoked.",
                 sanitize_log_args(email)[0],
             )
             raise UnauthorizedException(
                 code="REFRESH_TOKEN_REUSE",
                 message="Session has been invalidated. Please log in again.",
             )
@@
-        await self.token_store.revoke_refresh_token(email=email, jti=old_jti)

consume_refresh_token() needs to be implemented as one atomic Redis operation.

🧰 Tools
🪛 GitHub Actions: CI

[error] 208-208: mypy: Unexpected keyword argument "email" for "is_refresh_token_valid" of "TokenStoreService" [call-arg]


[error] 209-209: mypy: "TokenStoreService" has no attribute "revoke_all_user_tokens" [attr-defined]


[error] 226-226: mypy: Unexpected keyword argument "email" for "revoke_refresh_token" of "TokenStoreService" [call-arg]

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/auth/service.py` around lines 208 - 226, The refresh-token rotation is
vulnerable because is_refresh_token_valid(...) and revoke_refresh_token(...) are
separate awaits; replace them with a single atomic call on the token store
(e.g., implement and call consume_refresh_token(email, jti) on token_store) that
performs the check-and-delete in one Redis operation (Lua script/EVAL/EVALSHA)
and returns success/failure; update the service to call await
self.token_store.consume_refresh_token(email=email, jti=old_jti) and if it
returns False then call revoke_all_user_tokens(email) and raise the
REFRESH_TOKEN_REUSE UnauthorizedException, otherwise continue normal flow,
leaving get_user_by_email(...) and subsequent logic unchanged.

@@ -1,4 +1,4 @@
"""Integration tests for ``POST /api/v1/auth/login``."""
"""Integration tests for ``POST /routers/v1/auth/login``."""
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Fix inconsistent docstring.

The docstring states POST /routers/v1/auth/login but the actual test requests use /api/v1/auth/login (e.g., line 221). Update to match the actual endpoint path.

📝 Suggested fix
-"""Integration tests for ``POST /routers/v1/auth/login``."""
+"""Integration tests for ``POST /api/v1/auth/login``."""
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""Integration tests for ``POST /routers/v1/auth/login``."""
"""Integration tests for ``POST /api/v1/auth/login``."""
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_login.py` at line 1, The module docstring currently
says "POST /routers/v1/auth/login" but the tests actually call
"/api/v1/auth/login"; update the top-level docstring in
tests/test_auth/test_auth_login.py to state the correct endpoint
"/api/v1/auth/login" so it matches the requests used in the test functions
(e.g., the POST calls to "/api/v1/auth/login").

Comment on lines +248 to +272
def test_sets_new_httponly_refresh_cookie(
self,
client: TestClient,
db_session: Session,
token_store: TokenStoreService,
) -> None:
email = "refresh@example.com"
_seed_user(db_session, email=email)
raw_token, jti, ttl = _make_refresh_cookie(email)

import asyncio

asyncio.get_event_loop().run_until_complete(
token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl)
)

response = client.post(_URL, cookies={"refresh_token": raw_token})

assert response.status_code == 200
# TestClient exposes set-cookie as a header
set_cookie = response.headers.get("set-cookie", "")
assert "refresh_token=" in set_cookie
assert "HttpOnly" in set_cookie
assert "SameSite=strict" in set_cookie

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Assert the full refresh-cookie contract.

This test only checks HttpOnly and SameSite. The endpoint requirement also includes Secure, Path=/api/v1/auth, and Max-Age=604800, so a regression in any of those flags would still pass here.

Suggested fix
         assert response.status_code == 200
         # TestClient exposes set-cookie as a header
         set_cookie = response.headers.get("set-cookie", "")
+        lowered = set_cookie.lower()
         assert "refresh_token=" in set_cookie
-        assert "HttpOnly" in set_cookie
-        assert "SameSite=strict" in set_cookie
+        assert "httponly" in lowered
+        assert "secure" in lowered
+        assert "samesite=strict" in lowered
+        assert "path=/api/v1/auth" in lowered
+        assert "max-age=604800" in lowered
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_sets_new_httponly_refresh_cookie(
self,
client: TestClient,
db_session: Session,
token_store: TokenStoreService,
) -> None:
email = "refresh@example.com"
_seed_user(db_session, email=email)
raw_token, jti, ttl = _make_refresh_cookie(email)
import asyncio
asyncio.get_event_loop().run_until_complete(
token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl)
)
response = client.post(_URL, cookies={"refresh_token": raw_token})
assert response.status_code == 200
# TestClient exposes set-cookie as a header
set_cookie = response.headers.get("set-cookie", "")
assert "refresh_token=" in set_cookie
assert "HttpOnly" in set_cookie
assert "SameSite=strict" in set_cookie
def test_sets_new_httponly_refresh_cookie(
self,
client: TestClient,
db_session: Session,
token_store: TokenStoreService,
) -> None:
email = "refresh@example.com"
_seed_user(db_session, email=email)
raw_token, jti, ttl = _make_refresh_cookie(email)
import asyncio
asyncio.get_event_loop().run_until_complete(
token_store.save_refresh_token(email=email, jti=jti, ttl_seconds=ttl)
)
response = client.post(_URL, cookies={"refresh_token": raw_token})
assert response.status_code == 200
# TestClient exposes set-cookie as a header
set_cookie = response.headers.get("set-cookie", "")
lowered = set_cookie.lower()
assert "refresh_token=" in set_cookie
assert "httponly" in lowered
assert "secure" in lowered
assert "samesite=strict" in lowered
assert "path=/api/v1/auth" in lowered
assert "max-age=604800" in lowered
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` around lines 248 - 272, The test
test_sets_new_httponly_refresh_cookie only asserts partial cookie attributes;
update it to assert the full refresh cookie contract by checking that
response.headers.get("set-cookie") from client.post(_URL,
cookies={"refresh_token": raw_token}) contains "refresh_token=", "HttpOnly",
"SameSite=strict", "Secure", "Path=/api/v1/auth", and "Max-Age=604800" (or the
ttl variable if dynamic) so all required flags are validated in a single
assertion block.

Comment on lines +312 to +334
class TestRefreshTokenInvalid:
"""Tampered or expired tokens."""

def test_returns_401_for_garbage_token(
self, client: TestClient, db_session: Session
) -> None:
_seed_user(db_session)
response = client.post(_URL, cookies={"refresh_token": "this.is.garbage"})

assert response.status_code == 401
assert response.json()["code"] == "INVALID_REFRESH_TOKEN"

def test_returns_401_for_access_token_used_as_refresh(
self, client: TestClient, db_session: Session
) -> None:
"""An access token must not be accepted as a refresh token."""
_seed_user(db_session)
access_token, _ = _SECURITY.create_access_token(email="refresh@example.com")

response = client.post(_URL, cookies={"refresh_token": access_token})

assert response.status_code == 401
assert response.json()["code"] == "INVALID_REFRESH_TOKEN"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add the expired-refresh-token case.

The suite covers garbage JWTs and using an access token as a refresh token, but not a validly signed refresh token whose exp is already in the past. That was an explicit requirement and exercises a different decoder branch than the cases above.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_auth/test_auth_refresh.py` around lines 312 - 334, Add a test
method to TestRefreshTokenInvalid that seeds the user, builds a validly signed
refresh token whose exp is already past (e.g. call
_SECURITY.create_refresh_token(email="refresh@example.com",
expires_delta=timedelta(seconds=-1)) or otherwise inject an "exp" in the past),
send it as the "refresh_token" cookie to client.post(_URL), and assert
response.status_code == 401 and response.json()["code"] ==
"INVALID_REFRESH_TOKEN"; place the new test alongside
test_returns_401_for_garbage_token and
test_returns_401_for_access_token_used_as_refresh so it exercises the
expired-token decoder branch.

@aniebietafia aniebietafia force-pushed the feat/auth-refresh-token branch from 19df2dc to e1c5056 Compare March 29, 2026 16:04
@aniebietafia aniebietafia merged commit db1e815 into main Mar 29, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Token Rotation Endpoint

2 participants