mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 01:04:15 +00:00
238 lines
6.9 KiB
Go
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)")
|
|
}
|