Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Utiliser le scope dans les refresh tokens (PIX-13911) #10039

Merged
merged 7 commits into from
Sep 6, 2024
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -26,37 +26,42 @@ const createToken = async function (request, h, dependencies = { tokenService })
let accessToken, refreshToken;
let expirationDelaySeconds;

if (request.payload.grant_type === 'refresh_token') {
const grantType = request.payload.grant_type;
const scope = request.payload.scope;
bpetetot marked this conversation as resolved.
Show resolved Hide resolved

if (grantType === 'refresh_token') {
refreshToken = request.payload.refresh_token;
const accessTokenAndExpirationDelaySeconds = await usecases.createAccessTokenFromRefreshToken({ refreshToken });
accessToken = accessTokenAndExpirationDelaySeconds.accessToken;
expirationDelaySeconds = accessTokenAndExpirationDelaySeconds.expirationDelaySeconds;
} else if (request.payload.grant_type === 'password') {

// TODO: we should pass the scope when ember-simple-auth will pass it
bpetetot marked this conversation as resolved.
Show resolved Hide resolved
// see https://github.com/mainmatter/ember-simple-auth/pull/2813 for further details
const tokensInfo = await usecases.createAccessTokenFromRefreshToken({ refreshToken });

accessToken = tokensInfo.accessToken;
expirationDelaySeconds = tokensInfo.expirationDelaySeconds;
} else if (grantType === 'password') {
const { username, password, scope } = request.payload;
const localeFromCookie = request.state?.locale;

const source = 'pix';
const tokensAndExpirationDelaySeconds = await usecases.authenticateUser({
username,
password,
scope,
source,
localeFromCookie,
});
accessToken = tokensAndExpirationDelaySeconds.accessToken;
refreshToken = tokensAndExpirationDelaySeconds.refreshToken;
expirationDelaySeconds = tokensAndExpirationDelaySeconds.expirationDelaySeconds;

const tokensInfo = await usecases.authenticateUser({ username, password, scope, source, localeFromCookie });

accessToken = tokensInfo.accessToken;
refreshToken = tokensInfo.refreshToken;
expirationDelaySeconds = tokensInfo.expirationDelaySeconds;
} else {
throw new BadRequestError('Invalid grant type');
}

const userId = dependencies.tokenService.extractUserId(accessToken);

return h
.response({
token_type: 'bearer',
user_id: userId,
access_token: accessToken,
user_id: dependencies.tokenService.extractUserId(accessToken),
refresh_token: refreshToken,
expires_in: expirationDelaySeconds,
scope,
})
.code(200)
.header('Content-Type', 'application/json;charset=UTF-8')
Expand Down
10 changes: 9 additions & 1 deletion api/src/identity-access-management/domain/models/User.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,15 @@ class User {
(authenticationMethod) => authenticationMethod.identityProvider === NON_OIDC_IDENTITY_PROVIDERS.PIX.code,
);

return pixAuthenticationMethod ? pixAuthenticationMethod.authenticationComplement.shouldChangePassword : null;
return pixAuthenticationMethod ? pixAuthenticationMethod.authenticationComplement?.shouldChangePassword : null;
}

get passwordHash() {
const pixAuthenticationMethod = this.authenticationMethods.find(
(authenticationMethod) => authenticationMethod.identityProvider === NON_OIDC_IDENTITY_PROVIDERS.PIX.code,
);

return pixAuthenticationMethod ? pixAuthenticationMethod.authenticationComplement?.password : null;
}

get shouldSeeDataProtectionPolicyInformationBanner() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,15 @@ async function getUserByUsernameAndPassword({
dependencies = { userLoginRepository, cryptoService },
}) {
const foundUser = await userRepository.getByUsernameOrEmailWithRolesAndPassword(username);
const passwordHash = foundUser.authenticationMethods[0].authenticationComplement.password;

let userLogin = await dependencies.userLoginRepository.findByUserId(foundUser.id);
if (!userLogin) {
userLogin = await dependencies.userLoginRepository.create({ userId: foundUser.id });
}

try {
await dependencies.cryptoService.checkPassword({
password,
passwordHash,
});
const passwordHash = foundUser.passwordHash;
await dependencies.cryptoService.checkPassword({ password, passwordHash });
} catch (error) {
if (error instanceof PasswordNotMatching) {
userLogin.incrementFailureCount();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const REFRESH_TOKEN_EXPIRATION_DELAY_ADDITION_SECONDS = 60 * 60; // 1 hour

/**
* @param refreshToken
* @returns {Promise<{userId: string, source: string}>}
* @returns {Promise<{userId: string, source: string, scope: string}>}
*/
async function findByRefreshToken(refreshToken) {
return refreshTokenTemporaryStorage.get(refreshToken);
Expand All @@ -30,17 +30,18 @@ async function findByUserId(userId) {
* @typedef {function} createRefreshTokenFromUserId
* @param {Object} params
* @param {string} params.userId
* @param {string} params.scope
* @param {string} params.source
* @param {function} params.uuidGenerator
* @return {Promise<string>}
*/
async function createRefreshTokenFromUserId({ userId, source, uuidGenerator = randomUUID }) {
async function createRefreshTokenFromUserId({ userId, scope, source, uuidGenerator = randomUUID }) {
const expirationDelaySeconds = config.authentication.refreshTokenLifespanMs / 1000;
const refreshToken = `${_prefixForUser(userId)}${uuidGenerator()}`;
const refreshToken = [userId, scope, uuidGenerator()].filter(Boolean).join(':');

await refreshTokenTemporaryStorage.save({
key: refreshToken,
value: { type: 'refresh_token', userId, source },
value: { type: 'refresh_token', userId, scope, source },
expirationDelaySeconds,
});
await userRefreshTokensTemporaryStorage.lpush({ key: userId, value: refreshToken });
Expand All @@ -56,10 +57,14 @@ async function createRefreshTokenFromUserId({ userId, source, uuidGenerator = ra
* @typedef {function} createAccessTokenFromRefreshToken
* @param {Object} params
* @param {string} params.refreshToken
* @param {string} params.scope
* @return {Promise<{expirationDelaySeconds: number, accessToken: string}>}
*/
async function createAccessTokenFromRefreshToken({ refreshToken }) {
const { userId, source } = (await findByRefreshToken(refreshToken)) || {};
async function createAccessTokenFromRefreshToken({ refreshToken, scope: targetScope }) {
const { userId, source, scope } = (await findByRefreshToken(refreshToken)) || {};
bpetetot marked this conversation as resolved.
Show resolved Hide resolved
if (scope && targetScope && scope !== targetScope) {
throw new UnauthorizedError('Refresh token is invalid', 'INVALID_REFRESH_TOKEN');
}
if (!userId) throw new UnauthorizedError('Refresh token is invalid', 'INVALID_REFRESH_TOKEN');
return tokenService.createAccessTokenFromUser(userId, source);
}
Expand Down Expand Up @@ -106,7 +111,3 @@ export const refreshTokenService = {
revokeRefreshToken,
revokeRefreshTokensForUserId,
};

function _prefixForUser(userId) {
return `${userId}:`;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,3 @@
import lodash from 'lodash';

const { get } = lodash;

import { PIX_ADMIN, PIX_ORGA } from '../../../authorization/domain/constants.js';
import { ForbiddenAccess, LocaleFormatError, LocaleNotSupportedError } from '../../../shared/domain/errors.js';
import { MissingOrInvalidCredentialsError, UserShouldChangePasswordError } from '../errors.js';
Expand Down Expand Up @@ -39,20 +35,20 @@ const authenticateUser = async function ({
userRepository,
});

const shouldChangePassword = get(
foundUser,
'authenticationMethods[0].authenticationComplement.shouldChangePassword',
);

if (shouldChangePassword) {
if (foundUser.shouldChangePassword) {
const passwordResetToken = tokenService.createPasswordResetToken(foundUser.id);
throw new UserShouldChangePasswordError(undefined, passwordResetToken);
}

await _checkUserAccessScope(scope, foundUser, adminMemberRepository);
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({ userId: foundUser.id, source });
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({
userId: foundUser.id,
source,
scope,
});
const { accessToken, expirationDelaySeconds } = await refreshTokenService.createAccessTokenFromRefreshToken({
refreshToken,
scope,
});

foundUser.setLocaleIfNotAlreadySet(localeFromCookie);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const createAccessTokenFromRefreshToken = async function ({ refreshToken, refreshTokenService }) {
return refreshTokenService.createAccessTokenFromRefreshToken({ refreshToken });
const createAccessTokenFromRefreshToken = async function ({ refreshToken, scope, refreshTokenService }) {
return refreshTokenService.createAccessTokenFromRefreshToken({ refreshToken, scope });
};

export { createAccessTokenFromRefreshToken };
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe('Acceptance | Identity Access Management | Route | Token', function ()
grant_type: 'password',
username: userEmailAddress,
password: userPassword,
scope: 'pix',
scope: 'pix-orga',
}),
});

Expand All @@ -131,6 +131,7 @@ describe('Acceptance | Identity Access Management | Route | Token', function ()
payload: querystring.stringify({
grant_type: 'refresh_token',
refresh_token: accessTokenResult.refresh_token,
scope: 'pix-orga',
}),
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,50 +9,147 @@ describe('Integration | Identity Access Management | Domain | Service | refresh-
});

describe('#createRefreshTokenFromUserId', function () {
it('generates a refresh token', async function () {
// given
const userId = '123';
const source = 'APP';
const uuidGenerator = () => 'XXX-123-456';
context('when an application scope is given', function () {
it('generates a refresh token <user-id>:<scope>:<uuid>', async function () {
// given
const userId = '123';
const source = 'APP';
const scope = 'pix-orga';
const uuidGenerator = () => 'XXX-123-456';

// when
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({ userId, source, uuidGenerator });
// when
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({
userId,
source,
scope,
uuidGenerator,
});

// then
expect(refreshToken).to.equal('123:XXX-123-456');
// then
expect(refreshToken).to.equal('123:pix-orga:XXX-123-456');

const refreshTokenInDb = await refreshTokenService.findByRefreshToken(refreshToken);
expect(refreshTokenInDb).to.deep.equal({ type: 'refresh_token', source: 'APP', userId: '123' });
const refreshTokenInDb = await refreshTokenService.findByRefreshToken(refreshToken);
expect(refreshTokenInDb).to.deep.equal({ type: 'refresh_token', source, scope, userId });

const refreshTokensInDb = await refreshTokenService.findByUserId(userId);
expect(refreshTokensInDb).to.deep.equal(['123:XXX-123-456']);
const refreshTokensInDb = await refreshTokenService.findByUserId(userId);
expect(refreshTokensInDb).to.deep.equal(['123:pix-orga:XXX-123-456']);
});
});

context('when no application scope is given (legacy)', function () {
it('generates a refresh token <user-id>:<uuid>', async function () {
// given
const userId = '123';
const source = 'APP';
const uuidGenerator = () => 'XXX-123-456';

// when
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({ userId, source, uuidGenerator });

// then
expect(refreshToken).to.equal('123:XXX-123-456');

const refreshTokenInDb = await refreshTokenService.findByRefreshToken(refreshToken);
expect(refreshTokenInDb).to.deep.equal({ type: 'refresh_token', source, userId });

const refreshTokensInDb = await refreshTokenService.findByUserId(userId);
expect(refreshTokensInDb).to.deep.equal(['123:XXX-123-456']);
});
});
});

describe('#createAccessTokenFromRefreshToken', function () {
it('generates an access token from a valid refresh token', async function () {
// given
const userId = '123';
const source = 'APP';
const uuidGenerator = () => 'XXX-123-456';
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({ userId, source, uuidGenerator });
context('with refresh token containing a scope', function () {
it('generates an access token from a valid legacy refresh token', async function () {
// given
const userId = '123';
const source = 'APP';
const scope = 'pix-orga';
const uuidGenerator = () => 'XXX-123-456';
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({
userId,
source,
scope,
uuidGenerator,
});

// when
const { accessToken } = await refreshTokenService.createAccessTokenFromRefreshToken({ refreshToken });
// when
const { accessToken } = await refreshTokenService.createAccessTokenFromRefreshToken({ refreshToken, scope });

// then
expect(accessToken).to.be.a.string;
// then
expect(accessToken).to.be.a.string;
});

context('when the scope is different from the refresh token scope', function () {
it('throws an error', async function () {
// given
const userId = '123';
const source = 'APP';
const scope = 'pix-orga';
const differentScope = 'pix-admin';
const uuidGenerator = () => 'XXX-123-456';
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({
userId,
source,
scope,
uuidGenerator,
});

// when
const error = await catchErr(refreshTokenService.createAccessTokenFromRefreshToken)({
refreshToken,
scope: differentScope,
});

// then
expect(error).to.be.instanceOf(UnauthorizedError);
expect(error.message).to.be.equal('Refresh token is invalid');
expect(error.code).to.be.equal('INVALID_REFRESH_TOKEN');
});
});

context('when refresh token is invalid', function () {
it('throws an error', async function () {
// when
const error = await catchErr(refreshTokenService.createAccessTokenFromRefreshToken)({
refreshToken: 'BLABLA',
});

// then
expect(error).to.be.instanceOf(UnauthorizedError);
expect(error.message).to.be.equal('Refresh token is invalid');
expect(error.code).to.be.equal('INVALID_REFRESH_TOKEN');
});
});
});
context('with legacy refresh token (without scope)', function () {
it('generates an access token from a valid legacy refresh token', async function () {
// given
const userId = '123';
const source = 'APP';
const scope = 'pix-orga';
const uuidGenerator = () => 'XXX-123-456';
const refreshToken = await refreshTokenService.createRefreshTokenFromUserId({ userId, source, uuidGenerator });

context('when refresh token is invalid', function () {
it('throws an error', async function () {
// when
const error = await catchErr(refreshTokenService.createAccessTokenFromRefreshToken)({ refreshToken: 'BLABLA' });
const { accessToken } = await refreshTokenService.createAccessTokenFromRefreshToken({ refreshToken, scope });

// then
expect(error).to.be.instanceOf(UnauthorizedError);
expect(error.message).to.be.equal('Refresh token is invalid');
expect(error.code).to.be.equal('INVALID_REFRESH_TOKEN');
expect(accessToken).to.be.a.string;
});

context('when refresh token is invalid', function () {
it('throws an error', async function () {
// when
const error = await catchErr(refreshTokenService.createAccessTokenFromRefreshToken)({
refreshToken: 'BLABLA',
});

// then
expect(error).to.be.instanceOf(UnauthorizedError);
expect(error.message).to.be.equal('Refresh token is invalid');
expect(error.code).to.be.equal('INVALID_REFRESH_TOKEN');
});
});
});
});
Expand Down
Loading