mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
refactor oauth code to reduce complexity
This commit is contained in:
+67
-19
@@ -10,6 +10,9 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
@@ -20,26 +23,30 @@ type Client struct {
|
||||
dpopKey *ecdsa.PrivateKey
|
||||
dpopTransport *DPoPTransport
|
||||
resolver *atproto.Resolver
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseUrl string
|
||||
metadata *AuthServerMetadata
|
||||
}
|
||||
|
||||
// NewClient creates a new OAuth client for ATProto
|
||||
func NewClient(clientID, redirectURI string) (*Client, error) {
|
||||
// NewClient creates a new OAuth client for ATProto from a base URL
|
||||
func NewClient(baseURL string) (*Client, error) {
|
||||
// Generate DPoP key
|
||||
dpopKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate DPoP key: %w", err)
|
||||
}
|
||||
|
||||
return NewClientWithKey(baseURL, dpopKey), nil
|
||||
}
|
||||
|
||||
// NewClientWithKey creates a new OAuth client with an existing DPoP key
|
||||
// This is useful when working with stored credentials (e.g., in AppView token refresh)
|
||||
func NewClientWithKey(baseURL string, dpopKey *ecdsa.PrivateKey) *Client {
|
||||
return &Client{
|
||||
dpopKey: dpopKey,
|
||||
dpopTransport: NewDPoPTransport(http.DefaultTransport, dpopKey),
|
||||
resolver: atproto.NewResolver(),
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
}, nil
|
||||
baseUrl: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
// InitializeForHandle discovers the authorization server for a given handle/DID
|
||||
@@ -50,6 +57,12 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
|
||||
return fmt.Errorf("failed to resolve identity: %w", err)
|
||||
}
|
||||
|
||||
return c.InitializeForPDS(ctx, pdsEndpoint)
|
||||
}
|
||||
|
||||
// InitializeForPDS discovers the authorization server for a given PDS endpoint
|
||||
// This is useful when you already know the PDS endpoint (e.g., from stored credentials)
|
||||
func (c *Client) InitializeForPDS(ctx context.Context, pdsEndpoint string) error {
|
||||
// Discover authorization server metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, pdsEndpoint)
|
||||
if err != nil {
|
||||
@@ -59,18 +72,15 @@ func (c *Client) InitializeForHandle(ctx context.Context, handle string) error {
|
||||
c.metadata = metadata
|
||||
|
||||
// Configure OAuth2 client
|
||||
// Note: Both localhost and production need redirect_uri and scopes in the config
|
||||
// For localhost: client_id contains these (query-based) AND they're sent as params
|
||||
// For production: client_id is metadata URL, params come from config
|
||||
c.config = &oauth2.Config{
|
||||
ClientID: c.clientID,
|
||||
ClientID: c.ClientID(),
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: c.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
RedirectURL: c.RedirectURI(),
|
||||
Scopes: c.GetDefaultScopes(),
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -177,31 +187,69 @@ func (c *Client) RefreshToken(ctx context.Context, refreshToken string) (*oauth2
|
||||
Transport: c.dpopTransport,
|
||||
})
|
||||
|
||||
// Create a token source with the refresh token
|
||||
token := &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
}
|
||||
|
||||
// Refresh the token
|
||||
newToken, err := c.config.TokenSource(ctx, token).Token()
|
||||
newToken, err := c.config.TokenSource(ctx, &oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
}).Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
|
||||
// Set access token on transport for "ath" claim in future DPoP proofs
|
||||
c.dpopTransport.SetAccessToken(newToken.AccessToken)
|
||||
|
||||
return newToken, nil
|
||||
}
|
||||
|
||||
func (c *Client) ClientID() (string) {
|
||||
return c.ClientIDWithScopes(c.GetDefaultScopes())
|
||||
}
|
||||
|
||||
func (c *Client) ClientIDWithScopes(scopes []string) string {
|
||||
scopeStr := strings.Join(scopes, " ")
|
||||
if strings.Contains(c.baseUrl, "127.0.0.1") || strings.Contains(c.baseUrl, "localhost") {
|
||||
// Localhost: use query-based client ID
|
||||
return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
|
||||
url.QueryEscape(c.RedirectURI()),
|
||||
url.QueryEscape(scopeStr))
|
||||
}
|
||||
// Production: use metadata URL
|
||||
return c.baseUrl + "/client-metadata.json"
|
||||
}
|
||||
|
||||
func (c *Client) RedirectURI() string {
|
||||
return c.baseUrl + "/auth/oauth/callback"
|
||||
}
|
||||
|
||||
// DPoPKey returns the DPoP private key
|
||||
func (c *Client) DPoPKey() *ecdsa.PrivateKey {
|
||||
return c.dpopKey
|
||||
}
|
||||
|
||||
// DPoPTransport returns the DPoP transport
|
||||
func (c *Client) DPoPTransport() *DPoPTransport {
|
||||
return c.dpopTransport
|
||||
}
|
||||
|
||||
// SetDPoPKey sets the DPoP private key (useful when loading from storage)
|
||||
func (c *Client) SetDPoPKey(key *ecdsa.PrivateKey) {
|
||||
c.dpopKey = key
|
||||
c.dpopTransport = NewDPoPTransport(http.DefaultTransport, key)
|
||||
}
|
||||
|
||||
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
|
||||
func (c *Client) GetDefaultScopes() []string {
|
||||
return []string{
|
||||
"atproto",
|
||||
"transition:generic.full",
|
||||
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
|
||||
}
|
||||
}
|
||||
|
||||
// generateCodeVerifier generates a PKCE code verifier
|
||||
func generateCodeVerifier() (string, error) {
|
||||
// Generate 32 random bytes
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MakeLocalhostClientID creates a query-based client ID for localhost development
|
||||
// Per ATProto OAuth spec: http://localhost?redirect_uri=...&scope=...
|
||||
func MakeLocalhostClientID(redirectURI string, scopes string) string {
|
||||
return fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
|
||||
url.QueryEscape(redirectURI),
|
||||
url.QueryEscape(scopes))
|
||||
}
|
||||
|
||||
// MakeProductionClientID creates a metadata URL client ID for production
|
||||
// Format: https://example.com/client-metadata.json
|
||||
func MakeProductionClientID(baseURL string) string {
|
||||
return baseURL + "/client-metadata.json"
|
||||
}
|
||||
|
||||
// IsLocalhostURL checks if a URL is localhost/127.0.0.1
|
||||
func IsLocalhostURL(urlStr string) bool {
|
||||
return strings.Contains(urlStr, "127.0.0.1") || strings.Contains(urlStr, "localhost")
|
||||
}
|
||||
|
||||
// ClientIDConfig helps construct appropriate client IDs for different environments
|
||||
type ClientIDConfig struct {
|
||||
BaseURL string // Base URL (e.g., "http://127.0.0.1:8888" or "https://example.com")
|
||||
CallbackPath string // Callback path (e.g., "/oauth/callback")
|
||||
Scopes []string // OAuth scopes
|
||||
}
|
||||
|
||||
// MakeClientID creates the appropriate client ID based on the environment
|
||||
// Returns (clientID, redirectURI)
|
||||
func (c *ClientIDConfig) MakeClientID() (string, string) {
|
||||
redirectURI := c.BaseURL + c.CallbackPath
|
||||
scopeStr := strings.Join(c.Scopes, " ")
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "atproto"
|
||||
}
|
||||
|
||||
if IsLocalhostURL(c.BaseURL) {
|
||||
// Localhost: use query-based client ID
|
||||
return MakeLocalhostClientID(redirectURI, scopeStr), redirectURI
|
||||
}
|
||||
|
||||
// Production: use metadata URL
|
||||
return MakeProductionClientID(c.BaseURL), redirectURI
|
||||
}
|
||||
@@ -10,10 +10,9 @@ import (
|
||||
|
||||
// InteractiveFlowConfig configures an interactive OAuth flow
|
||||
type InteractiveFlowConfig struct {
|
||||
ClientID string
|
||||
RedirectURI string
|
||||
Handle string
|
||||
Scopes []string // optional, defaults to ["atproto"]
|
||||
BaseURL string // Base URL for OAuth callbacks (e.g., "http://127.0.0.1:8080")
|
||||
Handle string // ATProto handle or DID
|
||||
Scopes []string // Optional, defaults to GetDefaultScopes()
|
||||
}
|
||||
|
||||
// FlowResult contains the result of a successful OAuth flow
|
||||
@@ -31,8 +30,8 @@ type FlowResult struct {
|
||||
func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
|
||||
setupCallback func(authURL string, handler *CallbackHandler, metadata *ClientMetadata) error) (*FlowResult, error) {
|
||||
|
||||
// Create OAuth client
|
||||
client, err := NewClient(cfg.ClientID, cfg.RedirectURI)
|
||||
// Create OAuth client from base URL
|
||||
client, err := NewClient(cfg.BaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OAuth client: %w", err)
|
||||
}
|
||||
@@ -58,7 +57,7 @@ func RunInteractiveFlow(ctx context.Context, cfg InteractiveFlowConfig,
|
||||
|
||||
// Create callback handler and client metadata FIRST
|
||||
callbackHandler := NewCallbackHandler(state)
|
||||
metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI})
|
||||
metadata := NewClientMetadata(client.ClientID(), []string{client.RedirectURI()})
|
||||
|
||||
// Start server BEFORE generating auth URL (so PAR can fetch metadata)
|
||||
if err := setupCallback("", callbackHandler, metadata); err != nil {
|
||||
|
||||
+12
-36
@@ -4,11 +4,8 @@ import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
|
||||
// AccessTokenEntry represents a cached access token
|
||||
@@ -26,18 +23,16 @@ type Refresher struct {
|
||||
mu sync.RWMutex
|
||||
refreshLocks map[string]*sync.Mutex // Per-DID locks for refresh operations
|
||||
refreshLockMu sync.Mutex // Protects refreshLocks map
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseURL string
|
||||
}
|
||||
|
||||
// NewRefresher creates a new token refresher
|
||||
func NewRefresher(storage *RefreshTokenStorage, clientID, redirectURI string) *Refresher {
|
||||
func NewRefresher(storage *RefreshTokenStorage, baseURL string) *Refresher {
|
||||
return &Refresher{
|
||||
storage: storage,
|
||||
accessTokens: make(map[string]*AccessTokenEntry),
|
||||
refreshLocks: make(map[string]*sync.Mutex),
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
baseURL: baseURL,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,35 +93,16 @@ func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecds
|
||||
return "", nil, nil, fmt.Errorf("failed to get DPoP key: %w", err)
|
||||
}
|
||||
|
||||
// Create OAuth client with DPoP transport
|
||||
dpopTransport := NewDPoPTransport(http.DefaultTransport, dpopKey)
|
||||
httpClient := &http.Client{Transport: dpopTransport}
|
||||
// Create OAuth client with stored DPoP key
|
||||
client := NewClientWithKey(r.baseURL, dpopKey)
|
||||
|
||||
// Discover PDS OAuth metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, entry.PDS)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to discover auth server: %w", err)
|
||||
// Initialize for PDS endpoint
|
||||
if err := client.InitializeForPDS(ctx, entry.PDS); err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to initialize OAuth client: %w", err)
|
||||
}
|
||||
|
||||
// Configure OAuth2 client
|
||||
config := &oauth2.Config{
|
||||
ClientID: r.clientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: r.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
|
||||
// Create context with custom HTTP client
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Exchange refresh token for new access token
|
||||
token, err := config.TokenSource(ctxWithClient, &oauth2.Token{
|
||||
RefreshToken: entry.RefreshToken,
|
||||
}).Token()
|
||||
// Refresh the token
|
||||
token, err := client.RefreshToken(ctx, entry.RefreshToken)
|
||||
if err != nil {
|
||||
return "", nil, nil, fmt.Errorf("failed to refresh token: %w", err)
|
||||
}
|
||||
@@ -146,8 +122,8 @@ func (r *Refresher) RefreshToken(ctx context.Context, did string) (string, *ecds
|
||||
}
|
||||
}
|
||||
|
||||
// Set access token on transport for "ath" claim in future DPoP proofs
|
||||
dpopTransport.SetAccessToken(token.AccessToken)
|
||||
// Get DPoP transport (already has access token set by client.RefreshToken)
|
||||
dpopTransport := client.DPoPTransport()
|
||||
|
||||
// Cache the access token and transport
|
||||
// Expire 1 minute early to avoid edge cases
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
)
|
||||
|
||||
// GetDefaultScopes returns the default OAuth scopes for ATCR registry operations
|
||||
func GetDefaultScopes() []string {
|
||||
return []string{
|
||||
"atproto",
|
||||
"transition:generic.full",
|
||||
"blob:application/vnd.docker.distribution.manifest.v2+json",
|
||||
"blob:application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"blob:application/vnd.docker.container.image.v1+json",
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.ManifestCollection),
|
||||
fmt.Sprintf("repo:%s?action=create", atproto.TagCollection),
|
||||
fmt.Sprintf("repo:%s?action=update", atproto.TagCollection),
|
||||
}
|
||||
}
|
||||
+10
-40
@@ -12,7 +12,6 @@ import (
|
||||
|
||||
"atcr.io/pkg/atproto"
|
||||
"atcr.io/pkg/auth/session"
|
||||
"authelia.com/client/oauth2"
|
||||
)
|
||||
|
||||
// Server handles OAuth authorization for the AppView
|
||||
@@ -21,8 +20,6 @@ type Server struct {
|
||||
sessionManager *session.Manager
|
||||
resolver *atproto.Resolver
|
||||
refresher *Refresher
|
||||
clientID string
|
||||
redirectURI string
|
||||
baseURL string
|
||||
states map[string]*OAuthState
|
||||
statesMu sync.RWMutex
|
||||
@@ -41,21 +38,11 @@ type OAuthState struct {
|
||||
|
||||
// NewServer creates a new OAuth server
|
||||
func NewServer(storage *RefreshTokenStorage, sessionManager *session.Manager, baseURL string) *Server {
|
||||
// Create client ID based on AppView's base URL
|
||||
cfg := ClientIDConfig{
|
||||
BaseURL: baseURL,
|
||||
CallbackPath: "/auth/oauth/callback",
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
clientID, redirectURI := cfg.MakeClientID()
|
||||
|
||||
return &Server{
|
||||
storage: storage,
|
||||
sessionManager: sessionManager,
|
||||
resolver: atproto.NewResolver(),
|
||||
refresher: nil, // Will be set via SetRefresher()
|
||||
clientID: clientID,
|
||||
redirectURI: redirectURI,
|
||||
baseURL: baseURL,
|
||||
states: make(map[string]*OAuthState),
|
||||
}
|
||||
@@ -92,9 +79,9 @@ func (s *Server) ServeAuthorize(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
fmt.Printf("DEBUG [oauth/server]: Resolved handle=%s -> did=%s, pds=%s\n", handle, did, pdsEndpoint)
|
||||
|
||||
// Create OAuth client
|
||||
fmt.Printf("DEBUG [oauth/server]: Creating OAuth client with clientID=%s, redirectURI=%s\n", s.clientID, s.redirectURI)
|
||||
client, err := NewClient(s.clientID, s.redirectURI)
|
||||
// Create OAuth client from base URL
|
||||
fmt.Printf("DEBUG [oauth/server]: Creating OAuth client for baseURL=%s\n", s.baseURL)
|
||||
client, err := NewClient(s.baseURL)
|
||||
if err != nil {
|
||||
fmt.Printf("ERROR [oauth/server]: Failed to create OAuth client: %v\n", err)
|
||||
http.Error(w, fmt.Sprintf("failed to create OAuth client: %v", err), http.StatusInternalServerError)
|
||||
@@ -178,33 +165,16 @@ func (s *Server) ServeCallback(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// exchangeCodeForSession exchanges authorization code for tokens and creates session
|
||||
func (s *Server) exchangeCodeForSession(ctx context.Context, code string, state *OAuthState) (string, error) {
|
||||
// Discover OAuth metadata
|
||||
metadata, err := DiscoverAuthServer(ctx, state.PDSEndpoint)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to discover auth server: %w", err)
|
||||
// Create OAuth client with stored DPoP key
|
||||
client := NewClientWithKey(s.baseURL, state.DPoPKey)
|
||||
|
||||
// Initialize for PDS endpoint
|
||||
if err := client.InitializeForPDS(ctx, state.PDSEndpoint); err != nil {
|
||||
return "", fmt.Errorf("failed to initialize OAuth client: %w", err)
|
||||
}
|
||||
|
||||
// Create DPoP transport
|
||||
dpopTransport := NewDPoPTransport(http.DefaultTransport, state.DPoPKey)
|
||||
httpClient := &http.Client{Transport: dpopTransport}
|
||||
|
||||
// Configure OAuth2 client
|
||||
config := &oauth2.Config{
|
||||
ClientID: s.clientID,
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
PushedAuthURL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
},
|
||||
RedirectURL: s.redirectURI,
|
||||
Scopes: GetDefaultScopes(),
|
||||
}
|
||||
|
||||
// Create context with custom HTTP client
|
||||
ctxWithClient := context.WithValue(ctx, oauth2.HTTPClient, httpClient)
|
||||
|
||||
// Exchange code for token
|
||||
token, err := config.Exchange(ctxWithClient, code, oauth2.VerifierOption(state.CodeVerifier))
|
||||
token, err := client.Exchange(ctx, code, state.CodeVerifier)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to exchange code: %w", err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user