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:
parent
d4f7d098dc
commit
8e632678bc
13 changed files with 323 additions and 115 deletions
|
@ -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';
|
||||
|
|
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -15,6 +15,7 @@ const mockSsoConnectorLibrary: jest.Mocked<SsoConnectorLibrary> = {
|
|||
getSsoConnectorById: jest.fn(),
|
||||
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
createIdpInitiatedSamlSsoSession: jest.fn(),
|
||||
};
|
||||
|
||||
describe('verifyEmailIdentifier tests', () => {
|
||||
|
|
|
@ -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);
|
||||
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
@ -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>`;
|
||||
|
|
|
@ -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);
|
||||
|
|
111
packages/integration-tests/src/tests/api/authn.test.ts
Normal file
111
packages/integration-tests/src/tests/api/authn.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
})
|
||||
|
|
Loading…
Reference in a new issue