0
Fork 0
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:
simeng-li 2023-10-30 10:28:03 +08:00 committed by GitHub
parent aa375f906f
commit 4080a2599b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 197 additions and 9 deletions

View file

@ -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',

View file

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

View file

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

View file

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

View file

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

View file

@ -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: [],
});
});
});

View file

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

View file

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

View file

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

View file

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

View 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>;