Merge pull request #21356 from AustralianBioCommons/oidc-external-profile

Link to an external profile when user accounts are managed by a single OIDC provider
This commit is contained in:
Ahmed Hamid Awan
2025-12-16 09:40:45 +05:00
committed by GitHub
12 changed files with 543 additions and 27 deletions
@@ -3,26 +3,18 @@ import axios from "axios";
import { withPrefix } from "@/utils/redirect";
import { rethrowSimple } from "@/utils/simple-error";
/** Shape of the OIDC config object coming from Galaxy’s `/api/config`. */
export type OIDCConfig = Record<
string,
{
icon?: string;
label?: string;
custom_button_text?: string;
end_user_registration_endpoint?: string;
}
>;
export type OIDCConfigEntry = {
icon?: string;
label?: string;
custom_button_text?: string;
end_user_registration_endpoint?: string;
profile_url?: string;
};
export type OIDCConfigWithRegistration = Record<
string,
{
icon?: string;
label?: string;
custom_button_text?: string;
end_user_registration_endpoint: string;
}
>;
/** Shape of the OIDC config object coming from Galaxy’s `/api/config`. */
export type OIDCConfig = Record<string, OIDCConfigEntry>;
export type OIDCConfigWithRegistration = Record<string, OIDCConfigEntry & { end_user_registration_endpoint: string }>;
/** Return the per-IDP config, minus anything the caller wants to hide. */
export function getFilteredOIDCIdps(oidcConfig: OIDCConfig, exclude: string[] = []): OIDCConfig {
@@ -95,6 +87,26 @@ export function isOnlyOneOIDCProviderConfigured(config: OIDCConfig): boolean {
return Object.keys(config).length === 1;
}
export function getSingleOidcConfig(config: OIDCConfig): OIDCConfigEntry | null {
const providers = Object.keys(config);
if (providers.length !== 1) {
return null;
}
const idp = providers[0];
if (idp === undefined) {
throw new Error("OIDC provider key is undefined.");
}
return config[idp] || null;
}
export function hasSingleOidcProfile(config: OIDCConfig): boolean {
if (!isOnlyOneOIDCProviderConfigured(config)) {
return false;
}
const idp_config = getSingleOidcConfig(config);
return !!idp_config?.profile_url;
}
export async function redirectToSingleProvider(config: OIDCConfig): Promise<string | null> {
const providers = Object.keys(config);
@@ -0,0 +1,70 @@
import { createTestingPinia } from "@pinia/testing";
import { getFakeRegisteredUser } from "@tests/test-data";
import { getLocalVue } from "@tests/vitest/helpers";
import { mount } from "@vue/test-utils";
import { describe, expect, it, vi } from "vitest";
import { ref } from "vue";
import VueRouter from "vue-router";
import { useUserStore } from "@/stores/userStore";
import UserOidcProfile from "./UserOidcProfile.vue";
import GButton from "@/components/BaseComponents/GButton.vue";
const PROFILE_URL = "https://profile.example.com";
const MOCK_CONFIG = {
oidc: {
provider: {
label: "Example Provider",
profile_url: PROFILE_URL,
},
},
};
vi.mock("@/composables/config", () => ({
useConfig: vi.fn(() => ({
config: ref(MOCK_CONFIG),
isConfigLoaded: ref(true),
})),
}));
const localVue = getLocalVue();
localVue.use(VueRouter);
function mountProfile(userOverrides = {}) {
const pinia = createTestingPinia({ createSpy: vi.fn });
const userStore = useUserStore(pinia);
userStore.currentUser = getFakeRegisteredUser(userOverrides);
const router = new VueRouter();
return mount(UserOidcProfile, {
localVue,
pinia,
router,
stubs: {
FontAwesomeIcon: true,
},
});
}
describe("UserOidcProfile", () => {
it("shows the profile link from config", async () => {
const wrapper = mountProfile();
await wrapper.vm.$nextTick();
const profileButton = wrapper.findComponent(GButton);
expect(profileButton.exists()).toBe(true);
expect(profileButton.props("href")).toBe(PROFILE_URL);
});
it("displays username and email from the user store", async () => {
const username = "oidc_user";
const email = "oidc_user@example.com";
const wrapper = mountProfile({ username, email });
await wrapper.vm.$nextTick();
const details = wrapper.findAll("dd");
expect(details.at(0)!.text()).toBe(email);
expect(details.at(1)!.text()).toBe(username);
});
});
@@ -0,0 +1,115 @@
<script setup lang="ts">
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { faUser } from "font-awesome-6";
import { storeToRefs } from "pinia";
import { computed } from "vue";
import { isRegisteredUser } from "@/api";
import { getSingleOidcConfig, type OIDCConfigEntry } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import { useConfig } from "@/composables/config";
import { useUserStore } from "@/stores/userStore";
import localize from "@/utils/localization";
import GButton from "@/components/BaseComponents/GButton.vue";
import BreadcrumbHeading from "@/components/Common/BreadcrumbHeading.vue";
const { config, isConfigLoaded } = useConfig(true);
const userStore = useUserStore();
const { currentUser } = storeToRefs(userStore);
const breadcrumbItems = computed(() => [{ title: "User Preferences", to: "/user" }, { title: "Manage Profile" }]);
const username = computed(() => {
if (isRegisteredUser(currentUser.value)) {
return currentUser.value.username;
}
return localize("Not available");
});
const email = computed(() => {
if (isRegisteredUser(currentUser.value)) {
return currentUser.value.email;
}
return localize("Not available");
});
/** Get the config for the OIDC provider. Note we currently
* assume there is only a single OIDC provider when it is being
* used to manage profile details (username/email/password)
*/
const oidcProviderConfig = computed<OIDCConfigEntry | null>(() => {
if (!isConfigLoaded.value) {
return null;
}
return getSingleOidcConfig(config.value.oidc);
});
const profileUrl = computed(() => {
if (oidcProviderConfig.value === null) {
// Need to return a value to ensure the GButton is
// rendered as a link
return "#";
}
return oidcProviderConfig.value.profile_url;
});
const profileButtonLabel = computed(() => {
if (oidcProviderConfig.value === null) {
return "Update profile details";
}
const providerLabel = oidcProviderConfig.value.custom_button_text || oidcProviderConfig.value.label;
if (providerLabel) {
return `Update profile details at ${providerLabel}`;
}
return "Update profile details";
});
const buttonDisabled = computed(() => !isConfigLoaded.value || !profileUrl.value);
</script>
<template>
<div>
<BreadcrumbHeading :items="breadcrumbItems" />
<div id="manage-profile-card" class="ui-portlet-section">
<div class="portlet-header">
<span class="portlet-title">
<FontAwesomeIcon :icon="faUser" fixed-width class="mr-1" />
<span class="portlet-title-text">{{ localize("Manage Profile") }}</span>
</span>
</div>
<div class="portlet-content">
<dl class="d-flex flex-column flex-gapy-1">
<div class="my-2">
<dt class="text-md-left">{{ localize("Email") }}</dt>
<dd>{{ email }}</dd>
</div>
<div class="my-2">
<dt class="text-md-left">{{ localize("Username") }}</dt>
<dd>{{ username }}</dd>
<span class="text-sm-left">
<em>
{{
localize(
"Your username is an identifier that will be used to generate addresses for information you share publicly.",
)
}}
</em>
</span>
</div>
<div class="my-2">
<dt>{{ localize("Password") }}</dt>
<dd>●●●●●●●●●●</dd>
</div>
</dl>
<GButton
color="blue"
size="medium"
class="mt-3"
:disabled="buttonDisabled"
:href="profileUrl"
target="_blank">
<span>{{ localize(profileButtonLabel) }}</span>
<span class="mr-1 fa fa-external-link-alt" />
</GButton>
</div>
</div>
</div>
</template>
@@ -0,0 +1,201 @@
import { createTestingPinia } from "@pinia/testing";
import { getLocalVue } from "@tests/vitest/helpers";
import { shallowMount } from "@vue/test-utils";
import { faUnlockAlt } from "font-awesome-6";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computed } from "vue";
import VueRouter from "vue-router";
import { hasSingleOidcProfile } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel";
import { useConfig } from "@/composables/config";
import UserPreferences from "./UserPreferences.vue";
vi.mock("@/composables/config", () => ({
useConfig: vi.fn(),
}));
vi.mock("@/components/User/ExternalIdentities/ExternalIDHelper", () => ({
hasSingleOidcProfile: vi.fn(),
}));
vi.mock("@/components/User/UserPreferencesModel", () => ({
getUserPreferencesModel: vi.fn(),
}));
vi.mock("@/app", () => ({
getGalaxyInstance: () => ({
session_csrf_token: "mock-token",
}),
}));
vi.mock("@/composables/confirmDialog", () => ({
useConfirmDialog: () => ({
confirm: vi.fn(),
}),
}));
const localVue = getLocalVue();
localVue.use(VueRouter);
describe("UserPreferences.vue", () => {
const mockPreferences = (passwordDisabled: boolean) => {
// @ts-expect-error - getUserPreferencesModel is mocked
vi.mocked(getUserPreferencesModel).mockReturnValue({
password: {
id: "edit-preferences-password",
title: "Change Password",
description: "Edit your password.",
icon: faUnlockAlt,
disabled: passwordDisabled,
url: "/password",
redirect: "/user",
},
});
};
beforeEach(() => {
// Reset mocks
mockPreferences(false);
});
it("shows oidc-profile element when OIDC is enabled and configured correctly", async () => {
vi.mocked(useConfig).mockReturnValue({
config: computed(() => ({
enable_oidc: true,
disable_local_accounts: true,
oidc: {},
themes: {},
})),
isConfigLoaded: computed(() => true),
});
vi.mocked(hasSingleOidcProfile).mockReturnValue(true);
const wrapper = shallowMount(UserPreferences, {
localVue,
router: new VueRouter(),
pinia: createTestingPinia({ createSpy: vi.fn }),
stubs: {
BreadcrumbHeading: true,
UserDetailsElement: true,
UserPreferencesElement: true,
Heading: true,
BAlert: true,
BModal: true,
UserPickTheme: true,
UserBeaconSettings: true,
UserPreferredObjectStore: true,
UserDeletion: true,
},
});
expect(wrapper.find("#oidc-profile").exists()).toBe(true);
});
it("does not show oidc-profile element when profile_url is not set", async () => {
vi.mocked(useConfig).mockReturnValue({
config: computed(() => ({
enable_oidc: true,
disable_local_accounts: true,
oidc: {},
themes: {},
})),
isConfigLoaded: computed(() => true),
});
vi.mocked(hasSingleOidcProfile).mockReturnValue(false);
const wrapper = shallowMount(UserPreferences, {
localVue,
router: new VueRouter(),
pinia: createTestingPinia({ createSpy: vi.fn }),
stubs: {
BreadcrumbHeading: true,
UserDetailsElement: true,
UserPreferencesElement: true,
Heading: true,
BAlert: true,
BModal: true,
UserPickTheme: true,
UserBeaconSettings: true,
UserPreferredObjectStore: true,
UserDeletion: true,
},
});
expect(wrapper.find("#oidc-profile").exists()).toBe(false);
});
it.each([
[{ enable_account_interface: false }],
[{ enable_account_interface: true, use_remote_user: true }],
[{ enable_account_interface: true, disable_local_accounts: true }],
])("hides password preference when config is %o", async (configOverrides) => {
// Mock the config returned by useConfig (used by the component for other things)
vi.mocked(useConfig).mockReturnValue({
config: computed(() => ({
enable_oidc: false,
disable_local_accounts: false,
oidc: {},
themes: {},
...configOverrides,
})),
isConfigLoaded: computed(() => true),
});
mockPreferences(true);
const wrapper = shallowMount(UserPreferences, {
localVue,
router: new VueRouter(),
pinia: createTestingPinia({ createSpy: vi.fn }),
stubs: {
BreadcrumbHeading: true,
UserDetailsElement: true,
UserPreferencesElement: true,
Heading: true,
BAlert: true,
BModal: true,
UserPickTheme: true,
UserBeaconSettings: true,
UserPreferredObjectStore: true,
UserDeletion: true,
},
});
expect(wrapper.find("#edit-preferences-password").exists()).toBe(false);
});
it("shows password preference when allowed", async () => {
vi.mocked(useConfig).mockReturnValue({
config: computed(() => ({
disable_local_accounts: false,
enable_account_interface: true,
use_remote_user: false,
themes: {},
})),
isConfigLoaded: computed(() => true),
});
mockPreferences(false);
const wrapper = shallowMount(UserPreferences, {
localVue,
router: new VueRouter(),
pinia: createTestingPinia({ createSpy: vi.fn }),
stubs: {
BreadcrumbHeading: true,
UserDetailsElement: true,
UserPreferencesElement: true,
Heading: true,
BAlert: true,
BModal: true,
UserPickTheme: true,
UserBeaconSettings: true,
UserPreferredObjectStore: true,
UserDeletion: true,
},
});
expect(wrapper.find("#edit-preferences-password").exists()).toBe(true);
});
});
@@ -11,6 +11,7 @@ import {
faKey,
faLock,
faPalette,
faPerson,
faRadiation,
faSignOut,
faUsers,
@@ -18,6 +19,7 @@ import {
import { computed, onMounted, ref } from "vue";
import { getGalaxyInstance } from "@/app";
import { hasSingleOidcProfile, type OIDCConfig } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel";
import { useConfig } from "@/composables/config";
import { useConfirmDialog } from "@/composables/confirmDialog";
@@ -59,6 +61,16 @@ const activePreferences = computed(() => {
const enabledPreferences = Object.entries(userPreferencesEntries).filter(([, value]) => !value.disabled);
return Object.fromEntries(enabledPreferences);
});
// Show the OIDC profile management widget if local account editing is disabled and OIDC profile is configured
// through a single provider
const showOidcProfile = computed<boolean>(() => {
if (isConfigLoaded.value) {
const oidcConfig: OIDCConfig = config.value.oidc;
return config.value.enable_oidc && !config.value.enable_account_interface && hasSingleOidcProfile(oidcConfig);
} else {
return false;
}
});
const hasLogout = computed(() => {
if (isConfigLoaded.value) {
const Galaxy = getGalaxyInstance();
@@ -169,6 +181,14 @@ onMounted(async () => {
<div class="d-flex flex-gapy-1 flex-column">
<div class="d-flex flex-wrap mb-4 user-preferences-cards">
<UserPreferencesElement
v-if="showOidcProfile"
id="oidc-profile"
title="Manage my profile"
:icon="faPerson"
description="Manage my profile information (username, email, password)."
to="/user/oidc-profile" />
<UserPreferencesElement
v-for="(link, index) in activePreferences"
:id="link.id"
@@ -0,0 +1,62 @@
import { createTestingPinia } from "@pinia/testing";
import { setActivePinia } from "pinia";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { computed } from "vue";
import { getUserPreferencesModel } from "@/components/User/UserPreferencesModel";
import { useConfig } from "@/composables/config";
vi.mock("@/composables/config", () => ({
useConfig: vi.fn(),
}));
const mockConfig = (cfg: any) =>
vi.mocked(useConfig).mockReturnValue({
config: computed(() => cfg),
isConfigLoaded: computed(() => true),
});
describe("getUserPreferencesModel", () => {
beforeEach(() => {
setActivePinia(createTestingPinia({ createSpy: vi.fn }));
});
it("disables password when account interface is off", () => {
mockConfig({
enable_account_interface: false,
use_remote_user: false,
disable_local_accounts: false,
has_user_tool_filters: false,
themes: {},
});
const prefs = getUserPreferencesModel("user-id");
expect(prefs.password.disabled).toBe(true);
});
it("disables password when using remote user", () => {
mockConfig({
enable_account_interface: true,
use_remote_user: true,
disable_local_accounts: false,
has_user_tool_filters: false,
themes: {},
});
const prefs = getUserPreferencesModel("user-id");
expect(prefs.password.disabled).toBe(true);
});
it("enables password when local accounts allowed and interface enabled", () => {
mockConfig({
enable_account_interface: true,
use_remote_user: false,
disable_local_accounts: false,
has_user_tool_filters: false,
themes: {},
});
const prefs = getUserPreferencesModel("user-id");
expect(prefs.password.disabled).toBe(false);
});
});
@@ -34,9 +34,12 @@ export const getUserPreferencesModel: (user_id?: string) => UserPreferencesModel
title: localize("Manage Information"),
id: "edit-preferences-information",
description:
isConfigLoaded.value && config.value.enable_account_interface && !config.value.use_remote_user
isConfigLoaded.value &&
config.value.enable_account_interface &&
!config.value.use_remote_user &&
!config.value.disable_local_accounts
? localize("Edit your email, addresses and custom parameters or change your public name.")
: localize("Edit your custom parameters."),
: localize("Edit your addresses and custom parameters."),
url: `/api/users/${user_id}/information/inputs`,
icon: faUser,
redirect: "/user",
@@ -49,7 +52,11 @@ export const getUserPreferencesModel: (user_id?: string) => UserPreferencesModel
url: `/api/users/${user_id}/password/inputs`,
submitTitle: "Save Password",
redirect: "/user",
disabled: isConfigLoaded.value && (config.value.use_remote_user || !config.value.enable_account_interface),
disabled:
isConfigLoaded.value &&
(config.value.use_remote_user ||
config.value.disable_local_accounts ||
!config.value.enable_account_interface),
},
toolbox_filters: {
title: localize("Manage Toolbox Filters"),
+13
View File
@@ -5,6 +5,7 @@ import { getGalaxyInstance } from "@/app";
import { HistoryExport } from "@/components/HistoryExport/index";
import { APIKey } from "@/components/User/APIKey";
import { ExternalIdentities } from "@/components/User/ExternalIdentities";
import { hasSingleOidcProfile } from "@/components/User/ExternalIdentities/ExternalIDHelper";
import AdminRoutes from "@/entry/analysis/routes/admin-routes";
import LibraryRoutes from "@/entry/analysis/routes/library-routes";
import StorageRoutes from "@/entry/analysis/routes/storage-routes";
@@ -81,6 +82,7 @@ import CustomBuilds from "@/components/User/CustomBuilds.vue";
import HistoryStorageOverview from "@/components/User/DiskUsage/Visualizations/HistoryStorageOverview.vue";
import NotificationsPreferences from "@/components/User/Notifications/NotificationsPreferences.vue";
import UserDatasetPermissions from "@/components/User/UserDatasetPermissions.vue";
import UserOidcProfile from "@/components/User/UserOidcProfile.vue";
import UserPreferences from "@/components/User/UserPreferences.vue";
import UserPreferencesForm from "@/components/User/UserPreferencesForm.vue";
import DisplayApplication from "@/components/Visualizations/DisplayApplication.vue";
@@ -614,6 +616,17 @@ export function getRouter(Galaxy) {
component: CredentialsManagement,
redirect: redirectAnon(),
},
{
path: "user/oidc-profile",
component: UserOidcProfile,
redirect:
redirectIf(
!Galaxy.config.enable_oidc ||
Galaxy.config.enable_account_interface ||
!hasSingleOidcProfile(Galaxy.config.oidc),
"/user",
) || redirectAnon(),
},
{
path: "user/external_ids",
component: ExternalIdentities,
+4
View File
@@ -131,6 +131,8 @@ class AuthnzManager:
self.app.config.oidc[idp]["end_user_registration_endpoint"] = self.oidc_backends_config[idp][
"end_user_registration_endpoint"
]
if "profile_url" in self.oidc_backends_config[idp]:
self.app.config.oidc[idp]["profile_url"] = self.oidc_backends_config[idp]["profile_url"]
if len(self.oidc_backends_config) == 0:
raise etree.ParseError("No valid provider configuration parsed.")
@@ -174,6 +176,8 @@ class AuthnzManager:
rtv["username_key"] = config_xml.find("username_key").text
if config_xml.find("end_user_registration_endpoint") is not None:
rtv["end_user_registration_endpoint"] = config_xml.find("end_user_registration_endpoint").text
if config_xml.find("profile_url") is not None:
rtv["profile_url"] = config_xml.find("profile_url").text
# this is a EGI Check-in specific config
if config_xml.find("checkin_env") is not None:
@@ -170,6 +170,13 @@
</xs:documentation>
</xs:annotation>
</xs:element>
<xs:element name="profile_url" minOccurs="0" type="xs:string">
<xs:annotation>
<xs:documentation>
URL for profile management in the OIDC provider.
</xs:documentation>
</xs:annotation>
</xs:element>
</xs:all>
<xs:attribute name="name" type="xs:string" use="required">
<xs:annotation>
+3 -3
View File
@@ -2829,7 +2829,7 @@ mapping:
deprecated_alias: allow_user_creation
desc: |
Allow unregistered users to create new local (non-OIDC) accounts (otherwise, they will have to
be created by an admin). This option will be overridden to false in case disable_local_accounts
be created by an admin). This option will be overridden to false in case disable_local_accounts
is set to true.
disable_local_accounts:
@@ -2837,8 +2837,8 @@ mapping:
default: false
required: true
desc: |
Disable local accounts. If this option is set to true, at least one OIDC provider needs
to be configured and will serve as the account provider. If this option is set to true,
Disable local accounts. If this option is set to true, at least one OIDC provider needs
to be configured and will serve as the account provider. If this option is set to true,
allow_local_account creation will be overridden with false.
allow_user_deletion:
+7 -2
View File
@@ -820,7 +820,12 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController
"username": username,
}
is_galaxy_app = trans.webapp.name == "galaxy"
if (trans.app.config.enable_account_interface and not trans.app.config.use_remote_user) or not is_galaxy_app:
allow_profile_edit = (
trans.app.config.enable_account_interface
and not trans.app.config.use_remote_user
and not trans.app.config.disable_local_accounts
)
if allow_profile_edit or not is_galaxy_app:
inputs.append(
{
"id": "email_input",
@@ -836,7 +841,7 @@ class UserAPIController(BaseGalaxyAPIController, UsesTagsMixin, BaseUIController
}
)
if is_galaxy_app:
if trans.app.config.enable_account_interface and not trans.app.config.use_remote_user:
if allow_profile_edit:
inputs.append(
{
"id": "name_input",