diff --git a/.changeset/cyan-garlics-tan.md b/.changeset/cyan-garlics-tan.md new file mode 100644 index 000000000000..5b1074e81b6b --- /dev/null +++ b/.changeset/cyan-garlics-tan.md @@ -0,0 +1,11 @@ +--- +"@logto/core": minor +"@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()` +- schemas: migrate sign-in experience types from core to schemas diff --git a/packages/core/src/libraries/sign-in-experience/index.ts b/packages/core/src/libraries/sign-in-experience/index.ts index 9b7d6e252dc5..63a2cb08b933 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 550857b8a3c9..000000000000 --- 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 149c3814d0f5..3c7a25dbf1ee 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 9d92d057261b..59f5276df2b5 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 ff14cc9cf3bf..0697cc8e057c 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 38745f21984f..071181331865 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 89b277751f7f..0d74df738898 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.ts b/packages/core/src/routes/well-known.ts index 6e06daa2cd6f..8578d448f7d8 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/schemas/src/types/index.ts b/packages/schemas/src/types/index.ts index 72c1058bf428..5374430c77a7 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 000000000000..0a44b55b8c6c --- /dev/null +++ b/packages/schemas/src/types/sign-in-experience.ts @@ -0,0 +1,59 @@ +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, + '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({ + 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;