fix(auth): store OAuth refresh token JTI to fix /auth/refresh after OAuth signup
OAuth callbacks (POST /auth/google/callback, POST /auth/microsoft/callback)
issued refresh tokens via create_refresh_token() but never persisted the JTI
in the refresh_tokens table. The /auth/refresh rotation logic does a
conditional UPDATE that requires a matching unrevoked row; without it the
first refresh attempt 401s with "Refresh token has been revoked" and OAuth
users get effectively logged out after the ~5 minute access-token expiry.
- Promote _store_refresh_token to module-public store_refresh_token in
app.api.endpoints.auth (existing callers in /login, /login/json, /refresh
updated in-place — same module, just renamed).
- OAuth callbacks now call store_refresh_token(...) + db.commit() after
_sign_in_or_register returns. _sign_in_or_register already commits the
user/account/identity rows; the refresh-token row gets its own commit.
- Tests:
- test_oauth_google_callback_stores_refresh_token_jti — asserts the JTI
hash is in refresh_tokens after a Google callback.
- test_oauth_refresh_works_after_oauth_signup — full e2e: callback -> use
returned refresh token at /auth/refresh -> 200 with rotated tokens.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.endpoints.auth import store_refresh_token
|
||||
from app.core.admin_database import get_admin_db
|
||||
from app.core.config import settings
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
@@ -186,9 +187,16 @@ async def google_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
@@ -209,8 +217,15 @@ async def microsoft_callback(
|
||||
account_invite_code=payload.account_invite_code,
|
||||
invited_email=payload.invited_email,
|
||||
)
|
||||
refresh_token_str = create_refresh_token({"sub": str(user.id)})
|
||||
# Persist the refresh-token JTI so the first /auth/refresh call doesn't
|
||||
# reject this token as "revoked" (the rotation logic requires a row to
|
||||
# mark as used). _sign_in_or_register already committed; this needs a
|
||||
# second commit.
|
||||
await store_refresh_token(db, refresh_token_str, user.id)
|
||||
await db.commit()
|
||||
return OAuthCallbackResponse(
|
||||
access_token=create_access_token({"sub": str(user.id)}),
|
||||
refresh_token=create_refresh_token({"sub": str(user.id)}),
|
||||
refresh_token=refresh_token_str,
|
||||
is_new_user=is_new,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user