Files
at-container-registry/pkg/hold/pds/auth.go
T
2025-10-16 18:03:05 -05:00

238 lines
6.9 KiB
Go

package pds
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"github.com/bluesky-social/indigo/atproto/identity"
"github.com/bluesky-social/indigo/atproto/syntax"
)
// ValidatedUser represents a successfully validated user from DPoP + OAuth
type ValidatedUser struct {
DID string
Handle string
PDS string
Authorized bool
}
// ValidateDPoPRequest validates a request with DPoP + OAuth tokens
// This implements the standard ATProto token validation flow:
// 1. Extract Authorization header (DPoP <token>)
// 2. Extract DPoP header (proof JWT)
// 3. Call user's PDS to validate token via com.atproto.server.getSession
// 4. Return validated user DID
func ValidateDPoPRequest(r *http.Request) (*ValidatedUser, error) {
// Extract Authorization header
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
return nil, fmt.Errorf("missing Authorization header")
}
// Check for DPoP authorization scheme
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
return nil, fmt.Errorf("invalid Authorization header format")
}
if parts[0] != "DPoP" {
return nil, fmt.Errorf("expected DPoP authorization scheme, got: %s", parts[0])
}
accessToken := parts[1]
if accessToken == "" {
return nil, fmt.Errorf("missing access token")
}
// Extract DPoP header
dpopProof := r.Header.Get("DPoP")
if dpopProof == "" {
return nil, fmt.Errorf("missing DPoP header")
}
// TODO: We could verify the DPoP proof locally (signature, HTM, HTU, etc.)
// For now, we'll rely on the PDS to validate everything
// The token contains the user's DID in its claims, but we can't trust it without validation
// We need to call the user's PDS to validate the token
// Problem: We don't know which PDS to call yet!
// For now, we'll parse the JWT to extract the DID/PDS hint (unverified)
// Then validate against that PDS
// This is safe because the PDS will verify the token is valid for that DID
did, pds, err := extractDIDFromToken(accessToken)
if err != nil {
return nil, fmt.Errorf("failed to extract DID from token: %w", err)
}
// Validate token with the user's PDS
session, err := validateTokenWithPDS(r.Context(), pds, accessToken, dpopProof)
if err != nil {
return nil, fmt.Errorf("token validation failed: %w", err)
}
// Verify the DID matches
if session.DID != did {
return nil, fmt.Errorf("token DID mismatch: expected %s, got %s", did, session.DID)
}
return &ValidatedUser{
DID: session.DID,
Handle: session.Handle,
PDS: pds,
Authorized: true,
}, nil
}
// extractDIDFromToken extracts the DID and PDS from an unverified JWT token
// This is just for routing purposes - the token will be validated by the PDS
func extractDIDFromToken(token string) (string, string, error) {
// JWT format: header.payload.signature
parts := strings.Split(token, ".")
if len(parts) != 3 {
return "", "", fmt.Errorf("invalid JWT format")
}
// Decode payload (base64url)
payload, err := decodeBase64URL(parts[1])
if err != nil {
return "", "", fmt.Errorf("failed to decode payload: %w", err)
}
// Parse JSON
var claims struct {
Sub string `json:"sub"` // DID
Iss string `json:"iss"` // PDS URL (issuer)
}
if err := json.Unmarshal(payload, &claims); err != nil {
return "", "", fmt.Errorf("failed to parse claims: %w", err)
}
if claims.Sub == "" {
return "", "", fmt.Errorf("missing sub claim (DID)")
}
if claims.Iss == "" {
return "", "", fmt.Errorf("missing iss claim (PDS)")
}
return claims.Sub, claims.Iss, nil
}
// decodeBase64URL decodes base64url (RFC 4648)
func decodeBase64URL(s string) ([]byte, error) {
// Use Go's RawURLEncoding (base64url without padding)
return base64.RawURLEncoding.DecodeString(s)
}
// SessionResponse represents the response from com.atproto.server.getSession
type SessionResponse struct {
DID string `json:"did"`
Handle string `json:"handle"`
}
// validateTokenWithPDS calls the user's PDS to validate the token
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string) (*SessionResponse, error) {
// Call com.atproto.server.getSession with DPoP headers
url := fmt.Sprintf("%s/xrpc/com.atproto.server.getSession", strings.TrimSuffix(pdsURL, "/"))
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Add DPoP authorization headers
req.Header.Set("Authorization", "DPoP "+accessToken)
req.Header.Set("DPoP", dpopProof)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to call PDS: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("PDS returned status %d: %s", resp.StatusCode, string(body))
}
var session SessionResponse
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, fmt.Errorf("failed to decode session: %w", err)
}
return &session, nil
}
// ResolveDIDToPDS resolves a DID to its PDS endpoint (for reference)
// This is an alternative approach if we don't trust the token's issuer claim
func ResolveDIDToPDS(ctx context.Context, did string) (string, error) {
directory := identity.DefaultDirectory()
didParsed, err := syntax.ParseDID(did)
if err != nil {
return "", fmt.Errorf("invalid DID: %w", err)
}
ident, err := directory.LookupDID(ctx, didParsed)
if err != nil {
return "", fmt.Errorf("failed to resolve DID: %w", err)
}
pdsEndpoint := ident.PDSEndpoint()
if pdsEndpoint == "" {
return "", fmt.Errorf("no PDS endpoint found for DID")
}
return pdsEndpoint, nil
}
// ValidateOwnerOrCrewAdmin validates that the request has valid DPoP + OAuth tokens
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS) (*ValidatedUser, error) {
// Validate DPoP + OAuth token
user, err := ValidateDPoPRequest(r)
if err != nil {
return nil, fmt.Errorf("authentication failed: %w", err)
}
// Get captain record to check owner
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to get captain record: %w", err)
}
// Check if user is the owner
if user.DID == captain.Owner {
return user, nil
}
// Check if user is crew with admin permission
crew, err := pds.ListCrewMembers(r.Context())
if err != nil {
return nil, fmt.Errorf("failed to check crew membership: %w", err)
}
for _, member := range crew {
if member.Record.Member == user.DID {
// Check if this crew member has crew:admin permission
for _, perm := range member.Record.Permissions {
if perm == "crew:admin" {
return user, nil
}
}
// User is crew but doesn't have admin permission
return nil, fmt.Errorf("crew member lacks required 'crew:admin' permission")
}
}
// User is neither owner nor authorized crew
return nil, fmt.Errorf("user is not authorized (must be hold owner or crew admin)")
}