0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-03-31 22:51:25 -05:00

feat(connector): apple (#966)

* feat(connector): apple

* feat(connector): apple

* fix(connector): test

* refactor(connector): per review

* refactor(connector): update issue number

Co-authored-by: Xiao Yijun <xiaoyijun@silverhand.io>
This commit is contained in:
Gao Sun 2022-05-27 15:43:42 +08:00 committed by GitHub
parent e8ee5b1149
commit 7400ed8896
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 813 additions and 43 deletions

View file

@ -0,0 +1,2 @@
### Apple Social Connector README
placeholder

View file

@ -0,0 +1,6 @@
```json
{
"clientId": "<client-id>",
"clientSecret": "<client-secret>"
}
```

View file

@ -0,0 +1,8 @@
import { Config, merge } from '@silverhand/jest-config';
const config: Config.InitialOptions = merge({
testEnvironment: 'node',
setupFilesAfterEnv: ['jest-matcher-specific-error'],
});
export default config;

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 18 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M14.4582 10.6254C14.467 9.85999 14.6699 9.10938 15.0479 8.44381C15.426 7.77824 15.9667 7.2195 16.6195 6.81989C16.2046 6.22904 15.6583 5.74242 15.0236 5.39821C14.3889 5.054 13.6831 4.86159 12.9615 4.83606C11.4039 4.67816 9.92216 5.75299 9.13292 5.75299C8.34318 5.75299 7.12392 4.85925 5.83207 4.88391C4.98424 4.90748 4.15708 5.1509 3.43163 5.59032C2.70618 6.02974 2.10733 6.65008 1.69375 7.39056C-0.0686383 10.4507 1.2439 14.9843 2.96139 17.4673C3.80184 18.6812 4.80367 20.0485 6.11916 19.9987C7.38686 19.9488 7.86532 19.1799 9.3959 19.1799C10.9269 19.1799 11.3575 19.9987 12.6967 19.9739C14.0596 19.9488 14.9247 18.735 15.7583 17.5165C16.355 16.6404 16.8212 15.6822 17.1424 14.6721C16.3489 14.3332 15.6718 13.7694 15.1949 13.0503C14.7179 12.3312 14.4618 11.4883 14.4582 10.6254ZM11.9413 3.1933C12.6961 2.30799 13.0701 1.16004 12.9816 0C11.842 0.110543 10.7879 0.652884 10.0354 1.51581C9.65997 1.93385 9.37253 2.4232 9.19025 2.9547C9.00797 3.4862 8.93456 4.04897 8.97442 4.60944C9.54453 4.61731 10.1088 4.49364 10.6233 4.24803C11.1379 4.00243 11.5889 3.64151 11.9413 3.1933Z" fill="#111111"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -0,0 +1,59 @@
{
"name": "@logto/connector-apple",
"version": "0.1.0",
"description": "Apple web connector implementation.",
"main": "./lib/index.js",
"exports": "./lib/index.js",
"author": "Silverhand Inc. <contact@silverhand.io>",
"license": "MPL-2.0",
"files": [
"lib",
"docs",
"logo.svg"
],
"private": false,
"scripts": {
"preinstall": "npx only-allow pnpm",
"precommit": "lint-staged",
"build": "rm -rf lib/ && tsc -p tsconfig.build.json",
"lint": "eslint --ext .ts src",
"lint:report": "pnpm lint --format json --output-file report.json",
"dev": "rm -rf lib/ && tsc-watch -p tsconfig.build.json --preserveWatchOutput --onSuccess \"node ./lib/index.js\"",
"test": "jest",
"test:coverage": "jest --coverage --silent",
"prepack": "pnpm build"
},
"dependencies": {
"@logto/connector-types": "^0.1.0",
"@silverhand/jest-config": "^0.14.0",
"@logto/shared": "^0.1.0",
"@logto/schemas": "^0.1.0",
"@silverhand/essentials": "^1.1.0",
"got": "^11.8.2",
"jose": "^4.3.8",
"zod": "^3.14.3"
},
"devDependencies": {
"@jest/types": "^27.5.1",
"@silverhand/eslint-config": "^0.14.0",
"@silverhand/ts-config": "^0.14.0",
"@types/jest": "^27.4.1",
"@types/node": "^16.3.1",
"eslint": "^8.10.0",
"jest": "^27.5.1",
"jest-matcher-specific-error": "^1.0.0",
"lint-staged": "^12.0.0",
"nock": "^13.2.2",
"prettier": "^2.3.2",
"ts-jest": "^27.1.1",
"tsc-watch": "^5.0.0",
"typescript": "^4.6.2"
},
"engines": {
"node": "^16.0.0"
},
"eslintConfig": {
"extends": "@silverhand"
},
"prettier": "@silverhand/eslint-config/.prettierrc"
}

View file

@ -0,0 +1,40 @@
import path from 'path';
import { ConnectorMetadata, ConnectorType, ConnectorPlatform } from '@logto/connector-types';
import { getFileContents } from '@logto/shared';
// https://appleid.apple.com/.well-known/openid-configuration
export const issuer = 'https://appleid.apple.com';
export const authorizationEndpoint = `${issuer}/auth/authorize`;
export const accessTokenEndpoint = `${issuer}/auth/token`;
export const jwksUri = `${issuer}/auth/keys`;
// Note: only support fixed scope for v1.
export const scope = ''; // Note: `openid` is required when adding more scope(s)
// eslint-disable-next-line unicorn/prefer-module
const currentPath = __dirname;
const pathToReadmeFile = path.join(currentPath, '..', 'README.md');
const pathToConfigTemplate = path.join(currentPath, '..', 'docs', 'config-template.md');
const readmeContentFallback = 'Please check README.md file directory.';
const configTemplateFallback = 'Please check config-template.md file directory.';
export const defaultMetadata: ConnectorMetadata = {
id: 'apple-universal',
target: 'apple',
type: ConnectorType.Social,
platform: ConnectorPlatform.Universal,
name: {
en: 'Apple',
'zh-CN': 'Apple',
},
logo: './logo.svg',
description: {
en: 'Sign In with Apple',
'zh-CN': 'Apple 登录',
},
readme: getFileContents(pathToReadmeFile, readmeContentFallback),
configTemplate: getFileContents(pathToConfigTemplate, configTemplateFallback),
};
export const defaultTimeout = 5000;

View file

@ -0,0 +1,61 @@
import { GetConnectorConfig } from '@logto/connector-types';
import nock from 'nock';
import AppleConnector from '.';
import { defaultMetadata } from './constant';
import { mockedConfig } from './mock';
import { AppleConfig } from './types';
const getConnectorConfig = jest.fn() as GetConnectorConfig<AppleConfig>;
const appleMethods = new AppleConnector(getConnectorConfig);
beforeAll(() => {
jest.spyOn(appleMethods, 'getConfig').mockResolvedValue(mockedConfig);
});
describe('getAuthorizationUri', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should get a valid uri by redirectUri and state', async () => {
const authorizationUri = await appleMethods.getAuthorizationUri(
'some_state',
'http://localhost:3000/callback'
);
expect(authorizationUri).toEqual(
`${defaultMetadata.target}://?client_id=%3Cclient-id%3E&redirect_uri=http%3A%2F%2Flocalhost%3A3000%2Fcallback&scope=&state=some_state`
);
});
});
describe('getAccessToken', () => {
afterEach(() => {
nock.cleanAll();
jest.clearAllMocks();
});
it('should return code directly', async () => {
const accessToken = await appleMethods.getAccessToken('code');
expect(accessToken).toEqual('code');
});
});
describe('validateConfig', () => {
afterEach(() => {
jest.clearAllMocks();
});
it('should pass on valid config', async () => {
await expect(appleMethods.validateConfig({ clientId: 'clientId' })).resolves.not.toThrow();
});
it('should throw on empty config', async () => {
await expect(appleMethods.validateConfig({})).rejects.toThrowError();
});
});
describe('getUserInfo', () => {
// LOG-2726
});

View file

@ -0,0 +1,86 @@
import {
ConnectorMetadata,
GetAccessToken,
GetAuthorizationUri,
ValidateConfig,
GetUserInfo,
ConnectorError,
ConnectorErrorCodes,
SocialConnector,
GetConnectorConfig,
} from '@logto/connector-types';
import { conditional } from '@silverhand/essentials';
import { createRemoteJWKSet, jwtVerify } from 'jose';
import { scope, defaultMetadata, jwksUri, issuer } from './constant';
import { appleConfigGuard, AppleConfig, appleDataGuard } from './types';
// TO-DO: support nonce validation
export default class AppleConnector implements SocialConnector<string> {
public metadata: ConnectorMetadata = defaultMetadata;
constructor(public readonly getConfig: GetConnectorConfig<AppleConfig>) {}
public validateConfig: ValidateConfig = async (config: unknown) => {
const result = appleConfigGuard.safeParse(config);
if (!result.success) {
throw new ConnectorError(ConnectorErrorCodes.InvalidConfig, result.error.message);
}
};
public getAuthorizationUri: GetAuthorizationUri = async (state, redirectUri) => {
const config = await this.getConfig(this.metadata.id);
const queryParameters = new URLSearchParams({
client_id: config.clientId,
redirect_uri: redirectUri,
scope,
state,
});
return `${this.metadata.target}://?${queryParameters.toString()}`;
};
// Directly return now. Refactor with connector interface redesign.
getAccessToken: GetAccessToken<string> = async (code) => {
return code;
};
// Extract data from JSON string.
// Refactor with connector interface redesign.
public getUserInfo: GetUserInfo<string> = async (data) => {
const {
authorization: { id_token: idToken },
user,
} = appleDataGuard.parse(JSON.parse(data));
if (!idToken) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
}
const { clientId } = await this.getConfig(this.metadata.id);
try {
const { payload } = await jwtVerify(idToken, createRemoteJWKSet(new URL(jwksUri)), {
issuer,
audience: clientId,
});
if (!payload.sub) {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
}
const name = [user?.name?.firstName, user?.name?.lastName]
.filter((value) => Boolean(value))
.join(' ');
return {
id: payload.sub,
name: conditional(name),
};
} catch {
throw new ConnectorError(ConnectorErrorCodes.SocialIdTokenInvalid);
}
};
}

View file

@ -0,0 +1,4 @@
export const mockedConfig = {
clientId: '<client-id>',
clientSecret: '<client-secret>',
};

View file

@ -0,0 +1,26 @@
import { z } from 'zod';
export const appleConfigGuard = z.object({
clientId: z.string(),
});
export type AppleConfig = z.infer<typeof appleConfigGuard>;
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
export const appleDataGuard = z.object({
authorization: z.object({
id_token: z.string(),
}),
user: z
.object({
name: z
.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
})
.optional(),
})
.optional(),
});
export type AppleData = z.infer<typeof appleDataGuard>;

View file

@ -0,0 +1,10 @@
{
"extends": "@silverhand/ts-config/tsconfig.base",
"compilerOptions": {
"outDir": "lib",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "./tsconfig.base",
"include": ["src"],
"exclude": ["src/**/*.test.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.base",
"compilerOptions": {
"types": ["node", "jest", "jest-matcher-specific-error"]
},
"include": ["src", "jest.config.ts"]
}

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig",
"compilerOptions": {
"isolatedModules": false,
"allowJs": true,
}
}

View file

@ -33,6 +33,7 @@ export enum ConnectorErrorCodes {
TemplateNotFound,
SocialAuthCodeInvalid,
SocialAccessTokenInvalid,
SocialIdTokenInvalid,
}
export class ConnectorError extends Error {
@ -85,10 +86,10 @@ export interface EmailConnector extends BaseConnector {
sendMessage: EmailSendMessageFunction;
}
export interface SocialConnector extends BaseConnector {
export interface SocialConnector<TokenObject = AccessTokenObject> extends BaseConnector {
getAuthorizationUri: GetAuthorizationUri;
getAccessToken: GetAccessToken;
getUserInfo: GetUserInfo;
getAccessToken: GetAccessToken<TokenObject>;
getUserInfo: GetUserInfo<TokenObject>;
}
export type ValidateConfig<T = Record<string, unknown>> = (config: T) => Promise<void>;
@ -97,10 +98,13 @@ export type GetAuthorizationUri = (state: string, redirectUri: string) => Promis
export type AccessTokenObject = { accessToken: string } & Record<string, string>;
export type GetAccessToken = (code: string, redirectUri?: string) => Promise<AccessTokenObject>;
export type GetAccessToken<TokenObject = AccessTokenObject> = (
code: string,
redirectUri?: string
) => Promise<TokenObject>;
export type GetUserInfo = (
accessTokenObject: AccessTokenObject
export type GetUserInfo<TokenObject = AccessTokenObject> = (
accessTokenObject: TokenObject
) => Promise<{ id: string } & Record<string, string | undefined>>;
export type GetConnectorConfig<T = Record<string, unknown>> = (id: string) => Promise<T>;

View file

@ -23,9 +23,9 @@
"@logto/shared": "^0.1.0",
"@logto/schemas": "^0.1.0",
"@mdx-js/react": "^1.6.22",
"@parcel/core": "^2.5.0",
"@parcel/transformer-mdx": "^2.5.0",
"@parcel/transformer-sass": "^2.5.0",
"@parcel/core": "2.5.0",
"@parcel/transformer-mdx": "2.5.0",
"@parcel/transformer-sass": "2.5.0",
"@silverhand/eslint-config": "^0.14.0",
"@silverhand/eslint-config-react": "^0.14.0",
"@silverhand/essentials": "^1.1.6",
@ -51,7 +51,7 @@
"lint-staged": "^12.0.0",
"lodash.kebabcase": "^4.1.1",
"nanoid": "^3.1.23",
"parcel": "^2.5.0",
"parcel": "2.5.0",
"postcss": "^8.4.6",
"postcss-modules": "^4.3.0",
"prettier": "^2.3.2",

View file

@ -47,7 +47,8 @@ const ConnectorDetails = () => {
useEffect(() => {
if (data) {
setConfig(JSON.stringify(data.config, null, 2));
const hasData = Object.keys(data.config).length > 0;
setConfig(hasData ? JSON.stringify(data.config, null, 2) : data.configTemplate);
}
}, [data]);

View file

@ -24,6 +24,7 @@
"@logto/connector-alipay-native": "^0.1.0",
"@logto/connector-aliyun-dm": "^0.1.0",
"@logto/connector-aliyun-sms": "^0.1.0",
"@logto/connector-apple": "^0.1.0",
"@logto/connector-facebook": "^0.1.0",
"@logto/connector-github": "^0.1.0",
"@logto/connector-google": "^0.1.0",

View file

@ -4,6 +4,7 @@ export const connectorPackages = [
'@logto/connector-alipay-native',
'@logto/connector-aliyun-dm',
'@logto/connector-aliyun-sms',
'@logto/connector-apple',
'@logto/connector-facebook',
'@logto/connector-github',
'@logto/connector-google',

View file

@ -34,6 +34,12 @@ const aliyunSmsConnector = {
config: {},
createdAt: 1_646_382_233_666,
};
const appleConnector = {
id: 'apple-universal',
enabled: false,
config: {},
createdAt: 1_646_382_233_666,
};
const facebookConnector = {
id: 'facebook-universal',
enabled: true,
@ -82,6 +88,7 @@ const connectors = [
alipayNativeConnector,
aliyunDmConnector,
aliyunSmsConnector,
appleConnector,
facebookConnector,
githubConnector,
googleConnector,
@ -103,18 +110,11 @@ jest.mock('@/queries/connector', () => ({
describe('getConnectorInstances', () => {
test('should return the connectors existing in DB', async () => {
const connectorInstances = await getConnectorInstances();
expect(connectorInstances).toHaveLength(connectorInstances.length);
expect(connectorInstances[0]).toHaveProperty('connector', alipayConnector);
expect(connectorInstances[1]).toHaveProperty('connector', alipayNativeConnector);
expect(connectorInstances[2]).toHaveProperty('connector', aliyunDmConnector);
expect(connectorInstances[3]).toHaveProperty('connector', aliyunSmsConnector);
expect(connectorInstances[4]).toHaveProperty('connector', facebookConnector);
expect(connectorInstances[5]).toHaveProperty('connector', githubConnector);
expect(connectorInstances[6]).toHaveProperty('connector', googleConnector);
expect(connectorInstances[7]).toHaveProperty('connector', sendGridMailConnector);
expect(connectorInstances[8]).toHaveProperty('connector', twilioSmsConnector);
expect(connectorInstances[9]).toHaveProperty('connector', wechatConnector);
expect(connectorInstances[10]).toHaveProperty('connector', wechatNativeConnector);
expect(connectorInstances).toHaveLength(connectors.length);
for (const [index, connector] of connectors.entries()) {
expect(connectorInstances[index]).toHaveProperty('connector', connector);
}
});
test('should throw if any required connector does not exist in DB', async () => {

View file

@ -18,9 +18,9 @@
"devDependencies": {
"@logto/phrases": "^0.1.0",
"@logto/schemas": "^0.1.0",
"@parcel/core": "^2.5.0",
"@parcel/transformer-sass": "^2.5.0",
"@parcel/transformer-svg-react": "^2.5.0",
"@parcel/core": "2.5.0",
"@parcel/transformer-sass": "2.5.0",
"@parcel/transformer-svg-react": "2.5.0",
"@peculiar/webcrypto": "^1.3.3",
"@silverhand/eslint-config": "^0.14.0",
"@silverhand/eslint-config-react": "^0.14.0",
@ -35,6 +35,7 @@
"@types/react-dom": "^17.0.9",
"@types/react-modal": "^3.13.1",
"@types/react-router-dom": "^5.3.2",
"camelcase-keys": "^7.0.2",
"classnames": "^2.3.1",
"color": "^4.2.3",
"cross-env": "^7.0.3",
@ -47,7 +48,7 @@
"ky": "^0.30.0",
"libphonenumber-js": "^1.9.49",
"lint-staged": "^12.0.0",
"parcel": "^2.5.0",
"parcel": "2.5.0",
"postcss": "^8.4.6",
"postcss-modules": "^4.3.0",
"prettier": "^2.3.2",

View file

@ -4,6 +4,7 @@ import { Route, Routes, BrowserRouter, Navigate } from 'react-router-dom';
import * as styles from './App.module.scss';
import AppContent from './components/AppContent';
import { loadAppleSdk } from './hooks/use-apple-auth';
import usePageContext from './hooks/use-page-context';
import usePreview from './hooks/use-preview';
import initI18n from './i18n/init';
@ -16,7 +17,7 @@ import SecondarySignIn from './pages/SecondarySignIn';
import SignIn from './pages/SignIn';
import SocialRegister from './pages/SocialRegister';
import SocialSignInCallback from './pages/SocialSignInCallback';
import getSignInExperienceSettings from './utils/sign-in-experience';
import getSignInExperienceSettings, { isAppleConnectorEnabled } from './utils/sign-in-experience';
import './scss/normalized.scss';
@ -35,6 +36,11 @@ const App = () => {
(async () => {
const settings = await getSignInExperienceSettings();
// Load Apple official SDK if Apple connector is enabled
if (isAppleConnectorEnabled(settings)) {
await loadAppleSdk();
}
// Note: i18n must be initialized ahead of global experience settings
await initI18n(settings.languageInfo);

View file

@ -45,7 +45,7 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props)
setContentStyle(undefined);
}}
>
{connectors.map(({ id, name, logo }) => {
{connectors.map(({ id, name, logo, target }) => {
const languageKey = Object.keys(name).find((key) => key === language) ?? 'en';
const localName = name[languageKey as Language];
@ -53,7 +53,7 @@ const SocialSignInDropdown = ({ isOpen, onClose, connectors, anchorRef }: Props)
<DropdownItem
key={id}
onClick={() => {
void invokeSocialSignIn(id, onClose);
void invokeSocialSignIn(id, target, onClose);
}}
>
<img src={logo} alt={id} className={styles.socialLogo} />

View file

@ -34,7 +34,7 @@ const SocialSignInIconList = ({
className={styles.socialButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id);
void invokeSocialSignIn(connector.id, connector.target);
}}
/>
))}

View file

@ -42,7 +42,7 @@ const SocialSignInList = ({
className={styles.socialLinkButton}
connector={connector}
onClick={() => {
void invokeSocialSignIn(connector.id, onSocialSignInCallback);
void invokeSocialSignIn(connector.id, connector.target, onSocialSignInCallback);
}}
/>
))}

View file

@ -0,0 +1,92 @@
import camelcaseKeys from 'camelcase-keys';
import { useNavigate } from 'react-router-dom';
import { inOperator, parseQueryParameters } from '@/utils';
export const loadAppleSdk = async () =>
new Promise((resolve, reject) => {
const script = document.createElement('script');
script.addEventListener('load', resolve);
script.addEventListener('error', reject);
// eslint-disable-next-line @silverhand/fp/no-mutation
script.src =
'https://appleid.cdn-apple.com/appleauth/static/jsapi/appleid/1/en_US/appleid.auth.js';
document.head.append(script);
});
export const isAppleConnector = ({ target }: { target: string }) => target === 'apple';
// Derived from https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple
type AppleIdAuth = {
auth: {
init(
config: {
clientId?: string;
scope?: string;
redirectURI?: string;
state?: string;
nonce?: string;
usePopup: boolean;
} & Record<string, unknown>
): void;
signIn(): Promise<Record<string, unknown>>;
};
};
declare const AppleID: AppleIdAuth | undefined;
export const getAppleSdk = () => {
if (AppleID === undefined) {
throw new Error('AppleID auth SDK not found.');
}
return AppleID;
};
const useAppleAuth = () => {
const navigate = useNavigate();
const auth = async (connectorId: string, redirectUri: string) => {
const url = new URL(redirectUri);
const { redirect_uri: redirectURI, ...rest } = parseQueryParameters(url.searchParams);
const config = {
redirectURI: redirectURI ?? '',
...camelcaseKeys(rest),
};
const { auth } = getAppleSdk();
auth.init({ usePopup: true, ...config });
try {
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3331292
const data = await auth.signIn();
const { authorization } = data;
if (!authorization || typeof authorization !== 'object') {
throw new TypeError('Missing authorization object.');
}
const state = inOperator('state', authorization) ? String(authorization.state) : '';
const parameters = new URLSearchParams({
state,
// Due to the design limit of connectors, we have to use key `code`.
// TO-DO: @Darcy @Simeng update key after refactoring
code: JSON.stringify(data),
});
navigate(`/sign-in/callback/${connectorId}?${parameters.toString()}`);
} catch (error: unknown) {
// TO-DO: @Simeng handle error properly
// https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/configuring_your_webpage_for_sign_in_with_apple#3523993
console.log('error!', error);
}
};
return auth;
};
export default useAppleAuth;

View file

@ -3,6 +3,7 @@ import { useCallback, useContext } from 'react';
import { invokeSocialSignIn } from '@/apis/social';
import useApi from './use-api';
import useAppleAuth, { isAppleConnector } from './use-apple-auth';
import { PageContext } from './use-page-context';
import useTerms from './use-terms';
import { getLogtoNativeSdk, isNativeWebview, generateState, storeState } from './utils';
@ -10,11 +11,12 @@ import { getLogtoNativeSdk, isNativeWebview, generateState, storeState } from '.
const useSocial = () => {
const { experienceSettings } = useContext(PageContext);
const { termsValidation } = useTerms();
const appleAuth = useAppleAuth();
const { run: asyncInvokeSocialSignIn } = useApi(invokeSocialSignIn);
const invokeSocialSignInHandler = useCallback(
async (connectorId: string, callback?: () => void) => {
async (connectorId: string, target: string, callback?: () => void) => {
if (!(await termsValidation())) {
return;
}
@ -37,6 +39,13 @@ const useSocial = () => {
// Callback hook to close the social sign in modal
callback?.();
// For Sign In with Apple, use the official SDK directly
if (isAppleConnector({ target })) {
await appleAuth(connectorId, result.redirectTo);
return;
}
// Invoke Native Social Sign In flow
if (isNativeWebview()) {
getLogtoNativeSdk()?.getPostMessage()({

View file

@ -7,7 +7,7 @@ export const parseQueryParameters = (parameters: string | URLSearchParams) => {
const searchParameters =
parameters instanceof URLSearchParams ? parameters : new URLSearchParams(parameters);
return Object.fromEntries(searchParameters.entries());
return Object.fromEntries(searchParameters);
};
export const queryStringify = (parameters: URLSearchParams | Record<string, string>) => {
@ -31,3 +31,9 @@ type Entries<T> = Array<
>;
export const entries = <T>(object: T): Entries<T> => Object.entries(object) as Entries<T>;
// eslint-disable-next-line @typescript-eslint/ban-types
export const inOperator = <K extends string, T extends object>(
key: K,
object: T
): object is T & Record<K, unknown> => key in object;

View file

@ -6,6 +6,7 @@
import { SignInMethods } from '@logto/schemas';
import { getSignInExperience } from '@/apis/settings';
import { isAppleConnector } from '@/hooks/use-apple-auth';
import { filterSocialConnectors } from '@/hooks/utils';
import { SignInMethod, SignInExperienceSettingsResponse, SignInExperienceSettings } from '@/types';
@ -28,6 +29,14 @@ export const getSecondarySignInMethods = (signInMethods: SignInMethods) =>
return methods;
}, []);
export const isAppleConnectorEnabled = ({
primarySignInMethod,
secondarySignInMethods,
socialConnectors,
}: SignInExperienceSettings) =>
(primarySignInMethod === 'social' || secondarySignInMethods.includes('social')) &&
socialConnectors.some((connector) => isAppleConnector(connector));
const getSignInExperienceSettings = async (): Promise<SignInExperienceSettings> => {
const { signInMethods, socialConnectors, ...rest } =
await getSignInExperience<SignInExperienceSettingsResponse>();

334
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff