···2121 "atcr.io/pkg/appview/middleware"
2222 "atcr.io/pkg/appview/storage"
2323 "atcr.io/pkg/atproto"
2424+ "atcr.io/pkg/atproto/did"
2425 "atcr.io/pkg/auth"
2526 "atcr.io/pkg/auth/oauth"
2627 "atcr.io/pkg/auth/token"
···138139 } else if invalidatedCount > 0 {
139140 slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
140141 }
142142+143143+ // Load or generate AppView K-256 signing key (for proxy assertions and DID document)
144144+ slog.Info("Loading AppView signing key", "path", cfg.Server.ProxyKeyPath)
145145+ proxySigningKey, err := oauth.GenerateOrLoadPDSKey(cfg.Server.ProxyKeyPath)
146146+ if err != nil {
147147+ return fmt.Errorf("failed to load proxy signing key: %w", err)
148148+ }
149149+150150+ // Generate AppView DID from base URL
151151+ serviceDID := did.GenerateDIDFromURL(baseURL)
152152+ slog.Info("AppView DID initialized", "did", serviceDID)
153153+154154+ // Store signing key and DID for use by proxy assertion system
155155+ middleware.SetGlobalProxySigningKey(proxySigningKey, serviceDID)
141156142157 // Create oauth token refresher
143158 refresher := oauth.NewRefresher(oauthClientApp)
···402417 }
403418 })
404419405405- // Note: Indigo handles OAuth state cleanup internally via its store
420420+ // Serve DID document for AppView (enables proxy assertion validation)
421421+ mainRouter.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
422422+ pubKey, err := proxySigningKey.PublicKey()
423423+ if err != nil {
424424+ slog.Error("Failed to get public key for DID document", "error", err)
425425+ http.Error(w, "internal error", http.StatusInternalServerError)
426426+ return
427427+ }
428428+429429+ services := did.DefaultAppViewServices(baseURL)
430430+ doc, err := did.GenerateDIDDocument(baseURL, pubKey, services)
431431+ if err != nil {
432432+ slog.Error("Failed to generate DID document", "error", err)
433433+ http.Error(w, "internal error", http.StatusInternalServerError)
434434+ return
435435+ }
436436+437437+ w.Header().Set("Content-Type", "application/json")
438438+ w.Header().Set("Access-Control-Allow-Origin", "*")
439439+ if err := json.NewEncoder(w).Encode(doc); err != nil {
440440+ slog.Error("Failed to encode DID document", "error", err)
441441+ }
442442+ })
443443+ slog.Info("DID document endpoint enabled", "endpoint", "/.well-known/did.json", "did", serviceDID)
406444407445 // Mount auth endpoints if enabled
408446 if issuer != nil {
···413451 // This validates OAuth sessions are usable (not just exist) before issuing tokens
414452 // Prevents the flood of errors when a stale session is discovered during push
415453 tokenHandler.SetOAuthSessionValidator(refresher)
454454+455455+ // Enable service token authentication for CI platforms (e.g., Tangled/Spindle)
456456+ // Service tokens from getServiceAuth are validated against this service's DID
457457+ if serviceDID != "" {
458458+ tokenHandler.SetServiceTokenValidator(serviceDID)
459459+ slog.Info("Service token authentication enabled", "service_did", serviceDID)
460460+ }
416461417462 // Register token post-auth callback for profile management
418463 // This decouples the token package from AppView-specific dependencies
+5
pkg/appview/config.go
···5858 // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
5959 // Shown in OAuth authorization screens
6060 ClientName string `yaml:"client_name"`
6161+6262+ // 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")
6363+ // Auto-generated on first run. Used to sign proxy assertions for Hold services.
6464+ ProxyKeyPath string `yaml:"proxy_key_path"`
6165}
62666367// UIConfig defines web UI settings
···150154 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
151155 cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
152156 cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
157157+ cfg.Server.ProxyKeyPath = getEnvOrDefault("ATCR_PROXY_KEY_PATH", "/var/lib/atcr/auth/proxy-key")
153158154159 // Auto-detect base URL if not explicitly set
155160 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
+52-4
pkg/appview/middleware/registry.go
···1010 "sync"
1111 "time"
12121313+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1314 "github.com/distribution/distribution/v3"
1415 "github.com/distribution/distribution/v3/registry/api/errcode"
1516 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
···2021 "atcr.io/pkg/atproto"
2122 "atcr.io/pkg/auth"
2223 "atcr.io/pkg/auth/oauth"
2424+ "atcr.io/pkg/auth/proxy"
2325 "atcr.io/pkg/auth/token"
2426)
2527···170172// These are set by main.go during startup and copied into NamespaceResolver instances.
171173// After initialization, request handling uses the NamespaceResolver's instance fields.
172174var (
173173- globalRefresher *oauth.Refresher
174174- globalDatabase storage.DatabaseMetrics
175175- globalAuthorizer auth.HoldAuthorizer
176176- globalReadmeCache storage.ReadmeCache
175175+ globalRefresher *oauth.Refresher
176176+ globalDatabase storage.DatabaseMetrics
177177+ globalAuthorizer auth.HoldAuthorizer
178178+ globalReadmeCache storage.ReadmeCache
179179+ globalProxySigningKey *atcrypto.PrivateKeyK256
180180+ globalServiceDID string
177181)
178182179183// SetGlobalRefresher sets the OAuth refresher instance during initialization
···198202// Must be called before the registry starts serving requests
199203func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
200204 globalReadmeCache = readmeCache
205205+}
206206+207207+// SetGlobalProxySigningKey sets the K-256 signing key and DID for proxy assertions
208208+// Must be called before the registry starts serving requests
209209+func SetGlobalProxySigningKey(key *atcrypto.PrivateKeyK256, serviceDID string) {
210210+ globalProxySigningKey = key
211211+ globalServiceDID = serviceDID
212212+}
213213+214214+// GetGlobalServiceDID returns the AppView service DID
215215+func GetGlobalServiceDID() string {
216216+ return globalServiceDID
217217+}
218218+219219+// GetGlobalProxySigningKey returns the K-256 signing key for proxy assertions
220220+func GetGlobalProxySigningKey() *atcrypto.PrivateKeyK256 {
221221+ return globalProxySigningKey
201222}
202223203224func init() {
···455476 // 2. OAuth sessions can be refreshed/invalidated between requests
456477 // 3. The refresher already caches sessions efficiently (in-memory + DB)
457478 // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
479479+ // Check if hold trusts AppView for proxy assertions
480480+ var proxyAsserter *proxy.Asserter
481481+ holdTrusted := false
482482+483483+ if globalProxySigningKey != nil && globalServiceDID != "" && nr.authorizer != nil {
484484+ // Create proxy asserter with AppView's signing key
485485+ proxyAsserter = proxy.NewAsserter(globalServiceDID, globalProxySigningKey)
486486+487487+ // Check if the hold has AppView in its trustedProxies
488488+ captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID)
489489+ if err != nil {
490490+ slog.Debug("Could not fetch captain record for proxy trust check",
491491+ "hold_did", holdDID, "error", err)
492492+ } else if captain != nil {
493493+ for _, trusted := range captain.TrustedProxies {
494494+ if trusted == globalServiceDID {
495495+ holdTrusted = true
496496+ slog.Debug("Hold trusts AppView, will use proxy assertions",
497497+ "hold_did", holdDID, "appview_did", globalServiceDID)
498498+ break
499499+ }
500500+ }
501501+ }
502502+ }
503503+458504 registryCtx := &storage.RegistryContext{
459505 DID: did,
460506 Handle: handle,
···464510 ServiceToken: serviceToken, // Cached service token from middleware validation
465511 ATProtoClient: atprotoClient,
466512 AuthMethod: authMethod, // Auth method from JWT token
513513+ ProxyAsserter: proxyAsserter, // Creates proxy assertions signed by AppView
514514+ HoldTrusted: holdTrusted, // Whether hold trusts AppView for proxy auth
467515 Database: nr.database,
468516 Authorizer: nr.authorizer,
469517 Refresher: nr.refresher,
+6-1
pkg/appview/storage/context.go
···66 "atcr.io/pkg/atproto"
77 "atcr.io/pkg/auth"
88 "atcr.io/pkg/auth/oauth"
99+ "atcr.io/pkg/auth/proxy"
910)
10111112// DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs
···3233 Repository string // Image repository name (e.g., "debian")
3334 ServiceToken string // Service token for hold authentication (cached by middleware)
3435 ATProtoClient *atproto.Client // Authenticated ATProto client for this user
3535- AuthMethod string // Auth method used ("oauth" or "app_password")
3636+ AuthMethod string // Auth method used ("oauth", "app_password", "service_token")
3737+3838+ // Proxy assertion support (for CI and performance optimization)
3939+ ProxyAsserter *proxy.Asserter // Creates proxy assertions (nil if not configured)
4040+ HoldTrusted bool // Whether hold trusts AppView (has did:web:atcr.io in trustedProxies)
36413742 // Shared services (same for all requests)
3843 Database DatabaseMetrics // Metrics tracking database
+32-9
pkg/appview/storage/proxy_blob_store.go
···1212 "time"
13131414 "atcr.io/pkg/atproto"
1515+ "atcr.io/pkg/auth/proxy"
1516 "github.com/distribution/distribution/v3"
1617 "github.com/distribution/distribution/v3/registry/api/errcode"
1718 "github.com/opencontainers/go-digest"
···6061 }
6162}
62636363-// doAuthenticatedRequest performs an HTTP request with service token authentication
6464-// Uses the service token from middleware to authenticate requests to the hold service
6464+// doAuthenticatedRequest performs an HTTP request with authentication
6565+// Uses proxy assertion if hold trusts AppView, otherwise falls back to service token
6566func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
6666- // Use service token that middleware already validated and cached
6767- // Middleware fails fast with HTTP 401 if OAuth session is invalid
6868- if p.ctx.ServiceToken == "" {
6969- // Should never happen - middleware validates OAuth before handlers run
7070- slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
7171- return nil, fmt.Errorf("no service token available (middleware should have validated)")
6767+ var token string
6868+6969+ // Use proxy assertion if hold trusts AppView (faster, no per-request service token validation)
7070+ if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil {
7171+ // Create proxy assertion signed by AppView
7272+ proofHash := proxy.HashProofForAudit(p.ctx.ServiceToken)
7373+ assertion, err := p.ctx.ProxyAsserter.CreateAssertion(p.ctx.DID, p.ctx.HoldDID, p.ctx.AuthMethod, proofHash)
7474+ if err != nil {
7575+ slog.Error("Failed to create proxy assertion, falling back to service token",
7676+ "component", "proxy_blob_store", "error", err)
7777+ // Fall through to service token
7878+ } else {
7979+ token = assertion
8080+ slog.Debug("Using proxy assertion for hold authentication",
8181+ "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
8282+ }
8383+ }
8484+8585+ // Fall back to service token if proxy assertion not available
8686+ if token == "" {
8787+ if p.ctx.ServiceToken == "" {
8888+ // Should never happen - middleware validates OAuth before handlers run
8989+ slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
9090+ return nil, fmt.Errorf("no service token available (middleware should have validated)")
9191+ }
9292+ token = p.ctx.ServiceToken
9393+ slog.Debug("Using service token for hold authentication",
9494+ "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
7295 }
73967497 // Add Bearer token to Authorization header
7575- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
9898+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
769977100 return p.httpClient.Do(req)
78101}
+145
pkg/atproto/did/document.go
···11+// Package did provides shared DID document types and utilities for ATProto services.
22+// Both AppView and Hold use this package for did:web document generation.
33+package did
44+55+import (
66+ "encoding/json"
77+ "fmt"
88+ "net/url"
99+1010+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1111+)
1212+1313+// DIDDocument represents a did:web document
1414+type DIDDocument struct {
1515+ Context []string `json:"@context"`
1616+ ID string `json:"id"`
1717+ AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
1818+ VerificationMethod []VerificationMethod `json:"verificationMethod"`
1919+ Authentication []string `json:"authentication,omitempty"`
2020+ AssertionMethod []string `json:"assertionMethod,omitempty"`
2121+ Service []Service `json:"service,omitempty"`
2222+}
2323+2424+// VerificationMethod represents a public key in a DID document
2525+type VerificationMethod struct {
2626+ ID string `json:"id"`
2727+ Type string `json:"type"`
2828+ Controller string `json:"controller"`
2929+ PublicKeyMultibase string `json:"publicKeyMultibase"`
3030+}
3131+3232+// Service represents a service endpoint in a DID document
3333+type Service struct {
3434+ ID string `json:"id"`
3535+ Type string `json:"type"`
3636+ ServiceEndpoint string `json:"serviceEndpoint"`
3737+}
3838+3939+// GenerateDIDFromURL creates a did:web identifier from a public URL
4040+// Example: "https://atcr.io" -> "did:web:atcr.io"
4141+// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
4242+// Note: Non-standard ports are included in the DID
4343+func GenerateDIDFromURL(publicURL string) string {
4444+ u, err := url.Parse(publicURL)
4545+ if err != nil {
4646+ // Fallback: assume it's just a hostname
4747+ return fmt.Sprintf("did:web:%s", publicURL)
4848+ }
4949+5050+ hostname := u.Hostname()
5151+ if hostname == "" {
5252+ hostname = "localhost"
5353+ }
5454+5555+ port := u.Port()
5656+5757+ // Include port in DID if it's non-standard (not 80 for http, not 443 for https)
5858+ if port != "" && port != "80" && port != "443" {
5959+ return fmt.Sprintf("did:web:%s:%s", hostname, port)
6060+ }
6161+6262+ return fmt.Sprintf("did:web:%s", hostname)
6363+}
6464+6565+// GenerateDIDDocument creates a DID document for a did:web identity
6666+// This is a standalone function that can be used by any ATProto service.
6767+// The services parameter allows customizing which service endpoints to include.
6868+func GenerateDIDDocument(publicURL string, publicKey atcrypto.PublicKey, services []Service) (*DIDDocument, error) {
6969+ u, err := url.Parse(publicURL)
7070+ if err != nil {
7171+ return nil, fmt.Errorf("failed to parse public URL: %w", err)
7272+ }
7373+7474+ hostname := u.Hostname()
7575+ port := u.Port()
7676+7777+ // Build host string (include non-standard ports)
7878+ host := hostname
7979+ if port != "" && port != "80" && port != "443" {
8080+ host = fmt.Sprintf("%s:%s", hostname, port)
8181+ }
8282+8383+ did := fmt.Sprintf("did:web:%s", host)
8484+8585+ // Get public key in multibase format
8686+ publicKeyMultibase := publicKey.Multibase()
8787+8888+ doc := &DIDDocument{
8989+ Context: []string{
9090+ "https://www.w3.org/ns/did/v1",
9191+ "https://w3id.org/security/multikey/v1",
9292+ "https://w3id.org/security/suites/secp256k1-2019/v1",
9393+ },
9494+ ID: did,
9595+ AlsoKnownAs: []string{
9696+ fmt.Sprintf("at://%s", host),
9797+ },
9898+ VerificationMethod: []VerificationMethod{
9999+ {
100100+ ID: fmt.Sprintf("%s#atproto", did),
101101+ Type: "Multikey",
102102+ Controller: did,
103103+ PublicKeyMultibase: publicKeyMultibase,
104104+ },
105105+ },
106106+ Authentication: []string{
107107+ fmt.Sprintf("%s#atproto", did),
108108+ },
109109+ Service: services,
110110+ }
111111+112112+ return doc, nil
113113+}
114114+115115+// MarshalDIDDocument converts a DID document to JSON bytes
116116+func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
117117+ return json.MarshalIndent(doc, "", " ")
118118+}
119119+120120+// DefaultHoldServices returns the standard service endpoints for a Hold service
121121+func DefaultHoldServices(publicURL string) []Service {
122122+ return []Service{
123123+ {
124124+ ID: "#atproto_pds",
125125+ Type: "AtprotoPersonalDataServer",
126126+ ServiceEndpoint: publicURL,
127127+ },
128128+ {
129129+ ID: "#atcr_hold",
130130+ Type: "AtcrHoldService",
131131+ ServiceEndpoint: publicURL,
132132+ },
133133+ }
134134+}
135135+136136+// DefaultAppViewServices returns the standard service endpoints for AppView
137137+func DefaultAppViewServices(publicURL string) []Service {
138138+ return []Service{
139139+ {
140140+ ID: "#atcr_registry",
141141+ Type: "AtcrRegistryService",
142142+ ServiceEndpoint: publicURL,
143143+ },
144144+ }
145145+}
+9-8
pkg/atproto/lexicon.go
···539539// Stored in the hold's embedded PDS to identify the hold owner and settings
540540// Uses CBOR encoding for efficient storage in hold's carstore
541541type CaptainRecord struct {
542542- Type string `json:"$type" cborgen:"$type"`
543543- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
544544- Public bool `json:"public" cborgen:"public"` // Public read access
545545- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
546546- EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
547547- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
548548- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
549549- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
542542+ Type string `json:"$type" cborgen:"$type"`
543543+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
544544+ Public bool `json:"public" cborgen:"public"` // Public read access
545545+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
546546+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
547547+ TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"])
548548+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
549549+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
550550+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
550551}
551552552553// CrewRecord represents a crew member in the hold
+322
pkg/auth/proxy/assertion.go
···11+// Package proxy provides proxy assertion creation and validation for trusted proxy authentication.
22+// Proxy assertions allow AppView to vouch for users when communicating with Hold services,
33+// eliminating the need for per-request service token validation.
44+package proxy
55+66+import (
77+ "context"
88+ "encoding/base64"
99+ "encoding/json"
1010+ "fmt"
1111+ "log/slog"
1212+ "strings"
1313+ "sync"
1414+ "time"
1515+1616+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1717+ "github.com/bluesky-social/indigo/atproto/syntax"
1818+ "github.com/golang-jwt/jwt/v5"
1919+2020+ "atcr.io/pkg/atproto"
2121+)
2222+2323+// ProxyAssertionClaims represents the claims in a proxy assertion JWT
2424+type ProxyAssertionClaims struct {
2525+ jwt.RegisteredClaims
2626+ UserDID string `json:"user_did"` // User being proxied (for clarity, also in sub)
2727+ AuthMethod string `json:"auth_method"` // Original auth method: "oauth", "app_password", "service_token"
2828+ Proof string `json:"proof"` // Original token (truncated hash for audit, not full token)
2929+}
3030+3131+// Asserter creates proxy assertions signed by AppView
3232+type Asserter struct {
3333+ proxyDID string // AppView's DID (e.g., "did:web:atcr.io")
3434+ signingKey *atcrypto.PrivateKeyK256 // AppView's K-256 signing key
3535+}
3636+3737+// NewAsserter creates a new proxy assertion creator
3838+func NewAsserter(proxyDID string, signingKey *atcrypto.PrivateKeyK256) *Asserter {
3939+ return &Asserter{
4040+ proxyDID: proxyDID,
4141+ signingKey: signingKey,
4242+ }
4343+}
4444+4545+// CreateAssertion creates a proxy assertion JWT for a user
4646+// userDID: the user being proxied
4747+// holdDID: the target hold service
4848+// authMethod: how the user authenticated ("oauth", "app_password", "service_token")
4949+// proofHash: a hash of the original authentication proof (for audit trail)
5050+func (a *Asserter) CreateAssertion(userDID, holdDID, authMethod, proofHash string) (string, error) {
5151+ now := time.Now()
5252+5353+ claims := ProxyAssertionClaims{
5454+ RegisteredClaims: jwt.RegisteredClaims{
5555+ Issuer: a.proxyDID,
5656+ Subject: userDID,
5757+ Audience: jwt.ClaimStrings{holdDID},
5858+ ExpiresAt: jwt.NewNumericDate(now.Add(60 * time.Second)), // Short-lived
5959+ IssuedAt: jwt.NewNumericDate(now),
6060+ },
6161+ UserDID: userDID,
6262+ AuthMethod: authMethod,
6363+ Proof: proofHash,
6464+ }
6565+6666+ // Create JWT header
6767+ header := map[string]string{
6868+ "alg": "ES256K",
6969+ "typ": "JWT",
7070+ }
7171+7272+ // Encode header
7373+ headerJSON, err := json.Marshal(header)
7474+ if err != nil {
7575+ return "", fmt.Errorf("failed to marshal header: %w", err)
7676+ }
7777+ headerB64 := base64.RawURLEncoding.EncodeToString(headerJSON)
7878+7979+ // Encode payload
8080+ payloadJSON, err := json.Marshal(claims)
8181+ if err != nil {
8282+ return "", fmt.Errorf("failed to marshal claims: %w", err)
8383+ }
8484+ payloadB64 := base64.RawURLEncoding.EncodeToString(payloadJSON)
8585+8686+ // Create signing input
8787+ signingInput := headerB64 + "." + payloadB64
8888+8989+ // Sign using K-256
9090+ signature, err := a.signingKey.HashAndSign([]byte(signingInput))
9191+ if err != nil {
9292+ return "", fmt.Errorf("failed to sign assertion: %w", err)
9393+ }
9494+9595+ // Encode signature
9696+ signatureB64 := base64.RawURLEncoding.EncodeToString(signature)
9797+9898+ // Combine into JWT
9999+ token := signingInput + "." + signatureB64
100100+101101+ slog.Debug("Created proxy assertion",
102102+ "proxyDID", a.proxyDID,
103103+ "userDID", userDID,
104104+ "holdDID", holdDID,
105105+ "authMethod", authMethod)
106106+107107+ return token, nil
108108+}
109109+110110+// ValidatedUser represents a validated proxy assertion issuer
111111+type ValidatedUser struct {
112112+ DID string // User DID from sub claim
113113+ ProxyDID string // Proxy DID from iss claim
114114+ AuthMethod string // Original auth method
115115+}
116116+117117+// Validator validates proxy assertions from trusted proxies
118118+type Validator struct {
119119+ trustedProxies []string // List of trusted proxy DIDs
120120+ pubKeyCache *publicKeyCache // Cache for proxy public keys
121121+}
122122+123123+// NewValidator creates a new proxy assertion validator
124124+func NewValidator(trustedProxies []string) *Validator {
125125+ return &Validator{
126126+ trustedProxies: trustedProxies,
127127+ pubKeyCache: newPublicKeyCache(24 * time.Hour), // Cache public keys for 24 hours
128128+ }
129129+}
130130+131131+// ValidateAssertion validates a proxy assertion JWT
132132+// Returns the validated user info if successful
133133+func (v *Validator) ValidateAssertion(ctx context.Context, tokenString, holdDID string) (*ValidatedUser, error) {
134134+ // Parse JWT parts
135135+ parts := strings.Split(tokenString, ".")
136136+ if len(parts) != 3 {
137137+ return nil, fmt.Errorf("invalid JWT format")
138138+ }
139139+140140+ // Decode payload
141141+ payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
142142+ if err != nil {
143143+ return nil, fmt.Errorf("failed to decode payload: %w", err)
144144+ }
145145+146146+ // Parse claims
147147+ var claims ProxyAssertionClaims
148148+ if err := json.Unmarshal(payloadBytes, &claims); err != nil {
149149+ return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
150150+ }
151151+152152+ // Get issuer (proxy DID)
153153+ proxyDID := claims.Issuer
154154+ if proxyDID == "" {
155155+ return nil, fmt.Errorf("missing iss claim")
156156+ }
157157+158158+ // Check if issuer is trusted
159159+ if !v.isTrustedProxy(proxyDID) {
160160+ return nil, fmt.Errorf("proxy %s not in trustedProxies", proxyDID)
161161+ }
162162+163163+ // Verify audience matches this hold
164164+ audiences, err := claims.GetAudience()
165165+ if err != nil {
166166+ return nil, fmt.Errorf("failed to get audience: %w", err)
167167+ }
168168+ if len(audiences) == 0 || audiences[0] != holdDID {
169169+ return nil, fmt.Errorf("audience mismatch: expected %s, got %v", holdDID, audiences)
170170+ }
171171+172172+ // Verify expiration
173173+ exp, err := claims.GetExpirationTime()
174174+ if err != nil {
175175+ return nil, fmt.Errorf("failed to get expiration: %w", err)
176176+ }
177177+ if exp != nil && time.Now().After(exp.Time) {
178178+ return nil, fmt.Errorf("assertion has expired")
179179+ }
180180+181181+ // Fetch proxy's public key (with caching)
182182+ publicKey, err := v.getProxyPublicKey(ctx, proxyDID)
183183+ if err != nil {
184184+ return nil, fmt.Errorf("failed to fetch public key for proxy %s: %w", proxyDID, err)
185185+ }
186186+187187+ // Verify signature
188188+ signedData := []byte(parts[0] + "." + parts[1])
189189+ signature, err := base64.RawURLEncoding.DecodeString(parts[2])
190190+ if err != nil {
191191+ return nil, fmt.Errorf("failed to decode signature: %w", err)
192192+ }
193193+194194+ if err := publicKey.HashAndVerify(signedData, signature); err != nil {
195195+ return nil, fmt.Errorf("signature verification failed: %w", err)
196196+ }
197197+198198+ // Get user DID from sub claim
199199+ userDID := claims.Subject
200200+ if userDID == "" {
201201+ userDID = claims.UserDID // Fallback to explicit field
202202+ }
203203+ if userDID == "" {
204204+ return nil, fmt.Errorf("missing user DID in assertion")
205205+ }
206206+207207+ slog.Debug("Validated proxy assertion",
208208+ "proxyDID", proxyDID,
209209+ "userDID", userDID,
210210+ "authMethod", claims.AuthMethod)
211211+212212+ return &ValidatedUser{
213213+ DID: userDID,
214214+ ProxyDID: proxyDID,
215215+ AuthMethod: claims.AuthMethod,
216216+ }, nil
217217+}
218218+219219+// isTrustedProxy checks if a proxy DID is in the trusted list
220220+func (v *Validator) isTrustedProxy(proxyDID string) bool {
221221+ for _, trusted := range v.trustedProxies {
222222+ if trusted == proxyDID {
223223+ return true
224224+ }
225225+ }
226226+ return false
227227+}
228228+229229+// getProxyPublicKey fetches and caches a proxy's public key
230230+func (v *Validator) getProxyPublicKey(ctx context.Context, proxyDID string) (atcrypto.PublicKey, error) {
231231+ // Check cache first
232232+ if key := v.pubKeyCache.get(proxyDID); key != nil {
233233+ return key, nil
234234+ }
235235+236236+ // Fetch from DID document
237237+ key, err := fetchPublicKeyFromDID(ctx, proxyDID)
238238+ if err != nil {
239239+ return nil, err
240240+ }
241241+242242+ // Cache the key
243243+ v.pubKeyCache.set(proxyDID, key)
244244+245245+ return key, nil
246246+}
247247+248248+// publicKeyCache caches public keys for proxy DIDs
249249+type publicKeyCache struct {
250250+ mu sync.RWMutex
251251+ entries map[string]cacheEntry
252252+ ttl time.Duration
253253+}
254254+255255+type cacheEntry struct {
256256+ key atcrypto.PublicKey
257257+ expiresAt time.Time
258258+}
259259+260260+func newPublicKeyCache(ttl time.Duration) *publicKeyCache {
261261+ return &publicKeyCache{
262262+ entries: make(map[string]cacheEntry),
263263+ ttl: ttl,
264264+ }
265265+}
266266+267267+func (c *publicKeyCache) get(did string) atcrypto.PublicKey {
268268+ c.mu.RLock()
269269+ defer c.mu.RUnlock()
270270+271271+ entry, ok := c.entries[did]
272272+ if !ok || time.Now().After(entry.expiresAt) {
273273+ return nil
274274+ }
275275+ return entry.key
276276+}
277277+278278+func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) {
279279+ c.mu.Lock()
280280+ defer c.mu.Unlock()
281281+282282+ c.entries[did] = cacheEntry{
283283+ key: key,
284284+ expiresAt: time.Now().Add(c.ttl),
285285+ }
286286+}
287287+288288+// fetchPublicKeyFromDID fetches a public key from a DID document
289289+func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
290290+ directory := atproto.GetDirectory()
291291+ atID, err := syntax.ParseAtIdentifier(did)
292292+ if err != nil {
293293+ return nil, fmt.Errorf("invalid DID format: %w", err)
294294+ }
295295+296296+ ident, err := directory.Lookup(ctx, *atID)
297297+ if err != nil {
298298+ return nil, fmt.Errorf("failed to resolve DID: %w", err)
299299+ }
300300+301301+ publicKey, err := ident.PublicKey()
302302+ if err != nil {
303303+ return nil, fmt.Errorf("failed to get public key from DID: %w", err)
304304+ }
305305+306306+ return publicKey, nil
307307+}
308308+309309+// HashProofForAudit creates a truncated hash of a token for audit purposes
310310+// This allows tracking without storing the full sensitive token
311311+func HashProofForAudit(token string) string {
312312+ if token == "" {
313313+ return ""
314314+ }
315315+ // Use first 16 chars of a simple hash (not cryptographic, just for tracking)
316316+ // We don't need security here, just a way to correlate requests
317317+ hash := 0
318318+ for _, c := range token {
319319+ hash = hash*31 + int(c)
320320+ }
321321+ return fmt.Sprintf("%016x", uint64(hash))
322322+}
+223
pkg/auth/serviceauth/validator.go
···11+// Package serviceauth provides service token validation for ATProto service authentication.
22+// Service tokens are JWTs issued by a user's PDS via com.atproto.server.getServiceAuth.
33+// They allow services to authenticate users on behalf of other services.
44+package serviceauth
55+66+import (
77+ "context"
88+ "encoding/base64"
99+ "encoding/json"
1010+ "fmt"
1111+ "log/slog"
1212+ "sync"
1313+ "time"
1414+1515+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1616+ "github.com/bluesky-social/indigo/atproto/syntax"
1717+ "github.com/golang-jwt/jwt/v5"
1818+1919+ "atcr.io/pkg/atproto"
2020+)
2121+2222+// ValidatedUser represents a validated user from a service token
2323+type ValidatedUser struct {
2424+ DID string // User DID (from iss claim - the user's PDS signed this token for the user)
2525+}
2626+2727+// ServiceTokenClaims represents the claims in an ATProto service token
2828+type ServiceTokenClaims struct {
2929+ jwt.RegisteredClaims
3030+ Lxm string `json:"lxm,omitempty"` // Lexicon method identifier (e.g., "io.atcr.registry.push")
3131+}
3232+3333+// Validator validates ATProto service tokens
3434+type Validator struct {
3535+ serviceDID string // This service's DID (expected in aud claim)
3636+ pubKeyCache *publicKeyCache // Cache for public keys
3737+}
3838+3939+// NewValidator creates a new service token validator
4040+// serviceDID is the DID of this service (e.g., "did:web:atcr.io")
4141+// Tokens will be validated to ensure they are intended for this service (aud claim)
4242+func NewValidator(serviceDID string) *Validator {
4343+ return &Validator{
4444+ serviceDID: serviceDID,
4545+ pubKeyCache: newPublicKeyCache(24 * time.Hour),
4646+ }
4747+}
4848+4949+// Validate validates a service token and returns the authenticated user
5050+// tokenString is the raw JWT token (without "Bearer " prefix)
5151+// Returns the user DID if validation succeeds
5252+func (v *Validator) Validate(ctx context.Context, tokenString string) (*ValidatedUser, error) {
5353+ // Parse JWT parts manually (golang-jwt doesn't support ES256K algorithm used by ATProto)
5454+ parts := splitJWT(tokenString)
5555+ if parts == nil {
5656+ return nil, fmt.Errorf("invalid JWT format")
5757+ }
5858+5959+ // Decode payload to extract claims
6060+ payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[1])
6161+ if err != nil {
6262+ return nil, fmt.Errorf("failed to decode JWT payload: %w", err)
6363+ }
6464+6565+ // Parse claims
6666+ var claims ServiceTokenClaims
6767+ if err := json.Unmarshal(payloadBytes, &claims); err != nil {
6868+ return nil, fmt.Errorf("failed to unmarshal claims: %w", err)
6969+ }
7070+7171+ // Get issuer DID (the user's DID - they own the PDS that issued this token)
7272+ issuerDID := claims.Issuer
7373+ if issuerDID == "" {
7474+ return nil, fmt.Errorf("missing iss claim")
7575+ }
7676+7777+ // Verify audience matches this service
7878+ audiences, err := claims.GetAudience()
7979+ if err != nil {
8080+ return nil, fmt.Errorf("failed to get audience: %w", err)
8181+ }
8282+ if len(audiences) == 0 || audiences[0] != v.serviceDID {
8383+ return nil, fmt.Errorf("audience mismatch: expected %s, got %v", v.serviceDID, audiences)
8484+ }
8585+8686+ // Verify expiration
8787+ exp, err := claims.GetExpirationTime()
8888+ if err != nil {
8989+ return nil, fmt.Errorf("failed to get expiration: %w", err)
9090+ }
9191+ if exp != nil && time.Now().After(exp.Time) {
9292+ return nil, fmt.Errorf("token has expired")
9393+ }
9494+9595+ // Fetch public key from issuer's DID document (with caching)
9696+ publicKey, err := v.getPublicKey(ctx, issuerDID)
9797+ if err != nil {
9898+ return nil, fmt.Errorf("failed to fetch public key for issuer %s: %w", issuerDID, err)
9999+ }
100100+101101+ // Verify signature using ATProto's secp256k1 crypto
102102+ signedData := []byte(parts[0] + "." + parts[1])
103103+ signature, err := base64.RawURLEncoding.DecodeString(parts[2])
104104+ if err != nil {
105105+ return nil, fmt.Errorf("failed to decode signature: %w", err)
106106+ }
107107+108108+ if err := publicKey.HashAndVerify(signedData, signature); err != nil {
109109+ return nil, fmt.Errorf("signature verification failed: %w", err)
110110+ }
111111+112112+ slog.Debug("Successfully validated service token",
113113+ "userDID", issuerDID,
114114+ "serviceDID", v.serviceDID)
115115+116116+ return &ValidatedUser{
117117+ DID: issuerDID,
118118+ }, nil
119119+}
120120+121121+// splitJWT splits a JWT into its three parts
122122+// Returns nil if the format is invalid
123123+func splitJWT(token string) []string {
124124+ parts := make([]string, 0, 3)
125125+ start := 0
126126+ count := 0
127127+128128+ for i, c := range token {
129129+ if c == '.' {
130130+ parts = append(parts, token[start:i])
131131+ start = i + 1
132132+ count++
133133+ }
134134+ }
135135+136136+ // Add the final part
137137+ parts = append(parts, token[start:])
138138+139139+ if len(parts) != 3 {
140140+ return nil
141141+ }
142142+ return parts
143143+}
144144+145145+// getPublicKey fetches and caches a public key for a DID
146146+func (v *Validator) getPublicKey(ctx context.Context, did string) (atcrypto.PublicKey, error) {
147147+ // Check cache first
148148+ if key := v.pubKeyCache.get(did); key != nil {
149149+ return key, nil
150150+ }
151151+152152+ // Fetch from DID document
153153+ key, err := fetchPublicKeyFromDID(ctx, did)
154154+ if err != nil {
155155+ return nil, err
156156+ }
157157+158158+ // Cache the key
159159+ v.pubKeyCache.set(did, key)
160160+161161+ return key, nil
162162+}
163163+164164+// fetchPublicKeyFromDID fetches the public key from a DID document
165165+func fetchPublicKeyFromDID(ctx context.Context, did string) (atcrypto.PublicKey, error) {
166166+ directory := atproto.GetDirectory()
167167+ atID, err := syntax.ParseAtIdentifier(did)
168168+ if err != nil {
169169+ return nil, fmt.Errorf("invalid DID format: %w", err)
170170+ }
171171+172172+ ident, err := directory.Lookup(ctx, *atID)
173173+ if err != nil {
174174+ return nil, fmt.Errorf("failed to resolve DID: %w", err)
175175+ }
176176+177177+ publicKey, err := ident.PublicKey()
178178+ if err != nil {
179179+ return nil, fmt.Errorf("failed to get public key from DID: %w", err)
180180+ }
181181+182182+ return publicKey, nil
183183+}
184184+185185+// publicKeyCache caches public keys for DIDs
186186+type publicKeyCache struct {
187187+ mu sync.RWMutex
188188+ entries map[string]cacheEntry
189189+ ttl time.Duration
190190+}
191191+192192+type cacheEntry struct {
193193+ key atcrypto.PublicKey
194194+ expiresAt time.Time
195195+}
196196+197197+func newPublicKeyCache(ttl time.Duration) *publicKeyCache {
198198+ return &publicKeyCache{
199199+ entries: make(map[string]cacheEntry),
200200+ ttl: ttl,
201201+ }
202202+}
203203+204204+func (c *publicKeyCache) get(did string) atcrypto.PublicKey {
205205+ c.mu.RLock()
206206+ defer c.mu.RUnlock()
207207+208208+ entry, ok := c.entries[did]
209209+ if !ok || time.Now().After(entry.expiresAt) {
210210+ return nil
211211+ }
212212+ return entry.key
213213+}
214214+215215+func (c *publicKeyCache) set(did string, key atcrypto.PublicKey) {
216216+ c.mu.Lock()
217217+ defer c.mu.Unlock()
218218+219219+ c.entries[did] = cacheEntry{
220220+ key: key,
221221+ expiresAt: time.Now().Add(c.ttl),
222222+ }
223223+}