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:
parent
9ee0285528
commit
c41ecc40df
3 changed files with 71 additions and 115 deletions
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
|
@ -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'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Add table
Reference in a new issue