Skip to content

Commit

Permalink
STCOR-946: set tenant context based on authentication response with o…
Browse files Browse the repository at this point in the history
…verrideUser parameter on login (#1598)

Refs STCOR-946.
  • Loading branch information
aidynoJ authored Feb 24, 2025
1 parent 94f6864 commit db9d895
Show file tree
Hide file tree
Showing 3 changed files with 120 additions and 15 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
* Change Help icon aria label to just Help in MainNav component. Refs STCOR-931.
* *BREAKING* remove token-based authentication code. Refs STCOR-918.
* *BREAKING* replace useSecureTokens conditionalsRefs STCOR-922.
* Set tenant context based on authentication response with overrideUser parameter on login (Eureka, ECS - Single tenant UX) STCOR-946.

## [10.2.0](https://github.com/folio-org/stripes-core/tree/v10.2.0) (2024-10-11)
[Full Changelog](https://github.com/folio-org/stripes-core/compare/v10.1.1...v10.2.0)
Expand Down
64 changes: 50 additions & 14 deletions src/loginServices.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import localforage from 'localforage';
import { config, translations } from 'stripes-config';
import { config, okapi, translations } from 'stripes-config';
import rtlDetect from 'rtl-detect';
import moment from 'moment';
import { loadDayJSLocale } from '@folio/stripes-components';
Expand Down Expand Up @@ -400,12 +400,12 @@ function loadResources(store, tenant, userId) {
// in mod-configuration so we can only retrieve them if the user has
// read-permission for configuration entries.
if (canReadConfig(store)) {
const okapi = store.getState()?.okapi;
const okapiObject = store.getState()?.okapi;
promises = [
getLocale(okapi.url, store, tenant),
getUserLocale(okapi.url, store, tenant, userId),
getPlugins(okapi.url, store, tenant),
getBindings(okapi.url, store, tenant),
getLocale(okapiObject.url, store, tenant),
getUserLocale(okapiObject.url, store, tenant, userId),
getPlugins(okapiObject.url, store, tenant),
getBindings(okapiObject.url, store, tenant),
];
}

Expand Down Expand Up @@ -594,7 +594,15 @@ export function createOkapiSession(store, tenant, token, data) {
rtExpires: data.tokenExpiration?.refreshTokenExpiration ? new Date(data.tokenExpiration.refreshTokenExpiration).getTime() : Date.now() + (10 * 60 * 1000),
};

const sessionTenant = data.tenant || tenant;
/* @ See the comments for fetchOverriddenUserWithPerms.
* There are consortia(multi-tenant) and non-consortia modes/envs.
* We don't want to care if it is consortia or non-consortia modes, just use fetchOverriderUserWithPerms on login to initiate the session.
* 1. In consortia mode, fetchOverriderUserWithPerms returns originalTenantId.
* 2. In non-consortia mode, fetchOverriderUserWithPerms won't response with originalTenantId,
* instead `tenant` field will be provided.
* 3. As a fallback use default tenant.
*/
const sessionTenant = data.originalTenantId || data.tenant || tenant;
const okapiSess = {
token,
isAuthenticated: true,
Expand Down Expand Up @@ -891,17 +899,16 @@ export function requestLogin(okapiUrl, store, tenant, data) {

/**
* fetchUserWithPerms
* retrieve currently-authenticated user
* retrieve currently-authenticated user data, e.g. after switching affiliations, and we want permissions for the current tenant
* @param {string} okapiUrl
* @param {string} tenant
* @param {string} token
* @param {boolean} rtrIgnore
* @param {boolean} isKeycloak
*
* @returns {Promise} Promise resolving to the response of the request
*/
function fetchUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false, isKeycloak = false) {
const usersPath = isKeycloak ? 'users-keycloak' : 'bl-users';
function fetchUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false) {
const usersPath = okapi.authnUrl ? 'users-keycloak' : 'bl-users';
return fetch(
`${okapiUrl}/${usersPath}/_self?expandPermissions=true&fullPermissions=true`,
{
Expand All @@ -911,6 +918,36 @@ function fetchUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false, isKeyclo
);
}

/**
* fetchOverriddenUserWithPerms
* When starting a session after turning from OIDC authentication, the user's current tenant
* (provided to X-Okapi-Tenant) may not match the central tenant. overrideUser=true query allow us to
* retrieve a real user's tenant permissions and service points even if X-Okapi-Tenant == central tenant.
* Example, we have user `exampleUser` that directly affiliated with `Member tenant` and for central tenant exampleUser is a shadow user.
* Previously, we had to log in to central tenant as a shadow user, and then switch the affiliation.
* But providing query overrideUser=true, we fetch the real user's tenant with its permissions and service points.
* Response looks like this, {originalTenantId: "Member tenant", permissions: {permissions: [...memberTenantPermissions]}, ...rest}.
* Now, we can set the originalTenantId to the session with fetched permissions and servicePoints.
* Compare with fetchUserWithPerms, which only fetches data from the current tenant, which is called during an established session when switching tenants.
* @param {string} okapiUrl
* @param {redux store} store
* @param {string} tenant
* @param {string} token
*
* @returns {Promise} Promise resolving to the response-body (JSON) of the request
*/

export function fetchOverriddenUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false) {
const usersPath = okapi.authnUrl ? 'users-keycloak' : 'bl-users';
return fetch(
`${okapiUrl}/${usersPath}/_self?expandPermissions=true&fullPermissions=true&overrideUser=true`,
{
headers: getHeaders(tenant, token),
rtrIgnore,
},
);
}

/**
* requestUserWithPerms
* retrieve currently-authenticated user, then process the result to begin a session.
Expand All @@ -922,8 +959,7 @@ function fetchUserWithPerms(okapiUrl, tenant, token, rtrIgnore = false, isKeyclo
* @returns {Promise} Promise resolving to the response-body (JSON) of the request
*/
export function requestUserWithPerms(okapiUrl, store, tenant, token) {
const isKeycloak = Boolean(store.getState().okapi?.authnUrl);
return fetchUserWithPerms(okapiUrl, tenant, token, !token, isKeycloak)
return fetchOverriddenUserWithPerms(okapiUrl, tenant, token, !token)
.then((resp) => {
if (resp.ok) {
return processOkapiSession(store, tenant, resp, token);
Expand Down Expand Up @@ -989,7 +1025,7 @@ export function updateUser(store, data) {
*/
export async function updateTenant(okapiConfig, tenant) {
const okapiSess = await getOkapiSession();
const userWithPermsResponse = await fetchUserWithPerms(okapiConfig.url, tenant, okapiConfig.token, false, Boolean(okapiConfig.authnUrl));
const userWithPermsResponse = await fetchUserWithPerms(okapiConfig.url, tenant, okapiConfig.token, false);
const userWithPerms = await userWithPermsResponse.json();
await localforage.setItem(SESSION_NAME, { ...okapiSess, tenant, ...spreadUserWithPerms(userWithPerms) });
}
70 changes: 69 additions & 1 deletion src/loginServices.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
IS_LOGGING_OUT,
SESSION_NAME, getStoredTenant,
requestLogin,
requestUserWithPerms,
fetchOverriddenUserWithPerms,
} from './loginServices';

import {
Expand All @@ -50,6 +52,11 @@ import {

import { defaultErrors } from './constants';

jest.mock('./loginServices', () => ({
...jest.requireActual('./loginServices'),
fetchOverriddenUserWithPerms: jest.fn()
}));

jest.mock('localforage', () => ({
getItem: jest.fn(() => Promise.resolve({ user: {} })),
setItem: jest.fn(() => Promise.resolve()),
Expand All @@ -63,6 +70,9 @@ jest.mock('stripes-config', () => ({
remus: { name: 'remus', clientId: 'remus-application' },
}
},
okapi: {
authnUrl: 'https://authn.url',
},
translations: {}
}));

Expand All @@ -85,7 +95,7 @@ const mockFetchError = (error) => {

// restore default fetch impl
const mockFetchCleanUp = () => {
global.fetch.mockClear();
global.fetch?.mockClear();
delete global.fetch;
};

Expand Down Expand Up @@ -765,4 +775,62 @@ describe('unauthorizedPath functions', () => {
);
});
});

describe('requestUserWithPerms', () => {
afterEach(() => {
mockFetchCleanUp();
jest.clearAllMocks();
});
it('should authenticate and create session when valid credentials provided', async () => {
mockFetchSuccess({ tenant:'tenant', originalTenantId:'originalTenantId', ok: true });
const mockStore = {
getState: () => ({
okapi: {},
}),
dispatch: jest.fn()
};

await requestUserWithPerms(
'http://okapi-url',
mockStore,
'test-tenant',
'token'
);

expect(global.fetch).toHaveBeenCalledWith('http://okapi-url/users-keycloak/_self?expandPermissions=true&fullPermissions=true&overrideUser=true',
{
headers: expect.objectContaining({
'X-Okapi-Tenant': 'test-tenant',
'X-Okapi-Token': 'token',
'Content-Type': 'application/json',
}),
'rtrIgnore': false
});
});

it('should reject with an error object when response is not ok', async () => {
const mockError = { message: 'Permission denied' };
const mockStore = {
getState: () => ({
okapi: {},
}),
dispatch: jest.fn()
};
const mockResponse = {
ok: false,
json: jest.fn().mockResolvedValue(mockError), // Ensure `json()` is async
};
global.fetch = jest.fn().mockImplementation(() => (
Promise.resolve({
ok: false,
status: 404,
json: () => Promise.resolve('Reject message'),
headers: new Map(),
})));
fetchOverriddenUserWithPerms.mockResolvedValue(mockResponse);

await expect(requestUserWithPerms('okapiUrl', mockStore, 'tenant', true)).rejects.toEqual('Reject message');
mockFetchCleanUp();
});
});
});

0 comments on commit db9d895

Please sign in to comment.