0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2024-12-16 20:26:19 -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,
redirectUri: string
) => {
await api.put(`${interactionPrefix}`, { json: { event: InteractionEvent.SignIn } });
await putInteraction(InteractionEvent.SignIn);
return api
.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 SocialLinkButton from '@/components/Button/SocialLinkButton';
@ -10,7 +10,7 @@ import useSocial from './use-social';
type Props = {
readonly className?: string;
readonly socialConnectors?: ConnectorMetadata[];
readonly socialConnectors?: ExperienceSocialConnector[];
};
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 PageContext from '@/Providers/PageContextProvider/PageContext';
@ -14,22 +14,25 @@ const useSocial = () => {
const handleError = useErrorHandler();
const asyncInvokeSocialSignIn = useApi(getSocialAuthorizationUrl);
const nativeSignInHandler = useCallback((redirectTo: string, connector: ConnectorMetadata) => {
const { id: connectorId, platform } = connector;
const nativeSignInHandler = useCallback(
(redirectTo: string, connector: ExperienceSocialConnector) => {
const { id: connectorId, platform } = connector;
const redirectUri =
platform === ConnectorPlatform.Universal
? buildSocialLandingUri(`/social/landing/${connectorId}`, redirectTo).toString()
: redirectTo;
const redirectUri =
platform === ConnectorPlatform.Universal
? buildSocialLandingUri(`/social/landing/${connectorId}`, redirectTo).toString()
: redirectTo;
getLogtoNativeSdk()?.getPostMessage()({
callbackUri: `${window.location.origin}/callback/social/${connectorId}`,
redirectTo: redirectUri,
});
}, []);
getLogtoNativeSdk()?.getPostMessage()({
callbackUri: `${window.location.origin}/callback/social/${connectorId}`,
redirectTo: redirectUri,
});
},
[]
);
const invokeSocialSignInHandler = useCallback(
async (connector: ConnectorMetadata) => {
async (connector: ExperienceSocialConnector) => {
const { id: connectorId } = connector;
const state = generateState();

View file

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

View file

@ -7,6 +7,7 @@ import LandingPageLayout from '@/Layout/LandingPageLayout';
import SingleSignOnFormModeContextProvider from '@/Providers/SingleSignOnFormModeContextProvider';
import SingleSignOnFormModeContext from '@/Providers/SingleSignOnFormModeContextProvider/SingleSignOnFormModeContext';
import Divider from '@/components/Divider';
import GoogleOneTap from '@/components/GoogleOneTap';
import TextLink from '@/components/TextLink';
import SocialSignInList from '@/containers/SocialSignInList';
import TermsAndPrivacyCheckbox from '@/containers/TermsAndPrivacyCheckbox';
@ -75,6 +76,7 @@ const Register = () => {
return (
<LandingPageLayout title="description.create_your_account">
<GoogleOneTap context="signup" />
<SingleSignOnFormModeContextProvider>
{signUpMethods.length > 0 && (
<IdentifierRegisterForm signUpMethods={signUpMethods} className={styles.main} />
@ -88,7 +90,6 @@ const Register = () => {
)}
<RegisterFooter />
</SingleSignOnFormModeContextProvider>
{/* Hide footer elements when showing Single Sign On form */}
</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';
@ -8,7 +8,7 @@ import * as styles from './index.module.scss';
type Props = {
readonly signInMethods: SignIn['methods'];
readonly socialConnectors: ConnectorMetadata[];
readonly socialConnectors: ExperienceSocialConnector[];
};
const Main = ({ signInMethods, socialConnectors }: Props) => {

View file

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

View file

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

View file

@ -1,11 +1,12 @@
import { GoogleConnector } from '@logto/connector-kit';
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 { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
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 useApi from '@/hooks/use-api';
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 { socialAccountNotExistErrorDataGuard } from '@/types/guard';
import { parseQueryParameters } from '@/utils';
import { stateValidation } from '@/utils/social-connectors';
import { validateGoogleOneTapCsrfToken, validateState } from '@/utils/social-connectors';
const useSocialSignInListener = (connectorId: string) => {
const [loading, setLoading] = useState(true);
@ -33,6 +34,7 @@ const useSocialSignInListener = (connectorId: string) => {
const bindSocialRelatedUser = useBindSocialRelatedUser();
const registerWithSocial = useSocialRegister(connectorId, true);
const asyncSignInWithSocial = useApi(signInWithSocial);
const asyncPutInteraction = useApi(putInteraction);
const accountNotExistErrorHandler = useCallback(
async (error: RequestErrorBody) => {
@ -107,6 +109,11 @@ const useSocialSignInListener = (connectorId: string) => {
const signInWithSocialHandler = useCallback(
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({
connectorId,
connectorData: {
@ -127,7 +134,7 @@ const useSocialSignInListener = (connectorId: string) => {
window.location.replace(result.redirectTo);
}
},
[asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers]
[asyncPutInteraction, asyncSignInWithSocial, handleError, signInWithSocialErrorHandlers]
);
// Social Sign-in Callback Handler
@ -143,7 +150,10 @@ const useSocialSignInListener = (connectorId: string) => {
// Cleanup the search parameters once it's consumed
setSearchParameters({}, { replace: true });
if (!state || !stateValidation(state, connectorId)) {
if (
!validateState(state, connectorId) &&
!validateGoogleOneTapCsrfToken(rest[GoogleConnector.oneTapParams.csrfToken])
) {
setToast(t('error.invalid_connector_auth'));
navigate('/' + experience.routes.signIn);
return;

View file

@ -1,11 +1,9 @@
import type {
SignInExperience,
ConnectorMetadata,
SignInIdentifier,
Theme,
WebAuthnRegistrationOptions,
WebAuthnAuthenticationOptions,
SsoConnectorMetadata,
FullSignInExperience,
} from '@logto/schemas';
export enum UserFlow {
@ -30,17 +28,7 @@ export type Platform = 'web' | 'mobile';
export type VerificationCodeIdentifier = SignInIdentifier.Email | SignInIdentifier.Phone;
// Omit socialSignInConnectorTargets since it is being translated into socialConnectors
export type SignInExperienceResponse = Omit<SignInExperience, 'socialSignInConnectorTargets'> & {
socialConnectors: ConnectorMetadata[];
ssoConnectors: SsoConnectorMetadata[];
notification?: string;
forgotPassword: {
phone: boolean;
email: boolean;
};
isDevelopmentTenant: boolean;
};
export type SignInExperienceResponse = Omit<FullSignInExperience, 'socialSignInConnectorTargets'>;
export type PreviewConfig = {
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 { getCookie } from 'tiny-cookie';
import { SearchParameters } from '@/types';
import { generateRandomString } from '@/utils';
@ -21,7 +23,15 @@ export const storeState = (state: string, connectorId: string) => {
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 stateStorage = sessionStorage.getItem(storageKey);
sessionStorage.removeItem(storageKey);
@ -29,6 +39,9 @@ export const stateValidation = (state: string, connectorId: string) => {
return stateStorage === state;
};
export const validateGoogleOneTapCsrfToken = (csrfToken?: string): boolean =>
Boolean(csrfToken && getCookie(GoogleConnector.oneTapParams.csrfToken) === csrfToken);
/**
* Native Social Redirect Utility Methods
*/
@ -63,12 +76,12 @@ export const removeCallbackLinkFromStorage = (connectorId: string) => {
/**
* Social Connectors Filter Utility Methods
*/
export const filterSocialConnectors = (socialConnectors?: ConnectorMetadata[]) => {
export const filterSocialConnectors = (socialConnectors?: ExperienceSocialConnector[]) => {
if (!socialConnectors) {
return [];
}
const connectorMap = new Map<string, ConnectorMetadata>();
const connectorMap = new Map<string, ExperienceSocialConnector>();
/**
* Browser Environment
@ -152,13 +165,13 @@ export const filterSocialConnectors = (socialConnectors?: ConnectorMetadata[]) =
*/
export const filterPreviewSocialConnectors = (
platform: ConnectorPlatform.Native | ConnectorPlatform.Web,
socialConnectors?: ConnectorMetadata[]
socialConnectors?: ExperienceSocialConnector[]
) => {
if (!socialConnectors) {
return [];
}
const connectorMap = new Map<string, ConnectorMetadata>();
const connectorMap = new Map<string, ExperienceSocialConnector>();
/**
* Browser Environment