0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -05:00

feat(core,schemas): add sso idp initiated auth config api (#6660)

* feat(core,schemas): add sso idp initiated auth config api

add sso connector idp initiated auth config management api

* fix(core): align the naming convention

align the naming convention
This commit is contained in:
simeng-li 2024-10-14 10:50:43 +08:00 committed by GitHub
parent 22566e375c
commit 14a07dcead
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 426 additions and 3 deletions

View file

@ -44,6 +44,7 @@ const ssoConnectorLibrary = {
getSsoConnectors: jest.fn(),
getSsoConnectorById: jest.fn(),
getAvailableSsoConnectors: jest.fn(),
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
};
const { MockQueries } = await import('#src/test-utils/tenant.js');

View file

@ -1,4 +1,8 @@
import { type SupportedSsoConnector } from '@logto/schemas';
import {
ApplicationType,
type CreateSsoConnectorIdpInitiatedAuthConfig,
type SupportedSsoConnector,
} from '@logto/schemas';
import { assert } from '@silverhand/essentials';
import RequestError from '#src/errors/RequestError/index.js';
@ -6,10 +10,13 @@ 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';
export type SsoConnectorLibrary = ReturnType<typeof createSsoConnectorLibrary>;
export const createSsoConnectorLibrary = (queries: Queries) => {
const { ssoConnectors } = queries;
const { ssoConnectors, applications } = queries;
const getSsoConnectors = async (
limit?: number,
@ -52,9 +59,32 @@ export const createSsoConnectorLibrary = (queries: Queries) => {
return connector;
};
/**
* Creates a new IdP-initiated authentication configuration for a SSO connector.
*
* @throws {RequestError} Throws a 404 error if the application is not found
* @throws {RequestError} Throws a 400 error if the application is not a first-party traditional web application
*/
const createSsoConnectorIdpInitiatedAuthConfig = async (
data: OmitAutoSetFields<CreateSsoConnectorIdpInitiatedAuthConfig>
) => {
const { defaultApplicationId } = data;
// 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.insertIdpInitiatedAuthConfig(data);
};
return {
getSsoConnectors,
getAvailableSsoConnectors,
getSsoConnectorById,
createSsoConnectorIdpInitiatedAuthConfig,
};
};

View file

@ -1,6 +1,8 @@
import {
type CreateSsoConnector,
type SsoConnector,
type SsoConnectorIdpInitiatedAuthConfig,
SsoConnectorIdpInitiatedAuthConfigs,
type SsoConnectorKeys,
SsoConnectors,
} from '@logto/schemas';
@ -9,11 +11,42 @@ import { sql, type CommonQueryMethods } from '@silverhand/slonik';
import SchemaQueries from '#src/utils/SchemaQueries.js';
import { convertToIdentifiers } from '#src/utils/sql.js';
import { buildInsertIntoWithPool } from '../database/insert-into.js';
import { buildUpdateWhereWithPool } from '../database/update-where.js';
const {
table: ssoConnectorIdpInitiatedAuthConfigsTable,
fields: ssoConnectorIdpInitiatedAuthConfigsFields,
} = convertToIdentifiers(SsoConnectorIdpInitiatedAuthConfigs);
export default class SsoConnectorQueries extends SchemaQueries<
SsoConnectorKeys,
CreateSsoConnector,
SsoConnector
> {
public readonly insertIdpInitiatedAuthConfig = buildInsertIntoWithPool(this.pool)(
SsoConnectorIdpInitiatedAuthConfigs,
{
returning: true,
onConflict: {
fields: [
ssoConnectorIdpInitiatedAuthConfigsFields.connectorId,
ssoConnectorIdpInitiatedAuthConfigsFields.tenantId,
],
setExcludedFields: [
ssoConnectorIdpInitiatedAuthConfigsFields.defaultApplicationId,
ssoConnectorIdpInitiatedAuthConfigsFields.redirectUri,
ssoConnectorIdpInitiatedAuthConfigsFields.authParameters,
],
},
}
);
public readonly updateIdpInitiatedAuthConfig = buildUpdateWhereWithPool(this.pool)(
SsoConnectorIdpInitiatedAuthConfigs,
true
);
constructor(pool: CommonQueryMethods) {
super(pool, SsoConnectors);
}
@ -26,4 +59,11 @@ export default class SsoConnectorQueries extends SchemaQueries<
where ${fields.connectorName}=${connectorName}
`);
}
async getIdpInitiatedAuthConfigByConnectorId(connectorId: string) {
return this.pool.maybeOne<SsoConnectorIdpInitiatedAuthConfig>(sql`
SELECT * FROM ${ssoConnectorIdpInitiatedAuthConfigsTable}
WHERE ${ssoConnectorIdpInitiatedAuthConfigsFields.connectorId}=${connectorId}
`);
}
}

View file

@ -13,6 +13,7 @@ const mockSsoConnectorLibrary: SsoConnectorLibrary = {
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
getSsoConnectors: jest.fn(),
getSsoConnectorById: jest.fn(),
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
};
describe('verifyEmailIdentifier tests', () => {

View file

@ -0,0 +1,57 @@
{
"tags": [
{
"name": "Dev feature"
}
],
"paths": {
"/api/sso-connectors/{id}/idp-initiated-auth-config": {
"get": {
"summary": "Get IdP initiated auth config",
"description": "Get the IdP initiated authentication config of the given SAML SSO connector.",
"responses": {
"200": {
"description": "The IdP initiated authentication config."
},
"404": {
"description": "SSO connector or the IdP initiated auth config not found."
}
}
},
"put": {
"summary": "Set IdP initiated auth config",
"description": "Set IdP initiated authentication config for a given SAML SSO connector. Any existing IdP initiated auth config will be overwritten.",
"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": "SSO connector or application not found."
},
"400": {
"description": "The request body is invalid. The SSO connector is not a SAML connector or the application is not a Traditional web application."
}
}
}
}
}
}

View file

@ -0,0 +1,98 @@
import {
SsoConnectors,
SsoConnectorIdpInitiatedAuthConfigs,
SsoProviderType,
} from '@logto/schemas';
import { z } from 'zod';
import RequestError from '#src/errors/RequestError/index.js';
import koaGuard from '#src/middleware/koa-guard.js';
import { ssoConnectorFactories } from '#src/sso/index.js';
import { tableToPathname } from '#src/utils/SchemaRouter.js';
import assertThat from '../../utils/assert-that.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
export default function ssoConnectorIdpInitiatedAuthConfigRoutes<T extends ManagementApiRouter>(
...args: RouterInitArgs<T>
) {
const [
router,
{
queries,
libraries: {
ssoConnectors: { getSsoConnectorById, createSsoConnectorIdpInitiatedAuthConfig },
},
},
] = args;
const pathPrefix = `/${tableToPathname(SsoConnectors.table)}/:id/idp-initiated-auth-config`;
router.put(
pathPrefix,
koaGuard({
body: SsoConnectorIdpInitiatedAuthConfigs.createGuard.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;
// Throws an 404 error if the connector is not found
const { providerName } = await getSsoConnectorById(id);
const { providerType } = ssoConnectorFactories[providerName];
assertThat(
providerType === SsoProviderType.SAML,
new RequestError('connector.saml_only_idp_initiated_auth')
);
const config = await createSsoConnectorIdpInitiatedAuthConfig({
connectorId: id,
...body,
});
ctx.body = config;
ctx.status = 200;
return next();
}
);
router.get(
pathPrefix,
koaGuard({
params: z.object({ id: z.string().min(1) }),
response: SsoConnectorIdpInitiatedAuthConfigs.guard,
status: [200, 404],
}),
async (ctx, next) => {
const {
params: { id },
} = ctx.guard;
const configs = await queries.ssoConnectors.getIdpInitiatedAuthConfigByConnectorId(id);
assertThat(
configs,
new RequestError({
code: 'entity.not_found',
status: 404,
})
);
ctx.body = configs;
ctx.status = 200;
return next();
}
);
}

View file

@ -17,8 +17,10 @@ import { isSupportedSsoConnector, isSupportedSsoProvider } from '#src/sso/utils.
import { tableToPathname } from '#src/utils/SchemaRouter.js';
import assertThat from '#src/utils/assert-that.js';
import { EnvSet } from '../../env-set/index.js';
import { type ManagementApiRouter, type RouterInitArgs } from '../types.js';
import ssoConnectorIdpInitiatedAuthConfigRoutes from './idp-initiated-auth-config.js';
import {
fetchConnectorProviderDetails,
parseConnectorConfig,
@ -300,4 +302,8 @@ export default function singleSignOnConnectorsRoutes<T extends ManagementApiRout
return next();
}
);
if (EnvSet.values.isDevFeaturesEnabled) {
ssoConnectorIdpInitiatedAuthConfigRoutes(...args);
}
}

View file

@ -1,10 +1,13 @@
import {
type CreateSsoConnectorIdpInitiatedAuthConfig,
SsoProviderName,
type CreateSsoConnector,
type SsoConnector,
type SsoConnectorProvidersResponse,
type SsoConnectorIdpInitiatedAuthConfig,
} from '@logto/schemas';
import { metadataXml } from '#src/__mocks__/sso-connectors-mock.js';
import { authedAdminApi } from '#src/api/api.js';
import { logtoUrl } from '#src/constants.js';
import { randomString } from '#src/utils.js';
@ -65,6 +68,20 @@ export class SsoConnectorApi {
return connector;
}
async createMockSamlConnector(domains: string[], connectorName?: string) {
const connector = await this.create({
providerName: SsoProviderName.SAML,
connectorName: connectorName ?? `test-saml-${randomString()}`,
domains,
config: {
metadata: metadataXml,
},
syncProfile: true,
});
return connector;
}
async create(data: Partial<CreateSsoConnector>): Promise<SsoConnector> {
const connector = await createSsoConnector(data);
@ -83,6 +100,21 @@ export class SsoConnectorApi {
);
}
async setSsoConnectorIdpInitiatedAuthConfig(data: CreateSsoConnectorIdpInitiatedAuthConfig) {
const { connectorId, ...rest } = data;
return authedAdminApi
.put(`sso-connectors/${connectorId}/idp-initiated-auth-config`, {
json: rest,
})
.json<SsoConnectorIdpInitiatedAuthConfig>();
}
async getSsoConnectorIdpInitiatedAuthConfig(connectorId: string) {
return authedAdminApi
.get(`sso-connectors/${connectorId}/idp-initiated-auth-config`)
.json<SsoConnectorIdpInitiatedAuthConfig>();
}
get firstConnectorId() {
return Array.from(this.connectorInstances.keys())[0];
}

View file

@ -0,0 +1,155 @@
import { ApplicationType, type SsoConnector, type Application } from '@logto/schemas';
import { createApplication, deleteApplication } from '#src/api/application.js';
import { SsoConnectorApi } from '#src/api/sso-connector.js';
import { expectRejects } from '#src/helpers/index.js';
import { randomString, devFeatureTest } from '#src/utils.js';
devFeatureTest.describe('SAML IdP initiated authentication config', () => {
const ssoConnectorsApi = new SsoConnectorApi();
const applications = new Map<string, Application>();
const ssoConnectors = new Map<string, SsoConnector>();
beforeAll(async () => {
const [samlConnector, oidcConnector] = await Promise.all([
ssoConnectorsApi.createMockSamlConnector(['example.com']),
ssoConnectorsApi.createMockOidcConnector(['example.com']),
]);
ssoConnectors.set('saml', samlConnector);
ssoConnectors.set('oidc', oidcConnector);
const [spaApplication, webApplication, thirdPartyApplication] = await Promise.all([
createApplication(`spa-app-${randomString()}`, ApplicationType.SPA),
createApplication(`web-app-${randomString()}`, ApplicationType.Traditional),
createApplication(`third-party-app-${randomString()}`, ApplicationType.Traditional, {
isThirdParty: true,
}),
]);
applications.set('spa', spaApplication);
applications.set('traditional', webApplication);
applications.set('thirdParty', thirdPartyApplication);
});
afterAll(async () => {
await Promise.all(
Array.from(applications.values()).map(async (app) => deleteApplication(app.id))
);
await ssoConnectorsApi.cleanUp();
});
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,
defaultApplicationId,
redirectUri: 'https://example.com',
}),
{
code: 'connector.saml_idp_initiated_auth_invalid_application_type',
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,
});
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,
});
});
});

View file

@ -35,6 +35,9 @@ const connector = {
'You can not have multiple social connectors that have same target and platform.',
cannot_overwrite_metadata_for_non_standard_connector:
"This connector's 'metadata' cannot be overwritten.",
saml_only_idp_initiated_auth: 'Only the SAML connector can set up IdP initiated authentication.',
saml_idp_initiated_auth_invalid_application_type:
'SAML IdP initiated authentication can only be used with a first-party traditional web application.',
};
export default Object.freeze(connector);

View file

@ -15,7 +15,7 @@ export const idpInitiatedAuthParamsGuard = z
resources: z.array(z.string()).optional(),
scopes: z.array(z.string()).optional(),
})
.catchall(z.string());
.catchall(z.unknown());
export type IdpInitiatedAuthParams = z.infer<typeof idpInitiatedAuthParamsGuard>;