>;
+};
+
+const SecondarySocialSignIn = ({ className, connectors }: Props) => {
+ const { invokeSocialSignIn } = useSocial();
+ const sampled = connectors.length > 4;
+
+ const sampledConnectors = useMemo(() => {
+ // TODO: filter with native returned
+
+ if (sampled) {
+ return connectors.slice(0, 3);
+ }
+
+ return connectors;
+ }, [connectors, sampled]);
+
+ return (
+
+ {sampledConnectors.map((connector) => (
+ {
+ void invokeSocialSignIn(connector.id);
+ }}
+ />
+ ))}
+ {sampled && }
+
+ );
+};
+
+export default SecondarySocialSignIn;
diff --git a/packages/ui/src/containers/SocialSignIn/index.module.scss b/packages/ui/src/containers/SocialSignIn/index.module.scss
new file mode 100644
index 000000000..8e46bbb57
--- /dev/null
+++ b/packages/ui/src/containers/SocialSignIn/index.module.scss
@@ -0,0 +1,16 @@
+@use '@/scss/underscore' as _;
+
+.socialIconList {
+ width: 100%;
+ max-width: 360px;
+ @include _.flex-row;
+ justify-content: center;
+}
+
+.socialButton {
+ margin-right: _.unit(10);
+
+ &:last-child {
+ margin-right: 0;
+ }
+}
diff --git a/packages/ui/src/containers/SocialSignIn/index.ts b/packages/ui/src/containers/SocialSignIn/index.ts
new file mode 100644
index 000000000..a5247871b
--- /dev/null
+++ b/packages/ui/src/containers/SocialSignIn/index.ts
@@ -0,0 +1 @@
+export { default as SecondarySocialSignIn } from './SecondarySocialSignIn';
diff --git a/packages/ui/src/hooks/use-social-connector.ts b/packages/ui/src/hooks/use-social-connector.ts
new file mode 100644
index 000000000..0f94448b0
--- /dev/null
+++ b/packages/ui/src/hooks/use-social-connector.ts
@@ -0,0 +1,87 @@
+import { useEffect, useCallback } from 'react';
+import { useLocation } from 'react-router-dom';
+
+import { invokeSocialSignIn, signInWithSoical } from '@/apis/social';
+import { generateRandomString } from '@/utils';
+
+import useApi from './use-api';
+
+const storageKeyPrefix = 'social_auth_state';
+const webPlatformPrefix = 'web';
+const mobilePlatformPrefix = 'mobile';
+
+const isMobileWebview = () => {
+ // TODO: read from native sdk embedded params
+ return true;
+};
+
+const useSocial = () => {
+ const { result: invokeSocialSignInResult, run: asyncSignInWithSocial } =
+ useApi(invokeSocialSignIn);
+ const { result: signInToSocialResult, run: asyncSignInWithSoical } = useApi(signInWithSoical);
+
+ const { search } = useLocation();
+
+ const validateState = useCallback((state: string, connectorId: string) => {
+ if (state.startsWith(mobilePlatformPrefix)) {
+ return true; // Not able to validate the state source from the native call stack
+ }
+
+ const stateStorage = sessionStorage.getItem(`${storageKeyPrefix}:${connectorId}`);
+
+ return stateStorage === state;
+ }, []);
+
+ const signInWithSocialHandler = useCallback(
+ (connector?: string) => {
+ const uriParameters = new URLSearchParams(search);
+ const state = uriParameters.get('state');
+ const code = uriParameters.get('code');
+
+ if (!state || !code || !connector) {
+ // TODO: error message
+ return;
+ }
+
+ if (!validateState(state, connector)) {
+ // TODO: error message
+ return;
+ }
+
+ void asyncSignInWithSoical({ connectorId: connector, state, code, redirectUri: 'TODO' });
+ },
+ [asyncSignInWithSoical, search, validateState]
+ );
+
+ const invokeSocialSignInHandler = useCallback(
+ async (connectorId: string) => {
+ const state = `${
+ isMobileWebview() ? mobilePlatformPrefix : webPlatformPrefix
+ }_${generateRandomString()}`;
+ const { origin } = window.location;
+ sessionStorage.setItem(`${storageKeyPrefix}:${connectorId}`, state);
+
+ return asyncSignInWithSocial(connectorId, state, `${origin}/callback/${connectorId}`);
+ },
+ [asyncSignInWithSocial]
+ );
+
+ useEffect(() => {
+ if (invokeSocialSignInResult?.redirectTo) {
+ window.location.assign(invokeSocialSignInResult.redirectTo);
+ }
+ }, [invokeSocialSignInResult]);
+
+ useEffect(() => {
+ if (signInToSocialResult?.redirectTo) {
+ window.location.assign(signInToSocialResult.redirectTo);
+ }
+ }, [signInToSocialResult]);
+
+ return {
+ invokeSocialSignIn: invokeSocialSignInHandler,
+ signInWithSocial: signInWithSocialHandler,
+ };
+};
+
+export default useSocial;
diff --git a/packages/ui/src/jest.setup.ts b/packages/ui/src/jest.setup.ts
index 52ca21b8f..b843b08dd 100644
--- a/packages/ui/src/jest.setup.ts
+++ b/packages/ui/src/jest.setup.ts
@@ -1,3 +1,5 @@
+import { Crypto } from '@peculiar/webcrypto';
+
// https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
// eslint-disable-next-line @silverhand/fp/no-mutating-methods
Object.defineProperty(window, 'matchMedia', {
@@ -15,6 +17,9 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
+// eslint-disable-next-line @silverhand/fp/no-mutation
+global.crypto = new Crypto();
+
const translation = (key: string) => key;
jest.mock('react-i18next', () => ({
diff --git a/packages/ui/src/pages/Callback/index.tsx b/packages/ui/src/pages/Callback/index.tsx
new file mode 100644
index 000000000..b5e38d2c8
--- /dev/null
+++ b/packages/ui/src/pages/Callback/index.tsx
@@ -0,0 +1,21 @@
+import React, { useEffect } from 'react';
+import { useParams } from 'react-router-dom';
+
+import useSocial from '@/hooks/use-social-connector';
+
+type Props = {
+ connector?: string;
+};
+
+const Callback = () => {
+ const { connector } = useParams();
+ const { signInWithSocial } = useSocial();
+
+ useEffect(() => {
+ signInWithSocial(connector);
+ }, [signInWithSocial, connector]);
+
+ return {connector} loading...
;
+};
+
+export default Callback;
diff --git a/packages/ui/src/utils/index.test.ts b/packages/ui/src/utils/index.test.ts
new file mode 100644
index 000000000..2c2a77468
--- /dev/null
+++ b/packages/ui/src/utils/index.test.ts
@@ -0,0 +1,8 @@
+import { generateRandomString } from '.';
+
+describe('util methods', () => {
+ it('generateRandomString', () => {
+ const random = generateRandomString();
+ expect(random).not.toBeNull();
+ });
+});
diff --git a/packages/ui/src/utils/index.ts b/packages/ui/src/utils/index.ts
new file mode 100644
index 000000000..e47257af0
--- /dev/null
+++ b/packages/ui/src/utils/index.ts
@@ -0,0 +1,4 @@
+import { fromUint8Array } from 'js-base64';
+
+export const generateRandomString = (length = 16) =>
+ fromUint8Array(crypto.getRandomValues(new Uint8Array(length)), true);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d58e16f6e..3384dea43 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -353,6 +353,7 @@ importers:
'@logto/schemas': ^0.1.0
'@parcel/core': ^2.3.2
'@parcel/transformer-sass': ^2.3.2
+ '@peculiar/webcrypto': ^1.3.3
'@silverhand/eslint-config': ^0.10.2
'@silverhand/eslint-config-react': ^0.10.3
'@silverhand/essentials': ^1.1.7
@@ -371,6 +372,7 @@ importers:
identity-obj-proxy: ^3.0.0
jest: ^27.5.1
jest-transform-stub: ^2.0.0
+ js-base64: ^3.7.2
ky: ^0.29.0
libphonenumber-js: ^1.9.49
lint-staged: ^11.1.1
@@ -396,6 +398,7 @@ importers:
classnames: 2.3.1
i18next: 21.6.11
i18next-browser-languagedetector: 6.1.3
+ js-base64: 3.7.2
ky: 0.29.0
libphonenumber-js: 1.9.49
react: 17.0.2
@@ -410,6 +413,7 @@ importers:
'@jest/types': 27.5.1
'@parcel/core': 2.3.2
'@parcel/transformer-sass': 2.3.2_@parcel+core@2.3.2
+ '@peculiar/webcrypto': 1.3.3
'@silverhand/eslint-config': 0.10.2_3a533fa6cc3da0cf8525ef55d41c4384
'@silverhand/eslint-config-react': 0.10.3_75f27ebc3d08f23e7ca8c3f63c7fa502
'@silverhand/ts-config': 0.10.2_typescript@4.6.2
@@ -5103,6 +5107,33 @@ packages:
nullthrows: 1.1.1
dev: true
+ /@peculiar/asn1-schema/2.1.0:
+ resolution: {integrity: sha512-D6g4C5YRKC/iPujMAOXuZ7YGdaoMx8GsvWzfVSyx2LYeL38ECOKNywlYAuwbqQvON64lgsYdAujWQPX8hhoBLw==}
+ dependencies:
+ '@types/asn1js': 2.0.2
+ asn1js: 2.3.2
+ pvtsutils: 1.2.2
+ tslib: 2.3.1
+ dev: true
+
+ /@peculiar/json-schema/1.1.12:
+ resolution: {integrity: sha512-coUfuoMeIB7B8/NMekxaDzLhaYmp0HZNPEjYRm9goRou8UZIC3z21s0sL9AWoCw4EG876QyO3kYrc61WNF9B/w==}
+ engines: {node: '>=8.0.0'}
+ dependencies:
+ tslib: 2.3.1
+ dev: true
+
+ /@peculiar/webcrypto/1.3.3:
+ resolution: {integrity: sha512-+jkp16Hp18HkphJlMtqsQKjyDWJBh0AhDuoB+vVakuIRbkBdaFb7v26Ldm25altjiYhCyQnR5NChHxwSTvbXJw==}
+ engines: {node: '>=10.12.0'}
+ dependencies:
+ '@peculiar/asn1-schema': 2.1.0
+ '@peculiar/json-schema': 1.1.12
+ pvtsutils: 1.2.2
+ tslib: 2.3.1
+ webcrypto-core: 1.7.3
+ dev: true
+
/@polka/url/1.0.0-next.21:
resolution: {integrity: sha512-a5Sab1C4/icpTZVzZc5Ghpz88yQtGOyNqYXcZgOssB2uuAr+wF/MvN6bgtW32q7HHrvBki+BsZ0OuNv6EV3K9g==}
dev: false
@@ -5574,6 +5605,10 @@ packages:
resolution: {integrity: sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig==}
dev: true
+ /@types/asn1js/2.0.2:
+ resolution: {integrity: sha512-t4YHCgtD+ERvH0FyxvNlYwJ2ezhqw7t+Ygh4urQ7dJER8i185JPv6oIM3ey5YQmGN6Zp9EMbpohkjZi9t3UxwA==}
+ dev: true
+
/@types/babel__core/7.1.17:
resolution: {integrity: sha512-6zzkezS9QEIL8yCBvXWxPTJPNuMeECJVxSOhxNY/jfq9LxOTHivaYTqr37n9LknWWRTIkzqH2UilS5QFvfa90A==}
dependencies:
@@ -6726,6 +6761,13 @@ packages:
safer-buffer: 2.1.2
dev: true
+ /asn1js/2.3.2:
+ resolution: {integrity: sha512-IYzujqcOk7fHaePpTyvD3KPAA0AjT3qZlaQAw76zmPPAV/XTjhO+tbHjbFbIQZIhw+fk9wCSfb0Z6K+JHe8Q2g==}
+ engines: {node: '>=6.0.0'}
+ dependencies:
+ pvutils: 1.1.3
+ dev: true
+
/assert-plus/1.0.0:
resolution: {integrity: sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=}
engines: {node: '>=0.8'}
@@ -15792,6 +15834,17 @@ packages:
resolution: {integrity: sha1-H+Bk+wrIUfDeYTIKi/eWg2Qi8z4=}
dev: false
+ /pvtsutils/1.2.2:
+ resolution: {integrity: sha512-OALo5ZEdqiI127i64+CXwkCOyFHUA+tCQgaUO/MvRDFXWPr53f2sx28ECNztUEzuyu5xvuuD1EB/szg9mwJoGA==}
+ dependencies:
+ tslib: 2.3.1
+ dev: true
+
+ /pvutils/1.1.3:
+ resolution: {integrity: sha512-pMpnA0qRdFp32b1sJl1wOJNxZLQ2cbQx+k6tjNtZ8CpvVhNqEPRgivZ2WOUev2YMajecdH7ctUPDvEe87nariQ==}
+ engines: {node: '>=6.0.0'}
+ dev: true
+
/q/1.5.1:
resolution: {integrity: sha1-fjL3W0E4EpHQRhHxvxQQmsAGUdc=}
engines: {node: '>=0.6.0', teleport: '>=0.2.0'}
@@ -19158,6 +19211,16 @@ packages:
resolution: {integrity: sha512-wYxSGajtmoP4WxfejAPIr4l0fVh+jeMXZb08wNc0tMg6xsfZXj3cECqIK0G7ZAqUq0PP8WlMDtaOGVBTAWztNw==}
dev: false
+ /webcrypto-core/1.7.3:
+ resolution: {integrity: sha512-8TnMtwwC/hQOyvElAOJ26lJKGgcErUG02KnKS1+QhjV4mDvQetVWU1EUEeLF8ICOrdc42+GypocyBJKRqo2kQg==}
+ dependencies:
+ '@peculiar/asn1-schema': 2.1.0
+ '@peculiar/json-schema': 1.1.12
+ asn1js: 2.3.2
+ pvtsutils: 1.2.2
+ tslib: 2.3.1
+ dev: true
+
/webidl-conversions/3.0.1:
resolution: {integrity: sha1-JFNCdeKnvGvnvIZhHMFq4KVlSHE=}