Compare commits
1 Commits
main
...
trusted-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f4641a2a |
@@ -21,6 +21,7 @@ import (
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
"atcr.io/pkg/appview/storage"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/atproto/did"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/auth/token"
|
||||
@@ -139,6 +140,20 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
|
||||
}
|
||||
|
||||
// Load or generate AppView K-256 signing key (for proxy assertions and DID document)
|
||||
slog.Info("Loading AppView signing key", "path", cfg.Server.ProxyKeyPath)
|
||||
proxySigningKey, err := oauth.GenerateOrLoadPDSKey(cfg.Server.ProxyKeyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load proxy signing key: %w", err)
|
||||
}
|
||||
|
||||
// Generate AppView DID from base URL
|
||||
serviceDID := did.GenerateDIDFromURL(baseURL)
|
||||
slog.Info("AppView DID initialized", "did", serviceDID)
|
||||
|
||||
// Store signing key and DID for use by proxy assertion system
|
||||
middleware.SetGlobalProxySigningKey(proxySigningKey, serviceDID)
|
||||
|
||||
// Create oauth token refresher
|
||||
refresher := oauth.NewRefresher(oauthClientApp)
|
||||
|
||||
@@ -402,7 +417,30 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
})
|
||||
|
||||
// Note: Indigo handles OAuth state cleanup internally via its store
|
||||
// Serve DID document for AppView (enables proxy assertion validation)
|
||||
mainRouter.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
|
||||
pubKey, err := proxySigningKey.PublicKey()
|
||||
if err != nil {
|
||||
slog.Error("Failed to get public key for DID document", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
services := did.DefaultAppViewServices(baseURL)
|
||||
doc, err := did.GenerateDIDDocument(baseURL, pubKey, services)
|
||||
if err != nil {
|
||||
slog.Error("Failed to generate DID document", "error", err)
|
||||
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||
if err := json.NewEncoder(w).Encode(doc); err != nil {
|
||||
slog.Error("Failed to encode DID document", "error", err)
|
||||
}
|
||||
})
|
||||
slog.Info("DID document endpoint enabled", "endpoint", "/.well-known/did.json", "did", serviceDID)
|
||||
|
||||
// Mount auth endpoints if enabled
|
||||
if issuer != nil {
|
||||
@@ -414,6 +452,13 @@ func serveRegistry(cmd *cobra.Command, args []string) error {
|
||||
// Prevents the flood of errors when a stale session is discovered during push
|
||||
tokenHandler.SetOAuthSessionValidator(refresher)
|
||||
|
||||
// Enable service token authentication for CI platforms (e.g., Tangled/Spindle)
|
||||
// Service tokens from getServiceAuth are validated against this service's DID
|
||||
if serviceDID != "" {
|
||||
tokenHandler.SetServiceTokenValidator(serviceDID)
|
||||
slog.Info("Service token authentication enabled", "service_did", serviceDID)
|
||||
}
|
||||
|
||||
// Register token post-auth callback for profile management
|
||||
// This decouples the token package from AppView-specific dependencies
|
||||
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
|
||||
|
||||
@@ -58,6 +58,10 @@ type ServerConfig struct {
|
||||
// ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
|
||||
// Shown in OAuth authorization screens
|
||||
ClientName string `yaml:"client_name"`
|
||||
|
||||
// ProxyKeyPath is the path to the K-256 signing key for proxy assertions (from env: ATCR_PROXY_KEY_PATH, default: "/var/lib/atcr/auth/proxy-key")
|
||||
// Auto-generated on first run. Used to sign proxy assertions for Hold services.
|
||||
ProxyKeyPath string `yaml:"proxy_key_path"`
|
||||
}
|
||||
|
||||
// UIConfig defines web UI settings
|
||||
@@ -150,6 +154,7 @@ func LoadConfigFromEnv() (*Config, error) {
|
||||
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
|
||||
cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
|
||||
cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
|
||||
cfg.Server.ProxyKeyPath = getEnvOrDefault("ATCR_PROXY_KEY_PATH", "/var/lib/atcr/auth/proxy-key")
|
||||
|
||||
// Auto-detect base URL if not explicitly set
|
||||
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/distribution/distribution/v3"
|
||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
|
||||
@@ -20,6 +21,7 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/auth/proxy"
|
||||
"atcr.io/pkg/auth/token"
|
||||
)
|
||||
|
||||
@@ -170,10 +172,12 @@ func (vc *validationCache) getOrFetch(ctx context.Context, cacheKey string, fetc
|
||||
// These are set by main.go during startup and copied into NamespaceResolver instances.
|
||||
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
||||
var (
|
||||
globalRefresher *oauth.Refresher
|
||||
globalDatabase storage.DatabaseMetrics
|
||||
globalAuthorizer auth.HoldAuthorizer
|
||||
globalReadmeCache storage.ReadmeCache
|
||||
globalRefresher *oauth.Refresher
|
||||
globalDatabase storage.DatabaseMetrics
|
||||
globalAuthorizer auth.HoldAuthorizer
|
||||
globalReadmeCache storage.ReadmeCache
|
||||
globalProxySigningKey *atcrypto.PrivateKeyK256
|
||||
globalServiceDID string
|
||||
)
|
||||
|
||||
// SetGlobalRefresher sets the OAuth refresher instance during initialization
|
||||
@@ -200,6 +204,23 @@ func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
|
||||
globalReadmeCache = readmeCache
|
||||
}
|
||||
|
||||
// SetGlobalProxySigningKey sets the K-256 signing key and DID for proxy assertions
|
||||
// Must be called before the registry starts serving requests
|
||||
func SetGlobalProxySigningKey(key *atcrypto.PrivateKeyK256, serviceDID string) {
|
||||
globalProxySigningKey = key
|
||||
globalServiceDID = serviceDID
|
||||
}
|
||||
|
||||
// GetGlobalServiceDID returns the AppView service DID
|
||||
func GetGlobalServiceDID() string {
|
||||
return globalServiceDID
|
||||
}
|
||||
|
||||
// GetGlobalProxySigningKey returns the K-256 signing key for proxy assertions
|
||||
func GetGlobalProxySigningKey() *atcrypto.PrivateKeyK256 {
|
||||
return globalProxySigningKey
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Register the name resolution middleware
|
||||
registrymw.Register("atproto-resolver", initATProtoResolver)
|
||||
@@ -455,6 +476,31 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
// 2. OAuth sessions can be refreshed/invalidated between requests
|
||||
// 3. The refresher already caches sessions efficiently (in-memory + DB)
|
||||
// 4. Caching the repository with a stale ATProtoClient causes refresh token errors
|
||||
// Check if hold trusts AppView for proxy assertions
|
||||
var proxyAsserter *proxy.Asserter
|
||||
holdTrusted := false
|
||||
|
||||
if globalProxySigningKey != nil && globalServiceDID != "" && nr.authorizer != nil {
|
||||
// Create proxy asserter with AppView's signing key
|
||||
proxyAsserter = proxy.NewAsserter(globalServiceDID, globalProxySigningKey)
|
||||
|
||||
// Check if the hold has AppView in its trustedProxies
|
||||
captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID)
|
||||
if err != nil {
|
||||
slog.Debug("Could not fetch captain record for proxy trust check",
|
||||
"hold_did", holdDID, "error", err)
|
||||
} else if captain != nil {
|
||||
for _, trusted := range captain.TrustedProxies {
|
||||
if trusted == globalServiceDID {
|
||||
holdTrusted = true
|
||||
slog.Debug("Hold trusts AppView, will use proxy assertions",
|
||||
"hold_did", holdDID, "appview_did", globalServiceDID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
registryCtx := &storage.RegistryContext{
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
@@ -464,6 +510,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
||||
ServiceToken: serviceToken, // Cached service token from middleware validation
|
||||
ATProtoClient: atprotoClient,
|
||||
AuthMethod: authMethod, // Auth method from JWT token
|
||||
ProxyAsserter: proxyAsserter, // Creates proxy assertions signed by AppView
|
||||
HoldTrusted: holdTrusted, // Whether hold trusts AppView for proxy auth
|
||||
Database: nr.database,
|
||||
Authorizer: nr.authorizer,
|
||||
Refresher: nr.refresher,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/oauth"
|
||||
"atcr.io/pkg/auth/proxy"
|
||||
)
|
||||
|
||||
// DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs
|
||||
@@ -32,7 +33,11 @@ type RegistryContext struct {
|
||||
Repository string // Image repository name (e.g., "debian")
|
||||
ServiceToken string // Service token for hold authentication (cached by middleware)
|
||||
ATProtoClient *atproto.Client // Authenticated ATProto client for this user
|
||||
AuthMethod string // Auth method used ("oauth" or "app_password")
|
||||
AuthMethod string // Auth method used ("oauth", "app_password", "service_token")
|
||||
|
||||
// Proxy assertion support (for CI and performance optimization)
|
||||
ProxyAsserter *proxy.Asserter // Creates proxy assertions (nil if not configured)
|
||||
HoldTrusted bool // Whether hold trusts AppView (has did:web:atcr.io in trustedProxies)
|
||||
|
||||
// Shared services (same for all requests)
|
||||
Database DatabaseMetrics // Metrics tracking database
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/proxy"
|
||||
"github.com/distribution/distribution/v3"
|
||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||
"github.com/opencontainers/go-digest"
|
||||
@@ -60,19 +61,41 @@ func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
|
||||
}
|
||||
}
|
||||
|
||||
// doAuthenticatedRequest performs an HTTP request with service token authentication
|
||||
// Uses the service token from middleware to authenticate requests to the hold service
|
||||
// doAuthenticatedRequest performs an HTTP request with authentication
|
||||
// Uses proxy assertion if hold trusts AppView, otherwise falls back to service token
|
||||
func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||
// Use service token that middleware already validated and cached
|
||||
// Middleware fails fast with HTTP 401 if OAuth session is invalid
|
||||
if p.ctx.ServiceToken == "" {
|
||||
// Should never happen - middleware validates OAuth before handlers run
|
||||
slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
|
||||
return nil, fmt.Errorf("no service token available (middleware should have validated)")
|
||||
var token string
|
||||
|
||||
// Use proxy assertion if hold trusts AppView (faster, no per-request service token validation)
|
||||
if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil {
|
||||
// Create proxy assertion signed by AppView
|
||||
proofHash := proxy.HashProofForAudit(p.ctx.ServiceToken)
|
||||
assertion, err := p.ctx.ProxyAsserter.CreateAssertion(p.ctx.DID, p.ctx.HoldDID, p.ctx.AuthMethod, proofHash)
|
||||
if err != nil {
|
||||
slog.Error("Failed to create proxy assertion, falling back to service token",
|
||||
"component", "proxy_blob_store", "error", err)
|
||||
// Fall through to service token
|
||||
} else {
|
||||
token = assertion
|
||||
slog.Debug("Using proxy assertion for hold authentication",
|
||||
"component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service token if proxy assertion not available
|
||||
if token == "" {
|
||||
if p.ctx.ServiceToken == "" {
|
||||
// Should never happen - middleware validates OAuth before handlers run
|
||||
slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
|
||||
return nil, fmt.Errorf("no service token available (middleware should have validated)")
|
||||
}
|
||||
token = p.ctx.ServiceToken
|
||||
slog.Debug("Using service token for hold authentication",
|
||||
"component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
|
||||
}
|
||||
|
||||
// Add Bearer token to Authorization header
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
|
||||
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
|
||||
|
||||
return p.httpClient.Do(req)
|
||||
}
|
||||
|
||||
145
pkg/atproto/did/document.go
Normal file
145
pkg/atproto/did/document.go
Normal file
@@ -0,0 +1,145 @@
|
||||
// Package did provides shared DID document types and utilities for ATProto services.
|
||||
// Both AppView and Hold use this package for did:web document generation.
|
||||
package did
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
)
|
||||
|
||||
// DIDDocument represents a did:web document
|
||||
type DIDDocument struct {
|
||||
Context []string `json:"@context"`
|
||||
ID string `json:"id"`
|
||||
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
|
||||
VerificationMethod []VerificationMethod `json:"verificationMethod"`
|
||||
Authentication []string `json:"authentication,omitempty"`
|
||||
AssertionMethod []string `json:"assertionMethod,omitempty"`
|
||||
Service []Service `json:"service,omitempty"`
|
||||
}
|
||||
|
||||
// VerificationMethod represents a public key in a DID document
|
||||
type VerificationMethod struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Controller string `json:"controller"`
|
||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
||||
}
|
||||
|
||||
// Service represents a service endpoint in a DID document
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
}
|
||||
|
||||
// GenerateDIDFromURL creates a did:web identifier from a public URL
|
||||
// Example: "https://atcr.io" -> "did:web:atcr.io"
|
||||
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
|
||||
// Note: Non-standard ports are included in the DID
|
||||
func GenerateDIDFromURL(publicURL string) string {
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
// Fallback: assume it's just a hostname
|
||||
return fmt.Sprintf("did:web:%s", publicURL)
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
port := u.Port()
|
||||
|
||||
// Include port in DID if it's non-standard (not 80 for http, not 443 for https)
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
return fmt.Sprintf("did:web:%s:%s", hostname, port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("did:web:%s", hostname)
|
||||
}
|
||||
|
||||
// GenerateDIDDocument creates a DID document for a did:web identity
|
||||
// This is a standalone function that can be used by any ATProto service.
|
||||
// The services parameter allows customizing which service endpoints to include.
|
||||
func GenerateDIDDocument(publicURL string, publicKey atcrypto.PublicKey, services []Service) (*DIDDocument, error) {
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
// Build host string (include non-standard ports)
|
||||
host := hostname
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
host = fmt.Sprintf("%s:%s", hostname, port)
|
||||
}
|
||||
|
||||
did := fmt.Sprintf("did:web:%s", host)
|
||||
|
||||
// Get public key in multibase format
|
||||
publicKeyMultibase := publicKey.Multibase()
|
||||
|
||||
doc := &DIDDocument{
|
||||
Context: []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
},
|
||||
ID: did,
|
||||
AlsoKnownAs: []string{
|
||||
fmt.Sprintf("at://%s", host),
|
||||
},
|
||||
VerificationMethod: []VerificationMethod{
|
||||
{
|
||||
ID: fmt.Sprintf("%s#atproto", did),
|
||||
Type: "Multikey",
|
||||
Controller: did,
|
||||
PublicKeyMultibase: publicKeyMultibase,
|
||||
},
|
||||
},
|
||||
Authentication: []string{
|
||||
fmt.Sprintf("%s#atproto", did),
|
||||
},
|
||||
Service: services,
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// MarshalDIDDocument converts a DID document to JSON bytes
|
||||
func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
|
||||
return json.MarshalIndent(doc, "", " ")
|
||||
}
|
||||
|
||||
// DefaultHoldServices returns the standard service endpoints for a Hold service
|
||||
func DefaultHoldServices(publicURL string) []Service {
|
||||
return []Service{
|
||||
{
|
||||
ID: "#atproto_pds",
|
||||
Type: "AtprotoPersonalDataServer",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
{
|
||||
ID: "#atcr_hold",
|
||||
Type: "AtcrHoldService",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DefaultAppViewServices returns the standard service endpoints for AppView
|
||||
func DefaultAppViewServices(publicURL string) []Service {
|
||||
return []Service{
|
||||
{
|
||||
ID: "#atcr_registry",
|
||||
Type: "AtcrRegistryService",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -539,14 +539,15 @@ func (t *TagRecord) GetManifestDigest() (string, error) {
|
||||
// Stored in the hold's embedded PDS to identify the hold owner and settings
|
||||
// Uses CBOR encoding for efficient storage in hold's carstore
|
||||
type CaptainRecord struct {
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
||||
Public bool `json:"public" cborgen:"public"` // Public read access
|
||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
|
||||
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
|
||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
||||
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
|
||||
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
|
||||
Type string `json:"$type" cborgen:"$type"`
|
||||
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
||||
Public bool `json:"public" cborgen:"public"` // Public read access
|
||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
|
||||
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
|
||||
TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"])
|
||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
||||
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
|
||||
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
|
||||
}
|
||||
|
||||
// CrewRecord represents a crew member in the hold
|
||||
|
||||
322
pkg/auth/proxy/assertion.go
Normal file
322
pkg/auth/proxy/assertion.go
Normal file
@@ -0,0 +1,322 @@
|
||||
// Package proxy provides proxy assertion creation and validation for trusted proxy authentication.
|
||||
// Proxy assertions allow AppView to vouch for users when communicating with Hold services,
|
||||
// eliminating the need for per-request service token validation.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ProxyAssertionClaims represents the claims in a proxy assertion JWT
|
||||
type ProxyAssertionClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
UserDID string `json:"user_did"` // User being proxied (for clarity, also in sub)
|
||||
AuthMethod string `json:"auth_method"` // Original auth method: "oauth", "app_password", "service_token"
|
||||
Proof string `json:"proof"` // Original token (truncated hash for audit, not full token)
|
||||
}
|
||||
|
||||
// Asserter creates proxy assertions signed by AppView
|
||||
type Asserter struct {
|
||||
proxyDID string // AppView's DID (e.g., "did:web:atcr.io")
|
||||
signingKey *atcrypto.PrivateKeyK256 // AppView's K-256 signing key
|
||||
}
|
||||
|
||||
// NewAsserter creates a new proxy assertion creator
|
||||
func NewAsserter(proxyDID string, signingKey *atcrypto.PrivateKeyK256) *Asserter {
|
||||
return &Asserter{
|
||||
proxyDID: proxyDID,
|
||||
signingKey: signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateAssertion creates a proxy assertion JWT for a user
|
||||
// userDID: the user being proxied
|
||||
// holdDID: the target hold service
|
||||
// authMethod: how the user authenticated ("oauth", "app_password", "service_token")
|
||||
// proofHash: a hash of the original authentication proof (for audit trail)
|
||||
func (a *Asserter) CreateAssertion(userDID, holdDID, authMethod, proofHash string) (string, error) {
|
||||
now := time.Now()
|
||||
|
||||
claims := ProxyAssertionClaims{
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
Issuer: a.proxyDID,
|
||||
Subject: userDID,
|
||||
Audience: jwt.ClaimStrings{holdDID},
|
||||
ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), // Short-lived
|
||||
IssuedAt: jwt.NewNumericDate(now),
|
||||
},
|
||||
UserDID: userDID,
|
||||
AuthMethod: authMethod,
|
||||
Proof: proofHash,
|
||||
}
|
||||
|
||||
// Create JWT header
|
||||
header := map[string]string{
|
||||
"alg": "ES256K",
|
||||
"typ": "JWT",
|
||||
}
|
||||
|
||||
// Encode header
|
||||
headerJSON, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal header: %w", err)
|
||||
}
|
||||
headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
|
||||
|
||||
// Encode payload
|
||||
payloadJSON, err := json.Marshal(claims)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal claims: %w", err)
|
||||
}
|
||||
payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
|
||||
|
||||
// Create signing input
|
||||
signingInput := headerB64 + "." + payloadB64
|
||||
|
||||
// Sign using K-256
|
||||
signature, err := a.signingKey.HashAndSign([]byte(signingInput))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign assertion: %w", err)
|
||||
}
|
||||
|
||||
// Encode signature
|
||||
signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
|
||||
|
||||
// Combine into JWT
|
||||
token := signingInput + "." + signatureB64
|
||||
|
||||
slog.Debug("Created proxy assertion",
|
||||
"proxyDID", a.proxyDID,
|
||||
"userDID", userDID,
|
||||
"holdDID", holdDID,
|
||||
"authMethod", authMethod)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// ValidatedUser represents a validated proxy assertion issuer
|
||||
type ValidatedUser struct {
|
||||
DID string // User DID from sub claim
|
||||
ProxyDID string // Proxy DID from iss claim
|
||||
AuthMethod string // Original auth method
|
||||
}
|
||||
|
||||
// Validator validates proxy assertions from trusted proxies
|
||||
type Validator struct {
|
||||
trustedProxies []string // List of trusted proxy DIDs
|
||||
pubKeyCache *publicKeyCache // Cache for proxy public keys
|
||||
}
|
||||
|
||||
// NewValidator creates a new proxy assertion validator
|
||||
func NewValidator(trustedProxies []string) *Validator {
|
||||
return &Validator{
|
||||
trustedProxies: trustedProxies,
|
||||
pubKeyCache: newPublicKeyCache(24 * time.Hour), // Cache public keys for 24 hours
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAssertion validates a proxy assertion JWT
|
||||
// Returns the validated user info if successful
|
||||
func (v *Validator) ValidateAssertion(ctx context.Context, tokenString, holdDID string) (*ValidatedUser, error) {
|
||||
// Parse JWT parts
|
||||
parts := strings.Split(tokenString, ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
|
||||
// Decode payload
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode payload: %w", err)
|
||||
}
|
||||
|
||||
// Parse claims
|
||||
var claims ProxyAssertionClaims
|
||||
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Get issuer (proxy DID)
|
||||
proxyDID := claims.Issuer
|
||||
if proxyDID == "" {
|
||||
return nil, fmt.Errorf("missing iss claim")
|
||||
}
|
||||
|
||||
// Check if issuer is trusted
|
||||
if !v.isTrustedProxy(proxyDID) {
|
||||
return nil, fmt.Errorf("proxy %s not in trustedProxies", proxyDID)
|
||||
}
|
||||
|
||||
// Verify audience matches this hold
|
||||
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("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, fmt.Errorf("assertion has expired")
|
||||
}
|
||||
|
||||
// Fetch proxy's public key (with caching)
|
||||
publicKey, err := v.getProxyPublicKey(ctx, proxyDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public key for proxy %s: %w", proxyDID, err)
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
signedData := []byte(parts[0] + "." + parts[1])
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode signature: %w", err)
|
||||
}
|
||||
|
||||
if err := publicKey.HashAndVerify(signedData, signature); err != nil {
|
||||
return nil, fmt.Errorf("signature verification failed: %w", err)
|
||||
}
|
||||
|
||||
// Get user DID from sub claim
|
||||
userDID := claims.Subject
|
||||
if userDID == "" {
|
||||
userDID = claims.UserDID // Fallback to explicit field
|
||||
}
|
||||
if userDID == "" {
|
||||
return nil, fmt.Errorf("missing user DID in assertion")
|
||||
}
|
||||
|
||||
slog.Debug("Validated proxy assertion",
|
||||
"proxyDID", proxyDID,
|
||||
"userDID", userDID,
|
||||
"authMethod", claims.AuthMethod)
|
||||
|
||||
return &ValidatedUser{
|
||||
DID: userDID,
|
||||
ProxyDID: proxyDID,
|
||||
AuthMethod: claims.AuthMethod,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// isTrustedProxy checks if a proxy DID is in the trusted list
|
||||
func (v *Validator) isTrustedProxy(proxyDID string) bool {
|
||||
for _, trusted := range v.trustedProxies {
|
||||
if trusted == proxyDID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getProxyPublicKey fetches and caches a proxy's public key
|
||||
func (v *Validator) getProxyPublicKey(ctx context.Context, proxyDID string) (atcrypto.PublicKey, error) {
|
||||
// Check cache first
|
||||
if key := v.pubKeyCache.get(proxyDID); key != nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Fetch from DID document
|
||||
key, err := fetchPublicKeyFromDID(ctx, proxyDID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the key
|
||||
v.pubKeyCache.set(proxyDID, key)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// publicKeyCache caches public keys for proxy DIDs
|
||||
type publicKeyCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]cacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key atcrypto.PublicKey
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newPublicKeyCache(ttl time.Duration) *publicKeyCache {
|
||||
return &publicKeyCache{
|
||||
entries: make(map[string]cacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *publicKeyCache) get(did string) atcrypto.PublicKey {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, ok := c.entries[did]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil
|
||||
}
|
||||
return entry.key
|
||||
}
|
||||
|
||||
func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.entries[did] = cacheEntry{
|
||||
key: key,
|
||||
expiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
}
|
||||
|
||||
// fetchPublicKeyFromDID fetches a public key from a DID document
|
||||
func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
publicKey, err := ident.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key from DID: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// HashProofForAudit creates a truncated hash of a token for audit purposes
|
||||
// This allows tracking without storing the full sensitive token
|
||||
func HashProofForAudit(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
// Use first 16 chars of a simple hash (not cryptographic, just for tracking)
|
||||
// We don't need security here, just a way to correlate requests
|
||||
hash := 0
|
||||
for _, c := range token {
|
||||
hash = hash*31 + int(c)
|
||||
}
|
||||
return fmt.Sprintf("%016x", uint64(hash))
|
||||
}
|
||||
223
pkg/auth/serviceauth/validator.go
Normal file
223
pkg/auth/serviceauth/validator.go
Normal file
@@ -0,0 +1,223 @@
|
||||
// Package serviceauth provides service token validation for ATProto service authentication.
|
||||
// Service tokens are JWTs issued by a user's PDS via com.atproto.server.getServiceAuth.
|
||||
// They allow services to authenticate users on behalf of other services.
|
||||
package serviceauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// ValidatedUser represents a validated user from a service token
|
||||
type ValidatedUser struct {
|
||||
DID string // User DID (from iss claim - the user's PDS signed this token for the user)
|
||||
}
|
||||
|
||||
// ServiceTokenClaims represents the claims in an ATProto service token
|
||||
type ServiceTokenClaims struct {
|
||||
jwt.RegisteredClaims
|
||||
Lxm string `json:"lxm,omitempty"` // Lexicon method identifier (e.g., "io.atcr.registry.push")
|
||||
}
|
||||
|
||||
// Validator validates ATProto service tokens
|
||||
type Validator struct {
|
||||
serviceDID string // This service's DID (expected in aud claim)
|
||||
pubKeyCache *publicKeyCache // Cache for public keys
|
||||
}
|
||||
|
||||
// NewValidator creates a new service token validator
|
||||
// serviceDID is the DID of this service (e.g., "did:web:atcr.io")
|
||||
// Tokens will be validated to ensure they are intended for this service (aud claim)
|
||||
func NewValidator(serviceDID string) *Validator {
|
||||
return &Validator{
|
||||
serviceDID: serviceDID,
|
||||
pubKeyCache: newPublicKeyCache(24 * time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
// Validate validates a service token and returns the authenticated user
|
||||
// tokenString is the raw JWT token (without "Bearer " prefix)
|
||||
// Returns the user DID if validation succeeds
|
||||
func (v *Validator) Validate(ctx context.Context, tokenString string) (*ValidatedUser, error) {
|
||||
// Parse JWT parts manually (golang-jwt doesn't support ES256K algorithm used by ATProto)
|
||||
parts := splitJWT(tokenString)
|
||||
if parts == nil {
|
||||
return nil, fmt.Errorf("invalid JWT format")
|
||||
}
|
||||
|
||||
// Decode payload to extract claims
|
||||
payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
|
||||
}
|
||||
|
||||
// Parse claims
|
||||
var claims ServiceTokenClaims
|
||||
if err := json.Unmarshal(payloadBytes, &claims); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
|
||||
}
|
||||
|
||||
// Get issuer DID (the user's DID - they own the PDS that issued this token)
|
||||
issuerDID := claims.Issuer
|
||||
if issuerDID == "" {
|
||||
return nil, fmt.Errorf("missing iss claim")
|
||||
}
|
||||
|
||||
// Verify audience matches this service
|
||||
audiences, err := claims.GetAudience()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get audience: %w", err)
|
||||
}
|
||||
if len(audiences) == 0 || audiences[0] != v.serviceDID {
|
||||
return nil, fmt.Errorf("audience mismatch: expected %s, got %v", v.serviceDID, 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, fmt.Errorf("token has expired")
|
||||
}
|
||||
|
||||
// Fetch public key from issuer's DID document (with caching)
|
||||
publicKey, err := v.getPublicKey(ctx, issuerDID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err)
|
||||
}
|
||||
|
||||
// Verify signature using ATProto's secp256k1 crypto
|
||||
signedData := []byte(parts[0] + "." + parts[1])
|
||||
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode signature: %w", err)
|
||||
}
|
||||
|
||||
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,
|
||||
"serviceDID", v.serviceDID)
|
||||
|
||||
return &ValidatedUser{
|
||||
DID: issuerDID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// splitJWT splits a JWT into its three parts
|
||||
// Returns nil if the format is invalid
|
||||
func splitJWT(token string) []string {
|
||||
parts := make([]string, 0, 3)
|
||||
start := 0
|
||||
count := 0
|
||||
|
||||
for i, c := range token {
|
||||
if c == '.' {
|
||||
parts = append(parts, token[start:i])
|
||||
start = i + 1
|
||||
count++
|
||||
}
|
||||
}
|
||||
|
||||
// Add the final part
|
||||
parts = append(parts, token[start:])
|
||||
|
||||
if len(parts) != 3 {
|
||||
return nil
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// getPublicKey fetches and caches a public key for a DID
|
||||
func (v *Validator) getPublicKey(ctx context.Context, did string) (atcrypto.PublicKey, error) {
|
||||
// Check cache first
|
||||
if key := v.pubKeyCache.get(did); key != nil {
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Fetch from DID document
|
||||
key, err := fetchPublicKeyFromDID(ctx, did)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the key
|
||||
v.pubKeyCache.set(did, key)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// fetchPublicKeyFromDID fetches the public key from a DID document
|
||||
func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
|
||||
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)
|
||||
}
|
||||
|
||||
publicKey, err := ident.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key from DID: %w", err)
|
||||
}
|
||||
|
||||
return publicKey, nil
|
||||
}
|
||||
|
||||
// publicKeyCache caches public keys for DIDs
|
||||
type publicKeyCache struct {
|
||||
mu sync.RWMutex
|
||||
entries map[string]cacheEntry
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
key atcrypto.PublicKey
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
func newPublicKeyCache(ttl time.Duration) *publicKeyCache {
|
||||
return &publicKeyCache{
|
||||
entries: make(map[string]cacheEntry),
|
||||
ttl: ttl,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *publicKeyCache) get(did string) atcrypto.PublicKey {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
|
||||
entry, ok := c.entries[did]
|
||||
if !ok || time.Now().After(entry.expiresAt) {
|
||||
return nil
|
||||
}
|
||||
return entry.key
|
||||
}
|
||||
|
||||
func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.entries[did] = cacheEntry{
|
||||
key: key,
|
||||
expiresAt: time.Now().Add(c.ttl),
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,9 @@ import (
|
||||
|
||||
// Auth method constants
|
||||
const (
|
||||
AuthMethodOAuth = "oauth"
|
||||
AuthMethodAppPassword = "app_password"
|
||||
AuthMethodOAuth = "oauth"
|
||||
AuthMethodAppPassword = "app_password"
|
||||
AuthMethodServiceToken = "service_token"
|
||||
)
|
||||
|
||||
// Claims represents the JWT claims for registry authentication
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"atcr.io/pkg/appview/db"
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth"
|
||||
"atcr.io/pkg/auth/serviceauth"
|
||||
)
|
||||
|
||||
// PostAuthCallback is called after successful Basic Auth authentication.
|
||||
@@ -31,11 +32,12 @@ type OAuthSessionValidator interface {
|
||||
|
||||
// Handler handles /auth/token requests
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *auth.SessionValidator
|
||||
deviceStore *db.DeviceStore // For validating device secrets
|
||||
postAuthCallback PostAuthCallback
|
||||
issuer *Issuer
|
||||
validator *auth.SessionValidator
|
||||
deviceStore *db.DeviceStore // For validating device secrets
|
||||
postAuthCallback PostAuthCallback
|
||||
oauthSessionValidator OAuthSessionValidator
|
||||
serviceTokenValidator *serviceauth.Validator // For CI service token authentication
|
||||
}
|
||||
|
||||
// NewHandler creates a new token handler
|
||||
@@ -60,6 +62,13 @@ func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
|
||||
h.oauthSessionValidator = validator
|
||||
}
|
||||
|
||||
// SetServiceTokenValidator sets the service token validator for CI authentication
|
||||
// When set, the handler will accept Bearer tokens with service tokens from CI platforms
|
||||
// serviceDID is the DID of this service (e.g., "did:web:atcr.io")
|
||||
func (h *Handler) SetServiceTokenValidator(serviceDID string) {
|
||||
h.serviceTokenValidator = serviceauth.NewValidator(serviceDID)
|
||||
}
|
||||
|
||||
// TokenResponse represents the response from /auth/token
|
||||
type TokenResponse struct {
|
||||
Token string `json:"token,omitempty"` // Legacy field
|
||||
@@ -132,16 +141,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Extract Basic auth credentials
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
slog.Debug("No Basic auth credentials provided")
|
||||
sendAuthError(w, r, "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
|
||||
|
||||
// Parse query parameters
|
||||
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
|
||||
scopeParam := r.URL.Query().Get("scope")
|
||||
@@ -163,6 +162,50 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
var accessToken string
|
||||
var authMethod string
|
||||
|
||||
// Check for Bearer token authentication (CI service tokens)
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(authHeader, "Bearer ") && h.serviceTokenValidator != nil {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
slog.Debug("Processing service token authentication")
|
||||
|
||||
validatedUser, err := h.serviceTokenValidator.Validate(r.Context(), tokenString)
|
||||
if err != nil {
|
||||
slog.Debug("Service token validation failed", "error", err)
|
||||
http.Error(w, fmt.Sprintf("service token authentication failed: %v", err), http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
did = validatedUser.DID
|
||||
authMethod = AuthMethodServiceToken
|
||||
|
||||
slog.Debug("Service token validated successfully", "did", did)
|
||||
|
||||
// Resolve handle from DID for access validation
|
||||
resolvedDID, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(r.Context(), did)
|
||||
if resolveErr != nil {
|
||||
slog.Warn("Failed to resolve handle for service token user", "did", did, "error", resolveErr)
|
||||
// Use empty handle - access validation will use DID
|
||||
} else {
|
||||
did = resolvedDID // Use canonical DID from resolution
|
||||
handle = resolvedHandle
|
||||
}
|
||||
|
||||
// Service token auth - issue token and return
|
||||
h.issueToken(w, r, did, handle, access, authMethod)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract Basic auth credentials
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
slog.Debug("No Basic auth credentials provided")
|
||||
sendAuthError(w, r, "authentication required")
|
||||
return
|
||||
}
|
||||
|
||||
slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
|
||||
|
||||
// 1. Check if it's a device secret (starts with "atcr_device_")
|
||||
if strings.HasPrefix(password, "atcr_device_") {
|
||||
device, err := h.deviceStore.ValidateDeviceSecret(password)
|
||||
@@ -227,6 +270,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue token using common helper
|
||||
h.issueToken(w, r, did, handle, access, authMethod)
|
||||
}
|
||||
|
||||
// issueToken validates access and issues a JWT token
|
||||
// This is the common code path for all authentication methods
|
||||
func (h *Handler) issueToken(w http.ResponseWriter, r *http.Request, did, handle string, access []auth.AccessEntry, authMethod string) {
|
||||
// Validate that the user has permission for the requested access
|
||||
// Use the actual handle from the validated credentials, not the Basic Auth username
|
||||
if err := auth.ValidateAccess(did, handle, access); err != nil {
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/proxy"
|
||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
@@ -258,33 +259,54 @@ func ValidateOwnerOrCrewAdmin(r *http.Request, pds *HoldPDS, httpClient HTTPClie
|
||||
// 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, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
|
||||
}
|
||||
|
||||
// Get captain record to check owner and public settings
|
||||
// Get captain record first - needed for proxy validation and crew check
|
||||
_, captain, err := pds.GetCaptainRecord(r.Context())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
||||
}
|
||||
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
var user *ValidatedUser
|
||||
|
||||
if strings.HasPrefix(authHeader, "Bearer ") {
|
||||
tokenString := strings.TrimPrefix(authHeader, "Bearer ")
|
||||
|
||||
// Try proxy assertion first if we have trusted proxies configured
|
||||
if len(captain.TrustedProxies) > 0 {
|
||||
validator := proxy.NewValidator(captain.TrustedProxies)
|
||||
proxyUser, proxyErr := validator.ValidateAssertion(r.Context(), tokenString, pds.did)
|
||||
if proxyErr == nil {
|
||||
// Proxy assertion validated successfully
|
||||
slog.Debug("Validated proxy assertion", "userDID", proxyUser.DID, "proxyDID", proxyUser.ProxyDID)
|
||||
user = &ValidatedUser{
|
||||
DID: proxyUser.DID,
|
||||
Authorized: true,
|
||||
}
|
||||
} else if !strings.Contains(proxyErr.Error(), "not in trustedProxies") {
|
||||
// Log non-trust errors for debugging
|
||||
slog.Debug("Proxy assertion validation failed, trying service token", "error", proxyErr)
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to service token if proxy assertion didn't work
|
||||
if user == nil {
|
||||
var serviceErr error
|
||||
user, serviceErr = ValidateServiceToken(r, pds.did, httpClient)
|
||||
if serviceErr != nil {
|
||||
return nil, fmt.Errorf("bearer token authentication failed: %w", serviceErr)
|
||||
}
|
||||
}
|
||||
} else if strings.HasPrefix(authHeader, "DPoP ") {
|
||||
// DPoP + OAuth authentication (direct user access)
|
||||
var dpopErr error
|
||||
user, dpopErr = ValidateDPoPRequest(r, httpClient)
|
||||
if dpopErr != nil {
|
||||
return nil, fmt.Errorf("DPoP authentication failed: %w", dpopErr)
|
||||
}
|
||||
} else {
|
||||
return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
|
||||
}
|
||||
|
||||
// Check if user is the owner (always has write access)
|
||||
if user.DID == captain.Owner {
|
||||
return user, nil
|
||||
|
||||
@@ -1,99 +1,29 @@
|
||||
package pds
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"atcr.io/pkg/atproto/did"
|
||||
)
|
||||
|
||||
// DIDDocument represents a did:web document
|
||||
type DIDDocument struct {
|
||||
Context []string `json:"@context"`
|
||||
ID string `json:"id"`
|
||||
AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
|
||||
VerificationMethod []VerificationMethod `json:"verificationMethod"`
|
||||
Authentication []string `json:"authentication,omitempty"`
|
||||
AssertionMethod []string `json:"assertionMethod,omitempty"`
|
||||
Service []Service `json:"service,omitempty"`
|
||||
}
|
||||
// Type aliases for backward compatibility - code using pds.DIDDocument etc. still works
|
||||
type DIDDocument = did.DIDDocument
|
||||
type VerificationMethod = did.VerificationMethod
|
||||
type Service = did.Service
|
||||
|
||||
// VerificationMethod represents a public key in a DID document
|
||||
type VerificationMethod struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Controller string `json:"controller"`
|
||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
||||
}
|
||||
// GenerateDIDFromURL creates a did:web identifier from a public URL
|
||||
// Delegates to shared package
|
||||
var GenerateDIDFromURL = did.GenerateDIDFromURL
|
||||
|
||||
// Service represents a service endpoint in a DID document
|
||||
type Service struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ServiceEndpoint string `json:"serviceEndpoint"`
|
||||
}
|
||||
|
||||
// GenerateDIDDocument creates a DID document for a did:web identity
|
||||
// GenerateDIDDocument creates a DID document for the hold's did:web identity
|
||||
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
|
||||
// Parse URL to extract host and port
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public URL: %w", err)
|
||||
}
|
||||
|
||||
hostname := u.Hostname()
|
||||
port := u.Port()
|
||||
|
||||
// Build host string (include non-standard ports per did:web spec)
|
||||
host := hostname
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
host = fmt.Sprintf("%s:%s", hostname, port)
|
||||
}
|
||||
|
||||
did := fmt.Sprintf("did:web:%s", host)
|
||||
|
||||
// Get public key in multibase format using indigo's crypto
|
||||
pubKey, err := p.signingKey.PublicKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
publicKeyMultibase := pubKey.Multibase()
|
||||
|
||||
doc := &DIDDocument{
|
||||
Context: []string{
|
||||
"https://www.w3.org/ns/did/v1",
|
||||
"https://w3id.org/security/multikey/v1",
|
||||
"https://w3id.org/security/suites/secp256k1-2019/v1",
|
||||
},
|
||||
ID: did,
|
||||
AlsoKnownAs: []string{
|
||||
fmt.Sprintf("at://%s", host),
|
||||
},
|
||||
VerificationMethod: []VerificationMethod{
|
||||
{
|
||||
ID: fmt.Sprintf("%s#atproto", did),
|
||||
Type: "Multikey",
|
||||
Controller: did,
|
||||
PublicKeyMultibase: publicKeyMultibase,
|
||||
},
|
||||
},
|
||||
Authentication: []string{
|
||||
fmt.Sprintf("%s#atproto", did),
|
||||
},
|
||||
Service: []Service{
|
||||
{
|
||||
ID: "#atproto_pds",
|
||||
Type: "AtprotoPersonalDataServer",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
{
|
||||
ID: "#atcr_hold",
|
||||
Type: "AtcrHoldService",
|
||||
ServiceEndpoint: publicURL,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return doc, nil
|
||||
services := did.DefaultHoldServices(publicURL)
|
||||
return did.GenerateDIDDocument(publicURL, pubKey, services)
|
||||
}
|
||||
|
||||
// MarshalDIDDocument converts a DID document to JSON using the stored public URL
|
||||
@@ -103,33 +33,5 @@ func (p *HoldPDS) MarshalDIDDocument() ([]byte, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return json.MarshalIndent(doc, "", " ")
|
||||
}
|
||||
|
||||
// GenerateDIDFromURL creates a did:web identifier from a public URL
|
||||
// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
|
||||
// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID
|
||||
func GenerateDIDFromURL(publicURL string) string {
|
||||
// Parse URL
|
||||
u, err := url.Parse(publicURL)
|
||||
if err != nil {
|
||||
// Fallback: assume it's just a hostname
|
||||
return fmt.Sprintf("did:web:%s", publicURL)
|
||||
}
|
||||
|
||||
// Get hostname
|
||||
hostname := u.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = "localhost"
|
||||
}
|
||||
|
||||
// Get port
|
||||
port := u.Port()
|
||||
|
||||
// Include port in DID if it's non-standard (not 80 for http, not 443 for https)
|
||||
if port != "" && port != "80" && port != "443" {
|
||||
return fmt.Sprintf("did:web:%s:%s", hostname, port)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("did:web:%s", hostname)
|
||||
return did.MarshalDIDDocument(doc)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user