mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-21 17:10:28 +00:00
709 lines
23 KiB
Go
709 lines
23 KiB
Go
package pds
|
|
|
|
import (
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
"github.com/golang-jwt/jwt/v5"
|
|
)
|
|
|
|
// Authentication errors
|
|
var (
|
|
ErrMissingAuthHeader = errors.New("missing Authorization header")
|
|
ErrInvalidAuthFormat = errors.New("invalid Authorization header format")
|
|
ErrInvalidAuthScheme = errors.New("invalid authorization scheme: expected 'Bearer' or 'DPoP'")
|
|
ErrMissingToken = errors.New("missing token")
|
|
ErrMissingDPoPHeader = errors.New("missing DPoP header")
|
|
)
|
|
|
|
// JWT validation errors
|
|
var (
|
|
ErrInvalidJWTFormat = errors.New("invalid JWT format: expected header.payload.signature")
|
|
ErrMissingISSClaim = errors.New("missing 'iss' claim in token")
|
|
ErrMissingSubClaim = errors.New("missing 'sub' claim in token")
|
|
ErrTokenExpired = errors.New("token has expired")
|
|
)
|
|
|
|
// AuthError provides structured authorization error information
|
|
type AuthError struct {
|
|
Action string // The action being attempted: "blob:read", "blob:write", "crew:admin"
|
|
Reason string // Why access was denied
|
|
Required []string // What permission(s) would grant access
|
|
}
|
|
|
|
func (e *AuthError) Error() string {
|
|
return fmt.Sprintf("access denied for %s: %s (required: %s)",
|
|
e.Action, e.Reason, strings.Join(e.Required, " or "))
|
|
}
|
|
|
|
// NewAuthError creates a new AuthError
|
|
func NewAuthError(action, reason string, required ...string) *AuthError {
|
|
return &AuthError{
|
|
Action: action,
|
|
Reason: reason,
|
|
Required: required,
|
|
}
|
|
}
|
|
|
|
// HTTPClient interface allows injecting a custom HTTP client for testing
|
|
type HTTPClient interface {
|
|
Do(*http.Request) (*http.Response, error)
|
|
}
|
|
|
|
// 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
|
|
//
|
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
|
// This allows tests to inject a mock HTTP client.
|
|
func ValidateDPoPRequest(r *http.Request, httpClient HTTPClient) (*ValidatedUser, error) {
|
|
// Extract Authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return nil, ErrMissingAuthHeader
|
|
}
|
|
|
|
// Check for DPoP authorization scheme
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 {
|
|
return nil, ErrInvalidAuthFormat
|
|
}
|
|
|
|
if parts[0] != "DPoP" {
|
|
return nil, fmt.Errorf("expected DPoP authorization scheme, got: %s", parts[0])
|
|
}
|
|
|
|
accessToken := parts[1]
|
|
if accessToken == "" {
|
|
return nil, ErrMissingToken
|
|
}
|
|
|
|
// Extract DPoP header
|
|
dpopProof := r.Header.Get("DPoP")
|
|
if dpopProof == "" {
|
|
return nil, ErrMissingDPoPHeader
|
|
}
|
|
|
|
// 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, httpClient)
|
|
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 "", "", ErrInvalidJWTFormat
|
|
}
|
|
|
|
// 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 "", "", ErrMissingSubClaim
|
|
}
|
|
|
|
if claims.Iss == "" {
|
|
return "", "", ErrMissingISSClaim
|
|
}
|
|
|
|
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
|
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
|
func validateTokenWithPDS(ctx context.Context, pdsURL, accessToken, dpopProof string, httpClient HTTPClient) (*SessionResponse, error) {
|
|
// Call com.atproto.server.getSession with DPoP headers
|
|
url := fmt.Sprintf("%s%s", strings.TrimSuffix(pdsURL, "/"), atproto.ServerGetSession)
|
|
|
|
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)
|
|
|
|
// Use provided client or default to http.DefaultClient
|
|
client := httpClient
|
|
if client == nil {
|
|
client = http.DefaultClient
|
|
}
|
|
|
|
resp, err := client.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
|
|
}
|
|
|
|
// ValidateOwnerOrCrewAdmin validates that the request has valid authentication
|
|
// and that the authenticated user is either the hold owner or a crew member with crew:admin permission.
|
|
// Supports two authentication methods:
|
|
// 1. Service tokens (Bearer tokens from com.atproto.server.getServiceAuth) - for AppView access
|
|
// 2. DPoP + OAuth tokens - for direct user access
|
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
|
func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
|
|
// Try service token validation first (for AppView access)
|
|
authHeader := r.Header.Get("Authorization")
|
|
var user *ValidatedUser
|
|
var err error
|
|
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
// Service token authentication
|
|
user, err = ValidateServiceToken(r, pds.did, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service token authentication failed: %w", err)
|
|
}
|
|
} else if strings.HasPrefix(authHeader, "DPoP ") {
|
|
// DPoP + OAuth authentication (direct user access)
|
|
user, err = ValidateDPoPRequest(r, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
|
|
}
|
|
} else {
|
|
return nil, ErrInvalidAuthScheme
|
|
}
|
|
|
|
// 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
|
|
if slices.Contains(member.Record.Permissions, "crew:admin") {
|
|
return user, nil
|
|
}
|
|
// User is crew but doesn't have admin permission
|
|
return nil, NewAuthError("crew:admin", "crew member lacks permission", "crew:admin")
|
|
}
|
|
}
|
|
|
|
// User is neither owner nor authorized crew
|
|
return nil, NewAuthError("crew:admin", "user is not a crew member", "crew:admin")
|
|
}
|
|
|
|
// ValidateBlobWriteAccess validates that the request has valid authentication
|
|
// and that the authenticated user is either the hold owner or a crew member with blob:write permission.
|
|
// Supports two authentication methods:
|
|
// 1. Service tokens (Bearer tokens from com.atproto.server.getServiceAuth) - for AppView access
|
|
// 2. DPoP + OAuth tokens - for direct user access
|
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
|
func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
|
|
// Try service token validation first (for AppView access)
|
|
authHeader := r.Header.Get("Authorization")
|
|
var user *ValidatedUser
|
|
var err error
|
|
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
// Service token authentication
|
|
user, err = ValidateServiceToken(r, pds.did, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service token authentication failed: %w", err)
|
|
}
|
|
} else if strings.HasPrefix(authHeader, "DPoP ") {
|
|
// DPoP + OAuth authentication (direct user access)
|
|
user, err = ValidateDPoPRequest(r, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
|
|
}
|
|
} else {
|
|
return nil, ErrInvalidAuthScheme
|
|
}
|
|
|
|
// Get captain record to check owner and public settings
|
|
_, 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 (always has write access)
|
|
if user.DID == captain.Owner {
|
|
return user, nil
|
|
}
|
|
|
|
// Check if user is crew with blob:write 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 blob:write permission
|
|
if slices.Contains(member.Record.Permissions, "blob:write") {
|
|
return user, nil
|
|
}
|
|
// User is crew but doesn't have write permission
|
|
return nil, NewAuthError("blob:write", "crew member lacks permission", "blob:write")
|
|
}
|
|
}
|
|
|
|
// User is neither owner nor authorized crew
|
|
return nil, NewAuthError("blob:write", "user is not a crew member", "blob:write")
|
|
}
|
|
|
|
// ValidateBlobReadAccess validates that the request has read access to blobs
|
|
// If captain.public = true: No auth required (returns nil user to indicate public access)
|
|
// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read or blob:write permission).
|
|
// Note: blob:write implicitly grants blob:read access.
|
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
|
// If scannerSecret is non-empty, a Bearer token matching it grants full read access (for scanner blob fetches).
|
|
func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient, scannerSecret string) (*ValidatedUser, error) {
|
|
// Get captain record to check public setting
|
|
_, captain, err := pds.GetCaptainRecord(r.Context())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
|
}
|
|
|
|
// If hold is public, allow access without authentication
|
|
if captain.Public {
|
|
return nil, nil // nil user indicates public access
|
|
}
|
|
|
|
// Private hold - require authentication (accept both service tokens and DPoP)
|
|
authHeader := r.Header.Get("Authorization")
|
|
var user *ValidatedUser
|
|
|
|
if strings.HasPrefix(authHeader, "Bearer ") {
|
|
// Check if this is a scanner using the shared secret
|
|
if scannerSecret != "" && strings.TrimPrefix(authHeader, "Bearer ") == scannerSecret {
|
|
return &ValidatedUser{DID: "scanner"}, nil
|
|
}
|
|
// Service token authentication (from AppView via getServiceAuth)
|
|
user, err = ValidateServiceToken(r, pds.did, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("service token authentication failed: %w", err)
|
|
}
|
|
} else if strings.HasPrefix(authHeader, "DPoP ") {
|
|
// DPoP + OAuth authentication (direct user access)
|
|
user, err = ValidateDPoPRequest(r, httpClient)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("DPoP authentication failed: %w", err)
|
|
}
|
|
} else {
|
|
return nil, ErrInvalidAuthScheme
|
|
}
|
|
|
|
// Check if user is the owner (always has read access)
|
|
if user.DID == captain.Owner {
|
|
return user, nil
|
|
}
|
|
|
|
// Check if user is crew with blob:read or blob:write permission
|
|
// Note: blob:write implicitly grants blob:read access
|
|
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 blob:read or blob:write permission
|
|
// blob:write implicitly grants read access (can't push without pulling)
|
|
if slices.Contains(member.Record.Permissions, "blob:read") ||
|
|
slices.Contains(member.Record.Permissions, "blob:write") {
|
|
return user, nil
|
|
}
|
|
// User is crew but doesn't have read or write permission
|
|
return nil, NewAuthError("blob:read", "crew member lacks permission", "blob:read", "blob:write")
|
|
}
|
|
}
|
|
|
|
// User is neither owner nor authorized crew
|
|
return nil, NewAuthError("blob:read", "user is not a crew member", "blob:read", "blob:write")
|
|
}
|
|
|
|
// ServiceTokenClaims represents the claims in a service token JWT
|
|
type ServiceTokenClaims struct {
|
|
jwt.RegisteredClaims
|
|
}
|
|
|
|
// ValidateServiceToken validates a service token JWT from com.atproto.server.getServiceAuth
|
|
// This validates the JWT signature using the issuer's (PDS) public key from their DID document
|
|
// Returns the user DID from the iss claim if validation succeeds
|
|
func ValidateServiceToken(r *http.Request, holdDID string, httpClient HTTPClient) (*ValidatedUser, error) {
|
|
// Extract Authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return nil, ErrMissingAuthHeader
|
|
}
|
|
|
|
// Check for Bearer authorization scheme
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 {
|
|
return nil, ErrInvalidAuthFormat
|
|
}
|
|
|
|
if parts[0] != "Bearer" {
|
|
return nil, fmt.Errorf("expected Bearer authorization scheme, got: %s", parts[0])
|
|
}
|
|
|
|
tokenString := parts[1]
|
|
if tokenString == "" {
|
|
return nil, ErrMissingToken
|
|
}
|
|
|
|
slog.Debug("Validating service token", "holdDID", holdDID)
|
|
|
|
// Manually parse JWT (bypass golang-jwt since it doesn't support ES256K algorithm used by ATProto)
|
|
// Split token: header.payload.signature
|
|
tokenParts := strings.Split(tokenString, ".")
|
|
if len(tokenParts) != 3 {
|
|
return nil, ErrInvalidJWTFormat
|
|
}
|
|
|
|
// Decode payload (second part) to extract claims
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
|
|
}
|
|
|
|
// Parse claims from JSON
|
|
var claims ServiceTokenClaims
|
|
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
|
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
|
}
|
|
|
|
// Get issuer (user DID)
|
|
issuerDID := claims.Issuer
|
|
if issuerDID == "" {
|
|
return nil, ErrMissingISSClaim
|
|
}
|
|
|
|
// Verify audience matches this hold service
|
|
audiences, err := claims.GetAudience()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get audience: %w", err)
|
|
}
|
|
if len(audiences) == 0 || audiences[0] != holdDID {
|
|
return nil, fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
|
|
}
|
|
|
|
// Verify expiration
|
|
exp, err := claims.GetExpirationTime()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get expiration: %w", err)
|
|
}
|
|
if exp != nil && time.Now().After(exp.Time) {
|
|
return nil, ErrTokenExpired
|
|
}
|
|
|
|
// Verify JWT signature using ATProto's secp256k1 crypto
|
|
|
|
// Signature is over "header.payload"
|
|
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
|
|
|
|
// Decode signature (base64url)
|
|
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to decode signature: %w", err)
|
|
}
|
|
|
|
// Fetch public key from issuer's DID document
|
|
publicKey, err := fetchPublicKeyFromDID(r.Context(), issuerDID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err)
|
|
}
|
|
|
|
// Verify signature using indigo's crypto (handles secp256k1)
|
|
if err := publicKey.HashAndVerify(signedData, signature); err != nil {
|
|
return nil, fmt.Errorf("signature verification failed: %w", err)
|
|
}
|
|
|
|
slog.Debug("Successfully validated service token", "userDID", issuerDID)
|
|
|
|
// Return validated user
|
|
return &ValidatedUser{
|
|
DID: issuerDID,
|
|
Handle: "", // Not available in service token
|
|
PDS: "", // Not needed for authorization
|
|
Authorized: true,
|
|
}, nil
|
|
}
|
|
|
|
// ValidateAppviewToken validates a JWT signed by the trusted appview using ES256 (P-256).
|
|
// It resolves the appview's DID document to extract the P-256 public key, then verifies
|
|
// the JWT signature, issuer (iss), and audience (aud).
|
|
//
|
|
// Returns the subject (sub) claim which is the user DID being acted upon.
|
|
func ValidateAppviewToken(r *http.Request, appviewDID, holdDID string) (string, error) {
|
|
// Extract Authorization header
|
|
authHeader := r.Header.Get("Authorization")
|
|
if authHeader == "" {
|
|
return "", ErrMissingAuthHeader
|
|
}
|
|
|
|
parts := strings.SplitN(authHeader, " ", 2)
|
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
|
return "", fmt.Errorf("expected Bearer authorization scheme")
|
|
}
|
|
|
|
tokenString := parts[1]
|
|
if tokenString == "" {
|
|
return "", ErrMissingToken
|
|
}
|
|
|
|
// Manually parse JWT
|
|
tokenParts := strings.Split(tokenString, ".")
|
|
if len(tokenParts) != 3 {
|
|
return "", ErrInvalidJWTFormat
|
|
}
|
|
|
|
// Decode and parse claims
|
|
payloadBytes, err := base64.RawURLEncoding.DecodeString(tokenParts[1])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode JWT payload: %w", err)
|
|
}
|
|
|
|
var claims ServiceTokenClaims
|
|
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
|
return "", fmt.Errorf("failed to unmarshal claims: %w", err)
|
|
}
|
|
|
|
// Verify issuer matches configured appview DID
|
|
if claims.Issuer != appviewDID {
|
|
return "", fmt.Errorf("token issuer mismatch: expected %s, got %s", appviewDID, claims.Issuer)
|
|
}
|
|
|
|
// Verify audience matches this hold's DID
|
|
audiences, err := claims.GetAudience()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get audience: %w", err)
|
|
}
|
|
if len(audiences) == 0 || audiences[0] != holdDID {
|
|
return "", fmt.Errorf("token audience mismatch: expected %s, got %v", holdDID, audiences)
|
|
}
|
|
|
|
// Verify expiration
|
|
exp, err := claims.GetExpirationTime()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get expiration: %w", err)
|
|
}
|
|
if exp != nil && time.Now().After(exp.Time) {
|
|
return "", ErrTokenExpired
|
|
}
|
|
|
|
// Get subject (user DID)
|
|
subject, err := claims.GetSubject()
|
|
if err != nil || subject == "" {
|
|
return "", ErrMissingSubClaim
|
|
}
|
|
|
|
// Fetch P-256 public key from appview DID document
|
|
pubKey, err := fetchP256PublicKeyFromDID(r.Context(), appviewDID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to fetch appview public key: %w", err)
|
|
}
|
|
|
|
// Verify JWT signature with P-256 key
|
|
signedData := []byte(tokenParts[0] + "." + tokenParts[1])
|
|
signature, err := base64.RawURLEncoding.DecodeString(tokenParts[2])
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to decode signature: %w", err)
|
|
}
|
|
|
|
if err := pubKey.HashAndVerifyLenient(signedData, signature); err != nil {
|
|
return "", fmt.Errorf("signature verification failed: %w", err)
|
|
}
|
|
|
|
slog.Debug("Validated appview service token", "appviewDID", appviewDID, "userDID", subject)
|
|
return subject, nil
|
|
}
|
|
|
|
// fetchP256PublicKeyFromDID fetches a P-256 public key from a did:web DID document.
|
|
// It resolves the DID document and looks for a Multikey verification method with P-256 prefix.
|
|
func fetchP256PublicKeyFromDID(ctx context.Context, did string) (*atcrypto.PublicKeyP256, error) {
|
|
if !strings.HasPrefix(did, "did:web:") {
|
|
return nil, fmt.Errorf("only did:web is supported for appview DID, got %s", did)
|
|
}
|
|
|
|
// Resolve did:web to URL
|
|
host := strings.TrimPrefix(did, "did:web:")
|
|
host = strings.ReplaceAll(host, "%3A", ":")
|
|
scheme := "https"
|
|
if atproto.IsTestMode() {
|
|
scheme = "http"
|
|
}
|
|
didDocURL := fmt.Sprintf("%s://%s/.well-known/did.json", scheme, host)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", didDocURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
|
}
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to fetch DID document: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("DID document fetch returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
var doc struct {
|
|
VerificationMethod []struct {
|
|
ID string `json:"id"`
|
|
Type string `json:"type"`
|
|
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
|
} `json:"verificationMethod"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil {
|
|
return nil, fmt.Errorf("failed to decode DID document: %w", err)
|
|
}
|
|
|
|
// Find a Multikey verification method with P-256 public key
|
|
for _, vm := range doc.VerificationMethod {
|
|
if vm.Type != "Multikey" || vm.PublicKeyMultibase == "" {
|
|
continue
|
|
}
|
|
|
|
// Try parsing as P-256 key via atcrypto's multibase parser
|
|
pubKey, err := atcrypto.ParsePublicMultibase(vm.PublicKeyMultibase)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
p256Key, ok := pubKey.(*atcrypto.PublicKeyP256)
|
|
if ok {
|
|
return p256Key, nil
|
|
}
|
|
}
|
|
|
|
return nil, fmt.Errorf("no P-256 public key found in DID document for %s", did)
|
|
}
|
|
|
|
// fetchPublicKeyFromDID fetches the public key from a DID document
|
|
// Supports did:plc and did:web
|
|
// Returns the atcrypto.PublicKey for signature verification
|
|
func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
|
|
// Use indigo's identity resolution
|
|
directory := atproto.GetDirectory()
|
|
atID, err := syntax.ParseAtIdentifier(did)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid DID format: %w", err)
|
|
}
|
|
|
|
ident, err := directory.Lookup(ctx, atID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resolve DID: %w", err)
|
|
}
|
|
|
|
// Get the public key using indigo's built-in method
|
|
// This returns an atcrypto.PublicKey (secp256k1)
|
|
publicKey, err := ident.PublicKey()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to get public key from DID: %w", err)
|
|
}
|
|
|
|
return publicKey, nil
|
|
}
|