From 99fbfa857f392f83506bbe629707ffb8a4f8f01d Mon Sep 17 00:00:00 2001 From: LEGO Technix <109212476+lego-technix@users.noreply.github.com> Date: Thu, 12 Dec 2024 17:41:07 +0100 Subject: [PATCH] feat(api): demonstrate audience setting with origin --- api/lib/infrastructure/authentication.js | 22 +++++- .../oidc-provider/oidc-provider.controller.js | 7 +- .../application/token/token.controller.js | 6 +- .../oidc-authentication-service-registry.js | 9 ++- .../services/oidc-authentication-service.js | 13 +++- .../authenticate-oidc-user.usecase.js | 29 ++++---- .../domain/usecases/authenticate-user.js | 34 +++++---- .../shared/domain/services/token-service.js | 9 ++- .../infrastructure/authentication_test.js | 74 +++++++++++++++++++ 9 files changed, 164 insertions(+), 39 deletions(-) create mode 100644 api/tests/unit/infrastructure/authentication_test.js diff --git a/api/lib/infrastructure/authentication.js b/api/lib/infrastructure/authentication.js index c36f6f690b8..c48b7b459ec 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,12 @@ async function _checkIsAuthenticated(request, h, { key, validate }) { const decodedAccessToken = tokenService.getDecodedToken(accessToken, key); if (decodedAccessToken) { + const audience = getOriginFromHeaders(request.headers); + console.warn('\n\ndecodedAccessToken audience:', audience, 'decodedAccessToken.aud:', decodedAccessToken.aud); + if (decodedAccessToken.aud != audience) { + return { isValid: false, errorCode: 403 }; + } + const { isValid, credentials, errorCode } = validate(decodedAccessToken, request, h); if (isValid) { return h.authenticated({ credentials }); @@ -93,4 +113,4 @@ const authentication = { defaultStrategy: 'jwt-user', }; -export { authentication }; +export { authentication, getOriginFromHeaders }; 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..3797bce54a7 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); } 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/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'); + }); + }); +});