Something went wrong. Try again.
A container registry that uses the AT Protocol for manifest storage and S3 for blob storage.
Something went wrong. Try again.
5.2 kB · 173 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174// Package auth provides authentication and authorization for ATCR, including// ATProto session validation, hold authorization (captain/crew membership),// scope parsing, and token caching for OAuth and service tokens.package auth
import ( "bytes" "context" "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "io" "log/slog" "net/http" "sync" "time"
"atcr.io/pkg/atproto")
// CachedSession represents a cached sessiontype CachedSession struct { DID string Handle string PDS string AccessToken string ExpiresAt time.Time}
// SessionValidator validates ATProto credentialstype SessionValidator struct { httpClient *http.Client cache map[string]*CachedSession cacheMu sync.RWMutex}
// NewSessionValidator creates a new ATProto session validatorfunc NewSessionValidator() *SessionValidator { return &SessionValidator{ httpClient: &http.Client{}, cache: make(map[string]*CachedSession), }}
// getCacheKey generates a cache key from username and passwordfunc getCacheKey(username, password string) string { h := sha256.New() h.Write([]byte(username + ":" + password)) return hex.EncodeToString(h.Sum(nil))}
// getCachedSession retrieves a cached session if validfunc (v *SessionValidator) getCachedSession(cacheKey string) (*CachedSession, bool) { v.cacheMu.RLock() defer v.cacheMu.RUnlock()
session, ok := v.cache[cacheKey] if !ok { return nil, false }
// Check if expired (with 5 minute buffer) if time.Now().After(session.ExpiresAt.Add(-5 * time.Minute)) { return nil, false }
return session, true}
// setCachedSession stores a session in the cachefunc (v *SessionValidator) setCachedSession(cacheKey string, session *CachedSession) { v.cacheMu.Lock() defer v.cacheMu.Unlock() v.cache[cacheKey] = session}
// SessionResponse represents the response from createSessiontype SessionResponse struct { DID string `json:"did"` Handle string `json:"handle"` AccessJWT string `json:"accessJwt"` RefreshJWT string `json:"refreshJwt"` Email string `json:"email,omitempty"` AccessToken string `json:"access_token,omitempty"` // Alternative field name}
// CreateSessionAndGetToken creates a session and returns the DID, handle, and access tokenfunc (v *SessionValidator) CreateSessionAndGetToken(ctx context.Context, identifier, password string) (did, handle, accessToken string, err error) { // Check cache first cacheKey := getCacheKey(identifier, password) if cached, ok := v.getCachedSession(cacheKey); ok { slog.Debug("Using cached session", "identifier", identifier, "did", cached.DID) return cached.DID, cached.Handle, cached.AccessToken, nil }
slog.Debug("No cached session, creating new session", "identifier", identifier)
// Resolve identifier to PDS endpoint _, _, pds, err := atproto.ResolveIdentity(ctx, identifier) if err != nil { return "", "", "", err }
// Create session sessionResp, err := v.createSession(ctx, pds, identifier, password) if err != nil { return "", "", "", fmt.Errorf("authentication failed: %w", err) }
// Cache the session (ATProto sessions typically last 2 hours) v.setCachedSession(cacheKey, &CachedSession{ DID: sessionResp.DID, Handle: sessionResp.Handle, PDS: pds, AccessToken: sessionResp.AccessJWT, ExpiresAt: time.Now().Add(2 * time.Hour), }) slog.Debug("Cached session (expires in 2 hours)", "identifier", identifier, "did", sessionResp.DID)
return sessionResp.DID, sessionResp.Handle, sessionResp.AccessJWT, nil}
// createSession calls com.atproto.server.createSessionfunc (v *SessionValidator) createSession(ctx context.Context, pdsEndpoint, identifier, password string) (*SessionResponse, error) { payload := map[string]string{ "identifier": identifier, "password": password, }
body, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) }
url := fmt.Sprintf("%s%s", pdsEndpoint, atproto.ServerCreateSession) slog.Debug("Creating ATProto session", "url", url)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body)) if err != nil { return nil, err }
req.Header.Set("Content-Type", "application/json")
resp, err := v.httpClient.Do(req) if err != nil { slog.Debug("Session creation HTTP request failed", "error", err) return nil, fmt.Errorf("failed to create session: %w", err) } defer resp.Body.Close()
slog.Debug("Received session creation response", "status", resp.StatusCode)
if resp.StatusCode == http.StatusUnauthorized { bodyBytes, _ := io.ReadAll(resp.Body) slog.Debug("Session creation unauthorized", "response", string(bodyBytes)) return nil, fmt.Errorf("invalid credentials") }
if resp.StatusCode != http.StatusOK { bodyBytes, _ := io.ReadAll(resp.Body) slog.Debug("Session creation failed", "status", resp.StatusCode, "response", string(bodyBytes)) return nil, fmt.Errorf("create session failed with status %d: %s", resp.StatusCode, string(bodyBytes)) }
var sessionResp SessionResponse if err := json.NewDecoder(resp.Body).Decode(&sessionResp); err != nil { return nil, fmt.Errorf("failed to decode response: %w", err) }
return &sessionResp, nil}