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:
parent
942780fcfa
commit
50c35a2143
13 changed files with 116 additions and 53 deletions
8
.changeset/nine-carrots-roll.md
Normal file
8
.changeset/nine-carrots-roll.md
Normal 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.
|
|
@ -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`, {
|
||||
|
|
43
packages/experience/src/components/GoogleOneTap/index.tsx
Normal file
43
packages/experience/src/components/GoogleOneTap/index.tsx
Normal 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;
|
|
@ -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) => {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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';
|
||||
}
|
||||
| {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue