diff --git a/CLAUDE.md b/CLAUDE.md index 1b9b537..13ab2af 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -167,13 +167,35 @@ ATCR implements the full ATProto OAuth specification with mandatory security fea - **PAR** (RFC 9126) - Pushed Authorization Requests for server-to-server parameter exchange - **PKCE** (RFC 7636) - Proof Key for Code Exchange to prevent authorization code interception -**Key Components** (`pkg/auth/`): +**Key Components** (`pkg/auth/oauth/`): -1. **OAuth Client** (`oauth/client.go`) - Handles authorization flow with DPoP -2. **DPoP Transport** (`oauth/transport.go`) - HTTP RoundTripper that auto-adds DPoP headers -3. **Token Storage** (`oauth/storage.go`) - Persists tokens and DPoP key in `~/.atcr/oauth-token.json` -4. **Token Validator** (`atproto/validator.go`) - Validates tokens via PDS `getSession` endpoint -5. **Exchange Handler** (`exchange/handler.go`) - Exchanges OAuth tokens for registry JWTs +1. **Client** (`client.go`) - Core OAuth client with encapsulated configuration + - Constructor: `NewClient(baseURL)` - accepts base URL, derives client ID/redirect URI + - `NewClientWithKey(baseURL, dpopKey)` - for token refresh with stored DPoP key + - `ClientID()` - computes localhost vs production client ID dynamically + - `RedirectURI()` - returns `baseURL + "/auth/oauth/callback"` + - `GetDefaultScopes()` - returns ATCR registry scopes + - All OAuth flows (authorization, token exchange, refresh) in one place + +2. **DPoP Transport** (`transport.go`) - HTTP RoundTripper that auto-adds DPoP headers + +3. **Token Storage** (`tokenstorage.go`) - Persists refresh tokens and DPoP keys for AppView + - File-based storage in `/var/lib/atcr/refresh-tokens.json` (AppView) + - Client uses `~/.atcr/oauth-token.json` (credential helper) + +4. **Refresher** (`refresher.go`) - Token refresh manager for AppView + - Caches access tokens with automatic refresh + - Per-DID locking prevents concurrent refresh races + - Uses Client methods for consistency + +5. **Server** (`server.go`) - OAuth authorization endpoints for AppView + - `GET /auth/oauth/authorize` - starts OAuth flow + - `GET /auth/oauth/callback` - handles OAuth callback + - Uses Client methods for authorization and token exchange + +6. **Interactive Flow** (`flow.go`) - Reusable OAuth flow for CLI tools + - Used by credential helper and hold service registration + - Two-phase callback setup ensures PAR metadata availability **Authentication Flow:** ``` @@ -398,13 +420,19 @@ Environment variables: ### Development Notes +**General:** - Middleware is registered via `init()` functions in `pkg/middleware/` - Import `_ "atcr.io/pkg/middleware"` in main.go to register middleware - Storage drivers imported as `_ "github.com/distribution/distribution/v3/registry/storage/driver/s3-aws"` - Storage service reuses distribution's driver factory for multi-backend support -- OAuth client uses `authelia.com/client/oauth2` for PAR support + +**OAuth implementation:** +- Client (`pkg/auth/oauth/client.go`) encapsulates all OAuth configuration +- Uses `authelia.com/client/oauth2` for PAR support - DPoP proofs generated with `github.com/AxisCommunications/go-dpop` (auto-handles JWK) - Token validation via `com.atproto.server.getSession` ensures no trust in client-provided identity +- All ATCR components use standardized `/auth/oauth/callback` path +- Client ID generation (localhost query-based vs production metadata URL) handled internally ### Testing Strategy @@ -432,10 +460,12 @@ When writing tests: 2. Update `pkg/middleware/registry.go` if changing routing logic 3. Remember: `findStorageEndpoint()` queries PDS for `io.atcr.hold` records -**Implementing OAuth authentication**: -- AppView: `pkg/auth/exchange/handler.go` - validates tokens via PDS getSession -- Client: `pkg/auth/oauth/client.go` - OAuth + DPoP flow -- Helper: `cmd/credential-helper/` - Docker credential protocol +**Working with OAuth client**: +- Client is self-contained: pass `baseURL`, it handles client ID/redirect URI/scopes +- For AppView server/refresher: use `NewClient(baseURL)` or `NewClientWithKey(baseURL, storedKey)` +- For custom scopes: call `client.SetScopes(customScopes)` after initialization +- Standard callback path: `/auth/oauth/callback` (used by all ATCR components) +- Client methods are consistent across authorization, token exchange, and refresh flows **Adding BYOS support for a user**: 1. User sets environment variables (storage credentials, public URL) diff --git a/cmd/hold/main.go b/cmd/hold/main.go index 9155acc..af6863b 100644 --- a/cmd/hold/main.go +++ b/cmd/hold/main.go @@ -862,9 +862,9 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection), } - // Determine base URL and client ID based on mode - var baseURL, callbackPath string - callbackPath = "/oauth/callback" + // Determine base URL based on mode + // Callback path standardized to /auth/oauth/callback across ATCR + var baseURL string if s.config.Server.TestMode { // Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record @@ -882,30 +882,21 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri baseURL = publicURL } - // Use shared helper to construct client ID - cfg := oauth.ClientIDConfig{ - BaseURL: baseURL, - CallbackPath: callbackPath, - Scopes: holdScopes, - } - clientID, redirectURI := cfg.MakeClientID() - // Run interactive OAuth flow with persistent server ctx := context.Background() result, err := oauth.RunInteractiveFlow( ctx, oauth.InteractiveFlowConfig{ - ClientID: clientID, - RedirectURI: redirectURI, - Handle: handle, - Scopes: holdScopes, + BaseURL: baseURL, + Handle: handle, + Scopes: holdScopes, }, func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error { // First call (authURL empty): register callback handler if authURL == "" { // Register callback on existing server (persistent server pattern) // Note: metadata is not used here since hold service serves it separately on main server - http.HandleFunc("/oauth/callback", handler.ServeHTTP) + http.HandleFunc("/auth/oauth/callback", handler.ServeHTTP) return nil } diff --git a/cmd/registry/serve.go b/cmd/registry/serve.go index 8ba9ca9..a2f2e97 100644 --- a/cmd/registry/serve.go +++ b/cmd/registry/serve.go @@ -108,35 +108,20 @@ func serveRegistry(cmd *cobra.Command, args []string) error { fmt.Printf("DEBUG: Base URL for OAuth: %s\n", baseURL) - // 4. Get client ID from config - clientIDConfig := oauth.ClientIDConfig{ - BaseURL: baseURL, - CallbackPath: "/auth/oauth/callback", - Scopes: oauth.GetDefaultScopes(), - } - clientID, redirectURI := clientIDConfig.MakeClientID() - - fmt.Printf("DEBUG: Client ID: %s\n", clientID) - fmt.Printf("DEBUG: Redirect URI: %s\n", redirectURI) - - // 5. Create refresher - refresher := oauth.NewRefresher(refreshStorage, clientID, redirectURI) + // 4. Create refresher + refresher := oauth.NewRefresher(refreshStorage, baseURL) // Start cleanup routine (runs every hour) refresher.StartCleanupRoutine(1 * time.Hour) - // 6. Set global refresher for middleware + // 5. Set global refresher for middleware middleware.SetGlobalRefresher(refresher) - // 7. Create client metadata (only needed for production, not localhost) - // For localhost, client metadata is embedded in the client_id query string - // clientMetadata := oauth.NewClientMetadata(clientID, []string{redirectURI}) - - // 8. Create OAuth server + // 6. Create OAuth server oauthServer := oauth.NewServer(refreshStorage, sessionManager, baseURL) // Connect server to refresher for cache invalidation oauthServer.SetRefresher(refresher) - // 9. Initialize auth keys and create token issuer + // 7. Initialize auth keys and create token issuer var issuer *token.Issuer if config.Auth["token"] != nil { if err := initializeAuthKeys(config); err != nil { diff --git a/hold b/hold deleted file mode 100755 index 74eda3e..0000000 Binary files a/hold and /dev/null differ diff --git a/pkg/auth/oauth/client.go b/pkg/auth/oauth/client.go index c7b3a40..bc36390 100644 --- a/pkg/auth/oauth/client.go +++ b/pkg/auth/oauth/client.go @@ -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 diff --git a/pkg/auth/oauth/client_id.go b/pkg/auth/oauth/client_id.go deleted file mode 100644 index 3d12b5a..0000000 --- a/pkg/auth/oauth/client_id.go +++ /dev/null @@ -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 -} diff --git a/pkg/auth/oauth/flow.go b/pkg/auth/oauth/flow.go index d4ce686..785f0ff 100644 --- a/pkg/auth/oauth/flow.go +++ b/pkg/auth/oauth/flow.go @@ -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 { diff --git a/pkg/auth/oauth/refresher.go b/pkg/auth/oauth/refresher.go index deed2ef..63d8ce7 100644 --- a/pkg/auth/oauth/refresher.go +++ b/pkg/auth/oauth/refresher.go @@ -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 diff --git a/pkg/auth/oauth/scopes.go b/pkg/auth/oauth/scopes.go deleted file mode 100644 index 84633f9..0000000 --- a/pkg/auth/oauth/scopes.go +++ /dev/null @@ -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), - } -} diff --git a/pkg/auth/oauth/server.go b/pkg/auth/oauth/server.go index 41f41fd..624b584 100644 --- a/pkg/auth/oauth/server.go +++ b/pkg/auth/oauth/server.go @@ -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) } diff --git a/registry b/registry deleted file mode 100755 index 653afbc..0000000 Binary files a/registry and /dev/null differ