Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(experience): add one-time token landing page to handle magic link auth #7159

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions packages/experience/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import LoadingLayerProvider from './Providers/LoadingLayerProvider';
import PageContextProvider from './Providers/PageContextProvider';
import SettingsProvider from './Providers/SettingsProvider';
import UserInteractionContextProvider from './Providers/UserInteractionContextProvider';
import { isDevFeaturesEnabled } from './constants/env';
import DevelopmentTenantNotification from './containers/DevelopmentTenantNotification';
import Callback from './pages/Callback';
import Consent from './pages/Consent';
Expand All @@ -24,6 +25,7 @@ import MfaVerification from './pages/MfaVerification';
import BackupCodeVerification from './pages/MfaVerification/BackupCodeVerification';
import TotpVerification from './pages/MfaVerification/TotpVerification';
import WebAuthnVerification from './pages/MfaVerification/WebAuthnVerification';
import OneTimeToken from './pages/OneTimeToken';
import Register from './pages/Register';
import RegisterPassword from './pages/RegisterPassword';
import ResetPassword from './pages/ResetPassword';
Expand Down Expand Up @@ -61,8 +63,10 @@ const App = () => {
element={<SocialSignInWebCallback />}
/>
<Route path="direct/:method/:target?" element={<DirectSignIn />} />

<Route element={<AppLayout />}>
{isDevFeaturesEnabled && (
<Route path="one-time-token/:token" element={<OneTimeToken />} />
)}
<Route
path="unknown-session"
element={<ErrorPage message="error.invalid_session" />}
Expand Down Expand Up @@ -153,7 +157,6 @@ const App = () => {
path={experience.routes.resetPassword}
element={<ResetPasswordLanding />}
/>

<Route path="*" element={<ErrorPage />} />
</Route>
</Route>
Expand Down
1 change: 1 addition & 0 deletions packages/experience/src/apis/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export {

export * from './mfa';
export * from './social';
export * from './one-time-token';

export const registerWithVerifiedIdentifier = async (verificationId: string) => {
await updateInteractionEvent(InteractionEvent.Register);
Expand Down
16 changes: 16 additions & 0 deletions packages/experience/src/apis/experience/one-time-token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { InteractionEvent, type OneTimeTokenVerificationVerifyPayload } from '@logto/schemas';

import api from '../api';

import { experienceApiRoutes, type VerificationResponse } from './const';
import { initInteraction } from './interaction';

export const registerWithOneTimeToken = async (payload: OneTimeTokenVerificationVerifyPayload) => {
await initInteraction(InteractionEvent.Register);

return api
.post(`${experienceApiRoutes.verification}/one-time-token/verify`, {
json: payload,
})
.json<VerificationResponse>();
};
13 changes: 13 additions & 0 deletions packages/experience/src/hooks/use-fallback-route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { experience } from '@logto/schemas';
import { useMemo } from 'react';

const useFallbackRoute = () =>
useMemo(() => {
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
return (
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
experience.routes.signIn
);
}, []);

export default useFallbackRoute;
12 changes: 3 additions & 9 deletions packages/experience/src/pages/DirectSignIn/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { experience } from '@logto/schemas';
import { useEffect, useMemo } from 'react';
import { useEffect } from 'react';
import { useParams } from 'react-router-dom';

import LoadingLayer from '@/components/LoadingLayer';
import useSocial from '@/containers/SocialSignInList/use-social';
import useFallbackRoute from '@/hooks/use-fallback-route';
import { useSieMethods } from '@/hooks/use-sie';
import useSingleSignOn from '@/hooks/use-single-sign-on';

Expand All @@ -12,13 +12,7 @@ const DirectSignIn = () => {
const { socialConnectors, ssoConnectors } = useSieMethods();
const { invokeSocialSignIn } = useSocial();
const invokeSso = useSingleSignOn();
const fallback = useMemo(() => {
const fallbackKey = new URLSearchParams(window.location.search).get('fallback');
return (
Object.entries(experience.routes).find(([key]) => key === fallbackKey)?.[1] ??
experience.routes.signIn
);
}, []);
const fallback = useFallbackRoute();

useEffect(() => {
if (method === 'social') {
Expand Down
148 changes: 148 additions & 0 deletions packages/experience/src/pages/OneTimeToken/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import {
AgreeToTermsPolicy,
InteractionEvent,
SignInIdentifier,
type RequestErrorBody,
} from '@logto/schemas';
import { condString } from '@silverhand/essentials';
import { useCallback, useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';

import {
identifyAndSubmitInteraction,
signInWithVerifiedIdentifier,
registerWithOneTimeToken,
} from '@/apis/experience';
import LoadingLayer from '@/components/LoadingLayer';
import useApi from '@/hooks/use-api';
import useErrorHandler from '@/hooks/use-error-handler';
import useGlobalRedirectTo from '@/hooks/use-global-redirect-to';
import useLoginHint from '@/hooks/use-login-hint';
import useSubmitInteractionErrorHandler from '@/hooks/use-submit-interaction-error-handler';
import useTerms from '@/hooks/use-terms';

import ErrorPage from '../ErrorPage';

const OneTimeToken = () => {
const { token } = useParams();
const email = useLoginHint();
const [oneTimeTokenError, setOneTimeTokenError] = useState<RequestErrorBody | boolean>();

const asyncIdentifyUserAndSubmit = useApi(identifyAndSubmitInteraction);
const asyncSignInWithVerifiedIdentifier = useApi(signInWithVerifiedIdentifier);
const asyncRegisterWithOneTimeToken = useApi(registerWithOneTimeToken);

const { termsValidation, agreeToTermsPolicy } = useTerms();
const handleError = useErrorHandler();
const redirectTo = useGlobalRedirectTo();
const preSignInErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.SignIn);
const preRegisterErrorHandler = useSubmitInteractionErrorHandler(InteractionEvent.Register);

/**
* Update interaction event to `SignIn`, and then identify user and submit.
*/
const signInWithOneTimeToken = useCallback(
async (verificationId: string) => {
const [error, result] = await asyncSignInWithVerifiedIdentifier(verificationId);

if (error) {
await handleError(error, preSignInErrorHandler);
return;
}

if (result?.redirectTo) {
await redirectTo(result.redirectTo);
}
},
[preSignInErrorHandler, asyncSignInWithVerifiedIdentifier, handleError, redirectTo]
);

/**
* Always try to submit the one-time token interaction with `Register` event first.
* If the email already exists, call the `signInWithOneTimeToken` function instead.
*/
const submit = useCallback(
async (verificationId: string) => {
const [error, result] = await asyncIdentifyUserAndSubmit({ verificationId });

if (error) {
await handleError(error, {
'user.email_already_in_use': async () => {
await signInWithOneTimeToken(verificationId);
},
...preRegisterErrorHandler,
});
return;
}

if (result?.redirectTo) {
await redirectTo(result.redirectTo);
}
},
[
preRegisterErrorHandler,
asyncIdentifyUserAndSubmit,
handleError,
redirectTo,
signInWithOneTimeToken,
]
);

useEffect(() => {
(async () => {
if (!token || !email) {
setOneTimeTokenError(true);
return;
}

/**
* Check if the user has agreed to the terms and privacy policy before navigating to the 3rd-party social sign-in page
* when the policy is set to `Manual`
*/
if (agreeToTermsPolicy === AgreeToTermsPolicy.Manual && !(await termsValidation())) {
return;
}

const [error, result] = await asyncRegisterWithOneTimeToken({
token,
identifier: { type: SignInIdentifier.Email, value: email },
});

if (error) {
await handleError(error, {
global: (error: RequestErrorBody) => {
setOneTimeTokenError(error);
},
});
return;
}

if (!result?.verificationId) {
return;
}

await submit(result.verificationId);
})();
}, [
agreeToTermsPolicy,
email,
token,
asyncRegisterWithOneTimeToken,
handleError,
termsValidation,
submit,
]);

if (oneTimeTokenError) {
return (
<ErrorPage
title="error.invalid_link"
message="error.invalid_link_description"
rawMessage={condString(typeof oneTimeTokenError !== 'boolean' && oneTimeTokenError.message)}
/>
);
}

return <LoadingLayer />;
};
export default OneTimeToken;
21 changes: 16 additions & 5 deletions packages/experience/src/utils/search-parameters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ export const searchKeys = Object.freeze({
appId: 'app_id',
} satisfies Record<SearchKeysCamelCase, string>);

/**
* The one-time token used as verification method.
* Example usage: Magic link
*/
export const oneTimeTokenSearchKey = 'one_time_token';

export const handleSearchParametersData = () => {
const { search } = window.location;

Expand All @@ -34,9 +40,14 @@ export const handleSearchParametersData = () => {
}
}

window.history.replaceState(
{},
'',
window.location.pathname + condString(parameters.size > 0 && `?${parameters.toString()}`)
);
const conditionalParamString = condString(parameters.size > 0 && `?${parameters.toString()}`);

// Check one-time token existence, and redirect to the `/one-time-token` route.
const oneTimeToken = parameters.get(oneTimeTokenSearchKey);
if (oneTimeToken) {
window.history.replaceState({}, '', '/one-time-token/' + oneTimeToken + conditionalParamString);
return;
}

window.history.replaceState({}, '', window.location.pathname + conditionalParamString);
};
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ar/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'انتهت مهلة الطلب. يرجى المحاولة مرة أخرى لاحقًا.',
password_rejected,
sso_not_enabled: 'تسجيل الدخول الموحد غير ممكّن لحساب البريد الإلكتروني هذا.',
invalid_link: 'رابط غير صالح',
invalid_link_description: 'ربما يكون رمز الدخول المؤقت قد انتهى أو لم يعد صالحًا.',
something_went_wrong: 'حدث خطأ ما.',
};

export default Object.freeze(error);
4 changes: 4 additions & 0 deletions packages/phrases-experience/src/locales/de/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ const error = {
timeout: 'Zeitüberschreitung. Bitte melde dich erneut an.',
password_rejected,
sso_not_enabled: 'Single Sign-On ist für dieses E-Mail-Konto nicht aktiviert.',
invalid_link: 'Ungültiger Link',
invalid_link_description:
'Dein einmaliger Token ist möglicherweise abgelaufen oder nicht mehr gültig.',
something_went_wrong: 'Etwas ist schiefgegangen.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/en/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Request timeout. Please try again later.',
password_rejected,
sso_not_enabled: 'Single Sign-On is not enabled for this email account.',
invalid_link: 'Invalid link',
invalid_link_description: 'Your one-time token may have expired or is no longer valid.',
something_went_wrong: 'Something went wrong.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/es/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const error = {
password_rejected,
sso_not_enabled:
'El inicio de sesión único no está habilitado para esta cuenta de correo electrónico.',
invalid_link: 'Enlace no válido',
invalid_link_description: 'Tu token de un solo uso puede haber expirado o ya no ser válido.',
something_went_wrong: 'Algo salió mal.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/fr/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ const error = {
timeout: "Délai d'attente de la requête dépassé. Veuillez réessayer plus tard.",
password_rejected,
sso_not_enabled: "La authentification unique n'est pas activée pour ce compte de messagerie.",
invalid_link: 'Lien invalide',
invalid_link_description: "Votre jeton à usage unique a peut-être expiré ou n'est plus valide.",
something_went_wrong: 'Quelque chose a mal tourné.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/it/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Timeout della richiesta. Si prega di riprovare più tardi.',
password_rejected,
sso_not_enabled: 'Single sign-on non abilitato per questo account email.',
invalid_link: 'Link invalido',
invalid_link_description: 'Il tuo token monouso potrebbe essere scaduto o non è più valido.',
something_went_wrong: 'Qualcosa è andato storto.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ja/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'リクエストタイムアウト。後でもう一度お試しください。',
password_rejected,
sso_not_enabled: 'このメールアカウントではシングルサインオンが有効になっていません。',
invalid_link: '無効なリンク',
invalid_link_description: 'ワンタイムトークンの有効期限が切れているか、無効になっています。',
something_went_wrong: '問題が発生しました。',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/ko/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: '요청 시간이 초과되었어요. 잠시 후에 다시 시도해 주세요.',
password_rejected,
sso_not_enabled: '이 이메일 계정에 대해 단일 로그인이 활성화되지 않았어요.',
invalid_link: '유효하지 않은 링크',
invalid_link_description: '일회성 토큰이 만료되었거나 더 이상 유효하지 않을 수 있어요.',
something_went_wrong: '문제가 발생했어요.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pl-pl/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'Czas żądania upłynął. Proszę spróbuj ponownie później.',
password_rejected,
sso_not_enabled: 'Pojedyncze logowanie nie jest włączony dla tego konta e-mail.',
invalid_link: 'Nieprawidłowy link',
invalid_link_description: 'Twój jednorazowy token mógł wygasnąć lub nie jest już ważny.',
something_went_wrong: 'Coś poszło nie tak.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pt-br/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ const error = {
timeout: 'Tempo limite excedido. Por favor, tente novamente mais tarde.',
password_rejected,
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
invalid_link: 'Link inválido',
invalid_link_description: 'Seu token de uso único pode ter expirado ou não é mais válido.',
something_went_wrong: 'Algo deu errado.',
};

export default Object.freeze(error);
3 changes: 3 additions & 0 deletions packages/phrases-experience/src/locales/pt-pt/error/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const error = {
timeout: 'Tempo limite de sessão. Volte e faça login novamente.',
password_rejected,
sso_not_enabled: 'O Single Sign-On não está habilitado para esta conta de e-mail.',
invalid_link: 'Link inválido',
invalid_link_description: 'O teu token de uso único pode ter expirado ou já não ser válido.',
something_went_wrong: 'Algo correu mal.',
};

export default Object.freeze(error);
Loading
Loading