refactor(core): auth (#7999)

closes AF-753 AF-1227
This commit is contained in:
forehalo 2024-09-03 09:03:43 +00:00
parent 8b0afd6eeb
commit e33aa35f7e
No known key found for this signature in database
GPG Key ID: 56709255DC7EC728
18 changed files with 286 additions and 166 deletions

View File

@ -126,6 +126,8 @@ export const OnboardingPage = ({
return null;
}
// deprecated
// TODO(@forehalo): remove
if (callbackUrl?.startsWith('/open-app/signin-redirect')) {
const url = new URL(callbackUrl, window.location.origin);
url.searchParams.set('next', 'onboarding');

View File

@ -30,7 +30,7 @@ const OAuthProviderMap: Record<
},
};
export function OAuth({ redirectUri }: { redirectUri?: string | null }) {
export function OAuth() {
const serverConfig = useService(ServerConfigService).serverConfig;
const oauth = useLiveData(serverConfig.features$.map(r => r?.oauth));
const oauthProviders = useLiveData(
@ -47,21 +47,11 @@ export function OAuth({ redirectUri }: { redirectUri?: string | null }) {
}
return oauthProviders?.map(provider => (
<OAuthProvider
key={provider}
provider={provider}
redirectUri={redirectUri}
/>
<OAuthProvider key={provider} provider={provider} />
));
}
function OAuthProvider({
provider,
redirectUri,
}: {
provider: OAuthProviderType;
redirectUri?: string | null;
}) {
function OAuthProvider({ provider }: { provider: OAuthProviderType }) {
const { icon } = OAuthProviderMap[provider];
const authService = useService(AuthService);
const [isConnecting, setIsConnecting] = useState(false);
@ -69,7 +59,7 @@ function OAuthProvider({
const onClick = useAsyncCallback(async () => {
try {
setIsConnecting(true);
await authService.signInOauth(provider, redirectUri);
await authService.signInOauth(provider);
} catch (err) {
console.error(err);
notify.error({ title: 'Failed to sign in, please try again.' });
@ -77,7 +67,7 @@ function OAuthProvider({
setIsConnecting(false);
track.$.$.auth.oauth({ provider });
}
}, [authService, provider, redirectUri]);
}, [authService, provider]);
return (
<Button

View File

@ -38,6 +38,7 @@ export const SignIn: FC<AuthPanelProps> = ({
const [isValidEmail, setIsValidEmail] = useState(true);
const { openModal } = useAtomValue(authAtom);
const errorMsg = searchParams.get('error');
useEffect(() => {
const timeout = setInterval(() => {
@ -65,32 +66,22 @@ export const SignIn: FC<AuthPanelProps> = ({
setAuthEmail(email);
try {
const { hasPassword, isExist: isUserExist } =
const { hasPassword, registered } =
await authService.checkUserByEmail(email);
if (verifyToken) {
if (isUserExist) {
if (registered) {
// provider password sign-in if user has by default
// If with payment, onl support email sign in to avoid redirect to affine app
if (hasPassword) {
setAuthState('signInWithPassword');
} else {
track.$.$.auth.signIn();
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
searchParams.get('redirect_uri')
);
await authService.sendEmailMagicLink(email, verifyToken, challenge);
setAuthState('afterSignInSendEmail');
}
} else {
await authService.sendEmailMagicLink(
email,
verifyToken,
challenge,
searchParams.get('redirect_uri')
);
await authService.sendEmailMagicLink(email, verifyToken, challenge);
track.$.$.auth.signUp();
setAuthState('afterSignUpSendEmail');
}
@ -105,15 +96,7 @@ export const SignIn: FC<AuthPanelProps> = ({
}
setIsMutating(false);
}, [
authService,
challenge,
email,
searchParams,
setAuthEmail,
setAuthState,
verifyToken,
]);
}, [authService, challenge, email, setAuthEmail, setAuthState, verifyToken]);
return (
<>
@ -122,7 +105,7 @@ export const SignIn: FC<AuthPanelProps> = ({
subTitle={t['com.affine.brand.affineCloud']()}
/>
<OAuth redirectUri={searchParams.get('redirect_uri')} />
<OAuth />
<div className={style.authModalContent}>
<AuthInput
@ -142,8 +125,6 @@ export const SignIn: FC<AuthPanelProps> = ({
onEnter={onContinue}
/>
{verifyToken ? null : <Captcha />}
{verifyToken ? (
<Button
style={{ width: '100%' }}
@ -157,7 +138,11 @@ export const SignIn: FC<AuthPanelProps> = ({
>
{t['com.affine.auth.sign.email.continue']()}
</Button>
) : null}
) : (
<Captcha />
)}
{errorMsg && <div className={style.errorMessage}>{errorMsg}</div>}
<div className={style.authMessage}>
{/*prettier-ignore*/}

View File

@ -14,6 +14,14 @@ export const authMessage = style({
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
export const errorMessage = style({
marginTop: '30px',
color: cssVar('textHighlightForegroundRed'),
fontSize: cssVar('fontXs'),
lineHeight: 1.5,
});
globalStyle(`${authMessage} a`, {
color: cssVar('linkColor'),
});

View File

@ -1,4 +1,5 @@
import { AIProvider } from '@affine/core/blocksuite/presets/ai';
import { buildAppUrl, popupWindow } from '@affine/core/utils';
import { apis, appInfo } from '@affine/electron-api';
import type { OAuthProviderType } from '@affine/graphql';
import {
@ -80,72 +81,82 @@ export class AuthService extends Service {
async sendEmailMagicLink(
email: string,
verifyToken: string,
challenge?: string,
redirectUri?: string | null
challenge?: string
) {
const searchParams = new URLSearchParams();
if (challenge) {
searchParams.set('challenge', challenge);
}
searchParams.set('token', verifyToken);
const redirect = environment.isDesktop
? this.buildRedirectUri('/open-app/signin-redirect')
: (redirectUri ?? location.href);
searchParams.set('redirect_uri', redirect.toString());
const res = await this.fetchService.fetch(
'/api/auth/sign-in?' + searchParams.toString(),
{
method: 'POST',
body: JSON.stringify({ email }),
body: JSON.stringify({
email,
// we call it [callbackUrl] instead of [redirect_uri]
// to make it clear the url is used to finish the sign-in process instead of redirect after signed-in
callbackUrl: buildAppUrl('/magic-link', {
desktop: environment.isDesktop,
openInHiddenWindow: true,
redirectFromWeb: true,
}),
}),
headers: {
'content-type': 'application/json',
},
}
);
if (!res?.ok) {
if (!res.ok) {
throw new Error('Failed to send email');
}
}
async signInOauth(provider: OAuthProviderType, redirectUri?: string | null) {
async signInOauth(provider: OAuthProviderType) {
const res = await this.fetchService.fetch('/api/oauth/preflight', {
method: 'POST',
body: JSON.stringify({ provider }),
headers: {
'content-type': 'application/json',
},
});
if (!res.ok) {
throw new Error(`Failed to sign in with ${provider}`);
}
let { url } = await res.json();
// change `state=xxx` to `state={state:xxx,native:true}`
// so we could know the callback should be redirect to native app
const oauthUrl = new URL(url);
oauthUrl.searchParams.set(
'state',
JSON.stringify({
state: oauthUrl.searchParams.get('state'),
client: environment.isDesktop ? appInfo?.schema : 'web',
})
);
url = oauthUrl.toString();
if (environment.isDesktop) {
await apis?.ui.openExternal(
`${
runtimeConfig.serverUrlPrefix
}/desktop-signin?provider=${provider}&redirect_uri=${this.buildRedirectUri(
'/open-app/signin-redirect'
)}`
);
await apis?.ui.openExternal(url);
} else {
location.href = `${
runtimeConfig.serverUrlPrefix
}/oauth/login?provider=${provider}&redirect_uri=${encodeURIComponent(
redirectUri ?? location.pathname
)}`;
popupWindow(url);
}
return;
}
async signInPassword(credential: { email: string; password: string }) {
const searchParams = new URLSearchParams();
const redirectUri = new URL(location.href);
if (environment.isDesktop) {
redirectUri.pathname = this.buildRedirectUri('/open-app/signin-redirect');
}
searchParams.set('redirect_uri', redirectUri.toString());
const res = await this.fetchService.fetch(
'/api/auth/sign-in?' + searchParams.toString(),
{
method: 'POST',
body: JSON.stringify(credential),
headers: {
'content-type': 'application/json',
},
}
);
const res = await this.fetchService.fetch('/api/auth/sign-in', {
method: 'POST',
body: JSON.stringify(credential),
headers: {
'content-type': 'application/json',
},
});
if (!res.ok) {
throw new Error('Failed to sign in');
}
@ -158,19 +169,6 @@ export class AuthService extends Service {
this.session.revalidate();
}
private buildRedirectUri(callbackUrl: string) {
const params: string[][] = [];
if (environment.isDesktop && appInfo?.schema) {
params.push(['schema', appInfo.schema]);
}
const query =
params.length > 0
? '?' +
params.map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&')
: '';
return callbackUrl + query;
}
checkUserByEmail(email: string) {
return this.store.checkUserByEmail(email);
}

View File

@ -1,5 +1,4 @@
import {
getUserQuery,
removeAvatarMutation,
updateUserProfileMutation,
uploadAvatarMutation,
@ -81,15 +80,23 @@ export class AuthStore extends Store {
}
async checkUserByEmail(email: string) {
const data = await this.gqlService.gql({
query: getUserQuery,
variables: {
email,
const res = await this.fetchService.fetch('/api/auth/preflight', {
method: 'POST',
body: JSON.stringify({ email }),
headers: {
'content-type': 'application/json',
},
});
return {
isExist: !!data.user,
hasPassword: !!data.user?.hasPassword,
if (!res.ok) {
throw new Error(`Failed to check user by email: ${email}`);
}
const data = (await res.json()) as {
registered: boolean;
hasPassword: boolean;
};
return data;
}
}

View File

@ -1,5 +1,9 @@
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type LoaderFunction, redirect } from 'react-router-dom';
import { AuthService } from '../modules/cloud';
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
@ -35,6 +39,17 @@ export const loader: LoaderFunction = async ({ request }) => {
};
export const Component = () => {
const service = useService(AuthService);
const user = useLiveData(service.session.account$);
useEffect(() => {
service.session.revalidate();
}, [service]);
// TODO(@pengx17): window.close() in electron hidden window will close main window as well
if (!environment.isDesktop && user) {
window.close();
}
// TODO(@eyhn): loading ui
return null;
};

View File

@ -0,0 +1,74 @@
import { useLiveData, useService } from '@toeverything/infra';
import { useEffect } from 'react';
import { type LoaderFunction, redirect } from 'react-router-dom';
import { AuthService } from '../modules/cloud';
export const loader: LoaderFunction = async ({ request }) => {
const url = new URL(request.url);
const queries = url.searchParams;
const code = queries.get('code');
let stateStr = queries.get('state') ?? '{}';
let error: string | undefined;
try {
const { state, client } = JSON.parse(stateStr);
stateStr = state;
// bypass code & state to redirect_uri
if (!environment.isDesktop && client && client !== 'web') {
url.searchParams.set('state', JSON.stringify({ state }));
return redirect(
`/open-app/url?url=${encodeURIComponent(`${client}://${url.pathname}${url.search}`)}&hidden=true`
);
}
} catch {
error = 'Invalid oauth callback parameters';
}
const res = await fetch('/api/oauth/callback', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code, state: stateStr }),
});
if (!res.ok) {
try {
const { message } = await res.json();
error = message;
} catch {
error = 'failed to verify sign-in token';
}
}
if (error) {
// TODO(@pengx17): in desktop app, the callback page will be opened in a hidden window
// how could we tell the main window to show the error message?
return redirect(`/signIn?error=${encodeURIComponent(error)}`);
} else {
const body = await res.json();
/* @deprecated handle for old client */
if (body.redirect_uri) {
return redirect(body.redirect_uri);
}
}
return null;
};
export const Component = () => {
const service = useService(AuthService);
const user = useLiveData(service.session.account$);
useEffect(() => {
service.session.revalidate();
}, [service]);
// TODO(@pengx17): window.close() in electron hidden window will close main window as well
if (!environment.isDesktop && user) {
window.close();
}
return null;
};

View File

@ -148,24 +148,31 @@ const OpenAppImpl = ({ urlToOpen, channel }: OpenAppProps) => {
const OpenUrl = () => {
const [params] = useSearchParams();
const urlToOpen = useMemo(() => params.get('url'), [params]);
const channel = useMemo(() => {
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
return schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
}, [urlToOpen]);
const urlToOpen = params.get('url');
params.delete('url');
return <OpenAppImpl urlToOpen={urlToOpen} channel={channel} />;
const urlObj = new URL(urlToOpen || '');
const maybeSchema = appSchemas.safeParse(urlObj.protocol.replace(':', ''));
const channel =
schemaToChanel[maybeSchema.success ? maybeSchema.data : 'affine'];
params.forEach((v, k) => {
urlObj.searchParams.set(k, v);
});
return <OpenAppImpl urlToOpen={urlObj.toString()} channel={channel} />;
};
/**
* @deprecated
*/
const OpenOAuthJwt = () => {
const { currentUser } = useLoaderData() as LoaderData;
const [params] = useSearchParams();
const schema = useMemo(() => {
const maybeSchema = appSchemas.safeParse(params.get('schema'));
return maybeSchema.success ? maybeSchema.data : 'affine';
}, [params]);
const next = useMemo(() => params.get('next'), [params]);
const maybeSchema = appSchemas.safeParse(params.get('schema'));
const schema = maybeSchema.success ? maybeSchema.data : 'affine';
const next = params.get('next');
const channel = schemaToChanel[schema as Schema];
if (!currentUser || !currentUser?.token?.sessionToken) {

View File

@ -2,6 +2,7 @@ import { DebugLogger } from '@affine/debug';
import { type LoaderFunction, Navigate, useLoaderData } from 'react-router-dom';
const trustedDomain = [
'google.com',
'stripe.com',
'github.com',
'twitter.com',

View File

@ -74,10 +74,6 @@ export const topLevelRoutes = [
path: '/magic-link',
lazy: () => import('./pages/magic-link'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
},
{
path: '/upgrade-success',
lazy: () => import('./pages/upgrade-success'),
@ -86,10 +82,6 @@ export const topLevelRoutes = [
path: '/ai-upgrade-success',
lazy: () => import('./pages/ai-upgrade-success'),
},
{
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
},
{
path: '/onboarding',
lazy: () => import('./pages/onboarding'),
@ -118,6 +110,20 @@ export const topLevelRoutes = [
path: '/template/import',
lazy: () => import('./pages/import-template'),
},
{
path: '/oauth/callback',
lazy: () => import('./pages/oauth-callback'),
},
{
path: '/open-app/:action',
lazy: () => import('./pages/open-app'),
},
// deprecated, keep for old client compatibility
// TODO(@forehalo): remove
{
path: '/desktop-signin',
lazy: () => import('./pages/desktop-signin'),
},
{
path: '*',
lazy: () => import('./pages/404'),

View File

@ -6,3 +6,4 @@ export * from './popup';
export * from './string2color';
export * from './toast';
export * from './unflatten-object';
export * from './url';

View File

@ -0,0 +1,33 @@
import { appInfo } from '@affine/electron-api';
interface AppUrlOptions {
desktop?: boolean | string;
openInHiddenWindow?: boolean;
redirectFromWeb?: boolean;
}
export function buildAppUrl(path: string, opts: AppUrlOptions = {}) {
// TODO(@EYHN): should use server base url
const webBase = runtimeConfig.serverUrlPrefix;
// TODO(@pengx17): how could we know the corresponding app schema in web environment
if (opts.desktop && appInfo?.schema) {
const urlCtor = new URL(path, webBase);
if (opts.openInHiddenWindow) {
urlCtor.searchParams.set('hidden', 'true');
}
const url = `${appInfo.schema}://${urlCtor.pathname}${urlCtor.search}`;
if (opts.redirectFromWeb) {
const redirect_uri = new URL('/open-app/url', webBase);
redirect_uri.searchParams.set('url', url);
return redirect_uri.toString();
}
return url;
} else {
return new URL(path, webBase).toString();
}
}

View File

@ -39,6 +39,8 @@ const desktopWhiteList = [
'/upgrade-success',
'/ai-upgrade-success',
'/share',
'/oauth',
'/magic-link',
];
if (
!environment.isDesktop &&

View File

@ -2,13 +2,13 @@ import path from 'node:path';
import type { App } from 'electron';
import { buildType, CLOUD_BASE_URL, isDev } from './config';
import { buildType, isDev } from './config';
import { mainWindowOrigin } from './constants';
import { logger } from './logger';
import {
getMainWindow,
handleOpenUrlInHiddenWindow,
setCookie,
openUrlInHiddenWindow,
openUrlInMainWindow,
} from './windows-manager';
let protocol = buildType === 'stable' ? 'affine' : `affine-${buildType}`;
@ -61,51 +61,28 @@ async function handleAffineUrl(url: string) {
logger.info('open affine url', url);
const urlObj = new URL(url);
logger.info('handle affine schema action', urlObj.hostname);
// handle more actions here
// hostname is the action name
if (urlObj.hostname === 'signin-redirect') {
await handleOauthJwt(url);
}
if (urlObj.hostname === 'bring-to-front') {
const mainWindow = await getMainWindow();
if (mainWindow) {
mainWindow.show();
}
} else {
await openUrl(urlObj);
}
}
async function handleOauthJwt(url: string) {
const mainWindow = await getMainWindow();
if (url && mainWindow) {
try {
mainWindow.show();
const urlObj = new URL(url);
const token = urlObj.searchParams.get('token');
async function openUrl(urlObj: URL) {
const params = urlObj.searchParams;
if (!token) {
logger.error('no token in url', url);
return;
}
const openInHiddenWindow = params.get('hidden');
params.delete('hidden');
// set token to cookie
await setCookie({
url: CLOUD_BASE_URL,
httpOnly: true,
value: token,
secure: true,
name: 'affine_session',
expirationDate: Math.floor(
Date.now() / 1000 +
3600 *
24 *
399 /* as long as possible, cookie max expires is 400 days */
),
});
// hacks to refresh auth state in the main window
await handleOpenUrlInHiddenWindow(mainWindowOrigin + '/auth/signIn');
} catch (e) {
logger.error('failed to open url in popup', e);
}
const url = mainWindowOrigin + urlObj.pathname + '?' + params.toString();
if (!openInHiddenWindow) {
await openUrlInHiddenWindow(url);
} else {
// TODO(@pengx17): somehow the page won't load the url passed, help needed
await openUrlInMainWindow(url);
}
}

View File

@ -229,16 +229,15 @@ export async function showMainWindow() {
/**
* Open a URL in a hidden window.
* This is useful for opening a URL in the background without user interaction for *authentication*.
*/
export async function handleOpenUrlInHiddenWindow(url: string) {
export async function openUrlInHiddenWindow(url: string) {
const win = new BrowserWindow({
width: 1200,
height: 600,
webPreferences: {
preload: join(__dirname, './preload.js'),
},
show: false,
show: environment.isDebug,
});
win.on('close', e => {
e.preventDefault();
@ -250,3 +249,11 @@ export async function handleOpenUrlInHiddenWindow(url: string) {
await win.loadURL(url);
return win;
}
export async function openUrlInMainWindow(url: string) {
const mainWindow = await getMainWindow();
if (mainWindow) {
mainWindow.show();
await mainWindow.loadURL(url);
}
}

View File

@ -29,12 +29,20 @@ export function getElectronAPIs() {
};
}
type Schema =
| 'affine'
| 'affine-canary'
| 'affine-beta'
| 'affine-internal'
| 'affine-dev';
// todo: remove duplicated codes
const ReleaseTypeSchema = z.enum(['stable', 'beta', 'canary', 'internal']);
const envBuildType = (process.env.BUILD_TYPE || 'canary').trim().toLowerCase();
const buildType = ReleaseTypeSchema.parse(envBuildType);
const isDev = process.env.NODE_ENV === 'development';
let schema = buildType === 'stable' ? 'affine' : `affine-${envBuildType}`;
let schema =
buildType === 'stable' ? 'affine' : (`affine-${envBuildType}` as Schema);
schema = isDev ? 'affine-dev' : schema;
export const appInfo = {
@ -45,7 +53,7 @@ export const appInfo = {
viewId:
process.argv.find(arg => arg.startsWith('--view-id='))?.split('=')[1] ??
'unknown',
schema: `${schema}`,
schema,
};
function getMainAPIs() {

View File

@ -412,7 +412,6 @@ export const createConfiguration: (
{ context: '/api', target: 'http://localhost:3010' },
{ context: '/socket.io', target: 'http://localhost:3010', ws: true },
{ context: '/graphql', target: 'http://localhost:3010' },
{ context: '/oauth', target: 'http://localhost:3010' },
],
} as DevServerConfiguration,
} satisfies webpack.Configuration;