Caddy module to require at-proto authentication and restrict routes to DIDs
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}