mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-04-22 01:10:36 +00:00
191 lines
6.2 KiB
Go
191 lines
6.2 KiB
Go
// Package oauth provides OAuth client and flow implementation for ATCR.
|
|
// It wraps indigo's OAuth library with ATCR-specific configuration,
|
|
// including default scopes, client metadata, token refreshing, and
|
|
// interactive browser-based authentication flows.
|
|
package oauth
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/auth/oauth"
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// App wraps indigo's ClientApp with ATCR-specific configuration
|
|
type App struct {
|
|
clientApp *oauth.ClientApp
|
|
baseURL string
|
|
}
|
|
|
|
// NewApp creates a new OAuth app for ATCR with default scopes
|
|
func NewApp(baseURL string, store oauth.ClientAuthStore, holdDid string, keyPath string, clientName string) (*App, error) {
|
|
return NewAppWithScopes(baseURL, store, GetDefaultScopes(holdDid), keyPath, clientName)
|
|
}
|
|
|
|
// NewAppWithScopes creates a new OAuth app for ATCR with custom scopes
|
|
// Automatically configures confidential client for production deployments
|
|
// keyPath specifies where to store/load the OAuth client P-256 key (ignored for localhost)
|
|
// clientName is added to OAuth client metadata
|
|
func NewAppWithScopes(baseURL string, store oauth.ClientAuthStore, scopes []string, keyPath string, clientName string) (*App, error) {
|
|
var config oauth.ClientConfig
|
|
redirectURI := RedirectURI(baseURL)
|
|
|
|
// If production (not localhost), automatically set up confidential client
|
|
if !isLocalhost(baseURL) {
|
|
clientID := baseURL + "/client-metadata.json"
|
|
config = oauth.NewPublicConfig(clientID, redirectURI, scopes)
|
|
|
|
// Generate or load P-256 key
|
|
privateKey, err := GenerateOrLoadClientKey(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load OAuth client key: %w", err)
|
|
}
|
|
|
|
// Generate key ID from public key
|
|
keyID, err := GenerateKeyID(privateKey)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
|
}
|
|
|
|
// Upgrade to confidential client
|
|
if err := config.SetClientSecret(privateKey, keyID); err != nil {
|
|
return nil, fmt.Errorf("failed to configure confidential client: %w", err)
|
|
}
|
|
|
|
slog.Info("Configured confidential OAuth client", "key_id", keyID, "key_path", keyPath)
|
|
} else {
|
|
config = oauth.NewLocalhostConfig(redirectURI, scopes)
|
|
|
|
slog.Info("Using public OAuth client (localhost development)")
|
|
}
|
|
|
|
clientApp := oauth.NewClientApp(&config, store)
|
|
clientApp.Dir = atproto.GetDirectory()
|
|
|
|
return &App{
|
|
clientApp: clientApp,
|
|
baseURL: baseURL,
|
|
}, nil
|
|
}
|
|
|
|
func (a *App) GetConfig() *oauth.ClientConfig {
|
|
return a.clientApp.Config
|
|
}
|
|
|
|
// StartAuthFlow initiates an OAuth authorization flow for a given handle
|
|
// Returns the authorization URL (state is stored in the auth store)
|
|
func (a *App) StartAuthFlow(ctx context.Context, handle string) (authURL string, err error) {
|
|
// Start auth flow with handle as identifier
|
|
// Indigo will resolve the handle internally
|
|
authURL, err = a.clientApp.StartAuthFlow(ctx, handle)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to start auth flow: %w", err)
|
|
}
|
|
|
|
return authURL, nil
|
|
}
|
|
|
|
// ProcessCallback processes an OAuth callback with authorization code and state
|
|
// Returns ClientSessionData which contains the session information
|
|
func (a *App) ProcessCallback(ctx context.Context, params url.Values) (*oauth.ClientSessionData, error) {
|
|
sessionData, err := a.clientApp.ProcessCallback(ctx, params)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to process OAuth callback: %w", err)
|
|
}
|
|
|
|
return sessionData, nil
|
|
}
|
|
|
|
// ResumeSession resumes an existing OAuth session
|
|
// Returns a ClientSession that can be used to make authenticated requests
|
|
func (a *App) ResumeSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSession, error) {
|
|
session, err := a.clientApp.ResumeSession(ctx, did, sessionID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to resume session: %w", err)
|
|
}
|
|
|
|
return session, nil
|
|
}
|
|
|
|
// GetClientApp returns the underlying indigo ClientApp
|
|
// This is useful for advanced use cases that need direct access
|
|
func (a *App) GetClientApp() *oauth.ClientApp {
|
|
return a.clientApp
|
|
}
|
|
|
|
// Directory returns the identity directory used by the OAuth app
|
|
func (a *App) Directory() identity.Directory {
|
|
return a.clientApp.Dir
|
|
}
|
|
|
|
// RedirectURI returns the OAuth redirect URI for ATCR
|
|
func RedirectURI(baseURL string) string {
|
|
return baseURL + "/auth/oauth/callback"
|
|
}
|
|
|
|
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
|
|
// testMode determines whether to use transition:generic (test) or rpc scopes (production)
|
|
func GetDefaultScopes(did string) []string {
|
|
scopes := []string{
|
|
"atproto",
|
|
// Image manifest types (single-arch)
|
|
"blob:application/vnd.oci.image.manifest.v1+json",
|
|
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
|
// Manifest list/index types (multi-arch)
|
|
"blob:application/vnd.oci.image.index.v1+json",
|
|
"blob:application/vnd.docker.distribution.manifest.list.v2+json",
|
|
// OCI artifact manifests (for cosign signatures, SBOMs, attestations)
|
|
"blob:application/vnd.cncf.oras.artifact.manifest.v1+json",
|
|
// Used for service token validation on holds
|
|
"rpc:com.atproto.repo.getRecord?aud=*",
|
|
}
|
|
|
|
// Add repo scopes
|
|
scopes = append(scopes,
|
|
fmt.Sprintf("repo:%s", atproto.ManifestCollection),
|
|
fmt.Sprintf("repo:%s", atproto.TagCollection),
|
|
fmt.Sprintf("repo:%s", atproto.StarCollection),
|
|
fmt.Sprintf("repo:%s", atproto.SailorProfileCollection),
|
|
)
|
|
|
|
return scopes
|
|
}
|
|
|
|
// ScopesMatch checks if two scope lists are equivalent (order-independent)
|
|
// Returns true if both lists contain the same scopes, regardless of order
|
|
func ScopesMatch(stored, desired []string) bool {
|
|
// Handle nil/empty cases
|
|
if len(stored) == 0 && len(desired) == 0 {
|
|
return true
|
|
}
|
|
if len(stored) != len(desired) {
|
|
return false
|
|
}
|
|
|
|
// Build map of desired scopes for O(1) lookup
|
|
desiredMap := make(map[string]bool, len(desired))
|
|
for _, scope := range desired {
|
|
desiredMap[scope] = true
|
|
}
|
|
|
|
// Check if all stored scopes exist in desired
|
|
for _, scope := range stored {
|
|
if !desiredMap[scope] {
|
|
return false
|
|
}
|
|
}
|
|
|
|
return true
|
|
}
|
|
|
|
// isLocalhost checks if a base URL is a localhost address
|
|
func isLocalhost(baseURL string) bool {
|
|
return strings.Contains(baseURL, "127.0.0.1") || strings.Contains(baseURL, "localhost")
|
|
}
|