Repository: 1024pix/pix
Pull request #11552: [FEATURE] Enregistrer la date de dernière connexion Pix pour les méthodes de connexion GAR (PIX-16624)
Author: login: P-Jeremy
Labels: name: Tech Review OK
name: :rocket: Ready to Merge
name: Func Review OK
description: PO validated functionally the PR
name: team-acces
Created: 2025-03-03T14:16:15Z | Merged: 2025-03-05T14:21:34Z

## :pancakes: Problème

Quand un utilisateur se connecte avec une méthode de connexionGAR, on souhaite stocker la date de dernière connexion pour l’application connecté et par méthode de connexion.

🥓 Proposition
Quand un utilisateur se connecte avec une méthode de connexion GAR, stocker la date de dernière connexion pour l’application connecté et par méthode de connexion.


## :yum: Pour tester

Route POST /api/sco-organization-learners/external

- Depuis l'app pix-saml-idp (localhost:7654), se réconcilier avec un elisa delajungle (01/01/2011), numéro de campagne SCOBADGE1
- Constater en base une écriture sur la table "last-user-application-connections"
- Constater en base une écriture sur la table "authentication-méthods" avec l'identityProvider (GAR) et le lastLoggedAt (date de la réconciliation)

Route POST /api/saml/assert
- Se déconnecter de pix app
- Recommencer l'étape 1 avec le même user ( sans réconciliation )
- Constater en base une écriture sur la table "last-user-application-connections"
- Constater en base une écriture sur la table "authentication-méthods" avec l'identityProvider (GAR) et le lastLoggedAt (date de reconnexion)


Diff:
diff --git a/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js b/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js
index c81c59a8ea7..fc3652de4fb 100644
--- a/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js
+++ b/api/lib/application/sco-organization-learners/sco-organization-learner-controller.js
@@ -1,7 +1,10 @@
 import dayjs from 'dayjs';
 
 import * as studentInformationForAccountRecoverySerializer from '../../../src/identity-access-management/infrastructure/serializers/jsonapi/student-information-for-account-recovery-serializer.js';
-import { getForwardedOrigin } from '../../../src/identity-access-management/infrastructure/utils/network.js';
+import {
+  getForwardedOrigin,
+  RequestedApplication,
+} from '../../../src/identity-access-management/infrastructure/utils/network.js';
 import * as scoOrganizationLearnerSerializer from '../../../src/prescription/learner-management/infrastructure/serializers/jsonapi/sco-organization-learner-serializer.js';
 import { DomainTransaction } from '../../../src/shared/domain/DomainTransaction.js';
 import * as requestResponseUtils from '../../../src/shared/infrastructure/utils/request-response-utils.js';
@@ -68,11 +71,13 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
   const { birthdate, 'campaign-code': campaignCode, 'external-user-token': token } = request.payload.data.attributes;
 
   const origin = getForwardedOrigin(request.headers);
+  const requestedApplication = RequestedApplication.fromOrigin(origin);
   const accessToken = await usecases.createUserAndReconcileToOrganizationLearnerFromExternalUser({
     birthdate,
     campaignCode,
     token,
     audience: origin,
+    requestedApplication,
   });
 
   const scoOrganizationLearner = {
diff --git a/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js b/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js
index d72fb840833..83634e0a818 100644
--- a/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js
+++ b/api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js
@@ -17,6 +17,7 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
   obfuscationService,
   tokenService,
   audience,
+  requestedApplication,
   userReconciliationService,
   userService,
   authenticationMethodRepository,
@@ -26,6 +27,7 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
   userToCreateRepository,
   organizationLearnerRepository,
   prescriptionOrganizationLearnerRepository,
+  lastUserApplicationConnectionsRepository,
   studentRepository,
 }) {
   const campaign = await campaignRepository.getByCode(campaignCode);
@@ -115,9 +117,37 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
     }
   }
   const tokenUserId = userWithSamlId ? userWithSamlId.id : userId;
+
+  await _updateUserLastConnection({
+    userId: tokenUserId,
+    requestedApplication,
+    authenticationMethodRepository,
+    lastUserApplicationConnectionsRepository,
+    userLoginRepository,
+  });
+
   const accessToken = tokenService.createAccessTokenForSaml({ userId: tokenUserId, audience });
-  await userLoginRepository.updateLastLoggedAt({ userId: tokenUserId });
+
   return accessToken;
 };
 
 export { createUserAndReconcileToOrganizationLearnerFromExternalUser };
+
+async function _updateUserLastConnection({
+  userId,
+  requestedApplication,
+  authenticationMethodRepository,
+  lastUserApplicationConnectionsRepository,
+  userLoginRepository,
+}) {
+  await userLoginRepository.updateLastLoggedAt({ userId });
+  await lastUserApplicationConnectionsRepository.upsert({
+    userId,
+    application: requestedApplication.applicationName,
+    lastLoggedAt: new Date(),
+  });
+  await authenticationMethodRepository.updateLastLoggedAtByIdentityProvider({
+    userId,
+    identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code,
+  });
+}
diff --git a/api/lib/domain/usecases/index.js b/api/lib/domain/usecases/index.js
index 92662687954..9f0144cfb76 100644
--- a/api/lib/domain/usecases/index.js
+++ b/api/lib/domain/usecases/index.js
@@ -103,6 +103,7 @@ const oidcAuthenticationServiceRegistry = new OidcAuthenticationServiceRegistry(
  * @typedef {userRepository} UserRepository
  * @typedef {certificationChallengesService} CertificationChallengesService
  * @typedef {assessmentRepository} AssessmentRepository
+ * @typedef {LastUserApplicationConnectionsRepository} LastUserApplicationConnectionsRepository
  */
 const dependencies = {
   accountRecoveryDemandRepository,
diff --git a/api/src/identity-access-management/application/saml/saml.controller.js b/api/src/identity-access-management/application/saml/saml.controller.js
index ace2f09d205..66a0a31b0f7 100644
--- a/api/src/identity-access-management/application/saml/saml.controller.js
+++ b/api/src/identity-access-management/application/saml/saml.controller.js
@@ -3,7 +3,7 @@ import { tokenService } from '../../../shared/domain/services/token-service.js';
 import { logger } from '../../../shared/infrastructure/utils/logger.js';
 import { usecases } from '../../domain/usecases/index.js';
 import * as saml from '../../infrastructure/saml.js';
-import { getForwardedOrigin } from '../../infrastructure/utils/network.js';
+import { getForwardedOrigin, RequestedApplication } from '../../infrastructure/utils/network.js';
 
 const metadata = function (request, h) {
   return h.response(saml.getServiceProviderMetadata()).type('application/xml');
@@ -24,11 +24,13 @@ const assert = async function (request, h) {
 
   try {
     const origin = getForwardedOrigin(request.headers);
+    const requestedApplication = RequestedApplication.fromOrigin(origin);
     const redirectionUrl = await usecases.getSamlAuthenticationRedirectionUrl({
       userAttributes,
       tokenService,
       config,
       audience: origin,
+      requestedApplication,
     });
 
     return h.redirect(redirectionUrl);
diff --git a/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js b/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
index 078d0c02b89..6d5c9304e7e 100644
--- a/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
+++ b/api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
@@ -6,9 +6,11 @@ const getSamlAuthenticationRedirectionUrl = async function ({
   userRepository,
   userLoginRepository,
   authenticationMethodRepository,
+  lastUserApplicationConnectionsRepository,
   tokenService,
   config,
   audience,
+  requestedApplication,
 }) {
   const { attributeMapping } = config.saml;
   const externalUser = {
@@ -18,15 +20,30 @@ const getSamlAuthenticationRedirectionUrl = async function ({
   };
 
   const user = await userRepository.getBySamlId(externalUser.samlId);
-
   if (user) {
-    return await _getUrlWithAccessToken({
+    await _updateUserLastConnection({
+      user,
+      requestedApplication,
+      authenticationMethodRepository,
+      lastUserApplicationConnectionsRepository,
+      userLoginRepository,
+    });
+
+    await _saveUserFirstAndLastName({
+      authenticationMethodRepository,
+      user,
+      externalUser,
+    });
+
+    return _getUrlWithAccessToken({
       user,
       audience,
       externalUser,
       tokenService,
       userLoginRepository,
       authenticationMethodRepository,
+      lastUserApplicationConnectionsRepository,
+      requestedApplication,
     });
   }
 
@@ -35,20 +52,27 @@ const getSamlAuthenticationRedirectionUrl = async function ({
 
 export { getSamlAuthenticationRedirectionUrl };
 
-async function _getUrlWithAccessToken({
-  user,
-  audience,
-  externalUser,
-  tokenService,
-  userLoginRepository,
-  authenticationMethodRepository,
-}) {
+async function _getUrlWithAccessToken({ user, audience, tokenService }) {
   const token = tokenService.createAccessTokenForSaml({ userId: user.id, audience });
-  await userLoginRepository.updateLastLoggedAt({ userId: user.id });
-  await _saveUserFirstAndLastName({ authenticationMethodRepository, user, externalUser });
+
   return `/connexion/gar#${encodeURIComponent(token)}`;
 }
 
+function _externalUserFirstAndLastNameMatchesAuthenticationMethodFirstAndLastName({
+  authenticationMethod,
+  externalUser,
+}) {
+  return (
+    externalUser.firstName === authenticationMethod.authenticationComplement?.firstName &&
+    externalUser.lastName === authenticationMethod.authenticationComplement?.lastName
+  );
+}
+
+function _getUrlForReconciliationPage({ tokenService, externalUser }) {
+  const externalUserToken = tokenService.createIdTokenForUserReconciliation(externalUser);
+  return `/campagnes?externalUser=${encodeURIComponent(externalUserToken)}`;
+}
+
 async function _saveUserFirstAndLastName({ authenticationMethodRepository, user, externalUser }) {
   const authenticationMethod = await authenticationMethodRepository.findOneByUserIdAndIdentityProvider({
     userId: user.id,
@@ -69,17 +93,21 @@ async function _saveUserFirstAndLastName({ authenticationMethodRepository, user,
   authenticationMethodRepository.update(authenticationMethod);
 }
 
-function _externalUserFirstAndLastNameMatchesAuthenticationMethodFirstAndLastName({
-  authenticationMethod,
-  externalUser,
+async function _updateUserLastConnection({
+  user,
+  requestedApplication,
+  authenticationMethodRepository,
+  lastUserApplicationConnectionsRepository,
+  userLoginRepository,
 }) {
-  return (
-    externalUser.firstName === authenticationMethod.authenticationComplement?.firstName &&
-    externalUser.lastName === authenticationMethod.authenticationComplement?.lastName
-  );
-}
-
-function _getUrlForReconciliationPage({ tokenService, externalUser }) {
-  const externalUserToken = tokenService.createIdTokenForUserReconciliation(externalUser);
-  return `/campagnes?externalUser=${encodeURIComponent(externalUserToken)}`;
+  await userLoginRepository.updateLastLoggedAt({ userId: user.id });
+  await lastUserApplicationConnectionsRepository.upsert({
+    userId: user.id,
+    application: requestedApplication.applicationName,
+    lastLoggedAt: new Date(),
+  });
+  await authenticationMethodRepository.updateLastLoggedAtByIdentityProvider({
+    userId: user.id,
+    identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code,
+  });
 }
diff --git a/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js b/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js
index 3a27275c8d2..32c28e8eeee 100644
--- a/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js
+++ b/api/tests/acceptance/application/sco-organization-learners/sco-organization-learner-controller_test.js
@@ -152,7 +152,7 @@ describe('Acceptance | Controller | sco-organization-learners', function () {
       options = {
         method: 'POST',
         url: '/api/sco-organization-learners/external',
-        headers: {},
+        headers: generateAuthenticatedUserRequestHeaders(),
         payload: {},
       };
 
diff --git a/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js b/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js
index e8d04e995f5..609f6c95311 100644
--- a/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js
+++ b/api/tests/identity-access-management/unit/domain/usecases/get-saml-authentication-redirection-url_test.js
@@ -2,15 +2,18 @@ import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../../src/identity-access-
 import { AuthenticationMethod } from '../../../../../src/identity-access-management/domain/models/AuthenticationMethod.js';
 import { User } from '../../../../../src/identity-access-management/domain/models/User.js';
 import { getSamlAuthenticationRedirectionUrl } from '../../../../../src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js';
+import { RequestedApplication } from '../../../../../src/identity-access-management/infrastructure/utils/network.js';
 import { domainBuilder, expect, sinon } from '../../../../test-helper.js';
 
 describe('Unit | UseCase | get-external-authentication-redirection-url', function () {
   let userRepository;
   let userLoginRepository;
   let authenticationMethodRepository;
+  let lastUserApplicationConnectionsRepository;
   let tokenService;
   let samlSettings;
   const audience = 'https://app.pix.fr';
+  const requestedApplication = new RequestedApplication('app');
 
   beforeEach(function () {
     userRepository = {
@@ -23,6 +26,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
 
     authenticationMethodRepository = {
       findOneByUserIdAndIdentityProvider: sinon.stub(),
+      updateLastLoggedAtByIdentityProvider: sinon.stub(),
       update: sinon.stub(),
     };
 
@@ -40,6 +44,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
         },
       },
     };
+
+    lastUserApplicationConnectionsRepository = {
+      upsert: sinon.stub(),
+    };
   });
 
   context('when user does not exist in database yet', function () {
@@ -59,6 +67,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
         userAttributes,
         userRepository,
         userLoginRepository,
+        lastUserApplicationConnectionsRepository,
         tokenService,
         config: samlSettings,
       });
@@ -107,8 +116,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
         userRepository,
         userLoginRepository,
         authenticationMethodRepository,
+        lastUserApplicationConnectionsRepository,
         tokenService,
         config: samlSettings,
+        requestedApplication,
       });
 
       // then
@@ -116,7 +127,7 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
       expect(result).to.deep.equal(expectedUrl);
     });
 
-    it('should save the last login date', async function () {
+    it('should save the last login dates', async function () {
       // given
       const userAttributes = {
         IDO: 'saml-id-for-adele',
@@ -137,12 +148,23 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
         userRepository,
         userLoginRepository,
         authenticationMethodRepository,
+        lastUserApplicationConnectionsRepository,
         tokenService,
         config: samlSettings,
+        requestedApplication,
       });
 
       // then
       expect(userLoginRepository.updateLastLoggedAt).to.have.been.calledWithExactly({ userId: 777 });
+      expect(lastUserApplicationConnectionsRepository.upsert).to.have.been.calledWithExactly({
+        userId: 777,
+        application: 'app',
+        lastLoggedAt: sinon.match.instanceOf(Date),
+      });
+      expect(authenticationMethodRepository.updateLastLoggedAtByIdentityProvider).to.have.been.calledWithExactly({
+        userId: 777,
+        identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code,
+      });
     });
 
     context("when user's authentication method does not contain first and last name", function () {
@@ -170,8 +192,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
           userRepository,
           userLoginRepository,
           authenticationMethodRepository,
+          lastUserApplicationConnectionsRepository,
           tokenService,
           config: samlSettings,
+          requestedApplication,
         });
 
         // then
@@ -207,8 +231,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
           userRepository,
           userLoginRepository,
           authenticationMethodRepository,
+          lastUserApplicationConnectionsRepository,
           tokenService,
           config: samlSettings,
+          requestedApplication,
         });
 
         // then
@@ -243,8 +269,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
           userRepository,
           userLoginRepository,
           authenticationMethodRepository,
+          lastUserApplicationConnectionsRepository,
           tokenService,
           config: samlSettings,
+          requestedApplication,
         });
 
         // then
@@ -279,8 +307,10 @@ describe('Unit | UseCase | get-external-authentication-redirection-url', functio
           userRepository,
           userLoginRepository,
           authenticationMethodRepository,
+          lastUserApplicationConnectionsRepository,
           tokenService,
           config: samlSettings,
+          requestedApplication,
         });
 
         // then
diff --git a/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js b/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
index 27317e34ca7..62684d00844 100644
--- a/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
+++ b/api/tests/integration/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
@@ -1,5 +1,6 @@
 import { usecases } from '../../../../lib/domain/usecases/index.js';
 import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../src/identity-access-management/domain/constants/identity-providers.js';
+import { RequestedApplication } from '../../../../src/identity-access-management/infrastructure/utils/network.js';
 import {
   CampaignCodeError,
   NotFoundError,
@@ -10,6 +11,13 @@ import { tokenService } from '../../../../src/shared/domain/services/token-servi
 import { catchErr, databaseBuilder, expect, knex } from '../../../test-helper.js';
 
 describe('Integration | UseCases | create-user-and-reconcile-to-organization-learner-from-external-user', function () {
+  let audience, requestedApplication;
+
+  beforeEach(async function () {
+    audience = 'https://app.pix.fr';
+    requestedApplication = new RequestedApplication('app');
+  });
+
   context('When there is no campaign with the given code', function () {
     it('should throw a campaign code error', async function () {
       // when
@@ -163,6 +171,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
         campaignCode,
         token,
         tokenService,
+        audience,
+        requestedApplication,
       });
 
       // then
@@ -171,11 +181,16 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
 
       const authenticationMethodInDB = await knex('authentication-methods');
       const authenticationMethod = authenticationMethodInDB[0];
+      const lastUserApplicationConnections = await knex('last-user-application-connections');
+      const lastUserApplicationConnection = lastUserApplicationConnections[0];
       expect(authenticationMethod.externalIdentifier).to.equal(samlId);
       expect(authenticationMethod.authenticationComplement).to.deep.equal({
         firstName: 'Julie',
         lastName: 'Dumoulin-Lemarchand',
       });
+      expect(authenticationMethod.identityProvider).to.equal(NON_OIDC_IDENTITY_PROVIDERS.GAR.code);
+      expect(lastUserApplicationConnection.userId).to.equal(authenticationMethod.userId);
+      expect(lastUserApplicationConnection.application).to.equal(requestedApplication.applicationName);
     });
 
     context('When the external user is already reconciled to another account', function () {
@@ -240,6 +255,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
               campaignCode,
               token,
               birthdate: organizationLearner.birthdate,
+              audience,
+              requestedApplication,
             });
 
             // then
@@ -292,6 +309,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
               campaignCode,
               token,
               birthdate: organizationLearner.birthdate,
+              audience,
+              requestedApplication,
             });
 
             // then
@@ -305,11 +324,16 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
               userId: otherAccount.id,
             });
             const authenticationMethod = authenticationMethodInDB[0];
+            const lastUserApplicationConnections = await knex('last-user-application-connections');
+            const lastUserApplicationConnection = lastUserApplicationConnections[0];
             expect(authenticationMethod.externalIdentifier).to.equal(samlId);
             expect(authenticationMethod.authenticationComplement).to.deep.equal({
               firstName: 'Julie',
               lastName: 'Dumoulin-Lemarchand',
             });
+            expect(authenticationMethod.identityProvider).to.equal(NON_OIDC_IDENTITY_PROVIDERS.GAR.code);
+            expect(lastUserApplicationConnection.userId).to.equal(authenticationMethod.userId);
+            expect(lastUserApplicationConnection.application).to.equal(requestedApplication.applicationName);
           });
         });
       });
@@ -337,6 +361,8 @@ describe('Integration | UseCases | create-user-and-reconcile-to-organization-lea
           birthdate: organizationLearner.birthdate,
           campaignCode,
           token,
+          audience,
+          requestedApplication,
         });
 
         // then
diff --git a/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js b/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
index b71ff47700c..de4d3b92984 100644
--- a/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
+++ b/api/tests/unit/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user_test.js
@@ -1,4 +1,6 @@
 import { createUserAndReconcileToOrganizationLearnerFromExternalUser } from '../../../../lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js';
+import { NON_OIDC_IDENTITY_PROVIDERS } from '../../../../src/identity-access-management/domain/constants/identity-providers.js';
+import { RequestedApplication } from '../../../../src/identity-access-management/infrastructure/utils/network.js';
 import { domainBuilder, expect, sinon } from '../../../test-helper.js';
 
 describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-from-external-user', function () {
@@ -12,7 +14,9 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
   let userLoginRepository;
   let organizationLearnerRepository;
   let studentRepository;
+  let lastUserApplicationConnectionsRepository;
   const audience = 'https://app.pix.fr';
+  const requestedApplication = new RequestedApplication('app');
 
   beforeEach(function () {
     campaignRepository = {
@@ -35,6 +39,14 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
     userService = {
       createAndReconcileUserToOrganizationLearner: sinon.stub(),
     };
+
+    authenticationMethodRepository = {
+      updateLastLoggedAtByIdentityProvider: sinon.stub(),
+    };
+
+    lastUserApplicationConnectionsRepository = {
+      upsert: sinon.stub(),
+    };
   });
 
   context('when user has saml id', function () {
@@ -66,10 +78,21 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
         userLoginRepository,
         organizationLearnerRepository,
         studentRepository,
+        lastUserApplicationConnectionsRepository,
+        requestedApplication,
       });
 
       // then
       expect(userLoginRepository.updateLastLoggedAt).to.have.been.calledWithExactly({ userId: user.id });
+      expect(lastUserApplicationConnectionsRepository.upsert).to.have.been.calledWithExactly({
+        userId: user.id,
+        application: requestedApplication.applicationName,
+        lastLoggedAt: sinon.match.date,
+      });
+      expect(authenticationMethodRepository.updateLastLoggedAtByIdentityProvider).to.have.been.calledWithExactly({
+        userId: user.id,
+        identityProvider: NON_OIDC_IDENTITY_PROVIDERS.GAR.code,
+      });
     });
 
     it('should return an access token', async function () {
@@ -103,6 +126,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
         userLoginRepository,
         organizationLearnerRepository,
         studentRepository,
+        lastUserApplicationConnectionsRepository,
+        requestedApplication,
       });
 
       // then
@@ -111,7 +136,7 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
   });
 
   context('when user does not have saml id', function () {
-    it('should save last login date', async function () {
+    it('should save last login dates', async function () {
       // given
       const user = domainBuilder.buildUser();
       const organizationLearner = domainBuilder.buildOrganizationLearner(user);
@@ -140,6 +165,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
         userLoginRepository,
         organizationLearnerRepository,
         studentRepository,
+        lastUserApplicationConnectionsRepository,
+        requestedApplication,
       });
 
       // then
@@ -178,6 +205,8 @@ describe('Unit | UseCase | create-user-and-reconcile-to-organization-learner-fro
         userLoginRepository,
         organizationLearnerRepository,
         studentRepository,
+        lastUserApplicationConnectionsRepository,
+        requestedApplication,
       });
 
       // then


Reviews:
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:39:28Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:43:13Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T10:44:00Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T11:10:42Z
user: login: lego-technix
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T11:12:04Z
user: login: EmmanuelleBonnemay
body: Lu et testé avec succès
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T15:05:02Z
user: login: lego-technix
body: ✅ Lu et testé fonctionnellement avec succès avec Firefox 🦊
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-04T16:19:28Z

Review comments:
diff_hunk: @@ -42,13 +46,43 @@ async function _getUrlWithAccessToken({
   tokenService,
   userLoginRepository,
   authenticationMethodRepository,
+  lastUserApplicationConnectionsRepository,
+  requestedApplication,
 }) {
+  await _saveUserFirstAndLastName({
+    authenticationMethodRepository,
+    user,
+    externalUser,
+  });
+
+  await _updateUserLastConnection({
+    requestedApplication,
+    user,
+    authenticationMethodRepository,
+    lastUserApplicationConnectionsRepository,
+    userLoginRepository,
+  });
path: api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
user: login: lego-technix
body: Fonctionnellement il me semble inapproprié de mettre les fonctions `_saveUserFirstAndLastName` et `_updateUserLastConnection` dans une fonction `_getUrlWithAccessToken` car ce sont des opérations vraiment disjointes.
created_at: 2025-03-04T10:39:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -115,9 +117,37 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
     }
   }
   const tokenUserId = userWithSamlId ? userWithSamlId.id : userId;
+
+  await _updateUserLastConnection({
+    requestedApplication,
+    userId: tokenUserId,
+    authenticationMethodRepository,
+    lastUserApplicationConnectionsRepository,
+    userLoginRepository,
+  });
+
   const accessToken = tokenService.createAccessTokenForSaml({ userId: tokenUserId, audience });
-  await userLoginRepository.updateLastLoggedAt({ userId: tokenUserId });
+
   return accessToken;
 };
 
 export { createUserAndReconcileToOrganizationLearnerFromExternalUser };
+
+async function _updateUserLastConnection({
+  requestedApplication,
+  userId,
path: api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js
user: login: lego-technix
body: `userId` est plus important que `requestedApplication`, le mettre donc en premier :

```suggestion
async function _updateUserLastConnection({
  userId,
  requestedApplication,
```
created_at: 2025-03-04T10:43:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -26,6 +26,8 @@ const createUserAndReconcileToOrganizationLearnerFromExternalUser = async functi
   userToCreateRepository,
   organizationLearnerRepository,
   prescriptionOrganizationLearnerRepository,
+  lastUserApplicationConnectionsRepository,
+  requestedApplication,
path: api/lib/domain/usecases/create-user-and-reconcile-to-organization-learner-from-external-user.js
user: login: lego-technix
body: Déplacer le `requestedApplication` avant les dependencies.
created_at: 2025-03-04T10:43:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -35,20 +46,33 @@ const getSamlAuthenticationRedirectionUrl = async function ({
 
 export { getSamlAuthenticationRedirectionUrl };
 
-async function _getUrlWithAccessToken({
-  user,
-  audience,
-  externalUser,
-  tokenService,
-  userLoginRepository,
-  authenticationMethodRepository,
-}) {
+async function _getUrlWithAccessToken({ user, audience, externalUser, tokenService, authenticationMethodRepository }) {
+  await _saveUserFirstAndLastName({
+    authenticationMethodRepository,
+    user,
+    externalUser,
+  });
path: api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
user: login: lego-technix
body: Alors je comprends bien que c'était le cas avant cette PR, mais autant utiliser cette PR pour déplacer `_saveUserFirstAndLastName` hors de cette fonction `_getUrlWithAccessToken` car il n'y a aucun rapport fonctionnel entre les deux.
created_at: 2025-03-04T11:10:42Z
author_association: CONTRIBUTOR
path: api/src/identity-access-management/domain/usecases/get-saml-authentication-redirection-url.js
user: login: lego-technix
body: Se servir de cette PR pour encore modifier un `return await` inutile : `return await _getUrlWithAccessToken`→`return _getUrlWithAccessToken`
created_at: 2025-03-04T11:12:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: pix-bot-github
created_at: 2025-03-03T14:17:35Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr11552.review.pix.fr)
  * [App (.org)](https://app-pr11552.review.pix.org)
  * [Orga (.fr)](https://orga-pr11552.review.pix.fr)
  * [Orga (.org)](https://orga-pr11552.review.pix.org)
  * [Certif (.fr)](https://certif-pr11552.review.pix.fr)
  * [Certif (.org)](https://certif-pr11552.review.pix.org)
  * [Junior](https://junior-pr11552.review.pix.fr)
  * [Admin](https://admin-pr11552.review.pix.fr)
  * [API](https://api-pr11552.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr11552.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr11552.osc-fr1.scalingo.io/api/)

Les variables d'environnement seront accessibles via les liens suivants :
  * [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr11552/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr11552/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr11552/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr11552/environment)

author_association: NONE
user: login: pix-bot-github
created_at: 2025-03-05T14:14:24Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr11552.review.pix.fr)
  * [App (.org)](https://app-pr11552.review.pix.org)
  * [Orga (.fr)](https://orga-pr11552.review.pix.fr)
  * [Orga (.org)](https://orga-pr11552.review.pix.org)
  * [Certif (.fr)](https://certif-pr11552.review.pix.fr)
  * [Certif (.org)](https://certif-pr11552.review.pix.org)
  * [Junior](https://junior-pr11552.review.pix.fr)
  * [Admin](https://admin-pr11552.review.pix.fr)
  * [API](https://api-pr11552.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr11552.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr11552.osc-fr1.scalingo.io/api/)

Les variables d'environnement seront accessibles via les liens suivants :
  * [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr11552/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr11552/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr11552/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr11552/environment)

author_association: NONE
user: login: pix-bot-github
created_at: 2025-03-05T14:14:39Z
body: Une fois les applications déployées, elles seront accessibles via les liens suivants :
  * [App (.fr)](https://app-pr11552.review.pix.fr)
  * [App (.org)](https://app-pr11552.review.pix.org)
  * [Orga (.fr)](https://orga-pr11552.review.pix.fr)
  * [Orga (.org)](https://orga-pr11552.review.pix.org)
  * [Certif (.fr)](https://certif-pr11552.review.pix.fr)
  * [Certif (.org)](https://certif-pr11552.review.pix.org)
  * [Junior](https://junior-pr11552.review.pix.fr)
  * [Admin](https://admin-pr11552.review.pix.fr)
  * [API](https://api-pr11552.review.pix.fr/api/)
  * [API MaDDo](https://pix-api-maddo-review-pr11552.osc-fr1.scalingo.io/api/)
  * [Audit Logger](https://pix-audit-logger-review-pr11552.osc-fr1.scalingo.io/api/)

Les variables d'environnement seront accessibles via les liens suivants :
  * [scalingo front](https://dashboard.scalingo.com/apps/osc-fr1/pix-front-review-pr11552/environment)
  * [scalingo api](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-review-pr11552/environment)
  * [scalingo api-maddo](https://dashboard.scalingo.com/apps/osc-fr1/pix-api-maddo-review-pr11552/environment)
  * [scalingo audit-logger](https://dashboard.scalingo.com/apps/osc-fr1/pix-audit-logger-review-pr11552/environment)

author_association: NONE