diff --git a/api/lib/infrastructure/authentication.js b/api/lib/infrastructure/authentication.js index c36f6f690b8..cdd0b1713ee 100644 --- a/api/lib/infrastructure/authentication.js +++ b/api/lib/infrastructure/authentication.js @@ -7,6 +7,20 @@ import boom from '@hapi/boom'; import { config } from '../../src/shared/config.js'; import { tokenService } from '../../src/shared/domain/services/token-service.js'; +function getOriginFromHeaders(headers) { + const urlParts = []; + + urlParts.push(_getHeaderFirstValue(headers['x-forwarded-proto'])); + urlParts.push('://'); + urlParts.push(_getHeaderFirstValue(headers['x-forwarded-host'])); + + return urlParts.join(''); +} + +function _getHeaderFirstValue(headerValue) { + return headerValue.split(',')[0]; +} + async function _checkIsAuthenticated(request, h, { key, validate }) { if (!request.headers.authorization) { return boom.unauthorized(null, 'jwt'); @@ -21,6 +35,22 @@ async function _checkIsAuthenticated(request, h, { key, validate }) { const decodedAccessToken = tokenService.getDecodedToken(accessToken, key); if (decodedAccessToken) { + const audience = getOriginFromHeaders(request.headers); + console.warn('\n\ndecodedAccessToken:', decodedAccessToken); + console.warn('decodedAccessToken audience:', audience, 'decodedAccessToken.aud:', decodedAccessToken.aud); + if (decodedAccessToken.aud != audience) { + return { isValid: false, errorCode: 403 }; + } + + const issuedAt = new Date(decodedAccessToken.iat * 1000); + console.warn('decodedAccessToken iat:', issuedAt); + const revokeTokensFromUserId = 10001; // allorga@example.net + const revokeTokensFromUserBeforeDate = new Date('2024-12-18T10:18:00.000Z'); // 11h15 + if (decodedAccessToken.user_id == revokeTokensFromUserId && issuedAt < revokeTokensFromUserBeforeDate) { + console.warn(`Token REVOKED: issuedAt ${issuedAt} < revokeTokensFromUserBeforeDate ${revokeTokensFromUserBeforeDate}`); + return { isValid: false, errorCode: 403 }; + } + const { isValid, credentials, errorCode } = validate(decodedAccessToken, request, h); if (isValid) { return h.authenticated({ credentials }); @@ -93,4 +123,4 @@ const authentication = { defaultStrategy: 'jwt-user', }; -export { authentication }; +export { authentication, getOriginFromHeaders }; diff --git a/api/src/identity-access-management/application/oidc-provider/oidc-provider.admin.controller.js b/api/src/identity-access-management/application/oidc-provider/oidc-provider.admin.controller.js index 35641ed721f..8519e311884 100644 --- a/api/src/identity-access-management/application/oidc-provider/oidc-provider.admin.controller.js +++ b/api/src/identity-access-management/application/oidc-provider/oidc-provider.admin.controller.js @@ -1,4 +1,5 @@ import { oidcAuthenticationServiceRegistry } from '../../../../lib/domain/usecases/index.js'; +import { getOriginFromHeaders } from '../../../../lib/infrastructure/authentication.js'; import { PIX_ADMIN } from '../../../authorization/domain/constants.js'; import { DomainTransaction } from '../../../shared/domain/DomainTransaction.js'; import { usecases } from '../../domain/usecases/index.js'; @@ -42,6 +43,9 @@ async function reconcileUserForAdmin( oidcAuthenticationServiceRegistry, }, ) { + const audience = getOriginFromHeaders(request.headers); + console.warn('oidcProviderController.reconcileUser audience:', audience); + const { email, identityProvider, authenticationKey } = request.deserializedPayload; await dependencies.oidcAuthenticationServiceRegistry.loadOidcProviderServices(); @@ -56,6 +60,7 @@ async function reconcileUserForAdmin( email, identityProvider, authenticationKey, + audience, oidcAuthenticationService, }); diff --git a/api/src/identity-access-management/application/oidc-provider/oidc-provider.controller.js b/api/src/identity-access-management/application/oidc-provider/oidc-provider.controller.js index f0e8b612db0..ab6d3124cad 100644 --- a/api/src/identity-access-management/application/oidc-provider/oidc-provider.controller.js +++ b/api/src/identity-access-management/application/oidc-provider/oidc-provider.controller.js @@ -1,3 +1,4 @@ +import { getOriginFromHeaders } from '../../../../lib/infrastructure/authentication.js'; import { BadRequestError, UnauthorizedError } from '../../../shared/application/http-errors.js'; import { requestResponseUtils } from '../../../shared/infrastructure/utils/request-response-utils.js'; import { usecases } from '../../domain/usecases/index.js'; @@ -11,7 +12,9 @@ import * as oidcSerializer from '../../infrastructure/serializers/jsonapi/oidc-s * @return {Promise<*>} */ async function authenticateOidcUser(request, h) { - const { code, state, iss, identityProvider: identityProviderCode, audience } = request.deserializedPayload; + const audience = getOriginFromHeaders(request.headers); + console.warn('oidcProviderController.authenticateOidcUser audience:', audience); + const { code, state, iss, identityProvider: identityProviderCode } = request.deserializedPayload; const sessionState = request.yar.get('state', true); const nonce = request.yar.get('nonce', true); @@ -32,6 +35,8 @@ async function authenticateOidcUser(request, h) { }); if (result.isAuthenticationComplete) { + console.warn('oidcProviderController.authenticateOidcUser pixAccessToken:', result.pixAccessToken); + return h.response({ access_token: result.pixAccessToken, logout_url_uuid: result.logoutUrlUUID }).code(200); } @@ -55,6 +60,9 @@ async function authenticateOidcUser(request, h) { * @return {Promise<{access_token: string, logout_url_uuid: string}>} */ async function createUser(request, h, dependencies = { requestResponseUtils }) { + const audience = getOriginFromHeaders(request.headers); + console.warn('oidcProviderController.createUser audience:', audience); + const { identityProvider, authenticationKey } = request.deserializedPayload; const localeFromCookie = request.state?.locale; const language = dependencies.requestResponseUtils.extractLocaleFromRequest(request); @@ -62,6 +70,7 @@ async function createUser(request, h, dependencies = { requestResponseUtils }) { const { accessToken: access_token, logoutUrlUUID: logout_url_uuid } = await usecases.createOidcUser({ authenticationKey, identityProvider, + audience, localeFromCookie, language, }); @@ -144,11 +153,15 @@ async function getRedirectLogoutUrl(request, h) { * @return {Promise<{access_token: string, logout_url_uuid: string}>} */ async function reconcileUser(request, h) { + const audience = getOriginFromHeaders(request.headers); + console.warn('oidcProviderController.reconcileUser audience:', audience); + const { identityProvider, authenticationKey } = request.deserializedPayload; const result = await usecases.reconcileOidcUser({ authenticationKey, identityProvider, + audience, }); return h.response({ access_token: result.accessToken, logout_url_uuid: result.logoutUrlUUID }).code(200); diff --git a/api/src/identity-access-management/application/token/token.controller.js b/api/src/identity-access-management/application/token/token.controller.js index 86950535bb7..09c7ff82057 100644 --- a/api/src/identity-access-management/application/token/token.controller.js +++ b/api/src/identity-access-management/application/token/token.controller.js @@ -1,3 +1,4 @@ +import { getOriginFromHeaders } from '../../../../lib/infrastructure/authentication.js'; import { BadRequestError } from '../../../shared/application/http-errors.js'; import { tokenService } from '../../../shared/domain/services/token-service.js'; import { usecases } from '../../domain/usecases/index.js'; @@ -30,11 +31,12 @@ const createToken = async function (request, h, dependencies = { tokenService }) const scope = request.payload.scope; if (grantType === 'password') { - const { username, password, scope } = request.payload; + const { username, password } = request.payload; const localeFromCookie = request.state?.locale; + const audience = getOriginFromHeaders(request.headers); const source = 'pix'; - const tokensInfo = await usecases.authenticateUser({ username, password, scope, source, localeFromCookie }); + const tokensInfo = await usecases.authenticateUser({ username, password, audience, source, localeFromCookie }); accessToken = tokensInfo.accessToken; refreshToken = tokensInfo.refreshToken; diff --git a/api/src/identity-access-management/domain/services/oidc-authentication-service-registry.js b/api/src/identity-access-management/domain/services/oidc-authentication-service-registry.js index 7e2130d3c99..b047ff1e602 100644 --- a/api/src/identity-access-management/domain/services/oidc-authentication-service-registry.js +++ b/api/src/identity-access-management/domain/services/oidc-authentication-service-registry.js @@ -44,9 +44,12 @@ export class OidcAuthenticationServiceRegistry { return this.#readyOidcProviderServicesForPixAdmin; } - getOidcProviderServiceByCode({ identityProviderCode, audience = 'app' }) { - const services = - audience === 'admin' ? this.#readyOidcProviderServicesForPixAdmin : this.#readyOidcProviderServices; + getOidcProviderServiceByCode({ identityProviderCode, audience }) { + // const audienceUrl = new URL(audience) + // const services = + // audience === 'admin' ? this.#readyOidcProviderServicesForPixAdmin : this.#readyOidcProviderServices; + // TODO: Do a clean separation of the different OIDC providers by application + const services = [...this.#readyOidcProviderServicesForPixAdmin, ...this.#readyOidcProviderServices]; const oidcProviderService = services.find((service) => identityProviderCode === service.code); if (!oidcProviderService) { diff --git a/api/src/identity-access-management/domain/services/oidc-authentication-service.js b/api/src/identity-access-management/domain/services/oidc-authentication-service.js index 558e9b83096..5cdef130009 100644 --- a/api/src/identity-access-management/domain/services/oidc-authentication-service.js +++ b/api/src/identity-access-management/domain/services/oidc-authentication-service.js @@ -122,8 +122,16 @@ export class OidcAuthenticationService { } } - createAccessToken(userId) { - return jsonwebtoken.sign({ user_id: userId }, config.authentication.secret, this.accessTokenJwtOptions); + // TODO: Use an object for arguments instead + // TODO: Use the tokenService to read-write tokens + createAccessToken(userId, audience) { + console.warn('OidcAuthenticationService.createAccessToken audience:', audience); + + return jsonwebtoken.sign( + { user_id: userId, aud: audience }, + config.authentication.secret, + this.accessTokenJwtOptions, + ); } async saveIdToken({ idToken, userId }) { @@ -198,6 +206,7 @@ export class OidcAuthenticationService { } async getUserInfo({ idToken, accessToken }) { + // TODO: Use the tokenService to read-write tokens let userInfo = jsonwebtoken.decode(idToken); if (this.claimManager.hasMissingClaims(userInfo)) { diff --git a/api/src/identity-access-management/domain/usecases/authenticate-oidc-user.usecase.js b/api/src/identity-access-management/domain/usecases/authenticate-oidc-user.usecase.js index 23a24a1c3de..b4a3ca627db 100644 --- a/api/src/identity-access-management/domain/usecases/authenticate-oidc-user.usecase.js +++ b/api/src/identity-access-management/domain/usecases/authenticate-oidc-user.usecase.js @@ -33,6 +33,8 @@ async function authenticateOidcUser({ userLoginRepository, userRepository, }) { + console.warn('usecases.authenticateOidcUser audience:', audience); + await oidcAuthenticationServiceRegistry.loadOidcProviderServices(); await oidcAuthenticationServiceRegistry.configureReadyOidcProviderServiceByCode(identityProviderCode); @@ -40,6 +42,7 @@ async function authenticateOidcUser({ identityProviderCode, audience, }); + console.warn('usecases.authenticateOidcUser oidcAuthenticationService:', oidcAuthenticationService); const sessionContent = await oidcAuthenticationService.exchangeCodeForTokens({ code, @@ -63,7 +66,7 @@ async function authenticateOidcUser({ return { authenticationKey, givenName, familyName, email, isAuthenticationComplete: false }; } - await _assertUserWithPixAdminAccess({ audience, userId: user.id, adminMemberRepository }); + // await _assertUserWithPixAdminAccess({ audience, userId: user.id, adminMemberRepository }); await _updateAuthenticationMethodWithComplement({ userInfo, @@ -73,7 +76,7 @@ async function authenticateOidcUser({ authenticationMethodRepository, }); - const pixAccessToken = oidcAuthenticationService.createAccessToken(user.id); + const pixAccessToken = oidcAuthenticationService.createAccessToken(user.id, audience); let logoutUrlUUID; if (oidcAuthenticationService.shouldCloseSession) { @@ -109,14 +112,14 @@ async function _updateAuthenticationMethodWithComplement({ }); } -async function _assertUserWithPixAdminAccess({ audience, userId, adminMemberRepository }) { - if (audience === PIX_ADMIN.AUDIENCE) { - const adminMember = await adminMemberRepository.get({ userId }); - if (!adminMember?.hasAccessToAdminScope) { - throw new ForbiddenAccess( - 'User does not have the rights to access the application', - 'PIX_ADMIN_ACCESS_NOT_ALLOWED', - ); - } - } -} +// async function _assertUserWithPixAdminAccess({ audience, userId, adminMemberRepository }) { +// if (audience === PIX_ADMIN.AUDIENCE) { +// const adminMember = await adminMemberRepository.get({ userId }); +// if (!adminMember?.hasAccessToAdminScope) { +// throw new ForbiddenAccess( +// 'User does not have the rights to access the application', +// 'PIX_ADMIN_ACCESS_NOT_ALLOWED', +// ); +// } +// } +// } diff --git a/api/src/identity-access-management/domain/usecases/authenticate-user.js b/api/src/identity-access-management/domain/usecases/authenticate-user.js index 091c3ed872f..eba3d6848ba 100644 --- a/api/src/identity-access-management/domain/usecases/authenticate-user.js +++ b/api/src/identity-access-management/domain/usecases/authenticate-user.js @@ -5,6 +5,7 @@ import { RefreshToken } from '../models/RefreshToken.js'; const authenticateUser = async function ({ password, + audience, scope, source, username, @@ -16,6 +17,8 @@ const authenticateUser = async function ({ userLoginRepository, adminMemberRepository, }) { + console.warn('usecases.authenticateUser audience:', audience); + try { const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({ username, @@ -28,12 +31,17 @@ const authenticateUser = async function ({ throw new UserShouldChangePasswordError(undefined, passwordResetToken); } - await _checkUserAccessScope(scope, foundUser, adminMemberRepository); + // await _checkUserAccessScope(scope, foundUser, adminMemberRepository); const refreshToken = RefreshToken.generate({ userId: foundUser.id, scope, source }); await refreshTokenRepository.save({ refreshToken }); - const { accessToken, expirationDelaySeconds } = await tokenService.createAccessTokenFromUser(foundUser.id, source); + const { accessToken, expirationDelaySeconds } = await tokenService.createAccessTokenFromUser({ + userId: foundUser.id, + audience, + source, + }); + console.warn('authenticateUser accessToken:', accessToken); foundUser.setLocaleIfNotAlreadySet(localeFromCookie); if (foundUser.hasBeenModified) { @@ -56,17 +64,17 @@ const authenticateUser = async function ({ } }; -async function _checkUserAccessScope(scope, user, adminMemberRepository) { - if (scope === PIX_ORGA.SCOPE && !user.isLinkedToOrganizations()) { - throw new ForbiddenAccess(PIX_ORGA.NOT_LINKED_ORGANIZATION_MSG); - } +// async function _checkUserAccessScope(scope, user, adminMemberRepository) { +// if (scope === PIX_ORGA.SCOPE && !user.isLinkedToOrganizations()) { +// throw new ForbiddenAccess(PIX_ORGA.NOT_LINKED_ORGANIZATION_MSG); +// } - if (scope === PIX_ADMIN.SCOPE) { - const adminMember = await adminMemberRepository.get({ userId: user.id }); - if (!adminMember?.hasAccessToAdminScope) { - throw new ForbiddenAccess(PIX_ADMIN.NOT_ALLOWED_MSG); - } - } -} +// if (scope === PIX_ADMIN.SCOPE) { +// const adminMember = await adminMemberRepository.get({ userId: user.id }); +// if (!adminMember?.hasAccessToAdminScope) { +// throw new ForbiddenAccess(PIX_ADMIN.NOT_ALLOWED_MSG); +// } +// } +// } export { authenticateUser }; diff --git a/api/src/identity-access-management/domain/usecases/create-oidc-user.usecase.js b/api/src/identity-access-management/domain/usecases/create-oidc-user.usecase.js index 6c419ad1cf3..ec704ead243 100644 --- a/api/src/identity-access-management/domain/usecases/create-oidc-user.usecase.js +++ b/api/src/identity-access-management/domain/usecases/create-oidc-user.usecase.js @@ -21,12 +21,15 @@ async function createOidcUser({ authenticationKey, localeFromCookie, language, + audience, authenticationSessionService, oidcAuthenticationServiceRegistry, authenticationMethodRepository, userToCreateRepository, userLoginRepository, }) { + console.warn('usecases.createOidcUser audience:', audience); + const sessionContentAndUserInfo = await authenticationSessionService.getByKey(authenticationKey); if (!sessionContentAndUserInfo) { throw new AuthenticationKeyExpired(); @@ -68,7 +71,7 @@ async function createOidcUser({ authenticationMethodRepository, }); - const accessToken = oidcAuthenticationService.createAccessToken(userId); + const accessToken = oidcAuthenticationService.createAccessToken(userId, audience); let logoutUrlUUID; if (oidcAuthenticationService.shouldCloseSession) { diff --git a/api/src/identity-access-management/domain/usecases/reconcile-oidc-user-for-admin.usecase.js b/api/src/identity-access-management/domain/usecases/reconcile-oidc-user-for-admin.usecase.js index 230c05d6b79..9289f972df7 100644 --- a/api/src/identity-access-management/domain/usecases/reconcile-oidc-user-for-admin.usecase.js +++ b/api/src/identity-access-management/domain/usecases/reconcile-oidc-user-for-admin.usecase.js @@ -24,6 +24,7 @@ export const reconcileOidcUserForAdmin = async function ({ authenticationKey, email, identityProvider, + audience, oidcAuthenticationService, authenticationSessionService, authenticationMethodRepository, @@ -58,7 +59,7 @@ export const reconcileOidcUserForAdmin = async function ({ }), }); - const accessToken = await oidcAuthenticationService.createAccessToken(userId); + const accessToken = await oidcAuthenticationService.createAccessToken(userId, audience); await userLoginRepository.updateLastLoggedAt({ userId }); return accessToken; diff --git a/api/src/identity-access-management/domain/usecases/reconcile-oidc-user.usecase.js b/api/src/identity-access-management/domain/usecases/reconcile-oidc-user.usecase.js index f27501eaf45..4352b61a0d2 100644 --- a/api/src/identity-access-management/domain/usecases/reconcile-oidc-user.usecase.js +++ b/api/src/identity-access-management/domain/usecases/reconcile-oidc-user.usecase.js @@ -15,11 +15,14 @@ import { AuthenticationMethod } from '../models/AuthenticationMethod.js'; export const reconcileOidcUser = async function ({ authenticationKey, identityProvider, + audience, authenticationSessionService, authenticationMethodRepository, oidcAuthenticationServiceRegistry, userLoginRepository, }) { + console.warn('usecases.createOidcUser audience:', audience); + await oidcAuthenticationServiceRegistry.loadOidcProviderServices(); await oidcAuthenticationServiceRegistry.configureReadyOidcProviderServiceByCode(identityProvider); @@ -52,7 +55,7 @@ export const reconcileOidcUser = async function ({ }), }); - const accessToken = await oidcAuthenticationService.createAccessToken(userId); + const accessToken = await oidcAuthenticationService.createAccessToken(userId, audience); let logoutUrlUUID; if (oidcAuthenticationService.shouldCloseSession) { diff --git a/api/src/shared/domain/services/token-service.js b/api/src/shared/domain/services/token-service.js index 086c6cd7f8e..3017e229483 100644 --- a/api/src/shared/domain/services/token-service.js +++ b/api/src/shared/domain/services/token-service.js @@ -11,15 +11,16 @@ import { const CERTIFICATION_RESULTS_BY_RECIPIENT_EMAIL_LINK_SCOPE = 'certificationResultsByRecipientEmailLink'; -function _createAccessToken({ userId, source, expirationDelaySeconds }) { - return jsonwebtoken.sign({ user_id: userId, source }, config.authentication.secret, { +function _createAccessToken({ userId, audience, source, expirationDelaySeconds }) { + return jsonwebtoken.sign({ user_id: userId, aud: audience, source }, config.authentication.secret, { expiresIn: expirationDelaySeconds, }); } -function createAccessTokenFromUser(userId, source) { +function createAccessTokenFromUser({ userId, audience, source }) { + console.warn('createAccessTokenFromUser audience:', audience); const expirationDelaySeconds = config.authentication.accessTokenLifespanMs / 1000; - const accessToken = _createAccessToken({ userId, source, expirationDelaySeconds }); + const accessToken = _createAccessToken({ userId, audience, source, expirationDelaySeconds }); return { accessToken, expirationDelaySeconds }; } diff --git a/api/tests/unit/infrastructure/authentication_test.js b/api/tests/unit/infrastructure/authentication_test.js new file mode 100644 index 00000000000..755cfd88438 --- /dev/null +++ b/api/tests/unit/infrastructure/authentication_test.js @@ -0,0 +1,74 @@ +import { getOriginFromHeaders } from '../../../lib/infrastructure/authentication.js'; +import { expect } from '../../../tests/test-helper.js'; + +describe('Unit | Infrastructure | Authentication', function () { + describe('#getOriginFromHeaders', function () { + context('when port is HTTP standard port 80', function () { + it('returns an HTTP URL', async function () { + // given + const headers = { + 'x-forwarded-proto': 'http', + 'x-forwarded-port': '80', + 'x-forwarded-host': 'localhost', + }; + + // when + const origin = getOriginFromHeaders(headers); + + // then + expect(origin).to.equal('http://localhost'); + }); + }); + + context('when port is HTTPS standard port 443', function () { + it('returns an HTTPS URL', async function () { + // given + const headers = { + 'x-forwarded-proto': 'https', + 'x-forwarded-port': '443', + 'x-forwarded-host': 'app-pr10823.review.pix.fr', + }; + + // when + const origin = getOriginFromHeaders(headers); + + // then + expect(origin).to.equal('https://app-pr10823.review.pix.fr'); + }); + }); + + context('when port is neither HTTP nor HTTPS standard ports', function () { + it('returns an URL with a specific port', async function () { + // given + const headers = { + 'x-forwarded-proto': 'http', + 'x-forwarded-port': '4200', + 'x-forwarded-host': 'localhost:4200', + }; + + // when + const origin = getOriginFromHeaders(headers); + + // then + expect(origin).to.equal('http://localhost:4200'); + }); + }); + }); + + context('when x-forwarded-proto and x-forwarded-port have multiple values (ember serve --proxy)', function () { + it('returns an URL corresponding to the first HTTP proxy facing the user', async function () { + // given + const headers = { + 'x-forwarded-proto': 'https,http', + 'x-forwarded-port': '80', + 'x-forwarded-host': 'app.dev.pix.org', + }; + + // when + const origin = getOriginFromHeaders(headers); + + // then + expect(origin).to.equal('https://app.dev.pix.org'); + }); + }); +});