mirror of
https://github.com/logto-io/logto.git
synced 2024-12-30 20:33:54 -05:00
feat(core): add POST API for handling IdP SAML assertion (#2961)
This commit is contained in:
parent
1d0b286f82
commit
2c3b7bff26
7 changed files with 278 additions and 7 deletions
|
@ -10,6 +10,7 @@ import RequestError from '#src/errors/RequestError/index.js';
|
||||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||||
import type Queries from '#src/tenants/Queries.js';
|
import type Queries from '#src/tenants/Queries.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
import type { LogtoConnector } from '#src/utils/connectors/types.js';
|
||||||
|
|
||||||
export type SocialUserInfoSession = {
|
export type SocialUserInfoSession = {
|
||||||
connectorId: string;
|
connectorId: string;
|
||||||
|
@ -45,7 +46,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
|
||||||
const { findUserByEmail, findUserByPhone } = queries.users;
|
const { findUserByEmail, findUserByPhone } = queries.users;
|
||||||
const { getLogtoConnectorById } = connectorLibrary;
|
const { getLogtoConnectorById } = connectorLibrary;
|
||||||
|
|
||||||
const getConnector = async (connectorId: string) => {
|
const getConnector = async (connectorId: string): Promise<LogtoConnector> => {
|
||||||
try {
|
try {
|
||||||
return await getLogtoConnectorById(connectorId);
|
return await getLogtoConnectorById(connectorId);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
|
@ -109,5 +110,10 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
return { getUserInfoByAuthCode, getUserInfoFromInteractionResult, findSocialRelatedUser };
|
return {
|
||||||
|
getConnector,
|
||||||
|
getUserInfoByAuthCode,
|
||||||
|
getUserInfoFromInteractionResult,
|
||||||
|
findSocialRelatedUser,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -19,6 +19,7 @@ import phraseRoutes from './phrase.js';
|
||||||
import resourceRoutes from './resource.js';
|
import resourceRoutes from './resource.js';
|
||||||
import roleRoutes from './role.js';
|
import roleRoutes from './role.js';
|
||||||
import roleScopeRoutes from './role.scope.js';
|
import roleScopeRoutes from './role.scope.js';
|
||||||
|
import samlAssertionHandlerRoutes from './saml-assertion-handler.js';
|
||||||
import settingRoutes from './setting.js';
|
import settingRoutes from './setting.js';
|
||||||
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
import signInExperiencesRoutes from './sign-in-experience/index.js';
|
||||||
import statusRoutes from './status.js';
|
import statusRoutes from './status.js';
|
||||||
|
@ -49,6 +50,7 @@ const createRouters = (tenant: TenantContext) => {
|
||||||
verificationCodeRoutes(managementRouter, tenant);
|
verificationCodeRoutes(managementRouter, tenant);
|
||||||
|
|
||||||
const anonymousRouter: AnonymousRouter = new Router();
|
const anonymousRouter: AnonymousRouter = new Router();
|
||||||
|
samlAssertionHandlerRoutes(anonymousRouter, tenant);
|
||||||
phraseRoutes(anonymousRouter, tenant);
|
phraseRoutes(anonymousRouter, tenant);
|
||||||
wellKnownRoutes(anonymousRouter, tenant);
|
wellKnownRoutes(anonymousRouter, tenant);
|
||||||
statusRoutes(anonymousRouter, tenant);
|
statusRoutes(anonymousRouter, tenant);
|
||||||
|
|
|
@ -2,7 +2,9 @@ import type { ConnectorSession } from '@logto/connector-kit';
|
||||||
import { connectorSessionGuard } from '@logto/connector-kit';
|
import { connectorSessionGuard } from '@logto/connector-kit';
|
||||||
import type { Profile } from '@logto/schemas';
|
import type { Profile } from '@logto/schemas';
|
||||||
import { InteractionEvent } from '@logto/schemas';
|
import { InteractionEvent } from '@logto/schemas';
|
||||||
|
import { assert } from '@silverhand/essentials';
|
||||||
import type { Context } from 'koa';
|
import type { Context } from 'koa';
|
||||||
|
import { errors } from 'oidc-provider';
|
||||||
import type { InteractionResults } from 'oidc-provider';
|
import type { InteractionResults } from 'oidc-provider';
|
||||||
import type Provider from 'oidc-provider';
|
import type Provider from 'oidc-provider';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
@ -166,3 +168,45 @@ export const getConnectorSessionResult = async (
|
||||||
|
|
||||||
return signInResult.data.connectorSession;
|
return signInResult.data.connectorSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The following three methods (`getInteractionFromProviderByJti`, `assignResultToInteraction`
|
||||||
|
* and `epochTime`) refer to implementation in
|
||||||
|
* https://github.com/panva/node-oidc-provider/blob/main/lib/provider.js
|
||||||
|
*/
|
||||||
|
type Interaction = Awaited<ReturnType<Provider['interactionDetails']>>;
|
||||||
|
|
||||||
|
const epochTime = (date = Date.now()) => Math.floor(date / 1000);
|
||||||
|
|
||||||
|
export const getInteractionFromProviderByJti = async (
|
||||||
|
jti: string,
|
||||||
|
provider: Provider
|
||||||
|
): Promise<Interaction> => {
|
||||||
|
const interaction = await provider.Interaction.find(jti);
|
||||||
|
|
||||||
|
assert(interaction, new errors.SessionNotFound('interaction session not found'));
|
||||||
|
|
||||||
|
if (interaction.session?.uid) {
|
||||||
|
const session = await provider.Session.findByUid(interaction.session.uid);
|
||||||
|
|
||||||
|
assert(session, new errors.SessionNotFound('session not found'));
|
||||||
|
|
||||||
|
assert(
|
||||||
|
interaction.session.accountId === session.accountId,
|
||||||
|
new errors.SessionNotFound('session principal changed')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return interaction;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignResultToInteraction = async (
|
||||||
|
interaction: Interaction,
|
||||||
|
result: InteractionResults
|
||||||
|
) => {
|
||||||
|
const { lastSubmission, exp } = interaction;
|
||||||
|
|
||||||
|
// eslint-disable-next-line @silverhand/fp/no-mutation
|
||||||
|
interaction.result = { ...lastSubmission, ...result };
|
||||||
|
await interaction.save(exp - epochTime());
|
||||||
|
};
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { ConnectorType } from '@logto/schemas';
|
||||||
|
|
||||||
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
|
||||||
import {
|
import {
|
||||||
getConnectorSessionResult,
|
|
||||||
assignConnectorSessionResult,
|
assignConnectorSessionResult,
|
||||||
|
getConnectorSessionResult,
|
||||||
} from '#src/routes/interaction/utils/interaction.js';
|
} from '#src/routes/interaction/utils/interaction.js';
|
||||||
import type TenantContext from '#src/tenants/TenantContext.js';
|
import type TenantContext from '#src/tenants/TenantContext.js';
|
||||||
import assertThat from '#src/utils/assert-that.js';
|
import assertThat from '#src/utils/assert-that.js';
|
||||||
|
@ -31,6 +31,7 @@ export const createSocialAuthorizationUrl = async (
|
||||||
const {
|
const {
|
||||||
headers: { 'user-agent': userAgent },
|
headers: { 'user-agent': userAgent },
|
||||||
} = ctx.request;
|
} = ctx.request;
|
||||||
|
|
||||||
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
const { jti } = await provider.interactionDetails(ctx.req, ctx.res);
|
||||||
|
|
||||||
return connector.getAuthorizationUri(
|
return connector.getAuthorizationUri(
|
||||||
|
@ -38,12 +39,9 @@ export const createSocialAuthorizationUrl = async (
|
||||||
state,
|
state,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
/**
|
/**
|
||||||
* For upcoming POST /interaction/verification/assertion API, we need to block requests
|
* For POST /saml-assertion-handler/:connectorId API, we need to block requests
|
||||||
* for non-SAML connector (relies on connectorFactoryId) and use `connectorId`
|
* for non-SAML connector (relies on connectorFactoryId) and use `connectorId`
|
||||||
* to find correct connector config.
|
* to find correct connector config.
|
||||||
*
|
|
||||||
* TODO @darcy : add check on `connectorId` and `connectorFactoryId` existence and save logic
|
|
||||||
* in SAML connector `getAuthorizationUri` method.
|
|
||||||
*/
|
*/
|
||||||
connectorId,
|
connectorId,
|
||||||
connectorFactoryId: connector.metadata.id,
|
connectorFactoryId: connector.metadata.id,
|
||||||
|
|
87
packages/core/src/routes/saml-assertion-handler.test.ts
Normal file
87
packages/core/src/routes/saml-assertion-handler.test.ts
Normal file
|
@ -0,0 +1,87 @@
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
72
packages/core/src/routes/saml-assertion-handler.ts
Normal file
72
packages/core/src/routes/saml-assertion-handler.ts
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
62
packages/core/src/utils/saml-assertion-handler.ts
Normal file
62
packages/core/src/utils/saml-assertion-handler.ts
Normal file
|
@ -0,0 +1,62 @@
|
||||||
|
import type { ConnectorSession } from '@logto/connector-kit';
|
||||||
|
import { connectorSessionGuard } from '@logto/connector-kit';
|
||||||
|
import type Provider from 'oidc-provider';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getInteractionFromProviderByJti,
|
||||||
|
assignResultToInteraction,
|
||||||
|
} from '#src/routes/interaction/utils/interaction.js';
|
||||||
|
|
||||||
|
import assertThat from './assert-that.js';
|
||||||
|
|
||||||
|
export const assignConnectorSessionResultViaJti = async (
|
||||||
|
jti: string,
|
||||||
|
provider: Provider,
|
||||||
|
connectorSession: ConnectorSession
|
||||||
|
) => {
|
||||||
|
const interaction = await getInteractionFromProviderByJti(jti, provider);
|
||||||
|
|
||||||
|
const { result } = interaction;
|
||||||
|
|
||||||
|
const connectorSessionResult = z
|
||||||
|
.object({
|
||||||
|
connectorSession: connectorSessionGuard,
|
||||||
|
})
|
||||||
|
.catchall(z.unknown())
|
||||||
|
.safeParse(result);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
result && connectorSessionResult.success,
|
||||||
|
'session.connector_validation_session_not_found'
|
||||||
|
);
|
||||||
|
|
||||||
|
const { connectorSession: originalConnectorSession, ...rest } = connectorSessionResult.data;
|
||||||
|
|
||||||
|
await assignResultToInteraction(interaction, {
|
||||||
|
...rest,
|
||||||
|
connectorSession: { ...originalConnectorSession, ...connectorSession },
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getConnectorSessionResultFromJti = async (
|
||||||
|
jti: string,
|
||||||
|
provider: Provider
|
||||||
|
): Promise<ConnectorSession> => {
|
||||||
|
const interaction = await getInteractionFromProviderByJti(jti, provider);
|
||||||
|
|
||||||
|
const { result } = interaction;
|
||||||
|
|
||||||
|
const connectorSessionResult = z
|
||||||
|
.object({
|
||||||
|
connectorSession: connectorSessionGuard,
|
||||||
|
})
|
||||||
|
.safeParse(result);
|
||||||
|
|
||||||
|
assertThat(
|
||||||
|
result && connectorSessionResult.success,
|
||||||
|
'session.connector_validation_session_not_found'
|
||||||
|
);
|
||||||
|
|
||||||
|
return connectorSessionResult.data.connectorSession;
|
||||||
|
};
|
Loading…
Reference in a new issue