diff --git a/.vite/helpers/create-defra-id-test-tokens.js b/.vite/helpers/create-defra-id-test-tokens.js index 7f74d782..525e9620 100644 --- a/.vite/helpers/create-defra-id-test-tokens.js +++ b/.vite/helpers/create-defra-id-test-tokens.js @@ -6,7 +6,7 @@ const VALID_DEFRA_AUDIENCE = 'test-defra' const USER_EMAIL = 'someone@test-company.com' // Generate key pair once at module load time -// @ts-ignore - @types/node is missing generateKeyPairSync overloads for jwk format (incomplete fix in PR #63492) +// @ts-ignore const keyPair = generateKeyPairSync('rsa', { modulusLength: 4096, publicKeyEncoding: { @@ -20,9 +20,10 @@ const keyPair = generateKeyPairSync('rsa', { }) const privateKey = keyPair.privateKey -/** @type {import('crypto').JsonWebKey & {kid: string, use: string, alg: string}} */ +/** @type {import('crypto').JsonWebKey & {kid: string, use: string, alg: string}} */ export const publicKey = { + // @ts-ignore - keyPair.publicKey is JsonWebKey but spread causes type issues ...keyPair.publicKey, // Add JWKS-required fields to the public key @@ -31,18 +32,19 @@ export const publicKey = { alg: 'RS256' } +/** @type {import('../../src/common/helpers/auth/types.js').DefraIdTokenPayload} */ export const baseDefraIdTokenPayload = { - name: 'John Doe', id: 'test-contact-id', email: USER_EMAIL, - aud: VALID_DEFRA_AUDIENCE, - iss: `https://dcidmtest.b2clogin.com/DCIDMTest.onmicrosoft.com/v2.0`, + firstName: 'John', + lastName: 'Doe', currentRelationshipId: 'rel-1', relationships: ['rel-1'], - nbf: new Date().getTime() / 1000, + iss: `https://dcidmtest.b2clogin.com/DCIDMTest.onmicrosoft.com/v2.0`, + aud: VALID_DEFRA_AUDIENCE, exp: new Date().getTime() / 1000 + 3600, - maxAgeSec: 3600, // 60 minutes - timeSkewSec: 15 + iat: new Date().getTime() / 1000, + nbf: new Date().getTime() / 1000 } /** @type {{key: string, algorithm: 'RS256'}} */ diff --git a/src/common/helpers/auth/add-user-if-not-initial.js b/src/common/helpers/auth/add-user-if-not-initial.js new file mode 100644 index 00000000..a4e5f69e --- /dev/null +++ b/src/common/helpers/auth/add-user-if-not-initial.js @@ -0,0 +1,35 @@ +import { ROLES } from '#common/helpers/auth/constants.js' + +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ + +/** + * Adds a user to an organisation if they are not the initial user + * @param {Object} request - The Hapi request object + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload containing user information + * @param {Object} organisationById - The organisation object + * @returns {Promise} + */ +export const addUserIfNotInitial = async ( + request, + tokenPayload, + organisationById +) => { + const { organisationsRepository } = request + const { email, firstName, lastName } = tokenPayload + + await organisationsRepository.update( + organisationById.id, + organisationById.version, + { + users: [ + ...organisationById.users, + { + email, + fullName: `${firstName} ${lastName}`, + isInitialUser: false, + roles: [ROLES.standardUser] + } + ] + } + ) +} diff --git a/src/common/helpers/auth/get-defra-user-roles.js b/src/common/helpers/auth/get-defra-user-roles.js index b8334165..9e6c48a0 100644 --- a/src/common/helpers/auth/get-defra-user-roles.js +++ b/src/common/helpers/auth/get-defra-user-roles.js @@ -5,13 +5,13 @@ import { getUsersOrganisationInfo } from './get-users-org-info.js' import { getRolesForOrganisationAccess } from './get-roles-for-org-access.js' /** @typedef {import('#repositories/organisations/port.js').OrganisationsRepository} OrganisationsRepository */ +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ /** - * @param {Object} tokenPayload - * @param {string} tokenPayload.id - * @param {string} tokenPayload.email - * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - * @returns {Promise} + * Determines the roles for a Defra ID user based on their token and request context + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload + * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - The Hapi request object + * @returns {Promise} Array of role strings */ export async function getDefraUserRoles(tokenPayload, request) { const { email } = tokenPayload @@ -46,7 +46,12 @@ export async function getDefraUserRoles(tokenPayload, request) { // - the request does not have an organisationId param // - or if the linkedEprOrg does not match the organisationId param // - or if the organisation status is not accessible - const roles = await getRolesForOrganisationAccess(request, linkedEprOrg) + // Adds the user to the organisation if they are not already present + const roles = await getRolesForOrganisationAccess( + request, + linkedEprOrg, + tokenPayload + ) return roles } diff --git a/src/common/helpers/auth/get-defra-user-roles.test.js b/src/common/helpers/auth/get-defra-user-roles.test.js index f71a97b6..878576ba 100644 --- a/src/common/helpers/auth/get-defra-user-roles.test.js +++ b/src/common/helpers/auth/get-defra-user-roles.test.js @@ -193,7 +193,8 @@ describe('#getDefraUserRoles', () => { ) expect(mockGetRolesForOrganisationAccess).toHaveBeenCalledWith( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + tokenPayload ) }) @@ -231,7 +232,8 @@ describe('#getDefraUserRoles', () => { expect(mockGetRolesForOrganisationAccess).toHaveBeenCalledWith( customRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + tokenPayload ) expect(mockGetRolesForOrganisationAccess).toHaveBeenCalledTimes(1) }) diff --git a/src/common/helpers/auth/get-entra-user-roles.js b/src/common/helpers/auth/get-entra-user-roles.js index c774caca..7dbff434 100644 --- a/src/common/helpers/auth/get-entra-user-roles.js +++ b/src/common/helpers/auth/get-entra-user-roles.js @@ -1,6 +1,13 @@ import { ROLES } from '#common/helpers/auth/constants.js' import { getConfig } from '#root/config.js' +/** @typedef {import('./types.js').EntraIdTokenPayload} EntraIdTokenPayload */ + +/** + * Determines the roles for an Entra ID user based on their token + * @param {EntraIdTokenPayload} tokenPayload - The Entra ID token payload + * @returns {Promise} Array of role strings + */ export async function getEntraUserRoles(tokenPayload) { const userEmail = tokenPayload.email || tokenPayload.preferred_username diff --git a/src/common/helpers/auth/get-jwt-strategy-config.js b/src/common/helpers/auth/get-jwt-strategy-config.js index fe416fce..c38ef90a 100644 --- a/src/common/helpers/auth/get-jwt-strategy-config.js +++ b/src/common/helpers/auth/get-jwt-strategy-config.js @@ -3,6 +3,17 @@ import Boom from '@hapi/boom' import { getDefraUserRoles } from './get-defra-user-roles.js' import { getEntraUserRoles } from './get-entra-user-roles.js' +/** @typedef {import('./types.js').TokenPayload} TokenPayload */ +/** @typedef {import('./types.js').EntraIdTokenPayload} EntraIdTokenPayload */ +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ + +/** + * Configures JWT authentication strategy for both Entra ID and Defra ID + * @param {Object} oidcConfigs - OIDC configuration for both identity providers + * @param {Object} oidcConfigs.entraIdOidcConfig - Entra ID OIDC configuration + * @param {Object} oidcConfigs.defraIdOidcConfig - Defra ID OIDC configuration + * @returns {Object} JWT strategy configuration object + */ export function getJwtStrategyConfig(oidcConfigs) { const { entraIdOidcConfig, defraIdOidcConfig } = oidcConfigs diff --git a/src/common/helpers/auth/get-roles-for-org-access.js b/src/common/helpers/auth/get-roles-for-org-access.js index 1b89f8b2..05666418 100644 --- a/src/common/helpers/auth/get-roles-for-org-access.js +++ b/src/common/helpers/auth/get-roles-for-org-access.js @@ -1,15 +1,23 @@ import Boom from '@hapi/boom' import { STATUS } from '#domain/organisations/model.js' import { ROLES } from '#common/helpers/auth/constants.js' +import { addUserIfNotInitial } from './add-user-if-not-initial.js' /** @typedef {import('#repositories/organisations/port.js').OrganisationsRepository} OrganisationsRepository */ +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ /** - * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - * @param {string} linkedEprOg - * @returns {Promise} + * Determines roles for organization access based on token and organization status + * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - The Hapi request object + * @param {string} linkedEprOg - The linked EPR organization ID + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload + * @returns {Promise} Array of role strings */ -export const getRolesForOrganisationAccess = async (request, linkedEprOg) => { +export const getRolesForOrganisationAccess = async ( + request, + linkedEprOg, + tokenPayload +) => { const { organisationId } = request.params if (!organisationId) { @@ -32,7 +40,7 @@ export const getRolesForOrganisationAccess = async (request, linkedEprOg) => { throw Boom.forbidden('Access denied: organisation status not accessible') } - // Placeholder for checking whether the user is part of the EPROrganisation + addUserIfNotInitial(request, tokenPayload, organisationById) return [ROLES.standardUser] } diff --git a/src/common/helpers/auth/get-roles-for-org-access.test.js b/src/common/helpers/auth/get-roles-for-org-access.test.js index 189e2cce..78791ecc 100644 --- a/src/common/helpers/auth/get-roles-for-org-access.test.js +++ b/src/common/helpers/auth/get-roles-for-org-access.test.js @@ -5,6 +5,7 @@ import { ObjectId } from 'mongodb' import { getRolesForOrganisationAccess } from './get-roles-for-org-access.js' import { STATUS } from '#domain/organisations/model.js' import { ROLES } from '#common/helpers/auth/constants.js' +import { baseDefraIdTokenPayload } from '#vite/helpers/create-defra-id-test-tokens.js' describe('#getRolesForOrganisationAccess', () => { const mockOrganisationId = new ObjectId().toString() @@ -17,7 +18,8 @@ describe('#getRolesForOrganisationAccess', () => { vi.clearAllMocks() mockOrganisationsRepository = { - findById: vi.fn() + findById: vi.fn(), + update: vi.fn() } mockRequest = { @@ -33,43 +35,33 @@ describe('#getRolesForOrganisationAccess', () => { }) describe('happy path', () => { - test('returns standard_user role when organisation is ACTIVE', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.ACTIVE - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) + test.each([ + ['ACTIVE', STATUS.ACTIVE], + ['SUSPENDED', STATUS.SUSPENDED] + ])( + 'returns standard_user role when organisation is %s', + async (statusName, status) => { + const mockOrganisation = { + id: mockOrganisationId, + status, + users: [], + version: 1 + } - const result = await getRolesForOrganisationAccess( - mockRequest, - mockLinkedEprOrg - ) + mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - expect(result).toEqual([ROLES.standardUser]) - expect(mockOrganisationsRepository.findById).toHaveBeenCalledWith( - mockOrganisationId - ) - }) + const result = await getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) - test('returns standard_user role when organisation is SUSPENDED', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.SUSPENDED + expect(result).toEqual([ROLES.standardUser]) + expect(mockOrganisationsRepository.findById).toHaveBeenCalledWith( + mockOrganisationId + ) } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - const result = await getRolesForOrganisationAccess( - mockRequest, - mockLinkedEprOrg - ) - - expect(result).toEqual([ROLES.standardUser]) - expect(mockOrganisationsRepository.findById).toHaveBeenCalledWith( - mockOrganisationId - ) - }) + ) test('calls repository with correct organisation ID from params', async () => { const customOrgId = new ObjectId().toString() @@ -77,12 +69,18 @@ describe('#getRolesForOrganisationAccess', () => { const mockOrganisation = { id: customOrgId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - await getRolesForOrganisationAccess(mockRequest, customOrgId) + await getRolesForOrganisationAccess( + mockRequest, + customOrgId, + baseDefraIdTokenPayload + ) expect(mockOrganisationsRepository.findById).toHaveBeenCalledWith( customOrgId @@ -91,47 +89,35 @@ describe('#getRolesForOrganisationAccess', () => { }) describe('no organisation ID in params', () => { - test('returns empty array when organisationId is undefined', async () => { - mockRequest.params.organisationId = undefined + test.each([ + ['undefined', undefined], + ['null', null], + ['empty string', ''] + ])( + 'returns empty array when organisationId is %s', + async (description, orgIdValue) => { + mockRequest.params.organisationId = orgIdValue - const result = await getRolesForOrganisationAccess( - mockRequest, - mockLinkedEprOrg - ) - - expect(result).toEqual([]) - expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() - }) - - test('returns empty array when organisationId is null', async () => { - mockRequest.params.organisationId = null - - const result = await getRolesForOrganisationAccess( - mockRequest, - mockLinkedEprOrg - ) - - expect(result).toEqual([]) - expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() - }) - - test('returns empty array when organisationId is empty string', async () => { - mockRequest.params.organisationId = '' - - const result = await getRolesForOrganisationAccess( - mockRequest, - mockLinkedEprOrg - ) + const result = await getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) - expect(result).toEqual([]) - expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() - }) + expect(result).toEqual([]) + expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() + } + ) test('returns empty array when params object is missing', async () => { mockRequest.params = undefined await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) + getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) ).rejects.toThrow() }) }) @@ -141,7 +127,11 @@ describe('#getRolesForOrganisationAccess', () => { const differentOrgId = new ObjectId().toString() await expect( - getRolesForOrganisationAccess(mockRequest, differentOrgId) + getRolesForOrganisationAccess( + mockRequest, + differentOrgId, + baseDefraIdTokenPayload + ) ).rejects.toThrow(Boom.forbidden('Access denied: organisation mismatch')) expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() @@ -151,7 +141,11 @@ describe('#getRolesForOrganisationAccess', () => { const differentOrgId = new ObjectId().toString() try { - await getRolesForOrganisationAccess(mockRequest, differentOrgId) + await getRolesForOrganisationAccess( + mockRequest, + differentOrgId, + baseDefraIdTokenPayload + ) expect.fail('Should have thrown an error') } catch (error) { expect(error.isBoom).toBe(true) @@ -168,7 +162,11 @@ describe('#getRolesForOrganisationAccess', () => { }) await expect( - getRolesForOrganisationAccess(mockRequest, differentOrgId) + getRolesForOrganisationAccess( + mockRequest, + differentOrgId, + baseDefraIdTokenPayload + ) ).rejects.toThrow(Boom.forbidden('Access denied: organisation mismatch')) // Repository should not be called if IDs don't match @@ -177,106 +175,53 @@ describe('#getRolesForOrganisationAccess', () => { }) describe('organisation status not accessible', () => { - test('throws forbidden error when organisation status is CREATED', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.CREATED - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) - - test('throws forbidden error when organisation status is APPROVED', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.APPROVED - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) - - test('throws forbidden error when organisation status is REJECTED', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.REJECTED - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) - - test('throws forbidden error when organisation status is ARCHIVED', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: STATUS.ARCHIVED - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) - - test('throws forbidden error when organisation status is undefined', async () => { - const mockOrganisation = { - id: mockOrganisationId - // status is missing - } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) + test.each([ + ['CREATED', STATUS.CREATED], + ['APPROVED', STATUS.APPROVED], + ['REJECTED', STATUS.REJECTED], + ['ARCHIVED', STATUS.ARCHIVED], + ['undefined', undefined], + ['null', null] + ])( + 'throws forbidden error when organisation status is %s', + async (statusName, status) => { + const mockOrganisation = { + id: mockOrganisationId, + status, + users: [], + version: 1 + } - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) + mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - test('throws forbidden error when organisation status is null', async () => { - const mockOrganisation = { - id: mockOrganisationId, - status: null + await expect( + getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) + ).rejects.toThrow( + Boom.forbidden('Access denied: organisation status not accessible') + ) } - - mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow( - Boom.forbidden('Access denied: organisation status not accessible') - ) - }) + ) test('throws forbidden error with exact message format for non-accessible status', async () => { const mockOrganisation = { id: mockOrganisationId, - status: STATUS.REJECTED + status: STATUS.REJECTED, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) try { - await getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) + await getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) expect.fail('Should have thrown an error') } catch (error) { expect(error.isBoom).toBe(true) @@ -289,30 +234,44 @@ describe('#getRolesForOrganisationAccess', () => { }) describe('repository errors', () => { - test('propagates repository error when findById fails', async () => { - const repositoryError = new Error('Database connection failed') - mockOrganisationsRepository.findById.mockRejectedValue(repositoryError) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow(repositoryError) - }) - - test('propagates error when findById returns null', async () => { - mockOrganisationsRepository.findById.mockResolvedValue(null) - - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow() - }) + test.each([ + [ + 'propagates repository error when findById fails', + () => { + const error = new Error('Database connection failed') + mockOrganisationsRepository.findById.mockRejectedValue(error) + return error + } + ], + [ + 'propagates error when findById returns null', + () => { + mockOrganisationsRepository.findById.mockResolvedValue(null) + return undefined + } + ], + [ + 'handles timeout error from repository', + () => { + const error = new Error('Query timeout') + mockOrganisationsRepository.findById.mockRejectedValue(error) + return error + } + ] + ])('%s', async (description, setupMock) => { + const expectedError = setupMock() - test('handles timeout error from repository', async () => { - const timeoutError = new Error('Query timeout') - mockOrganisationsRepository.findById.mockRejectedValue(timeoutError) + const promise = getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) - await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) - ).rejects.toThrow(timeoutError) + if (expectedError) { + await expect(promise).rejects.toThrow(expectedError) + } else { + await expect(promise).rejects.toThrow() + } }) }) @@ -323,6 +282,7 @@ describe('#getRolesForOrganisationAccess', () => { status: STATUS.ACTIVE, name: 'Test Organisation', users: [{ email: 'test@example.com' }], + version: 1, createdAt: new Date(), metadata: { foo: 'bar' } } @@ -331,7 +291,9 @@ describe('#getRolesForOrganisationAccess', () => { const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) @@ -343,14 +305,17 @@ describe('#getRolesForOrganisationAccess', () => { const mockOrganisation = { id: objectIdFormat, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - objectIdFormat + objectIdFormat, + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) @@ -360,14 +325,17 @@ describe('#getRolesForOrganisationAccess', () => { const activeStatus = STATUS.ACTIVE const mockOrganisation = { id: mockOrganisationId, - status: activeStatus + status: activeStatus, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) @@ -376,14 +344,17 @@ describe('#getRolesForOrganisationAccess', () => { test('returns array with single role, not just the role string', async () => { const mockOrganisation = { id: mockOrganisationId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(Array.isArray(result)).toBe(true) @@ -398,7 +369,8 @@ describe('#getRolesForOrganisationAccess', () => { const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(result).toEqual([]) @@ -409,7 +381,11 @@ describe('#getRolesForOrganisationAccess', () => { const differentOrgId = new ObjectId().toString() await expect( - getRolesForOrganisationAccess(mockRequest, differentOrgId) + getRolesForOrganisationAccess( + mockRequest, + differentOrgId, + baseDefraIdTokenPayload + ) ).rejects.toThrow() expect(mockOrganisationsRepository.findById).not.toHaveBeenCalled() @@ -418,12 +394,18 @@ describe('#getRolesForOrganisationAccess', () => { test('fetches organisation only after validation passes', async () => { const mockOrganisation = { id: mockOrganisationId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) - await getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) + await getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) expect(mockOrganisationsRepository.findById).toHaveBeenCalledOnce() expect(mockOrganisationsRepository.findById).toHaveBeenCalledWith( @@ -439,14 +421,17 @@ describe('#getRolesForOrganisationAccess', () => { callOrder.push(`findById:${id}`) return { id: mockOrganisationId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } } ) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(callOrder).toEqual([`findById:${mockOrganisationId}`]) @@ -468,14 +453,17 @@ describe('#getRolesForOrganisationAccess', () => { for (const status of accessibleStatuses) { const mockOrganisation = { id: mockOrganisationId, - status + status, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) @@ -485,13 +473,19 @@ describe('#getRolesForOrganisationAccess', () => { for (const status of nonAccessibleStatuses) { const mockOrganisation = { id: mockOrganisationId, - status + status, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) await expect( - getRolesForOrganisationAccess(mockRequest, mockLinkedEprOrg) + getRolesForOrganisationAccess( + mockRequest, + mockLinkedEprOrg, + baseDefraIdTokenPayload + ) ).rejects.toThrow( Boom.forbidden('Access denied: organisation status not accessible') ) @@ -501,14 +495,17 @@ describe('#getRolesForOrganisationAccess', () => { test('status comparison is exact match', async () => { const mockOrganisation = { id: mockOrganisationId, - status: 'active' // Same value as STATUS.ACTIVE + status: 'active', // Same value as STATUS.ACTIVE + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) @@ -521,7 +518,8 @@ describe('#getRolesForOrganisationAccess', () => { mockRequest.params.organisationId = undefined let result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(Array.isArray(result)).toBe(true) @@ -529,13 +527,16 @@ describe('#getRolesForOrganisationAccess', () => { mockRequest.params.organisationId = mockOrganisationId const mockOrganisation = { id: mockOrganisationId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(Array.isArray(result)).toBe(true) }) @@ -543,14 +544,17 @@ describe('#getRolesForOrganisationAccess', () => { test('returns array containing only standard_user role constant', async () => { const mockOrganisation = { id: mockOrganisationId, - status: STATUS.ACTIVE + status: STATUS.ACTIVE, + users: [], + version: 1 } mockOrganisationsRepository.findById.mockResolvedValue(mockOrganisation) const result = await getRolesForOrganisationAccess( mockRequest, - mockLinkedEprOrg + mockLinkedEprOrg, + baseDefraIdTokenPayload ) expect(result).toEqual([ROLES.standardUser]) diff --git a/src/common/helpers/auth/get-users-org-info.js b/src/common/helpers/auth/get-users-org-info.js index 54818088..e67300a9 100644 --- a/src/common/helpers/auth/get-users-org-info.js +++ b/src/common/helpers/auth/get-users-org-info.js @@ -9,10 +9,13 @@ import { */ /** @typedef {import('#repositories/organisations/port.js').OrganisationsRepository} OrganisationsRepository */ +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ /** - * @param {object} tokenPayload - The OIDC token payload containing user and organization data + * Retrieves organization information for a user based on their Defra ID token + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload containing user and organization data * @param {OrganisationsRepository} organisationsRepository - The organisations repository + * @returns {Promise<{linkedEprOrg: string, userOrgs: Array}>} Object containing linked EPR org and all user orgs */ export async function getUsersOrganisationInfo( tokenPayload, diff --git a/src/common/helpers/auth/is-authorised-org-linking-req.js b/src/common/helpers/auth/is-authorised-org-linking-req.js index df37b6cc..703db2f3 100644 --- a/src/common/helpers/auth/is-authorised-org-linking-req.js +++ b/src/common/helpers/auth/is-authorised-org-linking-req.js @@ -3,10 +3,13 @@ import Boom from '@hapi/boom' import { isInitialUser } from './roles/helpers.js' /** @typedef {import('#repositories/organisations/port.js').OrganisationsRepository} OrganisationsRepository */ +/** @typedef {import('./types.js').DefraIdTokenPayload} DefraIdTokenPayload */ /** - * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - * @param {object} tokenPayload - The OIDC token payload containing user and organization data + * Checks if the request is an authorized organization linking request + * @param {import('#common/hapi-types.js').HapiRequest & {organisationsRepository: OrganisationsRepository}} request - The Hapi request object + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload containing user and organization data + * @returns {Promise} True if the request is authorized, false otherwise */ export async function isAuthorisedOrgLinkingReq(request, tokenPayload) { const isOrganisationLinkingRequest = diff --git a/src/common/helpers/auth/roles/helpers.js b/src/common/helpers/auth/roles/helpers.js index 1c7bf636..6ed48dc6 100644 --- a/src/common/helpers/auth/roles/helpers.js +++ b/src/common/helpers/auth/roles/helpers.js @@ -1,19 +1,14 @@ import { organisationsLinkedGetAllPath } from '#domain/organisations/paths.js' /** @typedef {import('#repositories/organisations/port.js').OrganisationsRepository} OrganisationsRepository */ +/** @typedef {import('../types.js').DefraIdTokenPayload} DefraIdTokenPayload */ +/** @typedef {import('../types.js').DefraIdRelationship} DefraIdRelationship */ /** - * @typedef {Object} TokenPayload - * @property {string} id - The user ID - * @property {string} email - The user email - * @property {string} currentRelationshipId - The current relationship ID - * @property {string[]} relationships - Array of relationship strings in format "relationshipId:organisationId:organisationName" - */ - -/** - * @param {Object} organisation - * @param {string} email - * @returns {boolean} + * Checks if a user is the initial user of an organisation + * @param {Object} organisation - The organisation object + * @param {string} email - The user's email address + * @returns {boolean} True if the user is the initial user */ export function isInitialUser(organisation, email) { return organisation.users.some( @@ -23,8 +18,9 @@ export function isInitialUser(organisation, email) { } /** - * @param {TokenPayload} tokenPayload - * @returns {Array<{defraIdRelationshipId: string, defraIdOrgId: string, defraIdOrgName: string, isCurrent: boolean}>} + * Extracts and parses organization data from a Defra ID token + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload + * @returns {DefraIdRelationship[]} Array of parsed relationship objects */ export function getOrgDataFromDefraIdToken(tokenPayload) { const { currentRelationshipId, relationships } = tokenPayload @@ -44,20 +40,17 @@ export function getOrgDataFromDefraIdToken(tokenPayload) { /** * Finds and returns the current relationship from an array of relationships - * @param {Array<{defraIdRelationshipId: string, defraIdOrgId: string, defraIdOrgName: string, isCurrent: boolean}>} relationships - Array of relationship objects - * @returns {{defraIdRelationshipId: string, defraIdOrgId: string, defraIdOrgName: string, isCurrent: boolean} | undefined} The current relationship or undefined if none found - */ -/** - * Finds and returns the current relationship from an array of relationships - * @param {Array<{defraIdRelationshipId: string, defraIdOrgId: string, defraIdOrgName: string, isCurrent: boolean}>} relationships - Array of relationship objects - * @returns {{defraIdRelationshipId: string, defraIdOrgId: string, defraIdOrgName: string, isCurrent: boolean} | undefined} The current relationship or undefined if none found + * @param {DefraIdRelationship[]} relationships - Array of relationship objects + * @returns {DefraIdRelationship | undefined} The current relationship or undefined if none found */ export function getCurrentRelationship(relationships) { return relationships.find(({ isCurrent }) => isCurrent) } /** - * @param {TokenPayload} tokenPayload + * Extracts a summary of organization data from a Defra ID token + * @param {DefraIdTokenPayload} tokenPayload - The Defra ID token payload + * @returns {{defraIdOrgId?: string, defraIdOrgName?: string, defraIdRelationships: DefraIdRelationship[]}} Summary object containing current org ID, name, and all relationships */ export function getDefraTokenSummary(tokenPayload) { const defraIdRelationships = getOrgDataFromDefraIdToken(tokenPayload) @@ -77,18 +70,14 @@ export function isOrganisationsDiscoveryReq(request) { ) } -/** - * @param {string} email - * @param {string} defraIdOrgId - * @param {OrganisationsRepository} organisationsRepository - The organisations repository - * @returns {Promise<{all: Array, unlinked: Array, linked: Array}>} - */ /** * Helper function to deduplicate organisations by ID + * * Exported for testing purposes - * @param {Array} unlinkedOrganisations - * @param {Array} linkedOrganisations - * @returns {Array} + * + * @param {Array} unlinkedOrganisations - Array of unlinked organisations + * @param {Array} linkedOrganisations - Array of linked organisations + * @returns {Array} Deduplicated array of organisations */ export function deduplicateOrganisations( unlinkedOrganisations, @@ -103,6 +92,13 @@ export function deduplicateOrganisations( ) } +/** + * Finds organization matches for a user based on email and Defra ID org ID + * @param {string} _email - The user's email address + * @param {string} _defraIdOrgId - The Defra ID organization ID + * @param {OrganisationsRepository} _organisationsRepository - The organisations repository + * @returns {Promise<{all: Array, unlinked: Array, linked: Array}>} Object containing all, unlinked, and linked organizations + */ export async function findOrganisationMatches( _email, _defraIdOrgId, diff --git a/src/common/helpers/auth/types.js b/src/common/helpers/auth/types.js new file mode 100644 index 00000000..8ecbeb23 --- /dev/null +++ b/src/common/helpers/auth/types.js @@ -0,0 +1,57 @@ +/** + * Entra ID (Azure Active Directory) token payload + * + * Used for Admin UI authentication and service maintainer access + * + * @typedef {{ + * id: string + * email?: string + * preferred_username?: string + * iss: string + * aud: string + * sub?: string + * oid?: string + * exp: number + * iat: number + * nbf?: number + * }} EntraIdTokenPayload + */ + +/** + * Defra ID token payload + * + * Used for Frontend application authentication and organization-based access control + * + * @typedef {{ + * id: string + * email: string + * firstName: string + * lastName: string + * currentRelationshipId: string + * relationships: string[] + * iss: string + * aud: string + * exp: number + * iat: number + * nbf?: number + * }} DefraIdTokenPayload + */ + +/** + * Union type representing any valid token payload from either identity provider + * + * @typedef {EntraIdTokenPayload | DefraIdTokenPayload} TokenPayload + */ + +/** + * Parsed organization data extracted from Defra ID token relationships + * + * @typedef {{ + * defraIdRelationshipId: string + * defraIdOrgId: string + * defraIdOrgName: string + * isCurrent: boolean + * }} DefraIdRelationship + */ + +export {} // NOSONAR: javascript:S7787 - Required to make this file a module for JSDoc @import