package kate import ( "bytes" "context" "errors" "fmt" "io" "net/http" "strings" "time" ) // Auth provides secure cookie-based authentication for HTTP applications. // // It uses generic type T to allow storage of any serializable data in encrypted cookies. type Auth[T any] struct { enc encryption config AuthConfig[T] } // AuthConfig holds configuration settings for the Auth instance. // // It specifies how data is serialized, cookie properties, and security settings. type AuthConfig[T any] struct { // SerDes handles serialization and deserialization of authentication data SerDes SerDes[T] // CookieName is the name of the HTTP cookie used for authentication CookieName string // URLPath restricts the cookie to a specific path on the server (optional) URLPath string // URLDomain restricts the cookie to a specific domain (optional) URLDomain string // HTTPSOnly when true, requires cookies to be sent only over HTTPS connections HTTPSOnly bool // MaxAge determines how long in seconds the authentication cookie remains valid MaxAge time.Duration } // SerDes defines the interface for serializing and deserializing authentication data. // // Implementations must handle conversion between type T and byte streams. type SerDes[T any] interface { // Serialize writes the data of type T to the provided writer Serialize(io.Writer, T) error // Deserialize reads data from the reader and populates the provided pointer Deserialize(io.Reader, *T) error } // New creates a new Auth instance with the given private key and configuration. // // The private key must be a hex-encoded string used for cookie encryption. // Panics if the private key is invalid. func New[T any](privkey string, config AuthConfig[T]) Auth[T] { enc, err := encryptionFromHexKey(privkey) if err != nil { panic(err.Error()) } return Auth[T]{enc: enc, config: config} } // Required is an HTTP middleware that enforces authentication. // // It checks for a valid authentication cookie and makes the authenticated data // available in the request context. Returns 401 Unauthorized if authentication fails. func (a Auth[T]) Required(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(a.config.CookieName) if errors.Is(err, http.ErrNoCookie) { http.Error(w, "Authentication missing", http.StatusUnauthorized) return } cleartext, ok := a.enc.Decrypt(cookie.Value) if !ok { a.Clear(w) http.Error(w, "Authentication failed", http.StatusUnauthorized) return } var data T if err := a.config.SerDes.Deserialize(bytes.NewBuffer(cleartext), &data); err != nil { a.Clear(w) http.Error(w, "Server error", http.StatusInternalServerError) return } handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), key, data))) }) } // Optional returns an HTTP middleware that allows optional authentication. // // It checks for a valid authentication cookie and makes the authenticated data // available in the request context if present. Unlike Required, this middleware // allows requests to proceed even when authentication is missing or invalid. // Returns 500 Internal Server Error only if deserialization fails on valid authentication data. func (a Auth[T]) Optional(handler http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cookie, err := r.Cookie(a.config.CookieName) if errors.Is(err, http.ErrNoCookie) { handler.ServeHTTP(w, r) return } cleartext, ok := a.enc.Decrypt(cookie.Value) if !ok { a.Clear(w) handler.ServeHTTP(w, r) return } var data T if err := a.config.SerDes.Deserialize(bytes.NewBuffer(cleartext), &data); err != nil { a.Clear(w) http.Error(w, "Server error", http.StatusInternalServerError) return } handler.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), key, data))) }) } // Set creates and sets an authentication cookie containing the provided data. // // The data is serialized, encrypted, and stored in an HTTP cookie. // Returns an error if serialization fails. func (a Auth[T]) Set(w http.ResponseWriter, data T) error { buf := &bytes.Buffer{} if err := a.config.SerDes.Serialize(buf, data); err != nil { return fmt.Errorf("Auth.Set: %w", err) } cookie := &http.Cookie{ Name: a.config.CookieName, Value: a.enc.Encrypt(buf.Bytes()), Path: a.config.URLPath, Domain: a.config.URLDomain, MaxAge: int(a.config.MaxAge / time.Second), Secure: a.config.HTTPSOnly, HttpOnly: true, SameSite: http.SameSiteLaxMode, } w.Header().Add("Set-Cookie", cookie.String()) return nil } // removeSetCookieHeaders removes any existing Set-Cookie headers that match the given cookie name. // This ensures we don't send multiple Set-Cookie headers with the same cookie name, which // violates RFC 6265 recommendations. func removeSetCookieHeaders(w http.ResponseWriter, cookieName string) { headers := w.Header() setCookieHeaders := headers["Set-Cookie"] if len(setCookieHeaders) == 0 { return } // Filter out headers that match our cookie name var filteredHeaders []string for _, header := range setCookieHeaders { // Parse the cookie name from the Set-Cookie header // Format: "name=value; other=attributes" if idx := strings.Index(header, "="); idx > 0 { headerCookieName := strings.TrimSpace(header[:idx]) if headerCookieName != cookieName { filteredHeaders = append(filteredHeaders, header) } } else { // Keep malformed headers as-is filteredHeaders = append(filteredHeaders, header) } } // Replace the Set-Cookie headers with the filtered list if len(filteredHeaders) == 0 { headers.Del("Set-Cookie") } else { headers["Set-Cookie"] = filteredHeaders } } // Clear removes the authentication cookie by setting it to expire immediately. // // This effectively logs out the user by invalidating their authentication cookie. // If there are existing Set-Cookie headers for the same cookie name, they are removed // to comply with RFC 6265 recommendations against multiple Set-Cookie headers with // the same cookie name. func (a Auth[T]) Clear(w http.ResponseWriter) { // Remove any existing Set-Cookie headers for this cookie name removeSetCookieHeaders(w, a.config.CookieName) cookie := &http.Cookie{ Name: a.config.CookieName, Value: "", Path: a.config.URLPath, Domain: a.config.URLDomain, MaxAge: -1, Secure: a.config.HTTPSOnly, HttpOnly: true, SameSite: http.SameSiteLaxMode, } w.Header().Add("Set-Cookie", cookie.String()) } // Get retrieves authentication data from the request context. // // Returns the data and true if authentication data is present and valid, // otherwise returns the zero value and false. Should be called within handlers // protected by the Required middleware. func (a Auth[T]) Get(ctx context.Context) (T, bool) { var zero T val := ctx.Value(key) if val == nil { return zero, false } switch v := val.(type) { case T: return v, true default: return zero, false } } type keyt struct{} var key = keyt{}