···21 "atcr.io/pkg/appview/middleware"
22 "atcr.io/pkg/appview/storage"
23 "atcr.io/pkg/atproto"
024 "atcr.io/pkg/auth"
25 "atcr.io/pkg/auth/oauth"
26 "atcr.io/pkg/auth/token"
···138 } else if invalidatedCount > 0 {
139 slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
140 }
00000000000000141142 // Create oauth token refresher
143 refresher := oauth.NewRefresher(oauthClientApp)
···402 }
403 })
404405- // Note: Indigo handles OAuth state cleanup internally via its store
00000000000000000000000406407 // Mount auth endpoints if enabled
408 if issuer != nil {
···413 // This validates OAuth sessions are usable (not just exist) before issuing tokens
414 // Prevents the flood of errors when a stale session is discovered during push
415 tokenHandler.SetOAuthSessionValidator(refresher)
0000000416417 // Register token post-auth callback for profile management
418 // This decouples the token package from AppView-specific dependencies
···21 "atcr.io/pkg/appview/middleware"
22 "atcr.io/pkg/appview/storage"
23 "atcr.io/pkg/atproto"
24+ "atcr.io/pkg/atproto/did"
25 "atcr.io/pkg/auth"
26 "atcr.io/pkg/auth/oauth"
27 "atcr.io/pkg/auth/token"
···139 } else if invalidatedCount > 0 {
140 slog.Info("Invalidated OAuth sessions due to scope changes", "count", invalidatedCount)
141 }
142+143+ // Load or generate AppView K-256 signing key (for proxy assertions and DID document)
144+ slog.Info("Loading AppView signing key", "path", cfg.Server.ProxyKeyPath)
145+ proxySigningKey, err := oauth.GenerateOrLoadPDSKey(cfg.Server.ProxyKeyPath)
146+ if err != nil {
147+ return fmt.Errorf("failed to load proxy signing key: %w", err)
148+ }
149+150+ // Generate AppView DID from base URL
151+ serviceDID := did.GenerateDIDFromURL(baseURL)
152+ slog.Info("AppView DID initialized", "did", serviceDID)
153+154+ // Store signing key and DID for use by proxy assertion system
155+ middleware.SetGlobalProxySigningKey(proxySigningKey, serviceDID)
156157 // Create oauth token refresher
158 refresher := oauth.NewRefresher(oauthClientApp)
···417 }
418 })
419420+ // Serve DID document for AppView (enables proxy assertion validation)
421+ mainRouter.Get("/.well-known/did.json", func(w http.ResponseWriter, r *http.Request) {
422+ pubKey, err := proxySigningKey.PublicKey()
423+ if err != nil {
424+ slog.Error("Failed to get public key for DID document", "error", err)
425+ http.Error(w, "internal error", http.StatusInternalServerError)
426+ return
427+ }
428+429+ services := did.DefaultAppViewServices(baseURL)
430+ doc, err := did.GenerateDIDDocument(baseURL, pubKey, services)
431+ if err != nil {
432+ slog.Error("Failed to generate DID document", "error", err)
433+ http.Error(w, "internal error", http.StatusInternalServerError)
434+ return
435+ }
436+437+ w.Header().Set("Content-Type", "application/json")
438+ w.Header().Set("Access-Control-Allow-Origin", "*")
439+ if err := json.NewEncoder(w).Encode(doc); err != nil {
440+ slog.Error("Failed to encode DID document", "error", err)
441+ }
442+ })
443+ slog.Info("DID document endpoint enabled", "endpoint", "/.well-known/did.json", "did", serviceDID)
444445 // Mount auth endpoints if enabled
446 if issuer != nil {
···451 // This validates OAuth sessions are usable (not just exist) before issuing tokens
452 // Prevents the flood of errors when a stale session is discovered during push
453 tokenHandler.SetOAuthSessionValidator(refresher)
454+455+ // Enable service token authentication for CI platforms (e.g., Tangled/Spindle)
456+ // Service tokens from getServiceAuth are validated against this service's DID
457+ if serviceDID != "" {
458+ tokenHandler.SetServiceTokenValidator(serviceDID)
459+ slog.Info("Service token authentication enabled", "service_did", serviceDID)
460+ }
461462 // Register token post-auth callback for profile management
463 // This decouples the token package from AppView-specific dependencies
+5
pkg/appview/config.go
···58 // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
59 // Shown in OAuth authorization screens
60 ClientName string `yaml:"client_name"`
000061}
6263// UIConfig defines web UI settings
···150 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
151 cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
152 cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
0153154 // Auto-detect base URL if not explicitly set
155 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
···58 // ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
59 // Shown in OAuth authorization screens
60 ClientName string `yaml:"client_name"`
61+62+ // 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")
63+ // Auto-generated on first run. Used to sign proxy assertions for Hold services.
64+ ProxyKeyPath string `yaml:"proxy_key_path"`
65}
6667// UIConfig defines web UI settings
···154 cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
155 cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
156 cfg.Server.ClientName = getEnvOrDefault("ATCR_CLIENT_NAME", "AT Container Registry")
157+ cfg.Server.ProxyKeyPath = getEnvOrDefault("ATCR_PROXY_KEY_PATH", "/var/lib/atcr/auth/proxy-key")
158159 // Auto-detect base URL if not explicitly set
160 cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
+52-4
pkg/appview/middleware/registry.go
···10 "sync"
11 "time"
12013 "github.com/distribution/distribution/v3"
14 "github.com/distribution/distribution/v3/registry/api/errcode"
15 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
···20 "atcr.io/pkg/atproto"
21 "atcr.io/pkg/auth"
22 "atcr.io/pkg/auth/oauth"
023 "atcr.io/pkg/auth/token"
24)
25···170// These are set by main.go during startup and copied into NamespaceResolver instances.
171// After initialization, request handling uses the NamespaceResolver's instance fields.
172var (
173- globalRefresher *oauth.Refresher
174- globalDatabase storage.DatabaseMetrics
175- globalAuthorizer auth.HoldAuthorizer
176- globalReadmeCache storage.ReadmeCache
00177)
178179// SetGlobalRefresher sets the OAuth refresher instance during initialization
···198// Must be called before the registry starts serving requests
199func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
200 globalReadmeCache = readmeCache
00000000000000000201}
202203func init() {
···455 // 2. OAuth sessions can be refreshed/invalidated between requests
456 // 3. The refresher already caches sessions efficiently (in-memory + DB)
457 // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
0000000000000000000000000458 registryCtx := &storage.RegistryContext{
459 DID: did,
460 Handle: handle,
···464 ServiceToken: serviceToken, // Cached service token from middleware validation
465 ATProtoClient: atprotoClient,
466 AuthMethod: authMethod, // Auth method from JWT token
00467 Database: nr.database,
468 Authorizer: nr.authorizer,
469 Refresher: nr.refresher,
···10 "sync"
11 "time"
1213+ "github.com/bluesky-social/indigo/atproto/atcrypto"
14 "github.com/distribution/distribution/v3"
15 "github.com/distribution/distribution/v3/registry/api/errcode"
16 registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
···21 "atcr.io/pkg/atproto"
22 "atcr.io/pkg/auth"
23 "atcr.io/pkg/auth/oauth"
24+ "atcr.io/pkg/auth/proxy"
25 "atcr.io/pkg/auth/token"
26)
27···172// These are set by main.go during startup and copied into NamespaceResolver instances.
173// After initialization, request handling uses the NamespaceResolver's instance fields.
174var (
175+ globalRefresher *oauth.Refresher
176+ globalDatabase storage.DatabaseMetrics
177+ globalAuthorizer auth.HoldAuthorizer
178+ globalReadmeCache storage.ReadmeCache
179+ globalProxySigningKey *atcrypto.PrivateKeyK256
180+ globalServiceDID string
181)
182183// SetGlobalRefresher sets the OAuth refresher instance during initialization
···202// Must be called before the registry starts serving requests
203func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
204 globalReadmeCache = readmeCache
205+}
206+207+// SetGlobalProxySigningKey sets the K-256 signing key and DID for proxy assertions
208+// Must be called before the registry starts serving requests
209+func SetGlobalProxySigningKey(key *atcrypto.PrivateKeyK256, serviceDID string) {
210+ globalProxySigningKey = key
211+ globalServiceDID = serviceDID
212+}
213+214+// GetGlobalServiceDID returns the AppView service DID
215+func GetGlobalServiceDID() string {
216+ return globalServiceDID
217+}
218+219+// GetGlobalProxySigningKey returns the K-256 signing key for proxy assertions
220+func GetGlobalProxySigningKey() *atcrypto.PrivateKeyK256 {
221+ return globalProxySigningKey
222}
223224func init() {
···476 // 2. OAuth sessions can be refreshed/invalidated between requests
477 // 3. The refresher already caches sessions efficiently (in-memory + DB)
478 // 4. Caching the repository with a stale ATProtoClient causes refresh token errors
479+ // Check if hold trusts AppView for proxy assertions
480+ var proxyAsserter *proxy.Asserter
481+ holdTrusted := false
482+483+ if globalProxySigningKey != nil && globalServiceDID != "" && nr.authorizer != nil {
484+ // Create proxy asserter with AppView's signing key
485+ proxyAsserter = proxy.NewAsserter(globalServiceDID, globalProxySigningKey)
486+487+ // Check if the hold has AppView in its trustedProxies
488+ captain, err := nr.authorizer.GetCaptainRecord(ctx, holdDID)
489+ if err != nil {
490+ slog.Debug("Could not fetch captain record for proxy trust check",
491+ "hold_did", holdDID, "error", err)
492+ } else if captain != nil {
493+ for _, trusted := range captain.TrustedProxies {
494+ if trusted == globalServiceDID {
495+ holdTrusted = true
496+ slog.Debug("Hold trusts AppView, will use proxy assertions",
497+ "hold_did", holdDID, "appview_did", globalServiceDID)
498+ break
499+ }
500+ }
501+ }
502+ }
503+504 registryCtx := &storage.RegistryContext{
505 DID: did,
506 Handle: handle,
···510 ServiceToken: serviceToken, // Cached service token from middleware validation
511 ATProtoClient: atprotoClient,
512 AuthMethod: authMethod, // Auth method from JWT token
513+ ProxyAsserter: proxyAsserter, // Creates proxy assertions signed by AppView
514+ HoldTrusted: holdTrusted, // Whether hold trusts AppView for proxy auth
515 Database: nr.database,
516 Authorizer: nr.authorizer,
517 Refresher: nr.refresher,
+6-1
pkg/appview/storage/context.go
···6 "atcr.io/pkg/atproto"
7 "atcr.io/pkg/auth"
8 "atcr.io/pkg/auth/oauth"
09)
1011// DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs
···32 Repository string // Image repository name (e.g., "debian")
33 ServiceToken string // Service token for hold authentication (cached by middleware)
34 ATProtoClient *atproto.Client // Authenticated ATProto client for this user
35- AuthMethod string // Auth method used ("oauth" or "app_password")
00003637 // Shared services (same for all requests)
38 Database DatabaseMetrics // Metrics tracking database
···6 "atcr.io/pkg/atproto"
7 "atcr.io/pkg/auth"
8 "atcr.io/pkg/auth/oauth"
9+ "atcr.io/pkg/auth/proxy"
10)
1112// DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs
···33 Repository string // Image repository name (e.g., "debian")
34 ServiceToken string // Service token for hold authentication (cached by middleware)
35 ATProtoClient *atproto.Client // Authenticated ATProto client for this user
36+ AuthMethod string // Auth method used ("oauth", "app_password", "service_token")
37+38+ // Proxy assertion support (for CI and performance optimization)
39+ ProxyAsserter *proxy.Asserter // Creates proxy assertions (nil if not configured)
40+ HoldTrusted bool // Whether hold trusts AppView (has did:web:atcr.io in trustedProxies)
4142 // Shared services (same for all requests)
43 Database DatabaseMetrics // Metrics tracking database
+32-9
pkg/appview/storage/proxy_blob_store.go
···12 "time"
1314 "atcr.io/pkg/atproto"
015 "github.com/distribution/distribution/v3"
16 "github.com/distribution/distribution/v3/registry/api/errcode"
17 "github.com/opencontainers/go-digest"
···60 }
61}
6263-// doAuthenticatedRequest performs an HTTP request with service token authentication
64-// Uses the service token from middleware to authenticate requests to the hold service
65func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
66- // Use service token that middleware already validated and cached
67- // Middleware fails fast with HTTP 401 if OAuth session is invalid
68- if p.ctx.ServiceToken == "" {
69- // Should never happen - middleware validates OAuth before handlers run
70- slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
71- return nil, fmt.Errorf("no service token available (middleware should have validated)")
000000000000000000000072 }
7374 // Add Bearer token to Authorization header
75- req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", p.ctx.ServiceToken))
7677 return p.httpClient.Do(req)
78}
···12 "time"
1314 "atcr.io/pkg/atproto"
15+ "atcr.io/pkg/auth/proxy"
16 "github.com/distribution/distribution/v3"
17 "github.com/distribution/distribution/v3/registry/api/errcode"
18 "github.com/opencontainers/go-digest"
···61 }
62}
6364+// doAuthenticatedRequest performs an HTTP request with authentication
65+// Uses proxy assertion if hold trusts AppView, otherwise falls back to service token
66func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
67+ var token string
68+69+ // Use proxy assertion if hold trusts AppView (faster, no per-request service token validation)
70+ if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil {
71+ // Create proxy assertion signed by AppView
72+ proofHash := proxy.HashProofForAudit(p.ctx.ServiceToken)
73+ assertion, err := p.ctx.ProxyAsserter.CreateAssertion(p.ctx.DID, p.ctx.HoldDID, p.ctx.AuthMethod, proofHash)
74+ if err != nil {
75+ slog.Error("Failed to create proxy assertion, falling back to service token",
76+ "component", "proxy_blob_store", "error", err)
77+ // Fall through to service token
78+ } else {
79+ token = assertion
80+ slog.Debug("Using proxy assertion for hold authentication",
81+ "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
82+ }
83+ }
84+85+ // Fall back to service token if proxy assertion not available
86+ if token == "" {
87+ if p.ctx.ServiceToken == "" {
88+ // Should never happen - middleware validates OAuth before handlers run
89+ slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
90+ return nil, fmt.Errorf("no service token available (middleware should have validated)")
91+ }
92+ token = p.ctx.ServiceToken
93+ slog.Debug("Using service token for hold authentication",
94+ "component", "proxy_blob_store", "user_did", p.ctx.DID, "hold_did", p.ctx.HoldDID)
95 }
9697 // Add Bearer token to Authorization header
98+ req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
99100 return p.httpClient.Do(req)
101}
···1+// Package did provides shared DID document types and utilities for ATProto services.
2+// Both AppView and Hold use this package for did:web document generation.
3+package did
4+5+import (
6+ "encoding/json"
7+ "fmt"
8+ "net/url"
9+10+ "github.com/bluesky-social/indigo/atproto/atcrypto"
11+)
12+13+// DIDDocument represents a did:web document
14+type DIDDocument struct {
15+ Context []string `json:"@context"`
16+ ID string `json:"id"`
17+ AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
18+ VerificationMethod []VerificationMethod `json:"verificationMethod"`
19+ Authentication []string `json:"authentication,omitempty"`
20+ AssertionMethod []string `json:"assertionMethod,omitempty"`
21+ Service []Service `json:"service,omitempty"`
22+}
23+24+// VerificationMethod represents a public key in a DID document
25+type VerificationMethod struct {
26+ ID string `json:"id"`
27+ Type string `json:"type"`
28+ Controller string `json:"controller"`
29+ PublicKeyMultibase string `json:"publicKeyMultibase"`
30+}
31+32+// Service represents a service endpoint in a DID document
33+type Service struct {
34+ ID string `json:"id"`
35+ Type string `json:"type"`
36+ ServiceEndpoint string `json:"serviceEndpoint"`
37+}
38+39+// GenerateDIDFromURL creates a did:web identifier from a public URL
40+// Example: "https://atcr.io" -> "did:web:atcr.io"
41+// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
42+// Note: Non-standard ports are included in the DID
43+func GenerateDIDFromURL(publicURL string) string {
44+ u, err := url.Parse(publicURL)
45+ if err != nil {
46+ // Fallback: assume it's just a hostname
47+ return fmt.Sprintf("did:web:%s", publicURL)
48+ }
49+50+ hostname := u.Hostname()
51+ if hostname == "" {
52+ hostname = "localhost"
53+ }
54+55+ port := u.Port()
56+57+ // Include port in DID if it's non-standard (not 80 for http, not 443 for https)
58+ if port != "" && port != "80" && port != "443" {
59+ return fmt.Sprintf("did:web:%s:%s", hostname, port)
60+ }
61+62+ return fmt.Sprintf("did:web:%s", hostname)
63+}
64+65+// GenerateDIDDocument creates a DID document for a did:web identity
66+// This is a standalone function that can be used by any ATProto service.
67+// The services parameter allows customizing which service endpoints to include.
68+func GenerateDIDDocument(publicURL string, publicKey atcrypto.PublicKey, services []Service) (*DIDDocument, error) {
69+ u, err := url.Parse(publicURL)
70+ if err != nil {
71+ return nil, fmt.Errorf("failed to parse public URL: %w", err)
72+ }
73+74+ hostname := u.Hostname()
75+ port := u.Port()
76+77+ // Build host string (include non-standard ports)
78+ host := hostname
79+ if port != "" && port != "80" && port != "443" {
80+ host = fmt.Sprintf("%s:%s", hostname, port)
81+ }
82+83+ did := fmt.Sprintf("did:web:%s", host)
84+85+ // Get public key in multibase format
86+ publicKeyMultibase := publicKey.Multibase()
87+88+ doc := &DIDDocument{
89+ Context: []string{
90+ "https://www.w3.org/ns/did/v1",
91+ "https://w3id.org/security/multikey/v1",
92+ "https://w3id.org/security/suites/secp256k1-2019/v1",
93+ },
94+ ID: did,
95+ AlsoKnownAs: []string{
96+ fmt.Sprintf("at://%s", host),
97+ },
98+ VerificationMethod: []VerificationMethod{
99+ {
100+ ID: fmt.Sprintf("%s#atproto", did),
101+ Type: "Multikey",
102+ Controller: did,
103+ PublicKeyMultibase: publicKeyMultibase,
104+ },
105+ },
106+ Authentication: []string{
107+ fmt.Sprintf("%s#atproto", did),
108+ },
109+ Service: services,
110+ }
111+112+ return doc, nil
113+}
114+115+// MarshalDIDDocument converts a DID document to JSON bytes
116+func MarshalDIDDocument(doc *DIDDocument) ([]byte, error) {
117+ return json.MarshalIndent(doc, "", " ")
118+}
119+120+// DefaultHoldServices returns the standard service endpoints for a Hold service
121+func DefaultHoldServices(publicURL string) []Service {
122+ return []Service{
123+ {
124+ ID: "#atproto_pds",
125+ Type: "AtprotoPersonalDataServer",
126+ ServiceEndpoint: publicURL,
127+ },
128+ {
129+ ID: "#atcr_hold",
130+ Type: "AtcrHoldService",
131+ ServiceEndpoint: publicURL,
132+ },
133+ }
134+}
135+136+// DefaultAppViewServices returns the standard service endpoints for AppView
137+func DefaultAppViewServices(publicURL string) []Service {
138+ return []Service{
139+ {
140+ ID: "#atcr_registry",
141+ Type: "AtcrRegistryService",
142+ ServiceEndpoint: publicURL,
143+ },
144+ }
145+}
+9-8
pkg/atproto/lexicon.go
···539// Stored in the hold's embedded PDS to identify the hold owner and settings
540// Uses CBOR encoding for efficient storage in hold's carstore
541type CaptainRecord struct {
542- Type string `json:"$type" cborgen:"$type"`
543- Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
544- Public bool `json:"public" cborgen:"public"` // Public read access
545- AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
546- EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
547- DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
548- Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
549- Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
0550}
551552// CrewRecord represents a crew member in the hold
···539// Stored in the hold's embedded PDS to identify the hold owner and settings
540// Uses CBOR encoding for efficient storage in hold's carstore
541type CaptainRecord struct {
542+ Type string `json:"$type" cborgen:"$type"`
543+ Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
544+ Public bool `json:"public" cborgen:"public"` // Public read access
545+ AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
546+ EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
547+ TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"])
548+ DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
549+ Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
550+ Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
551}
552553// CrewRecord represents a crew member in the hold
···12 "atcr.io/pkg/appview/db"
13 "atcr.io/pkg/atproto"
14 "atcr.io/pkg/auth"
015)
1617// PostAuthCallback is called after successful Basic Auth authentication.
···3132// Handler handles /auth/token requests
33type Handler struct {
34- issuer *Issuer
35- validator *auth.SessionValidator
36- deviceStore *db.DeviceStore // For validating device secrets
37- postAuthCallback PostAuthCallback
38 oauthSessionValidator OAuthSessionValidator
039}
4041// NewHandler creates a new token handler
···58// This prevents the flood of errors that occurs when a stale session is discovered during push
59func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
60 h.oauthSessionValidator = validator
000000061}
6263// TokenResponse represents the response from /auth/token
···132 return
133 }
134135- // Extract Basic auth credentials
136- username, password, ok := r.BasicAuth()
137- if !ok {
138- slog.Debug("No Basic auth credentials provided")
139- sendAuthError(w, r, "authentication required")
140- return
141- }
142-143- slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
144-145 // Parse query parameters
146 _ = r.URL.Query().Get("service") // service parameter - validated by issuer
147 scopeParam := r.URL.Query().Get("scope")
···163 var accessToken string
164 var authMethod string
16500000000000000000000000000000000000000000000166 // 1. Check if it's a device secret (starts with "atcr_device_")
167 if strings.HasPrefix(password, "atcr_device_") {
168 device, err := h.deviceStore.ValidateDeviceSecret(password)
···227 }
228 }
2290000000230 // Validate that the user has permission for the requested access
231 // Use the actual handle from the validated credentials, not the Basic Auth username
232 if err := auth.ValidateAccess(did, handle, access); err != nil {
···12 "atcr.io/pkg/appview/db"
13 "atcr.io/pkg/atproto"
14 "atcr.io/pkg/auth"
15+ "atcr.io/pkg/auth/serviceauth"
16)
1718// PostAuthCallback is called after successful Basic Auth authentication.
···3233// Handler handles /auth/token requests
34type Handler struct {
35+ issuer *Issuer
36+ validator *auth.SessionValidator
37+ deviceStore *db.DeviceStore // For validating device secrets
38+ postAuthCallback PostAuthCallback
39 oauthSessionValidator OAuthSessionValidator
40+ serviceTokenValidator *serviceauth.Validator // For CI service token authentication
41}
4243// NewHandler creates a new token handler
···60// This prevents the flood of errors that occurs when a stale session is discovered during push
61func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
62 h.oauthSessionValidator = validator
63+}
64+65+// SetServiceTokenValidator sets the service token validator for CI authentication
66+// When set, the handler will accept Bearer tokens with service tokens from CI platforms
67+// serviceDID is the DID of this service (e.g., "did:web:atcr.io")
68+func (h *Handler) SetServiceTokenValidator(serviceDID string) {
69+ h.serviceTokenValidator = serviceauth.NewValidator(serviceDID)
70}
7172// TokenResponse represents the response from /auth/token
···141 return
142 }
1430000000000144 // Parse query parameters
145 _ = r.URL.Query().Get("service") // service parameter - validated by issuer
146 scopeParam := r.URL.Query().Get("scope")
···162 var accessToken string
163 var authMethod string
164165+ // Check for Bearer token authentication (CI service tokens)
166+ authHeader := r.Header.Get("Authorization")
167+ if strings.HasPrefix(authHeader, "Bearer ") && h.serviceTokenValidator != nil {
168+ tokenString := strings.TrimPrefix(authHeader, "Bearer ")
169+170+ slog.Debug("Processing service token authentication")
171+172+ validatedUser, err := h.serviceTokenValidator.Validate(r.Context(), tokenString)
173+ if err != nil {
174+ slog.Debug("Service token validation failed", "error", err)
175+ http.Error(w, fmt.Sprintf("service token authentication failed: %v", err), http.StatusUnauthorized)
176+ return
177+ }
178+179+ did = validatedUser.DID
180+ authMethod = AuthMethodServiceToken
181+182+ slog.Debug("Service token validated successfully", "did", did)
183+184+ // Resolve handle from DID for access validation
185+ resolvedDID, resolvedHandle, _, resolveErr := atproto.ResolveIdentity(r.Context(), did)
186+ if resolveErr != nil {
187+ slog.Warn("Failed to resolve handle for service token user", "did", did, "error", resolveErr)
188+ // Use empty handle - access validation will use DID
189+ } else {
190+ did = resolvedDID // Use canonical DID from resolution
191+ handle = resolvedHandle
192+ }
193+194+ // Service token auth - issue token and return
195+ h.issueToken(w, r, did, handle, access, authMethod)
196+ return
197+ }
198+199+ // Extract Basic auth credentials
200+ username, password, ok := r.BasicAuth()
201+ if !ok {
202+ slog.Debug("No Basic auth credentials provided")
203+ sendAuthError(w, r, "authentication required")
204+ return
205+ }
206+207+ slog.Debug("Got Basic auth credentials", "username", username, "passwordLength", len(password))
208+209 // 1. Check if it's a device secret (starts with "atcr_device_")
210 if strings.HasPrefix(password, "atcr_device_") {
211 device, err := h.deviceStore.ValidateDeviceSecret(password)
···270 }
271 }
272273+ // Issue token using common helper
274+ h.issueToken(w, r, did, handle, access, authMethod)
275+}
276+277+// issueToken validates access and issues a JWT token
278+// This is the common code path for all authentication methods
279+func (h *Handler) issueToken(w http.ResponseWriter, r *http.Request, did, handle string, access []auth.AccessEntry, authMethod string) {
280 // Validate that the user has permission for the requested access
281 // Use the actual handle from the validated credentials, not the Basic Auth username
282 if err := auth.ValidateAccess(did, handle, access); err != nil {
+37-15
pkg/hold/pds/auth.go
···13 "time"
1415 "atcr.io/pkg/atproto"
016 "github.com/bluesky-social/indigo/atproto/atcrypto"
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 "github.com/golang-jwt/jwt/v5"
···258// 2. DPoP + OAuth tokens - for direct user access
259// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
260func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
261- // Try service token validation first (for AppView access)
00000262 authHeader := r.Header.Get("Authorization")
263 var user *ValidatedUser
264- var err error
265266 if strings.HasPrefix(authHeader, "Bearer ") {
267- // Service token authentication
268- user, err = ValidateServiceToken(r, pds.did, httpClient)
269- if err != nil {
270- return nil, fmt.Errorf("service token authentication failed: %w", err)
0000000000000000000000271 }
272 } else if strings.HasPrefix(authHeader, "DPoP ") {
273 // DPoP + OAuth authentication (direct user access)
274- user, err = ValidateDPoPRequest(r, httpClient)
275- if err != nil {
276- return nil, fmt.Errorf("DPoP authentication failed: %w", err)
0277 }
278 } else {
279 return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
280- }
281-282- // Get captain record to check owner and public settings
283- _, captain, err := pds.GetCaptainRecord(r.Context())
284- if err != nil {
285- return nil, fmt.Errorf("failed to get captain record: %w", err)
286 }
287288 // Check if user is the owner (always has write access)
···13 "time"
1415 "atcr.io/pkg/atproto"
16+ "atcr.io/pkg/auth/proxy"
17 "github.com/bluesky-social/indigo/atproto/atcrypto"
18 "github.com/bluesky-social/indigo/atproto/syntax"
19 "github.com/golang-jwt/jwt/v5"
···259// 2. DPoP + OAuth tokens - for direct user access
260// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
261func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
262+ // Get captain record first - needed for proxy validation and crew check
263+ _, captain, err := pds.GetCaptainRecord(r.Context())
264+ if err != nil {
265+ return nil, fmt.Errorf("failed to get captain record: %w", err)
266+ }
267+268 authHeader := r.Header.Get("Authorization")
269 var user *ValidatedUser
0270271 if strings.HasPrefix(authHeader, "Bearer ") {
272+ tokenString := strings.TrimPrefix(authHeader, "Bearer ")
273+274+ // Try proxy assertion first if we have trusted proxies configured
275+ if len(captain.TrustedProxies) > 0 {
276+ validator := proxy.NewValidator(captain.TrustedProxies)
277+ proxyUser, proxyErr := validator.ValidateAssertion(r.Context(), tokenString, pds.did)
278+ if proxyErr == nil {
279+ // Proxy assertion validated successfully
280+ slog.Debug("Validated proxy assertion", "userDID", proxyUser.DID, "proxyDID", proxyUser.ProxyDID)
281+ user = &ValidatedUser{
282+ DID: proxyUser.DID,
283+ Authorized: true,
284+ }
285+ } else if !strings.Contains(proxyErr.Error(), "not in trustedProxies") {
286+ // Log non-trust errors for debugging
287+ slog.Debug("Proxy assertion validation failed, trying service token", "error", proxyErr)
288+ }
289+ }
290+291+ // Fall back to service token if proxy assertion didn't work
292+ if user == nil {
293+ var serviceErr error
294+ user, serviceErr = ValidateServiceToken(r, pds.did, httpClient)
295+ if serviceErr != nil {
296+ return nil, fmt.Errorf("bearer token authentication failed: %w", serviceErr)
297+ }
298 }
299 } else if strings.HasPrefix(authHeader, "DPoP ") {
300 // DPoP + OAuth authentication (direct user access)
301+ var dpopErr error
302+ user, dpopErr = ValidateDPoPRequest(r, httpClient)
303+ if dpopErr != nil {
304+ return nil, fmt.Errorf("DPoP authentication failed: %w", dpopErr)
305 }
306 } else {
307 return nil, fmt.Errorf("missing or invalid Authorization header (expected Bearer or DPoP)")
000000308 }
309310 // Check if user is the owner (always has write access)
+13-111
pkg/hold/pds/did.go
···1package pds
23import (
4- "encoding/json"
5 "fmt"
6- "net/url"
07)
89-// DIDDocument represents a did:web document
10-type DIDDocument struct {
11- Context []string `json:"@context"`
12- ID string `json:"id"`
13- AlsoKnownAs []string `json:"alsoKnownAs,omitempty"`
14- VerificationMethod []VerificationMethod `json:"verificationMethod"`
15- Authentication []string `json:"authentication,omitempty"`
16- AssertionMethod []string `json:"assertionMethod,omitempty"`
17- Service []Service `json:"service,omitempty"`
18-}
1920-// VerificationMethod represents a public key in a DID document
21-type VerificationMethod struct {
22- ID string `json:"id"`
23- Type string `json:"type"`
24- Controller string `json:"controller"`
25- PublicKeyMultibase string `json:"publicKeyMultibase"`
26-}
27-28-// Service represents a service endpoint in a DID document
29-type Service struct {
30- ID string `json:"id"`
31- Type string `json:"type"`
32- ServiceEndpoint string `json:"serviceEndpoint"`
33-}
3435-// GenerateDIDDocument creates a DID document for a did:web identity
36func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
37- // Parse URL to extract host and port
38- u, err := url.Parse(publicURL)
39- if err != nil {
40- return nil, fmt.Errorf("failed to parse public URL: %w", err)
41- }
42-43- hostname := u.Hostname()
44- port := u.Port()
45-46- // Build host string (include non-standard ports per did:web spec)
47- host := hostname
48- if port != "" && port != "80" && port != "443" {
49- host = fmt.Sprintf("%s:%s", hostname, port)
50- }
51-52- did := fmt.Sprintf("did:web:%s", host)
53-54- // Get public key in multibase format using indigo's crypto
55 pubKey, err := p.signingKey.PublicKey()
56 if err != nil {
57 return nil, fmt.Errorf("failed to get public key: %w", err)
58 }
59- publicKeyMultibase := pubKey.Multibase()
6061- doc := &DIDDocument{
62- Context: []string{
63- "https://www.w3.org/ns/did/v1",
64- "https://w3id.org/security/multikey/v1",
65- "https://w3id.org/security/suites/secp256k1-2019/v1",
66- },
67- ID: did,
68- AlsoKnownAs: []string{
69- fmt.Sprintf("at://%s", host),
70- },
71- VerificationMethod: []VerificationMethod{
72- {
73- ID: fmt.Sprintf("%s#atproto", did),
74- Type: "Multikey",
75- Controller: did,
76- PublicKeyMultibase: publicKeyMultibase,
77- },
78- },
79- Authentication: []string{
80- fmt.Sprintf("%s#atproto", did),
81- },
82- Service: []Service{
83- {
84- ID: "#atproto_pds",
85- Type: "AtprotoPersonalDataServer",
86- ServiceEndpoint: publicURL,
87- },
88- {
89- ID: "#atcr_hold",
90- Type: "AtcrHoldService",
91- ServiceEndpoint: publicURL,
92- },
93- },
94- }
95-96- return doc, nil
97}
9899// MarshalDIDDocument converts a DID document to JSON using the stored public URL
···103 return nil, err
104 }
105106- return json.MarshalIndent(doc, "", " ")
107-}
108-109-// GenerateDIDFromURL creates a did:web identifier from a public URL
110-// Example: "http://hold1.example.com:8080" -> "did:web:hold1.example.com:8080"
111-// Note: Per did:web spec, non-standard ports (not 80/443) are included in the DID
112-func GenerateDIDFromURL(publicURL string) string {
113- // Parse URL
114- u, err := url.Parse(publicURL)
115- if err != nil {
116- // Fallback: assume it's just a hostname
117- return fmt.Sprintf("did:web:%s", publicURL)
118- }
119-120- // Get hostname
121- hostname := u.Hostname()
122- if hostname == "" {
123- hostname = "localhost"
124- }
125-126- // Get port
127- port := u.Port()
128-129- // Include port in DID if it's non-standard (not 80 for http, not 443 for https)
130- if port != "" && port != "80" && port != "443" {
131- return fmt.Sprintf("did:web:%s:%s", hostname, port)
132- }
133-134- return fmt.Sprintf("did:web:%s", hostname)
135}
···1package pds
23import (
04 "fmt"
5+6+ "atcr.io/pkg/atproto/did"
7)
89+// Type aliases for backward compatibility - code using pds.DIDDocument etc. still works
10+type DIDDocument = did.DIDDocument
11+type VerificationMethod = did.VerificationMethod
12+type Service = did.Service
0000001314+// GenerateDIDFromURL creates a did:web identifier from a public URL
15+// Delegates to shared package
16+var GenerateDIDFromURL = did.GenerateDIDFromURL
000000000001718+// GenerateDIDDocument creates a DID document for the hold's did:web identity
19func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
00000000000000000020 pubKey, err := p.signingKey.PublicKey()
21 if err != nil {
22 return nil, fmt.Errorf("failed to get public key: %w", err)
23 }
02425+ services := did.DefaultHoldServices(publicURL)
26+ return did.GenerateDIDDocument(publicURL, pubKey, services)
000000000000000000000000000000000027}
2829// MarshalDIDDocument converts a DID document to JSON using the stored public URL
···33 return nil, err
34 }
3536+ return did.MarshalDIDDocument(doc)
000000000000000000000000000037}