A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
atcr.io
docker
container
atproto
go
1package auth
2
3import (
4 "atcr.io/pkg/atproto"
5 "bytes"
6 "context"
7 "crypto/sha256"
8 "encoding/hex"
9 "encoding/json"
10 "fmt"
11 "io"
12 "net/http"
13 "sync"
14 "time"
15
16 "github.com/bluesky-social/indigo/atproto/identity"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18)
19
20// CachedSession represents a cached session
21type CachedSession struct {
22 DID string
23 Handle string
24 PDS string
25 AccessToken string
26 ExpiresAt time.Time
27}
28
29// SessionValidator validates ATProto credentials
30type SessionValidator struct {
31 directory identity.Directory
32 httpClient *http.Client
33 cache map[string]*CachedSession
34 cacheMu sync.RWMutex
35}
36
37// NewSessionValidator creates a new ATProto session validator
38func NewSessionValidator() *SessionValidator {
39 return &SessionValidator{
40 directory: identity.DefaultDirectory(),
41 httpClient: &http.Client{},
42 cache: make(map[string]*CachedSession),
43 }
44}
45
46// getCacheKey generates a cache key from username and password
47func getCacheKey(username, password string) string {
48 h := sha256.New()
49 h.Write([]byte(username + ":" + password))
50 return hex.EncodeToString(h.Sum(nil))
51}
52
53// getCachedSession retrieves a cached session if valid
54func (v *SessionValidator) getCachedSession(cacheKey string) (*CachedSession, bool) {
55 v.cacheMu.RLock()
56 defer v.cacheMu.RUnlock()
57
58 session, ok := v.cache[cacheKey]
59 if !ok {
60 return nil, false
61 }
62
63 // Check if expired (with 5 minute buffer)
64 if time.Now().After(session.ExpiresAt.Add(-5 * time.Minute)) {
65 return nil, false
66 }
67
68 return session, true
69}
70
71// setCachedSession stores a session in the cache
72func (v *SessionValidator) setCachedSession(cacheKey string, session *CachedSession) {
73 v.cacheMu.Lock()
74 defer v.cacheMu.Unlock()
75 v.cache[cacheKey] = session
76}
77
78// SessionResponse represents the response from createSession
79type SessionResponse struct {
80 DID string `json:"did"`
81 Handle string `json:"handle"`
82 AccessJWT string `json:"accessJwt"`
83 RefreshJWT string `json:"refreshJwt"`
84 Email string `json:"email,omitempty"`
85 AccessToken string `json:"access_token,omitempty"` // Alternative field name
86}
87
88// CreateSessionAndGetToken creates a session and returns the DID, handle, and access token
89func (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, handle, accessToken string, err error) {
90 // Check cache first
91 cacheKey := getCacheKey(identifier, password)
92 if cached, ok := v.getCachedSession(cacheKey); ok {
93 fmt.Printf("DEBUG [atproto/session]: Using cached session for %s (DID=%s)\n", identifier, cached.DID)
94 return cached.DID, cached.Handle, cached.AccessToken, nil
95 }
96
97 fmt.Printf("DEBUG [atproto/session]: No cached session for %s, creating new session\n", identifier)
98
99 // Resolve identifier to PDS endpoint
100 atID, err := syntax.ParseAtIdentifier(identifier)
101 if err != nil {
102 return "", "", "", fmt.Errorf("invalid identifier %q: %w", identifier, err)
103 }
104
105 ident, err := v.directory.Lookup(ctx, *atID)
106 if err != nil {
107 return "", "", "", fmt.Errorf("failed to resolve identity %q: %w", identifier, err)
108 }
109
110 did = ident.DID.String()
111 pds := ident.PDSEndpoint()
112 if pds == "" {
113 return "", "", "", fmt.Errorf("no PDS endpoint found for %q", identifier)
114 }
115
116 // Create session
117 sessionResp, err := v.createSession(ctx, pds, identifier, password)
118 if err != nil {
119 return "", "", "", fmt.Errorf("authentication failed: %w", err)
120 }
121
122 // Cache the session (ATProto sessions typically last 2 hours)
123 v.setCachedSession(cacheKey, &CachedSession{
124 DID: sessionResp.DID,
125 Handle: sessionResp.Handle,
126 PDS: pds,
127 AccessToken: sessionResp.AccessJWT,
128 ExpiresAt: time.Now().Add(2 * time.Hour),
129 })
130 fmt.Printf("DEBUG [atproto/session]: Cached session for %s (expires in 2 hours)\n", identifier)
131
132 return sessionResp.DID, sessionResp.Handle, sessionResp.AccessJWT, nil
133}
134
135// createSession calls com.atproto.server.createSession
136func (v *SessionValidator) createSession(ctx context.Context, pdsEndpoint, identifier, password string) (*SessionResponse, error) {
137 payload := map[string]string{
138 "identifier": identifier,
139 "password": password,
140 }
141
142 body, err := json.Marshal(payload)
143 if err != nil {
144 return nil, fmt.Errorf("failed to marshal request: %w", err)
145 }
146
147 url := fmt.Sprintf("%s%s", pdsEndpoint, atproto.ServerCreateSession)
148 fmt.Printf("DEBUG [atproto/session]: POST %s\n", url)
149
150 req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
151 if err != nil {
152 return nil, err
153 }
154
155 req.Header.Set("Content-Type", "application/json")
156
157 resp, err := v.httpClient.Do(req)
158 if err != nil {
159 fmt.Printf("DEBUG [atproto/session]: HTTP request failed: %v\n", err)
160 return nil, fmt.Errorf("failed to create session: %w", err)
161 }
162 defer resp.Body.Close()
163
164 fmt.Printf("DEBUG [atproto/session]: Got HTTP status %d\n", resp.StatusCode)
165
166 if resp.StatusCode == http.StatusUnauthorized {
167 bodyBytes, _ := io.ReadAll(resp.Body)
168 fmt.Printf("DEBUG [atproto/session]: Unauthorized response: %s\n", string(bodyBytes))
169 return nil, fmt.Errorf("invalid credentials")
170 }
171
172 if resp.StatusCode != http.StatusOK {
173 bodyBytes, _ := io.ReadAll(resp.Body)
174 fmt.Printf("DEBUG [atproto/session]: Error response: %s\n", string(bodyBytes))
175 return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes))
176 }
177
178 var sessionResp SessionResponse
179 if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil {
180 return nil, fmt.Errorf("failed to decode response: %w", err)
181 }
182
183 return &sessionResp, nil
184}