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

feat(core): handle idp initiated saml assertion request (#6666)

* feat(core): handle idp initiated saml assertion request

handle idp initiated saml assertion request

* refactor(core): extract the set cookie logic

extract the set cookie logic, any refine some type definitions
This commit is contained in:
simeng-li 2024-10-14 14:47:55 +08:00 committed by GitHub
parent d4f7d098dc
commit 8e632678bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 323 additions and 115 deletions

View file

@ -3,3 +3,8 @@ export const protectedAppSignInCallbackUrl = 'sign-in-callback';
export const subjectTokenExpiresIn = 600;
/** The prefix for subject tokens */
export const subjectTokenPrefix = 'sub_';
/** The default lifetime of SSO SAML assertion record (in milliseconds) */
export const defaultIdPInitiatedSamlSsoSessionTtl = 10 * 60 * 1000; // 10 minutes
export const idpInitiatedSamlSsoSessionCookieName = '_logto_idp_saml_sso_session_id';

View file

@ -47,6 +47,7 @@ const ssoConnectorLibrary: jest.Mocked<SsoConnectorLibrary> = {
getAvailableSsoConnectors: jest.fn(),
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
createIdpInitiatedSamlSsoSession: jest.fn(),
};
const { MockQueries } = await import('#src/test-utils/tenant.js');

View file

@ -1,17 +1,19 @@
import {
ApplicationType,
type SsoSamlAssertionContent,
type CreateSsoConnectorIdpInitiatedAuthConfig,
type SupportedSsoConnector,
} from '@logto/schemas';
import { generateStandardId } from '@logto/shared';
import { assert } from '@silverhand/essentials';
import { defaultIdPInitiatedSamlSsoSessionTtl } from '#src/constants/index.js';
import RequestError from '#src/errors/RequestError/index.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
import { isSupportedSsoConnector } from '#src/sso/utils.js';
import type Queries from '#src/tenants/Queries.js';
import assertThat from '../utils/assert-that.js';
import { type OmitAutoSetFields } from '../utils/sql.js';
import assertThat from '#src/utils/assert-that.js';
import { type OmitAutoSetFields } from '#src/utils/sql.js';
export type SsoConnectorLibrary = ReturnType<typeof createSsoConnectorLibrary>;
@ -107,11 +109,37 @@ export const createSsoConnectorLibrary = (queries: Queries) => {
});
};
/**
* Records the verified SAML assertion content to the database.
* @remarks
* For IdP-initiated SAML SSO flow use only.
* Save the SAML assertion content to the database
* The session ID will be used to retrieve the SAML assertion content when the user is redirected to Logto SSO authentication flow.
*/
const createIdpInitiatedSamlSsoSession = async (
connectorId: string,
assertionContent: SsoSamlAssertionContent
) => {
// If the assertion has a notOnOrAfter condition, we will use it as the expiration date,
// otherwise use the creation date + 10 minutes
const expiresAt = assertionContent.conditions?.notOnOrAfter
? new Date(assertionContent.conditions.notOnOrAfter)
: new Date(Date.now() + defaultIdPInitiatedSamlSsoSessionTtl);
return ssoConnectors.insertIdpInitiatedSamlSsoSession({
id: generateStandardId(),
connectorId,
assertionContent,
expiresAt: expiresAt.valueOf(),
});
};
return {
getSsoConnectors,
getAvailableSsoConnectors,
getSsoConnectorById,
createSsoConnectorIdpInitiatedAuthConfig,
updateSsoConnectorIdpInitiatedAuthConfig,
createIdpInitiatedSamlSsoSession,
};
};

View file

@ -5,12 +5,14 @@ import {
SsoConnectorIdpInitiatedAuthConfigs,
type SsoConnectorKeys,
SsoConnectors,
IdpInitiatedSamlSsoSessions,
} from '@logto/schemas';
import { sql, type CommonQueryMethods } from '@silverhand/slonik';
import SchemaQueries from '#src/utils/SchemaQueries.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { buildDeleteByIdWithPool } from '../database/delete-by-id.js';
import { buildInsertIntoWithPool } from '../database/insert-into.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
import { DeletionError } from '../errors/SlonikError/index.js';
@ -48,6 +50,18 @@ export default class SsoConnectorQueries extends SchemaQueries<
true
);
public readonly insertIdpInitiatedSamlSsoSession = buildInsertIntoWithPool(this.pool)(
IdpInitiatedSamlSsoSessions,
{
returning: true,
}
);
public readonly deleteIdpInitiatedSamlSsoSessionById = buildDeleteByIdWithPool(
this.pool,
IdpInitiatedSamlSsoSessions.table
);
constructor(pool: CommonQueryMethods) {
super(pool, SsoConnectors);
}

View file

@ -16,6 +16,9 @@ import {
assignSamlAssertionResultViaJti,
} from '#src/utils/saml-assertion-handler.js';
import { idpInitiatedSamlSsoSessionCookieName } from '../constants/index.js';
import { EnvSet } from '../env-set/index.js';
import { ssoPath } from './interaction/const.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';
@ -24,7 +27,7 @@ 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, id: tenantId }]: RouterInitArgs<T>
...[router, { envSet, provider, libraries, id: tenantId, queries }]: RouterInitArgs<T>
) {
const {
users: { findUserRoles },
@ -174,9 +177,11 @@ export default function authnRoutes<T extends AnonymousRouter>(
router.post(
`/authn/${ssoPath}/saml/:connectorId`,
koaGuard({
body: z.object({ RelayState: z.string(), SAMLResponse: z.string() }).catchall(z.unknown()),
body: z
.object({ RelayState: z.string().optional(), SAMLResponse: z.string() })
.catchall(z.unknown()),
params: z.object({ connectorId: z.string().min(1) }),
status: [302, 404],
status: [302, 400, 404],
}),
async (ctx, next) => {
const {
@ -188,20 +193,6 @@ export default function authnRoutes<T extends AnonymousRouter>(
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,
@ -210,8 +201,70 @@ export default function authnRoutes<T extends AnonymousRouter>(
assertThat(connectorInstance instanceof SamlConnector, 'connector.unexpected_type');
const userInfo = await connectorInstance.parseSamlAssertion(body);
// 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;
// IdP initiated SSO will not have the jti in the RelayState.
// Trigger the IdP initiated SSO flow if enabled for the current connector.
if (!jti && EnvSet.values.isDevFeaturesEnabled) {
const idpInitiatedAuthConfig =
await queries.ssoConnectors.getIdpInitiatedAuthConfigByConnectorId(connectorId);
// No IdP initiated auth config found
assertThat(
idpInitiatedAuthConfig,
new RequestError({
code: 'session.connector_validation_session_not_found',
status: 404,
})
);
const assertionContent = await connectorInstance.parseSamlAssertionContent(body);
const { id, expiresAt } = await ssoConnectorsLibrary.createIdpInitiatedSamlSsoSession(
connectorId,
assertionContent
);
// Set the session ID to cookie for later use.
ctx.cookies.set(idpInitiatedSamlSsoSessionCookieName, id, {
httpOnly: true,
sameSite: 'strict',
expires: new Date(expiresAt),
overwrite: true,
});
// TODO: redirect to SSO direct sign-in flow
return next();
}
// TODO: remove this assertion after the IdP initiated SSO flow is implemented
assertThat(
jti,
new RequestError({
code: 'session.connector_validation_session_not_found',
status: 404,
})
);
// 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,
new RequestError({
code: 'session.connector_validation_session_not_found',
status: 404,
})
);
const assertionContent = await connectorInstance.parseSamlAssertionContent(body);
const userInfo = connectorInstance.getUserInfoFromSamlAssertion(assertionContent);
// Store the extracted user info to the connector session storage for later use.
await assignSamlAssertionResultViaJti(jti, provider, {
...singleSignOnSession,
userInfo,

View file

@ -15,6 +15,7 @@ const mockSsoConnectorLibrary: jest.Mocked<SsoConnectorLibrary> = {
getSsoConnectorById: jest.fn(),
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
createIdpInitiatedSamlSsoSession: jest.fn(),
};
describe('verifyEmailIdentifier tests', () => {

View file

@ -1,7 +1,7 @@
import { type Optional } from '@silverhand/essentials';
import { type SsoSamlAssertionContent } from '@logto/schemas';
import { conditional, type Optional } from '@silverhand/essentials';
import { XMLValidator } from 'fast-xml-parser';
import * as saml from 'samlify';
import { z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
@ -45,7 +45,7 @@ import {
* @property _identityProvider The SAML identity provider instance cache
*
* @method getSamlIdpMetadata Parse and return SAML config from the SAML connector config. Throws error if config is invalid.
* @method parseSamlAssertion Parse and store the SAML assertion from IdP.
* @method getUserInfoFromSamlAssertion Parse and store the SAML assertion from IdP.
* @method getSingleSignOnUrl Get the SAML SSO URL.
* @method getIdpMetadataXml Get the raw SAML metadata (in XML-format) from the raw SAML SSO connector config.
* @method getIdpMetadataJson Get manually configured IdP SAML metadata from the raw SAML SSO connector config.
@ -98,13 +98,12 @@ class SamlConnector {
}
/**
* Parse and return the SAML assertion from IdP (with attribute mapping applied).
* Parse the SAML assertion content received from IdP.
*
* @param assertion The SAML assertion from IdP.
*
* @returns The parsed SAML assertion from IdP (with attribute mapping applied).
* @param body The raw SAML assertion request body received from IdP.
* @throws {SsoConnectorError} If the SAML assertion is invalid or cannot be parsed.
*/
async parseSamlAssertion(body: Record<string, unknown>): Promise<ExtendedSocialUserInfo> {
async parseSamlAssertionContent(body: Record<string, unknown>) {
const identityProvider = await this.getIdentityProvider();
const { x509Certificate } = await this.getSamlIdpMetadata();
@ -114,18 +113,23 @@ class SamlConnector {
entityId: this.serviceProviderMetadata.entityId,
});
const userProfileGuard = z.record(z.string().or(z.array(z.string())));
const rawProfileParseResult = userProfileGuard.safeParse(samlAssertionContent);
return samlAssertionContent;
}
if (!rawProfileParseResult.success) {
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid SAML assertion',
response: samlAssertionContent,
error: rawProfileParseResult.error.flatten(),
});
}
const rawUserProfile = rawProfileParseResult.data;
/**
* Extract the user info from the SAML assertion received from IdP. (with attribute mapping applied).
*
* @param body The raw SAML assertion request body received from IdP.
*
* @returns {ExtendedSocialUserInfo} The parsed social user info (with attribute mapping applied).
*/
getUserInfoFromSamlAssertion(
samlAssertionContent: SsoSamlAssertionContent
): ExtendedSocialUserInfo {
const rawUserProfile: Record<string, unknown> = {
...conditional(samlAssertionContent.nameID && { nameID: samlAssertionContent.nameID }),
...samlAssertionContent.attributes,
};
const profileMap = attributeMappingPostProcessor(this.idpConfig.attributeMapping);

View file

@ -1,11 +1,12 @@
import { X509Certificate } from 'node:crypto';
import * as validator from '@authenio/samlify-node-xmllint';
import { ssoSamlAssertionContentGuard, type SsoSamlAssertionContent } from '@logto/schemas';
import { type Optional, appendPath, tryThat } from '@silverhand/essentials';
import { conditional } from '@silverhand/essentials';
import { HTTPError, got } from 'got';
import * as saml from 'samlify';
import { z } from 'zod';
import { z, ZodError } from 'zod';
import { ssoPath } from '#src/routes/interaction/const.js';
@ -172,7 +173,7 @@ export const handleSamlAssertion = async (
request: ESamlHttpRequest,
identityProvider: saml.IdentityProviderInstance,
metadata: { entityId: string; x509Certificate: string }
): Promise<Record<string, unknown>> => {
): Promise<SsoSamlAssertionContent> => {
const { entityId: entityID, x509Certificate } = metadata;
// eslint-disable-next-line new-cap
@ -191,18 +192,11 @@ export const handleSamlAssertion = async (
request
);
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...(Boolean(assertionResult.extract.nameID) && {
// Usually identity provider DOES NOT allow the configuration of `nameID` claim name.
nameID: assertionResult.extract.nameID,
}),
...assertionResult.extract.attributes,
};
return ssoSamlAssertionContentGuard.parse(assertionResult.extract);
} catch (error: unknown) {
throw new SsoConnectorError(SsoConnectorErrorCodes.AuthorizationFailed, {
message: 'Invalid SAML assertion',
error,
error: error instanceof ZodError ? error.flatten() : error,
});
}
};

View file

@ -72,3 +72,55 @@ export const partialConfigAndProviderNames: Array<{
},
},
];
export const mockOktaSamlConnectorMetadata = `
<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="http://www.okta.com/exkjbcsmt3qWQLZIR697">
<md:IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
<md:KeyDescriptor use="signing">
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
<ds:X509Data>
<ds:X509Certificate>MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0 MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk 5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7 xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2A</ds:X509Certificate>
</ds:X509Data>
</ds:KeyInfo>
</md:KeyDescriptor>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified</md:NameIDFormat>
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://trial-4576104.okta.com/app/trial-4576104_logtolocalhost_1/exkjbcsmt3qWQLZIR697/sso/saml"/>
<md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" Location="https://trial-4576104.okta.com/app/trial-4576104_logtolocalhost_1/exkjbcsmt3qWQLZIR697/sso/saml"/>
</md:IDPSSODescriptor>
</md:EntityDescriptor>
`;
export const mockOktaSamlAssertion = `<?xml version="1.0" encoding="UTF-8"?><saml2p:Response Destination="http://localhost:3001/api/authn/single-sign-on/saml/90ipi52ch151" ID="id1149608524674391958450825" IssueInstant="2024-10-10T07:47:07.202Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exkjbcsmt3qWQLZIR697</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id1149608524674391958450825"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>VEVglIhA1OMUc9XH9vemBsE2keHElaL91eM9EX3d9xM=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>C6XRyVYM1/eOCIv5Cg9peCTo765RXvKPzO5FlQh8/wMaweXiy8H9FioRZN3ArPSR0Kbq57EN6rs8gcpvKAbJu/IPTExueToIOlUx3BxzspjboeQTaU5t5sINvf0BxQbst2VL2hb5y76QifOhOt33VOnrXt4fVLuJXbDGquTYYP3mDx1xjMJLfaNZzuGaSJb8s8O0UQh8NwjXYdPIdNOGyzy4bEX1hifZgUcWive9X9tHPIqaTpHd6c1G/DCVHOUevkSzzY6SqKD+oeur3eu900dw7CLZBmA3FqEHHAP6C8Nda6O4g8n9KSLhMcXNMrR5kODfqWu2xRjm7ImBmOtK/A==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG
A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B
CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG
A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL
BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0
MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk
5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u
JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh
i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT
B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB
AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw
rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk
HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7
xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X
mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2A</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2p:Status xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"><saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></saml2p:Status><saml2:Assertion ID="id1149608524840623743520189" IssueInstant="2024-10-10T07:47:07.202Z" Version="2.0" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exkjbcsmt3qWQLZIR697</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id1149608524840623743520189"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>5xc1+qcdoEhYuTRhiGR6RDb7oIK5XfG51YsU6QEXrIk=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>oDn7HoGgb50l7VXArOTEboc5Y8UX+CfXo6aAPU1e5zpLA9AFCitqPlxOq78YEecp+ur6GamP1oybTUU6z0C8eP5RopWBFBWpjeGqooftxzN2BPUYZTNA0jf3YFI4ROrtIWzrFEA4Hwq7c2Jx9o4hFXDvQLYtI51ImnolBR82XXk/PU9bspCn8i+cCWM00LJhA3V74MJ74bfvECQhuznT6I2m09SRhfIqgYTncQoO79/0IgK5tEb7lEjtR7AmssyBMJlZrBPhe3Fv9prIOzOgWTQkFPvor62fkeNBb0MEHBK2MUfpgbrZpsUlE4R7NRxEM7n3gZYFHgaoF77MFwIJhw==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDqjCCApKgAwIBAgIGAZIxmUd7MA0GCSqGSIb3DQEBCwUAMIGVMQswCQYDVQQGEwJVUzETMBEG
A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
MBIGA1UECwwLU1NPUHJvdmlkZXIxFjAUBgNVBAMMDXRyaWFsLTQ1NzYxMDQxHDAaBgkqhkiG9w0B
CQEWDWluZm9Ab2t0YS5jb20wHhcNMjQwOTI3MDM0ODQxWhcNMzQwOTI3MDM0OTQxWjCBlTELMAkG
A1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcMDVNhbiBGcmFuY2lzY28xDTAL
BgNVBAoMBE9rdGExFDASBgNVBAsMC1NTT1Byb3ZpZGVyMRYwFAYDVQQDDA10cmlhbC00NTc2MTA0
MRwwGgYJKoZIhvcNAQkBFg1pbmZvQG9rdGEuY29tMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
CgKCAQEAyjmF5lvCe3CU9+YBPvoioWgcgzaTsgVYTUQRgeRNhOVaRVaKoZahMYoLiHj9Vtjj9EGk
5bY9qH3+6HPbqbvpRS00izsmMWQ+XyLgNIeAVPoxhQ9FaX3Ept+SiCnL9gPtApTI1m42+n93+x8u
JNLGMGnsq/T1thw9rWW30KkY7agANglStsV3d7c1Z6bCZ+CIu495AqpmYshBgucCQ31cssNz0+vh
i1i/rUNvbBlc9VQovxxKInshRSnVDZdTEKqjdHpVgIEr+TRS6+rGmpfAN2H8Ou6MX0DyXKIdS1aT
B5Kdwur4Bje6fTMBBRGf3/CtqERPr0nVb0xBQv8oKoLAsQIDAQABMA0GCSqGSIb3DQEBCwUAA4IB
AQCMneUgkAQBW2wQL+5zD6CI7XeChKzu0gvzBMqeH+7BhCjiiUMM0L+QS+gKYo/q9UxSmZK2qnmw
rIVPW9CYJzwLLDvOU25ZcOIFGULTgnSbNIC8l9xE7UaN9uZ7ZSXDqeg/Oofg0BXy2A7zZobL/fHk
HadHm+uahk8X+RxGa8I2yS/Ns32XRjlurm/wKxLdCnzzPwN4NVhzJt7VVEkjGrztMak69sZbOjH7
xQv3J3Qb27418ZgPP9sBD+r6h/d2uc/20jb+u1jNmvtmaZ97FBDTC143DzQunIrR4sMrfcwt0F8X
mAhbN1/BDxPBST2NHKyMH2PE6waP3HK6FAOwMG2A</ds:X509Certificate></ds:X509Data></ds:KeyInfo></ds:Signature><saml2:Subject xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified">mock_user_id</saml2:NameID><saml2:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer"><saml2:SubjectConfirmationData NotOnOrAfter="2024-10-10T07:52:07.202Z" Recipient="http://localhost:3001/api/authn/single-sign-on/saml/90ipi52ch151"/></saml2:SubjectConfirmation></saml2:Subject><saml2:Conditions NotBefore="2024-10-10T07:42:07.202Z" NotOnOrAfter="2024-10-10T07:52:07.202Z" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AudienceRestriction><saml2:Audience>http://localhost:3001/enterprise-sso/90ipi52ch151</saml2:Audience></saml2:AudienceRestriction></saml2:Conditions><saml2:AuthnStatement AuthnInstant="2024-10-10T05:43:54.561Z" SessionIndex="id1728546427073.1671158468" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:AuthnContext><saml2:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml2:AuthnContextClassRef></saml2:AuthnContext></saml2:AuthnStatement><saml2:AttributeStatement xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"><saml2:Attribute Name="email" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"><saml2:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">mock_user_id</saml2:AttributeValue></saml2:Attribute><saml2:Attribute Name="name" NameFormat="urn:oasis:names:tc:SAML:2.0:attrname-format:unspecified"></saml2:Attribute></saml2:AttributeStatement></saml2:Assertion></saml2p:Response>`;

View file

@ -68,13 +68,13 @@ export class SsoConnectorApi {
return connector;
}
async createMockSamlConnector(domains: string[], connectorName?: string) {
async createMockSamlConnector(domains: string[], connectorName?: string, metadata?: string) {
const connector = await this.create({
providerName: SsoProviderName.SAML,
connectorName: connectorName ?? `test-saml-${randomString()}`,
domains,
config: {
metadata: metadataXml,
metadata: metadata ?? metadataXml,
},
syncProfile: true,
});
@ -89,6 +89,10 @@ export class SsoConnectorApi {
return connector;
}
async getSsoConnectorById(id: string) {
return getSsoConnectorById(id);
}
async delete(id: string) {
await deleteSsoConnectorById(id);
this.connectorInstances.delete(id);

View file

@ -0,0 +1,111 @@
import { ApplicationType } from '@logto/schemas';
import {
mockOktaSamlConnectorMetadata,
mockOktaSamlAssertion,
} from '#src/__mocks__/sso-connectors-mock.js';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { devFeatureTest, randomString } from '#src/utils.js';
describe('SAML SSO ACS endpoint', () => {
const ssoConnectorApi = new SsoConnectorApi();
const encodedSamlAssertion = Buffer.from(mockOktaSamlAssertion).toString('base64');
beforeAll(async () => {
await ssoConnectorApi.createMockSamlConnector(
['example.com'],
undefined,
mockOktaSamlConnectorMetadata
);
});
afterAll(async () => {
await ssoConnectorApi.cleanUp();
});
it('should throw 404 if the session is not found', async () => {
const connectorId = ssoConnectorApi.firstConnectorId!;
await expectRejects(
postSamlAssertion({
connectorId,
RelayState: 'foo',
SAMLResponse: encodedSamlAssertion,
}),
{
code: 'session.not_found',
status: 400,
}
);
});
it('should throw 401 if the assertion is invalid', async () => {
const connectorId = ssoConnectorApi.firstConnectorId!;
const client = await initClient();
const { redirectTo } = await client.send(getSsoAuthorizationUrl, {
connectorId,
state: 'foo_state',
redirectUri: 'http://foo.dev/callback',
});
const url = new URL(redirectTo);
const RelayState = url.searchParams.get('RelayState')!;
const { providerConfig } = await ssoConnectorApi.getSsoConnectorById(connectorId);
await expectRejects(
postSamlAssertion({ connectorId, RelayState, SAMLResponse: encodedSamlAssertion }),
{
code: 'connector.authorization_failed',
status: 401,
}
);
});
devFeatureTest.describe('IdP initiated SSO', () => {
it('should throw 404 if no relayState is provided, and IdP initiated SSO is not enabled', async () => {
const connectorId = ssoConnectorApi.firstConnectorId!;
await expectRejects(
postSamlAssertion({
connectorId,
RelayState: '',
SAMLResponse: encodedSamlAssertion,
}),
{
code: 'session.connector_validation_session_not_found',
status: 404,
}
);
});
it('should try to process the SAML assertion if no relayState is provided and IdP initiated SSO is enabled', async () => {
const connectorId = ssoConnectorApi.firstConnectorId!;
const application = await createApplication(
`web-app-${randomString()}`,
ApplicationType.Traditional
);
await ssoConnectorApi.setSsoConnectorIdpInitiatedAuthConfig({
connectorId,
defaultApplicationId: application.id,
redirectUri: 'https://example.com',
});
await expectRejects(
postSamlAssertion({ connectorId, RelayState: '', SAMLResponse: encodedSamlAssertion }),
{
code: 'connector.authorization_failed',
status: 401,
}
);
await deleteApplication(application.id);
});
});
});

View file

@ -1,14 +1,9 @@
import { InteractionEvent, SsoProviderName } from '@logto/schemas';
import {
partialConfigAndProviderNames,
samlAssertion,
} from '#src/__mocks__/sso-connectors-mock.js';
import { getSsoAuthorizationUrl, postSamlAssertion } from '#src/api/interaction-sso.js';
import { getSsoAuthorizationUrl } from '#src/api/interaction-sso.js';
import { putInteraction } from '#src/api/interaction.js';
import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js';
import { initClient } from '#src/helpers/client.js';
import { expectRejects } from '#src/helpers/index.js';
import { randomString } from '#src/utils.js';
describe('Single Sign On Sad Path', () => {
@ -55,58 +50,4 @@ describe('Single Sign On Sad Path', () => {
await deleteSsoConnectorById(id);
});
});
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;
});
afterAll(async () => {
await deleteSsoConnectorById(connectorId);
});
it('should throw if the session dose not exist', async () => {
await initClient();
await expectRejects(
postSamlAssertion({ connectorId, RelayState: 'foo', SAMLResponse: samlAssertion }),
{
code: 'session.not_found',
status: 400,
}
);
});
it('should throw if the response is invalid', async () => {
const client = await initClient();
const { redirectTo } = await client.send(getSsoAuthorizationUrl, {
connectorId,
state,
redirectUri: 'http://foo.dev/callback',
});
const url = new URL(redirectTo);
const RelayState = url.searchParams.get('RelayState')!;
await expectRejects(
postSamlAssertion({ connectorId, RelayState, SAMLResponse: samlAssertion }),
{
code: 'connector.authorization_failed',
status: 401,
}
);
});
});
});

View file

@ -27,8 +27,8 @@ export const ssoSamlAssertionContentGuard = z
attributes: z.record(z.string().or(z.array(z.string()))).optional(),
conditions: z
.object({
notBefore: z.string(),
notOnOrAfter: z.string(),
notBefore: z.string().optional(),
notOnOrAfter: z.string().optional(),
})
.optional(),
})