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:
parent
6308ee1857
commit
942780fcfa
14 changed files with 138 additions and 50 deletions
13
.changeset/cyan-garlics-tan.md
Normal file
13
.changeset/cyan-garlics-tan.md
Normal 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
|
|
@ -167,6 +167,7 @@ describe('getFullSignInExperience()', () => {
|
|||
},
|
||||
],
|
||||
isDevelopmentTenant: false,
|
||||
googleOneTap: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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(),
|
||||
});
|
|
@ -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,
|
||||
};
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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' });
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
|
||||
|
|
|
@ -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: [],
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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.',
|
||||
|
|
|
@ -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';
|
||||
|
|
60
packages/schemas/src/types/sign-in-experience.ts
Normal file
60
packages/schemas/src/types/sign-in-experience.ts
Normal 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>;
|
Loading…
Reference in a new issue