0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core): add the saml assertion handle flow (#4872)

* feat(core): add saml assertion handle flow

add saml assertion handle flow

* fix(core): address some comments

address some comments

* chore(core): comments update

comments update

* chore(core): add deprecated tag

add dreprecated tag

* refactor(core): use url object instead of plain string

use url object instead of plain string
This commit is contained in:
simeng-li 2023-11-17 14:14:45 +08:00 committed by GitHub
parent 733261af55
commit edb2bdccec
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 359 additions and 67 deletions

View file

@ -6,12 +6,17 @@ import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import { verifyBearerTokenFromRequest } from '#src/middleware/koa-auth/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import SamlConnector from '#src/sso/SamlConnector/index.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
import assertThat from '#src/utils/assert-that.js';
import {
getConnectorSessionResultFromJti,
assignConnectorSessionResultViaJti,
getSingleSignOnSessionResultByJti,
assignSamlAssertionResultViaJti,
} from '#src/utils/saml-assertion-handler.js';
import { ssoPath } from './interaction/const.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';
/**
@ -19,11 +24,12 @@ import type { AnonymousRouter, RouterInitArgs } from './types.js';
* This router will have a route `/authn` to authenticate tokens with a general manner.
*/
export default function authnRoutes<T extends AnonymousRouter>(
...[router, { envSet, provider, libraries }]: RouterInitArgs<T>
...[router, { envSet, provider, libraries, id: tenantId }]: RouterInitArgs<T>
) {
const {
users: { findUserRoles },
socials: { getConnector },
ssoConnectors: ssoConnectorsLibrary,
} = libraries;
const hasuraResponseGuard = z.object({
@ -90,7 +96,11 @@ export default function authnRoutes<T extends AnonymousRouter>(
}
);
// Create an specialized API to handle SAML assertion
/**
* Standard SAML social connector's assertion consumer service endpoint
* @deprecated
* Will be replaced by the SSO SAML assertion consumer service endpoint bellow.
*/
router.post(
'/authn/saml/:connectorId',
/**
@ -144,4 +154,74 @@ export default function authnRoutes<T extends AnonymousRouter>(
return next();
}
);
/**
* SAML SSO connector's assertion consumer service endpoint
*
* @param connectorId The connector id.
* @property body The SAML assertion response body.
* @property body.RelayState We use this to find the connector session.
* RelayState is a SAML standard parameter that will be transmitted between the identity provider and the service provider.
* @returns Redirect to the redirect uri find in the connector session storage.
*
* @remark
* This API is used to handle SSO SAML assertion from the identity provider.
* Validate and parse the SAML assertion, then store the assertion data to the connector session storage.
* Redirect to the redirect uri find in the connector session storage.
*/
router.post(
`/authn/${ssoPath}/saml/:connectorId`,
koaGuard({
body: z.object({ RelayState: z.string() }).catchall(z.unknown()),
params: z.object({ connectorId: z.string().min(1) }),
status: [302, 404],
}),
async (ctx, next) => {
const {
params: { connectorId },
body,
} = ctx.guard;
// Will throw 404 if connector not found, or not supported
const connectorData = await ssoConnectorsLibrary.getSsoConnectorById(connectorId);
const { providerName } = connectorData;
// Get relay state from the request body. We need to use it to find the connector session.
// All the rest of the request body will be validated and parsed by the connector.
const { RelayState: jti } = body;
// Retrieve the single sign on session data using the jti
const singleSignOnSession = await getSingleSignOnSessionResultByJti(jti, provider);
const { redirectUri, state, connectorId: sessionConnectorId } = singleSignOnSession;
assertThat(
connectorId === sessionConnectorId,
'session.connector_validation_session_not_found'
);
// Will throw ConnectorError if the config is invalid
const connectorInstance = new ssoConnectorFactories[providerName].constructor(
connectorData,
tenantId
);
assertThat(connectorInstance instanceof SamlConnector, 'connector.unexpected_type');
const userInfo = await connectorInstance.parseSamlAssertion(body);
await assignSamlAssertionResultViaJti(jti, provider, {
...singleSignOnSession,
userInfo,
});
// Client side will verify the state to prevent CSRF attack.
const url = new URL(redirectUri);
url.searchParams.append('state', state);
ctx.redirect(url.toString());
return next();
}
);
}

View file

@ -52,11 +52,6 @@ export const verifySsoOnlyEmailIdentifier = async (
/**
* Get the single sign on session data from the oidc provider session storage.
*
* @param ctx
* @param provider
* @param connectorId
* @returns The single sign on session data
*
* @remark Forked from ./social-verification.ts.
* Use SingleSignOnSession guard instead of ConnectorSession guard.
*/

View file

@ -3,6 +3,7 @@ import { createMockUtils } from '@logto/shared/esm';
import type Provider from 'oidc-provider';
import { mockSsoConnector, wellConfiguredSsoConnector } from '#src/__mocks__/sso.js';
import RequestError from '#src/errors/RequestError/index.js';
import { type WithLogContext } from '#src/middleware/koa-audit-log.js';
import { OidcSsoConnector } from '#src/sso/OidcSsoConnector/index.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
@ -27,6 +28,7 @@ const findUserByEmailMock = jest.fn();
const insertUserMock = jest.fn();
const storeInteractionResultMock = jest.fn();
const getAvailableSsoConnectorsMock = jest.fn();
const getSingleSignOnSessionResultMock = jest.fn();
class MockOidcSsoConnector extends OidcSsoConnector {
override getAuthorizationUrl = getAuthorizationUrlMock;
@ -38,6 +40,10 @@ mockEsm('./interaction.js', () => ({
storeInteractionResult: storeInteractionResultMock,
}));
mockEsm('./single-sign-on-guard.js', () => ({
getSingleSignOnSessionResult: getSingleSignOnSessionResultMock,
}));
jest
.spyOn(ssoConnectorFactories.OIDC, 'constructor')
.mockImplementation((data: SsoConnector) => new MockOidcSsoConnector(data));
@ -109,6 +115,20 @@ describe('Single sign on util methods tests', () => {
});
describe('getSsoAuthentication tests', () => {
it('should throw an error if the connector config is invalid', async () => {
getSingleSignOnSessionResultMock.mockRejectedValueOnce(
new RequestError('session.connector_validation_session_not_found')
);
await expect(getSsoAuthentication(mockContext, tenant, mockSsoConnector, {})).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
expect.objectContaining({
status: 400,
code: `session.connector_validation_session_not_found`,
})
);
});
it('should throw an error if the connector config is invalid', async () => {
await expect(getSsoAuthentication(mockContext, tenant, mockSsoConnector, {})).rejects.toThrow(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
@ -117,6 +137,19 @@ describe('Single sign on util methods tests', () => {
});
it('should return the authentication result', async () => {
const session = {
connectorId: 'connectorId',
jti: 'jti',
redirectUri: 'https://example.com',
state: 'state',
};
const payload = {
code: 'code',
};
getSingleSignOnSessionResultMock.mockResolvedValueOnce(session);
getUserInfoMock.mockResolvedValueOnce({ id: 'id', email: 'email' });
getIssuerMock.mockReturnValueOnce('https://example.com');
@ -124,11 +157,11 @@ describe('Single sign on util methods tests', () => {
mockContext,
tenant,
wellConfiguredSsoConnector,
{}
payload
);
expect(getIssuerMock).toBeCalled();
expect(getUserInfoMock).toBeCalled();
expect(getUserInfoMock).toBeCalledWith(session, payload);
expect(result).toStrictEqual({
issuer: 'https://example.com',

View file

@ -90,6 +90,8 @@ export const getSsoAuthentication = async (
const log = createLog(`Interaction.SignIn.Identifier.SingleSignOn.Submit`);
log.append({ connectorId, data });
const singleSignOnSession = await getSingleSignOnSessionResult(ctx, provider);
try {
// Will throw ConnectorError if the config is invalid
const connectorInstance = new ssoConnectorFactories[providerName].constructor(
@ -98,9 +100,7 @@ export const getSsoAuthentication = async (
);
const issuer = await connectorInstance.getIssuer();
const userInfo = await connectorInstance.getUserInfo(data, async () =>
getSingleSignOnSessionResult(ctx, provider)
);
const userInfo = await connectorInstance.getUserInfo(singleSignOnSession, data);
const result = {
issuer,

View file

@ -4,7 +4,10 @@ import { assert, conditional } from '@silverhand/essentials';
import snakecaseKeys from 'snakecase-keys';
import { type BaseOidcConfig, type BasicOidcConnectorConfig } from '../types/oidc.js';
import { type CreateSingleSignOnSession, type GetSingleSignOnSession } from '../types/session.js';
import {
type SingleSignOnConnectorSession,
type CreateSingleSignOnSession,
} from '../types/session.js';
import { fetchOidcConfig, fetchToken, getIdTokenClaims } from './utils.js';
@ -91,21 +94,14 @@ class OidcConnector {
* Handle the sign-in callback from the OIDC provider and return the user info
*
* @param data unknown oidc authorization response
* @param getSession Get the connector session data from the oidc provider session storage. @see @logto/connector-kit
* @param connectorSession The connector session data from the oidc provider session storage
* @returns The user info from the OIDC provider
* @remark Forked from @logto/oidc-connector
*
*/
getUserInfo = async (data: unknown, getSession: GetSingleSignOnSession) => {
assert(
getSession,
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
message: 'Connector session storage not found',
})
);
getUserInfo = async (connectorSession: SingleSignOnConnectorSession, data: unknown) => {
const oidcConfig = await this.getOidcConfig();
const { nonce, redirectUri } = await getSession();
const { nonce, redirectUri } = connectorSession;
// Fetch token from the OIDC provider using authorization code
const { idToken } = await fetchToken(oidcConfig, data, redirectUri);

View file

@ -4,8 +4,14 @@ import * as saml from 'samlify';
import { z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import { ssoPath } from '#src/routes/interaction/const.js';
import { type SamlConfig, type SamlConnectorConfig, samlMetadataGuard } from '../types/saml.js';
import {
type SamlConfig,
type SamlConnectorConfig,
samlMetadataGuard,
type ExtendedSocialUserInfo,
} from '../types/saml.js';
import {
parseXmlMetadata,
@ -23,7 +29,7 @@ import {
* All the SAML single sign-on connector should extend this class.
*
* @property config The SAML connector config
* @property acsUrl The SAML connector's assertion consumer service URL
* @property acsUrl The SAML connector's assertion consumer service URL {@link file://src/routes/authn.ts}
* @property _rawSamlMetadata The cached raw SAML metadata (in XML-format) from the raw SAML SSO connector config
* @property _parsedSamlMetadata The cached parsed SAML metadata from the raw SAML SSO connector config
* @property _samlAssertionContent The cached parsed SAML assertion from IdP (with attribute mapping applied)
@ -45,8 +51,7 @@ class SamlConnector {
) {
this.acsUrl = appendPath(
getTenantEndpoint(tenantId, EnvSet.values),
// TODO: update this endpoint
`api/authn/saml/sso/${ssoConnectorId}`
`api/authn/${ssoPath}/saml/${ssoConnectorId}`
).toString();
}
@ -93,7 +98,7 @@ class SamlConnector {
*
* @returns The parsed SAML assertion from IdP (with attribute mapping applied).
*/
async parseSamlAssertion(assertion: Record<string, unknown>) {
async parseSamlAssertion(assertion: Record<string, unknown>): Promise<ExtendedSocialUserInfo> {
const parsedConfig = await this.getSamlConfig();
const profileMap = attributeMappingPostProcessor(parsedConfig.attributeMapping);
const idpMetadataXml = await this.getIdpXmlMetadata();
@ -149,16 +154,15 @@ class SamlConnector {
// eslint-disable-next-line new-cap
const identityProvider = saml.IdentityProvider({
wantAuthnRequestsSigned: true, // Sign auth request by default
metadata: idpMetadataXml,
});
// eslint-disable-next-line new-cap
const serviceProvider = saml.ServiceProvider({
entityID,
entityID, // FIXME: @darcyYe
relayState,
nameIDFormat: nameIdFormat,
signingCert: x509Certificate,
authnRequestsSigned: true, // Sign auth request by default
requestSignatureAlgorithm: signingAlgorithm,
assertionConsumerService: [
{

View file

@ -2,12 +2,16 @@ import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { type SsoConnector } from '@logto/schemas';
import { SsoProviderName } from '#src/sso/types/index.js';
import assertThat from '#src/utils/assert-that.js';
import SamlConnector from '../SamlConnector/index.js';
import { type SingleSignOnFactory } from '../index.js';
import { type SingleSignOn } from '../types/index.js';
import { samlConnectorConfigGuard } from '../types/saml.js';
import { type CreateSingleSignOnSession } from '../types/session.js';
import {
type SingleSignOnConnectorSession,
type CreateSingleSignOnSession,
} from '../types/session.js';
/**
* SAML SSO connector
@ -75,12 +79,16 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
/**
* Get social user info.
*
* @param assertion The SAML assertion from IdP.
*
* @param connectorSession The connector session data from interaction session storage.
* @returns The social user info extracted from SAML assertion.
*
* @remarks For SAML connector, userInfo will be extracted from the SAML assertion by ACS callback endpoint.
* This method only asserts the userInfo is not null and directly return it.
*/
async getUserInfo(assertion: Record<string, unknown>) {
return this.parseSamlAssertion(assertion);
async getUserInfo({ userInfo }: SingleSignOnConnectorSession) {
assertThat(userInfo, 'session.connector_validation_session_not_found');
return userInfo;
}
}

View file

@ -7,6 +7,11 @@ import {
getInteractionFromProviderByJti,
assignResultToInteraction,
} from '#src/routes/interaction/utils/interaction.js';
import {
type SingleSignOnConnectorSession,
singleSignOnConnectorSessionGuard,
} from '#src/sso/types/index.js';
import { type ExtendedSocialUserInfo } from '#src/sso/types/saml.js';
import assertThat from './assert-that.js';
@ -39,6 +44,11 @@ export const assignConnectorSessionResultViaJti = async (
});
};
/**
* Used by the standard SAML social connectors ACS endpoint.
* @deprecated
* Will be cleaned once the old SAML social connectors are removed.
*/
export const getConnectorSessionResultFromJti = async (
jti: string,
provider: Provider
@ -60,3 +70,51 @@ export const getConnectorSessionResultFromJti = async (
return connectorSessionResult.data.connectorSession;
};
/**
* Get the single sign on session data from the oidc provider session storage by the jti.
*
* @param jti The jti of the interaction session.
*
* @remark This method is used by the SSO SAML assertion consumer service endpoint.
* Since we do not have the interaction ctx under SAML ACS endpoint, we need to get the session data by the jti.
* Forked from the above {@link getConnectorSessionResultFromJti} method, with more detailed SingleSignOnConnectorSession type guard.
*/
export const getSingleSignOnSessionResultByJti = async (
jti: string,
provider: Provider
): Promise<SingleSignOnConnectorSession> => {
const { result } = await getInteractionFromProviderByJti(jti, provider);
const singleSignOnSessionResult = z
.object({
connectorSession: singleSignOnConnectorSessionGuard,
})
.safeParse(result);
assertThat(singleSignOnSessionResult.success, 'session.connector_validation_session_not_found');
return singleSignOnSessionResult.data.connectorSession;
};
/**
* Assign the SAML assertion result to the interaction single sign-on session storage by the jti.
*
* @param jti The jti of the interaction session.
*/
export const assignSamlAssertionResultViaJti = async (
jti: string,
provider: Provider,
sessionResultWithAssertion: Omit<SingleSignOnConnectorSession, 'userInfo'> & {
userInfo: ExtendedSocialUserInfo;
}
) => {
const interaction = await getInteractionFromProviderByJti(jti, provider);
const { result } = interaction;
await assignResultToInteraction(interaction, {
...result,
connectorSession: sessionResultWithAssertion,
});
};

File diff suppressed because one or more lines are too long

View file

@ -35,3 +35,16 @@ export const getSsoConnectorsByEmail = async (
})
.json<string[]>();
};
export const postSamlAssertion = async (data: {
connectorId: string;
RelayState: string;
SAMLResponse: string;
}) => {
const { connectorId, ...payload } = data;
return api
.post(`authn/${ssoPath}/saml/${connectorId}`, {
json: payload,
})
.json();
};

View file

@ -1,55 +1,116 @@
import { InteractionEvent } from '@logto/schemas';
import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js';
import {
partialConfigAndProviderNames,
samlAssertion,
} from '#src/__mocks__/sso-connectors-mock.js';
import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js';
import { putInteraction } from '#src/api/interaction.js';
import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js';
import { SsoProviderName } from '#src/constants.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
describe('Single Sign On Sad Path', () => {
const state = 'foo_state';
const redirectUri = 'http://foo.dev/callback';
it('should throw if connector not found', async () => {
const client = await initClient();
describe('getAuthorizationUrl', () => {
it('should throw if connector not found', async () => {
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await expect(
client.send(getSsoAuthorizationUrl, {
connectorId: 'foo',
state,
redirectUri,
})
).rejects.toThrow();
});
await expect(
client.send(getSsoAuthorizationUrl, {
connectorId: 'foo',
state,
redirectUri,
})
).rejects.toThrow();
it('should throw if connector config is invalid', async () => {
const { id } = await createSsoConnector({
providerName: SsoProviderName.OIDC,
connectorName: 'test-oidc',
config: {
clientId: 'foo',
clientSecret: 'bar',
},
});
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
});
await expect(
client.send(getSsoAuthorizationUrl, {
connectorId: id,
state,
redirectUri,
})
).rejects.toThrow();
await deleteSsoConnectorById(id);
});
});
it('should throw if connector config is invalid', async () => {
const { id } = await createSsoConnector({
providerName: SsoProviderName.OIDC,
connectorName: 'test-oidc',
config: {
clientId: 'foo',
clientSecret: 'bar',
},
describe('postSamlAssertion', () => {
// eslint-disable-next-line @silverhand/fp/no-let
let connectorId: string;
beforeAll(async () => {
const mockSamlConnector = partialConfigAndProviderNames[1]!;
const { id } = await createSsoConnector({
connectorName: 'test-saml',
...mockSamlConnector,
});
// eslint-disable-next-line @silverhand/fp/no-mutation
connectorId = id;
});
const client = await initClient();
await client.successSend(putInteraction, {
event: InteractionEvent.SignIn,
afterAll(async () => {
await deleteSsoConnectorById(connectorId);
});
await expect(
client.send(getSsoAuthorizationUrl, {
connectorId: id,
it('should throw if the session dose not exist', async () => {
await initClient();
await expectRejects(
postSamlAssertion({ connectorId, RelayState: 'foo', SAMLResponse: samlAssertion }),
{
code: 'session.not_found',
statusCode: 400,
}
);
});
it('should throw if the response is invalid', async () => {
const client = await initClient();
const { redirectTo } = await client.send(getSsoAuthorizationUrl, {
connectorId,
state,
redirectUri,
})
).rejects.toThrow();
redirectUri: 'http://foo.dev/callback',
});
await deleteSsoConnectorById(id);
const url = new URL(redirectTo);
const RelayState = url.searchParams.get('RelayState')!;
await expectRejects(
postSamlAssertion({ connectorId, RelayState, SAMLResponse: samlAssertion }),
{
code: 'connector.general',
statusCode: 400,
}
);
});
});
});