mirror of
https://github.com/logto-io/logto.git
synced 2025-03-31 22:51:25 -05:00
feat(core): add ssoConnectors to the sie well-known api (#4777)
* feat(core): add ssoConnectors to the sie wellknown api add ssoConnectors to the sie wellknown api * fix(test): fix well-known sie ut fix well-known sie ut
This commit is contained in:
parent
aa375f906f
commit
4080a2599b
11 changed files with 197 additions and 9 deletions
|
@ -20,6 +20,7 @@ export * from './sign-in-experience.js';
|
|||
export * from './cloud-connection.js';
|
||||
export * from './user.js';
|
||||
export * from './domain.js';
|
||||
export * from './sso.js';
|
||||
|
||||
export const mockApplication: Application = {
|
||||
tenantId: 'fake_tenant',
|
||||
|
|
|
@ -7,8 +7,10 @@ import {
|
|||
socialTarget02,
|
||||
mockSignInExperience,
|
||||
mockSocialConnectors,
|
||||
mockSsoConnector,
|
||||
} from '#src/__mocks__/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import { ssoConnectorFactories } from '#src/sso/index.js';
|
||||
|
||||
import { createConnectorLibrary } from '../connector.js';
|
||||
|
||||
|
@ -32,7 +34,13 @@ const signInExperiences = {
|
|||
};
|
||||
const { findDefaultSignInExperience, updateDefaultSignInExperience } = signInExperiences;
|
||||
|
||||
const ssoConnectorLibrary = {
|
||||
getSsoConnectors: jest.fn(),
|
||||
getSsoConnectorById: jest.fn(),
|
||||
};
|
||||
|
||||
const { MockQueries } = await import('#src/test-utils/tenant.js');
|
||||
|
||||
const queries = new MockQueries({
|
||||
customPhrases,
|
||||
signInExperiences,
|
||||
|
@ -40,11 +48,12 @@ const queries = new MockQueries({
|
|||
const connectorLibrary = createConnectorLibrary(queries, {
|
||||
getClient: jest.fn(),
|
||||
});
|
||||
|
||||
const getLogtoConnectors = jest.spyOn(connectorLibrary, 'getLogtoConnectors');
|
||||
|
||||
const { createSignInExperienceLibrary } = await import('./index.js');
|
||||
const { validateLanguageInfo, removeUnavailableSocialConnectorTargets } =
|
||||
createSignInExperienceLibrary(queries, connectorLibrary);
|
||||
const { validateLanguageInfo, removeUnavailableSocialConnectorTargets, getFullSignInExperience } =
|
||||
createSignInExperienceLibrary(queries, connectorLibrary, ssoConnectorLibrary);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
@ -118,3 +127,42 @@ describe('remove unavailable social connector targets', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('get sso connectors', () => {
|
||||
it('should filter out sso connectors that has invalid config', async () => {
|
||||
ssoConnectorLibrary.getSsoConnectors.mockResolvedValueOnce([mockSsoConnector]);
|
||||
getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors);
|
||||
findDefaultSignInExperience.mockResolvedValueOnce(mockSignInExperience);
|
||||
|
||||
const { ssoConnectors } = await getFullSignInExperience();
|
||||
expect(ssoConnectors).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return sso connectors with valid config', async () => {
|
||||
getLogtoConnectors.mockResolvedValueOnce(mockSocialConnectors);
|
||||
findDefaultSignInExperience.mockResolvedValueOnce(mockSignInExperience);
|
||||
|
||||
const ssoConnector = {
|
||||
...mockSsoConnector,
|
||||
config: {
|
||||
clientId: 'mockClientId',
|
||||
clientSecret: 'mockClientSecret',
|
||||
issuer: 'mockIssuer',
|
||||
},
|
||||
};
|
||||
|
||||
ssoConnectorLibrary.getSsoConnectors.mockResolvedValueOnce([ssoConnector]);
|
||||
|
||||
const { ssoConnectors } = await getFullSignInExperience();
|
||||
|
||||
expect(ssoConnectors).toEqual([
|
||||
{
|
||||
id: ssoConnector.id,
|
||||
connectorName: ssoConnector.connectorName,
|
||||
domains: ssoConnector.domains,
|
||||
logo: ssoConnectorFactories[ssoConnector.providerName].logo,
|
||||
darkLogo: undefined,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,10 +1,13 @@
|
|||
import { builtInLanguages } from '@logto/phrases-experience';
|
||||
import type { ConnectorMetadata, LanguageInfo } from '@logto/schemas';
|
||||
import type { ConnectorMetadata, LanguageInfo, SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { ConnectorType } from '@logto/schemas';
|
||||
import { deduplicate } from '@silverhand/essentials';
|
||||
|
||||
import { EnvSet } from '#src/env-set/index.js';
|
||||
import RequestError from '#src/errors/RequestError/index.js';
|
||||
import type { ConnectorLibrary } from '#src/libraries/connector.js';
|
||||
import type { SsoConnectorLibrary } from '#src/libraries/sso-connector.js';
|
||||
import { ssoConnectorFactories } from '#src/sso/index.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
import assertThat from '#src/utils/assert-that.js';
|
||||
|
||||
|
@ -17,7 +20,8 @@ export type SignInExperienceLibrary = ReturnType<typeof createSignInExperienceLi
|
|||
|
||||
export const createSignInExperienceLibrary = (
|
||||
queries: Queries,
|
||||
{ getLogtoConnectors }: ConnectorLibrary
|
||||
{ getLogtoConnectors }: ConnectorLibrary,
|
||||
{ getSsoConnectors }: SsoConnectorLibrary
|
||||
) => {
|
||||
const {
|
||||
customPhrases: { findAllCustomLanguageTags },
|
||||
|
@ -53,10 +57,46 @@ export const createSignInExperienceLibrary = (
|
|||
});
|
||||
};
|
||||
|
||||
const getActiveSsoConnectors = async (): Promise<SsoConnectorMetadata[]> => {
|
||||
// TODO: @simeng-li Return empty array if dev features are not enabled
|
||||
if (!EnvSet.values.isDevFeaturesEnabled) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ssoConnectors = await getSsoConnectors();
|
||||
|
||||
return ssoConnectors.reduce<SsoConnectorMetadata[]>(
|
||||
(previous, connector): SsoConnectorMetadata[] => {
|
||||
const { providerName, connectorName, config, id, branding, domains } = connector;
|
||||
const factory = ssoConnectorFactories[providerName];
|
||||
|
||||
// Filter out sso connectors that has invalid config
|
||||
const result = factory.configGuard.safeParse(config);
|
||||
|
||||
if (!result.success) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
// Format the connector metadata for the client
|
||||
const connectorMetadata: SsoConnectorMetadata = {
|
||||
id,
|
||||
connectorName,
|
||||
domains,
|
||||
logo: branding.logo ?? factory.logo,
|
||||
darkLogo: branding.darkLogo,
|
||||
};
|
||||
|
||||
return [...previous, connectorMetadata];
|
||||
},
|
||||
[]
|
||||
);
|
||||
};
|
||||
|
||||
const getFullSignInExperience = async (): Promise<FullSignInExperience> => {
|
||||
const [signInExperience, logtoConnectors] = await Promise.all([
|
||||
const [signInExperience, logtoConnectors, ssoConnectors] = await Promise.all([
|
||||
findDefaultSignInExperience(),
|
||||
getLogtoConnectors(),
|
||||
getActiveSsoConnectors(),
|
||||
]);
|
||||
|
||||
const forgotPassword = {
|
||||
|
@ -80,6 +120,7 @@ export const createSignInExperienceLibrary = (
|
|||
return {
|
||||
...signInExperience,
|
||||
socialConnectors,
|
||||
ssoConnectors,
|
||||
forgotPassword,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -1,5 +1,10 @@
|
|||
import { connectorMetadataGuard, type ConnectorMetadata } from '@logto/connector-kit';
|
||||
import { type SignInExperience, SignInExperiences } from '@logto/schemas';
|
||||
import {
|
||||
type SignInExperience,
|
||||
SignInExperiences,
|
||||
type SsoConnectorMetadata,
|
||||
ssoConnectorMetadataGuard,
|
||||
} from '@logto/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
type ForgotPassword = {
|
||||
|
@ -11,11 +16,13 @@ type ConnectorMetadataWithId = ConnectorMetadata & { id: string };
|
|||
|
||||
export type FullSignInExperience = SignInExperience & {
|
||||
socialConnectors: ConnectorMetadataWithId[];
|
||||
ssoConnectors: SsoConnectorMetadata[];
|
||||
forgotPassword: ForgotPassword;
|
||||
};
|
||||
|
||||
export const guardFullSignInExperience: z.ZodType<FullSignInExperience> =
|
||||
SignInExperiences.guard.extend({
|
||||
socialConnectors: connectorMetadataGuard.extend({ id: z.string() }).array(),
|
||||
ssoConnectors: ssoConnectorMetadataGuard.array(),
|
||||
forgotPassword: z.object({ phone: z.boolean(), email: z.boolean() }),
|
||||
});
|
||||
|
|
|
@ -5,6 +5,8 @@ import { type SupportedSsoConnector } from '#src/sso/types/index.js';
|
|||
import { isSupportedSsoConnector } from '#src/sso/utils.js';
|
||||
import type Queries from '#src/tenants/Queries.js';
|
||||
|
||||
export type SsoConnectorLibrary = ReturnType<typeof createSsoConnectorLibrary>;
|
||||
|
||||
export const createSsoConnectorLibrary = (queries: Queries) => {
|
||||
const { ssoConnectors } = queries;
|
||||
|
||||
|
|
|
@ -44,11 +44,13 @@ const getLogtoConnectors = jest.fn(async () => {
|
|||
mockWechatNativeConnector,
|
||||
];
|
||||
});
|
||||
const getSsoConnectors = jest.fn().mockResolvedValue([0, []]);
|
||||
const tenantContext = new MockTenant(
|
||||
provider,
|
||||
{
|
||||
signInExperiences: sieQueries,
|
||||
users: { hasActiveUsers: jest.fn().mockResolvedValue(true) },
|
||||
ssoConnectors: { findAll: getSsoConnectors },
|
||||
},
|
||||
{ getLogtoConnectors }
|
||||
);
|
||||
|
@ -74,6 +76,7 @@ describe('GET /.well-known/sign-in-exp', () => {
|
|||
it('should return github and facebook connector instances', async () => {
|
||||
const response = await sessionRequest.get('/.well-known/sign-in-exp');
|
||||
expect(findDefaultSignInExperience).toHaveBeenCalledTimes(1);
|
||||
expect(getLogtoConnectors).toHaveBeenCalledTimes(1);
|
||||
expect(response.status).toEqual(200);
|
||||
expect(response.body).toMatchObject({
|
||||
...mockSignInExperience,
|
||||
|
@ -95,6 +98,7 @@ describe('GET /.well-known/sign-in-exp', () => {
|
|||
id: mockWechatNativeConnector.dbEntry.id,
|
||||
},
|
||||
],
|
||||
ssoConnectors: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -16,7 +16,6 @@ import type Queries from './Queries.js';
|
|||
|
||||
export default class Libraries {
|
||||
users = createUserLibrary(this.queries);
|
||||
signInExperiences = createSignInExperienceLibrary(this.queries, this.connectors);
|
||||
phrases = createPhraseLibrary(this.queries);
|
||||
hooks = createHookLibrary(this.queries);
|
||||
socials = createSocialLibrary(this.queries, this.connectors);
|
||||
|
@ -26,6 +25,11 @@ export default class Libraries {
|
|||
domains = createDomainLibrary(this.queries);
|
||||
quota = createQuotaLibrary(this.queries, this.cloudConnection, this.connectors);
|
||||
ssoConnector = createSsoConnectorLibrary(this.queries);
|
||||
signInExperiences = createSignInExperienceLibrary(
|
||||
this.queries,
|
||||
this.connectors,
|
||||
this.ssoConnector
|
||||
);
|
||||
|
||||
constructor(
|
||||
public readonly tenantId: string,
|
||||
|
|
|
@ -63,6 +63,8 @@ describe('post sso-connectors', () => {
|
|||
expect(response).toHaveProperty('domains', []);
|
||||
expect(response).toHaveProperty('ssoOnly', false);
|
||||
expect(response).toHaveProperty('syncProfile', false);
|
||||
|
||||
await deleteSsoConnectorById(response.id);
|
||||
});
|
||||
|
||||
it('should throw if invalid config is provided', async () => {
|
||||
|
@ -98,6 +100,8 @@ describe('post sso-connectors', () => {
|
|||
expect(response).toHaveProperty('domains', data.domains);
|
||||
expect(response).toHaveProperty('ssoOnly', data.ssoOnly);
|
||||
expect(response).toHaveProperty('syncProfile', false);
|
||||
|
||||
await deleteSsoConnectorById(response.id);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -118,6 +122,8 @@ describe('get sso-connectors', () => {
|
|||
|
||||
// Invalid config
|
||||
expect(connector?.providerConfig).toBeUndefined();
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -141,6 +147,8 @@ describe('get sso-connector by id', () => {
|
|||
expect(connector).toHaveProperty('domains', []);
|
||||
expect(connector).toHaveProperty('ssoOnly', false);
|
||||
expect(connector).toHaveProperty('syncProfile', false);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -165,7 +173,9 @@ describe('delete sso-connector by id', () => {
|
|||
|
||||
describe('patch sso-connector by id', () => {
|
||||
it('should return 404 if connector is not found', async () => {
|
||||
await expect(getSsoConnectorById('invalid-id')).rejects.toThrow(HTTPError);
|
||||
await expect(patchSsoConnectorById('invalid-id', { connectorName: 'foo' })).rejects.toThrow(
|
||||
HTTPError
|
||||
);
|
||||
});
|
||||
|
||||
it('should patch sso connector without config', async () => {
|
||||
|
@ -187,6 +197,8 @@ describe('patch sso-connector by id', () => {
|
|||
expect(connector).toHaveProperty('domains', ['test.com']);
|
||||
expect(connector).toHaveProperty('ssoOnly', true);
|
||||
expect(connector).toHaveProperty('syncProfile', false);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
|
||||
it('should directly return if no changes are made', async () => {
|
||||
|
@ -206,6 +218,8 @@ describe('patch sso-connector by id', () => {
|
|||
expect(connector).toHaveProperty('domains', []);
|
||||
expect(connector).toHaveProperty('ssoOnly', false);
|
||||
expect(connector).toHaveProperty('syncProfile', false);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
|
||||
it('should throw if invalid config is provided', async () => {
|
||||
|
@ -222,6 +236,8 @@ describe('patch sso-connector by id', () => {
|
|||
},
|
||||
})
|
||||
).rejects.toThrow(HTTPError);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
|
||||
it('should patch sso connector with config', async () => {
|
||||
|
@ -251,6 +267,8 @@ describe('patch sso-connector by id', () => {
|
|||
scope: 'profile email openid', // Should merged with default scope openid
|
||||
});
|
||||
expect(connector).toHaveProperty('syncProfile', true);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -273,6 +291,8 @@ describe('patch sso-connector config by id', () => {
|
|||
clientId: 'foo',
|
||||
})
|
||||
).rejects.toThrow(HTTPError);
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
|
||||
it('should patch sso connector config', async () => {
|
||||
|
@ -298,5 +318,7 @@ describe('patch sso-connector config by id', () => {
|
|||
issuer: logtoIssuer,
|
||||
scope: 'openid',
|
||||
});
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
import { type SignInExperience, type Translation } from '@logto/schemas';
|
||||
import { type SignInExperience, type Translation, type SsoConnectorMetadata } from '@logto/schemas';
|
||||
import { HTTPError } from 'got';
|
||||
|
||||
import api, { adminTenantApi, authedAdminApi } from '#src/api/api.js';
|
||||
import { createSsoConnector, deleteSsoConnectorById } from '#src/api/sso-connector.js';
|
||||
import { logtoUrl } from '#src/constants.js';
|
||||
|
||||
describe('.well-known api', () => {
|
||||
it('should return tenant endpoint URL for any given tenant id', async () => {
|
||||
|
@ -52,4 +54,46 @@ describe('.well-known api', () => {
|
|||
.json<{ translation: Translation }>();
|
||||
expect(updated.translation.demo_app).toHaveProperty('notification', notification);
|
||||
});
|
||||
|
||||
describe('sso connectors in sign-in experience', () => {
|
||||
const logtoIssuer = `${logtoUrl}/oidc`;
|
||||
|
||||
const newOIDCSsoConnector = {
|
||||
providerName: 'OIDC',
|
||||
connectorName: 'OIDC sso connector',
|
||||
branding: {
|
||||
logo: 'https://logto.io/oidc-logo.png',
|
||||
darkLogo: 'https://logto.io/oidc-dark-logo.png',
|
||||
},
|
||||
config: {
|
||||
clientId: 'client-id',
|
||||
clientSecret: 'client-secret',
|
||||
issuer: logtoIssuer,
|
||||
},
|
||||
};
|
||||
|
||||
it('should get the sso connectors in sign-in experience', async () => {
|
||||
const { id, connectorName, domains } = await createSsoConnector(newOIDCSsoConnector);
|
||||
|
||||
const signInExperience = await api
|
||||
.get('.well-known/sign-in-exp')
|
||||
.json<SignInExperience & { ssoConnectors: SsoConnectorMetadata[] }>();
|
||||
|
||||
expect(signInExperience.ssoConnectors.length).toBeGreaterThan(0);
|
||||
|
||||
const newCreatedConnector = signInExperience.ssoConnectors.find(
|
||||
(connector) => connector.id === id
|
||||
);
|
||||
|
||||
expect(newCreatedConnector).toMatchObject({
|
||||
id,
|
||||
connectorName,
|
||||
domains,
|
||||
logo: newOIDCSsoConnector.branding.logo,
|
||||
darkLogo: newOIDCSsoConnector.branding.darkLogo,
|
||||
});
|
||||
|
||||
await deleteSsoConnectorById(id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -21,3 +21,4 @@ export * from './domain.js';
|
|||
export * from './sentinel.js';
|
||||
export * from './mfa.js';
|
||||
export * from './organization.js';
|
||||
export * from './sso-connector.js';
|
||||
|
|
14
packages/schemas/src/types/sso-connector.ts
Normal file
14
packages/schemas/src/types/sso-connector.ts
Normal file
|
@ -0,0 +1,14 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* SSO Connector data type that are returned to the experience client for sign-in use.
|
||||
*/
|
||||
export const ssoConnectorMetadataGuard = z.object({
|
||||
id: z.string(),
|
||||
connectorName: z.string(),
|
||||
domains: z.array(z.string()),
|
||||
logo: z.string(),
|
||||
darkLogo: z.string().optional(),
|
||||
});
|
||||
|
||||
export type SsoConnectorMetadata = z.infer<typeof ssoConnectorMetadataGuard>;
|
Loading…
Add table
Reference in a new issue