0
Fork 0
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:
simeng-li 2024-10-14 11:29:08 +08:00 committed by GitHub
parent 14a07dcead
commit 17c2a79caf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 335 additions and 93 deletions

View file

@ -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');

View file

@ -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,
};
};

View file

@ -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);
}
}
}

View file

@ -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', () => {

View file

@ -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."
}
}
}
}
}

View file

@ -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();
}
);
}

View file

@ -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];
}

View file

@ -39,6 +39,7 @@ devFeatureTest.describe('SAML IdP initiated authentication config', () => {
await ssoConnectorsApi.cleanUp();
});
describe('Set IdP-initiated authentication configuration', () => {
it('should throw 404 if the connector is not found', async () => {
const defaultApplicationId = applications.get('traditional')!.id;
@ -125,7 +126,9 @@ devFeatureTest.describe('SAML IdP initiated authentication config', () => {
authParameters,
});
const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(connectorId);
const fetchedConfig = await ssoConnectorsApi.getSsoConnectorIdpInitiatedAuthConfig(
connectorId
);
expect(fetchedConfig).toMatchObject(config);
});
@ -152,4 +155,85 @@ devFeatureTest.describe('SAML IdP initiated authentication config', () => {
status: 404,
});
});
});
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,
}
);
});
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,
}
);
}
);
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;
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,
}
);
});
});
});