Skip to content

Commit a1647d7

Browse files
🔐 feat: Enhance OpenID User Info Handling (#4561)
* oidc-changes Initial attempt at testing openidStrategy and adding OPENID_USERNAME_CLAIM setting * oidc-changes Add OPENID_NAME_CLAIM * oidc-changes cleanup oidc test code * oidc-changes using mongo memory server for test * oidc-changes Change tests to expect username all lowercase * oidc-changes Add more tests * chore: linting * refactor: Simplify OpenID full name retrieval logic * refactor: Simplify OpenID user info retrieval logic * refactor: move helper to openidStrategy.js --------- Co-authored-by: alihacks <[email protected]>
1 parent 600d217 commit a1647d7

File tree

3 files changed

+219
-13
lines changed

3 files changed

+219
-13
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,10 @@ OPENID_CALLBACK_URL=/oauth/openid/callback
399399
OPENID_REQUIRED_ROLE=
400400
OPENID_REQUIRED_ROLE_TOKEN_KIND=
401401
OPENID_REQUIRED_ROLE_PARAMETER_PATH=
402+
# Set to determine which user info property returned from OpenID Provider to store as the User's username
403+
OPENID_USERNAME_CLAIM=
404+
# Set to determine which user info property returned from OpenID Provider to store as the User's name
405+
OPENID_NAME_CLAIM=
402406

403407
OPENID_BUTTON_LABEL=
404408
OPENID_IMAGE_URL=

api/strategies/openidStrategy.js

Lines changed: 40 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ try {
1414
} catch (err) {
1515
logger.error('[openidStrategy] crypto support is disabled!', err);
1616
}
17+
1718
/**
1819
* Downloads an image from a URL using an access token.
1920
* @param {string} url
@@ -53,6 +54,36 @@ const downloadImage = async (url, accessToken) => {
5354
}
5455
};
5556

57+
/**
58+
* Determines the full name of a user based on OpenID userinfo and environment configuration.
59+
*
60+
* @param {Object} userinfo - The user information object from OpenID Connect
61+
* @param {string} [userinfo.given_name] - The user's first name
62+
* @param {string} [userinfo.family_name] - The user's last name
63+
* @param {string} [userinfo.username] - The user's username
64+
* @param {string} [userinfo.email] - The user's email address
65+
* @returns {string} The determined full name of the user
66+
*/
67+
function getFullName(userinfo) {
68+
if (process.env.OPENID_NAME_CLAIM) {
69+
return userinfo[process.env.OPENID_NAME_CLAIM];
70+
}
71+
72+
if (userinfo.given_name && userinfo.family_name) {
73+
return `${userinfo.given_name} ${userinfo.family_name}`;
74+
}
75+
76+
if (userinfo.given_name) {
77+
return userinfo.given_name;
78+
}
79+
80+
if (userinfo.family_name) {
81+
return userinfo.family_name;
82+
}
83+
84+
return userinfo.username || userinfo.email;
85+
}
86+
5687
/**
5788
* Converts an input into a string suitable for a username.
5889
* If the input is a string, it will be returned as is.
@@ -117,16 +148,7 @@ async function setupOpenId() {
117148
);
118149
}
119150

120-
let fullName = '';
121-
if (userinfo.given_name && userinfo.family_name) {
122-
fullName = userinfo.given_name + ' ' + userinfo.family_name;
123-
} else if (userinfo.given_name) {
124-
fullName = userinfo.given_name;
125-
} else if (userinfo.family_name) {
126-
fullName = userinfo.family_name;
127-
} else {
128-
fullName = userinfo.username || userinfo.email;
129-
}
151+
const fullName = getFullName(userinfo);
130152

131153
if (requiredRole) {
132154
let decodedToken = '';
@@ -158,9 +180,14 @@ async function setupOpenId() {
158180
}
159181
}
160182

161-
const username = convertToUsername(
162-
userinfo.username || userinfo.given_name || userinfo.email,
163-
);
183+
let username = '';
184+
if (process.env.OPENID_USERNAME_CLAIM) {
185+
username = userinfo[process.env.OPENID_USERNAME_CLAIM];
186+
} else {
187+
username = convertToUsername(
188+
userinfo.username || userinfo.given_name || userinfo.email,
189+
);
190+
}
164191

165192
if (!user) {
166193
user = {

api/strategies/openidStrategy.spec.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
const jwtDecode = require('jsonwebtoken/decode');
2+
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
3+
const mongoose = require('mongoose');
4+
const { MongoMemoryServer } = require('mongodb-memory-server');
5+
const User = require('~/models/User');
6+
const setupOpenId = require('./openidStrategy');
7+
8+
jest.mock('jsonwebtoken/decode');
9+
jest.mock('openid-client');
10+
11+
jest.mock('~/server/services/Files/strategies', () => ({
12+
getStrategyFunctions: jest.fn(() => ({
13+
saveBuffer: jest.fn(),
14+
})),
15+
}));
16+
17+
Issuer.discover = jest.fn().mockResolvedValue({
18+
Client: jest.fn(),
19+
});
20+
21+
describe('setupOpenId', () => {
22+
const OLD_ENV = process.env;
23+
describe('OpenIDStrategy', () => {
24+
let validateFn, mongoServer;
25+
26+
beforeAll(async () => {
27+
mongoServer = await MongoMemoryServer.create();
28+
const mongoUri = mongoServer.getUri();
29+
await mongoose.connect(mongoUri);
30+
});
31+
32+
afterAll(async () => {
33+
process.env = OLD_ENV;
34+
await mongoose.disconnect();
35+
await mongoServer.stop();
36+
});
37+
38+
beforeEach(async () => {
39+
jest.clearAllMocks();
40+
await User.deleteMany({});
41+
process.env = {
42+
...OLD_ENV,
43+
OPENID_ISSUER: 'https://fake-issuer.com',
44+
OPENID_CLIENT_ID: 'fake_client_id',
45+
OPENID_CLIENT_SECRET: 'fake_client_secret',
46+
DOMAIN_SERVER: 'https://example.com',
47+
OPENID_CALLBACK_URL: '/callback',
48+
OPENID_SCOPE: 'openid profile email',
49+
OPENID_REQUIRED_ROLE: 'requiredRole',
50+
OPENID_REQUIRED_ROLE_PARAMETER_PATH: 'roles',
51+
OPENID_REQUIRED_ROLE_TOKEN_KIND: 'id',
52+
};
53+
54+
jwtDecode.mockReturnValue({
55+
roles: ['requiredRole'],
56+
});
57+
58+
//call setup so we can grab a reference to the validate function
59+
await setupOpenId();
60+
validateFn = OpenIDStrategy.mock.calls[0][1];
61+
});
62+
63+
const tokenset = {
64+
id_token: 'fake_id_token',
65+
};
66+
67+
const userinfo = {
68+
sub: '1234',
69+
70+
email_verified: true,
71+
given_name: 'First',
72+
family_name: 'Last',
73+
name: 'My Full',
74+
username: 'flast',
75+
};
76+
77+
const userModel = {
78+
openidId: userinfo.sub,
79+
email: userinfo.email,
80+
};
81+
82+
it('should set username correctly for a new user when username claim exists', async () => {
83+
const expectUsername = userinfo.username.toLowerCase();
84+
await validateFn(tokenset, userinfo, (err, user) => {
85+
expect(err).toBe(null);
86+
expect(user.username).toBe(expectUsername);
87+
});
88+
89+
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
90+
});
91+
92+
it('should set username correctly for a new user when given_name claim exists, but username does not', async () => {
93+
let userinfo_modified = { ...userinfo };
94+
delete userinfo_modified.username;
95+
const expectUsername = userinfo.given_name.toLowerCase();
96+
97+
await validateFn(tokenset, userinfo_modified, (err, user) => {
98+
expect(err).toBe(null);
99+
expect(user.username).toBe(expectUsername);
100+
});
101+
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
102+
});
103+
104+
it('should set username correctly for a new user when email claim exists, but username and given_name do not', async () => {
105+
let userinfo_modified = { ...userinfo };
106+
delete userinfo_modified.username;
107+
delete userinfo_modified.given_name;
108+
const expectUsername = userinfo.email.toLowerCase();
109+
110+
await validateFn(tokenset, userinfo_modified, (err, user) => {
111+
expect(err).toBe(null);
112+
expect(user.username).toBe(expectUsername);
113+
});
114+
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
115+
});
116+
117+
it('should set username correctly for a new user when using OPENID_USERNAME_CLAIM', async () => {
118+
process.env.OPENID_USERNAME_CLAIM = 'sub';
119+
const expectUsername = userinfo.sub.toLowerCase();
120+
121+
await validateFn(tokenset, userinfo, (err, user) => {
122+
expect(err).toBe(null);
123+
expect(user.username).toBe(expectUsername);
124+
});
125+
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
126+
});
127+
128+
it('should set name correctly for a new user with first and last names', async () => {
129+
const expectName = userinfo.given_name + ' ' + userinfo.family_name;
130+
await validateFn(tokenset, userinfo, (err, user) => {
131+
expect(err).toBe(null);
132+
expect(user.name).toBe(expectName);
133+
});
134+
await expect(User.exists({ name: expectName })).resolves.not.toBeNull();
135+
});
136+
137+
it('should set name correctly for a new user using OPENID_NAME_CLAIM', async () => {
138+
const expectName = 'Custom Name';
139+
process.env.OPENID_NAME_CLAIM = 'name';
140+
let userinfo_modified = { ...userinfo, name: expectName };
141+
142+
await validateFn(tokenset, userinfo_modified, (err, user) => {
143+
expect(err).toBe(null);
144+
expect(user.name).toBe(expectName);
145+
});
146+
await expect(User.exists({ name: expectName })).resolves.not.toBeNull();
147+
});
148+
149+
it('should should update existing user after login', async () => {
150+
const expectUsername = userinfo.username.toLowerCase();
151+
await User.create(userModel);
152+
153+
await validateFn(tokenset, userinfo, (err) => {
154+
expect(err).toBe(null);
155+
});
156+
const newUser = await User.findOne({ openidId: userModel.openidId });
157+
await expect(newUser.provider).toBe('openid');
158+
await expect(newUser.username).toBe(expectUsername);
159+
await expect(newUser.name).toBe(userinfo.given_name + ' ' + userinfo.family_name);
160+
});
161+
162+
it('should should enforce required role', async () => {
163+
jwtDecode.mockReturnValue({
164+
roles: ['SomeOtherRole'],
165+
});
166+
await validateFn(tokenset, userinfo, (err, user, details) => {
167+
expect(err).toBe(null);
168+
expect(user).toBe(false);
169+
expect(details.message).toBe(
170+
'You must have the "' + process.env.OPENID_REQUIRED_ROLE + '" role to log in.',
171+
);
172+
});
173+
});
174+
});
175+
});

0 commit comments

Comments
 (0)