Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 0 additions & 1 deletion env.d/development/dev.dist
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ AUTHENTICATION_BACKEND=dummy
#AUTHENTICATION_BASE_URL=http://localhost:8080
#AUTHENTICATION_KEYCLOAK_REALM=your-realm
#AUTHENTICATION_KEYCLOAK_CLIENT_ID=your-client-id
#AUTHENTICATION_KEYCLOAK_TOKEN=token
#AUTHENTICATION_BACKEND=keycloak

# LMS Backend
Expand Down
3 changes: 0 additions & 3 deletions sandbox/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -330,9 +330,6 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura
"REALM": values.Value(
"", environ_name="AUTHENTICATION_KEYCLOAK_REALM", environ_prefix=None
),
"TOKEN": values.Value(
"", environ_name="AUTHENTICATION_KEYCLOAK_TOKEN", environ_prefix=None
),
}

# Elasticsearch
Expand Down
65 changes: 63 additions & 2 deletions src/frontend/js/api/auth/keycloak.spec.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,33 @@
import { RichieContextFactory as mockRichieContextFactory } from 'utils/test/factories/richie';
import { KeycloakAccountApi } from 'types/api';
import API from './keycloak';

const mockKeycloakInit = jest.fn().mockResolvedValue(true);
const mockKeycloakLogout = jest.fn().mockResolvedValue(undefined);
const mockKeycloakLogin = jest.fn().mockResolvedValue(undefined);
const mockKeycloakLoadUserProfile = jest.fn();
const mockKeycloakUpdateToken = jest.fn().mockResolvedValue(true);
const mockKeycloakCreateAccountUrl = jest
.fn()
.mockReturnValue('https://keycloak.test/auth/realms/richie-realm/account');
const mockIdToken = 'mock-id-token-12345';
const mockIdTokenParsed = {
preferred_username: 'johndoe',
firstName: 'John',
lastName: 'Doe',
email: 'johndoe@example.com',
};

jest.mock('keycloak-js', () => {
return jest.fn().mockImplementation(() => ({
init: mockKeycloakInit,
logout: mockKeycloakLogout,
login: mockKeycloakLogin,
loadUserProfile: mockKeycloakLoadUserProfile,
updateToken: mockKeycloakUpdateToken,
createAccountUrl: mockKeycloakCreateAccountUrl,
idToken: mockIdToken,
idTokenParsed: mockIdTokenParsed,
}));
});

Expand Down Expand Up @@ -50,28 +66,54 @@ describe('Keycloak API', () => {

beforeEach(() => {
jest.clearAllMocks();
sessionStorage.clear();
keycloakApi = API(authConfig);
});

describe('user.accessToken', () => {
it('returns null when no token is stored', () => {
const token = keycloakApi.user.accessToken!();
expect(token).toBeNull();
});

it('returns the token from sessionStorage', () => {
sessionStorage.setItem('RICHIE_USER_TOKEN', mockIdToken);
const token = keycloakApi.user.accessToken!();
expect(token).toEqual(mockIdToken);
});
});

describe('user.me', () => {
it('returns null when updateToken fails', async () => {
mockKeycloakUpdateToken.mockRejectedValueOnce(new Error('Token refresh failed'));
const response = await keycloakApi.user.me();
expect(response).toBeNull();
expect(mockKeycloakLoadUserProfile).not.toHaveBeenCalled();
});

it('returns null when loadUserProfile fails', async () => {
mockKeycloakUpdateToken.mockResolvedValueOnce(true);
mockKeycloakLoadUserProfile.mockRejectedValueOnce(new Error('Not authenticated'));
const response = await keycloakApi.user.me();
expect(response).toBeNull();
});

it('returns user when loadUserProfile succeeds', async () => {
mockKeycloakUpdateToken.mockResolvedValueOnce(true);
mockKeycloakLoadUserProfile.mockResolvedValueOnce({
firstName: 'John',
lastName: 'Doe',
email: 'johndoe@example.com',
});

const response = await keycloakApi.user.me();
expect(mockKeycloakUpdateToken).toHaveBeenCalledWith(30);
expect(response).toEqual({
username: 'John Doe',
email: 'johndoe@example.com',
access_token: mockIdToken,
});
expect(sessionStorage.getItem('RICHIE_USER_TOKEN')).toEqual(mockIdToken);
});
});

Expand Down Expand Up @@ -106,6 +148,24 @@ describe('Keycloak API', () => {
});
});

describe('user.account', () => {
it('returns profile data from idTokenParsed via account.get()', () => {
const profile = (keycloakApi.user.account as KeycloakAccountApi).get();
expect(profile).toEqual({
username: 'johndoe',
firstName: 'John',
lastName: 'Doe',
email: 'johndoe@example.com',
});
});

it('returns the account management URL via account.updateUrl()', () => {
const url = (keycloakApi.user.account as any).updateUrl();
expect(url).toBe('https://keycloak.test/auth/realms/richie-realm/account');
expect(mockKeycloakCreateAccountUrl).toHaveBeenCalled();
});
});

describe('Keycloak initialization', () => {
it('initializes keycloak with correct configuration', () => {
const Keycloak = require('keycloak-js');
Expand All @@ -118,8 +178,9 @@ describe('Keycloak API', () => {

expect(mockKeycloakInit).toHaveBeenCalledWith({
checkLoginIframe: false,
flow: 'implicit',
token: undefined,
flow: 'standard',
onLoad: 'check-sso',
pkceMethod: 'S256',
});
});
});
Expand Down
42 changes: 40 additions & 2 deletions src/frontend/js/api/auth/keycloak.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import Keycloak from 'keycloak-js';
import { AuthenticationBackend } from 'types/commonDataProps';
import { APIAuthentication } from 'types/api';
import { KeycloakApiProfile } from 'types/keycloak';
import { location } from 'utils/indirection/window';
import { handle } from 'utils/errors/handle';
import { RICHIE_USER_TOKEN } from 'settings';

const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
const keycloak = new Keycloak({
Expand All @@ -12,23 +14,46 @@ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
});
keycloak.init({
checkLoginIframe: false,
flow: 'implicit',
token: APIConf.token!,
flow: 'standard',
onLoad: 'check-sso',
pkceMethod: 'S256',
});

keycloak.onTokenExpired = () => {
keycloak.updateToken(30).catch(() => {
sessionStorage.removeItem(RICHIE_USER_TOKEN);
});
};

keycloak.onAuthRefreshSuccess = () => {
if (keycloak.idToken) {
sessionStorage.setItem(RICHIE_USER_TOKEN, keycloak.idToken);
}
};

const getRedirectUri = () => {
return `${location.origin}${location.pathname}`;
};

return {
user: {
accessToken: () => sessionStorage.getItem(RICHIE_USER_TOKEN),
me: async () => {
try {
await keycloak.updateToken(30);
} catch (error) {
handle(error);
return null;
}

return keycloak
.loadUserProfile()
.then((userProfile) => {
sessionStorage.setItem(RICHIE_USER_TOKEN, keycloak.idToken!);
return {
username: `${userProfile.firstName} ${userProfile.lastName}`,
email: userProfile.email,
access_token: keycloak.idToken,
};
})
.catch((error) => {
Expand All @@ -46,8 +71,21 @@ const API = (APIConf: AuthenticationBackend): { user: APIAuthentication } => {
},

logout: async () => {
sessionStorage.removeItem(RICHIE_USER_TOKEN);
await keycloak.logout({ redirectUri: getRedirectUri() });
},

account: {
get: (): KeycloakApiProfile => {
return {
username: keycloak.idTokenParsed?.preferred_username,
firstName: keycloak.idTokenParsed?.firstName,
lastName: keycloak.idTokenParsed?.lastName,
email: keycloak.idTokenParsed?.email,
};
},
updateUrl: () => keycloak.createAccountUrl(),
},
},
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import WithdrawRightCheckbox from 'components/SaleTunnel/WithdrawRightCheckbox';
import { PaymentSchedule, ProductType } from 'types/Joanie';
import { usePaymentPlan } from 'hooks/usePaymentPlan';
import { HttpError } from 'utils/errors/HttpError';
import { APIBackend, KeycloakAccountApi } from 'types/api';
import context from 'utils/context';
import { AuthenticationApi } from 'api/authentication';

const messages = defineMessages({
title: {
Expand Down Expand Up @@ -49,6 +52,31 @@ const messages = defineMessages({
defaultMessage:
'This email will be used to send you confirmation mails, it is the one you created your account with.',
},
keycloakUsernameLabel: {
id: 'components.SaleTunnel.Information.keycloak.account.label',
description: 'Label for the name',
defaultMessage: 'Account name',
},
keycloakUsernameInfo: {
id: 'components.SaleTunnel.Information.keycloak.account.info',
description: 'Info for the name',
defaultMessage: 'This name will be used in legal documents.',
},
keycloakEmailInfo: {
id: 'components.SaleTunnel.Information.keycloak.email.info',
description: 'Info for the email',
defaultMessage: 'This email will be used to send you confirmation mails.',
},
keycloakAccountLinkInfo: {
id: 'components.SaleTunnel.Information.keycloak.updateLinkInfo',
description: 'Text before the keycloak account update link',
defaultMessage: 'If any of the information above is incorrect,',
},
keycloakAccountLinkLabel: {
id: 'components.SaleTunnel.Information.keycloak.updateLinkLabel',
description: 'Label of the keycloak link to update account',
defaultMessage: 'please update your account',
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
defaultMessage: 'please update your account',
defaultMessage: 'Please update your account',

},
voucherTitle: {
id: 'components.SaleTunnel.Information.voucher.title',
description: 'Title for the voucher',
Expand Down Expand Up @@ -130,6 +158,8 @@ export const SaleTunnelInformationSingular = () => {
setNeedsPayment(!fromBatchOrder);
}, [fromBatchOrder, setNeedsPayment]);

const isKeycloakBackend = context?.authentication.backend === APIBackend.KEYCLOAK;

return (
<>
<div>
Expand All @@ -148,11 +178,17 @@ export const SaleTunnelInformationSingular = () => {
<div className="description mb-s">
<FormattedMessage {...messages.description} />
</div>
<OpenEdxFullNameForm />
<AddressSelector />
<div className="mt-s">
<Email />
</div>
{isKeycloakBackend ? (
<KeycloakAccountEdit />
) : (
<>
<OpenEdxFullNameForm />
<div className="mt-s">
<Email />
</div>
</>
)}
</div>
)}
<div>
Expand All @@ -163,6 +199,51 @@ export const SaleTunnelInformationSingular = () => {
);
};

const KeycloakAccountEdit = () => {
const accountApi = AuthenticationApi!.account as KeycloakAccountApi;
const { user } = useSession();

return (
<>
<div className="mt-s">
<div className="sale-tunnel__username">
<div className="sale-tunnel__username__top">
<h4>
<FormattedMessage {...messages.keycloakUsernameLabel} />
</h4>
<div className="fw-bold">{user?.username}</div>
</div>
<div className="sale-tunnel__username__description">
<FormattedMessage {...messages.keycloakUsernameInfo} />
</div>
</div>
</div>
<div className="mt-s">
<div className="sale-tunnel__email">
<div className="sale-tunnel__email__top">
<h4>
<FormattedMessage {...messages.emailLabel} />
</h4>
<div className="fw-bold">{user?.email}</div>
</div>
<div className="sale-tunnel__email__description">
<FormattedMessage {...messages.keycloakEmailInfo} />
</div>
</div>
</div>
<div className="mt-s">
<div className="sale-tunnel__account-link">
<FormattedMessage {...messages.keycloakAccountLinkInfo} />{' '}
<a href={accountApi.updateUrl()}>
<FormattedMessage {...messages.keycloakAccountLinkLabel} />
</a>
.
</div>
</div>
</>
);
};

const Email = () => {
const { user } = useSession();
const { data: openEdxProfileData } = useOpenEdxProfile({
Expand Down
8 changes: 8 additions & 0 deletions src/frontend/js/components/SaleTunnel/_styles.scss
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
}
}

&__username,
&__email {
&__top {
display: flex;
Expand All @@ -82,6 +83,13 @@
font-size: 0.75rem;
}
}

&__account-link {
a {
color: r-theme-val(sale-tunnel, account-link-color);
text-decoration: underline;
}
}
.price--striked {
text-decoration: line-through;
opacity: 0.5;
Expand Down
Loading