diff --git a/packages/core/src/routes/authn.test.ts b/packages/core/src/routes/authn.test.ts index 8811545b8..6b1c0759c 100644 --- a/packages/core/src/routes/authn.test.ts +++ b/packages/core/src/routes/authn.test.ts @@ -1,10 +1,14 @@ +import { ConnectorType } from '@logto/connector-kit'; import { pickDefault, createMockUtils } from '@logto/shared/esm'; import { mockRole } from '#src/__mocks__/index.js'; import RequestError from '#src/errors/RequestError/index.js'; import Libraries from '#src/tenants/Libraries.js'; +import { createMockProvider } from '#src/test-utils/oidc-provider.js'; import { MockTenant } from '#src/test-utils/tenant.js'; +import { mockConnector, mockMetadata, mockLogtoConnector } from '../__mocks__/connector.js'; + const { jest } = import.meta; const { mockEsmWithActual } = createMockUtils(jest); @@ -14,12 +18,52 @@ const { verifyBearerTokenFromRequest } = await mockEsmWithActual( verifyBearerTokenFromRequest: jest.fn(), }) ); +const validateSamlAssertion = jest.fn(); + +const mockSamlLogtoConnector = { + dbEntry: { ...mockConnector, connectorId: 'saml', id: 'saml_connector' }, + metadata: { ...mockMetadata, isStandard: true, id: 'saml', target: 'saml' }, + type: ConnectorType.Social, + ...mockLogtoConnector, + validateSamlAssertion, +}; + +const socialsLibraries = { + getConnector: jest.fn(async (connectorId: string) => { + if (connectorId !== 'saml_connector') { + throw new RequestError({ + code: 'entity.not_found', + connectorId, + status: 404, + }); + } + + return mockSamlLogtoConnector; + }), +}; + +const baseProviderMock = { + params: {}, + jti: 'jti', + client_id: 'client_id', +}; + +// Const samlAssertionHandlerRoutes = await pickDefault(import('./authn/saml.js')); +// const tenantContext = new MockTenant( +// createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), +// undefined, +// { socials: socialsLibraries } +// ); const usersLibraries = { findUserRoles: jest.fn(async () => [mockRole]), } satisfies Partial; -const tenantContext = new MockTenant(undefined, {}, { users: usersLibraries }); +const tenantContext = new MockTenant( + createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), + undefined, + { users: usersLibraries, socials: socialsLibraries } +); const { createRequester } = await import('#src/utils/test-utils.js'); const request = createRequester({ anonymousRoutes: await pickDefault(import('#src/routes/authn.js')), @@ -123,3 +167,33 @@ describe('authn route for Hasura', () => { }); }); }); + +describe('authn route for SAML', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('POST /authn/saml/non_saml_connector should throw 404', async () => { + const response = await request.post('/authn/saml/non_saml_connector'); + expect(response.status).toEqual(404); + }); + + it('POST /authn/saml/saml_connector should throw when `RelayState` missing', async () => { + const response = await request.post('/authn/saml/saml_connector').send({ + SAMLResponse: 'saml_response', + }); + expect(response.status).toEqual(500); + }); + + it('POST /authn/saml/saml_connector', async () => { + await request.post('/authn/saml/saml_connector').send({ + SAMLResponse: 'saml_response', + RelayState: 'relay_state', + }); + expect(validateSamlAssertion).toHaveBeenCalledWith( + { body: { RelayState: 'relay_state', SAMLResponse: 'saml_response' } }, + expect.anything(), + expect.anything() + ); + }); +}); diff --git a/packages/core/src/routes/authn.ts b/packages/core/src/routes/authn.ts index 147d78a0e..ce257de5e 100644 --- a/packages/core/src/routes/authn.ts +++ b/packages/core/src/routes/authn.ts @@ -1,21 +1,30 @@ +import type { ConnectorSession } from '@logto/connector-kit'; +import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; +import { arbitraryObjectGuard } from '@logto/schemas'; import { z } from 'zod'; import RequestError from '#src/errors/RequestError/index.js'; import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth.js'; import koaGuard from '#src/middleware/koa-guard.js'; import assertThat from '#src/utils/assert-that.js'; +import { + getConnectorSessionResultFromJti, + assignConnectorSessionResultViaJti, +} from '#src/utils/saml-assertion-handler.js'; import type { AnonymousRouter, RouterInitArgs } from './types.js'; /** * Authn stands for authentication. * This router will have a route `/authn` to authenticate tokens with a general manner. - * For now, we only implement the API for Hasura authentication. */ export default function authnRoutes( - ...[router, { envSet, libraries }]: RouterInitArgs + ...[router, { envSet, provider, libraries }]: RouterInitArgs ) { - const { findUserRoles } = libraries.users; + const { + users: { findUserRoles }, + socials: { getConnector }, + } = libraries; router.get( '/authn/hasura', @@ -72,4 +81,55 @@ export default function authnRoutes( return next(); } ); + + // Create an specialized API to handle SAML assertion + router.post( + '/authn/saml/:connectorId', + /** + * The API does not care the type of the SAML assertion request body, simply pass this to + * connector's built-in methods. + */ + koaGuard({ body: arbitraryObjectGuard, params: z.object({ connectorId: z.string().min(1) }) }), + async (ctx, next) => { + const { + params: { connectorId }, + body, + } = ctx.guard; + const connector = await getConnector(connectorId); + assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type'); + + const samlAssertionGuard = z.object({ SAMLResponse: z.string(), RelayState: z.string() }); + const samlAssertionParseResult = samlAssertionGuard.safeParse(body); + + if (!samlAssertionParseResult.success) { + throw new ConnectorError( + ConnectorErrorCodes.InvalidResponse, + samlAssertionParseResult.error + ); + } + + /** + * Since `RelayState` will be returned with value unchanged, we use it to pass `jti` + * to find the connector session we used to store essential information. + */ + const { RelayState: jti } = samlAssertionParseResult.data; + + const getSession = async () => getConnectorSessionResultFromJti(jti, provider); + const setSession = async (connectorSession: ConnectorSession) => + assignConnectorSessionResultViaJti(jti, provider, connectorSession); + + const { validateSamlAssertion } = connector; + assertThat( + validateSamlAssertion, + new ConnectorError(ConnectorErrorCodes.NotImplemented, { + message: 'Method `validateSamlAssertion()` is not implemented.', + }) + ); + const redirectTo = await validateSamlAssertion({ body }, getSession, setSession); + + ctx.redirect(redirectTo); + + return next(); + } + ); } diff --git a/packages/core/src/routes/init.ts b/packages/core/src/routes/init.ts index a80f0c064..0ea1fe8e4 100644 --- a/packages/core/src/routes/init.ts +++ b/packages/core/src/routes/init.ts @@ -20,7 +20,6 @@ import phraseRoutes from './phrase.js'; import resourceRoutes from './resource.js'; import roleRoutes from './role.js'; import roleScopeRoutes from './role.scope.js'; -import samlAssertionHandlerRoutes from './saml-assertion-handler.js'; import signInExperiencesRoutes from './sign-in-experience/index.js'; import statusRoutes from './status.js'; import swaggerRoutes from './swagger.js'; @@ -50,7 +49,6 @@ const createRouters = (tenant: TenantContext) => { verificationCodeRoutes(managementRouter, tenant); const anonymousRouter: AnonymousRouter = new Router(); - samlAssertionHandlerRoutes(anonymousRouter, tenant); phraseRoutes(anonymousRouter, tenant); wellKnownRoutes(anonymousRouter, tenant); statusRoutes(anonymousRouter, tenant); diff --git a/packages/core/src/routes/saml-assertion-handler.test.ts b/packages/core/src/routes/saml-assertion-handler.test.ts deleted file mode 100644 index 248009827..000000000 --- a/packages/core/src/routes/saml-assertion-handler.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { ConnectorType } from '@logto/connector-kit'; -import { pickDefault } from '@logto/shared/esm'; - -import RequestError from '#src/errors/RequestError/index.js'; -import { createMockProvider } from '#src/test-utils/oidc-provider.js'; -import { MockTenant } from '#src/test-utils/tenant.js'; -import { createRequester } from '#src/utils/test-utils.js'; - -import { mockConnector, mockMetadata, mockLogtoConnector } from '../__mocks__/connector.js'; - -const { jest } = import.meta; - -const validateSamlAssertion = jest.fn(); - -const mockSamlLogtoConnector = { - dbEntry: { ...mockConnector, connectorId: 'saml', id: 'saml_connector' }, - metadata: { ...mockMetadata, isStandard: true, id: 'saml', target: 'saml' }, - type: ConnectorType.Social, - ...mockLogtoConnector, - validateSamlAssertion, -}; - -const socialsLibraries = { - getConnector: jest.fn(async (connectorId: string) => { - if (connectorId !== 'saml_connector') { - throw new RequestError({ - code: 'entity.not_found', - connectorId, - status: 404, - }); - } - - return mockSamlLogtoConnector; - }), -}; - -const baseProviderMock = { - params: {}, - jti: 'jti', - client_id: 'client_id', -}; - -const samlAssertionHandlerRoutes = await pickDefault(import('./saml-assertion-handler.js')); -const tenantContext = new MockTenant( - createMockProvider(jest.fn().mockResolvedValue(baseProviderMock)), - undefined, - { socials: socialsLibraries } -); - -describe('samlAssertionHandlerRoutes', () => { - const assertionHandlerRequest = createRequester({ - anonymousRoutes: samlAssertionHandlerRoutes, - tenantContext, - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('POST /saml-assertion-handler/non_saml_connector should throw 404', async () => { - const response = await assertionHandlerRequest.post( - '/saml-assertion-handler/non_saml_connector' - ); - expect(response.status).toEqual(404); - }); - - it('POST /saml-assertion-handler/saml_connector should throw when `RelayState` missing', async () => { - const response = await assertionHandlerRequest - .post('/saml-assertion-handler/saml_connector') - .send({ - SAMLResponse: 'saml_response', - }); - expect(response.status).toEqual(500); - }); - - it('POST /saml-assertion-handler/saml_connector', async () => { - await assertionHandlerRequest.post('/saml-assertion-handler/saml_connector').send({ - SAMLResponse: 'saml_response', - RelayState: 'relay_state', - }); - expect(validateSamlAssertion).toHaveBeenCalledWith( - { body: { RelayState: 'relay_state', SAMLResponse: 'saml_response' } }, - expect.anything(), - expect.anything() - ); - }); -}); diff --git a/packages/core/src/routes/saml-assertion-handler.ts b/packages/core/src/routes/saml-assertion-handler.ts deleted file mode 100644 index c0ea99cda..000000000 --- a/packages/core/src/routes/saml-assertion-handler.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { ConnectorSession } from '@logto/connector-kit'; -import { ConnectorError, ConnectorErrorCodes, ConnectorType } from '@logto/connector-kit'; -import { arbitraryObjectGuard } from '@logto/schemas'; -import { z } from 'zod'; - -import koaGuard from '#src/middleware/koa-guard.js'; -import assertThat from '#src/utils/assert-that.js'; -import { - getConnectorSessionResultFromJti, - assignConnectorSessionResultViaJti, -} from '#src/utils/saml-assertion-handler.js'; - -import type { AnonymousRouter, RouterInitArgs } from './types.js'; - -export default function samlAssertionHandlerRoutes( - ...[router, { provider, libraries }]: RouterInitArgs -) { - const { - socials: { getConnector }, - } = libraries; - - // Create an specialized API to handle SAML assertion - router.post( - '/saml-assertion-handler/:connectorId', - /** - * The API does not care the type of the SAML assertion request body, simply pass this to - * connector's built-in methods. - */ - koaGuard({ body: arbitraryObjectGuard, params: z.object({ connectorId: z.string().min(1) }) }), - async (ctx, next) => { - const { - params: { connectorId }, - body, - } = ctx.guard; - const connector = await getConnector(connectorId); - assertThat(connector.type === ConnectorType.Social, 'connector.unexpected_type'); - - const samlAssertionGuard = z.object({ SAMLResponse: z.string(), RelayState: z.string() }); - const samlAssertionParseResult = samlAssertionGuard.safeParse(body); - - if (!samlAssertionParseResult.success) { - throw new ConnectorError( - ConnectorErrorCodes.InvalidResponse, - samlAssertionParseResult.error - ); - } - - /** - * Since `RelayState` will be returned with value unchanged, we use it to pass `jti` - * to find the connector session we used to store essential information. - */ - const { RelayState: jti } = samlAssertionParseResult.data; - - const getSession = async () => getConnectorSessionResultFromJti(jti, provider); - const setSession = async (connectorSession: ConnectorSession) => - assignConnectorSessionResultViaJti(jti, provider, connectorSession); - - const { validateSamlAssertion } = connector; - assertThat( - validateSamlAssertion, - new ConnectorError(ConnectorErrorCodes.NotImplemented, { - message: 'Method `validateSamlAssertion()` is not implemented.', - }) - ); - const redirectTo = await validateSamlAssertion({ body }, getSession, setSession); - - ctx.redirect(redirectTo); - - return next(); - } - ); -}