🔐 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 <alihacks@pm.me>
This commit is contained in:
Danny Avila 2024-10-27 11:41:48 -04:00 committed by GitHub
parent 600d21780b
commit a1647d76e0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 219 additions and 13 deletions

View file

@ -0,0 +1,175 @@
const jwtDecode = require('jsonwebtoken/decode');
const { Issuer, Strategy: OpenIDStrategy } = require('openid-client');
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const User = require('~/models/User');
const setupOpenId = require('./openidStrategy');
jest.mock('jsonwebtoken/decode');
jest.mock('openid-client');
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: jest.fn(() => ({
saveBuffer: jest.fn(),
})),
}));
Issuer.discover = jest.fn().mockResolvedValue({
Client: jest.fn(),
});
describe('setupOpenId', () => {
const OLD_ENV = process.env;
describe('OpenIDStrategy', () => {
let validateFn, mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
});
afterAll(async () => {
process.env = OLD_ENV;
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
jest.clearAllMocks();
await User.deleteMany({});
process.env = {
...OLD_ENV,
OPENID_ISSUER: 'https://fake-issuer.com',
OPENID_CLIENT_ID: 'fake_client_id',
OPENID_CLIENT_SECRET: 'fake_client_secret',
DOMAIN_SERVER: 'https://example.com',
OPENID_CALLBACK_URL: '/callback',
OPENID_SCOPE: 'openid profile email',
OPENID_REQUIRED_ROLE: 'requiredRole',
OPENID_REQUIRED_ROLE_PARAMETER_PATH: 'roles',
OPENID_REQUIRED_ROLE_TOKEN_KIND: 'id',
};
jwtDecode.mockReturnValue({
roles: ['requiredRole'],
});
//call setup so we can grab a reference to the validate function
await setupOpenId();
validateFn = OpenIDStrategy.mock.calls[0][1];
});
const tokenset = {
id_token: 'fake_id_token',
};
const userinfo = {
sub: '1234',
email: 'test@example.com',
email_verified: true,
given_name: 'First',
family_name: 'Last',
name: 'My Full',
username: 'flast',
};
const userModel = {
openidId: userinfo.sub,
email: userinfo.email,
};
it('should set username correctly for a new user when username claim exists', async () => {
const expectUsername = userinfo.username.toLowerCase();
await validateFn(tokenset, userinfo, (err, user) => {
expect(err).toBe(null);
expect(user.username).toBe(expectUsername);
});
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
});
it('should set username correctly for a new user when given_name claim exists, but username does not', async () => {
let userinfo_modified = { ...userinfo };
delete userinfo_modified.username;
const expectUsername = userinfo.given_name.toLowerCase();
await validateFn(tokenset, userinfo_modified, (err, user) => {
expect(err).toBe(null);
expect(user.username).toBe(expectUsername);
});
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
});
it('should set username correctly for a new user when email claim exists, but username and given_name do not', async () => {
let userinfo_modified = { ...userinfo };
delete userinfo_modified.username;
delete userinfo_modified.given_name;
const expectUsername = userinfo.email.toLowerCase();
await validateFn(tokenset, userinfo_modified, (err, user) => {
expect(err).toBe(null);
expect(user.username).toBe(expectUsername);
});
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
});
it('should set username correctly for a new user when using OPENID_USERNAME_CLAIM', async () => {
process.env.OPENID_USERNAME_CLAIM = 'sub';
const expectUsername = userinfo.sub.toLowerCase();
await validateFn(tokenset, userinfo, (err, user) => {
expect(err).toBe(null);
expect(user.username).toBe(expectUsername);
});
await expect(User.exists({ username: expectUsername })).resolves.not.toBeNull();
});
it('should set name correctly for a new user with first and last names', async () => {
const expectName = userinfo.given_name + ' ' + userinfo.family_name;
await validateFn(tokenset, userinfo, (err, user) => {
expect(err).toBe(null);
expect(user.name).toBe(expectName);
});
await expect(User.exists({ name: expectName })).resolves.not.toBeNull();
});
it('should set name correctly for a new user using OPENID_NAME_CLAIM', async () => {
const expectName = 'Custom Name';
process.env.OPENID_NAME_CLAIM = 'name';
let userinfo_modified = { ...userinfo, name: expectName };
await validateFn(tokenset, userinfo_modified, (err, user) => {
expect(err).toBe(null);
expect(user.name).toBe(expectName);
});
await expect(User.exists({ name: expectName })).resolves.not.toBeNull();
});
it('should should update existing user after login', async () => {
const expectUsername = userinfo.username.toLowerCase();
await User.create(userModel);
await validateFn(tokenset, userinfo, (err) => {
expect(err).toBe(null);
});
const newUser = await User.findOne({ openidId: userModel.openidId });
await expect(newUser.provider).toBe('openid');
await expect(newUser.username).toBe(expectUsername);
await expect(newUser.name).toBe(userinfo.given_name + ' ' + userinfo.family_name);
});
it('should should enforce required role', async () => {
jwtDecode.mockReturnValue({
roles: ['SomeOtherRole'],
});
await validateFn(tokenset, userinfo, (err, user, details) => {
expect(err).toBe(null);
expect(user).toBe(false);
expect(details.message).toBe(
'You must have the "' + process.env.OPENID_REQUIRED_ROLE + '" role to log in.',
);
});
});
});
});