From 537747393f930cbb86185d2207f6eecbf86da65d Mon Sep 17 00:00:00 2001 From: Uwe Winter Date: Wed, 3 Dec 2025 14:12:31 +1100 Subject: [PATCH 1/3] feat: allow OIDC to update EMAIL and username (if mapped) --- .../WorkflowInvocationShare.test.ts | 4 ++ client/src/stores/userStore.ts | 22 ++++++++ client/src/stores/users/queries.ts | 15 ++++++ lib/galaxy/authnz/psa_authnz.py | 47 ++++++++++++++++ lib/galaxy/managers/users.py | 53 +++++++++++++++++++ lib/galaxy/webapps/galaxy/api/users.py | 51 +++++++++--------- 6 files changed, 165 insertions(+), 27 deletions(-) diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts index 444ad7837651..add8d5e02712 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts @@ -59,6 +59,10 @@ vi.mock("@/composables/toast", () => ({ toastMock(message, "info"); }), }, + useToast: () => ({ + success: (...args: Parameters<(typeof Toast)["success"]>) => (Toast as any).success(...args), + info: (...args: Parameters<(typeof Toast)["info"]>) => (Toast as any).info(...args), + }), })); // Mock "@/utils/clipboard" diff --git a/client/src/stores/userStore.ts b/client/src/stores/userStore.ts index bc6ecb77e728..95ada2fdba35 100644 --- a/client/src/stores/userStore.ts +++ b/client/src/stores/userStore.ts @@ -3,11 +3,14 @@ import { computed, ref } from "vue"; import { type AnyUser, isAdminUser, isAnonymousUser, isRegisteredUser, type RegisteredUser } from "@/api"; import { useHashedUserId } from "@/composables/hashedUserId"; +import { useToast } from "@/composables/toast"; import { useUserLocalStorageFromHashId } from "@/composables/userLocalStorageFromHashedId"; import { useHistoryStore } from "@/stores/historyStore"; import { addFavoriteToolQuery, getCurrentUser, + getProfileUpdates, + type ProfileUpdatesResponse, removeFavoriteToolQuery, setCurrentThemeQuery, } from "@/stores/users/queries"; @@ -30,6 +33,7 @@ export const useUserStore = defineStore("userStore", () => { const currentUser = ref(null); const currentPreferences = ref(null); const { hashedUserId } = useHashedUserId(currentUser); + const toast = useToast(); const currentListViewPreferences = useUserLocalStorageFromHashId( "user-store-list-view-preferences", @@ -89,6 +93,24 @@ export const useUserStore = defineStore("userStore", () => { if (isRegisteredUser(user)) { currentUser.value = user; currentPreferences.value = processUserPreferences(user); + // One-time profile update notice from backend (cleared server-side). + const updates: ProfileUpdatesResponse = await getProfileUpdates(); + if (updates?.updates && updates.updates.length > 0) { + const labels: Record = { + email: "email address", + username: "public name", + fullname: "full name", + }; + const fieldList = updates.updates + .map((field) => labels[field] || field) + .filter((field, idx, arr) => field && arr.indexOf(field) === idx); + if (fieldList.length > 0) { + toast.info( + `Your profile was updated from your identity provider: ${fieldList.join(", ")}.`, + "Profile updated", + ); + } + } } else if (isAnonymousUser(user)) { currentUser.value = user; } else if (user === null) { diff --git a/client/src/stores/users/queries.ts b/client/src/stores/users/queries.ts index 5cce60926400..4b26577d4dab 100644 --- a/client/src/stores/users/queries.ts +++ b/client/src/stores/users/queries.ts @@ -1,4 +1,7 @@ +import axios from "axios"; + import { GalaxyApi } from "@/api"; +import { getAppRoot } from "@/onload/loadConfig"; import { rethrowSimple } from "@/utils/simple-error"; export async function getCurrentUser() { @@ -12,6 +15,18 @@ export async function getCurrentUser() { return data; } +export type ProfileUpdatesResponse = { updates: string[] }; + +export async function getProfileUpdates(): Promise { + try { + const { data } = await axios.get(`${getAppRoot()}api/users/current/profile_updates`); + return data as ProfileUpdatesResponse; + } catch (e) { + // If the endpoint is unavailable (e.g. in tests without a mock), fall back silently. + return { updates: [] }; + } +} + export async function addFavoriteToolQuery(userId: string, toolId: string) { const { data, error } = await GalaxyApi().PUT("/api/users/{user_id}/favorites/{object_type}", { params: { path: { user_id: userId, object_type: "tools" } }, diff --git a/lib/galaxy/authnz/psa_authnz.py b/lib/galaxy/authnz/psa_authnz.py index c6706a51de4a..35a34bb28413 100644 --- a/lib/galaxy/authnz/psa_authnz.py +++ b/lib/galaxy/authnz/psa_authnz.py @@ -19,7 +19,9 @@ ) from sqlalchemy.exc import IntegrityError +from galaxy import exceptions as galaxy_exceptions from galaxy.exceptions import MalformedContents +from galaxy.managers import users as user_managers from galaxy.model import ( PSAAssociation, PSACode, @@ -104,6 +106,7 @@ "social_core.pipeline.social_auth.load_extra_data", # Update the user record with any changed info from the auth service. "social_core.pipeline.user.user_details", + "galaxy.authnz.psa_authnz.sync_user_profile", "galaxy.authnz.psa_authnz.decode_access_token", ) @@ -248,6 +251,8 @@ def authenticate(self, trans, idphint=None): def callback(self, state_token, authz_code, trans, login_redirect_url): on_the_fly_config(trans.sa_session) self.config[setting_name("LOGIN_REDIRECT_URL")] = login_redirect_url + # Make Galaxy app/trans available to downstream pipeline steps that need to apply Galaxy-specific invariants. + self.config["GALAXY_TRANS"] = trans strategy = Strategy(trans.request, trans.session, Storage, self.config) strategy.session_set(f"{BACKENDS_NAME[self.config['provider']]}_state", state_token) backend = self._load_backend(strategy, self.config["redirect_uri"]) @@ -458,6 +463,48 @@ def verify(strategy=None, response=None, details=None, **kwargs): raise Exception(f"`{provider}` is an unsupported secondary authorization provider, contact admin.") +def sync_user_profile(strategy=None, details=None, user=None, **kwargs): + """ + Apply Galaxy-specific invariants (email + private role, validated public name) after PSA updates the user model. + """ + if not strategy or not user: + return + trans = strategy.config.get("GALAXY_TRANS") + if not trans: + log.debug("OIDC sync_user_profile skipped: no Galaxy transaction available.") + return + manager = getattr(trans.app, "user_manager", None) or user_managers.UserManager(trans.app) + updates: list[str] = [] + # Update email and keep private role in sync + if details and details.get("email"): + try: + manager.update_email(trans, user, details["email"], commit=False, send_activation_email=False) + updates.append("email") + except galaxy_exceptions.MessageException as exc: + log.warning("OIDC email sync skipped for user %s: %s", user.id, exc) + # Update public name with Galaxy validation + if details and details.get("username"): + try: + manager.update_username(trans, user, details["username"], commit=False) + updates.append("username") + except galaxy_exceptions.MessageException as exc: + log.warning("OIDC username sync skipped for user %s: %s", user.id, exc) + if updates: + trans.sa_session.add(user) + trans.sa_session.commit() + existing = [] + try: + existing_raw = user.preferences.get("profile_updates") + if existing_raw: + existing = json.loads(existing_raw) + except Exception: + existing = [] + merged = sorted(set(existing + updates)) + user.preferences["profile_updates"] = json.dumps(merged) + trans.sa_session.add(user) + trans.sa_session.commit() + + def allowed_to_disconnect( name=None, user=None, user_storage=None, strategy=None, backend=None, request=None, details=None, **kwargs ): diff --git a/lib/galaxy/managers/users.py b/lib/galaxy/managers/users.py index f2ca66ef236c..44ee5c443422 100644 --- a/lib/galaxy/managers/users.py +++ b/lib/galaxy/managers/users.py @@ -153,6 +153,59 @@ def create(self, email=None, username=None, password=None, **kwargs): raise exceptions.Conflict(str(db_err)) return user + def update_email( + self, trans, user: User, new_email: str, *, commit: bool = True, send_activation_email: bool = True + ) -> Optional[str]: + """ + Update a user's email address, keeping the private role in sync and honoring activation settings. + Returns an optional informational message (activation email sent notice), or None if unchanged. + Raises RequestParameterInvalidException on validation errors. + """ + message = validate_email(trans, new_email, user) + if message: + raise exceptions.RequestParameterInvalidException(message) + if user.email == new_email: + return None + private_role = trans.app.security_agent.get_private_user_role(user) + private_role.name = new_email + private_role.description = f"Private role for {new_email}" + user.email = new_email + session = self.session() + session.add_all([user, private_role]) + if trans.app.config.user_activation_on: + user.active = False + if send_activation_email: + if self.send_activation_email(trans, user.email, user.username): + message = ( + "The login information has been updated with the changes.
" + "Verification email has been sent to your new email address. Please verify it by clicking " + "the activation link in the email.
" + "Please check your spam/trash folder in case you cannot find the message." + ) + else: + message = "Unable to send activation email, please contact your local Galaxy administrator." + if trans.app.config.error_email_to is not None: + message += f" Contact: {trans.app.config.error_email_to}" + raise exceptions.InternalServerError(message) + if commit: + session.commit() + return message + + def update_username(self, trans, user: User, new_username: str, *, commit: bool = True) -> None: + """ + Update a user's public name after validating it. Raises RequestParameterInvalidException on validation errors. + """ + message = validate_publicname(trans, new_username, user) + if message: + raise exceptions.RequestParameterInvalidException(message) + if user.username == new_username: + return + user.username = new_username + session = self.session() + session.add(user) + if commit: + session.commit() + def delete(self, user, flush=True): """Mark the given user deleted.""" if not self.app.config.allow_user_deletion: diff --git a/lib/galaxy/webapps/galaxy/api/users.py b/lib/galaxy/webapps/galaxy/api/users.py index 7c89dd51ca2c..956de5d1adcd 100644 --- a/lib/galaxy/webapps/galaxy/api/users.py +++ b/lib/galaxy/webapps/galaxy/api/users.py @@ -713,6 +713,26 @@ def delete( raise exceptions.InsufficientPermissionsException("You may only delete your own account.") return self.service.user_to_detailed_model(user_to_update) + @router.get( + "/api/users/current/profile_updates", + name="get_profile_updates", + summary="Return and clear recent profile updates applied from external auth.", + ) + def profile_updates(self, trans: ProvidesUserContext = DependsOnTrans) -> dict[str, list[str]]: + updates: list[str] = [] + if trans.user and trans.user.preferences: + raw_updates = trans.user.preferences.get("profile_updates") + if raw_updates: + try: + updates = json.loads(raw_updates) + except Exception: + updates = [] + # clear after reading + del trans.user.preferences["profile_updates"] + trans.sa_session.add(trans.user) + trans.sa_session.commit() + return {"updates": updates} + @router.post( "/api/users/{user_id}/send_activation_email", name="send_activation_email", @@ -946,36 +966,13 @@ def set_information(self, trans, id, payload=None, **kwd): # Update email if "email" in payload: email = payload.get("email") - message = validate_email(trans, email, user) - if message: - raise exceptions.RequestParameterInvalidException(message) - if user.email != email: - # Update user email and user's private role name which must match - private_role = trans.app.security_agent.get_private_user_role(user) - private_role.name = email - private_role.description = f"Private role for {email}" - user.email = email - trans.sa_session.add(user) - trans.sa_session.add(private_role) - trans.sa_session.commit() - if trans.app.config.user_activation_on: - # Deactivate the user if email was changed and activation is on. - user.active = False - if self.user_manager.send_activation_email(trans, user.email, user.username): - message = "The login information has been updated with the changes.
Verification email has been sent to your new email address. Please verify it by clicking the activation link in the email.
Please check your spam/trash folder in case you cannot find the message." - else: - message = "Unable to send activation email, please contact your local Galaxy administrator." - if trans.app.config.error_email_to is not None: - message += f" Contact: {trans.app.config.error_email_to}" - raise exceptions.InternalServerError(message) + self.user_manager.update_email( + trans, user, email, commit=False, send_activation_email=True # commit at the end of the handler + ) # Update public name if "username" in payload: username = payload.get("username") - message = validate_publicname(trans, username, user) - if message: - raise exceptions.RequestParameterInvalidException(message) - if user.username != username: - user.username = username + self.user_manager.update_username(trans, user, username, commit=False) # Update user custom form if user_info_form_id := payload.get("info|form_id"): prefix = "info|" From 137562d1557aa02924d336424e5ec63a6281112b Mon Sep 17 00:00:00 2001 From: Uwe Winter Date: Wed, 3 Dec 2025 14:26:26 +1100 Subject: [PATCH 2/3] fix: openapi schema --- client/src/api/schema/schema.ts | 60 +++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/client/src/api/schema/schema.ts b/client/src/api/schema/schema.ts index 130f7ff2ad29..7eaa69dcb552 100644 --- a/client/src/api/schema/schema.ts +++ b/client/src/api/schema/schema.ts @@ -5174,6 +5174,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/users/current/profile_updates": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Return and clear recent profile updates applied from external auth. */ + get: operations["get_profile_updates_api_users_current_profile_updates_get"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/users/current/recalculate_disk_usage": { parameters: { query?: never; @@ -40966,6 +40983,49 @@ export interface operations { }; }; }; + get_profile_updates_api_users_current_profile_updates_get: { + parameters: { + query?: never; + header?: { + /** @description The user ID that will be used to effectively make this API call. Only admins and designated users can make API calls on behalf of other users. */ + "run-as"?: string | null; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + [key: string]: string[]; + }; + }; + }; + /** @description Request Error */ + "4XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + /** @description Server Error */ + "5XX": { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["MessageExceptionModel"]; + }; + }; + }; + }; recalculate_disk_usage_api_users_current_recalculate_disk_usage_put: { parameters: { query?: never; From 0455da9c508996d07bce426ee327524b328d0b2d Mon Sep 17 00:00:00 2001 From: Uwe Winter Date: Wed, 3 Dec 2025 14:32:27 +1100 Subject: [PATCH 3/3] fix: tests --- .../WorkflowInvocationShare.test.ts | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts index add8d5e02712..26521609e7f8 100644 --- a/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts +++ b/client/src/components/WorkflowInvocationState/WorkflowInvocationShare.test.ts @@ -50,20 +50,20 @@ const TYPE = 1; const toastMock = vi.fn((message, type: "success" | "info") => { return { message, type }; }); -vi.mock("@/composables/toast", () => ({ - Toast: { - success: vi.fn().mockImplementation((message) => { +vi.mock("@/composables/toast", () => { + const toast = { + success: vi.fn().mockImplementation((message: string) => { toastMock(message, "success"); }), - info: vi.fn().mockImplementation((message) => { + info: vi.fn().mockImplementation((message: string) => { toastMock(message, "info"); }), - }, - useToast: () => ({ - success: (...args: Parameters<(typeof Toast)["success"]>) => (Toast as any).success(...args), - info: (...args: Parameters<(typeof Toast)["info"]>) => (Toast as any).info(...args), - }), -})); + }; + return { + Toast: toast, + useToast: () => toast, + }; +}); // Mock "@/utils/clipboard" const writeText = vi.fn();