Skip to content

Commit 6385e01

Browse files
committed
✨(frontend) add Keycloak authentication support
Add Keycloak as an authentication backend in the frontend. Includes configuration updates, API integration, and required dependencies.
1 parent daff80b commit 6385e01

File tree

10 files changed

+218
-2
lines changed

10 files changed

+218
-2
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Versioning](https://semver.org/spec/v2.0.0.html).
88

99
## [Unreleased]
1010

11+
### Added
12+
13+
- Add keycloak as an authentication backend
14+
1115
## [3.2.1] - 2025-11-03
1216

1317
### Fixed

src/frontend/jest.config.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,17 @@ module.exports = {
1515
},
1616
resolver: '<rootDir>/jest/resolver.js',
1717
transformIgnorePatterns: [
18-
'node_modules/(?!(react-intl|lodash-es|@hookform/resolvers|query-string|decode-uri-component|split-on-first|filter-obj|@openfun/cunningham-react)/)',
18+
'node_modules/(?!(' +
19+
'react-intl' +
20+
'|lodash-es' +
21+
'|@hookform/resolvers' +
22+
'|query-string' +
23+
'|decode-uri-component' +
24+
'|split-on-first' +
25+
'|filter-obj' +
26+
'|@openfun/cunningham-react' +
27+
'|keycloak-js' +
28+
')/)',
1929
],
2030
globals: {
2131
RICHIE_VERSION: 'test',
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
2+
import API from './keycloak';
3+
4+
const mockKeycloakInit = jest.fn().mockResolvedValue(true);
5+
const mockKeycloakLogout = jest.fn().mockResolvedValue(undefined);
6+
const mockKeycloakLogin = jest.fn().mockResolvedValue(undefined);
7+
const mockKeycloakLoadUserProfile = jest.fn();
8+
9+
jest.mock('keycloak-js', () => {
10+
return jest.fn().mockImplementation(() => ({
11+
init: mockKeycloakInit,
12+
logout: mockKeycloakLogout,
13+
login: mockKeycloakLogin,
14+
loadUserProfile: mockKeycloakLoadUserProfile,
15+
}));
16+
});
17+
18+
jest.mock('utils/indirection/window', () => ({
19+
location: {
20+
origin: 'https://richie.test',
21+
pathname: '/courses/test-course/',
22+
},
23+
}));
24+
25+
jest.mock('utils/context', () => ({
26+
__esModule: true,
27+
default: mockRichieContextFactory({
28+
authentication: {
29+
backend: 'keycloak',
30+
endpoint: 'https://keycloak.test/auth',
31+
client_id: 'richie-client',
32+
realm: 'richie-realm',
33+
auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
34+
},
35+
}).one(),
36+
}));
37+
38+
describe('Keycloak API', () => {
39+
const authConfig = {
40+
backend: 'keycloak',
41+
endpoint: 'https://keycloak.test/auth',
42+
client_id: 'richie-client',
43+
realm: 'richie-realm',
44+
auth_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/auth',
45+
registration_url: 'https://keycloak.test/auth/realms/richie-realm/protocol/openid-connect/registrations',
46+
};
47+
48+
let keycloakApi: ReturnType<typeof API>;
49+
50+
beforeEach(() => {
51+
jest.clearAllMocks();
52+
keycloakApi = API(authConfig);
53+
});
54+
55+
describe('user.me', () => {
56+
it('returns null when loadUserProfile fails', async () => {
57+
mockKeycloakLoadUserProfile.mockRejectedValueOnce(new Error('Not authenticated'));
58+
const response = await keycloakApi.user.me();
59+
expect(response).toBeNull();
60+
});
61+
62+
it('returns user when loadUserProfile succeeds', async () => {
63+
mockKeycloakLoadUserProfile.mockResolvedValueOnce({
64+
firstName: 'John',
65+
lastName: 'Doe',
66+
email: 'johndoe@example.com',
67+
});
68+
69+
const response = await keycloakApi.user.me();
70+
expect(response).toEqual({
71+
username: 'John Doe',
72+
email: 'johndoe@example.com',
73+
});
74+
});
75+
});
76+
77+
describe('user.login', () => {
78+
it('calls keycloak.login with correct redirect URI', async () => {
79+
await keycloakApi.user.login();
80+
81+
expect(mockKeycloakLogin).toHaveBeenCalledWith({
82+
redirectUri: 'https://richie.test/courses/test-course/',
83+
});
84+
});
85+
});
86+
87+
describe('user.register', () => {
88+
it('calls keycloak.login with register action', async () => {
89+
await keycloakApi.user.register();
90+
91+
expect(mockKeycloakLogin).toHaveBeenCalledWith({
92+
redirectUri: 'https://richie.test/courses/test-course/',
93+
action: 'REGISTER',
94+
});
95+
});
96+
});
97+
98+
describe('user.logout', () => {
99+
it('calls keycloak.logout with correct redirect URI', async () => {
100+
await keycloakApi.user.logout();
101+
102+
expect(mockKeycloakLogout).toHaveBeenCalledWith({
103+
redirectUri: 'https://richie.test/courses/test-course/',
104+
});
105+
});
106+
});
107+
108+
describe('Keycloak initialization', () => {
109+
it('initializes keycloak with correct configuration', () => {
110+
const Keycloak = require('keycloak-js');
111+
112+
expect(Keycloak).toHaveBeenCalledWith({
113+
url: 'https://keycloak.test/auth',
114+
realm: 'richie-realm',
115+
clientId: 'richie-client',
116+
});
117+
118+
expect(mockKeycloakInit).toHaveBeenCalledWith({
119+
checkLoginIframe: false,
120+
flow: 'implicit',
121+
token: undefined,
122+
});
123+
});
124+
});
125+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import Keycloak from 'keycloak-js';
2+
import { AuthenticationBackend } from 'types/commonDataProps';
3+
import { APIAuthentication } from 'types/api';
4+
import { location } from 'utils/indirection/window';
5+
import { handle } from 'utils/errors/handle';
6+
7+
const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
8+
const keycloak = new Keycloak({
9+
url: APIConf.endpoint,
10+
realm: APIConf.realm!,
11+
clientId: APIConf.client_id!,
12+
});
13+
keycloak.init({
14+
checkLoginIframe: false,
15+
flow: 'implicit',
16+
token: APIConf.token!,
17+
});
18+
19+
const getRedirectUri = () => {
20+
return `${location.origin}${location.pathname}`;
21+
};
22+
23+
return {
24+
user: {
25+
me: async () => {
26+
return keycloak
27+
.loadUserProfile()
28+
.then((userProfile) => {
29+
return {
30+
username: `${userProfile.firstName} ${userProfile.lastName}`,
31+
email: userProfile.email,
32+
};
33+
})
34+
.catch((error) => {
35+
handle(error);
36+
return null;
37+
});
38+
},
39+
40+
login: async () => {
41+
await keycloak.login({ redirectUri: getRedirectUri() });
42+
},
43+
44+
register: async () => {
45+
await keycloak.login({ redirectUri: getRedirectUri(), action: 'REGISTER' });
46+
},
47+
48+
logout: async () => {
49+
await keycloak.logout({ redirectUri: getRedirectUri() });
50+
},
51+
},
52+
};
53+
};
54+
55+
export default API;

src/frontend/js/api/authentication.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { Nullable } from 'types/utils';
1111
import context from 'utils/context';
1212
import { APIAuthentication, APIBackend } from 'types/api';
1313
import DummyApiInterface from './lms/dummy';
14+
import KeycloakApiInterface from './auth/keycloak';
1415
import OpenEdxDogwoodApiInterface from './lms/openedx-dogwood';
1516
import OpenEdxHawthornApiInterface from './lms/openedx-hawthorn';
1617
import OpenEdxFonzieApiInterface from './lms/openedx-fonzie';
@@ -22,6 +23,8 @@ const AuthenticationAPIHandler = (): Nullable<APIAuthentication> => {
2223
switch (AUTHENTICATION.backend) {
2324
case APIBackend.DUMMY:
2425
return DummyApiInterface(AUTHENTICATION).user;
26+
case APIBackend.KEYCLOAK:
27+
return KeycloakApiInterface(AUTHENTICATION).user;
2528
case APIBackend.OPENEDX_DOGWOOD:
2629
return OpenEdxDogwoodApiInterface(AUTHENTICATION).user;
2730
case APIBackend.OPENEDX_HAWTHORN:

src/frontend/js/types/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ export enum APIBackend {
6060
DUMMY = 'dummy',
6161
FONZIE = 'fonzie',
6262
JOANIE = 'joanie',
63+
KEYCLOAK = 'keycloak',
6364
OPENEDX_DOGWOOD = 'openedx-dogwood',
6465
OPENEDX_HAWTHORN = 'openedx-hawthorn',
6566
}

src/frontend/js/types/commonDataProps.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,17 @@ export interface LMSBackend {
1414
export interface AuthenticationBackend {
1515
backend: string;
1616
endpoint: string;
17+
client_id?: string;
18+
realm?: string;
19+
token?: string;
20+
auth_url?: string;
21+
registration_url?: string;
22+
user_info_url?: string;
23+
logout_url?: string;
24+
user?: {
25+
username: string;
26+
email: string;
27+
};
1728
}
1829

1930
enum FEATURES {

src/frontend/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@
118118
"jest": "29.7.0",
119119
"jest-environment-jsdom": "29.7.0",
120120
"js-cookie": "3.0.5",
121+
"keycloak-js": "26.2.2",
121122
"lodash-es": "4.17.21",
122123
"mdn-polyfills": "5.20.0",
123124
"msw": "2.7.3",

src/frontend/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
"module": "esnext",
2020
"moduleResolution": "node",
2121
"paths": {
22-
"intl-pluralrules": ["types/libs/intl-pluralrules"]
22+
"intl-pluralrules": ["types/libs/intl-pluralrules"],
23+
"keycloak-js": ["../node_modules/keycloak-js/lib/keycloak"]
2324
},
2425
"resolveJsonModule": true,
2526
"skipLibCheck": true,

src/frontend/yarn.lock

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8398,6 +8398,11 @@ jsonify@^0.0.1:
83988398
object.assign "^4.1.4"
83998399
object.values "^1.1.6"
84008400

8401+
keycloak-js@26.2.2:
8402+
version "26.2.2"
8403+
resolved "https://registry.yarnpkg.com/keycloak-js/-/keycloak-js-26.2.2.tgz#857ea50b89d6c979870919d525991f6553a6edb0"
8404+
integrity sha512-ug7pNZ1xNkd7PPkerOJCEU2VnUhS7CYStDOCFJgqCNQ64h53ppxaKrh4iXH0xM8hFu5b1W6e6lsyYWqBMvaQFg==
8405+
84018406
keyv@^4.5.3:
84028407
version "4.5.4"
84038408
resolved "https://registry.yarnpkg.com/keyv/-/keyv-4.5.4.tgz#a879a99e29452f942439f2a405e3af8b31d4de93"

0 commit comments

Comments
 (0)