package kate import ( "net/http" ) // MagicLinkConfig configures the magic link authentication handler behavior. type MagicLinkConfig[T any] struct { // UserData provides user data lookup and email sending UserData MagicLinkUserData[T] // Redirects configures post-authentication redirect behavior Redirects Redirects // UsernameField is the POST form field name in the login handler 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 // AfterSendHandler is run on the login request after the email has been sent // // optional - the default will write a simple 200 response. AfterSendHandler http.Handler // RejectHandler is run on the login verification request when authentication is rejected. // // optional - the default will write a simple 401 response with textual message. RejectHandler http.Handler // LogError is an optional function to log errors LogError func(error) } // MagicLinkUserData provides user data lookup and email sending for magic link authentication. type MagicLinkUserData[T any] interface { // GenerateToken creates and stores a new login token. GenerateToken(username, redirectPath string) (*MagicLinkToken[T], error) // ValidateToken checks the validity of a token. // // It looks up the token by its identifier and returns it, or nil if it doesn't exist. // // Tokens should also be validated against a stored expiration date, and potentially // invalidated in this function to support one-time use tokens. ValidateToken(identifier string) (*MagicLinkToken[T], 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 } type MagicLinkToken[T any] struct { // A unique identifier for this login token. // // This is the field that will be encrypted and embedded into the emailed login link. Identifier string // The user associated with the login token. UserData T // Path to which the user should be redirected after login. RedirectPath string } // 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" } if mlc.AfterSendHandler == nil { mlc.AfterSendHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) if _, err := w.Write([]byte("Magic link sent")); err != nil { mlc.logError(err) } }) } if mlc.RejectHandler == nil { mlc.RejectHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "Authentication failed", http.StatusUnauthorized) }) } } func (mlc MagicLinkConfig[T]) logError(err error) { if mlc.LogError != nil { mlc.LogError(err) } } // 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 } token, err := config.UserData.GenerateToken(username, config.Redirects.target(r)) if err != nil { config.logError(err) http.Error(w, "Error finding user", http.StatusInternalServerError) return } if token == nil { // Don't reveal whether user exists config.AfterSendHandler.ServeHTTP(w, r) return } encryptedToken := a.enc.Encrypt([]byte(token.Identifier)) if err := config.UserData.SendEmail(token.UserData, encryptedToken); err != nil { config.logError(err) http.Error(w, "Failed to send email", http.StatusInternalServerError) return } config.AfterSendHandler.ServeHTTP(w, r) }) } // 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 } clearToken, ok := a.enc.Decrypt(encryptedToken) if !ok { http.Error(w, "Invalid token", http.StatusUnauthorized) return } token, err := config.UserData.ValidateToken(string(clearToken)) if err != nil { config.logError(err) config.RejectHandler.ServeHTTP(w, r) return } if token == nil { config.RejectHandler.ServeHTTP(w, r) return } if err := a.Set(w, token.UserData); err != nil { config.logError(err) http.Error(w, "Failed to set authentication", http.StatusInternalServerError) return } http.Redirect(w, r, token.RedirectPath, http.StatusSeeOther) }) }