mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
refactor(core)!: merge SAML assertion handler API to authn API (#3082)
This commit is contained in:
parent
fa57680b55
commit
98eb15e205
5 changed files with 138 additions and 165 deletions
|
@ -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<Libraries['users']>;
|
||||
|
||||
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()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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<T extends AnonymousRouter>(
|
||||
...[router, { envSet, libraries }]: RouterInitArgs<T>
|
||||
...[router, { envSet, provider, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
const { findUserRoles } = libraries.users;
|
||||
const {
|
||||
users: { findUserRoles },
|
||||
socials: { getConnector },
|
||||
} = libraries;
|
||||
|
||||
router.get(
|
||||
'/authn/hasura',
|
||||
|
@ -72,4 +81,55 @@ export default function authnRoutes<T extends AnonymousRouter>(
|
|||
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();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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()
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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<T extends AnonymousRouter>(
|
||||
...[router, { provider, libraries }]: RouterInitArgs<T>
|
||||
) {
|
||||
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();
|
||||
}
|
||||
);
|
||||
}
|
Loading…
Reference in a new issue