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

@lego-technix lego-technix Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serait-il possible de marquer la classe RefreshToken (et/ou son constructeur) comme @deprecated ?

Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,13 @@ export class RefreshToken {
return new RefreshToken({ userId, source, value, audience, sessionId });
}

/**
* @param {string} value
*/
static isRefreshToken(value) {

@lego-technix lego-technix Sep 8, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Serait-il possible de renommer isRefreshTokenisStatefulRefreshToken ? Il me semble que cela indiquerait mieux que cette méthode permet de différencier les 2 types de refresh tokens.

Suggested change
static isRefreshToken(value) {
static isStatefulRefreshToken(value) {

return /^\d+:\p{Hex_Digit}{8}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{12}$/u.test(value);
}

get expirationDelaySeconds() {
return config.authentication.refreshTokenLifespanMs / 1000;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import Joi from 'joi';

import { config } from '../../../shared/config.js';
import { tokenService } from '../../../shared/domain/services/token-service.js';
import { validateEntity } from '../../../shared/domain/validators/entity-validator.js';

export class UserRefreshToken {
constructor({ userId, audience, sessionId, source }) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggestion : dans tout ce fichier, par considération d’organisation et de facilité de compréhension du code, veiller à ordonner comme suit les propriétés :

  1. userId (c’est le plus important)
  2. sessionId (ça va avec le userId)
  3. audience (c’est une problématique sécurité réseau, et donc sur un autre plan que userId et sessionId )
  4. source (c’est une donnée sans utilité et que nous voulons supprimer depuis longtemps)
Suggested change
constructor({ userId, audience, sessionId, source }) {
constructor({ userId, sessionId, audience, source }) {

this.userId = userId;
this.audience = audience;
this.sessionId = sessionId;
this.source = source;

validateEntity(
Joi.object({
userId: Joi.number().required(),
audience: Joi.string().required(),
sessionId: Joi.string().required(),
source: Joi.string().optional(),
}),
this,
);
}

static generate({ userId, source, audience, sessionId }) {
const expirationDelaySeconds = config.authentication.refreshTokenLifespanMs / 1000;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Est-ce qu’on pourrait définir cette variable à la racine du module pour ne pas la déclarer et la calculer à chaque appel de la méthode ?

return tokenService.encodeToken(
{ user_id: userId, source, aud: audience, sid: sessionId },
config.authentication.secret,
expirationDelaySeconds,
);
}

static decode(encodedRefreshToken) {
const decodedRefreshToken = tokenService.getDecodedToken(encodedRefreshToken, config.authentication.secret);
if (!decodedRefreshToken) return undefined; // FIXME add log like api/src/identity-access-management/infrastructure/server-authentication.js:148 ?

return new UserRefreshToken({
userId: decodedRefreshToken.user_id,
source: decodedRefreshToken.source,
audience: decodedRefreshToken.aud,
sessionId: decodedRefreshToken.sid,
});
}

hasSameAudience(audience) {
return this.audience === audience;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Je sais que ce code provient de l’ancienne classe dépréciée RefreshToken, mais serait-il possible d’en profiter pour ne pas reproduire cette yoda condition :

Suggested change
return this.audience === audience;
return audience === this.audience;

}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { config } from '../../../shared/config.js';
import { PIX_ADMIN } from '../../../shared/constants.js';
import { ForbiddenAccess, PasswordNotMatching, UserNotFoundError } from '../../../shared/domain/errors.js';
import { featureToggles } from '../../../shared/infrastructure/feature-toggles/index.js';
import { NON_OIDC_IDENTITY_PROVIDERS } from '../constants/identity-providers.js';
import { createWarningConnectionEmail } from '../emails/create-warning-connection.email.js';
import {
Expand All @@ -11,6 +12,7 @@ import {
import { PasswordExpirationToken } from '../models/PasswordExpirationToken.js';
import { RefreshToken } from '../models/RefreshToken.js';
import { UserAccessToken } from '../models/UserAccessToken.js';
import { UserRefreshToken } from '../models/UserRefreshToken.js';

/**
* typedef { function } authenticateUser
Expand Down Expand Up @@ -71,8 +73,15 @@ const authenticateUser = async function ({

const sessionId = authenticationSessionService.generateSessionId();

const refreshToken = RefreshToken.generate({ userId: user.id, source, audience, sessionId });
await refreshTokenRepository.save({ refreshToken });
let encodedRefreshToken;
const isSessionLogoutEnabled = await featureToggles.get('isSessionLogoutEnabled');
if (isSessionLogoutEnabled) {
encodedRefreshToken = UserRefreshToken.generate({ userId: user.id, source, audience, sessionId });
} else {
const refreshToken = RefreshToken.generate({ userId: user.id, source, audience, sessionId });
await refreshTokenRepository.save({ refreshToken });
encodedRefreshToken = refreshToken.value;
}

const { accessToken, expirationDelaySeconds } = UserAccessToken.generateUserToken({
userId: user.id,
Expand Down Expand Up @@ -106,7 +115,7 @@ const authenticateUser = async function ({
identityProvider: NON_OIDC_IDENTITY_PROVIDERS.PIX.code,
});

return { accessToken, refreshToken: refreshToken.value, expirationDelaySeconds };
return { accessToken, refreshToken: encodedRefreshToken, expirationDelaySeconds };
} catch (error) {
if (error instanceof UserNotFoundError) {
throw new MissingOrInvalidCredentialsError();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { UnauthorizedError } from '../../../shared/application/errors/http-errors.js';
import { RefreshToken } from '../models/RefreshToken.js';
import { UserAccessToken } from '../models/UserAccessToken.js';
import { UserRefreshToken } from '../models/UserRefreshToken.js';

/**
* typedef { function } createAccessTokenFromRefreshToken
Expand All @@ -11,35 +13,39 @@ import { UserAccessToken } from '../models/UserAccessToken.js';
* @param {UserRepository} params.userRepository
* @returns {Promise<{accessToken: (*), expirationDelaySeconds: *}>}
*/
const createAccessTokenFromRefreshToken = async function ({
export async function createAccessTokenFromRefreshToken({
refreshToken,
audience,
locale,
refreshTokenRepository,
userRepository,
}) {
const foundRefreshToken = await refreshTokenRepository.findByToken({ token: refreshToken });
let decodedRefreshToken;

if (!foundRefreshToken) {
if (RefreshToken.isRefreshToken(refreshToken)) {
decodedRefreshToken = await refreshTokenRepository.findByToken({ token: refreshToken });
} else {
decodedRefreshToken = UserRefreshToken.decode(refreshToken);
}

if (!decodedRefreshToken) {
throw new UnauthorizedError('Refresh token is invalid', 'INVALID_REFRESH_TOKEN');
}

if (!foundRefreshToken.hasSameAudience(audience)) {
if (!decodedRefreshToken.hasSameAudience(audience)) {
throw new UnauthorizedError('Refresh token is invalid', 'INVALID_REFRESH_TOKEN');
}

const foundUser = await userRepository.findById(foundRefreshToken.userId);
const foundUser = await userRepository.findById(decodedRefreshToken.userId);
const changedLocale = foundUser.changeLocale(locale);
if (changedLocale) {
await userRepository.update({ id: foundUser.id, locale: foundUser.locale });
}

return UserAccessToken.generateUserToken({
userId: foundRefreshToken.userId,
source: foundRefreshToken.source,
userId: decodedRefreshToken.userId,
source: decodedRefreshToken.source,
audience,
sessionId: foundRefreshToken.sessionId,
sessionId: decodedRefreshToken.sessionId,
});
};

export { createAccessTokenFromRefreshToken };
}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { RefreshToken } from '../models/RefreshToken.js';

/**
* @param {{
* refreshToken: string,
Expand All @@ -6,5 +8,7 @@
* @return {Promise<void>}
*/
export const revokeRefreshToken = async function ({ refreshToken, refreshTokenRepository }) {
if (!RefreshToken.isRefreshToken(refreshToken)) return;

await refreshTokenRepository.revokeByToken({ token: refreshToken });
};
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from 'chai';
import jsonwebtoken from 'jsonwebtoken';
import sinon from 'sinon';

import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../../src/identity-access-management/domain/constants/identity-providers.js';
Expand All @@ -11,6 +12,7 @@ import { UserAccessToken } from '../../../../../src/identity-access-management/d
import { usecases } from '../../../../../src/identity-access-management/domain/usecases/index.js';
import { config } from '../../../../../src/shared/config.js';
import { ForbiddenAccess } from '../../../../../src/shared/domain/errors.js';
import { featureToggles } from '../../../../../src/shared/infrastructure/feature-toggles/index.js';
import { RequestedApplication } from '../../../../../src/shared/infrastructure/utils/network.js';
import { databaseBuilder, knex } from '../../../../tooling/databases.js';

Expand Down Expand Up @@ -43,13 +45,49 @@ describe('Integration | Identity Access Management | Domain | UseCase | authenti
expect(result).to.be.an.instanceOf(Object);
expect(result).to.have.all.keys('accessToken', 'refreshToken', 'expirationDelaySeconds');
expect(result.accessToken).to.be.a('string');
expect(result.refreshToken).to.be.a('string');
expect(result.refreshToken)
.to.be.a('string')
.that.matches(/^\d+:\p{Hex_Digit}{8}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{12}$/u);
expect(result.expirationDelaySeconds).to.be.a('number');

const decodedAccessToken = UserAccessToken.decode(result.accessToken);
expect(decodedAccessToken.sessionId).to.be.a('string');
});

describe('when isSessionLogoutEnabled is true', function () {
beforeEach(async function () {
await featureToggles.set('isSessionLogoutEnabled', true);
});

it('generates a JWT for refresh token', async function () {
// given
const email = 'user_exists@example.net';
const password = 'some password';
const { id: userId } = databaseBuilder.factory.buildUser.withRawPassword({ email, rawPassword: password });
await databaseBuilder.commit();

const audience = 'https://app.pix.fr';
const requestedApplication = RequestedApplication.fromOrigin(audience);

// when
const result = await usecases.authenticateUser({ username: email, password, requestedApplication, audience });

// then
expect(result.refreshToken).to.be.a('string');

const decodedRefreshToken = jsonwebtoken.verify(result.refreshToken, config.authentication.secret);
expect(decodedRefreshToken).to.include({
user_id: userId,
aud: audience,
});
expect(decodedRefreshToken)
.to.have.property('sid')
.that.matches(/^\p{Hex_Digit}{8}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{4}-\p{Hex_Digit}{12}$/u);
expect(decodedRefreshToken).to.have.property('iat').that.is.a('number');
expect(decodedRefreshToken).to.have.property('exp').that.is.a('number');
});
});

it('saves the last dates of login', async function () {
// given
const email = 'user_will_have_last_date_of_login@example.net';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,29 @@ describe('Unit | Identity Access Management | Domain | Model | RefreshToken', fu
expect(withDifferentAudience).to.be.false;
});
});

describe('#RefreshToken.isRefreshToken', function () {
it('returns true if token uses legacy format', function () {
// given
const legacyToken = '123456:91b952ad-c0f7-4ea6-94a7-28f4b489153e';
const invalidToken1 = 'abcdef:91b952ad-c0f7-4ea6-94a7-28f4b489153e';
const invalidToken2 = '12345:91b952ad-c0f7-4ea6-94a7-28f4b489153z';
const invalidToken3 = 'invalid-token';
const jwtToken = 'Abcd1324.EfGh5678.IJKl90';

// when
const result1 = RefreshToken.isRefreshToken(legacyToken);
const result2 = RefreshToken.isRefreshToken(invalidToken1);
const result3 = RefreshToken.isRefreshToken(invalidToken2);
const result4 = RefreshToken.isRefreshToken(invalidToken3);
const result5 = RefreshToken.isRefreshToken(jwtToken);

// then
expect(result1).to.be.true;
expect(result2).to.be.false;
expect(result3).to.be.false;
expect(result4).to.be.false;
expect(result5).to.be.false;
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { expect } from 'chai';
import jsonwebtoken from 'jsonwebtoken';

import { UserRefreshToken } from '../../../../../src/identity-access-management/domain/models/UserRefreshToken.js';
import { config } from '../../../../../src/shared/config.js';

describe('Unit | Identity Access Management | Domain | Model | UserRefreshToken', function () {
describe('UserRefreshToken.decode', function () {
it('decodes a valid token', function () {
// given
const encodedRefreshToken = jsonwebtoken.sign(
{
user_id: 123456,
source: 'source!',
aud: 'audience!',
sid: 'ABC-123-321',
},
config.authentication.secret,
{ expiresIn: config.authentication.refreshTokenLifespanMs / 1000 },
);

// when
const decoded = UserRefreshToken.decode(encodedRefreshToken);

// then
expect(decoded).to.be.instanceOf(UserRefreshToken);
expect(decoded).to.deep.equal({
userId: 123456,
source: 'source!',
audience: 'audience!',
sessionId: 'ABC-123-321',
});
});

it('returns undefined for an invalid token', async function () {
// given
const invalidToken = 'invalid.token';

// when
const decoded = UserRefreshToken.decode(invalidToken);

// then
expect(decoded).to.be.undefined;
});
});

describe('UserRefreshToken.generateUserToken', function () {
it('returns an encoded refresh token', function () {
// given
const payload = {
userId: 123456,
source: 'source!',
audience: 'audience!',
sessionId: 'sessionId!',
};

// when
const refreshToken = UserRefreshToken.generate(payload);

// then
expect(refreshToken).to.be.a('string');
const decodedRefreshToken = jsonwebtoken.verify(refreshToken, config.authentication.secret);
expect(decodedRefreshToken).to.include({
user_id: 123456,
source: 'source!',
aud: 'audience!',
sid: 'sessionId!',
});
expect(decodedRefreshToken).to.have.property('iat').which.is.a('number');
expect(decodedRefreshToken).to.have.property('exp').which.is.a('number');
});
});

describe('#hasSameAudience', function () {
it('returns true with same audience otherwise false', function () {
// given
const refreshToken = new UserRefreshToken({
userId: 123456,
source: 'source!',
audience: 'https://app.pix.fr',
sessionId: 'sessionId!',
});

// when
const withSameAudience = refreshToken.hasSameAudience('https://app.pix.fr');
const withDifferentAudience = refreshToken.hasSameAudience('https://orga.pix.fr');

// then
expect(withSameAudience).to.be.true;
expect(withDifferentAudience).to.be.false;
});
});
});
Loading
Loading