Files
at-container-registry/pkg/auth/atproto/validator.go
T
2025-10-04 13:50:28 -05:00

102 lines
3.3 KiB
Go

package atproto
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
mainAtproto "atcr.io/pkg/atproto"
)
// TokenValidator validates ATProto OAuth access tokens
type TokenValidator struct {
httpClient *http.Client
}
// NewTokenValidator creates a new token validator
func NewTokenValidator() *TokenValidator {
return &TokenValidator{
httpClient: &http.Client{},
}
}
// SessionInfo represents the response from com.atproto.server.getSession
type SessionInfo struct {
DID string `json:"did"`
Handle string `json:"handle"`
Email string `json:"email,omitempty"`
EmailConfirmed bool `json:"emailConfirmed,omitempty"`
Active bool `json:"active,omitempty"`
}
// ValidateToken validates an ATProto OAuth access token by calling getSession
// Returns the user's DID and handle if the token is valid
// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
func (v *TokenValidator) ValidateToken(ctx context.Context, pdsEndpoint, accessToken, dpopProof string) (*SessionInfo, error) {
// Call com.atproto.server.getSession with the access token
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", pdsEndpoint)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Always use Bearer auth for getSession validation
// The DPoP proof from the client is bound to their request to us (POST /auth/exchange),
// not to our request to the PDS (GET /getSession)
req.Header.Set("Authorization", "Bearer "+accessToken)
fmt.Printf("DEBUG [validator]: calling %s with Bearer auth, token_prefix=%s...\n",
url, accessToken[:min(20, len(accessToken))])
resp, err := v.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get session: %w", err)
}
defer resp.Body.Close()
// Read body once for both logging and error handling
bodyBytes, _ := io.ReadAll(resp.Body)
if resp.StatusCode == http.StatusUnauthorized {
fmt.Printf("DEBUG [validator]: getSession returned 401: %s\n", string(bodyBytes))
return nil, fmt.Errorf("invalid or expired token")
}
if resp.StatusCode != http.StatusOK {
fmt.Printf("DEBUG [validator]: getSession failed with status %d: %s\n", resp.StatusCode, string(bodyBytes))
return nil, fmt.Errorf("getSession failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var session SessionInfo
if err := json.Unmarshal(bodyBytes, &session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
// Validate required fields
if session.DID == "" {
return nil, fmt.Errorf("session response missing DID")
}
if session.Handle == "" {
return nil, fmt.Errorf("session response missing handle")
}
return &session, nil
}
// ValidateTokenWithResolver validates a token and automatically resolves the PDS endpoint
// dpopProof is optional - if provided, uses DPoP auth; otherwise uses Bearer
func (v *TokenValidator) ValidateTokenWithResolver(ctx context.Context, handle, accessToken, dpopProof string) (*SessionInfo, error) {
// Resolve handle to PDS endpoint
resolver := mainAtproto.NewResolver()
_, pdsEndpoint, err := resolver.ResolveIdentity(ctx, handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve PDS endpoint: %w", err)
}
// Validate token against the PDS
return v.ValidateToken(ctx, pdsEndpoint, accessToken, dpopProof)
}