1414
1515import jwt
1616from bson import ObjectId
17- from flask import Blueprint , g , jsonify , redirect , request
17+ from flask import Blueprint , Response , g , jsonify , redirect , request
1818from flask_dance .consumer import OAuth2ConsumerBlueprint , oauth_authorized
1919from flask_login import current_user , login_user
2020from flask_login .utils import LocalProxy
@@ -420,24 +420,15 @@ def attach_identity_to_user(
420420 wrapped_login_user (get_by_id (str (user .immutable_id )))
421421
422422
423- @EMAIL_BLUEPRINT .route ("/magic-link" , methods = ["POST" ])
424- def generate_and_share_magic_link ():
425- """Generates a JWT-based magic link with which a user can log in, stores it
426- in the database and sends it to the verified email address.
427-
428- """
429- request_json = request .get_json ()
430- email = request_json .get ("email" )
431- referrer = request_json .get ("referrer" )
432-
423+ def _validate_magic_link_request (email : str , referrer : str ) -> tuple [Response | None , int ]:
433424 if not email :
434425 return jsonify ({"status" : "error" , "detail" : "No email provided." }), 400
435426
436427 if not re .match (r"^\S+@\S+.\S+$" , email ):
437428 return jsonify ({"status" : "error" , "detail" : "Invalid email provided." }), 400
438429
439430 if not referrer :
440- LOGGER .warning ("No referrer provided for magic link request: %s" , request_json )
431+ LOGGER .warning ("No referrer provided for magic link request" )
441432 return (
442433 jsonify (
443434 {
@@ -448,25 +439,42 @@ def generate_and_share_magic_link():
448439 400 ,
449440 )
450441
451- # Generate a JWT for the user with a short expiration; the session itself
452- # should persist
453- # The key `exp` is a standard part of JWT; pyjwt treats this as an expiration time
454- # and will correctly encode the datetime
442+ return None , 200
443+
444+
445+ def _generate_and_store_token (email : str , is_test : bool = False ) -> str :
446+ """Generate a JWT for the user with a short expiration and store it in the session.
447+
448+ The session itself persists beyond the JWT expiration. The `exp` key is a standard
449+ part of JWT that PyJWT treats as an expiration time and will correctly encode the datetime.
450+
451+ Args:
452+ email: The user's email address to include in the token.
453+ is_test: If True, generates a token for testing purposes that may have different
454+ expiration or validation rules. Defaults to False.
455+
456+ Returns:
457+ The generated JWT token string.
458+ """
459+ payload = {
460+ "exp" : datetime .datetime .now (datetime .timezone .utc ) + LINK_EXPIRATION ,
461+ "email" : email ,
462+ }
463+ if is_test :
464+ payload ["is_test" ] = True
465+
455466 token = jwt .encode (
456- { "exp" : datetime . datetime . now ( datetime . timezone . utc ) + LINK_EXPIRATION , "email" : email } ,
467+ payload ,
457468 CONFIG .SECRET_KEY ,
458469 algorithm = "HS256" ,
459470 )
460471
461- flask_mongo .db .magic_links .insert_one (
462- {"jwt" : token },
463- )
472+ flask_mongo .db .magic_links .insert_one ({"jwt" : token })
464473
465- link = f" { referrer } ? token= { token } "
474+ return token
466475
467- instance_url = referrer .replace ("https://" , "" )
468476
469- # See if the user already exists and adjust the email if so
477+ def _check_user_registration_allowed ( email : str ) -> tuple [ Response | None , int ]:
470478 user = find_user_with_identity (email , IdentityType .EMAIL , verify = False )
471479
472480 if not user :
@@ -483,6 +491,14 @@ def generate_and_share_magic_link():
483491 403 ,
484492 )
485493
494+ return None , 200
495+
496+
497+ def _send_magic_link_email (email : str , token : str , referrer : str ) -> tuple [Response | None , int ]:
498+ link = f"{ referrer } ?token={ token } "
499+ instance_url = referrer .replace ("https://" , "" )
500+ user = find_user_with_identity (email , IdentityType .EMAIL , verify = False )
501+
486502 if user is not None :
487503 subject = "Datalab Sign-in Magic Link"
488504 body = f"Click the link below to sign-in to the datalab instance at { instance_url } :\n \n { link } \n \n This link is single-use and will expire in 1 hour."
@@ -496,6 +512,34 @@ def generate_and_share_magic_link():
496512 LOGGER .warning ("Failed to send email to %s: %s" , email , exc )
497513 return jsonify ({"status" : "error" , "detail" : "Email not sent successfully." }), 400
498514
515+ return None , 200
516+
517+
518+ @EMAIL_BLUEPRINT .route ("/magic-link" , methods = ["POST" ])
519+ def generate_and_share_magic_link ():
520+ """Generates a JWT-based magic link with which a user can log in, stores it
521+ in the database and sends it to the verified email address.
522+
523+ """
524+
525+ request_json = request .get_json ()
526+ email = request_json .get ("email" )
527+ referrer = request_json .get ("referrer" )
528+
529+ error_response , status_code = _validate_magic_link_request (email , referrer )
530+ if error_response :
531+ return error_response , status_code
532+
533+ error_response , status_code = _check_user_registration_allowed (email )
534+ if error_response :
535+ return error_response , status_code
536+
537+ token = _generate_and_store_token (email )
538+
539+ error_response , status_code = _send_magic_link_email (email , token , referrer )
540+ if error_response :
541+ return error_response , status_code
542+
499543 return jsonify ({"status" : "success" , "detail" : "Email sent successfully." }), 200
500544
501545
@@ -536,17 +580,19 @@ def email_logged_in():
536580 # If the email domain list is explicitly configured to None, this allows any
537581 # email address to make an active account, otherwise the email domain must match
538582 # the list of allowed domains and the admin must verify the user
539- allowed = _check_email_domain (email , CONFIG .EMAIL_DOMAIN_ALLOW_LIST )
540- if not allowed :
541- # If this point is reached, the token is valid but the server settings have
542- # changed since the link was generated, so best to fail safe
543- raise UserRegistrationForbidden
583+ is_test = data .get ("is_test" , False )
584+
585+ if not is_test :
586+ allowed = _check_email_domain (email , CONFIG .EMAIL_DOMAIN_ALLOW_LIST )
587+ if not allowed :
588+ raise UserRegistrationForbidden
544589
545590 create_account = AccountStatus .UNVERIFIED
546591 if (
547592 CONFIG .EMAIL_DOMAIN_ALLOW_LIST is None
548593 or CONFIG .EMAIL_AUTO_ACTIVATE_ACCOUNTS
549594 or CONFIG .AUTO_ACTIVATE_ACCOUNTS
595+ or is_test
550596 ):
551597 create_account = AccountStatus .ACTIVE
552598
@@ -686,3 +732,29 @@ def generate_user_api_key():
686732 ),
687733 401 ,
688734 )
735+
736+
737+ @AUTH .route ("/testing/create-magic-link" , methods = ["POST" ])
738+ def create_test_magic_link ():
739+ """Create a magic link for testing purposes.
740+
741+ This endpoint is only available when TESTING=True.
742+ It creates a user with the specified email and role, generates a magic link,
743+ and returns the token.
744+ """
745+ if not CONFIG .TESTING :
746+ return jsonify (
747+ {"status" : "error" , "detail" : "This endpoint is only available in testing mode." }
748+ ), 403
749+
750+ request_json = request .get_json ()
751+ email = request_json .get ("email" )
752+ referrer = request_json .get ("referrer" , "http://localhost:8080" )
753+
754+ error_response , status_code = _validate_magic_link_request (email , referrer )
755+ if error_response :
756+ return error_response , status_code
757+
758+ token = _generate_and_store_token (email , is_test = True )
759+
760+ return jsonify ({"status" : "success" , "token" : token }), 200
0 commit comments