mirror of
https://github.com/openziti/zrok.git
synced 2026-08-24 10:14:56 -05:00
273 lines
8.0 KiB
Go
273 lines
8.0 KiB
Go
package endpoints
|
|
|
|
import (
|
|
"bytes"
|
|
"compress/gzip"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/michaelquigley/df/dl"
|
|
"github.com/pkg/errors"
|
|
)
|
|
|
|
// OAuthCookieConfig defines the interface that OAuth configurations must implement
|
|
// to work with the shared cookie utilities
|
|
type OAuthCookieConfig interface {
|
|
GetCookieName() string
|
|
GetCookieDomain() string
|
|
GetMaxCookieSize() int
|
|
GetMaxCookieChunks() int
|
|
GetSessionLifetime() time.Duration
|
|
}
|
|
|
|
const (
|
|
defaultMaxCookieChunks = 10
|
|
hardMaxCookieChunks = 32
|
|
)
|
|
|
|
func getEffectiveMaxCookieChunks(cfg OAuthCookieConfig) int {
|
|
maxCookieChunks := cfg.GetMaxCookieChunks()
|
|
if maxCookieChunks == 0 {
|
|
return defaultMaxCookieChunks
|
|
}
|
|
return min(maxCookieChunks, hardMaxCookieChunks)
|
|
}
|
|
|
|
func getChunkCount(compressedToken string, maxCookieSize int) (int, int, error) {
|
|
chunkCount := 1
|
|
for {
|
|
prefixLen := len(fmt.Sprintf("%d|", chunkCount))
|
|
firstChunkSize := maxCookieSize - prefixLen
|
|
if firstChunkSize <= 0 {
|
|
return 0, 0, errors.New("max cookie size too small for striping")
|
|
}
|
|
|
|
requiredChunks := 1
|
|
if len(compressedToken) > firstChunkSize {
|
|
requiredChunks += (len(compressedToken) - firstChunkSize + maxCookieSize - 1) / maxCookieSize
|
|
}
|
|
if requiredChunks == chunkCount {
|
|
return chunkCount, firstChunkSize, nil
|
|
}
|
|
chunkCount = requiredChunks
|
|
}
|
|
}
|
|
|
|
// CompressToken compresses a JWT token string using gzip and returns a base64-encoded string
|
|
func CompressToken(token string) (string, error) {
|
|
var buf bytes.Buffer
|
|
gzWriter := gzip.NewWriter(&buf)
|
|
|
|
if _, err := gzWriter.Write([]byte(token)); err != nil {
|
|
return "", errors.Wrap(err, "failed to write to gzip writer")
|
|
}
|
|
|
|
if err := gzWriter.Close(); err != nil {
|
|
return "", errors.Wrap(err, "failed to close gzip writer")
|
|
}
|
|
|
|
return base64.URLEncoding.EncodeToString(buf.Bytes()), nil
|
|
}
|
|
|
|
// DecompressToken decompresses a base64-encoded gzip string back to the original JWT token
|
|
func DecompressToken(compressed string) (string, error) {
|
|
data, err := base64.URLEncoding.DecodeString(compressed)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "failed to decode base64")
|
|
}
|
|
|
|
gzReader, err := gzip.NewReader(bytes.NewReader(data))
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "failed to create gzip reader")
|
|
}
|
|
defer gzReader.Close()
|
|
|
|
decompressed, err := io.ReadAll(gzReader)
|
|
if err != nil {
|
|
return "", errors.Wrap(err, "failed to read decompressed data")
|
|
}
|
|
|
|
return string(decompressed), nil
|
|
}
|
|
|
|
// GetSessionCookie retrieves and reassembles a session cookie, handling both single and striped cookies
|
|
func GetSessionCookie(r *http.Request, cfg OAuthCookieConfig) (*http.Cookie, error) {
|
|
cookieName := cfg.GetCookieName()
|
|
|
|
// get the first cookie
|
|
cookie, err := r.Cookie(cookieName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var compressedValue string
|
|
|
|
// check if the cookie value has the stripe count prefix: {count}|{data}
|
|
if strings.Contains(cookie.Value, "|") {
|
|
parts := strings.SplitN(cookie.Value, "|", 2)
|
|
if len(parts) == 2 {
|
|
count, err := strconv.Atoi(parts[0])
|
|
if err != nil || count <= 0 {
|
|
return nil, errors.Errorf("invalid cookie chunk count '%s'", parts[0])
|
|
}
|
|
|
|
maxCookieChunks := getEffectiveMaxCookieChunks(cfg)
|
|
if count > maxCookieChunks {
|
|
return nil, errors.Errorf("cookie chunk count '%d' exceeds maximum '%d'", count, maxCookieChunks)
|
|
}
|
|
|
|
// this is a striped cookie
|
|
chunks := make([]string, count)
|
|
chunks[0] = parts[1]
|
|
|
|
// fetch the remaining chunks
|
|
for i := 1; i < count; i++ {
|
|
chunkCookie, err := r.Cookie(fmt.Sprintf("%s_%d", cookieName, i))
|
|
if err != nil {
|
|
return nil, errors.Errorf("missing cookie chunk '%s_%d'", cookieName, i)
|
|
}
|
|
chunks[i] = chunkCookie.Value
|
|
}
|
|
|
|
// reassemble the compressed value
|
|
compressedValue = strings.Join(chunks, "")
|
|
} else {
|
|
compressedValue = cookie.Value
|
|
}
|
|
} else {
|
|
// single cookie, no striping
|
|
compressedValue = cookie.Value
|
|
}
|
|
|
|
// decompress the value
|
|
decompressedValue, err := DecompressToken(compressedValue)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to decompress cookie value")
|
|
}
|
|
|
|
// return a cookie with the decompressed JWT value
|
|
return &http.Cookie{
|
|
Name: cookieName,
|
|
Value: decompressedValue,
|
|
}, nil
|
|
}
|
|
|
|
// SetSessionCookie sets a session cookie, compressing and striping it if necessary
|
|
func SetSessionCookie(w http.ResponseWriter, cookieName string, tokenValue string, cfg OAuthCookieConfig) error {
|
|
// compress the JWT token
|
|
compressedToken, err := CompressToken(tokenValue)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to compress token")
|
|
}
|
|
|
|
// use default max cookie size if not configured
|
|
maxCookieSize := cfg.GetMaxCookieSize()
|
|
if maxCookieSize == 0 {
|
|
maxCookieSize = 2048
|
|
}
|
|
|
|
// common cookie attributes
|
|
cookieAttrs := &http.Cookie{
|
|
MaxAge: int(cfg.GetSessionLifetime().Seconds()),
|
|
Domain: cfg.GetCookieDomain(),
|
|
Path: "/",
|
|
Expires: time.Now().Add(cfg.GetSessionLifetime()),
|
|
// Secure: true, // pending server tls feature https://github.com/openziti/zrok/issues/24
|
|
HttpOnly: true, // enabled because zrok frontend is the only intended consumer of this cookie, not client-side scripts
|
|
SameSite: http.SameSiteLaxMode, // explicitly set to the default Lax mode which allows the zrok share to be navigated to from another site and receive the cookie
|
|
}
|
|
|
|
// check if we need to stripe the cookie
|
|
if len(compressedToken) > maxCookieSize {
|
|
// calculate number of chunks needed, accounting for the count prefix in the first chunk
|
|
chunkCount, firstChunkSize, err := getChunkCount(compressedToken, maxCookieSize)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
maxCookieChunks := getEffectiveMaxCookieChunks(cfg)
|
|
if chunkCount > maxCookieChunks {
|
|
return errors.Errorf("compressed token requires '%d' cookie chunks; maximum is '%d'", chunkCount, maxCookieChunks)
|
|
}
|
|
|
|
// set first cookie with count prefix
|
|
firstChunk := compressedToken[:min(firstChunkSize, len(compressedToken))]
|
|
firstCookie := *cookieAttrs
|
|
firstCookie.Name = cookieName
|
|
firstCookie.Value = fmt.Sprintf("%d|%s", chunkCount, firstChunk)
|
|
http.SetCookie(w, &firstCookie)
|
|
|
|
// set remaining chunks
|
|
offset := firstChunkSize
|
|
for i := 1; i < chunkCount; i++ {
|
|
end := min(offset+maxCookieSize, len(compressedToken))
|
|
chunk := compressedToken[offset:end]
|
|
|
|
chunkCookie := *cookieAttrs
|
|
chunkCookie.Name = fmt.Sprintf("%s_%d", cookieName, i)
|
|
chunkCookie.Value = chunk
|
|
http.SetCookie(w, &chunkCookie)
|
|
|
|
offset = end
|
|
}
|
|
|
|
dl.Debugf("striped session cookie into '%d' chunks", chunkCount)
|
|
} else {
|
|
// single cookie is sufficient
|
|
cookie := *cookieAttrs
|
|
cookie.Name = cookieName
|
|
cookie.Value = compressedToken
|
|
http.SetCookie(w, &cookie)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ClearSessionCookies clears all session cookies including striped cookie chunks
|
|
func ClearSessionCookies(w http.ResponseWriter, r *http.Request, cookieName string, cfg OAuthCookieConfig) {
|
|
// iterate through all cookies and clear any that match the session cookie pattern
|
|
for _, cookie := range r.Cookies() {
|
|
// clear base cookie or any numbered chunks (cookieName_1, cookieName_2, etc.)
|
|
if cookie.Name == cookieName || strings.HasPrefix(cookie.Name, cookieName+"_") {
|
|
http.SetCookie(w, &http.Cookie{
|
|
Name: cookie.Name,
|
|
Value: "",
|
|
MaxAge: -1,
|
|
Domain: cfg.GetCookieDomain(),
|
|
Path: "/",
|
|
HttpOnly: true,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// FilterSessionCookies filters out session cookies and their striped chunks from a cookie list
|
|
func FilterSessionCookies(cookies []*http.Cookie, cookieName string) []*http.Cookie {
|
|
filtered := make([]*http.Cookie, 0, len(cookies))
|
|
for _, cookie := range cookies {
|
|
// filter out the base session cookie
|
|
if cookie.Name == cookieName {
|
|
continue
|
|
}
|
|
// filter out striped session cookie chunks (e.g., cookieName_1, cookieName_2)
|
|
if strings.HasPrefix(cookie.Name, cookieName+"_") {
|
|
// check if the suffix is a number
|
|
suffix := strings.TrimPrefix(cookie.Name, cookieName+"_")
|
|
if _, err := strconv.Atoi(suffix); err == nil {
|
|
continue
|
|
}
|
|
}
|
|
// filter out pkce cookie
|
|
if cookie.Name == "pkce" {
|
|
continue
|
|
}
|
|
filtered = append(filtered, cookie)
|
|
}
|
|
return filtered
|
|
}
|