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:
parent
22566e375c
commit
14a07dcead
12 changed files with 426 additions and 3 deletions
|
@ -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');
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ const mockSsoConnectorLibrary: SsoConnectorLibrary = {
|
|||
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,
|
||||
getSsoConnectors: jest.fn(),
|
||||
getSsoConnectorById: jest.fn(),
|
||||
createSsoConnectorIdpInitiatedAuthConfig: jest.fn(),
|
||||
};
|
||||
|
||||
describe('verifyEmailIdentifier tests', () => {
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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];
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
Loading…
Reference in a new issue