diff --git a/packages/core/src/saml-applications/routes/utils.test.ts b/packages/core/src/saml-applications/routes/utils.test.ts new file mode 100644 index 000000000..73cbc4af3 --- /dev/null +++ b/packages/core/src/saml-applications/routes/utils.test.ts @@ -0,0 +1,178 @@ +import nock from 'nock'; +import type { IdentityProviderInstance, ServiceProviderInstance } from 'samlify'; + +import { + createSamlTemplateCallback, + exchangeAuthorizationCode, + getUserInfo, + setupSamlProviders, +} from './utils.js'; + +const { jest } = import.meta; + +describe('createSamlTemplateCallback', () => { + const mockIdp = { + entityMeta: { + getEntityID: () => 'idp-entity-id', + }, + entitySetting: { + nameIDFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + }, + createLoginResponse: jest.fn(), + parseLoginRequest: jest.fn(), + entityType: 'idp', + getEntitySetting: jest.fn(), + }; + + const mockSp = { + entityMeta: { + getAssertionConsumerService: () => 'https://sp.example.com/acs', + getEntityID: () => 'sp-entity-id', + }, + createLoginRequest: jest.fn(), + parseLoginResponse: jest.fn(), + entitySetting: {}, + entityType: 'sp', + }; + + const mockUser = { + sub: 'user123', + email: 'user@example.com', + name: 'Test User', + }; + + it('should create SAML template callback with correct values', () => { + const callback = createSamlTemplateCallback( + mockIdp as unknown as IdentityProviderInstance, + mockSp as unknown as ServiceProviderInstance, + mockUser + ); + + const result = callback('ID:NameID:attrEmail:attrName'); + const generatedId = result.id.replace('ID_', ''); + + expect(result.id).toBe('ID_' + generatedId); + expect(typeof result.context).toBe('string'); + }); +}); + +describe('exchangeAuthorizationCode', () => { + const mockTokenEndpoint = 'https://auth.example.com/token'; + const mockCode = 'auth-code'; + const mockClientId = 'client-id'; + const mockClientSecret = 'client-secret'; + const mockRedirectUri = 'https://app.example.com/callback'; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should exchange authorization code successfully', async () => { + const mockResponse = { + access_token: 'access-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'openid profile email', + id_token: 'mock.id.token', + }; + + const expectedAuthHeader = `Basic ${Buffer.from( + `${mockClientId}:${mockClientSecret}`, + 'utf8' + ).toString('base64')}`; + + nock('https://auth.example.com') + .post('/token', { + grant_type: 'authorization_code', + code: mockCode, + client_id: mockClientId, + redirect_uri: mockRedirectUri, + }) + .matchHeader('Authorization', expectedAuthHeader) + .matchHeader('Content-Type', 'application/x-www-form-urlencoded') + .reply(200, JSON.stringify(mockResponse)); + + const result = await exchangeAuthorizationCode(mockTokenEndpoint, { + code: mockCode, + clientId: mockClientId, + clientSecret: mockClientSecret, + redirectUri: mockRedirectUri, + }); + + expect(result).toMatchObject({ + accessToken: mockResponse.access_token, + tokenType: mockResponse.token_type, + expiresIn: mockResponse.expires_in, + scope: mockResponse.scope, + idToken: mockResponse.id_token, + }); + }); + + it('should throw error when token response is invalid', async () => { + nock('https://auth.example.com').post('/token').reply(200, { invalid: 'response' }); + + await expect( + exchangeAuthorizationCode(mockTokenEndpoint, { + code: mockCode, + clientId: mockClientId, + clientSecret: mockClientSecret, + }) + ).rejects.toMatchObject({ + code: 'oidc.invalid_token', + }); + }); +}); + +describe('getUserInfo', () => { + const mockAccessToken = 'access-token'; + const mockUserinfoEndpoint = 'https://auth.example.com/userinfo'; + + afterEach(() => { + nock.cleanAll(); + }); + + it('should get user info successfully', async () => { + const mockUserInfo = { + sub: 'user123', + email: 'user@example.com', + name: 'Test User', + }; + + nock('https://auth.example.com') + .get('/userinfo') + .matchHeader('Authorization', `Bearer ${mockAccessToken}`) + .reply(200, mockUserInfo); + + const result = await getUserInfo(mockAccessToken, mockUserinfoEndpoint); + expect(result).toMatchObject(mockUserInfo); + }); + + it('should throw error when user info response is invalid', async () => { + nock('https://auth.example.com') + .get('/userinfo') + .matchHeader('Authorization', `Bearer ${mockAccessToken}`) + .reply(200, { invalid: 'response' }); + + await expect(getUserInfo(mockAccessToken, mockUserinfoEndpoint)).rejects.toMatchObject({ + code: 'oidc.invalid_request', + }); + }); +}); + +describe('setupSamlProviders', () => { + it('should setup SAML providers with correct configuration', () => { + const mockMetadata = '...'; + const mockPrivateKey = '-----BEGIN PRIVATE KEY-----...'; + const mockEntityId = 'https://sp.example.com'; + const mockAcsUrl = { + binding: 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + url: 'https://sp.example.com/acs', + }; + + const { idp, sp } = setupSamlProviders(mockMetadata, mockPrivateKey, mockEntityId, mockAcsUrl); + + expect(idp).toBeDefined(); + expect(sp).toBeDefined(); + expect(sp.entityMeta.getEntityID()).toBe(mockEntityId); + }); +}); diff --git a/packages/core/src/saml-applications/routes/utils.ts b/packages/core/src/saml-applications/routes/utils.ts index ca36e8dcf..872b0e17b 100644 --- a/packages/core/src/saml-applications/routes/utils.ts +++ b/packages/core/src/saml-applications/routes/utils.ts @@ -18,7 +18,7 @@ import { samlValueXmlnsXsi, } from '../libraries/consts.js'; -const createSamlTemplateCallback = +export const createSamlTemplateCallback = ( idp: saml.IdentityProviderInstance, sp: saml.ServiceProviderInstance, @@ -74,7 +74,7 @@ const createSamlTemplateCallback = }; }; -const exchangeAuthorizationCode = async ( +export const exchangeAuthorizationCode = async ( tokenEndpoint: string, { code, @@ -153,12 +153,12 @@ export const generateAutoSubmitForm = (actionUrl: string, samlResponse: string): `; }; -const getUserInfo = async ( +export const getUserInfo = async ( accessToken: string, userinfoEndpoint: string ): Promise> => { const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint); - const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(body); + const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(parseJson(body)); if (!result.success) { throw new RequestError({ diff --git a/packages/core/src/sso/OidcConnector/utils.ts b/packages/core/src/sso/OidcConnector/utils.ts index 3909dc177..e0c0706d9 100644 --- a/packages/core/src/sso/OidcConnector/utils.ts +++ b/packages/core/src/sso/OidcConnector/utils.ts @@ -174,7 +174,9 @@ export const getUserInfo = async (accessToken: string, userinfoEndpoint: string) try { const body = await getRawUserInfoResponse(accessToken, userinfoEndpoint); - const result = idTokenProfileStandardClaimsGuard.catchall(z.unknown()).safeParse(body); + const result = idTokenProfileStandardClaimsGuard + .catchall(z.unknown()) + .safeParse(parseJson(body)); if (!result.success) { throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {