package kate import ( "errors" "net/http" "strings" "time" ) // MagicLinkConfig configures the magic link authentication handler behavior. type MagicLinkConfig[T any] struct { // Mailer provides user data lookup and email sending Mailer MagicLinkMailer[T] // Redirects configures post-authentication redirect behavior Redirects Redirects // UsernameField is the form field name for username UsernameField string // TokenField is the URL parameter name for the magic link token TokenField string // TokenLocation specifies where to retrieve the token from TokenLocation TokenLocation // TokenExpiry is how long the magic link token is valid TokenExpiry time.Duration // LogError is an optional function to log errors LogError func(error) } // MagicLinkMailer provides user data lookup and email sending for magic link authentication. type MagicLinkMailer[T any] interface { // Fetch retrieves user data by username. // // Returns the user data, whether the user was found, and any error. // If the user is not found, should return (zero value, false, nil). Fetch(username string) (T, bool, error) // SendEmail sends a magic link email to the user. // // The token parameter contains the encrypted magic link token that should // be included in the email URL for authentication. SendEmail(userData T, token string) error } // TokenLocation specifies where the magic link token should be retrieved from type TokenLocation struct{ location int } var ( // TokenLocationQuery retrieves the token from URL query parameters TokenLocationQuery = TokenLocation{0} // TokenLocationPath retrieves the token from URL path parameters using Request.PathValue() TokenLocationPath = TokenLocation{1} ) func (mlc *MagicLinkConfig[T]) setDefaults() { if mlc.UsernameField == "" { mlc.UsernameField = "email" } if mlc.TokenField == "" { mlc.TokenField = "token" } // TokenLocation defaults to TokenLocationQuery (zero value) if mlc.TokenExpiry == 0 { mlc.TokenExpiry = 15 * time.Minute } } func (mlc MagicLinkConfig[T]) logError(err error) { if mlc.LogError != nil { mlc.LogError(err) } } type magicLinkToken struct { Username string Redirect string ExpiresAt time.Time } func (t magicLinkToken) serialize() string { return t.Redirect + "\x00" + t.ExpiresAt.Format(time.RFC3339) + "\x00" + t.Username } func parseMagicLinkToken(data string) (magicLinkToken, error) { parts := strings.SplitN(data, "\x00", 3) if len(parts) != 3 { return magicLinkToken{}, errors.New("invalid token format") } redirect := parts[0] expiresStr := parts[1] username := parts[2] expiresAt, err := time.Parse(time.RFC3339, expiresStr) if err != nil { return magicLinkToken{}, err } return magicLinkToken{ Redirect: redirect, ExpiresAt: expiresAt, Username: username, }, nil } // MagicLinkLoginHandler returns an HTTP handler that processes magic link requests. // // It looks up the user, generates a token, and sends an email with the magic link. func (a Auth[T]) MagicLinkLoginHandler(config MagicLinkConfig[T]) http.Handler { config.setDefaults() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } if err := r.ParseForm(); err != nil { http.Error(w, "Invalid form data", http.StatusBadRequest) return } username := r.PostForm.Get(config.UsernameField) if username == "" { http.Error(w, config.UsernameField+" required", http.StatusBadRequest) return } userData, ok, err := config.Mailer.Fetch(username) if err != nil { config.logError(err) http.Error(w, "Error finding user", http.StatusInternalServerError) return } if !ok { // Don't reveal whether user exists w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("Magic link sent")); err != nil { config.logError(err) } return } tokenData := []byte(magicLinkToken{ Username: username, Redirect: config.Redirects.target(r), ExpiresAt: time.Now().Add(config.TokenExpiry), }.serialize()) encryptedToken := a.enc.Encrypt(tokenData) if err := config.Mailer.SendEmail(userData, encryptedToken); err != nil { config.logError(err) http.Error(w, "Failed to send email", http.StatusInternalServerError) return } w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("Magic link sent")); err != nil { config.logError(err) } }) } // MagicLinkVerifyHandler returns an HTTP handler that verifies magic link tokens. // // It decrypts and validates the token, sets the authentication cookie, and redirects. func (a Auth[T]) MagicLinkVerifyHandler(config MagicLinkConfig[T]) http.Handler { config.setDefaults() return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return } var encryptedToken string switch config.TokenLocation { case TokenLocationPath: encryptedToken = r.PathValue(config.TokenField) case TokenLocationQuery: encryptedToken = r.URL.Query().Get(config.TokenField) default: encryptedToken = r.URL.Query().Get(config.TokenField) } if encryptedToken == "" { http.Error(w, "Missing token", http.StatusBadRequest) return } tokenData, ok := a.enc.Decrypt(encryptedToken) if !ok { http.Error(w, "Invalid token", http.StatusUnauthorized) return } token, err := parseMagicLinkToken(string(tokenData)) if err != nil { config.logError(err) http.Error(w, "Invalid token", http.StatusUnauthorized) return } if time.Now().After(token.ExpiresAt) { http.Error(w, "Token expired", http.StatusUnauthorized) return } userData, ok, err := config.Mailer.Fetch(token.Username) if err != nil { config.logError(err) http.Error(w, "Authentication failed", http.StatusUnauthorized) return } if !ok { http.Error(w, "Authentication failed", http.StatusUnauthorized) return } if err := a.Set(w, userData); err != nil { config.logError(err) http.Error(w, "Failed to set authentication", http.StatusInternalServerError) return } http.Redirect(w, r, token.Redirect, http.StatusSeeOther) }) }