Caddy module to require at-proto authentication and restrict routes to DIDs
3

Configure Feed

Select the types of activity you want to include in your feed.

1package session 2 3import ( 4 "crypto/hmac" 5 "crypto/sha256" 6 "encoding/base64" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "net/http" 11 "strings" 12 "time" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15) 16 17// Session represents the authenticated user data stored in the cookie. 18type Session struct { 19 DID string `json:"did"` 20 Handle string `json:"handle"` 21 SessionID string `json:"sid"` 22 ExpiresAt int64 `json:"exp"` 23} 24 25// Manager handles cookie signing and verification. 26type Manager struct { 27 Secret []byte 28 CookieName string 29 CookieDomain string 30} 31 32// NewManager creates a new session manager with the given secret. 33func NewManager(secret string, cookieName string, cookieDomain string) *Manager { 34 if cookieName == "" { 35 cookieName = "atproto_session" 36 } 37 return &Manager{ 38 Secret: []byte(secret), 39 CookieName: cookieName, 40 CookieDomain: cookieDomain, 41 } 42} 43 44// sign creates a signature for the given data. 45func (m *Manager) sign(data []byte) string { 46 h := hmac.New(sha256.New, m.Secret) 47 h.Write(data) 48 return base64.RawURLEncoding.EncodeToString(h.Sum(nil)) 49} 50 51// CreateCookie generates a signed http.Cookie for the session. 52func (m *Manager) CreateCookie(did syntax.DID, handle string, sessionID string, duration time.Duration) (*http.Cookie, error) { 53 exp := time.Now().Add(duration).Unix() 54 sess := Session{ 55 DID: did.String(), 56 Handle: handle, 57 SessionID: sessionID, 58 ExpiresAt: exp, 59 } 60 61 data, err := json.Marshal(sess) 62 if err != nil { 63 return nil, fmt.Errorf("failed to marshal session: %w", err) 64 } 65 66 encoded := base64.RawURLEncoding.EncodeToString(data) 67 signature := m.sign([]byte(encoded)) 68 value := fmt.Sprintf("%s.%s", encoded, signature) 69 70 cookieDomain := m.CookieDomain 71 // If cookieDomain is empty, we leave Domain empty for a host-only cookie. 72 // We no longer fallback to reqDomain. 73 74 cookie := &http.Cookie{ 75 Name: m.CookieName, 76 Value: value, 77 Path: "/", 78 Domain: cookieDomain, 79 Expires: time.Unix(exp, 0), 80 Secure: true, 81 HttpOnly: true, 82 SameSite: http.SameSiteLaxMode, 83 } 84 85 return cookie, nil 86} 87 88// VerifyCookie extracts and validates the session from the request. 89func (m *Manager) VerifyCookie(r *http.Request) (*Session, error) { 90 cookie, err := r.Cookie(m.CookieName) 91 if err != nil { 92 return nil, err 93 } 94 95 parts := strings.Split(cookie.Value, ".") 96 if len(parts) != 2 { 97 return nil, errors.New("invalid cookie format") 98 } 99 100 encodedData := parts[0] 101 signature := parts[1] 102 103 expectedSig := m.sign([]byte(encodedData)) 104 if !hmac.Equal([]byte(signature), []byte(expectedSig)) { 105 return nil, errors.New("invalid cookie signature") 106 } 107 108 data, err := base64.RawURLEncoding.DecodeString(encodedData) 109 if err != nil { 110 return nil, fmt.Errorf("failed to decode cookie data: %w", err) 111 } 112 113 var sess Session 114 if err := json.Unmarshal(data, &sess); err != nil { 115 return nil, fmt.Errorf("failed to unmarshal session: %w", err) 116 } 117 118 if time.Now().Unix() > sess.ExpiresAt { 119 return &sess, ErrExpired 120 } 121 122 return &sess, nil 123} 124 125// ErrExpired indicates the session is valid but expired 126var ErrExpired = errors.New("session expired") 127 128// ClearCookie returns a cookie that clears the session. 129func (m *Manager) ClearCookie() *http.Cookie { 130 return &http.Cookie{ 131 Name: m.CookieName, 132 Value: "", 133 Path: "/", 134 Domain: m.CookieDomain, 135 Expires: time.Unix(0, 0), 136 MaxAge: -1, 137 Secure: true, 138 HttpOnly: true, 139 SameSite: http.SameSiteLaxMode, 140 } 141}