Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0ca001a
feat: refactor institution handling in user authorization and mutations
Dec 10, 2025
cb446e0
fix: update upsertUserByOidcSub to handle institution input more flex…
Dec 10, 2025
4321cfc
fix: update user mutations tests and correct institutionId field type
Dec 10, 2025
7a75b71
fix: enhance user mutations tests by mocking UserAuthorization and lo…
Dec 10, 2025
79d7b45
fix: remove console log for created user institution ID in upsertUser…
Dec 10, 2025
f018c44
fix: update institution creation to allow null country and handle ror…
Dec 10, 2025
2e24f7d
fix: institutiuons
Dec 10, 2025
8ad5e9c
fix: remove outdated comment regarding institutionInput types in User…
Dec 11, 2025
ca25c91
fix: enhance error handling in upsertUserByOidc and add Cypress comma…
Dec 15, 2025
bb5ab7d
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
jekabs-karklins Dec 17, 2025
73656b4
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
Feb 24, 2026
bb1f2fe
fix: rename 'manual' to 'institutionData' in InstitutionInput and rel…
Feb 24, 2026
236af45
refactor: remove institutionId handling from user institution logic a…
Feb 24, 2026
1cefa8a
feat: implement ROR API integration for institution retrieval and add…
Feb 24, 2026
c70cab6
fix: rename 'manual' to 'institutionData' in upsertUserByOidc call
Feb 24, 2026
2f151dc
feat: implement getOrCreateUserInstitution logic to handle country cr…
Feb 24, 2026
2ae1bbf
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
jekabs-karklins Feb 27, 2026
31e296e
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
jekabs-karklins Mar 2, 2026
b5d2336
feat: add validation schema for upsertUserByOidcSub and update relate…
Mar 2, 2026
6049bf2
fix: improve type checking for getOrCreateUserInstitution input
Mar 2, 2026
acd4ad9
refactor: remove updateInstitutions mutation and related tests
Mar 2, 2026
44183f0
test: add unit tests for getInstitutionFromRor function with various …
Mar 2, 2026
b3d170a
feat: implement updateInstitutions mutation and related tests for use…
Mar 2, 2026
20d1d34
fix: correct institution data structure and update ROR ID in login tests
Mar 2, 2026
4dea7f0
fix: correct institution data structure in upsertUserByOidc function
Mar 3, 2026
5fd0d68
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
jekabs-karklins Mar 9, 2026
907caa0
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
Mar 10, 2026
f6fe74a
test: update description for new user creation with ROR ID in upsertU…
Mar 10, 2026
2f23c67
Merge branch 'develop' into SWAP-5157-update-upsertuserbyoidcsub
jekabs-karklins Mar 11, 2026
c031d01
feat: add ROR_API_URL to .env and update configuration documentation
Mar 11, 2026
6152327
fix: handle empty ROR ID input in getOrCreateUserInstitution method
Mar 11, 2026
39f5c64
Merge branch 'SWAP-5157-update-upsertuserbyoidcsub' of github.com:Use…
Mar 11, 2026
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
7 changes: 6 additions & 1 deletion apps/backend/example.development.env
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ TZ=Europe/Stockholm
DATE_FORMAT=dd-MM-yyyy
DATE_TIME_FORMAT=dd-MM-yyyy HH:mm

# ROR API URL, used for fetching organization information based on ROR IDs
# ROR_API_URL=https://api.ror.org/organizations

# The email address of the initial user officer.
# INITIAL_USER_OFFICER_EMAIL=

Expand All @@ -67,4 +70,6 @@ DATE_TIME_FORMAT=dd-MM-yyyy HH:mm
# SINK_EMAIL=sink.email@example.com

# Opentelemetry envs can be found in here https://opentelemetry.io/docs/languages/sdk-configuration/otlp-exporter/
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"
# OTEL_EXPORTER_OTLP_ENDPOINT="http://localhost:4318/v1/traces"

ROR_API_URL=https://api.ror.org/organizations
43 changes: 36 additions & 7 deletions apps/backend/src/auth/OAuthAuthorization.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ describe('OAuthAuthorization', () => {
country = 'testCountry',
}: {
user: User;
rorId?: string;
name?: string;
country?: string;
rorId?: string | null;
name?: string | null;
country?: string | null;
Comment thread
jekabs-karklins marked this conversation as resolved.
}) =>
(mockOpenIdClient.login = jest.fn().mockResolvedValue({
userProfile: {
Expand Down Expand Up @@ -128,8 +128,8 @@ describe('OAuthAuthorization', () => {

expect(mockAdminDataSource.createCountry).not.toHaveBeenCalled();
expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({
name: 'testName',
country: testCountry.countryId,
name: 'Unknown institution',
country: null,
rorId: 'testRorId',
});
expect(
Expand Down Expand Up @@ -161,7 +161,8 @@ describe('OAuthAuthorization', () => {
.spyOn(mockAdminDataSource, 'createInstitution')
.mockResolvedValue({ id: expectedInstitutionId } as Institution);

mockOpenIdLoginResponse({ user: dummyUser });
// make dummyUser without ror_id
mockOpenIdLoginResponse({ user: dummyUser, rorId: null });
await oauthAuthorization.externalTokenLogin('valid', '', null);

expect(mockAdminDataSource.createCountry).toHaveBeenCalledWith(
Expand All @@ -170,7 +171,7 @@ describe('OAuthAuthorization', () => {
expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({
name: 'testName',
country: testCountry.countryId,
rorId: 'testRorId',
rorId: undefined,
});

expect(
Expand Down Expand Up @@ -202,6 +203,34 @@ describe('OAuthAuthorization', () => {
).toBe(dummyUser.institutionId);
});

describe('getOrCreateUserInstitution', () => {
it('Should create new institution if institution name matches but country does not', async () => {
const existingInstitution = new Institution(1, 'Existing Inst', 1);
const newCountry = { country: 'New Country', countryId: 2 };

jest
.spyOn(mockAdminDataSource, 'getInstitutionByName')
.mockResolvedValue(existingInstitution);
jest
.spyOn(mockAdminDataSource, 'getCountryByName')
.mockResolvedValue(newCountry);
jest
.spyOn(mockAdminDataSource, 'createInstitution')
.mockResolvedValue({ id: 2 } as Institution);

await oauthAuthorization.getOrCreateUserInstitution({
name: existingInstitution.name,
country: newCountry.country,
});

expect(mockAdminDataSource.createInstitution).toHaveBeenCalledWith({
name: existingInstitution.name,
country: newCountry.countryId,
rorId: undefined,
});
});
});

describe('upsertUser->create: INITIAL_USER_OFFICER_EMAIL', () => {
it('should assign USER_OFFICER role if the email is the INITIAL_USER_OFFICER_EMAIL', async () => {
process.env.INITIAL_USER_OFFICER_EMAIL = dummyUser.email;
Expand Down
118 changes: 84 additions & 34 deletions apps/backend/src/auth/OAuthAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,18 @@ import { OpenIdClient } from '@user-office-software/openid';
import { ValidTokenSet } from '@user-office-software/openid/lib/model/ValidTokenSet';
import { ValidUserInfo } from '@user-office-software/openid/lib/model/ValidUserInfo';
import { GraphQLError } from 'graphql';
import { UserinfoResponse } from 'openid-client';
import { container } from 'tsyringe';

import { Tokens } from '../config/Tokens';
import { AdminDataSource } from '../datasources/AdminDataSource';
import { Institution } from '../models/Institution';
import { Rejection } from '../models/Rejection';
import { SettingsId } from '../models/Settings';
import { AuthJwtPayload, User, UserRole } from '../models/User';
import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation';
import { getInstitutionFromRor } from '../services/RorApi';
import { UserAuthorization } from './UserAuthorization';

interface UserinfoResponseWithInstitution extends UserinfoResponse {
institution_ror_id?: string;
institution_name?: string;
institution_country?: string;
}

export class OAuthAuthorization extends UserAuthorization {
private db = container.resolve<AdminDataSource>(Tokens.AdminDataSource);

Expand Down Expand Up @@ -85,38 +81,82 @@ export class OAuthAuthorization extends UserAuthorization {
});
}

public async getOrCreateUserInstitution(
userInfo: UserinfoResponseWithInstitution
) {
if (!userInfo.institution_name || !userInfo.institution_country) {
return null;
private async getOrCreateInstitutionByRorId(
rorId: string
): Promise<Institution | null> {
let institution = await this.adminDataSource.getInstitutionByRorId(rorId);
if (!institution) {
const rorData = await getInstitutionFromRor(rorId);
let name = 'Unknown institution';
let countryId: number | null = null;

if (rorData) {
name = rorData.name;
let country = await this.adminDataSource.getCountryByName(
rorData.country
);
if (!country) {
logger.logWarn('Country not found in database', {
countryName: rorData.country,
});
country = await this.adminDataSource.createCountry(rorData.country);
}
countryId = country.countryId;
}

institution = await this.adminDataSource.createInstitution({
name: name,
country: countryId,
rorId: rorId,
});
}

let institution = userInfo.institution_ror_id
? await this.adminDataSource.getInstitutionByRorId(
userInfo.institution_ror_id
)
: await this.adminDataSource.getInstitutionByName(
userInfo.institution_name
);
return institution;
}
Comment thread
jekabs-karklins marked this conversation as resolved.

private async getOrCreateInstitutionByManualInput(manualInput: {
name: string;
country: string;
}): Promise<Institution | null> {
const institutionName = manualInput.name;
const countryName = manualInput.country;

let country = await this.adminDataSource.getCountryByName(countryName);
if (!country) {
// create country if it does not exist
country = await this.adminDataSource.createCountry(countryName);
}

let institution =
await this.adminDataSource.getInstitutionByName(institutionName);

// If the institution exists but the country does not match, we should create a new institution
if (institution && institution.country !== country.countryId) {
institution = null;
}

if (!institution) {
let institutionCountry = await this.adminDataSource.getCountryByName(
userInfo.institution_country
);
institution = await this.adminDataSource.createInstitution({
name: institutionName,
country: country.countryId,
rorId: undefined,
});
}

if (!institutionCountry) {
institutionCountry = await this.adminDataSource.createCountry(
userInfo.institution_country
);
return institution;
}

public async getOrCreateUserInstitution(input: GetOrCreateInstitutionInput) {
let institution: Institution | null = null;
if (typeof input === 'string') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Will it add more value to validate rorID with more specific rule. Since the rorID is very standard, we can enforce the validation based on its pattern.

https://ror.readme.io/docs/identifier

Just an add-on

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good idea, I have added the validation schema to make sure all user input is valid at the get-go

// ROR ID provided
if (!input || input.trim() === '') {
return null;
}
const newInstitution = {
name: userInfo.institution_name,
country: institutionCountry.countryId,
rorId: userInfo.institution_ror_id,
};
institution =
await this.adminDataSource.createInstitution(newInstitution);
institution = await this.getOrCreateInstitutionByRorId(input);
} else if (input !== null && typeof input === 'object') {
// Manual institution details provided
institution = await this.getOrCreateInstitutionByManualInput(input);
}

return institution;
Expand All @@ -127,7 +167,17 @@ export class OAuthAuthorization extends UserAuthorization {
tokenSet: ValidTokenSet
): Promise<User> {
const client = await OpenIdClient.getInstance();
const institution = await this.getOrCreateUserInstitution(userInfo);
let institutionInput: GetOrCreateInstitutionInput = null;
if (userInfo.institution_ror_id) {
institutionInput = userInfo.institution_ror_id as string;
} else if (userInfo.institution_name && userInfo.institution_country) {
institutionInput = {
country: userInfo.institution_country as string,
name: userInfo.institution_name as string,
};
}

const institution = await this.getOrCreateUserInstitution(institutionInput);
const userId = this.getUniqueId(userInfo);
const userWithOAuthSubMatch =
await this.userDataSource.getByOIDCSub(userId);
Expand Down
9 changes: 4 additions & 5 deletions apps/backend/src/auth/StfcUserAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Instrument } from '../models/Instrument';
import { Rejection, rejection } from '../models/Rejection';
import { Role, Roles } from '../models/Role';
import { AuthJwtPayload, User, UserWithRole } from '../models/User';
import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation';
import { Cache } from '../utils/Cache';
import { StfcUserDataSource } from './../datasources/stfc/StfcUserDataSource';
import { UserAuthorization } from './UserAuthorization';
Expand Down Expand Up @@ -349,11 +350,9 @@ export class StfcUserAuthorization extends UserAuthorization {
return true;
}

getOrCreateUserInstitution(userInfo: {
institution_ror_id?: string;
institution_name?: string;
institution_country?: string;
}): Promise<Institution | null> {
getOrCreateUserInstitution(
_input: GetOrCreateInstitutionInput
): Promise<Institution | null> {
throw new Error('Method not implemented.');
}
}
9 changes: 4 additions & 5 deletions apps/backend/src/auth/UserAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { Institution } from '../models/Institution';
import { Rejection } from '../models/Rejection';
import { Role, Roles } from '../models/Role';
import { AuthJwtPayload, User, UserWithRole } from '../models/User';
import { GetOrCreateInstitutionInput } from '../resolvers/mutations/UpsertUserMutation';
import { AdminDataSource } from './../datasources/AdminDataSource';

export abstract class UserAuthorization {
Expand Down Expand Up @@ -198,11 +199,9 @@ export abstract class UserAuthorization {
iss: string | null
): Promise<User | null>;

abstract getOrCreateUserInstitution(userInfo: {
institution_ror_id?: string;
institution_name?: string;
institution_country?: string;
}): Promise<Institution | null>;
abstract getOrCreateUserInstitution(
institution: GetOrCreateInstitutionInput
): Promise<Institution | null>;

abstract logout(token: AuthJwtPayload): Promise<string | Rejection>;

Expand Down
Loading
Loading