Compare commits
1 Commits
refactor
...
trusted-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89f4641a2a |
@@ -21,6 +21,7 @@ import (
|
|||||||
"atcr.io/pkg/appview/middleware"
|
"atcr.io/pkg/appview/middleware"
|
||||||
"atcr.io/pkg/appview/storage"
|
"atcr.io/pkg/appview/storage"
|
||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
|
"atcr.io/pkg/atproto/did"
|
||||||
"atcr.io/pkg/auth"
|
"atcr.io/pkg/auth"
|
||||||
"atcr.io/pkg/auth/oauth"
|
"atcr.io/pkg/auth/oauth"
|
||||||
"atcr.io/pkg/auth/token"
|
"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)
|
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
|
// Create oauth token refresher
|
||||||
refresher := oauth.NewRefresher(oauthClientApp)
|
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
|
// Mount auth endpoints if enabled
|
||||||
if issuer != nil {
|
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
|
// Prevents the flood of errors when a stale session is discovered during push
|
||||||
tokenHandler.SetOAuthSessionValidator(refresher)
|
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
|
// Register token post-auth callback for profile management
|
||||||
// This decouples the token package from AppView-specific dependencies
|
// This decouples the token package from AppView-specific dependencies
|
||||||
tokenHandler.SetPostAuthCallback(func(ctx context.Context, did, handle, pdsEndpoint, accessToken string) error {
|
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")
|
// ClientName is the OAuth client display name (from env: ATCR_CLIENT_NAME, default: "AT Container Registry")
|
||||||
// Shown in OAuth authorization screens
|
// Shown in OAuth authorization screens
|
||||||
ClientName string `yaml:"client_name"`
|
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
|
// UIConfig defines web UI settings
|
||||||
@@ -150,6 +154,7 @@ func LoadConfigFromEnv() (*Config, error) {
|
|||||||
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
|
cfg.Server.TestMode = os.Getenv("TEST_MODE") == "true"
|
||||||
cfg.Server.OAuthKeyPath = getEnvOrDefault("ATCR_OAUTH_KEY_PATH", "/var/lib/atcr/oauth/client.key")
|
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.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
|
// Auto-detect base URL if not explicitly set
|
||||||
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
|
cfg.Server.BaseURL = os.Getenv("ATCR_BASE_URL")
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||||
"github.com/distribution/distribution/v3"
|
"github.com/distribution/distribution/v3"
|
||||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||||
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
|
registrymw "github.com/distribution/distribution/v3/registry/middleware/registry"
|
||||||
@@ -20,6 +21,7 @@ import (
|
|||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
"atcr.io/pkg/auth"
|
"atcr.io/pkg/auth"
|
||||||
"atcr.io/pkg/auth/oauth"
|
"atcr.io/pkg/auth/oauth"
|
||||||
|
"atcr.io/pkg/auth/proxy"
|
||||||
"atcr.io/pkg/auth/token"
|
"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.
|
// These are set by main.go during startup and copied into NamespaceResolver instances.
|
||||||
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
// After initialization, request handling uses the NamespaceResolver's instance fields.
|
||||||
var (
|
var (
|
||||||
globalRefresher *oauth.Refresher
|
globalRefresher *oauth.Refresher
|
||||||
globalDatabase storage.DatabaseMetrics
|
globalDatabase storage.DatabaseMetrics
|
||||||
globalAuthorizer auth.HoldAuthorizer
|
globalAuthorizer auth.HoldAuthorizer
|
||||||
globalReadmeCache storage.ReadmeCache
|
globalReadmeCache storage.ReadmeCache
|
||||||
|
globalProxySigningKey *atcrypto.PrivateKeyK256
|
||||||
|
globalServiceDID string
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetGlobalRefresher sets the OAuth refresher instance during initialization
|
// SetGlobalRefresher sets the OAuth refresher instance during initialization
|
||||||
@@ -200,6 +204,23 @@ func SetGlobalReadmeCache(readmeCache storage.ReadmeCache) {
|
|||||||
globalReadmeCache = 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() {
|
func init() {
|
||||||
// Register the name resolution middleware
|
// Register the name resolution middleware
|
||||||
registrymw.Register("atproto-resolver", initATProtoResolver)
|
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
|
// 2. OAuth sessions can be refreshed/invalidated between requests
|
||||||
// 3. The refresher already caches sessions efficiently (in-memory + DB)
|
// 3. The refresher already caches sessions efficiently (in-memory + DB)
|
||||||
// 4. Caching the repository with a stale ATProtoClient causes refresh token errors
|
// 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{
|
registryCtx := &storage.RegistryContext{
|
||||||
DID: did,
|
DID: did,
|
||||||
Handle: handle,
|
Handle: handle,
|
||||||
@@ -464,6 +510,8 @@ func (nr *NamespaceResolver) Repository(ctx context.Context, name reference.Name
|
|||||||
ServiceToken: serviceToken, // Cached service token from middleware validation
|
ServiceToken: serviceToken, // Cached service token from middleware validation
|
||||||
ATProtoClient: atprotoClient,
|
ATProtoClient: atprotoClient,
|
||||||
AuthMethod: authMethod, // Auth method from JWT token
|
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,
|
Database: nr.database,
|
||||||
Authorizer: nr.authorizer,
|
Authorizer: nr.authorizer,
|
||||||
Refresher: nr.refresher,
|
Refresher: nr.refresher,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
"atcr.io/pkg/auth"
|
"atcr.io/pkg/auth"
|
||||||
"atcr.io/pkg/auth/oauth"
|
"atcr.io/pkg/auth/oauth"
|
||||||
|
"atcr.io/pkg/auth/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DatabaseMetrics interface for tracking pull/push counts and querying hold DIDs
|
// 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")
|
Repository string // Image repository name (e.g., "debian")
|
||||||
ServiceToken string // Service token for hold authentication (cached by middleware)
|
ServiceToken string // Service token for hold authentication (cached by middleware)
|
||||||
ATProtoClient *atproto.Client // Authenticated ATProto client for this user
|
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)
|
// Shared services (same for all requests)
|
||||||
Database DatabaseMetrics // Metrics tracking database
|
Database DatabaseMetrics // Metrics tracking database
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
|
"atcr.io/pkg/auth/proxy"
|
||||||
"github.com/distribution/distribution/v3"
|
"github.com/distribution/distribution/v3"
|
||||||
"github.com/distribution/distribution/v3/registry/api/errcode"
|
"github.com/distribution/distribution/v3/registry/api/errcode"
|
||||||
"github.com/opencontainers/go-digest"
|
"github.com/opencontainers/go-digest"
|
||||||
@@ -60,19 +61,41 @@ func NewProxyBlobStore(ctx *RegistryContext) *ProxyBlobStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// doAuthenticatedRequest performs an HTTP request with service token authentication
|
// doAuthenticatedRequest performs an HTTP request with authentication
|
||||||
// Uses the service token from middleware to authenticate requests to the hold service
|
// 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) {
|
func (p *ProxyBlobStore) doAuthenticatedRequest(ctx context.Context, req *http.Request) (*http.Response, error) {
|
||||||
// Use service token that middleware already validated and cached
|
var token string
|
||||||
// Middleware fails fast with HTTP 401 if OAuth session is invalid
|
|
||||||
if p.ctx.ServiceToken == "" {
|
// Use proxy assertion if hold trusts AppView (faster, no per-request service token validation)
|
||||||
// Should never happen - middleware validates OAuth before handlers run
|
if p.ctx.HoldTrusted && p.ctx.ProxyAsserter != nil {
|
||||||
slog.Error("No service token in context", "component", "proxy_blob_store", "did", p.ctx.DID)
|
// Create proxy assertion signed by AppView
|
||||||
return nil, fmt.Errorf("no service token available (middleware should have validated)")
|
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
|
// 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)
|
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
|
// Stored in the hold's embedded PDS to identify the hold owner and settings
|
||||||
// Uses CBOR encoding for efficient storage in hold's carstore
|
// Uses CBOR encoding for efficient storage in hold's carstore
|
||||||
type CaptainRecord struct {
|
type CaptainRecord struct {
|
||||||
Type string `json:"$type" cborgen:"$type"`
|
Type string `json:"$type" cborgen:"$type"`
|
||||||
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
Owner string `json:"owner" cborgen:"owner"` // DID of hold owner
|
||||||
Public bool `json:"public" cborgen:"public"` // Public read access
|
Public bool `json:"public" cborgen:"public"` // Public read access
|
||||||
AllowAllCrew bool `json:"allowAllCrew" cborgen:"allowAllCrew"` // Allow any authenticated user to register as crew
|
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)
|
EnableBlueskyPosts bool `json:"enableBlueskyPosts" cborgen:"enableBlueskyPosts"` // Enable Bluesky posts when manifests are pushed (overrides env var)
|
||||||
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
TrustedProxies []string `json:"trustedProxies,omitempty" cborgen:"trustedProxies,omitempty"` // DIDs of trusted proxy services (e.g., ["did:web:atcr.io"])
|
||||||
Region string `json:"region,omitempty" cborgen:"region,omitempty"` // S3 region (optional)
|
DeployedAt string `json:"deployedAt" cborgen:"deployedAt"` // RFC3339 timestamp
|
||||||
Provider string `json:"provider,omitempty" cborgen:"provider,omitempty"` // Deployment provider (optional)
|
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
|
// 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
|
// Auth method constants
|
||||||
const (
|
const (
|
||||||
AuthMethodOAuth = "oauth"
|
AuthMethodOAuth = "oauth"
|
||||||
AuthMethodAppPassword = "app_password"
|
AuthMethodAppPassword = "app_password"
|
||||||
|
AuthMethodServiceToken = "service_token"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claims represents the JWT claims for registry authentication
|
// Claims represents the JWT claims for registry authentication
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"atcr.io/pkg/appview/db"
|
"atcr.io/pkg/appview/db"
|
||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
"atcr.io/pkg/auth"
|
"atcr.io/pkg/auth"
|
||||||
|
"atcr.io/pkg/auth/serviceauth"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PostAuthCallback is called after successful Basic Auth authentication.
|
// PostAuthCallback is called after successful Basic Auth authentication.
|
||||||
@@ -31,11 +32,12 @@ type OAuthSessionValidator interface {
|
|||||||
|
|
||||||
// Handler handles /auth/token requests
|
// Handler handles /auth/token requests
|
||||||
type Handler struct {
|
type Handler struct {
|
||||||
issuer *Issuer
|
issuer *Issuer
|
||||||
validator *auth.SessionValidator
|
validator *auth.SessionValidator
|
||||||
deviceStore *db.DeviceStore // For validating device secrets
|
deviceStore *db.DeviceStore // For validating device secrets
|
||||||
postAuthCallback PostAuthCallback
|
postAuthCallback PostAuthCallback
|
||||||
oauthSessionValidator OAuthSessionValidator
|
oauthSessionValidator OAuthSessionValidator
|
||||||
|
serviceTokenValidator *serviceauth.Validator // For CI service token authentication
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewHandler creates a new token handler
|
// NewHandler creates a new token handler
|
||||||
@@ -60,6 +62,13 @@ func (h *Handler) SetOAuthSessionValidator(validator OAuthSessionValidator) {
|
|||||||
h.oauthSessionValidator = validator
|
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
|
// TokenResponse represents the response from /auth/token
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
Token string `json:"token,omitempty"` // Legacy field
|
Token string `json:"token,omitempty"` // Legacy field
|
||||||
@@ -132,16 +141,6 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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
|
// Parse query parameters
|
||||||
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
|
_ = r.URL.Query().Get("service") // service parameter - validated by issuer
|
||||||
scopeParam := r.URL.Query().Get("scope")
|
scopeParam := r.URL.Query().Get("scope")
|
||||||
@@ -163,6 +162,50 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
var accessToken string
|
var accessToken string
|
||||||
var authMethod 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_")
|
// 1. Check if it's a device secret (starts with "atcr_device_")
|
||||||
if strings.HasPrefix(password, "atcr_device_") {
|
if strings.HasPrefix(password, "atcr_device_") {
|
||||||
device, err := h.deviceStore.ValidateDeviceSecret(password)
|
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
|
// Validate that the user has permission for the requested access
|
||||||
// Use the actual handle from the validated credentials, not the Basic Auth username
|
// Use the actual handle from the validated credentials, not the Basic Auth username
|
||||||
if err := auth.ValidateAccess(did, handle, access); err != nil {
|
if err := auth.ValidateAccess(did, handle, access); err != nil {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"atcr.io/pkg/atproto"
|
"atcr.io/pkg/atproto"
|
||||||
|
"atcr.io/pkg/auth/proxy"
|
||||||
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
"github.com/bluesky-social/indigo/atproto/atcrypto"
|
||||||
"github.com/bluesky-social/indigo/atproto/syntax"
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"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
|
// 2. DPoP + OAuth tokens - for direct user access
|
||||||
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
|
||||||
func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
|
func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
|
||||||
// Try service token validation first (for AppView access)
|
// Get captain record first - needed for proxy validation and crew check
|
||||||
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
|
|
||||||
_, captain, err := pds.GetCaptainRecord(r.Context())
|
_, captain, err := pds.GetCaptainRecord(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get captain record: %w", err)
|
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)
|
// Check if user is the owner (always has write access)
|
||||||
if user.DID == captain.Owner {
|
if user.DID == captain.Owner {
|
||||||
return user, nil
|
return user, nil
|
||||||
|
|||||||
@@ -1,99 +1,29 @@
|
|||||||
package pds
|
package pds
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
|
||||||
|
"atcr.io/pkg/atproto/did"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DIDDocument represents a did:web document
|
// Type aliases for backward compatibility - code using pds.DIDDocument etc. still works
|
||||||
type DIDDocument struct {
|
type DIDDocument = did.DIDDocument
|
||||||
Context []string `json:"@context"`
|
type VerificationMethod = did.VerificationMethod
|
||||||
ID string `json:"id"`
|
type Service = did.Service
|
||||||
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
|
// GenerateDIDFromURL creates a did:web identifier from a public URL
|
||||||
type VerificationMethod struct {
|
// Delegates to shared package
|
||||||
ID string `json:"id"`
|
var GenerateDIDFromURL = did.GenerateDIDFromURL
|
||||||
Type string `json:"type"`
|
|
||||||
Controller string `json:"controller"`
|
|
||||||
PublicKeyMultibase string `json:"publicKeyMultibase"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Service represents a service endpoint in a DID document
|
// GenerateDIDDocument creates a DID document for the hold's did:web identity
|
||||||
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
|
|
||||||
func (p *HoldPDS) GenerateDIDDocument(publicURL string) (*DIDDocument, error) {
|
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()
|
pubKey, err := p.signingKey.PublicKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||||
}
|
}
|
||||||
publicKeyMultibase := pubKey.Multibase()
|
|
||||||
|
|
||||||
doc := &DIDDocument{
|
services := did.DefaultHoldServices(publicURL)
|
||||||
Context: []string{
|
return did.GenerateDIDDocument(publicURL, pubKey, services)
|
||||||
"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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarshalDIDDocument converts a DID document to JSON using the stored public URL
|
// 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 nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return json.MarshalIndent(doc, "", " ")
|
return did.MarshalDIDDocument(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)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user