mirror of
https://github.com/logto-io/logto.git
synced 2024-12-16 20:26:19 -05:00
feat(core): add patch and delete SAML IdP initiated config API (#6662)
* feat(core): add patch and delete saml idp initiated config api add patch and delete sso connector idp initiated auth config api * fix(test): fix integration test fix integration test * fix(core): fix the rebase issue fix the rebase issue
This commit is contained in:
parent
14a07dcead
commit
17c2a79caf
8 changed files with 335 additions and 93 deletions
|
@ -19,6 +19,7 @@ import { mockLogtoConfigsLibrary } from '#src/test-utils/mock-libraries.js';
|
|||
|
||||
import { createCloudConnectionLibrary } from '../cloud-connection.js';
|
||||
import { createConnectorLibrary } from '../connector.js';
|
||||
import { type SsoConnectorLibrary } from '../sso-connector.js';
|
||||
|
||||
const { jest } = import.meta;
|
||||
|
||||
|
@ -40,11 +41,12 @@ const signInExperiences = {
|
|||
};
|
||||
const { findDefaultSignInExperience, updateDefaultSignInExperience } = signInExperiences;
|
||||
|
||||
const ssoConnectorLibrary = {
|
||||
const ssoConnectorLibrary: jest.Mocked<SsoConnectorLibrary> = {
|
||||
getSsoConnectors: jest.fn(),
|
||||
getSsoConnectorById: jest.fn(),
|
||||
getAvailableSsoConnectors: jest.fn(),
|
||||
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
};
|
||||
|
||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||
|
|
|
@ -81,10 +81,37 @@ export const createSsoConnectorLibrary = (queries: Queries) => {
|
|||
return ssoConnectors.insertIdpInitiatedAuthConfig(data);
|
||||
};
|
||||
|
||||
const updateSsoConnectorIdpInitiatedAuthConfig = async (
|
||||
connectorId: string,
|
||||
set: Pick<
|
||||
Partial<CreateSsoConnectorIdpInitiatedAuthConfig>,
|
||||
'defaultApplicationId' | 'redirectUri' | 'authParameters'
|
||||
>
|
||||
) => {
|
||||
const { defaultApplicationId } = set;
|
||||
|
||||
if (defaultApplicationId) {
|
||||
// Throws an 404 error if the application is not found
|
||||
const application = await applications.findApplicationById(defaultApplicationId);
|
||||
|
||||
assertThat(
|
||||
application.type === ApplicationType.Traditional && !application.isThirdParty,
|
||||
new RequestError('connector.saml_idp_initiated_auth_invalid_application_type')
|
||||
);
|
||||
}
|
||||
|
||||
return ssoConnectors.updateIdpInitiatedAuthConfig({
|
||||
set,
|
||||
where: { connectorId },
|
||||
jsonbMode: 'replace',
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
getSsoConnectors,
|
||||
getAvailableSsoConnectors,
|
||||
getSsoConnectorById,
|
||||
createSsoConnectorIdpInitiatedAuthConfig,
|
||||
updateSsoConnectorIdpInitiatedAuthConfig,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ import { convertToIdentifiers } from '#src/utils/sql.js';
|
|||
|
||||
import { buildInsertIntoWithPool } from '../database/insert-into.js';
|
||||
import { buildUpdateWhereWithPool } from '../database/update-where.js';
|
||||
import { DeletionError } from '../errors/SlonikError/index.js';
|
||||
|
||||
const {
|
||||
table: ssoConnectorIdpInitiatedAuthConfigsTable,
|
||||
|
@ -66,4 +67,15 @@ export default class SsoConnectorQueries extends SchemaQueries<
|
|||
WHERE ${ssoConnectorIdpInitiatedAuthConfigsFields.connectorId}=${connectorId}
|
||||
`);
|
||||
}
|
||||
|
||||
async deleteIdpInitiatedAuthConfigByConnectorId(connectorId: string) {
|
||||
const { rowCount } = await this.pool.query(sql`
|
||||
DELETE FROM ${ssoConnectorIdpInitiatedAuthConfigsTable}
|
||||
WHERE ${ssoConnectorIdpInitiatedAuthConfigsFields.connectorId}=${connectorId}
|
||||
`);
|
||||
|
||||
if (rowCount < 1) {
|
||||
throw new DeletionError(SsoConnectorIdpInitiatedAuthConfigs.table);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,11 +9,12 @@ const { jest } = import.meta;
|
|||
|
||||
const getAvailableSsoConnectorsMock = jest.fn();
|
||||
|
||||
const mockSsoConnectorLibrary: SsoConnectorLibrary = {
|
||||
const mockSsoConnectorLibrary: jest.Mocked<SsoConnectorLibrary> = {
|
||||
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
|
||||
getSsoConnectors: jest.fn(),
|
||||
getSsoConnectorById: jest.fn(),
|
||||
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
updateSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
};
|
||||
|
||||
describe('verifyEmailIdentifier tests', () => {
|
||||
|
|
|
@ -51,6 +51,52 @@
|
|||
"description": "The request body is invalid. The SSO connector is not a SAML connector or the application is not a Traditional web application."
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"summary": "Update IdP initiated auth config",
|
||||
"description": "Partially update the IdP initiated authentication config of the given SAML SSO connector.",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"properties": {
|
||||
"defaultApplicationId": {
|
||||
"description": "The unique identifier for the application that users will sign in to using IdP initiated authentication. The application type must be `Traditional` and cannot be a third-party application."
|
||||
},
|
||||
"redirectUri": {
|
||||
"description": "The sign-in redirect URI for the application. This URI must be registered in the application's OIDC client metadata. If not provided, Logto will use the first registered redirect URI of the application."
|
||||
},
|
||||
"authParameters": {
|
||||
"description": "The additional parameters to be sent to the application's OIDC authorization endpoint, e.g. `resources` and `scopes`."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "The updated IdP initiated auth config."
|
||||
},
|
||||
"404": {
|
||||
"description": "IdP initiated auth config not found for the given SSO connector or the provided application ID is not found."
|
||||
},
|
||||
"400": {
|
||||
"description": "The request body is invalid. The application is not a first-party Traditional web application."
|
||||
}
|
||||
}
|
||||
},
|
||||
"delete": {
|
||||
"summary": "Delete IdP initiated auth config",
|
||||
"description": "Delete the IdP initiated authentication config of the given SAML SSO connector.",
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "The IdP initiated auth config has been deleted."
|
||||
},
|
||||
"404": {
|
||||
"description": "IdP initiated auth config not found for the given SSO connector."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -21,7 +21,11 @@ export default function ssoConnectorIdpInitiatedAuthConfigRoutes<T extends Manag
|
|||
{
|
||||
queries,
|
||||
libraries: {
|
||||
ssoConnectors: { getSsoConnectorById, createSsoConnectorIdpInitiatedAuthConfig },
|
||||
ssoConnectors: {
|
||||
getSsoConnectorById,
|
||||
createSsoConnectorIdpInitiatedAuthConfig,
|
||||
updateSsoConnectorIdpInitiatedAuthConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
] = args;
|
||||
|
@ -95,4 +99,50 @@ export default function ssoConnectorIdpInitiatedAuthConfigRoutes<T extends Manag
|
|||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.patch(
|
||||
pathPrefix,
|
||||
koaGuard({
|
||||
body: SsoConnectorIdpInitiatedAuthConfigs.updateGuard.pick({
|
||||
defaultApplicationId: true,
|
||||
redirectUri: true,
|
||||
authParameters: true,
|
||||
}),
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
response: SsoConnectorIdpInitiatedAuthConfigs.guard,
|
||||
status: [200, 400, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
body,
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
|
||||
const config = await updateSsoConnectorIdpInitiatedAuthConfig(id, body);
|
||||
|
||||
ctx.body = config;
|
||||
ctx.status = 200;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
pathPrefix,
|
||||
koaGuard({
|
||||
params: z.object({ id: z.string().min(1) }),
|
||||
status: [204, 404],
|
||||
}),
|
||||
async (ctx, next) => {
|
||||
const {
|
||||
params: { id },
|
||||
} = ctx.guard;
|
||||
|
||||
await queries.ssoConnectors.deleteIdpInitiatedAuthConfigByConnectorId(id);
|
||||
|
||||
ctx.status = 204;
|
||||
|
||||
return next();
|
||||
}
|
||||
);
|
||||
}
|
||||
|
|
|
@ -115,6 +115,26 @@ export class SsoConnectorApi {
|
|||
.json<SsoConnectorIdpInitiatedAuthConfig>();
|
||||
}
|
||||
|
||||
async updateSsoConnectorIdpInitiatedAuthConfig(
|
||||
connectorId: string,
|
||||
data: Pick<
|
||||
Partial<CreateSsoConnectorIdpInitiatedAuthConfig>,
|
||||
'defaultApplicationId' | 'redirectUri' | 'authParameters'
|
||||
>
|
||||
) {
|
||||
return authedAdminApi
|
||||
.patch(`sso-connectors/${connectorId}/idp-initiated-auth-config`, {
|
||||
json: data,
|
||||
})
|
||||
.json<SsoConnectorIdpInitiatedAuthConfig>();
|
||||
}
|
||||
|
||||
async deleteSsoConnectorIdpInitiatedAuthConfig(connectorId: string) {
|
||||
return authedAdminApi
|
||||
.delete(`sso-connectors/${connectorId}/idp-initiated-auth-config`)
|
||||
.json<void>();
|
||||
}
|
||||
|
||||
get firstConnectorId() {
|
||||
return Array.from(this.connectorInstances.keys())[0];
|
||||
}
|
||||
|
|
|
@ -39,117 +39,201 @@ devFeatureTest.describe('SAML IdP initiated authentication config', () => {
|
|||
await ssoConnectorsApi.cleanUp();
|
||||
});
|
||||
|
||||
it('should throw 404 if the connector is not found', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
describe('Set IdP-initiated authentication configuration', () => {
|
||||
it('should throw 404 if the connector is not found', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: 'not-found',
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'entity.not_exists_with_id',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw 400 if the connector is not SAML', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('oidc')!.id,
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'connector.saml_only_idp_initiated_auth',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw 404 if the application is not found', async () => {
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('saml')!.id,
|
||||
defaultApplicationId: 'not-found',
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'entity.not_exists_with_id',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it.each(['spa', 'thirdParty'])(
|
||||
'should throw 400 if the application is not a first-party traditional web application',
|
||||
async (applicationKey) => {
|
||||
const defaultApplicationId = applications.get(applicationKey)!.id;
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('saml')!.id,
|
||||
connectorId: 'not-found',
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'connector.saml_idp_initiated_auth_invalid_application_type',
|
||||
code: 'entity.not_exists_with_id',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw 400 if the connector is not SAML', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('oidc')!.id,
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'connector.saml_only_idp_initiated_auth',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
it('should create a new IdP-initiated authentication configuration for a SAML SSO connector', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
const redirectUri = 'https://example.com';
|
||||
const authParameters = {
|
||||
resources: ['resource1', 'resource2'],
|
||||
scopes: ['profile', 'email'],
|
||||
};
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
|
||||
const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId,
|
||||
defaultApplicationId,
|
||||
redirectUri,
|
||||
authParameters,
|
||||
});
|
||||
|
||||
expect(config).toMatchObject({
|
||||
defaultApplicationId,
|
||||
redirectUri,
|
||||
authParameters,
|
||||
it('should throw 404 if the application is not found', async () => {
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('saml')!.id,
|
||||
defaultApplicationId: 'not-found',
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'entity.not_exists_with_id',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId);
|
||||
it.each(['spa', 'thirdParty'])(
|
||||
'should throw 400 if the application is not a first-party traditional web application',
|
||||
async (applicationKey) => {
|
||||
const defaultApplicationId = applications.get(applicationKey)!.id;
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId: ssoConnectors.get('saml')!.id,
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'connector.saml_idp_initiated_auth_invalid_application_type',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
expect(fetchedConfig).toMatchObject(config);
|
||||
it('should create a new IdP-initiated authentication configuration for a SAML SSO connector', async () => {
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
const redirectUri = 'https://example.com';
|
||||
const authParameters = {
|
||||
resources: ['resource1', 'resource2'],
|
||||
scopes: ['profile', 'email'],
|
||||
};
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
|
||||
const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId,
|
||||
defaultApplicationId,
|
||||
redirectUri,
|
||||
authParameters,
|
||||
});
|
||||
|
||||
expect(config).toMatchObject({
|
||||
defaultApplicationId,
|
||||
redirectUri,
|
||||
authParameters,
|
||||
});
|
||||
|
||||
const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(
|
||||
connectorId
|
||||
);
|
||||
|
||||
expect(fetchedConfig).toMatchObject(config);
|
||||
});
|
||||
|
||||
it('should cascade delete the IdP-initiated authentication configuration when the application is deleted', async () => {
|
||||
const application = await createApplication(
|
||||
`web-app-${randomString()}`,
|
||||
ApplicationType.Traditional
|
||||
);
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
|
||||
const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId,
|
||||
defaultApplicationId: application.id,
|
||||
redirectUri: 'https://example.com',
|
||||
});
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
|
||||
await deleteApplication(application.id);
|
||||
|
||||
await expectRejects(ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId), {
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should cascade delete the IdP-initiated authentication configuration when the application is deleted', async () => {
|
||||
const application = await createApplication(
|
||||
`web-app-${randomString()}`,
|
||||
ApplicationType.Traditional
|
||||
);
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
|
||||
const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId,
|
||||
defaultApplicationId: application.id,
|
||||
redirectUri: 'https://example.com',
|
||||
describe('Update IdP-initiated authentication configuration', () => {
|
||||
it('should throw 404 if the application is not found', async () => {
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(ssoConnectors.get('saml')!.id, {
|
||||
defaultApplicationId: 'not-found',
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'entity.not_exists_with_id',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
it.each(['spa', 'thirdParty'])(
|
||||
'should throw 400 if the application is not a first-party traditional web application',
|
||||
async (applicationKey) => {
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(ssoConnectors.get('saml')!.id, {
|
||||
defaultApplicationId: applications.get(applicationKey)!.id,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'connector.saml_idp_initiated_auth_invalid_application_type',
|
||||
status: 400,
|
||||
}
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
await deleteApplication(application.id);
|
||||
it('should update the IdP-initiated authentication configuration for a SAML SSO connector', async () => {
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
const defaultApplicationId = applications.get('traditional')!.id;
|
||||
|
||||
await expectRejects(ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId), {
|
||||
code: 'entity.not_found',
|
||||
status: 404,
|
||||
const config = await ssoConnectorsApi.setSsoConnectorIdpInitiatedAuthConfig({
|
||||
connectorId,
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://example.com',
|
||||
});
|
||||
|
||||
const updatedConfig = await ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(
|
||||
connectorId,
|
||||
{
|
||||
redirectUri: 'https://updated.com',
|
||||
authParameters: {
|
||||
resources: ['resource1', 'resource2'],
|
||||
scopes: ['profile', 'email'],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
expect(updatedConfig).toMatchObject({
|
||||
connectorId,
|
||||
defaultApplicationId,
|
||||
redirectUri: 'https://updated.com',
|
||||
authParameters: {
|
||||
resources: ['resource1', 'resource2'],
|
||||
scopes: ['profile', 'email'],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw 404 if the IdP-initiated authentication configuration is not found', async () => {
|
||||
const connectorId = ssoConnectors.get('saml')!.id;
|
||||
|
||||
await ssoConnectorsApi.deleteSsoConnectorIdpInitiatedAuthConfig(connectorId);
|
||||
|
||||
await expectRejects(
|
||||
ssoConnectorsApi.updateSsoConnectorIdpInitiatedAuthConfig(connectorId, {
|
||||
defaultApplicationId: applications.get('traditional')!.id,
|
||||
redirectUri: 'https://example.com',
|
||||
}),
|
||||
{
|
||||
code: 'entity.not_exists',
|
||||
status: 404,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Loading…
Reference in a new issue