Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
## [Unreleased](https://github.com/ethyca/fides/compare/2.69.0...main)

### Security
- Changed session invalidation logic to end all sessions for a user when their password has been changed [CVE-2025-57766](https://github.com/ethyca/fides/security/advisories/GHSA-rpw8-82v9-3q87)
- Fixed OAuth scope privilege escalation vulnerability that allowed clients to create or update other OAuth clients with unauthorized scopes [CVE-2025-57817](https://github.com/ethyca/fides/security/advisories/GHSA-hjfh-p8f5-24wr)
- Added stricter rate limiting to authentication endpoints to mitigate against brute force attacks. [CVE-2025-57815](https://github.com/ethyca/fides/security/advisories/GHSA-7q62-r88r-j5gw)
- Adds Redis-driven rate limiting across all endpoints [CVE-2025-57816](https://github.com/ethyca/fides/security/advisories/GHSA-fq34-xw6c-fphf)
Expand Down Expand Up @@ -64,9 +65,6 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o

### Fixed
- Fixed bug with non-applicable notices being saved as opted in in Fides.js [#6490](https://github.com/ethyca/fides/pull/6490)


### Fixed
- Handle missing GVL in TCF experience by displaying an error message instead of infinite spinners. [#6472](https://github.com/ethyca/fides/pull/6472)
- Prevent edits for assets that have been ignored in the Action Center [#6485](https://github.com/ethyca/fides/pull/6485)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,19 @@ import {
useToast,
} from "fidesui";
import { Form, Formik } from "formik";
import { useRouter } from "next/router";
import { useDispatch } from "react-redux";
import * as Yup from "yup";

import { useAppSelector } from "~/app/hooks";
import { selectUser } from "~/features/auth/auth.slice";
import { CustomTextInput } from "~/features/common/form/inputs";
import { passwordValidation } from "~/features/common/form/validation";
import { getErrorMessage } from "~/features/common/helpers";
import { errorToastParams, successToastParams } from "~/features/common/toast";
import { isErrorResult } from "~/types/errors";

import { clearAuthAndLogout } from "./logout-helpers";
import { useForceResetUserPasswordMutation } from "./user-management.slice";

const ValidationSchema = Yup.object().shape({
Expand All @@ -37,6 +42,9 @@ const useNewPasswordModal = (id: string) => {
const modal = useDisclosure();
const toast = useToast();
const [resetPassword] = useForceResetUserPasswordMutation();
const router = useRouter();
const dispatch = useDispatch();
const currentUser = useAppSelector(selectUser);

const handleResetPassword = async (values: FormValues) => {
const result = await resetPassword({
Expand All @@ -52,6 +60,11 @@ const useNewPasswordModal = (id: string) => {
),
);
modal.onClose();

// Only logout if admin reset their own password
if (currentUser?.id === id) {
clearAuthAndLogout(dispatch as any, router);
}
}
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,12 @@ import {
useDisclosure,
useToast,
} from "fidesui";
import { useRouter } from "next/router";
import React, { useState } from "react";
import { useDispatch } from "react-redux";

import { successToastParams } from "../common/toast";
import { clearAuthAndLogout } from "./logout-helpers";
import { useUpdateUserPasswordMutation } from "./user-management.slice";

const useUpdatePasswordModal = (id: string) => {
Expand All @@ -24,6 +27,8 @@ const useUpdatePasswordModal = (id: string) => {
const [oldPasswordValue, setOldPasswordValue] = useState("");
const [newPasswordValue, setNewPasswordValue] = useState("");
const [changePassword, { isLoading }] = useUpdateUserPasswordMutation();
const router = useRouter();
const dispatch = useDispatch();

const changePasswordValidation = !!(
id &&
Expand All @@ -49,7 +54,9 @@ const useUpdatePasswordModal = (id: string) => {
.unwrap()
.then(() => {
toast(successToastParams("Password updated"));
modal.onClose();
clearAuthAndLogout(dispatch as any, router, {
onClose: modal.onClose,
});
});
}
};
Expand Down
20 changes: 20 additions & 0 deletions clients/admin-ui/src/features/user-management/logout-helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { NextRouter } from "next/router";

import { AppDispatch } from "~/app/store";
import { LOGIN_ROUTE, STORAGE_ROOT_KEY } from "~/constants";
import { logout } from "~/features/auth/auth.slice";

export const clearAuthAndLogout = (
dispatch: AppDispatch,
router: NextRouter,
opts?: { onClose?: () => void },
) => {
try {
localStorage.removeItem(STORAGE_ROOT_KEY);
} catch (e) {
// no-op
}
dispatch(logout());
opts?.onClose?.();
router.push(LOGIN_ROUTE);
};
23 changes: 23 additions & 0 deletions src/fides/api/api/v1/endpoints/user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,17 @@ def update_user_password(

current_user.update_password(db=db, new_password=data.new_password)

# Delete the user's associated OAuth client to invalidate all existing sessions
if current_user.client:
try:
current_user.client.delete(db)
except Exception as exc:
logger.exception(
"Unable to delete user client during password reset for user {}: {}",
current_user.id,
exc,
Comment on lines +217 to +219
Copy link
Contributor

Choose a reason for hiding this comment

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

style: Using bare Exception catch is overly broad. Consider catching more specific exceptions like database errors to avoid masking unexpected issues

)

logger.info("Updated user with id: '{}'.", current_user.id)
return current_user

Expand Down Expand Up @@ -236,6 +247,18 @@ def force_update_password(
)

user.update_password(db=db, new_password=data.new_password)

# Delete the user's associated OAuth client to invalidate all existing sessions
if user.client:
try:
user.client.delete(db)
except Exception as exc:
logger.exception(
"Unable to delete user client during admin-forced password reset for user {}: {}",
user.id,
exc,
)

logger.info("Updated user with id: '{}'.", user.id)
return user

Expand Down
1 change: 1 addition & 0 deletions src/fides/api/models/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def __tablename__(self) -> str:
user_id = Column(
String, ForeignKey(FidesUser.id_field_path), nullable=True, unique=True
)
user: Optional["FidesUser"]

@classmethod
def create_client_and_secret(
Expand Down
45 changes: 42 additions & 3 deletions src/fides/api/oauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from datetime import datetime
from functools import update_wrapper
from types import FunctionType
from typing import Any, Callable, Dict, List, Optional, Tuple
from typing import Any, Callable, Dict, List, Optional, Tuple, cast

from fastapi import Depends, HTTPException, Request, Security
from fastapi.security import SecurityScopes
Expand Down Expand Up @@ -80,6 +80,37 @@ def is_callback_token_expired(issued_at: Optional[datetime]) -> bool:
).total_seconds() / 60.0 > CONFIG.execution.privacy_request_delay_timeout


def is_token_invalidated(issued_at: datetime, client: ClientDetail) -> bool:
"""
Return True if the token should be considered invalid due to security events
(e.g., user password reset) that occurred after the token was issued.

Any errors accessing related objects are logged and treated as non-invalidating.
"""
try:
if client.user is None or client.user.password_reset_at is None:
return False

reset_at = client.user.password_reset_at
# DB layer may return a tz-aware timestamp while the JWE issued_at is naive (server time).
# Normalize both to naive for a safe, deterministic comparison without raising TypeError.
issued_naive = (
issued_at.replace(tzinfo=None)
if issued_at.tzinfo is not None
else issued_at
)
reset_naive = (
reset_at.replace(tzinfo=None) if reset_at.tzinfo is not None else reset_at
)

return issued_naive < reset_naive
except Exception as exc:
logger.exception(
"Unable to evaluate password reset timestamp for client user: {}", exc
)
return False


def _get_webhook_jwe_or_error(
security_scopes: SecurityScopes, authorization: str = Security(oauth2_scheme)
) -> WebhookJWE:
Expand Down Expand Up @@ -225,7 +256,7 @@ async def get_current_user(
created_at=datetime.utcnow(),
)

return client.user # type: ignore[attr-defined]
return cast(FidesUser, client.user)


def verify_callback_oauth_policy_pre_webhook(
Expand Down Expand Up @@ -370,8 +401,10 @@ def extract_token_and_load_client(
logger.debug("Auth token expired.")
raise AuthorizationError(detail="Not Authorized for this action")

issued_at_dt = datetime.fromisoformat(issued_at)

if is_token_expired(
datetime.fromisoformat(issued_at),
issued_at_dt,
token_duration_override or CONFIG.security.oauth_access_token_expire_minutes,
):
raise AuthorizationError(detail="Not Authorized for this action")
Expand All @@ -394,6 +427,12 @@ def extract_token_and_load_client(
logger.debug("Auth token belongs to an invalid client_id.")
raise AuthorizationError(detail="Not Authorized for this action")

# Invalidate tokens issued prior to the user's most recent password reset.
# This ensures any existing sessions are expired immediately after a password change.
if is_token_invalidated(issued_at_dt, client):
logger.debug("Auth token issued before latest password reset.")
raise AuthorizationError(detail="Not Authorized for this action")

# Populate request-scoped context with the authenticated user identifier.
# Prefer the linked user_id; fall back to the client id when this is the
# special root client (which has no associated FidesUser row).
Expand Down
Loading
Loading