1
1
import { ArrowLeftIcon , EnvelopeIcon } from "@heroicons/react/20/solid" ;
2
2
import { InboxArrowDownIcon } from "@heroicons/react/24/solid" ;
3
- import type { ActionFunctionArgs , LoaderFunctionArgs , MetaFunction } from "@remix-run/node" ;
4
- import { redirect } from "@remix-run/node" ;
3
+ import {
4
+ redirect ,
5
+ type ActionFunctionArgs ,
6
+ type LoaderFunctionArgs ,
7
+ type MetaFunction ,
8
+ } from "@remix-run/node" ;
5
9
import { Form , useNavigation } from "@remix-run/react" ;
6
10
import { typedjson , useTypedLoaderData } from "remix-typedjson" ;
7
11
import { z } from "zod" ;
@@ -18,6 +22,14 @@ import { Spinner } from "~/components/primitives/Spinner";
18
22
import { TextLink } from "~/components/primitives/TextLink" ;
19
23
import { authenticator } from "~/services/auth.server" ;
20
24
import { commitSession , getUserSession } from "~/services/sessionStorage.server" ;
25
+ import {
26
+ checkMagicLinkEmailRateLimit ,
27
+ checkMagicLinkEmailDailyRateLimit ,
28
+ MagicLinkRateLimitError ,
29
+ checkMagicLinkIpRateLimit ,
30
+ } from "~/services/magicLinkRateLimiter.server" ;
31
+ import { logger , tryCatch } from "@trigger.dev/core/v3" ;
32
+ import { env } from "~/env.server" ;
21
33
22
34
export const meta : MetaFunction = ( { matches } ) => {
23
35
const parentMeta = matches
@@ -71,29 +83,99 @@ export async function action({ request }: ActionFunctionArgs) {
71
83
72
84
const payload = Object . fromEntries ( await clonedRequest . formData ( ) ) ;
73
85
74
- const { action } = z
75
- . object ( {
76
- action : z . enum ( [ "send" , "reset" ] ) ,
77
- } )
86
+ const data = z
87
+ . discriminatedUnion ( "action" , [
88
+ z . object ( {
89
+ action : z . literal ( "send" ) ,
90
+ email : z . string ( ) . trim ( ) . toLowerCase ( ) ,
91
+ } ) ,
92
+ z . object ( {
93
+ action : z . literal ( "reset" ) ,
94
+ } ) ,
95
+ ] )
78
96
. parse ( payload ) ;
79
97
80
- if ( action === "send" ) {
81
- return authenticator . authenticate ( "email-link" , request , {
82
- successRedirect : "/login/magic" ,
83
- failureRedirect : "/login/magic" ,
84
- } ) ;
85
- } else {
86
- const session = await getUserSession ( request ) ;
87
- session . unset ( "triggerdotdev:magiclink" ) ;
88
-
89
- return redirect ( "/login/magic" , {
90
- headers : {
91
- "Set-Cookie" : await commitSession ( session ) ,
92
- } ,
93
- } ) ;
98
+ switch ( data . action ) {
99
+ case "send" : {
100
+ if ( ! env . LOGIN_RATE_LIMITS_ENABLED ) {
101
+ return authenticator . authenticate ( "email-link" , request , {
102
+ successRedirect : "/login/magic" ,
103
+ failureRedirect : "/login/magic" ,
104
+ } ) ;
105
+ }
106
+
107
+ const { email } = data ;
108
+ const xff = request . headers . get ( "x-forwarded-for" ) ;
109
+ const clientIp = extractClientIp ( xff ) ;
110
+
111
+ const [ error ] = await tryCatch (
112
+ Promise . all ( [
113
+ clientIp ? checkMagicLinkIpRateLimit ( clientIp ) : Promise . resolve ( ) ,
114
+ checkMagicLinkEmailRateLimit ( email ) ,
115
+ checkMagicLinkEmailDailyRateLimit ( email ) ,
116
+ ] )
117
+ ) ;
118
+
119
+ if ( error ) {
120
+ if ( error instanceof MagicLinkRateLimitError ) {
121
+ logger . warn ( "Login magic link rate limit exceeded" , {
122
+ clientIp,
123
+ email,
124
+ error,
125
+ } ) ;
126
+ } else {
127
+ logger . error ( "Failed sending login magic link" , {
128
+ clientIp,
129
+ email,
130
+ error,
131
+ } ) ;
132
+ }
133
+
134
+ const errorMessage =
135
+ error instanceof MagicLinkRateLimitError
136
+ ? "Too many magic link requests. Please try again shortly."
137
+ : "Failed sending magic link. Please try again shortly." ;
138
+
139
+ const session = await getUserSession ( request ) ;
140
+ session . set ( "auth:error" , {
141
+ message : errorMessage ,
142
+ } ) ;
143
+
144
+ return redirect ( "/login/magic" , {
145
+ headers : {
146
+ "Set-Cookie" : await commitSession ( session ) ,
147
+ } ,
148
+ } ) ;
149
+ }
150
+
151
+ return authenticator . authenticate ( "email-link" , request , {
152
+ successRedirect : "/login/magic" ,
153
+ failureRedirect : "/login/magic" ,
154
+ } ) ;
155
+ }
156
+ case "reset" :
157
+ default : {
158
+ data . action satisfies "reset" ;
159
+
160
+ const session = await getUserSession ( request ) ;
161
+ session . unset ( "triggerdotdev:magiclink" ) ;
162
+
163
+ return redirect ( "/login/magic" , {
164
+ headers : {
165
+ "Set-Cookie" : await commitSession ( session ) ,
166
+ } ,
167
+ } ) ;
168
+ }
94
169
}
95
170
}
96
171
172
+ const extractClientIp = ( xff : string | null ) => {
173
+ if ( ! xff ) return null ;
174
+
175
+ const parts = xff . split ( "," ) . map ( ( p ) => p . trim ( ) ) ;
176
+ return parts [ parts . length - 1 ] ; // take last item, ALB appends the real client IP by default
177
+ } ;
178
+
97
179
export default function LoginMagicLinkPage ( ) {
98
180
const { magicLinkSent, magicLinkError } = useTypedLoaderData < typeof loader > ( ) ;
99
181
const navigate = useNavigation ( ) ;
0 commit comments