0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-10 22:22:45 -05:00

Merge pull request #6031 from logto-io/gao-google-one-tap-core

This commit is contained in:
Gao Sun 2024-06-18 09:59:32 +08:00 committed by GitHub
commit d9119b56ec
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 231 additions and 64 deletions

View file

@ -0,0 +1,13 @@
---
"@logto/core": minor
"@logto/phrases": patch
"@logto/schemas": patch
---
support Google One Tap
- core: `GET /api/.well-known/sign-in-exp` now returns `googleOneTap` field with the configuration when available
- core: add Google Sign-In (GSI) url to the security headers
- core: verify Google One Tap CSRF token in `verifySocialIdentity()`
- phrases: add Google One Tap phrases
- schemas: migrate sign-in experience types from core to schemas

View file

@ -212,10 +212,18 @@ export const mockGoogleConnector: LogtoConnector = {
dbEntry: {
...mockConnector,
id: 'google',
config: {
clientId: 'fake_client_id',
clientSecret: 'fake_client_secret',
oneTap: {
isEnabled: true,
autoSelect: true,
},
},
},
metadata: {
...mockMetadata,
id: 'google',
id: 'google-universal',
target: 'google',
platform: ConnectorPlatform.Web,
},
@ -238,16 +246,6 @@ export const mockDemoSocialConnector: LogtoConnector = {
...mockLogtoConnector,
};
export const mockLogtoConnectors = [
mockAliyunDmConnector,
mockAliyunSmsConnector,
mockFacebookConnector,
mockGithubConnector,
mockGoogleConnector,
mockWechatConnector,
mockWechatNativeConnector,
];
export const socialTarget01 = 'socialTarget-id01';
export const socialTarget02 = 'socialTarget-id02';

View file

@ -3,6 +3,8 @@ import { builtInLanguages } from '@logto/phrases-experience';
import type { CreateSignInExperience, SignInExperience } from '@logto/schemas';
import {
mockGithubConnector,
mockGoogleConnector,
mockSignInExperience,
mockSocialConnectors,
socialTarget01,
@ -167,6 +169,49 @@ describe('getFullSignInExperience()', () => {
},
],
isDevelopmentTenant: false,
googleOneTap: undefined,
});
});
it('should return full sign-in experience with google one tap', async () => {
findDefaultSignInExperience.mockResolvedValueOnce({
...mockSignInExperience,
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
});
getLogtoConnectors.mockResolvedValueOnce([mockGoogleConnector, mockGithubConnector]);
ssoConnectorLibrary.getAvailableSsoConnectors.mockResolvedValueOnce([
wellConfiguredSsoConnector,
]);
const fullSignInExperience = await getFullSignInExperience('en');
const connectorFactory = ssoConnectorFactories[wellConfiguredSsoConnector.providerName];
expect(fullSignInExperience).toStrictEqual({
...mockSignInExperience,
socialConnectors: [
{ ...mockGithubConnector.metadata, id: mockGithubConnector.dbEntry.id },
{ ...mockGoogleConnector.metadata, id: mockGoogleConnector.dbEntry.id },
],
socialSignInConnectorTargets: ['github', 'facebook', 'google'],
forgotPassword: {
email: false,
phone: false,
},
ssoConnectors: [
{
id: wellConfiguredSsoConnector.id,
connectorName: connectorFactory.name.en,
logo: connectorFactory.logo,
darkLogo: connectorFactory.logoDark,
},
],
isDevelopmentTenant: false,
googleOneTap: {
isEnabled: true,
autoSelect: true,
clientId: 'fake_client_id',
connectorId: 'google',
},
});
});
});

View file

@ -1,5 +1,11 @@
import { GoogleConnector } from '@logto/connector-kit';
import { builtInLanguages } from '@logto/phrases-experience';
import type { ConnectorMetadata, LanguageInfo, SsoConnectorMetadata } from '@logto/schemas';
import type {
ConnectorMetadata,
FullSignInExperience,
LanguageInfo,
SsoConnectorMetadata,
} from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import { deduplicate } from '@silverhand/essentials';
@ -15,8 +21,6 @@ import { isKeyOfI18nPhrases } from '#src/utils/translation.js';
import { type CloudConnectionLibrary } from '../cloud-connection.js';
import { type FullSignInExperience } from './types.js';
export * from './sign-up.js';
export * from './sign-in.js';
@ -123,7 +127,7 @@ export const createSignInExperienceLibrary = (
};
const socialConnectors = signInExperience.socialSignInConnectorTargets.reduce<
Array<ConnectorMetadata & { id: string }>
ConnectorMetadata[]
>((previous, connectorTarget) => {
const connectors = logtoConnectors.filter(
({ metadata: { target } }) => target === connectorTarget
@ -135,12 +139,40 @@ export const createSignInExperienceLibrary = (
];
}, []);
/**
* Get the Google One Tap configuration if the Google connector is enabled and configured.
*/
const getGoogleOneTap = (): FullSignInExperience['googleOneTap'] => {
const googleConnector =
signInExperience.socialSignInConnectorTargets.includes(GoogleConnector.target) &&
logtoConnectors.find(({ metadata }) => metadata.id === GoogleConnector.factoryId);
if (!googleConnector) {
return;
}
const googleConnectorConfig = GoogleConnector.configGuard.safeParse(
googleConnector.dbEntry.config
);
if (!googleConnectorConfig.success) {
return;
}
return {
...googleConnectorConfig.data.oneTap,
clientId: googleConnectorConfig.data.clientId,
connectorId: googleConnector.dbEntry.id,
};
};
return {
...signInExperience,
socialConnectors,
ssoConnectors,
forgotPassword,
isDevelopmentTenant,
googleOneTap: getGoogleOneTap(),
};
};

View file

@ -1,30 +0,0 @@
import { connectorMetadataGuard, type ConnectorMetadata } from '@logto/connector-kit';
import {
type SignInExperience,
SignInExperiences,
type SsoConnectorMetadata,
ssoConnectorMetadataGuard,
} from '@logto/schemas';
import { z } from 'zod';
type ForgotPassword = {
phone: boolean;
email: boolean;
};
type ConnectorMetadataWithId = ConnectorMetadata & { id: string };
export type FullSignInExperience = SignInExperience & {
socialConnectors: ConnectorMetadataWithId[];
ssoConnectors: SsoConnectorMetadata[];
forgotPassword: ForgotPassword;
isDevelopmentTenant: boolean;
};
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() }),
isDevelopmentTenant: z.boolean(),
});

View file

@ -55,7 +55,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
}
};
const getUserInfoByAuthCode = async (
const getUserInfo = async (
connectorId: string,
data: unknown,
getConnectorSession: GetSession
@ -105,7 +105,7 @@ export const createSocialLibrary = (queries: Queries, connectorLibrary: Connecto
return {
getConnector,
getUserInfoByAuthCode,
getUserInfo,
getUserInfoFromInteractionResult,
findSocialRelatedUser,
};

View file

@ -38,6 +38,8 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
const coreOrigins = urlSet.origins;
const developmentOrigins = conditionalArray(!isProduction && 'ws:');
const logtoOrigin = 'https://*.logto.io';
/** Google Sign-In (GSI) origin for Google One Tap. */
const gsiOrigin = 'https://accounts.google.com/gsi/';
// We use react-monaco-editor for code editing in the admin console. It loads the monaco editor asynchronously from a CDN.
// Allow the CDN src in the CSP.
@ -90,13 +92,15 @@ export default function koaSecurityHeaders<StateT, ContextT, ResponseBodyT>(
scriptSrc: [
"'self'",
"'unsafe-inline'",
`${gsiOrigin}client`,
...conditionalArray(!isProduction && "'unsafe-eval'"),
],
connectSrc: ["'self'", tenantEndpointOrigin, ...developmentOrigins],
connectSrc: ["'self'", gsiOrigin, tenantEndpointOrigin, ...developmentOrigins],
// WARNING: high risk Need to allow self hosted terms of use page loaded in an iframe
frameSrc: ["'self'", 'https:'],
frameSrc: ["'self'", 'https:', gsiOrigin],
// Alow loaded by console preview iframe
frameAncestors: ["'self'", ...adminOrigins],
defaultSrc: ["'self'", gsiOrigin],
},
},
};

View file

@ -198,11 +198,8 @@ export default function interactionRoutes<T extends AnonymousRouter>(
);
log.append({ identifier: verifiedIdentifier, interactionStorage });
const identifiers = mergeIdentifiers(verifiedIdentifier, interactionStorage.identifiers);
await storeInteractionResult({ identifiers }, ctx, provider, true);
ctx.status = 204;
return next();

View file

@ -1,4 +1,4 @@
import { ConnectorType } from '@logto/connector-kit';
import { ConnectorType, GoogleConnector } from '@logto/connector-kit';
import { createMockUtils } from '@logto/shared/esm';
import type { WithLogContext } from '#src/middleware/koa-audit-log.js';
@ -9,10 +9,10 @@ import { MockTenant } from '#src/test-utils/tenant.js';
const { jest } = import.meta;
const { mockEsm } = createMockUtils(jest);
const getUserInfoByAuthCode = jest.fn().mockResolvedValue({ id: 'foo' });
const getUserInfo = jest.fn().mockResolvedValue({ id: 'foo' });
const tenant = new MockTenant(undefined, undefined, undefined, {
socials: { getUserInfoByAuthCode },
socials: { getUserInfo },
});
mockEsm('#src/libraries/connector.js', () => ({
@ -27,8 +27,8 @@ mockEsm('#src/libraries/connector.js', () => ({
const { verifySocialIdentity } = await import('./social-verification.js');
describe('social-verification', () => {
it('verifySocialIdentity', async () => {
describe('verifySocialIdentity', () => {
it('should verify social identity', async () => {
// @ts-expect-error test mock context
const ctx: WithLogContext = {
...createMockContext(),
@ -38,7 +38,40 @@ describe('social-verification', () => {
const connectorData = { authCode: 'code' };
const userInfo = await verifySocialIdentity({ connectorId, connectorData }, ctx, tenant);
expect(getUserInfoByAuthCode).toBeCalledWith(connectorId, connectorData, expect.anything());
expect(getUserInfo).toBeCalledWith(connectorId, connectorData, expect.anything());
expect(userInfo).toEqual({ id: 'foo' });
});
it('should throw error if csrf token is not matched for Google One Tap verification', async () => {
const ctx: WithLogContext = {
...createMockContext(),
...createMockLogContext(),
// @ts-expect-error test mock context
cookies: { get: jest.fn().mockReturnValue('token') },
};
const connectorId = GoogleConnector.factoryId;
const connectorData = { credential: 'credential' };
await expect(verifySocialIdentity({ connectorId, connectorData }, ctx, tenant)).rejects.toThrow(
'CSRF token mismatch.'
);
});
it('should verify Google One Tap verification', async () => {
const ctx: WithLogContext = {
...createMockContext(),
...createMockLogContext(),
// @ts-expect-error test mock context
cookies: { get: jest.fn().mockReturnValue('token') },
};
const connectorId = GoogleConnector.factoryId;
const connectorData = {
[GoogleConnector.oneTapParams.credential]: 'credential',
[GoogleConnector.oneTapParams.csrfToken]: 'token',
};
await expect(
verifySocialIdentity({ connectorId, connectorData }, ctx, tenant)
).resolves.toEqual({ id: 'foo' });
});
});

View file

@ -1,5 +1,5 @@
import type { ConnectorSession, SocialUserInfo } from '@logto/connector-kit';
import { connectorSessionGuard } from '@logto/connector-kit';
import { connectorSessionGuard, GoogleConnector } from '@logto/connector-kit';
import type { SocialConnectorPayload } from '@logto/schemas';
import { ConnectorType } from '@logto/schemas';
import type { Context } from 'koa';
@ -57,13 +57,24 @@ export const verifySocialIdentity = async (
{ provider, libraries }: TenantContext
): Promise<SocialUserInfo> => {
const {
socials: { getUserInfoByAuthCode },
socials: { getUserInfo },
} = libraries;
const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit');
log.append({ connectorId, connectorData });
const userInfo = await getUserInfoByAuthCode(connectorId, connectorData, async () =>
// Verify the CSRF token if it's a Google connector and has credential (a Google One Tap
// verification)
if (
connectorId === GoogleConnector.factoryId &&
connectorData[GoogleConnector.oneTapParams.credential]
) {
const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken];
const value = ctx.cookies.get(GoogleConnector.oneTapParams.csrfToken);
assertThat(value === csrfToken, 'session.csrf_token_mismatch');
}
const userInfo = await getUserInfo(connectorId, connectorData, async () =>
getConnectorSessionResult(ctx, provider)
);

View file

@ -89,7 +89,10 @@ describe('GET /.well-known/sign-in-exp', () => {
...mockWechatNativeConnector.metadata,
id: mockWechatNativeConnector.dbEntry.id,
},
],
].map(
// Omits fields to match the `ExperienceSocialConnector` type
({ description, configTemplate, formItems, readme, customData, ...metadata }) => metadata
),
ssoConnectors: [],
});
});

View file

@ -1,11 +1,10 @@
import { isBuiltInLanguageTag } from '@logto/phrases-experience';
import { adminTenantId } from '@logto/schemas';
import { adminTenantId, guardFullSignInExperience } from '@logto/schemas';
import { conditionalArray } from '@silverhand/essentials';
import { z } from 'zod';
import { EnvSet, getTenantEndpoint } from '#src/env-set/index.js';
import detectLanguage from '#src/i18n/detect-language.js';
import { guardFullSignInExperience } from '#src/libraries/sign-in-experience/types.js';
import koaGuard from '#src/middleware/koa-guard.js';
import type { AnonymousRouter, RouterInitArgs } from './types.js';

View file

@ -18,6 +18,7 @@ const session = {
'The verification was not successful. Restart the verification flow and try again.',
connector_validation_session_not_found:
'The connector session for token validation is not found.',
csrf_token_mismatch: 'CSRF token mismatch.',
identifier_not_found: 'User identifier not found. Please go back and sign in again.',
interaction_not_found:
'Interaction session not found. Please go back and start the session again.',

View file

@ -27,3 +27,4 @@ export * from './tenant-organization.js';
export * from './mapi-proxy.js';
export * from './consent.js';
export * from './onboarding.js';
export * from './sign-in-experience.js';

View file

@ -0,0 +1,60 @@
import {
connectorMetadataGuard,
type ConnectorMetadata,
type GoogleOneTapConfig,
googleOneTapConfigGuard,
} from '@logto/connector-kit';
import { z } from 'zod';
import { type SignInExperience, SignInExperiences } from '../db-entries/index.js';
import { type ToZodObject } from '../utils/zod.js';
import { type SsoConnectorMetadata, ssoConnectorMetadataGuard } from './sso-connector.js';
type ForgotPassword = {
phone: boolean;
email: boolean;
};
/**
* Basic information about a social connector for sign-in experience rendering. This type can avoid
* the need to load the full connector metadata that is not needed for rendering.
*/
export type ExperienceSocialConnector = Omit<
ConnectorMetadata,
'description' | 'configTemplate' | 'formItems' | 'readme' | 'customData'
>;
export type FullSignInExperience = SignInExperience & {
socialConnectors: ExperienceSocialConnector[];
ssoConnectors: SsoConnectorMetadata[];
forgotPassword: ForgotPassword;
isDevelopmentTenant: boolean;
/**
* The Google One Tap configuration if the Google connector is enabled and configured.
*
* @remarks
* We need to use a standalone property for the Google One Tap configuration because it needs
* data from database entries that other connectors don't need. Thus we manually extract the
* minimal data needed here.
*/
googleOneTap?: GoogleOneTapConfig & { clientId: string; connectorId: string };
};
export const guardFullSignInExperience = SignInExperiences.guard.extend({
socialConnectors: connectorMetadataGuard
.omit({
description: true,
configTemplate: true,
formItems: true,
readme: true,
customData: true,
})
.array(),
ssoConnectors: ssoConnectorMetadataGuard.array(),
forgotPassword: z.object({ phone: z.boolean(), email: z.boolean() }),
isDevelopmentTenant: z.boolean(),
googleOneTap: googleOneTapConfigGuard
.extend({ clientId: z.string(), connectorId: z.string() })
.optional(),
}) satisfies ToZodObject<FullSignInExperience>;