summaryrefslogtreecommitdiff
path: root/auth.go
blob: 984d614034f690c9c757ccdbb12dc716b9e5c38f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
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
}

func (ac AuthConfig[T]) urlPath() string {
	if ac.URLPath == "" {
		return "/"
	}
	return ac.URLPath
}

// 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{}