Skip to content

Commit a024bb0

Browse files
yoganandanessjekabs-karklinsbolmsten
authored
feat: upsert user by oidc (#1224)
Co-authored-by: Jekabs Karklins <58165815+jekabs-karklins@users.noreply.github.com> Co-authored-by: Fredrik Bolmsten <fredrik.bolmsten@ess.eu>
1 parent 431f6c1 commit a024bb0

20 files changed

Lines changed: 638 additions & 72 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
2+
DO
3+
$$
4+
DECLARE
5+
duplicate_count INTEGER;
6+
duplicate_details TEXT;
7+
BEGIN
8+
IF register_patch('AddUniqueConstraintToOidcSub.sql', 'yoganandanpandiyan', 'Unique constraint', '2025-10-13') THEN
9+
BEGIN
10+
-- Clean up empty string values in oidc_sub column by setting them to NULL
11+
-- This prevents empty strings from conflicting with the unique constraint
12+
UPDATE users
13+
SET oidc_sub = NULL
14+
WHERE oidc_sub = '';
15+
16+
-- Check for duplicate oidc_sub values and throw error if any are found
17+
SELECT COUNT(*)
18+
INTO duplicate_count
19+
FROM (
20+
SELECT oidc_sub
21+
FROM users
22+
WHERE oidc_sub IS NOT NULL
23+
GROUP BY oidc_sub
24+
HAVING COUNT(*) > 1
25+
) duplicates;
26+
27+
IF duplicate_count > 0 THEN
28+
WITH duplicate_groups AS (
29+
SELECT
30+
oidc_sub,
31+
STRING_AGG(user_id::TEXT, ', ' ORDER BY user_id) AS user_ids
32+
FROM users
33+
WHERE oidc_sub IS NOT NULL
34+
AND oidc_sub IN (
35+
SELECT oidc_sub
36+
FROM users
37+
WHERE oidc_sub IS NOT NULL
38+
GROUP BY oidc_sub
39+
HAVING COUNT(*) > 1
40+
)
41+
GROUP BY oidc_sub
42+
)
43+
SELECT STRING_AGG(
44+
FORMAT('oidc_sub: "%s" (user_ids: %s)', oidc_sub, user_ids),
45+
E'\n'
46+
)
47+
INTO duplicate_details
48+
FROM duplicate_groups;
49+
50+
RAISE EXCEPTION 'Cannot add unique constraint to oidc_sub column. Found % duplicate oidc_sub value(s) that must be manually resolved:
51+
%
52+
53+
Please manually fix these duplicate values before running this migration again.
54+
OIDC subject identifiers should be unique as they come from identity providers.',
55+
duplicate_count, duplicate_details;
56+
END IF;
57+
58+
-- Add unique constraint to prevent duplicate oidc_sub values in the future
59+
ALTER TABLE users
60+
ADD CONSTRAINT unique_oidc_sub UNIQUE (oidc_sub);
61+
END;
62+
END IF;
63+
END;
64+
$$
65+
LANGUAGE plpgsql;

apps/backend/src/auth/OAuthAuthorization.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { SettingsId } from '../models/Settings';
1616
import { AuthJwtPayload, User, UserRole } from '../models/User';
1717
import { UserAuthorization } from './UserAuthorization';
1818

19-
interface UserinfoResponseWithInssitution extends UserinfoResponse {
19+
interface UserinfoResponseWithInstitution extends UserinfoResponse {
2020
institution_ror_id?: string;
2121
institution_name?: string;
2222
institution_country?: string;
@@ -85,11 +85,11 @@ export class OAuthAuthorization extends UserAuthorization {
8585
});
8686
}
8787

88-
private async getUserInstitutionId(
89-
userInfo: UserinfoResponseWithInssitution
88+
public async getOrCreateUserInstitution(
89+
userInfo: UserinfoResponseWithInstitution
9090
) {
9191
if (!userInfo.institution_name || !userInfo.institution_country) {
92-
return undefined;
92+
return null;
9393
}
9494

9595
let institution = userInfo.institution_ror_id
@@ -119,15 +119,15 @@ export class OAuthAuthorization extends UserAuthorization {
119119
await this.adminDataSource.createInstitution(newInstitution);
120120
}
121121

122-
return institution?.id;
122+
return institution;
123123
}
124124

125125
private async upsertUser(
126126
userInfo: ValidUserInfo,
127127
tokenSet: ValidTokenSet
128128
): Promise<User> {
129129
const client = await OpenIdClient.getInstance();
130-
const institutionId = await this.getUserInstitutionId(userInfo);
130+
const institution = await this.getOrCreateUserInstitution(userInfo);
131131
const userWithOAuthSubMatch = await this.userDataSource.getByOIDCSub(
132132
userInfo.sub
133133
);
@@ -152,7 +152,7 @@ export class OAuthAuthorization extends UserAuthorization {
152152
oauthIssuer: client.issuer.metadata.issuer,
153153
oauthRefreshToken: tokenSet.refresh_token ?? '',
154154
oidcSub: userInfo.sub,
155-
institutionId: institutionId ?? user.institutionId,
155+
institutionId: institution?.id ?? user.institutionId,
156156
position: userInfo.position as string,
157157
preferredname: userInfo.preferred_username,
158158
telephone: userInfo.phone_number,
@@ -172,7 +172,7 @@ export class OAuthAuthorization extends UserAuthorization {
172172
client.issuer.metadata.issuer,
173173
userInfo.gender ?? 'unspecified',
174174
new Date(),
175-
institutionId ?? 1,
175+
institution?.id ?? 1,
176176
'',
177177
(userInfo.position as string) ?? '',
178178
userInfo.email,

apps/backend/src/auth/StfcUserAuthorization.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
toStfcBasicPersonDetails,
1818
} from '../datasources/stfc/StfcUserDataSource';
1919
import { createUOWSClient } from '../datasources/stfc/UOWSClient';
20+
import { Institution } from '../models/Institution';
2021
import { Instrument } from '../models/Instrument';
2122
import { Rejection, rejection } from '../models/Rejection';
2223
import { Role, Roles } from '../models/Role';
@@ -347,4 +348,12 @@ export class StfcUserAuthorization extends UserAuthorization {
347348
async canBeAssignedToFap(userId: number): Promise<boolean> {
348349
return true;
349350
}
351+
352+
getOrCreateUserInstitution(userInfo: {
353+
institution_ror_id?: string;
354+
institution_name?: string;
355+
institution_country?: string;
356+
}): Promise<Institution | null> {
357+
throw new Error('Method not implemented.');
358+
}
350359
}

apps/backend/src/auth/UserAuthorization.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { InternalReviewDataSource } from '../datasources/InternalReviewDataSourc
77
import { ProposalDataSource } from '../datasources/ProposalDataSource';
88
import { UserDataSource } from '../datasources/UserDataSource';
99
import { VisitDataSource } from '../datasources/VisitDataSource';
10+
import { Institution } from '../models/Institution';
1011
import { Rejection } from '../models/Rejection';
1112
import { Role, Roles } from '../models/Role';
1213
import { AuthJwtPayload, User, UserWithRole } from '../models/User';
@@ -191,6 +192,12 @@ export abstract class UserAuthorization {
191192
iss: string | null
192193
): Promise<User | null>;
193194

195+
abstract getOrCreateUserInstitution(userInfo: {
196+
institution_ror_id?: string;
197+
institution_name?: string;
198+
institution_country?: string;
199+
}): Promise<Institution | null>;
200+
194201
abstract logout(token: AuthJwtPayload): Promise<string | Rejection>;
195202

196203
abstract isExternalTokenValid(

apps/backend/src/auth/mockups/UserAuthorization.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,78 @@
11
import 'reflect-metadata';
22
import { injectable } from 'tsyringe';
33

4+
import { dummyInstitution } from '../../datasources/mockups/AdminDataSource';
45
import { dummyUser } from '../../datasources/mockups/UserDataSource';
6+
import { Institution } from '../../models/Institution';
57
import { Role } from '../../models/Role';
68
import { AuthJwtPayload, User } from '../../models/User';
79
import { UserAuthorization } from '../UserAuthorization';
810

911
@injectable()
1012
export class UserAuthorizationMock extends UserAuthorization {
13+
// Mock institution data for testing
14+
private mockInstitutions: Institution[] = [
15+
dummyInstitution,
16+
new Institution(
17+
3,
18+
'Dummy Research Institute',
19+
3,
20+
'https://ror.org/dummy001'
21+
),
22+
new Institution(4, 'Test University', 4, 'https://ror.org/dummy002'),
23+
new Institution(5, 'Mock Academic Center', 4, 'https://ror.org/dummy003'),
24+
];
25+
26+
async getOrCreateUserInstitution(userInfo: {
27+
institution_ror_id?: string;
28+
institution_name?: string;
29+
institution_country?: string;
30+
}): Promise<Institution | null> {
31+
// Return default institution if all fields are empty or unspecified
32+
if (
33+
(!userInfo.institution_name || userInfo.institution_name.trim() === '') &&
34+
(!userInfo.institution_ror_id ||
35+
userInfo.institution_ror_id.trim() === '')
36+
) {
37+
return this.mockInstitutions[0]; // Return dummyInstitution as default
38+
}
39+
40+
// Try to find existing institution by ROR ID first (most reliable)
41+
if (userInfo.institution_ror_id) {
42+
const existingByRor = this.mockInstitutions.find(
43+
(inst) => inst.rorId === userInfo.institution_ror_id
44+
);
45+
if (existingByRor) {
46+
return existingByRor;
47+
}
48+
}
49+
50+
// Try to find existing institution by name (case-insensitive)
51+
if (userInfo.institution_name) {
52+
const existingByName = this.mockInstitutions.find(
53+
(inst) =>
54+
inst.name.toLowerCase() === userInfo.institution_name?.toLowerCase()
55+
);
56+
if (existingByName) {
57+
return existingByName;
58+
}
59+
}
60+
61+
// Create new institution if not found
62+
const newId = Math.max(...this.mockInstitutions.map((i) => i.id)) + 1;
63+
64+
const newInstitution = new Institution(
65+
newId,
66+
userInfo.institution_name ?? 'Unknown Institution',
67+
1,
68+
userInfo.institution_ror_id
69+
);
70+
71+
// Add to mock data for subsequent calls
72+
this.mockInstitutions.push(newInstitution);
73+
74+
return newInstitution;
75+
}
1176
async externalTokenLogin(
1277
token: string,
1378
_redirectUri: string

apps/backend/src/datasources/mockups/UserDataSource.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export const basicDummyUser = new BasicUserDetails(
2828
false,
2929
'test@email.com',
3030
'',
31+
'',
3132
''
3233
);
3334

@@ -43,6 +44,7 @@ export const basicDummyUserNotOnProposal = new BasicUserDetails(
4344
false,
4445
'test@email.com',
4546
'',
47+
'',
4648
''
4749
);
4850

@@ -259,8 +261,21 @@ export class UserDataSourceMock implements UserDataSource {
259261
async addUserRole(args: AddUserRoleArgs): Promise<boolean> {
260262
return true;
261263
}
264+
// Mock user storage for testing upsertUserByOidcSub
265+
private mockUsers: User[] = [
266+
dummyUser,
267+
dummyUserNotOnProposal,
268+
dummyUserOfficer,
269+
dummyPlaceHolderUser,
270+
];
271+
262272
async getByOIDCSub(oidcSub: string): Promise<User | null> {
263-
return dummyUser;
273+
// Check if user exists with this OIDC sub
274+
const existingUser = this.mockUsers.find(
275+
(user) => user.oidcSub === oidcSub
276+
);
277+
278+
return existingUser || null;
264279
}
265280
async createInviteUser(args: CreateUserByEmailInviteArgs): Promise<number> {
266281
return 5;
@@ -300,6 +315,7 @@ export class UserDataSourceMock implements UserDataSource {
300315
false,
301316
'test@email.com',
302317
'',
318+
'',
303319
''
304320
);
305321
}
@@ -483,8 +499,53 @@ export class UserDataSourceMock implements UserDataSource {
483499
return true;
484500
}
485501

486-
async create(firstname: string, lastname: string) {
487-
return dummyUser;
502+
async create(
503+
user_title: string | undefined,
504+
firstname: string,
505+
lastname: string,
506+
username: string,
507+
preferredname: string | undefined,
508+
oidc_sub: string,
509+
oauth_refresh_token: string,
510+
oauth_issuer: string,
511+
gender: string,
512+
birthdate: Date,
513+
institution_id: number,
514+
department: string,
515+
position: string,
516+
email: string,
517+
telephone: string
518+
) {
519+
// Generate a new user ID
520+
const newId = Math.max(...this.mockUsers.map((u) => u.id)) + 1;
521+
522+
const newUser = new User(
523+
newId,
524+
user_title || 'unspecified',
525+
firstname,
526+
lastname,
527+
username,
528+
preferredname || '',
529+
oidc_sub,
530+
oauth_refresh_token,
531+
oauth_issuer,
532+
gender || 'unspecified',
533+
birthdate,
534+
institution_id || 1,
535+
'Test institution',
536+
department,
537+
position,
538+
email,
539+
telephone,
540+
false,
541+
new Date().toISOString(),
542+
new Date().toISOString()
543+
);
544+
545+
// Add to mock users collection
546+
this.mockUsers.push(newUser);
547+
548+
return newUser;
488549
}
489550

490551
async ensureDummyUserExists(userId: number): Promise<User> {

0 commit comments

Comments
 (0)