summaryrefslogtreecommitdiff
path: root/magic_link_login.go
diff options
context:
space:
mode:
authorT <t@tjp.lol>2025-06-26 11:42:17 -0600
committerT <t@tjp.lol>2025-07-01 17:50:49 -0600
commit639ad6a02cbb4b713434671ec09f309aa5410921 (patch)
tree7dde9cce8136636d11f2f7c961072984cfc705e7 /magic_link_login.go
Create authentic_kate: user authentication for go HTTP applications
Diffstat (limited to 'magic_link_login.go')
-rw-r--r--magic_link_login.go233
1 files changed, 233 insertions, 0 deletions
diff --git a/magic_link_login.go b/magic_link_login.go
new file mode 100644
index 0000000..9228a97
--- /dev/null
+++ b/magic_link_login.go
@@ -0,0 +1,233 @@
+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)
+ })
+}
+