0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-30 20:33:54 -05:00

feat(core): google one tap

This commit is contained in:
Gao Sun 2024-06-16 14:31:33 +08:00
parent 6308ee1857
commit 942780fcfa
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
14 changed files with 138 additions and 50 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

@ -167,6 +167,7 @@ describe('getFullSignInExperience()', () => {
},
],
isDevelopmentTenant: false,
googleOneTap: undefined,
});
});
});

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

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

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

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