0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-01-13 21:30:30 -05:00

feat(experience): google one tap

This commit is contained in:
Gao Sun 2024-06-16 14:50:00 +08:00
parent 942780fcfa
commit 50c35a2143
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
13 changed files with 116 additions and 53 deletions

View file

@ -0,0 +1,8 @@
---
"@logto/experience": minor
---
support Google One Tap
- Conditionally load Google One Tap script if it's enabled in the config.
- Support callback from Google One Tap.

View file

@ -153,7 +153,7 @@ export const getSocialAuthorizationUrl = async (
state: string, state: string,
redirectUri: string redirectUri: string
) => { ) => {
await api.put(`${interactionPrefix}`, { json: { event: InteractionEvent.SignIn } }); await putInteraction(InteractionEvent.SignIn);
return api return api
.post(`${interactionPrefix}/${verificationPath}/social-authorization-uri`, { .post(`${interactionPrefix}/${verificationPath}/social-authorization-uri`, {

View file

@ -0,0 +1,43 @@
import { useContext } from 'react';
import { createPortal } from 'react-dom';
import { Helmet } from 'react-helmet';
import PageContext from '@/Providers/PageContextProvider/PageContext';
type Props = {
/** @see {@link https://developers.google.com/identity/gsi/web/reference/html-reference#data-context | Sign In With Google HTML API reference} */
readonly context: 'signin' | 'signup';
};
/**
* A component that renders the Google One Tap script if it is enabled in the experience settings.
*/
const GoogleOneTap = ({ context }: Props) => {
const { experienceSettings } = useContext(PageContext);
if (!experienceSettings?.googleOneTap?.isEnabled) {
return null;
}
return (
<>
<Helmet>
<script async src="https://accounts.google.com/gsi/client" />
</Helmet>
{createPortal(
<div
id="g_id_onload"
data-client_id={experienceSettings.googleOneTap.clientId}
data-context={context}
data-login_uri={`${window.location.origin}/callback/${experienceSettings.googleOneTap.connectorId}`}
data-auto_select={Boolean(experienceSettings.googleOneTap.autoSelect)}
data-cancel_on_tap_outside={Boolean(experienceSettings.googleOneTap.closeOnTapOutside)}
data-itp_support={Boolean(experienceSettings.googleOneTap.itpSupport)}
/>,
document.body
)}
</>
);
};
export default GoogleOneTap;

View file

@ -1,4 +1,4 @@
import type { ConnectorMetadata } from '@logto/schemas'; import type { ExperienceSocialConnector } from '@logto/schemas';
import classNames from 'classnames'; import classNames from 'classnames';
import SocialLinkButton from '@/components/Button/SocialLinkButton'; import SocialLinkButton from '@/components/Button/SocialLinkButton';
@ -10,7 +10,7 @@ import useSocial from './use-social';
type Props = { type Props = {
readonly className?: string; readonly className?: string;
readonly socialConnectors?: ConnectorMetadata[]; readonly socialConnectors?: ExperienceSocialConnector[];
}; };
const SocialSignInList = ({ className, socialConnectors = [] }: Props) => { const SocialSignInList = ({ className, socialConnectors = [] }: Props) => {

View file

@ -1,4 +1,4 @@
import { ConnectorPlatform, type ConnectorMetadata } from '@logto/schemas'; import { ConnectorPlatform, type ExperienceSocialConnector } from '@logto/schemas';
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from 'react';
import PageContext from '@/Providers/PageContextProvider/PageContext'; import PageContext from '@/Providers/PageContextProvider/PageContext';
@ -14,22 +14,25 @@ const useSocial = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl); const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
const nativeSignInHandler = useCallback((redirectTo: string, connector: ConnectorMetadata) => { const nativeSignInHandler = useCallback(
const { id: connectorId, platform } = connector; (redirectTo: string, connector: ExperienceSocialConnector) => {
const { id: connectorId, platform } = connector;
const redirectUri = const redirectUri =
platform === ConnectorPlatform.Universal platform === ConnectorPlatform.Universal
? buildSocialLandingUri(`/social/landing/${connectorId}`, redirectTo).toString() ? buildSocialLandingUri(`/social/landing/${connectorId}`, redirectTo).toString()
: redirectTo; : redirectTo;
getLogtoNativeSdk()?.getPostMessage()({ getLogtoNativeSdk()?.getPostMessage()({
callbackUri: `${window.location.origin}/callback/social/${connectorId}`, callbackUri: `${window.location.origin}/callback/social/${connectorId}`,
redirectTo: redirectUri, redirectTo: redirectUri,
}); });
}, []); },
[]
);
const invokeSocialSignInHandler = useCallback( const invokeSocialSignInHandler = useCallback(
async (connector: ConnectorMetadata) => { async (connector: ExperienceSocialConnector) => {
const { id: connectorId } = connector; const { id: connectorId } = connector;
const state = generateState(); const state = generateState();

View file

@ -1,8 +1,4 @@
import { import { type ExperienceSocialConnector, Theme, type SsoConnectorMetadata } from '@logto/schemas';
Theme,
type ConnectorMetadata as SocialConnectorMetadata,
type SsoConnectorMetadata,
} from '@logto/schemas';
import { type Optional } from '@silverhand/essentials'; import { type Optional } from '@silverhand/essentials';
import { useCallback, useContext } from 'react'; import { useCallback, useContext } from 'react';
@ -12,7 +8,7 @@ import { useSieMethods } from './use-sie';
type FindConnectorByIdResult = type FindConnectorByIdResult =
| { | {
connector: SocialConnectorMetadata; connector: ExperienceSocialConnector;
type: 'social'; type: 'social';
} }
| { | {

View file

@ -7,6 +7,7 @@ import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
import Divider from '@/components/Divider'; import Divider from '@/components/Divider';
import GoogleOneTap from '@/components/GoogleOneTap';
import TextLink from '@/components/TextLink'; import TextLink from '@/components/TextLink';
import SocialSignInList from '@/containers/SocialSignInList'; import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox'; import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
@ -75,6 +76,7 @@ const Register = () => {
return ( return (
<LandingPageLayout title="description.create_your_account"> <LandingPageLayout title="description.create_your_account">
<GoogleOneTap context="signup" />
<SingleSignOnFormModeContextProvider> <SingleSignOnFormModeContextProvider>
{signUpMethods.length > 0 && ( {signUpMethods.length > 0 && (
<IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} /> <IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} />
@ -88,7 +90,6 @@ const Register = () => {
)} )}
<RegisterFooter /> <RegisterFooter />
</SingleSignOnFormModeContextProvider> </SingleSignOnFormModeContextProvider>
{/* Hide footer elements when showing Single Sign On form */} {/* Hide footer elements when showing Single Sign On form */}
</LandingPageLayout> </LandingPageLayout>
); );

View file

@ -1,4 +1,4 @@
import type { SignIn, ConnectorMetadata } from '@logto/schemas'; import type { SignIn, ExperienceSocialConnector } from '@logto/schemas';
import SocialSignInList from '@/containers/SocialSignInList'; import SocialSignInList from '@/containers/SocialSignInList';
@ -8,7 +8,7 @@ import * as styles from './index.module.scss';
type Props = { type Props = {
readonly signInMethods: SignIn['methods']; readonly signInMethods: SignIn['methods'];
readonly socialConnectors: ConnectorMetadata[]; readonly socialConnectors: ExperienceSocialConnector[];
}; };
const Main = ({ signInMethods, socialConnectors }: Props) => { const Main = ({ signInMethods, socialConnectors }: Props) => {

View file

@ -7,6 +7,7 @@ import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider'; import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext'; import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
import Divider from '@/components/Divider'; import Divider from '@/components/Divider';
import GoogleOneTap from '@/components/GoogleOneTap';
import TextLink from '@/components/TextLink'; import TextLink from '@/components/TextLink';
import SocialSignInList from '@/containers/SocialSignInList'; import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks'; import TermsAndPrivacyLinks from '@/containers/TermsAndPrivacyLinks';
@ -76,11 +77,11 @@ const SignIn = () => {
return ( return (
<LandingPageLayout title="description.sign_in_to_your_account"> <LandingPageLayout title="description.sign_in_to_your_account">
<GoogleOneTap context="signin" />
<SingleSignOnFormModeContextProvider> <SingleSignOnFormModeContextProvider>
<Main signInMethods={signInMethods} socialConnectors={socialConnectors} /> <Main signInMethods={signInMethods} socialConnectors={socialConnectors} />
<SignInFooters /> <SignInFooters />
</SingleSignOnFormModeContextProvider> </SingleSignOnFormModeContextProvider>
<TermsAndPrivacyLinks className={styles.terms} /> <TermsAndPrivacyLinks className={styles.terms} />
</LandingPageLayout> </LandingPageLayout>
); );

View file

@ -11,7 +11,7 @@ import { useSieMethods } from '@/hooks/use-sie';
import useTerms from '@/hooks/use-terms'; import useTerms from '@/hooks/use-terms';
import useToast from '@/hooks/use-toast'; import useToast from '@/hooks/use-toast';
import { parseQueryParameters } from '@/utils'; import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors'; import { validateState } from '@/utils/social-connectors';
const useSingleSignOnRegister = () => { const useSingleSignOnRegister = () => {
const handleError = useErrorHandler(); const handleError = useErrorHandler();
@ -128,7 +128,7 @@ const useSingleSignOnListener = (connectorId: string) => {
setSearchParameters({}, { replace: true }); setSearchParameters({}, { replace: true });
// Validate the state parameter // Validate the state parameter
if (!state || !stateValidation(state, connectorId)) { if (!validateState(state, connectorId)) {
setToast(t('error.invalid_connector_auth')); setToast(t('error.invalid_connector_auth'));
navigate('/' + experience.routes.signIn); navigate('/' + experience.routes.signIn);
return; return;

View file

@ -1,11 +1,12 @@
import { GoogleConnector } from '@logto/connector-kit';
import type { RequestErrorBody } from '@logto/schemas'; import type { RequestErrorBody } from '@logto/schemas';
import { SignInMode, experience } from '@logto/schemas'; import { InteractionEvent, SignInMode, experience } from '@logto/schemas';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { validate } from 'superstruct'; import { validate } from 'superstruct';
import { signInWithSocial } from '@/apis/interaction'; import { putInteraction, signInWithSocial } from '@/apis/interaction';
import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user'; import useBindSocialRelatedUser from '@/containers/SocialLinkAccount/use-social-link-related-user';
import useApi from '@/hooks/use-api'; import useApi from '@/hooks/use-api';
import type { ErrorHandlers } from '@/hooks/use-error-handler'; import type { ErrorHandlers } from '@/hooks/use-error-handler';
@ -17,7 +18,7 @@ import useTerms from '@/hooks/use-terms';
import useToast from '@/hooks/use-toast'; import useToast from '@/hooks/use-toast';
import { socialAccountNotExistErrorDataGuard } from '@/types/guard'; import { socialAccountNotExistErrorDataGuard } from '@/types/guard';
import { parseQueryParameters } from '@/utils'; import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors'; import { validateGoogleOneTapCsrfToken, validateState } from '@/utils/social-connectors';
const useSocialSignInListener = (connectorId: string) => { const useSocialSignInListener = (connectorId: string) => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -33,6 +34,7 @@ const useSocialSignInListener = (connectorId: string) => {
const bindSocialRelatedUser = useBindSocialRelatedUser(); const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId, true); const registerWithSocial = useSocialRegister(connectorId, true);
const asyncSignInWithSocial = useApi(signInWithSocial); const asyncSignInWithSocial = useApi(signInWithSocial);
const asyncPutInteraction = useApi(putInteraction);
const accountNotExistErrorHandler = useCallback( const accountNotExistErrorHandler = useCallback(
async (error: RequestErrorBody) => { async (error: RequestErrorBody) => {
@ -107,6 +109,11 @@ const useSocialSignInListener = (connectorId: string) => {
const signInWithSocialHandler = useCallback( const signInWithSocialHandler = useCallback(
async (connectorId: string, data: Record<string, unknown>) => { async (connectorId: string, data: Record<string, unknown>) => {
// When the callback is called from Google One Tap, the interaction event was not set yet.
if (data[GoogleConnector.oneTapParams.csrfToken]) {
await asyncPutInteraction(InteractionEvent.SignIn);
}
const [error, result] = await asyncSignInWithSocial({ const [error, result] = await asyncSignInWithSocial({
connectorId, connectorId,
connectorData: { connectorData: {
@ -127,7 +134,7 @@ const useSocialSignInListener = (connectorId: string) => {
window.location.replace(result.redirectTo); window.location.replace(result.redirectTo);
} }
}, },
[asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers] [asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers]
); );
// Social Sign-in Callback Handler // Social Sign-in Callback Handler
@ -143,7 +150,10 @@ const useSocialSignInListener = (connectorId: string) => {
// Cleanup the search parameters once it's consumed // Cleanup the search parameters once it's consumed
setSearchParameters({}, { replace: true }); setSearchParameters({}, { replace: true });
if (!state || !stateValidation(state, connectorId)) { if (
!validateState(state, connectorId) &&
!validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken])
) {
setToast(t('error.invalid_connector_auth')); setToast(t('error.invalid_connector_auth'));
navigate('/' + experience.routes.signIn); navigate('/' + experience.routes.signIn);
return; return;

View file

@ -1,11 +1,9 @@
import type { import type {
SignInExperience,
ConnectorMetadata,
SignInIdentifier, SignInIdentifier,
Theme, Theme,
WebAuthnRegistrationOptions, WebAuthnRegistrationOptions,
WebAuthnAuthenticationOptions, WebAuthnAuthenticationOptions,
SsoConnectorMetadata, FullSignInExperience,
} from '@logto/schemas'; } from '@logto/schemas';
export enum UserFlow { export enum UserFlow {
@ -30,17 +28,7 @@ export type Platform = 'web' | 'mobile';
export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone; export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone;
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors export type SignInExperienceResponse = Omit<FullSignInExperience, 'socialSignInConnectorTargets'>;
export type SignInExperienceResponse = Omit<SignInExperience, 'socialSignInConnectorTargets'> & {
socialConnectors: ConnectorMetadata[];
ssoConnectors: SsoConnectorMetadata[];
notification?: string;
forgotPassword: {
phone: boolean;
email: boolean;
};
isDevelopmentTenant: boolean;
};
export type PreviewConfig = { export type PreviewConfig = {
signInExperience: SignInExperienceResponse; signInExperience: SignInExperienceResponse;

View file

@ -1,5 +1,7 @@
import type { ConnectorMetadata } from '@logto/schemas'; import { GoogleConnector } from '@logto/connector-kit';
import type { ExperienceSocialConnector } from '@logto/schemas';
import { ConnectorPlatform } from '@logto/schemas'; import { ConnectorPlatform } from '@logto/schemas';
import { getCookie } from 'tiny-cookie';
import { SearchParameters } from '@/types'; import { SearchParameters } from '@/types';
import { generateRandomString } from '@/utils'; import { generateRandomString } from '@/utils';
@ -21,7 +23,15 @@ export const storeState = (state: string, connectorId: string) => {
sessionStorage.setItem(`${storageStateKeyPrefix}:${connectorId}`, state); sessionStorage.setItem(`${storageStateKeyPrefix}:${connectorId}`, state);
}; };
export const stateValidation = (state: string, connectorId: string) => { /**
* Validate the state parameter from the social connector callback. If the state parameter is empty
* or invalid, it will return false.
*/
export const validateState = (state: string | undefined, connectorId: string): boolean => {
if (!state) {
return false;
}
const storageKey = `${storageStateKeyPrefix}:${connectorId}`; const storageKey = `${storageStateKeyPrefix}:${connectorId}`;
const stateStorage = sessionStorage.getItem(storageKey); const stateStorage = sessionStorage.getItem(storageKey);
sessionStorage.removeItem(storageKey); sessionStorage.removeItem(storageKey);
@ -29,6 +39,9 @@ export const stateValidation = (state: string, connectorId: string) => {
return stateStorage === state; return stateStorage === state;
}; };
export const validateGoogleOneTapCsrfToken = (csrfToken?: string): boolean =>
Boolean(csrfToken && getCookie(GoogleConnector.oneTapParams.csrfToken) === csrfToken);
/** /**
* Native Social Redirect Utility Methods * Native Social Redirect Utility Methods
*/ */
@ -63,12 +76,12 @@ export const removeCallbackLinkFromStorage = (connectorId: string) => {
/** /**
* Social Connectors Filter Utility Methods * Social Connectors Filter Utility Methods
*/ */
export const filterSocialConnectors = (socialConnectors?: ConnectorMetadata[]) => { export const filterSocialConnectors = (socialConnectors?: ExperienceSocialConnector[]) => {
if (!socialConnectors) { if (!socialConnectors) {
return []; return [];
} }
const connectorMap = new Map<string, ConnectorMetadata>(); const connectorMap = new Map<string, ExperienceSocialConnector>();
/** /**
* Browser Environment * Browser Environment
@ -152,13 +165,13 @@ export const filterSocialConnectors = (socialConnectors?: ConnectorMetadata[]) =
*/ */
export const filterPreviewSocialConnectors = ( export const filterPreviewSocialConnectors = (
platform: ConnectorPlatform.Native | ConnectorPlatform.Web, platform: ConnectorPlatform.Native | ConnectorPlatform.Web,
socialConnectors?: ConnectorMetadata[] socialConnectors?: ExperienceSocialConnector[]
) => { ) => {
if (!socialConnectors) { if (!socialConnectors) {
return []; return [];
} }
const connectorMap = new Map<string, ConnectorMetadata>(); const connectorMap = new Map<string, ExperienceSocialConnector>();
/** /**
* Browser Environment * Browser Environment