Skip to content

Commit

Permalink
feat(api): demonstrate audience setting with origin
Browse files Browse the repository at this point in the history
  • Loading branch information
lego-technix committed Dec 17, 2024
1 parent 965ce2e commit 99fbfa8
Show file tree
Hide file tree
Showing 9 changed files with 164 additions and 39 deletions.
22 changes: 21 additions & 1 deletion api/lib/infrastructure/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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 });
Expand Down Expand Up @@ -93,4 +113,4 @@ const authentication = {
defaultStrategy: 'jwt-user',
};

export { authentication };
export { authentication, getOriginFromHeaders };
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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);
Expand All @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,16 @@ async function authenticateOidcUser({
userLoginRepository,
userRepository,
}) {
console.warn('usecases.authenticateOidcUser audience:', audience);

await oidcAuthenticationServiceRegistry.loadOidcProviderServices();
await oidcAuthenticationServiceRegistry.configureReadyOidcProviderServiceByCode(identityProviderCode);

const oidcAuthenticationService = oidcAuthenticationServiceRegistry.getOidcProviderServiceByCode({
identityProviderCode,
audience,
});
console.warn('usecases.authenticateOidcUser oidcAuthenticationService:', oidcAuthenticationService);

const sessionContent = await oidcAuthenticationService.exchangeCodeForTokens({
code,
Expand All @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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',
// );
// }
// }
// }
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { RefreshToken } from '../models/RefreshToken.js';

const authenticateUser = async function ({
password,
audience,
scope,
source,
username,
Expand All @@ -16,6 +17,8 @@ const authenticateUser = async function ({
userLoginRepository,
adminMemberRepository,
}) {
console.warn('usecases.authenticateUser audience:', audience);

try {
const foundUser = await pixAuthenticationService.getUserByUsernameAndPassword({
username,
Expand All @@ -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) {
Expand All @@ -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 };
9 changes: 5 additions & 4 deletions api/src/shared/domain/services/token-service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}

Expand Down
74 changes: 74 additions & 0 deletions api/tests/unit/infrastructure/authentication_test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
});

0 comments on commit 99fbfa8

Please sign in to comment.