0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

refactor(core): refactor GET saml app metadata endpoint (#6938)

* refactor(core): refactor GET saml app metadata endpoint

* refactor: refactor SamlApplication class to validate only necessary fields
This commit is contained in:
Darcy Ye 2025-01-14 17:24:48 +08:00 committed by GitHub
parent 9ee0285528
commit c41ecc40df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 71 additions and 115 deletions

View file

@ -7,7 +7,6 @@ import {
type SamlAcsUrl, type SamlAcsUrl,
BindingType, BindingType,
NameIdFormat, NameIdFormat,
type SamlEncryption,
type SamlAttributeMapping, type SamlAttributeMapping,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
@ -41,18 +40,6 @@ import { type SamlApplicationDetails } from '../queries/index.js';
import { buildSamlAssertionNameId, getSamlAppCallbackUrl } from './utils.js'; import { buildSamlAssertionNameId, getSamlAppCallbackUrl } from './utils.js';
type ValidSamlApplicationDetails = {
secret: string;
entityId: string;
acsUrl: SamlAcsUrl;
redirectUri: string;
privateKey: string;
certificate: string;
attributeMapping: SamlAttributeMapping;
nameIdFormat: NameIdFormat;
encryption: Nullable<SamlEncryption>;
};
type SamlIdentityProviderConfig = { type SamlIdentityProviderConfig = {
entityId: string; entityId: string;
certificate: string; certificate: string;
@ -83,41 +70,6 @@ saml.setSchemaValidator({
}, },
}); });
const validateSamlApplicationDetails = (
details: SamlApplicationDetails
): ValidSamlApplicationDetails => {
const {
entityId,
acsUrl,
oidcClientMetadata: { redirectUris },
privateKey,
certificate,
secret,
nameIdFormat,
encryption,
attributeMapping,
} = details;
assertThat(acsUrl, 'application.saml.acs_url_required');
assertThat(entityId, 'application.saml.entity_id_required');
assertThat(redirectUris[0], 'oidc.invalid_redirect_uri');
assertThat(privateKey, 'application.saml.private_key_required');
assertThat(certificate, 'application.saml.certificate_required');
return {
secret,
entityId,
acsUrl,
redirectUri: redirectUris[0],
privateKey,
certificate,
nameIdFormat,
encryption,
attributeMapping,
};
};
const buildLoginResponseTemplate = () => { const buildLoginResponseTemplate = () => {
return { return {
context: samlLogInResponseTemplate, context: samlLogInResponseTemplate,
@ -193,8 +145,53 @@ const buildSamlServiceProvider = ({
}); });
}; };
class SamlApplicationConfig {
constructor(private readonly _details: SamlApplicationDetails) {}
public get secret() {
return this._details.secret;
}
public get entityId() {
assertThat(this._details.entityId, 'application.saml.entity_id_required');
return this._details.entityId;
}
public get acsUrl() {
assertThat(this._details.acsUrl, 'application.saml.acs_url_required');
return this._details.acsUrl;
}
public get redirectUri() {
assertThat(this._details.oidcClientMetadata.redirectUris[0], 'oidc.invalid_redirect_uri');
return this._details.oidcClientMetadata.redirectUris[0];
}
public get privateKey() {
assertThat(this._details.privateKey, 'application.saml.private_key_required');
return this._details.privateKey;
}
public get certificate() {
assertThat(this._details.certificate, 'application.saml.certificate_required');
return this._details.certificate;
}
public get nameIdFormat() {
return this._details.nameIdFormat;
}
public get encryption() {
return this._details.encryption;
}
public get attributeMapping() {
return this._details.attributeMapping;
}
}
export class SamlApplication { export class SamlApplication {
public details: ValidSamlApplicationDetails; public config: SamlApplicationConfig;
protected tenantEndpoint: URL; protected tenantEndpoint: URL;
protected oidcConfig?: CamelCaseKeys<OidcConfigResponse>; protected oidcConfig?: CamelCaseKeys<OidcConfigResponse>;
@ -208,7 +205,7 @@ export class SamlApplication {
protected issuer: string, protected issuer: string,
tenantId: string tenantId: string
) { ) {
this.details = validateSamlApplicationDetails(details); this.config = new SamlApplicationConfig(details);
this.tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values); this.tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
} }
@ -221,7 +218,7 @@ export class SamlApplication {
const { certificate: encryptCert, ...rest } = this.buildSpConfig(); const { certificate: encryptCert, ...rest } = this.buildSpConfig();
this._sp ||= buildSamlServiceProvider({ this._sp ||= buildSamlServiceProvider({
...rest, ...rest,
certificate: this.details.certificate, certificate: this.config.certificate,
isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(), isWantAuthnRequestsSigned: this.idp.entityMeta.isWantAuthnRequestsSigned(),
...cond(encryptCert && { encryptCert }), ...cond(encryptCert && { encryptCert }),
}); });
@ -233,7 +230,7 @@ export class SamlApplication {
} }
public get idPCertificate() { public get idPCertificate() {
return this.details.certificate; return this.config.certificate;
} }
public get samlAppCallbackUrl() { public get samlAppCallbackUrl() {
@ -265,7 +262,7 @@ export class SamlApplication {
'post', 'post',
userInfo, userInfo,
this.createSamlTemplateCallback({ userInfo, samlRequestId }), this.createSamlTemplateCallback({ userInfo, samlRequestId }),
this.details.encryption?.encryptThenSign, this.config.encryption?.encryptThenSign,
relayState ?? undefined relayState ?? undefined
); );
@ -291,7 +288,7 @@ export class SamlApplication {
const queryParameters = new URLSearchParams({ const queryParameters = new URLSearchParams({
[QueryKey.ClientId]: this.samlApplicationId, [QueryKey.ClientId]: this.samlApplicationId,
[QueryKey.RedirectUri]: this.details.redirectUri, [QueryKey.RedirectUri]: this.config.redirectUri,
[QueryKey.ResponseType]: 'code', [QueryKey.ResponseType]: 'code',
[QueryKey.Prompt]: Prompt.Login, [QueryKey.Prompt]: Prompt.Login,
}); });
@ -333,8 +330,8 @@ export class SamlApplication {
const result = await handleTokenExchange(tokenEndpoint, { const result = await handleTokenExchange(tokenEndpoint, {
code, code,
clientId: this.samlApplicationId, clientId: this.samlApplicationId,
clientSecret: this.details.secret, clientSecret: this.config.secret,
redirectUri: this.details.redirectUri, redirectUri: this.config.redirectUri,
}); });
if (!result.success) { if (!result.success) {
@ -383,18 +380,18 @@ export class SamlApplication {
requiredScopes.add(ReservedScope.OpenId); requiredScopes.add(ReservedScope.OpenId);
requiredScopes.add(UserScope.Profile); requiredScopes.add(UserScope.Profile);
if (this.details.nameIdFormat === NameIdFormat.EmailAddress) { if (this.config.nameIdFormat === NameIdFormat.EmailAddress) {
requiredScopes.add(UserScope.Email); requiredScopes.add(UserScope.Email);
} }
// If no attribute mapping, return empty array // If no attribute mapping, return empty array
if (Object.keys(this.details.attributeMapping).length === 0) { if (Object.keys(this.config.attributeMapping).length === 0) {
return Array.from(requiredScopes); return Array.from(requiredScopes);
} }
// Iterate through all claims in attribute mapping // Iterate through all claims in attribute mapping
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
for (const claim of Object.keys(this.details.attributeMapping) as Array< for (const claim of Object.keys(this.config.attributeMapping) as Array<
keyof SamlAttributeMapping keyof SamlAttributeMapping
>) { >) {
// Ignore `id` claim since this will always be included. // Ignore `id` claim since this will always be included.
@ -479,19 +476,19 @@ export class SamlApplication {
private buildIdpConfig(): SamlIdentityProviderConfig { private buildIdpConfig(): SamlIdentityProviderConfig {
return { return {
entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId), entityId: buildSamlIdentityProviderEntityId(this.tenantEndpoint, this.samlApplicationId),
privateKey: this.details.privateKey, privateKey: this.config.privateKey,
certificate: this.details.certificate, certificate: this.config.certificate,
singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId), singleSignOnUrl: buildSingleSignOnUrl(this.tenantEndpoint, this.samlApplicationId),
nameIdFormat: this.details.nameIdFormat, nameIdFormat: this.config.nameIdFormat,
encryptSamlAssertion: this.details.encryption?.encryptAssertion ?? false, encryptSamlAssertion: this.config.encryption?.encryptAssertion ?? false,
}; };
} }
private buildSpConfig(): SamlServiceProviderConfig { private buildSpConfig(): SamlServiceProviderConfig {
return { return {
entityId: this.details.entityId, entityId: this.config.entityId,
acsUrl: this.details.acsUrl, acsUrl: this.config.acsUrl,
certificate: this.details.encryption?.certificate, certificate: this.config.encryption?.certificate,
}; };
} }
} }

View file

@ -3,23 +3,15 @@ import {
type SamlApplicationResponse, type SamlApplicationResponse,
type PatchSamlApplication, type PatchSamlApplication,
type SamlApplicationSecret, type SamlApplicationSecret,
BindingType,
} from '@logto/schemas'; } from '@logto/schemas';
import { generateStandardId } from '@logto/shared'; import { generateStandardId } from '@logto/shared';
import { removeUndefinedKeys, pick } from '@silverhand/essentials'; import { removeUndefinedKeys, pick } from '@silverhand/essentials';
import saml from 'samlify';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import RequestError from '#src/errors/RequestError/index.js'; import RequestError from '#src/errors/RequestError/index.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 { import { ensembleSamlApplication, generateKeyPairAndCertificate } from './utils.js';
ensembleSamlApplication,
generateKeyPairAndCertificate,
buildSingleSignOnUrl,
buildSamlIdentityProviderEntityId,
} from './utils.js';
export const createSamlApplicationsLibrary = (queries: Queries) => { export const createSamlApplicationsLibrary = (queries: Queries) => {
const { const {
@ -27,7 +19,6 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
samlApplicationSecrets: { samlApplicationSecrets: {
insertInactiveSamlApplicationSecret, insertInactiveSamlApplicationSecret,
insertActiveSamlApplicationSecret, insertActiveSamlApplicationSecret,
findActiveSamlApplicationSecretByApplicationId,
}, },
samlApplicationConfigs: { samlApplicationConfigs: {
findSamlApplicationConfigByApplicationId, findSamlApplicationConfigByApplicationId,
@ -117,39 +108,9 @@ export const createSamlApplicationsLibrary = (queries: Queries) => {
}); });
}; };
const getSamlIdPMetadataByApplicationId = async (id: string): Promise<{ metadata: string }> => {
const [{ tenantId }, { certificate }] = await Promise.all([
findSamlApplicationConfigByApplicationId(id),
findActiveSamlApplicationSecretByApplicationId(id),
]);
const tenantEndpoint = getTenantEndpoint(tenantId, EnvSet.values);
// eslint-disable-next-line new-cap
const idp = saml.IdentityProvider({
entityID: buildSamlIdentityProviderEntityId(tenantEndpoint, id),
signingCert: certificate,
singleSignOnService: [
{
Location: buildSingleSignOnUrl(tenantEndpoint, id),
Binding: BindingType.Redirect,
},
{
Location: buildSingleSignOnUrl(tenantEndpoint, id),
Binding: BindingType.Post,
},
],
});
return {
metadata: idp.getMetadata(),
};
};
return { return {
createSamlApplicationSecret, createSamlApplicationSecret,
findSamlApplicationById, findSamlApplicationById,
updateSamlApplicationById, updateSamlApplicationById,
getSamlIdPMetadataByApplicationId,
}; };
}; };

View file

@ -32,9 +32,6 @@ const samlApplicationSignInCallbackQueryParametersGuard = z.union([
export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter>( export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter>(
...[router, { id: tenantId, libraries, queries, envSet }]: RouterInitArgs<T> ...[router, { id: tenantId, libraries, queries, envSet }]: RouterInitArgs<T>
) { ) {
const {
samlApplications: { getSamlIdPMetadataByApplicationId },
} = libraries;
const { const {
samlApplications: { getSamlApplicationDetailsById }, samlApplications: { getSamlApplicationDetailsById },
samlApplicationSessions: { samlApplicationSessions: {
@ -49,16 +46,17 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
'/saml-applications/:id/metadata', '/saml-applications/:id/metadata',
koaGuard({ koaGuard({
params: z.object({ id: z.string() }), params: z.object({ id: z.string() }),
status: [200, 404], status: [200, 400, 404],
response: z.string(), response: z.string(),
}), }),
async (ctx, next) => { async (ctx, next) => {
const { id } = ctx.guard.params; const { id } = ctx.guard.params;
const { metadata } = await getSamlIdPMetadataByApplicationId(id); const details = await getSamlApplicationDetailsById(id);
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
ctx.status = 200; ctx.status = 200;
ctx.body = metadata; ctx.body = samlApplication.idPMetadata;
ctx.type = 'text/xml;charset=utf-8'; ctx.type = 'text/xml;charset=utf-8';
return next(); return next();
@ -99,7 +97,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId); const samlApplication = new SamlApplication(details, id, envSet.oidc.issuer, tenantId);
assertThat( assertThat(
samlApplication.details.redirectUri === samlApplication.samlAppCallbackUrl, samlApplication.config.redirectUri === samlApplication.samlAppCallbackUrl,
'oidc.invalid_redirect_uri' 'oidc.invalid_redirect_uri'
); );
@ -246,7 +244,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
log.append({ extractResultData: extractResult.data }); log.append({ extractResultData: extractResult.data });
assertThat( assertThat(
extractResult.data.issuer === samlApplication.details.entityId, extractResult.data.issuer === samlApplication.config.entityId,
'application.saml.auth_request_issuer_not_match' 'application.saml.auth_request_issuer_not_match'
); );
@ -345,7 +343,7 @@ export default function samlApplicationAnonymousRoutes<T extends AnonymousRouter
log.append({ extractResultData: extractResult.data }); log.append({ extractResultData: extractResult.data });
assertThat( assertThat(
extractResult.data.issuer === samlApplication.details.entityId, extractResult.data.issuer === samlApplication.config.entityId,
'application.saml.auth_request_issuer_not_match' 'application.saml.auth_request_issuer_not_match'
); );