refactor(server): improve magic link login flow (#10736)

This commit is contained in:
fengmk2
2025-03-12 06:53:29 +00:00
parent 867ae7933f
commit 43712839fd
9 changed files with 149 additions and 15 deletions

View File

@@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto';
import { HttpStatus } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import ava, { TestFn } from 'ava';
@@ -249,3 +251,96 @@ test('should be able to sign out multiple accounts in one session', async t => {
session = await app.GET('/api/auth/session').expect(200);
t.falsy(session.body.user);
});
test('should be able to sign in with email and client nonce', async t => {
const { app, mailer } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, client_nonce: clientNonce })
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
await app
.POST('/api/auth/magic-link')
.send({ email, token, client_nonce: clientNonce })
.expect(201);
const session = await currentUser(app);
t.is(session?.id, u1.id);
});
test('should not be able to sign in with email and client nonce if invalid', async t => {
const { app, mailer } = t.context;
const clientNonce = randomUUID();
const u1 = await app.createUser();
// @ts-expect-error mock
mailer.sendSignInMail.resolves({ rejected: [] });
const res = await app
.POST('/api/auth/sign-in')
.send({ email: u1.email, client_nonce: clientNonce })
.expect(200);
t.is(res.body.email, u1.email);
t.true(mailer.sendSignInMail.calledOnce);
const [, { url: signInLink }] = mailer.sendSignInMail.firstCall.args;
const url = new URL(signInLink);
const email = url.searchParams.get('email');
const token = url.searchParams.get('token');
// invalid client nonce
await app
.POST('/api/auth/magic-link')
.send({ email, token, client_nonce: randomUUID() })
.expect(400)
.expect({
status: 400,
code: 'Bad Request',
type: 'BAD_REQUEST',
name: 'INVALID_AUTH_STATE',
message:
'Invalid auth state. You might start the auth progress from another device.',
});
// no client nonce
await app
.POST('/api/auth/magic-link')
.send({ email, token })
.expect(400)
.expect({
status: 400,
code: 'Bad Request',
type: 'BAD_REQUEST',
name: 'INVALID_AUTH_STATE',
message:
'Invalid auth state. You might start the auth progress from another device.',
});
const session = await currentUser(app);
t.falsy(session);
});
test('should not be able to sign in if token is invalid', async t => {
const { app } = t.context;
const res = await app
.POST('/api/auth/magic-link')
.send({ email: 'u1@affine.pro', token: 'invalid' })
.expect(400);
t.is(res.body.message, 'An invalid email token provided.');
});

View File

@@ -21,6 +21,7 @@ import {
EarlyAccessRequired,
EmailTokenNotFound,
InternalServerError,
InvalidAuthState,
InvalidEmail,
InvalidEmailToken,
Runtime,
@@ -45,11 +46,13 @@ interface SignInCredential {
email: string;
password?: string;
callbackUrl?: string;
client_nonce?: string;
}
interface MagicLinkCredential {
email: string;
token: string;
client_nonce?: string;
}
const OTP_CACHE_KEY = (otp: string) => `magic-link-otp:${otp}`;
@@ -140,7 +143,8 @@ export class AuthController {
res,
credential.email,
credential.callbackUrl,
redirectUri
redirectUri,
credential.client_nonce
);
}
}
@@ -162,7 +166,8 @@ export class AuthController {
res: Response,
email: string,
callbackUrl = '/magic-link',
redirectUrl?: string
redirectUrl?: string,
clientNonce?: string
) {
// send email magic link
const user = await this.models.user.getUserByEmail(email);
@@ -210,7 +215,11 @@ export class AuthController {
const otp = this.crypto.otp();
// TODO(@forehalo): this is a temporary solution, we should not rely on cache to store the otp
const cacheKey = OTP_CACHE_KEY(otp);
await this.cache.set(cacheKey, token, { ttl: ttlInSec * 1000 });
await this.cache.set(
cacheKey,
{ token, clientNonce },
{ ttl: ttlInSec * 1000 }
);
const magicLink = this.url.link(callbackUrl, {
token: otp,
@@ -266,24 +275,37 @@ export class AuthController {
async magicLinkSignIn(
@Req() req: Request,
@Res() res: Response,
@Body() { email, token }: MagicLinkCredential
@Body()
{ email, token: otp, client_nonce: clientNonce }: MagicLinkCredential
) {
if (!token || !email) {
if (!otp || !email) {
throw new EmailTokenNotFound();
}
validators.assertValidEmail(email);
const cacheKey = OTP_CACHE_KEY(token);
const cachedToken = await this.cache.get<string>(cacheKey);
const cacheKey = OTP_CACHE_KEY(otp);
const cachedToken = await this.cache.get<
{ token: string; clientNonce: string } | string
>(cacheKey);
let token: string | undefined;
// TODO(@fengmk2): this is a temporary compatible with cache token is string value, should be removed in 0.22
if (typeof cachedToken === 'string') {
token = cachedToken;
} else if (cachedToken) {
token = cachedToken.token;
if (cachedToken.clientNonce && cachedToken.clientNonce !== clientNonce) {
throw new InvalidAuthState();
}
}
if (!cachedToken) {
if (!token) {
throw new InvalidEmailToken();
}
const tokenRecord = await this.models.verificationToken.verify(
TokenType.SignIn,
cachedToken,
token,
{
credential: email,
}