0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-04-07 23:01:25 -05:00

feat(core,experience): implement google workspace connector (#4925)

* feat(core,experience): implement google workspace connector

implement google workspace connector

* fix(core): clean code

clean code

* fix(core): fix ut

fix ut

* fix(core): fix ut

fix ut

* fix(test): fix integration tests

fix integration tests

* fix(core): make the sso data as public property and remove getter

make the sso data as public perperty and remove getter
This commit is contained in:
simeng-li 2023-11-21 11:13:59 +08:00 committed by GitHub
parent 4b90782ae0
commit f880329d16
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 153 additions and 59 deletions

View file

@ -26,6 +26,7 @@ const insertUserSsoIdentityMock = jest.fn();
const updateUserMock = jest.fn();
const findUserByEmailMock = jest.fn();
const insertUserMock = jest.fn();
const generateUserIdMock = jest.fn().mockResolvedValue('foo');
const storeInteractionResultMock = jest.fn();
const getAvailableSsoConnectorsMock = jest.fn();
const getSingleSignOnSessionResultMock = jest.fn();
@ -79,6 +80,7 @@ describe('Single sign on util methods tests', () => {
{
users: {
insertUser: insertUserMock,
generateUserId: generateUserIdMock,
},
ssoConnectors: {
getAvailableSsoConnectors: getAvailableSsoConnectorsMock,

View file

@ -304,7 +304,7 @@ const registerWithSsoAuthentication = async (
// Insert new user
const { id: userId } = await usersLibrary.insertUser(
{
id: generateStandardId(),
id: await usersLibrary.generateUserId(),
...syncingProfile,
lastSignInAt: Date.now(),
},

View file

@ -94,7 +94,7 @@ describe('fetchConnectorProviderDetails', () => {
providerLogo: ssoConnectorFactories[connector.providerName].logo,
providerConfig: {
...connector.config,
scope: 'openid', // Default scope
scope: 'openid profile email', // Default scope
tokenEndpoint: 'http://example.com/token',
},
})

View file

@ -0,0 +1,17 @@
import { SsoProviderName } from '@logto/schemas';
import { SamlSsoConnector } from '../SamlSsoConnector/index.js';
import { type SingleSignOnFactory } from '../index.js';
import { samlConnectorConfigGuard } from '../types/saml.js';
export class AzureAdSsoConnector extends SamlSsoConnector {}
export const azureAdSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.AZURE_AD> = {
providerName: SsoProviderName.AZURE_AD,
logo: 'https://logtoeu.blob.core.windows.net/public-blobs/risa4g/aAYaRZOiGoxS/2023/09/03/zqdr28er/azure.png',
description: {
en: 'This connector is used to connect with Azure AD Single Sign-On.',
},
configGuard: samlConnectorConfigGuard,
constructor: AzureAdSsoConnector,
};

View file

@ -0,0 +1,54 @@
import { ConnectorError, ConnectorErrorCodes } from '@logto/connector-kit';
import { type SsoConnector, SsoProviderName } from '@logto/schemas';
import OidcConnector from '../OidcConnector/index.js';
import { type SingleSignOnFactory } from '../index.js';
import { type SingleSignOn } from '../types/index.js';
import { basicOidcConnectorConfigGuard } from '../types/oidc.js';
// Google use static issue endpoint.
const googleIssuer = 'https://accounts.google.com';
export class GoogleWorkspaceSsoConnector extends OidcConnector implements SingleSignOn {
static googleIssuer = googleIssuer;
constructor(readonly data: SsoConnector) {
const parseConfigResult = googleWorkspaceSsoConnectorConfigGuard.safeParse(data.config);
if (!parseConfigResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error);
}
super({
...parseConfigResult.data,
issuer: googleIssuer,
});
}
// OIDC connector doesn't have additional properties.
// eslint-disable-next-line unicorn/no-useless-undefined
getProperties = () => undefined;
async getConfig() {
return this.getOidcConfig();
}
async getIssuer() {
return this.issuer;
}
}
export const googleWorkspaceSsoConnectorConfigGuard = basicOidcConnectorConfigGuard.omit({
issuer: true,
});
export const googleWorkSpaceSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.GOOGLE_WORKSPACE> =
{
providerName: SsoProviderName.GOOGLE_WORKSPACE,
logo: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0yMy41MiAxMi4yNzI5QzIzLjUyIDExLjQyMiAyMy40NDM2IDEwLjYwMzggMjMuMzAxOCA5LjgxODM2SDEyVjE0LjQ2MDJIMTguNDU4MkMxOC4xOCAxNS45NjAyIDE3LjMzNDUgMTcuMjMxMSAxNi4wNjM2IDE4LjA4MlYyMS4wOTI5SDE5Ljk0MThDMjIuMjEwOSAxOS4wMDM4IDIzLjUyIDE1LjkyNzUgMjMuNTIgMTIuMjcyOVoiIGZpbGw9IiM0Mjg1RjQiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik0xMiAyMy45OTk5QzE1LjI0IDIzLjk5OTkgMTcuOTU2NCAyMi45MjU0IDE5Ljk0MTggMjEuMDkyNkwxNi4wNjM2IDE4LjA4MTdDMTQuOTg5MSAxOC44MDE3IDEzLjYxNDUgMTkuMjI3MiAxMiAxOS4yMjcyQzguODc0NTUgMTkuMjI3MiA2LjIyOTA5IDE3LjExNjMgNS4yODU0NiAxNC4yNzk5SDEuMjc2MzdWMTcuMzg5QzMuMjUwOTEgMjEuMzEwOCA3LjMwOTA5IDIzLjk5OTkgMTIgMjMuOTk5OVoiIGZpbGw9IiMzNEE4NTMiLz4KPHBhdGggZmlsbC1ydWxlPSJldmVub2RkIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01LjI4NTQ1IDE0LjI3OThDNS4wNDU0NSAxMy41NTk4IDQuOTA5MDkgMTIuNzkwNyA0LjkwOTA5IDExLjk5OThDNC45MDkwOSAxMS4yMDg5IDUuMDQ1NDUgMTAuNDM5OCA1LjI4NTQ1IDkuNzE5ODFWNi42MTA3MkgxLjI3NjM2QzAuNDYzNjM2IDguMjMwNzIgMCAxMC4wNjM0IDAgMTEuOTk5OEMwIDEzLjkzNjIgMC40NjM2MzYgMTUuNzY4OSAxLjI3NjM2IDE3LjM4ODlMNS4yODU0NSAxNC4yNzk4WiIgZmlsbD0iI0ZCQkMwNSIvPgo8cGF0aCBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGNsaXAtcnVsZT0iZXZlbm9kZCIgZD0iTTEyIDQuNzcyNzNDMTMuNzYxOCA0Ljc3MjczIDE1LjM0MzYgNS4zNzgxOCAxNi41ODczIDYuNTY3MjdMMjAuMDI5MSAzLjEyNTQ1QzE3Ljk1MDkgMS4xODkwOSAxNS4yMzQ1IDAgMTIgMEM3LjMwOTA5IDAgMy4yNTA5MSAyLjY4OTA5IDEuMjc2MzcgNi42MTA5MUw1LjI4NTQ2IDkuNzJDNi4yMjkwOSA2Ljg4MzY0IDguODc0NTUgNC43NzI3MyAxMiA0Ljc3MjczWiIgZmlsbD0iI0VBNDMzNSIvPgo8L3N2Zz4K',
description: {
en: 'This connector is used to connect with Google Workspace Single Sign-On.',
},
configGuard: googleWorkspaceSsoConnectorConfigGuard,
constructor: GoogleWorkspaceSsoConnector,
};

View file

@ -4,6 +4,7 @@ import { assert, conditional } from '@silverhand/essentials';
import snakecaseKeys from 'snakecase-keys';
import { type BaseOidcConfig, type BasicOidcConnectorConfig } from '../types/oidc.js';
import { type ExtendedSocialUserInfo } from '../types/saml.js';
import {
type SingleSignOnConnectorSession,
type CreateSingleSignOnSession,
@ -28,7 +29,7 @@ class OidcConnector {
constructor(private readonly config: BasicOidcConnectorConfig) {}
/* Fetch the full-list of OIDC config from the issuer. Throws error if config is invalid */
getOidcConfig = async (): Promise<BaseOidcConfig> => {
async getOidcConfig(): Promise<BaseOidcConfig> {
const { issuer } = this.config;
const oidcConfig = await fetchOidcConfig(issuer);
@ -37,11 +38,6 @@ class OidcConnector {
...this.config,
...oidcConfig,
};
};
/** `Issuer` will be used by SSO identity to indicate the source of the identity */
async getIssuer() {
return this.config.issuer;
}
/**
@ -52,14 +48,14 @@ class OidcConnector {
* @param oidcQueryParams.redirectUri The redirect uri for the OIDC provider
* @param setSession Set the connector session data to the oidc provider session storage.
*/
getAuthorizationUrl = async (
async getAuthorizationUrl(
{
state,
redirectUri,
connectorId,
}: { state: string; redirectUri: string; connectorId: string },
setSession: CreateSingleSignOnSession
) => {
) {
assert(
setSession,
new ConnectorError(ConnectorErrorCodes.NotImplemented, {
@ -84,7 +80,7 @@ class OidcConnector {
});
return `${oidcConfig.authorizationEndpoint}?${queryParameters.toString()}`;
};
}
get issuer() {
return this.config.issuer;
@ -99,7 +95,10 @@ class OidcConnector {
* @remark Forked from @logto/oidc-connector
*
*/
getUserInfo = async (connectorSession: SingleSignOnConnectorSession, data: unknown) => {
async getUserInfo(
connectorSession: SingleSignOnConnectorSession,
data: unknown
): Promise<ExtendedSocialUserInfo> {
const oidcConfig = await this.getOidcConfig();
const { nonce, redirectUri } = connectorSession;
@ -112,12 +111,12 @@ class OidcConnector {
return {
id: sub,
name: conditional(name),
avatar: conditional(picture),
email: conditional(email_verified && email),
phone: conditional(phone_verified && phone),
...conditional(name && { name }),
...conditional(picture && { avatar: picture }),
...conditional(email && email_verified && { email }),
...conditional(phone && phone_verified && { phone }),
};
};
}
}
export default OidcConnector;

View file

@ -7,8 +7,8 @@ import { type SingleSignOn } from '../types/index.js';
import { basicOidcConnectorConfigGuard } from '../types/oidc.js';
export class OidcSsoConnector extends OidcConnector implements SingleSignOn {
constructor(private readonly _data: SsoConnector) {
const parseConfigResult = basicOidcConnectorConfigGuard.safeParse(_data.config);
constructor(readonly data: SsoConnector) {
const parseConfigResult = basicOidcConnectorConfigGuard.safeParse(data.config);
if (!parseConfigResult.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, parseConfigResult.error);
@ -17,23 +17,24 @@ export class OidcSsoConnector extends OidcConnector implements SingleSignOn {
super(parseConfigResult.data);
}
get data() {
return this._data;
}
// OIDC connector doesn't have additional properties.
// eslint-disable-next-line unicorn/no-useless-undefined
getProperties = () => undefined;
getConfig = async () => this.getOidcConfig();
getIssuer = async () => this.issuer;
async getConfig() {
return this.getOidcConfig();
}
async getIssuer() {
return this.issuer;
}
}
export const oidcSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.OIDC> = {
providerName: SsoProviderName.OIDC,
logo: 'https://freesvg.org/img/techtonik_OpenID.png',
description: {
en: ' This connector is used to connect with OIDC single sign-on identity provider.',
en: 'This connector is used to connect with OIDC single sign-on identity provider.',
},
configGuard: basicOidcConnectorConfigGuard,
constructor: OidcSsoConnector,

View file

@ -104,9 +104,9 @@ export class SamlSsoConnector extends SamlConnector implements SingleSignOn {
export const samlSsoConnectorFactory: SingleSignOnFactory<SsoProviderName.SAML> = {
providerName: SsoProviderName.SAML,
logo: 'https://www.svgrepo.com/show/448246/saml.svg',
logo: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTAgMjAuMDk5QzAgMTcuNDYzMyAyLjUxNDM0IDcuNjMwMjggNy43MTI5MSAxLjA0MTE0QzQuMDQzMzMgNy45NjgxOCAxLjkwODcxIDEyLjY1ODQgMy40NjU3MSAxNi4zMTQ0QzQuNjg4OTEgMTkuMTg2NiAxMC45NzQ4IDE4LjI3NDMgMTQuMzcyNSAxNy4xMjU0QzguMzI0NTEgMTkuODYyNCAyLjExNzk0IDIwLjIzNDEgMCAyMC4wOTlaIiBmaWxsPSIjQzEyNzJEIi8+CjxwYXRoIGQ9Ik0xMS4yNjA1IDAuNzQ5ODc4QzEzLjU1NTYgMi4wNjc3MSAyMC44NjEzIDkuMTQ5NzEgMjQgMTYuOTIxNkMxOS44MDI1IDEwLjI5NzYgMTYuNzg1NSA2LjExNDA0IDEyLjgyMzMgNS42MjcwMUM5LjcxMDQ5IDUuMjQ0NCA3LjM2MjA1IDExLjExNDMgNi42NjM2NCAxNC42MTUxQzcuMzA0MTggOC4wMzc2OCAxMC4wODM4IDIuNTA2MzggMTEuMjYwNSAwLjc0OTg3OFoiIGZpbGw9IiNDMTI3MkQiLz4KPHBhdGggZD0iTTIyLjUzMTkgMjAuMDc1NUMyMC4yMzY4IDIxLjM5MzMgMTAuNDE2OCAyNC4xNDQzIDIuMDc5NTMgMjIuOTYxNkM5Ljk0NjU0IDIyLjY1ODUgMTUuMDk4MiAyMi4xNTE5IDE3LjUwMzQgMTguOTgyOUMxOS4zOTMgMTYuNDkzMyAxNS40NTU2IDExLjUzNTcgMTIuNzU2MiA5LjE4Mzg0QzE4LjE2MzcgMTMuMDI0MiAyMS41OTA3IDE4LjE4MzggMjIuNTMxOSAyMC4wNzU1WiIgZmlsbD0iI0MxMjcyRCIvPgo8L3N2Zz4K',
description: {
en: ' This connector is used to connect to SAML single sign-on identity provider.',
en: 'This connector is used to connect to SAML single sign-on identity provider.',
},
configGuard: samlConnectorConfigGuard,
constructor: SamlSsoConnector,

View file

@ -1,29 +1,40 @@
import { type I18nPhrases } from '@logto/connector-kit';
import { SsoProviderName } from '@logto/schemas';
import {
type AzureAdSsoConnector,
azureAdSsoConnectorFactory,
} from './AzureAdSsoConnector/index.js';
import {
type GoogleWorkspaceSsoConnector,
googleWorkSpaceSsoConnectorFactory,
type googleWorkspaceSsoConnectorConfigGuard,
} from './GoogleWorkspaceSsoConnector/index.js';
import { oidcSsoConnectorFactory, type OidcSsoConnector } from './OidcSsoConnector/index.js';
import { type SamlSsoConnector, samlSsoConnectorFactory } from './SamlSsoConnector/index.js';
import { type basicOidcConnectorConfigGuard } from './types/oidc.js';
import { type samlConnectorConfigGuard } from './types/saml.js';
type SingleSignOnConstructor<T extends SsoProviderName> = T extends SsoProviderName.OIDC
? typeof OidcSsoConnector
: T extends SsoProviderName.SAML
? typeof SamlSsoConnector
: never;
type SingleSignOnConstructor = {
[SsoProviderName.OIDC]: typeof OidcSsoConnector;
[SsoProviderName.SAML]: typeof SamlSsoConnector;
[SsoProviderName.AZURE_AD]: typeof AzureAdSsoConnector;
[SsoProviderName.GOOGLE_WORKSPACE]: typeof GoogleWorkspaceSsoConnector;
};
type SingleSignOnConnectorConfig<T extends SsoProviderName> = T extends SsoProviderName.OIDC
? typeof basicOidcConnectorConfigGuard
: T extends SsoProviderName.SAML
? typeof samlConnectorConfigGuard
: never;
type SingleSignOnConnectorConfig = {
[SsoProviderName.OIDC]: typeof basicOidcConnectorConfigGuard;
[SsoProviderName.SAML]: typeof samlConnectorConfigGuard;
[SsoProviderName.AZURE_AD]: typeof samlConnectorConfigGuard;
[SsoProviderName.GOOGLE_WORKSPACE]: typeof googleWorkspaceSsoConnectorConfigGuard;
};
export type SingleSignOnFactory<T extends SsoProviderName> = {
providerName: T;
logo: string;
description: I18nPhrases;
configGuard: SingleSignOnConnectorConfig<T>;
constructor: SingleSignOnConstructor<T>;
configGuard: SingleSignOnConnectorConfig[T];
constructor: SingleSignOnConstructor[T];
};
export const ssoConnectorFactories: {
@ -31,6 +42,8 @@ export const ssoConnectorFactories: {
} = {
[SsoProviderName.OIDC]: oidcSsoConnectorFactory,
[SsoProviderName.SAML]: samlSsoConnectorFactory,
[SsoProviderName.AZURE_AD]: azureAdSsoConnectorFactory,
[SsoProviderName.GOOGLE_WORKSPACE]: googleWorkSpaceSsoConnectorFactory,
};
export const standardSsoConnectorProviders = Object.freeze([

View file

@ -1,15 +1,19 @@
import { scopePostProcessor } from './oidc.js';
import { scopePostProcessor, RequiredOidcScope } from './oidc.js';
describe('scopePostProcessor', () => {
it('`openid` will be added if not exists (with empty string)', () => {
expect(scopePostProcessor('')).toEqual('openid');
const defaultScopes = Object.values(RequiredOidcScope);
it('`RequiredOidcScopes` will be added if not exists (with empty string)', () => {
for (const scope of defaultScopes) {
expect(scopePostProcessor('')).toContain(scope);
}
});
it('`openid` will be added if not exists (with non-empty string)', () => {
expect(scopePostProcessor('profile')).toEqual('profile openid');
});
it('return original input if openid exists', () => {
expect(scopePostProcessor('profile openid')).toEqual('profile openid');
it('`RequiredOidcScopes` will be added if not exists (with non-empty string)', () => {
const scopes = scopePostProcessor('read');
for (const scope of defaultScopes) {
expect(scopePostProcessor('read')).toContain(scope);
}
expect(scopes).toContain('read');
});
});

View file

@ -1,7 +1,12 @@
import { type CamelCaseKeys } from 'camelcase-keys';
import { z } from 'zod';
const openidScope = 'openid' as const;
export enum RequiredOidcScope {
OPEN_ID = 'openid',
PROFILE = 'profile',
EMAIL = 'email',
}
const scopeDelimiter = /[ +]/;
/**
* Scope config processor for OIDC connector. openid scope is required to retrieve id_token
@ -13,12 +18,10 @@ const scopeDelimiter = /[ +]/;
*/
export const scopePostProcessor = (scope = '') => {
const splitScopes = scope.split(scopeDelimiter).filter(Boolean);
const defaultScopes = Object.values(RequiredOidcScope);
const parsedScopes = new Set([...defaultScopes, ...splitScopes]);
if (!splitScopes.includes(openidScope)) {
return [...splitScopes, openidScope].join(' ');
}
return scope;
return Array.from(parsedScopes).join(' ');
};
export const basicOidcConnectorConfigGuard = z.object({
@ -48,7 +51,6 @@ export type BaseOidcConfig = CamelCaseKeys<OidcConfigResponse> & {
export const oidcAuthorizationResponseGuard = z.object({
code: z.string(),
state: z.string(),
});
export const oidcTokenResponseGuard = z.object({

View file

@ -18,13 +18,13 @@ export const getSingleSignOnConnectors = async (email: string) =>
export const getSingleSignOnUrl = async (
connectorId: string,
state: string,
callbackUri: string
redirectUri: string
) => {
const { redirectTo } = await api
.post(`${ssoPrefix}/${connectorId}/authorization-url`, {
json: {
state,
callbackUri,
redirectUri,
},
})
.json<Response>();

View file

@ -61,7 +61,7 @@ export const partialConfigAndProviderNames: Array<{
clientId: 'foo',
clientSecret: 'foo',
issuer: logtoIssuer,
scope: 'openid',
scope: 'openid profile email',
},
},
{

View file

@ -17,6 +17,8 @@ export type SsoConnectorMetadata = z.infer<typeof ssoConnectorMetadataGuard>;
export enum SsoProviderName {
OIDC = 'OIDC',
SAML = 'SAML',
AZURE_AD = 'AzureAD',
GOOGLE_WORKSPACE = 'GoogleWorkspace',
}
export type SupportedSsoConnector = Omit<SsoConnector, 'providerName'> & {