diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 80f4a9b660..038c79873b 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -31,6 +31,11 @@ class SignInCredential { password?: string; } +class MagicLinkCredential { + email!: string; + token!: string; +} + @Throttle('strict') @Controller('/api/auth') export class AuthController { @@ -90,7 +95,7 @@ export class AuthController { ) { const token = await this.token.createToken(TokenType.SignIn, email); - const magicLink = this.url.link('/api/auth/magic-link', { + const magicLink = this.url.link('/magic-link', { token, email, redirect_uri: redirectUri, @@ -129,20 +134,16 @@ export class AuthController { } @Public() - @Get('/magic-link') + @Post('/magic-link') async magicLinkSignIn( @Req() req: Request, @Res() res: Response, - @Query('token') token?: string, - @Query('email') email?: string, - @Query('redirect_uri') redirectUri = this.url.home + @Body() { email, token }: MagicLinkCredential ) { if (!token || !email) { - throw new BadRequestException('Invalid Sign-in mail Token'); + throw new BadRequestException('Missing sign-in mail token'); } - email = decodeURIComponent(email); - token = decodeURIComponent(token); validators.assertValidEmail(email); const valid = await this.token.verifyToken(TokenType.SignIn, token, { @@ -150,7 +151,7 @@ export class AuthController { }); if (!valid) { - throw new BadRequestException('Invalid Sign-in mail Token'); + throw new BadRequestException('Invalid sign-in mail token'); } const user = await this.user.fulfillUser(email, { @@ -160,7 +161,7 @@ export class AuthController { await this.auth.setCookie(req, res, user); - return this.url.safeRedirect(res, redirectUri); + res.send({ id: user.id, email: user.email, name: user.name }); } @Throttle('default', { limit: 1200 }) diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts index 3027a7b90a..16c10d1204 100644 --- a/packages/backend/server/src/core/auth/token.ts +++ b/packages/backend/server/src/core/auth/token.ts @@ -70,14 +70,17 @@ export class TokenService { !expired && (!record.credential || record.credential === credential); if ((expired || valid) && !keep) { - await this.db.verificationToken.delete({ + const deleted = await this.db.verificationToken.deleteMany({ where: { - type_token: { - token, - type, - }, + token, + type, }, }); + + // already deleted, means token has been used + if (!deleted.count) { + return null; + } } return valid ? record : null; diff --git a/packages/backend/server/src/fundamentals/utils/request.ts b/packages/backend/server/src/fundamentals/utils/request.ts index 21f4ed520f..37d965e3eb 100644 --- a/packages/backend/server/src/fundamentals/utils/request.ts +++ b/packages/backend/server/src/fundamentals/utils/request.ts @@ -26,7 +26,7 @@ export function getRequestResponseFromHost(host: ArgumentsHost) { const ws = host.switchToWs(); const req = ws.getClient().client.conn.request as Request; - const cookieStr = req?.headers?.cookie; + const cookieStr = req?.headers?.cookie ?? ''; // patch cookies to match auth guard logic if (typeof cookieStr === 'string') { req.cookies = cookieStr.split(';').reduce( diff --git a/packages/backend/server/tests/auth/controller.spec.ts b/packages/backend/server/tests/auth/controller.spec.ts index b8a967957c..2c08c6729d 100644 --- a/packages/backend/server/tests/auth/controller.spec.ts +++ b/packages/backend/server/tests/auth/controller.spec.ts @@ -69,13 +69,15 @@ test('should be able to sign in with email', async t => { t.is(res.body.email, u1.email); t.true(mailer.sendSignInMail.calledOnce); - let [signInLink] = mailer.sendSignInMail.firstCall.args; + const [signInLink] = mailer.sendSignInMail.firstCall.args; const url = new URL(signInLink); - signInLink = url.pathname + url.search; + const email = url.searchParams.get('email'); + const token = url.searchParams.get('token'); const signInRes = await request(app.getHttpServer()) - .get(signInLink) - .expect(302); + .post('/api/auth/magic-link') + .send({ email, token }) + .expect(201); const session = await getSession(app, signInRes); t.is(session.user!.id, u1.id); @@ -95,13 +97,15 @@ test('should be able to sign up with email', async t => { t.is(res.body.email, 'u2@affine.pro'); t.true(mailer.sendSignUpMail.calledOnce); - let [signUpLink] = mailer.sendSignUpMail.firstCall.args; + const [signUpLink] = mailer.sendSignUpMail.firstCall.args; const url = new URL(signUpLink); - signUpLink = url.pathname + url.search; + const email = url.searchParams.get('email'); + const token = url.searchParams.get('token'); const signInRes = await request(app.getHttpServer()) - .get(signUpLink) - .expect(302); + .post('/api/auth/magic-link') + .send({ email, token }) + .expect(201); const session = await getSession(app, signInRes); t.is(session.user!.email, 'u2@affine.pro'); diff --git a/packages/frontend/core/src/pages/magic-link.tsx b/packages/frontend/core/src/pages/magic-link.tsx new file mode 100644 index 0000000000..e3b343e892 --- /dev/null +++ b/packages/frontend/core/src/pages/magic-link.tsx @@ -0,0 +1,40 @@ +import { type LoaderFunction, redirect } from 'react-router-dom'; + +export const loader: LoaderFunction = async ({ request }) => { + const url = new URL(request.url); + const queries = url.searchParams; + const email = queries.get('email'); + const token = queries.get('token'); + const redirectUri = queries.get('redirect_uri'); + + if (!email || !token) { + return redirect('/404'); + } + + const res = await fetch('/api/auth/magic-link', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ email, token }), + }); + + if (!res.ok) { + let error: string; + try { + const { message } = await res.json(); + error = message; + } catch (e) { + error = 'failed to verify sign-in token'; + } + return redirect(`/signIn?error=${encodeURIComponent(error)}`); + } + + location.href = redirectUri || '/'; + return null; +}; + +export const Component = () => { + // TODO: loading ui + return null; +}; diff --git a/packages/frontend/core/src/router.tsx b/packages/frontend/core/src/router.tsx index 44179d521c..fe7acb81d6 100644 --- a/packages/frontend/core/src/router.tsx +++ b/packages/frontend/core/src/router.tsx @@ -76,6 +76,10 @@ export const topLevelRoutes = [ path: '/signIn', lazy: () => import('./pages/sign-in'), }, + { + path: '/magic-link', + lazy: () => import('./pages/magic-link'), + }, { path: '/open-app/:action', lazy: () => import('./pages/open-app'),