Skip to content

Commit

Permalink
fix(server): use post request to consume magic link token (#6656)
Browse files Browse the repository at this point in the history
  • Loading branch information
forehalo committed May 6, 2024
1 parent 5fdd0ac commit 7accf1c
Show file tree
Hide file tree
Showing 6 changed files with 76 additions and 24 deletions.
21 changes: 11 additions & 10 deletions packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ class SignInCredential {
password?: string;
}

class MagicLinkCredential {
email!: string;
token!: string;
}

@Controller('/api/auth')
export class AuthController {
constructor(
Expand Down Expand Up @@ -85,7 +90,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,
Expand Down Expand Up @@ -124,28 +129,24 @@ 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, {
credential: email,
});

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, {
Expand All @@ -155,7 +156,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 });
}

@Public()
Expand Down
13 changes: 8 additions & 5 deletions packages/backend/server/src/core/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/server/src/fundamentals/utils/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ export function getRequestResponseFromHost(host: ArgumentsHost) {
const ws = host.switchToWs();
const req = ws.getClient<Socket>().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(
Expand Down
20 changes: 12 additions & 8 deletions packages/backend/server/tests/auth/controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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');
Expand Down
40 changes: 40 additions & 0 deletions packages/frontend/core/src/pages/magic-link.tsx
Original file line number Diff line number Diff line change
@@ -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;
};
4 changes: 4 additions & 0 deletions packages/frontend/core/src/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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'),
Expand Down

0 comments on commit 7accf1c

Please sign in to comment.