Implement CSRF token hash storage and validation for sessions

Adds a hashed CSRF token field to the users_sessions table and model, updates session creation and refresh logic to store and validate the CSRF token hash, and enforces CSRF validation for web clients during token refresh. Updates middleware to require CSRF headers for web clients, and adds comprehensive tests for CSRF middleware behavior. Also improves frontend fetch utility to prevent concurrent refresh token requests. Fix access token not validated on private /idp routes
This commit is contained in:
João Vitória Silva
2025-12-18 16:02:04 +00:00
parent 188d600280
commit a116eb25a5
13 changed files with 525 additions and 52 deletions

View File

@@ -173,6 +173,15 @@ def upgrade() -> None:
comment="Timestamp of last token rotation",
),
)
op.add_column(
"users_sessions",
sa.Column(
"csrf_token_hash",
sa.String(length=255),
nullable=True,
comment="Hashed CSRF token for refresh validation",
),
)
op.create_index(
op.f("ix_users_sessions_oauth_state_id"),
"users_sessions",
@@ -246,6 +255,7 @@ def downgrade() -> None:
op.f("ix_users_sessions_token_family_id"), table_name="users_sessions"
)
op.drop_index(op.f("ix_users_sessions_oauth_state_id"), table_name="users_sessions")
op.drop_column("users_sessions", "csrf_token_hash")
op.drop_column("users_sessions", "last_rotation_at")
op.drop_column("users_sessions", "rotation_count")
op.drop_column("users_sessions", "token_family_id")

View File

@@ -464,8 +464,9 @@ async def exchange_tokens_for_session(
(access_token_exp - datetime.now(timezone.utc)).total_seconds()
)
# Update session with the actual hashed refresh token
# Update session with the actual hashed refresh token and CSRF hash
session_obj.refresh_token = password_hasher.hash_password(refresh_token)
session_obj.csrf_token_hash = password_hasher.hash_password(csrf_token)
db.commit()
# Set refresh token cookie for web clients (enables logout)

View File

@@ -291,30 +291,36 @@ async def refresh_token(
Session,
Depends(core_database.get_db),
],
client_type: Annotated[str, Depends(auth_security.header_client_type_scheme)],
x_csrf_token: Annotated[
str | None, Depends(auth_security.header_csrf_token_scheme)
] = None,
):
"""
Handles the refresh token process for user sessions.
This endpoint validates the provided refresh token, checks session and user status,
and issues new access, refresh, and CSRF tokens.
This endpoint validates the provided refresh token, checks session status,
validates the CSRF token (web clients only), and issues new tokens.
Args:
response (Response): The HTTP response object.
request (Request): The HTTP request object.
_validate_refresh_token (Callable): Dependency to validate the refresh token.
token_user_id (int): User ID extracted from the refresh token.
token_session_id (str): Session ID extracted from the refresh token.
refresh_token_value (str): The raw refresh token value.
password_hasher (PasswordHasher): Utility for verifying token hashes.
token_manager (TokenManager): Utility for creating tokens.
db (Session): Database session.
response: The HTTP response object.
request: The HTTP request object.
_validate_refresh_token: Dependency to validate the refresh token.
token_user_id: User ID extracted from the refresh token.
token_session_id: Session ID extracted from the refresh token.
refresh_token_value: The raw refresh token value.
password_hasher: Utility for verifying token hashes.
token_manager: Utility for creating tokens.
db: Database session.
client_type: Client type (\"web\" or \"mobile\").
x_csrf_token: CSRF token header (web clients only, via dependency).
Returns:
dict: Contains session_id, access_token, csrf_token, token_type, and expires_in
dict: Contains session_id, access_token, csrf_token, token_type, expires_in.
Raises:
HTTPException: If the session is not found, the refresh token is invalid,
the user is inactive, or CSRF token is missing/invalid.
HTTPException: If session not found, refresh token invalid,
user is inactive, or CSRF token is missing/invalid.
"""
# Get the session from the database
session = session_crud.get_session_by_id(token_session_id, db)
@@ -330,6 +336,19 @@ async def refresh_token(
# Validate session hasn't exceeded idle or absolute timeout
session_utils.validate_session_timeout(session)
# Verify CSRF token for web clients only
# Mobile clients don't use CSRF tokens
# Note: Middleware already checks header presence for web clients
if client_type == "web":
if not x_csrf_token or not password_hasher.verify(
x_csrf_token, session.csrf_token_hash
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid CSRF token",
headers={"WWW-Authenticate": "Bearer"},
)
# Check for token reuse BEFORE validating token
# Hash the incoming token to compare with rotated tokens
hashed_refresh_token = password_hasher.hash_password(refresh_token_value)
@@ -346,11 +365,6 @@ async def refresh_token(
headers={"WWW-Authenticate": "Bearer"},
)
# Validate CSRF token matches session
# Note: CSRF token is stored in session during initial auth
# For now, we validate that a CSRF token was provided
# Future enhancement: Store CSRF token hash in session
is_valid = password_hasher.verify(refresh_token_value, session.refresh_token)
if not is_valid:
@@ -395,7 +409,14 @@ async def refresh_token(
# Edit session and store in database
# Note: edit_session automatically increments rotation_count
# and updates last_rotation_at
session_utils.edit_session(session, request, new_refresh_token, password_hasher, db)
session_utils.edit_session(
session,
request,
new_refresh_token,
password_hasher,
db,
new_csrf_token=new_csrf_token,
)
# Opportunistically refresh IdP tokens for all linked identity providers
await idp_utils.refresh_idp_tokens_if_needed(user.id, db)

View File

@@ -27,6 +27,9 @@ header_client_type_scheme_optional = APIKeyHeader(
name="X-Client-Type", auto_error=False
)
# Define the API key header for CSRF token
header_csrf_token_scheme = APIKeyHeader(name="X-CSRF-Token", auto_error=False)
# Define the API key cookie for the refresh token
cookie_refresh_token_scheme = APIKeyCookie(
name="endurain_refresh_token",

View File

@@ -175,7 +175,13 @@ def complete_login(
# Create the session and store it in the database
session_utils.create_session(
session_id, user, request, refresh_token, password_hasher, db
session_id,
user,
request,
refresh_token,
password_hasher,
db,
csrf_token=csrf_token,
)
# Access token and CSRF token returned in body for in-memory storage

View File

@@ -107,7 +107,6 @@ class CSRFMiddleware(BaseHTTPMiddleware):
# Define paths that don't need CSRF protection
self.exempt_paths = [
"/api/v1/auth/login",
"/api/v1/auth/refresh",
"/api/v1/auth/mfa/verify",
"/api/v1/password-reset/request",
"/api/v1/password-reset/confirm",

View File

@@ -157,6 +157,7 @@ router.include_router(
identity_providers_router.router,
prefix=core_config.ROOT_PATH + "/idp",
tags=["identity_providers"],
dependencies=[Depends(auth_security.validate_access_token)],
)
router.include_router(
notifications_router.router,

View File

@@ -18,6 +18,7 @@ class UsersSessions(Base):
id (str): Unique identifier for the session (UUID).
user_id (int): ID of the user to whom the session belongs.
refresh_token (str): Hashed refresh token for the session.
csrf_token_hash (str): Hashed CSRF token for refresh validation.
ip_address (str): IP address of the client initiating the session.
device_type (str): Type of device used for the session.
operating_system (str): Operating system of the device.
@@ -25,7 +26,7 @@ class UsersSessions(Base):
browser (str): Browser used for the session.
browser_version (str): Version of the browser.
created_at (datetime): Timestamp when the session was created.
last_activity_at (datetime): Timestamp of last user activity (for idle timeout).
last_activity_at (datetime): Timestamp of last user activity.
expires_at (datetime): Timestamp when the session expires.
user (User): Relationship to the User model.
rotated_refresh_tokens (list): Rotated tokens for this session.
@@ -94,6 +95,11 @@ class UsersSessions(Base):
last_rotation_at = Column(
DateTime, nullable=True, comment="Timestamp of last token rotation"
)
csrf_token_hash = Column(
String(length=255),
nullable=True,
comment="Hashed CSRF token for refresh validation",
)
# Define a relationship to the User model
user = relationship("User", back_populates="users_sessions")

View File

@@ -18,6 +18,7 @@ class UsersSessions(BaseModel):
created_at (datetime): Session creation timestamp.
last_activity_at (datetime): Last activity timestamp for idle timeout.
expires_at (datetime): Session expiration timestamp.
csrf_token_hash (str | None): Hashed CSRF token for refresh validation (max length: 255).
Config:
from_attributes (bool): Allows model initialization from attributes.
extra (str): Forbids extra fields not defined in the model.
@@ -53,6 +54,11 @@ class UsersSessions(BaseModel):
last_rotation_at: datetime | None = Field(
None, description="Timestamp of last token rotation"
)
csrf_token_hash: str | None = Field(
None,
max_length=255,
description="Hashed CSRF token for refresh validation",
)
model_config = ConfigDict(
from_attributes=True, extra="forbid", validate_assignment=True

View File

@@ -109,20 +109,22 @@ def create_session_object(
hashed_refresh_token: str,
refresh_token_exp: datetime,
oauth_state_id: str | None = None,
csrf_token_hash: str | None = None,
) -> session_schema.UsersSessions:
"""
Creates a UsersSessions object representing a user session with device and request metadata.
Creates a UsersSessions object with device and request metadata.
Args:
session_id (str): Unique identifier for the session.
user (users_schema.UserRead): The user associated with the session.
request (Request): The HTTP request object containing client information.
hashed_refresh_token (str): The hashed refresh token for the session.
refresh_token_exp (datetime): The expiration datetime for the refresh token.
oauth_state_id (str | None): Optional OAuth state ID for PKCE mobile flows.
session_id: Unique identifier for the session.
user: The user associated with the session.
request: The HTTP request object containing client information.
hashed_refresh_token: The hashed refresh token for the session.
refresh_token_exp: The expiration datetime for the refresh token.
oauth_state_id: Optional OAuth state ID for PKCE mobile flows.
csrf_token_hash: Hashed CSRF token for refresh validation.
Returns:
session_schema.UsersSessions: The session object populated with user, device, and request details.
The session object populated with user, device, and request details.
"""
user_agent = get_user_agent(request)
device_info = parse_user_agent(user_agent)
@@ -147,6 +149,7 @@ def create_session_object(
token_family_id=session_id,
rotation_count=0,
last_rotation_at=None,
csrf_token_hash=csrf_token_hash,
)
@@ -155,18 +158,20 @@ def edit_session_object(
hashed_refresh_token: str,
refresh_token_exp: datetime,
session: Session,
csrf_token_hash: str | None = None,
) -> session_schema.UsersSessions:
"""
Edits and returns a UsersSessions object with updated session information.
Edits and returns a UsersSessions object with updated session info.
Args:
request (Request): The incoming HTTP request object.
hashed_refresh_token (str): The hashed refresh token to associate with the session.
refresh_token_exp (datetime): The expiration datetime for the refresh token.
session (Session): The existing session object to update.
request: The incoming HTTP request object.
hashed_refresh_token: The hashed refresh token for the session.
refresh_token_exp: The expiration datetime for the refresh token.
session: The existing session object to update.
csrf_token_hash: Hashed CSRF token for refresh validation.
Returns:
session_schema.UsersSessions: The updated UsersSessions object containing session details such as device info, IP address, and token expiration.
The updated UsersSessions object with device and token details.
"""
user_agent = get_user_agent(request)
device_info = parse_user_agent(user_agent)
@@ -192,6 +197,7 @@ def edit_session_object(
token_family_id=session.token_family_id,
rotation_count=new_rotation_count,
last_rotation_at=now,
csrf_token_hash=csrf_token_hash,
)
@@ -203,18 +209,20 @@ def create_session(
password_hasher: auth_password_hasher.PasswordHasher,
db: Session,
oauth_state_id: str | None = None,
csrf_token: str | None = None,
) -> None:
"""
Creates a new user session and stores it in the database.
Args:
session_id (str): Unique identifier for the session.
user (users_schema.UserRead): The user for whom the session is being created.
request (Request): The incoming HTTP request object.
refresh_token (str): The refresh token to be associated with the session.
password_hasher (auth_password_hasher.PasswordHasher): Utility to hash the refresh token.
db (Session): Database session for storing the session.
oauth_state_id (str | None): Optional OAuth state ID for PKCE mobile flows.
session_id: Unique identifier for the session.
user: The user for whom the session is being created.
request: The incoming HTTP request object.
refresh_token: The refresh token to be associated with the session.
password_hasher: Utility to hash tokens.
db: Database session for storing the session.
oauth_state_id: Optional OAuth state ID for PKCE mobile flows.
csrf_token: Plain CSRF token to hash and store for validation.
Returns:
None
@@ -224,6 +232,11 @@ def create_session(
days=auth_constants.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
# Hash the CSRF token if provided
csrf_hash = None
if csrf_token:
csrf_hash = password_hasher.hash_password(csrf_token)
# Create a new session
new_session = create_session_object(
session_id,
@@ -232,6 +245,7 @@ def create_session(
password_hasher.hash_password(refresh_token),
exp,
oauth_state_id,
csrf_hash,
)
# Add the session to the database
@@ -244,16 +258,18 @@ def edit_session(
new_refresh_token: str,
password_hasher: auth_password_hasher.PasswordHasher,
db: Session,
new_csrf_token: str | None = None,
) -> None:
"""
Edits an existing user session by updating its refresh token and expiration date.
Edits an existing user session by updating its refresh token.
Args:
session (session_schema.UsersSessions): The current user session object to be edited.
request (Request): The incoming request object containing session context.
new_refresh_token (str): The new refresh token to be set for the session.
password_hasher (auth_password_hasher.PasswordHasher): Utility for hashing the refresh token.
db (Session): Database session for committing changes.
session: The current user session object to be edited.
request: The incoming request object containing session context.
new_refresh_token: The new refresh token to be set for the session.
password_hasher: Utility for hashing tokens.
db: Database session for committing changes.
new_csrf_token: Plain CSRF token to hash and store for validation.
Returns:
None
@@ -263,12 +279,18 @@ def edit_session(
days=auth_constants.JWT_REFRESH_TOKEN_EXPIRE_DAYS
)
# Hash the new CSRF token if provided
csrf_hash = None
if new_csrf_token:
csrf_hash = password_hasher.hash_password(new_csrf_token)
# Update the session
updated_session = edit_session_object(
request,
password_hasher.hash_password(new_refresh_token),
exp,
session,
csrf_hash,
)
# Update the session in the database

View File

@@ -0,0 +1 @@
"""Tests for core module."""

View File

@@ -0,0 +1,385 @@
"""
Tests for CSRF middleware implementation (Phase B.16).
Verifies:
1. Middleware only requires X-CSRF-Token header (not cookie)
2. In-memory token validation works correctly
3. Web clients are enforced, mobile clients are exempt
4. Exempt paths work correctly
5. Only POST/PUT/DELETE/PATCH methods are checked
"""
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from core.middleware import CSRFMiddleware
@pytest.fixture
def app_with_csrf():
"""
Creates a minimal FastAPI app with CSRF middleware for testing.
Returns:
FastAPI: A test app with CSRF middleware and test endpoints.
"""
app = FastAPI()
# Add CSRF middleware
app.add_middleware(CSRFMiddleware)
# Test endpoints
@app.get("/api/v1/test/get")
async def test_get():
return {"message": "GET success"}
@app.post("/api/v1/test/post")
async def test_post():
return {"message": "POST success"}
@app.put("/api/v1/test/put")
async def test_put():
return {"message": "PUT success"}
@app.delete("/api/v1/test/delete")
async def test_delete():
return {"message": "DELETE success"}
@app.patch("/api/v1/test/patch")
async def test_patch():
return {"message": "PATCH success"}
# Exempt endpoint for testing
@app.post("/api/v1/auth/login")
async def test_login():
return {"message": "Login success"}
# Public endpoint for testing
@app.post("/api/v1/public/idp/session/test/callback")
async def test_public():
return {"message": "Public success"}
return app
@pytest.fixture
def client(app_with_csrf):
"""
Creates a TestClient for the app with CSRF middleware.
Args:
app_with_csrf: The FastAPI app fixture with CSRF middleware.
Returns:
TestClient: A test client for making requests.
"""
return TestClient(app_with_csrf)
class TestCSRFMiddlewareWebClients:
"""
Tests for CSRF middleware behavior with web clients (X-Client-Type: web).
"""
def test_get_request_no_csrf_required(self, client):
"""
Test that GET requests from web clients don't require CSRF token.
Verifies:
- GET requests succeed without X-CSRF-Token header
- Only state-changing methods require CSRF
"""
response = client.get(
"/api/v1/test/get",
headers={"X-Client-Type": "web"}
)
assert response.status_code == 200
assert response.json() == {"message": "GET success"}
def test_post_without_csrf_forbidden(self, client):
"""
Test that POST requests from web clients require CSRF token.
Verifies:
- POST without X-CSRF-Token header returns 403
- Error message indicates CSRF token is required
"""
with pytest.raises(Exception) as exc_info:
client.post(
"/api/v1/test/post",
headers={"X-Client-Type": "web"}
)
assert "403" in str(exc_info.value)
assert "CSRF token required" in str(exc_info.value)
def test_post_with_csrf_success(self, client):
"""
Test that POST requests with CSRF token succeed.
Verifies:
- X-CSRF-Token header is accepted (header-only, no cookie required)
- In-memory token model works correctly
"""
response = client.post(
"/api/v1/test/post",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "test-csrf-token-123"
}
)
assert response.status_code == 200
assert response.json() == {"message": "POST success"}
def test_put_with_csrf_success(self, client):
"""
Test that PUT requests with CSRF token succeed.
Verifies:
- PUT method is checked for CSRF
- X-CSRF-Token header works
"""
response = client.put(
"/api/v1/test/put",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "test-csrf-token-123"
}
)
assert response.status_code == 200
assert response.json() == {"message": "PUT success"}
def test_delete_with_csrf_success(self, client):
"""
Test that DELETE requests with CSRF token succeed.
Verifies:
- DELETE method is checked for CSRF
- X-CSRF-Token header works
"""
response = client.delete(
"/api/v1/test/delete",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "test-csrf-token-123"
}
)
assert response.status_code == 200
assert response.json() == {"message": "DELETE success"}
def test_patch_with_csrf_success(self, client):
"""
Test that PATCH requests with CSRF token succeed.
Verifies:
- PATCH method is checked for CSRF
- X-CSRF-Token header works
"""
response = client.patch(
"/api/v1/test/patch",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "test-csrf-token-123"
}
)
assert response.status_code == 200
assert response.json() == {"message": "PATCH success"}
class TestCSRFMiddlewareMobileClients:
"""
Tests for CSRF middleware behavior with mobile clients (no X-Client-Type or non-web).
"""
def test_post_without_client_type_success(self, client):
"""
Test that POST requests without X-Client-Type header succeed.
Verifies:
- Mobile clients (no X-Client-Type) are exempt from CSRF
- No X-CSRF-Token required for non-web clients
"""
response = client.post("/api/v1/test/post")
assert response.status_code == 200
assert response.json() == {"message": "POST success"}
def test_post_with_mobile_client_type_success(self, client):
"""
Test that POST requests from mobile clients succeed without CSRF.
Verifies:
- X-Client-Type: mobile is exempt from CSRF checks
- No X-CSRF-Token required
"""
response = client.post(
"/api/v1/test/post",
headers={"X-Client-Type": "mobile"}
)
assert response.status_code == 200
assert response.json() == {"message": "POST success"}
def test_delete_with_app_client_type_success(self, client):
"""
Test that DELETE requests from app clients succeed without CSRF.
Verifies:
- Any non-"web" X-Client-Type is exempt from CSRF
- DELETE works without X-CSRF-Token for mobile/app clients
"""
response = client.delete(
"/api/v1/test/delete",
headers={"X-Client-Type": "app"}
)
assert response.status_code == 200
assert response.json() == {"message": "DELETE success"}
class TestCSRFMiddlewareExemptPaths:
"""
Tests for CSRF middleware exempt paths (authentication endpoints, public routes).
"""
def test_login_exempt_no_csrf_required(self, client):
"""
Test that /api/v1/auth/login is exempt from CSRF checks.
Verifies:
- Login endpoint works without X-CSRF-Token
- Even with X-Client-Type: web
"""
response = client.post(
"/api/v1/auth/login",
headers={"X-Client-Type": "web"}
)
assert response.status_code == 200
assert response.json() == {"message": "Login success"}
def test_public_idp_route_exempt(self, client):
"""
Test that public IdP routes are exempt from CSRF checks.
Verifies:
- Routes starting with /api/v1/public/idp/session/ are exempt
- Dynamic route segments work correctly
"""
response = client.post(
"/api/v1/public/idp/session/test/callback",
headers={"X-Client-Type": "web"}
)
assert response.status_code == 200
assert response.json() == {"message": "Public success"}
class TestCSRFMiddlewareInMemoryModel:
"""
Tests for CSRF middleware in-memory token model (B.16).
Verifies that middleware only checks for header presence,
not cookie-based CSRF tokens (OAuth 2.1 compliance).
"""
def test_csrf_header_only_no_cookie_required(self, client):
"""
Test that CSRF token is accepted from header without cookie.
Verifies:
- Middleware only requires X-CSRF-Token header
- No CSRF cookie is checked or required
- In-memory token storage model is supported
"""
# Request with CSRF header but no CSRF cookie
response = client.post(
"/api/v1/test/post",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "in-memory-csrf-token-abc123"
}
)
assert response.status_code == 200
assert response.json() == {"message": "POST success"}
def test_csrf_token_any_format_accepted_by_middleware(self, client):
"""
Test that middleware accepts any CSRF token format in header.
Verifies:
- Middleware only checks for header presence (not format)
- Cryptographic validation happens in route handler
- This separation of concerns is intentional
"""
# Various token formats should pass middleware check
token_formats = [
"simple-token",
"base64url-encoded-token",
"a" * 64, # Long token
"123", # Short token
]
for token in token_formats:
response = client.post(
"/api/v1/test/post",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": token
}
)
assert response.status_code == 200, f"Failed for token format: {token}"
class TestCSRFMiddlewareSameSiteCookie:
"""
Tests for SameSite=Strict cookie behavior (B.16).
Note: These tests verify that CSRF middleware doesn't rely on cookies.
The actual SameSite=Strict enforcement is at the browser level for the
refresh token cookie, which is tested via integration tests.
"""
def test_csrf_works_without_cookies(self, client):
"""
Test that CSRF protection works without any cookies.
Verifies:
- CSRF middleware doesn't check cookies
- Only X-CSRF-Token header is required
- OAuth 2.1 in-memory token model is followed
"""
# Request without any cookies, only CSRF header
response = client.post(
"/api/v1/test/post",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "header-only-token"
},
cookies={} # Explicitly no cookies
)
assert response.status_code == 200
assert response.json() == {"message": "POST success"}
def test_csrf_ignores_cookie_values(self, client):
"""
Test that CSRF middleware ignores cookie-based CSRF tokens.
Verifies:
- Even if a CSRF cookie exists, middleware uses header
- Cookie-based CSRF (old model) is not checked
"""
# Request with CSRF cookie but no header - should fail
with pytest.raises(Exception) as exc_info:
client.post(
"/api/v1/test/post",
headers={"X-Client-Type": "web"},
cookies={"csrf_token": "cookie-csrf-token"}
)
assert "403" in str(exc_info.value)
# Request with both cookie and header - header wins
response = client.post(
"/api/v1/test/post",
headers={
"X-Client-Type": "web",
"X-CSRF-Token": "header-csrf-token"
},
cookies={"csrf_token": "different-cookie-csrf-token"}
)
assert response.status_code == 200

View File

@@ -86,7 +86,19 @@ async function fetchWithRetry(url, options, responseType = 'json') {
try {
// Use auth store's refreshAccessToken which updates tokens in memory
const authStore = useAuthStore()
await authStore.refreshAccessToken()
// Implement refresh token lock to prevent concurrent refresh requests
// If a refresh is already in progress, wait for it instead of starting a new one
if (!refreshTokenPromise) {
refreshTokenPromise = authStore.refreshAccessToken()
.finally(() => {
// Clear the promise after completion (success or failure)
refreshTokenPromise = null
})
}
await refreshTokenPromise
// Re-add auth headers after refresh (new tokens in memory)
options = addAuthHeaders(url, options)
return await attemptFetch(url, options, responseType)