Skip to content

[FEATURE] Migrer les applications en BDD (PIX-16590). #11434

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

Merged
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
23 changes: 23 additions & 0 deletions api/db/database-builder/factory/build-client-application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { cryptoService } from '../../../src/shared/domain/services/crypto-service.js';
import { databaseBuffer } from '../database-buffer.js';

export function buildClientApplication({
id = databaseBuffer.getNextId(),
name = 'clientApplication',
clientId = 'client-id',
clientSecret = 'super-secret',
scopes = ['scope1', 'scope2'],
} = {}) {
// eslint-disable-next-line no-sync
const hashedSecret = cryptoService.hashPasswordSync(clientSecret);
return databaseBuffer.pushInsertable({
tableName: 'client_applications',
values: {
id,
name,
clientId,
clientSecret: hashedSecret,
scopes,
},
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
const TABLE_NAME = 'client_applications';

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function up(knex) {
await knex.schema.createTable(TABLE_NAME, function (table) {
table.increments('id').primary().notNullable().comment('Identifiant d’une application tierce');
table.string('name').notNullable().unique().comment('Nom de l’application tierce');
table.string('clientId').notNullable().unique().comment('ID du client de l’application tierce');
table.string('clientSecret').notNullable().comment('Secret du client de l’application tierce');
table.specificType('scopes', 'varchar(255)[]').notNullable().comment('Scopes autorisés pour l’application');
table.timestamps(false, true, true);
table.comment('Applications tierces');
});
}

/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
export async function down(knex) {
await knex.schema.dropTable(TABLE_NAME);
}
29 changes: 29 additions & 0 deletions api/db/seeds/data/common/common-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ export const commonBuilder = async function ({ databaseBuilder }) {
_createSupportAdmin(databaseBuilder);
_createMetierAdmin(databaseBuilder);

createClientApplications(databaseBuilder);

await _createPublicTargetProfile(databaseBuilder);
await databaseBuilder.commit();
};
Expand Down Expand Up @@ -59,6 +61,33 @@ function _createCertifAdmin(databaseBuilder) {
databaseBuilder.factory.buildPixAdminRole({ userId: REAL_PIX_SUPER_ADMIN_ID + 3, role: ROLES.CERTIF });
}

function createClientApplications(databaseBuilder) {
databaseBuilder.factory.buildClientApplication({
name: 'livretScolaire',
clientId: 'livretScolaire',
clientSecret: 'livretScolaireSecret',
scopes: ['organizations-certifications-result'],
});
databaseBuilder.factory.buildClientApplication({
name: 'poleEmploi',
clientId: 'poleEmploi',
clientSecret: 'poleemploisecret',
scopes: ['pole-emploi-participants-result'],
});
databaseBuilder.factory.buildClientApplication({
name: 'pixData',
clientId: 'pixData',
clientSecret: 'pixdatasecret',
scopes: ['statistics'],
});
databaseBuilder.factory.buildClientApplication({
name: 'parcoursup',
clientId: 'parcoursup',
clientSecret: 'parcoursupsecret',
scopes: ['parcoursup'],
});
}

function _createPublicTargetProfile(databaseBuilder) {
return createTargetProfile({
databaseBuilder,
Expand Down
44 changes: 24 additions & 20 deletions api/lib/domain/usecases/authenticate-application.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,50 @@
import lodash from 'lodash';

import { config } from '../../../src/shared/config.js';
import {
ApplicationScopeNotAllowedError,
ApplicationWithInvalidClientIdError,
ApplicationWithInvalidClientSecretError,
} from '../../../src/shared/domain/errors.js';
const { apimRegisterApplicationsCredentials, jwtConfig } = config;

const { find } = lodash;

const authenticateApplication = async function ({ clientId, clientSecret, scope, tokenService }) {
const application = find(apimRegisterApplicationsCredentials, { clientId });
_checkClientId(application, clientId);
_checkClientSecret(application, clientSecret);
const { authentication } = config;

export async function authenticateApplication({
clientId,
clientSecret,
scope,
tokenService,
clientApplicationRepository,
cryptoService,
}) {
const application = await clientApplicationRepository.findByClientId(clientId);
_checkApplication(application, clientId);
await _checkClientSecret(application, clientSecret, cryptoService);
_checkAppScope(application, scope);

return tokenService.createAccessTokenFromApplication(
clientId,
application.source,
application.name,
scope,
jwtConfig[application.source].secret,
jwtConfig[application.source].tokenLifespan,
authentication.secret,
authentication.accessTokenLifespanMs,
);
};
}

function _checkClientId(application, clientId) {
if (!application || application.clientId !== clientId) {
function _checkApplication(application) {
if (!application) {
throw new ApplicationWithInvalidClientIdError('The client ID is invalid.');
}
}

function _checkClientSecret(application, clientSecret) {
if (application.clientSecret !== clientSecret) {
async function _checkClientSecret(application, clientSecret, cryptoService) {
try {
await cryptoService.checkPassword({ password: clientSecret, passwordHash: application.clientSecret });
} catch {
throw new ApplicationWithInvalidClientSecretError('The client secret is invalid.');
}
}

function _checkAppScope(application, scope) {
if (application.scope !== scope) {
if (!application.scopes.includes(scope)) {
throw new ApplicationScopeNotAllowedError('The scope is invalid.');
}
}

export { authenticateApplication };
2 changes: 2 additions & 0 deletions api/lib/domain/usecases/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ import * as resetPasswordService from '../../../src/identity-access-management/d
import { scoAccountRecoveryService } from '../../../src/identity-access-management/domain/services/sco-account-recovery.service.js';
import { accountRecoveryDemandRepository } from '../../../src/identity-access-management/infrastructure/repositories/account-recovery-demand.repository.js';
import * as authenticationMethodRepository from '../../../src/identity-access-management/infrastructure/repositories/authentication-method.repository.js';
import { clientApplicationRepository } from '../../../src/identity-access-management/infrastructure/repositories/client-application.repository.js';
import { emailValidationDemandRepository } from '../../../src/identity-access-management/infrastructure/repositories/email-validation-demand.repository.js';
// Not used in lib
import { userAnonymizedEventLoggingJobRepository } from '../../../src/identity-access-management/infrastructure/repositories/jobs/user-anonymized-event-logging-job-repository.js';
Expand Down Expand Up @@ -244,6 +245,7 @@ const dependencies = {
certificationCpfCityRepository,
certificationOfficerRepository,
challengeRepository,
clientApplicationRepository,
codeGenerator,
codeUtils,
competenceEvaluationRepository,
Expand Down
44 changes: 44 additions & 0 deletions api/scripts/prod/migrate-client-application.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { knex } from '../../db/knex-database-connection.js';
import { Script } from '../../src/shared/application/scripts/script.js';
import { ScriptRunner } from '../../src/shared/application/scripts/script-runner.js';
import { config } from '../../src/shared/config.js';
import { cryptoService } from '../../src/shared/domain/services/crypto-service.js';

export class MigrateClientApplicationScript extends Script {
constructor() {
super({
description: 'This script will migrate client application from environment variables (config) to database.',
permanent: false,
options: {
dryRun: {
type: 'boolean',
default: true,
description: 'when true does not insert to database',
},
},
});
}

async handle({ options, logger }) {
await knex.transaction(async (trx) => {
const { apimRegisterApplicationsCredentials } = config;

for (const clientApplication of apimRegisterApplicationsCredentials) {
const query = trx('client_applications').insert({
clientId: clientApplication.clientId,
clientSecret: await cryptoService.hashPassword(clientApplication.clientSecret),
name: clientApplication.source,
scopes: [clientApplication.scope],
});
logger.info({ event: 'MigrateClientApplicationScript' }, query.toString());
await query;
}
if (options.dryRun) {
logger.info({ event: 'MigrateClientApplicationScript' }, 'Dry run, rolling back insertions');
await trx.rollback();
}
});
}
}

await ScriptRunner.execute(import.meta.url, MigrateClientApplicationScript);
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class ClientApplication {
constructor({ id, name, clientId, clientSecret, scopes }) {
this.id = id;
this.name = name;
this.clientId = clientId;
this.clientSecret = clientSecret;
this.scopes = scopes;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { knex } from '../../../../db/knex-database-connection.js';
import { ClientApplication } from '../../domain/models/ClientApplication.js';

const TABLE_NAME = 'client_applications';

export const clientApplicationRepository = {
async findByClientId(clientId) {
const dto = await knex.select().from(TABLE_NAME).where({ clientId }).first();
if (!dto) return undefined;
return toDomain(dto);
},
};

function toDomain(dto) {
return new ClientApplication(dto);
}
1 change: 1 addition & 0 deletions api/src/shared/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,7 @@ const configuration = (function () {
};

config.jwtConfig.livretScolaire.secret = 'test-secretOsmose';
config.jwtConfig.livretScolaire.tokenLifespan = '4h';
config.jwtConfig.poleEmploi.secret = 'test-secretPoleEmploi';
config.jwtConfig.pixData.secret = 'test-secretPixData';
config.jwtConfig.parcoursup.secret = 'test-secretPixParcoursup';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ describe('Acceptance | Controller | authentication-controller', function () {
'x-forwarded-host': 'app.pix.fr',
},
};

databaseBuilder.factory.buildClientApplication({
name: 'osmose',
clientId: OSMOSE_CLIENT_ID,
clientSecret: OSMOSE_CLIENT_SECRET,
scopes: [SCOPE],
});
await databaseBuilder.commit();
});

it('should return an 200 with accessToken when clientId, client secret and scope are registred', async function () {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { clientApplicationRepository } from '../../../../../src/identity-access-management/infrastructure/repositories/client-application.repository.js';
import { databaseBuilder, domainBuilder, expect } from '../../../../test-helper.js';

describe('Integration | Identity Access Management | Infrastructure | Repository | client-application', function () {
describe('#findByClientId', function () {
let application2;

beforeEach(async function () {
databaseBuilder.factory.buildClientApplication({
name: 'appli1',
clientId: 'clientId-appli1',
clientSecret: 'secret-app1',
scopes: ['scope1', 'scope2'],
});
application2 = databaseBuilder.factory.buildClientApplication({
name: 'appli2',
clientId: 'clientId-appli2',
clientSecret: 'secret-app2',
scopes: ['scope3', 'scope4', 'scope5'],
});

await databaseBuilder.commit();
});

context('when application name is not found', function () {
it('should return undefined', async function () {
// given
const clientId = 'clientId-appli3';

// when
const application = await clientApplicationRepository.findByClientId(clientId);

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

context('when application name is found', function () {
it('should return the application model', async function () {
// given
const clientId = 'clientId-appli2';

// when
const application = await clientApplicationRepository.findByClientId(clientId);

// then
expect(application).to.deepEqualInstance(domainBuilder.buildClientApplication(application2));
});
});
});
});
Loading