From 942780fcfa48c527efc95f2183c636d908fcc4f7 Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 16 Jun 2024 14:31:33 +0800 Subject: [PATCH 1/2] feat(core): google one tap --- .changeset/cyan-garlics-tan.md | 13 ++++ .../sign-in-experience/index.test.ts | 1 + .../src/libraries/sign-in-experience/index.ts | 40 +++++++++++-- .../src/libraries/sign-in-experience/types.ts | 30 ---------- packages/core/src/libraries/social.ts | 4 +- .../src/middleware/koa-security-headers.ts | 8 ++- packages/core/src/routes/interaction/index.ts | 3 - .../utils/social-verification.test.ts | 6 +- .../interaction/utils/social-verification.ts | 13 +++- packages/core/src/routes/well-known.test.ts | 5 +- packages/core/src/routes/well-known.ts | 3 +- .../phrases/src/locales/en/errors/session.ts | 1 + packages/schemas/src/types/index.ts | 1 + .../schemas/src/types/sign-in-experience.ts | 60 +++++++++++++++++++ 14 files changed, 138 insertions(+), 50 deletions(-) create mode 100644 .changeset/cyan-garlics-tan.md delete mode 100644 packages/core/src/libraries/sign-in-experience/types.ts create mode 100644 packages/schemas/src/types/sign-in-experience.ts diff --git a/.changeset/cyan-garlics-tan.md b/.changeset/cyan-garlics-tan.md new file mode 100644 index 00000000000..55e977fbf8a --- /dev/null +++ b/.changeset/cyan-garlics-tan.md @@ -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 diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index 52bb8265369..36552005997 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -167,6 +167,7 @@ describe('getFullSignInExperience()', () => { }, ], isDevelopmentTenant: false, + googleOneTap: undefined, }); }); }); diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 9b7d6e252dc..63a2cb08b93 100644 --- a/packages/core/src/libraries/sign-in-experience/index.ts +++ b/packages/core/src/libraries/sign-in-experience/index.ts @@ -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[] >((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(), }; }; diff --git a/packages/core/src/libraries/sign-in-experience/types.ts b/packages/core/src/libraries/sign-in-experience/types.ts deleted file mode 100644 index 550857b8a3c..00000000000 --- a/packages/core/src/libraries/sign-in-experience/types.ts +++ /dev/null @@ -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 = - 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(), - }); diff --git a/packages/core/src/libraries/social.ts b/packages/core/src/libraries/social.ts index 149c3814d0f..3c7a25dbf1e 100644 --- a/packages/core/src/libraries/social.ts +++ b/packages/core/src/libraries/social.ts @@ -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, }; diff --git a/packages/core/src/middleware/koa-security-headers.ts b/packages/core/src/middleware/koa-security-headers.ts index 9d92d057261..59f5276df2b 100644 --- a/packages/core/src/middleware/koa-security-headers.ts +++ b/packages/core/src/middleware/koa-security-headers.ts @@ -38,6 +38,8 @@ export default function koaSecurityHeaders( 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( 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], }, }, }; diff --git a/packages/core/src/routes/interaction/index.ts b/packages/core/src/routes/interaction/index.ts index ff14cc9cf3b..0697cc8e057 100644 --- a/packages/core/src/routes/interaction/index.ts +++ b/packages/core/src/routes/interaction/index.ts @@ -198,11 +198,8 @@ export default function interactionRoutes( ); log.append({ identifier: verifiedIdentifier, interactionStorage }); - const identifiers = mergeIdentifiers(verifiedIdentifier, interactionStorage.identifiers); - await storeInteractionResult({ identifiers }, ctx, provider, true); - ctx.status = 204; return next(); diff --git a/packages/core/src/routes/interaction/utils/social-verification.test.ts b/packages/core/src/routes/interaction/utils/social-verification.test.ts index 38745f21984..07118133186 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.test.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.test.ts @@ -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', () => ({ @@ -38,7 +38,7 @@ 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' }); }); }); diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 89b277751f7..0d74df73889 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -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,20 @@ export const verifySocialIdentity = async ( { provider, libraries }: TenantContext ): Promise => { 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 Google One Tap CSRF token, if it exists + const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken]; + if (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) ); diff --git a/packages/core/src/routes/well-known.test.ts b/packages/core/src/routes/well-known.test.ts index be0fa554fab..f1f6432ebd5 100644 --- a/packages/core/src/routes/well-known.test.ts +++ b/packages/core/src/routes/well-known.test.ts @@ -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: [], }); }); diff --git a/packages/core/src/routes/well-known.ts b/packages/core/src/routes/well-known.ts index 6e06daa2cd6..8578d448f7d 100644 --- a/packages/core/src/routes/well-known.ts +++ b/packages/core/src/routes/well-known.ts @@ -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'; diff --git a/packages/phrases/src/locales/en/errors/session.ts b/packages/phrases/src/locales/en/errors/session.ts index f3d09fcaf3a..cdc4cb259a9 100644 --- a/packages/phrases/src/locales/en/errors/session.ts +++ b/packages/phrases/src/locales/en/errors/session.ts @@ -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.', diff --git a/packages/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 72c1058bf42..5374430c77a 100644 --- a/packages/schemas/src/types/index.ts +++ b/packages/schemas/src/types/index.ts @@ -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'; diff --git a/packages/schemas/src/types/sign-in-experience.ts b/packages/schemas/src/types/sign-in-experience.ts new file mode 100644 index 00000000000..9f6bf95ebb1 --- /dev/null +++ b/packages/schemas/src/types/sign-in-experience.ts @@ -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; From 552a3e59db6295d7f67018dc25e2cf0461bcc48b Mon Sep 17 00:00:00 2001 From: Gao Sun Date: Sun, 16 Jun 2024 22:02:13 +0800 Subject: [PATCH 2/2] chore: add tests --- packages/core/src/__mocks__/connector.ts | 20 ++++----- .../sign-in-experience/index.test.ts | 44 +++++++++++++++++++ .../utils/social-verification.test.ts | 39 ++++++++++++++-- .../interaction/utils/social-verification.ts | 10 +++-- 4 files changed, 96 insertions(+), 17 deletions(-) diff --git a/packages/core/src/__mocks__/connector.ts b/packages/core/src/__mocks__/connector.ts index ee0a0239184..325a085ec67 100644 --- a/packages/core/src/__mocks__/connector.ts +++ b/packages/core/src/__mocks__/connector.ts @@ -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'; diff --git a/packages/core/src/libraries/sign-in-experience/index.test.ts b/packages/core/src/libraries/sign-in-experience/index.test.ts index 36552005997..4c43db6f8a3 100644 --- a/packages/core/src/libraries/sign-in-experience/index.test.ts +++ b/packages/core/src/libraries/sign-in-experience/index.test.ts @@ -3,6 +3,8 @@ import { builtInLanguages } from '@logto/phrases-experience'; import type { CreateSignInExperience, SignInExperience } from '@logto/schemas'; import { + mockGithubConnector, + mockGoogleConnector, mockSignInExperience, mockSocialConnectors, socialTarget01, @@ -170,6 +172,48 @@ describe('getFullSignInExperience()', () => { 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', + }, + }); + }); }); describe('get sso connectors', () => { diff --git a/packages/core/src/routes/interaction/utils/social-verification.test.ts b/packages/core/src/routes/interaction/utils/social-verification.test.ts index 07118133186..7df4a16a61c 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.test.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.test.ts @@ -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'; @@ -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(), @@ -41,4 +41,37 @@ describe('social-verification', () => { 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' }); + }); }); diff --git a/packages/core/src/routes/interaction/utils/social-verification.ts b/packages/core/src/routes/interaction/utils/social-verification.ts index 0d74df73889..83a000e732b 100644 --- a/packages/core/src/routes/interaction/utils/social-verification.ts +++ b/packages/core/src/routes/interaction/utils/social-verification.ts @@ -63,9 +63,13 @@ export const verifySocialIdentity = async ( const log = ctx.createLog('Interaction.SignIn.Identifier.Social.Submit'); log.append({ connectorId, connectorData }); - // Verify Google One Tap CSRF token, if it exists - const csrfToken = connectorData[GoogleConnector.oneTapParams.csrfToken]; - if (csrfToken) { + // 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'); }