trying to consolidate oauth logic. trying to get credential helper working

This commit is contained in:
Evan Jarrett
2025-10-03 22:28:59 -05:00
parent 371b681f81
commit 528bac80de
9 changed files with 714 additions and 426 deletions
+235 -20
View File
@@ -1,12 +1,42 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
const (
callbackPort = "8888"
baseURL = "http://127.0.0.1:" + callbackPort
callbackPath = "/callback"
)
var (
clientID string
redirectURI string
)
func init() {
// Use shared helper to create localhost client ID
cfg := oauth.ClientIDConfig{
BaseURL: baseURL,
CallbackPath: callbackPath,
Scopes: []string{"atproto"},
}
clientID, redirectURI = cfg.MakeClientID()
}
// Docker credential helper protocol
// https://github.com/docker/docker-credential-helpers
@@ -19,7 +49,7 @@ type Credentials struct {
func main() {
if len(os.Args) < 2 {
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure>\n")
fmt.Fprintf(os.Stderr, "Usage: docker-credential-atcr <get|store|erase|configure [handle]>\n")
os.Exit(1)
}
@@ -33,7 +63,12 @@ func main() {
case "erase":
handleErase()
case "configure":
handleConfigure()
// Optional handle argument
var handle string
if len(os.Args) > 2 {
handle = os.Args[2]
}
handleConfigure(handle)
default:
fmt.Fprintf(os.Stderr, "Unknown command: %s\n", command)
os.Exit(1)
@@ -42,30 +77,69 @@ func main() {
// handleGet retrieves credentials for the given server
func handleGet() {
var request Credentials
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
// Docker sends the server URL as a plain string on stdin (not JSON)
var serverURL string
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
os.Exit(1)
}
// Load token from storage
tokenPath := getTokenPath()
token, err := loadToken(tokenPath)
token, err := oauth.LoadTokenStore(tokenPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading token: %v\n", err)
os.Exit(1)
}
// Check if token is expired and refresh if needed
if token.IsExpired && token.RefreshToken != "" {
if err := refreshToken(token); err != nil {
if token.IsExpired() && token.RefreshToken != "" {
// Create OAuth client
client, err := oauth.NewClient(clientID, redirectURI)
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating OAuth client: %v\n", err)
os.Exit(1)
}
// Load DPoP key
dpopKey, err := token.GetDPoPKey()
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading DPoP key: %v\n", err)
os.Exit(1)
}
client.SetDPoPKey(dpopKey)
// Initialize for the handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, token.Handle); err != nil {
fmt.Fprintf(os.Stderr, "Error initializing OAuth client: %v\n", err)
os.Exit(1)
}
// Refresh the token
newToken, err := client.RefreshToken(ctx, token.RefreshToken)
if err != nil {
fmt.Fprintf(os.Stderr, "Error refreshing token: %v\n", err)
os.Exit(1)
}
// Update token store
token.AccessToken = newToken.AccessToken
token.RefreshToken = newToken.RefreshToken
token.ExpiresAt = newToken.Expiry
// Save updated token
if err := token.Save(tokenPath); err != nil {
fmt.Fprintf(os.Stderr, "Error saving token: %v\n", err)
os.Exit(1)
}
}
// Exchange ATProto token for registry JWT
registryJWT, err := exchangeForRegistryToken(token.AccessToken, request.ServerURL)
fmt.Fprintf(os.Stderr, "[DEBUG] Exchanging token for %s, handle=%s, token_expired=%v\n", serverURL, token.Handle, token.IsExpired())
registryJWT, err := exchangeForRegistryToken(token.AccessToken, serverURL, token.Handle)
if err != nil {
fmt.Fprintf(os.Stderr, "Error exchanging token: %v\n", err)
os.Exit(1)
@@ -73,7 +147,7 @@ func handleGet() {
// Return credentials
creds := Credentials{
ServerURL: request.ServerURL,
ServerURL: serverURL,
Username: "oauth2",
Secret: registryJWT,
}
@@ -99,9 +173,10 @@ func handleStore() {
// handleErase removes stored credentials
func handleErase() {
var request Credentials
if err := json.NewDecoder(os.Stdin).Decode(&request); err != nil {
fmt.Fprintf(os.Stderr, "Error decoding request: %v\n", err)
// Docker sends the server URL as a plain string on stdin (not JSON)
var serverURL string
if _, err := fmt.Fscanln(os.Stdin, &serverURL); err != nil {
fmt.Fprintf(os.Stderr, "Error reading server URL: %v\n", err)
os.Exit(1)
}
@@ -114,17 +189,20 @@ func handleErase() {
}
// handleConfigure runs the OAuth flow to get initial credentials
func handleConfigure() {
func handleConfigure(handle string) {
fmt.Println("ATCR Credential Helper Configuration")
fmt.Println("=====================================")
fmt.Println()
// Ask for handle
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
var handle string
if _, err := fmt.Scanln(&handle); err != nil {
fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err)
os.Exit(1)
// Ask for handle if not provided as argument
if handle == "" {
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
if _, err := fmt.Scanln(&handle); err != nil {
fmt.Fprintf(os.Stderr, "Error reading handle: %v\n", err)
os.Exit(1)
}
} else {
fmt.Printf("Using handle: %s\n", handle)
}
// Run OAuth flow
@@ -156,3 +234,140 @@ func getTokenPath() string {
return filepath.Join(homeDir, ".atcr", "oauth-token.json")
}
// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT
func exchangeForRegistryToken(atprotoToken, registryURL, handle string, dpopKey interface{}) (string, error) {
// Call the registry's /auth/exchange endpoint
// This endpoint validates the ATProto token and returns a registry JWT
// Normalize registry URL - add scheme if missing
if !strings.HasPrefix(registryURL, "http://") && !strings.HasPrefix(registryURL, "https://") {
registryURL = "http://" + registryURL
}
exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL)
reqBody := map[string]any{
"access_token": atprotoToken,
"handle": handle, // Required for PDS resolution and token validation
"scope": []string{"repository:*:pull,push"},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
fmt.Fprintf(os.Stderr, "[DEBUG] POST %s\n", exchangeURL)
fmt.Fprintf(os.Stderr, "[DEBUG] Request: handle=%s, token_prefix=%s..., scope=%v\n",
handle,
atprotoToken[:min(20, len(atprotoToken))],
reqBody["scope"])
// Create HTTP client with DPoP transport
transport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey)
transport.SetAccessToken(atprotoToken)
client := &http.Client{Transport: transport}
req, err := http.NewRequest("POST", exchangeURL, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to call exchange endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// Read response body for debugging
bodyBytes, _ := io.ReadAll(resp.Body)
fmt.Fprintf(os.Stderr, "[DEBUG] Exchange failed with status %d\n", resp.StatusCode)
fmt.Fprintf(os.Stderr, "[DEBUG] Response body: %s\n", string(bodyBytes))
return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode)
}
var result struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.Token != "" {
return result.Token, nil
}
return result.AccessToken, nil
}
// runOAuthFlow executes the OAuth flow with browser
func runOAuthFlow(handle string) (*oauth.TokenStore, error) {
var server *http.Server
// Run interactive OAuth flow with ephemeral server
result, err := oauth.RunInteractiveFlow(
context.Background(),
oauth.InteractiveFlowConfig{
ClientID: clientID,
RedirectURI: redirectURI,
Handle: handle,
},
func(authURL string, handler *oauth.CallbackHandler, metadata *oauth.ClientMetadata) error {
// First call (authURL empty): start server
if authURL == "" {
var err error
server, err = oauth.StartCallbackServer(handler, metadata)
if err != nil {
return fmt.Errorf("failed to start callback server: %w", err)
}
return nil
}
// Second call (authURL populated): display URL and open browser
fmt.Printf("Opening browser to: %s\n", authURL)
if err := oauth.OpenBrowser(authURL); err != nil {
fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL)
}
return nil
},
)
if err != nil {
return nil, err
}
// Shutdown ephemeral server
if server != nil {
defer server.Shutdown(context.Background())
}
fmt.Println("Authorization successful!")
// Resolve handle to get DID
resolver := atproto.NewResolver()
did, _, err := resolver.ResolveIdentity(context.Background(), handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
}
// Create token store
store := &oauth.TokenStore{
AccessToken: result.Token.AccessToken,
RefreshToken: result.Token.RefreshToken,
TokenType: result.Token.TokenType,
ExpiresAt: result.Token.Expiry,
Handle: handle,
DID: did,
}
// Save DPoP key
if err := store.SetDPoPKey(result.Client.DPoPKey()); err != nil {
return nil, fmt.Errorf("failed to save DPoP key: %w", err)
}
return store, nil
}
-176
View File
@@ -1,176 +0,0 @@
package main
import (
"context"
"fmt"
"net/http"
"os/exec"
"runtime"
"time"
"atcr.io/pkg/atproto"
"atcr.io/pkg/auth/oauth"
)
const (
clientID = "http://localhost:8888/client-metadata.json"
redirectURI = "http://localhost:8888/callback"
)
// runOAuthFlow executes the OAuth flow with browser
func runOAuthFlow(handle string) (*oauth.TokenStore, error) {
// Create OAuth client
client, err := oauth.NewClient(clientID, redirectURI)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth client: %w", err)
}
// Initialize for the given handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, handle); err != nil {
return nil, fmt.Errorf("failed to initialize client: %w", err)
}
// Start local callback server
codeChan := make(chan string, 1)
errChan := make(chan error, 1)
server := startCallbackServer(codeChan, errChan)
defer server.Shutdown(context.Background())
// Also serve client metadata
http.HandleFunc("/client-metadata.json", oauth.ServeMetadata(
oauth.NewClientMetadata(clientID, []string{redirectURI}),
))
// Generate authorization URL with PKCE
state := generateState()
authURL, codeVerifier, err := client.AuthorizeURL(state)
if err != nil {
return nil, fmt.Errorf("failed to generate auth URL: %w", err)
}
// Open browser
fmt.Printf("Opening browser to: %s\n", authURL)
if err := openBrowser(authURL); err != nil {
fmt.Printf("Failed to open browser automatically. Please open this URL manually:\n%s\n", authURL)
}
// Wait for callback
var code string
select {
case code = <-codeChan:
fmt.Println("Authorization successful!")
case err := <-errChan:
return nil, fmt.Errorf("authorization failed: %w", err)
case <-time.After(5 * time.Minute):
return nil, fmt.Errorf("authorization timed out")
}
// Exchange code for token
ctx, cancel = context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
token, err := client.Exchange(ctx, code, codeVerifier)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
// Resolve handle to get DID
resolver := atproto.NewResolver()
did, _, err := resolver.ResolveIdentity(context.Background(), handle)
if err != nil {
return nil, fmt.Errorf("failed to resolve DID: %w", err)
}
// Create token store
store := &oauth.TokenStore{
AccessToken: token.AccessToken,
RefreshToken: token.RefreshToken,
TokenType: token.TokenType,
ExpiresAt: token.Expiry,
Handle: handle,
DID: did,
}
// Save DPoP key
if err := store.SetDPoPKey(client.DPoPKey()); err != nil {
return nil, fmt.Errorf("failed to save DPoP key: %w", err)
}
return store, nil
}
// startCallbackServer starts a local HTTP server to receive the OAuth callback
func startCallbackServer(codeChan chan string, errChan chan error) *http.Server {
mux := http.NewServeMux()
mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
errorParam := r.URL.Query().Get("error")
if errorParam != "" {
errChan <- fmt.Errorf("OAuth error: %s (%s)",
errorParam,
r.URL.Query().Get("error_description"))
http.Error(w, "Authorization failed", http.StatusBadRequest)
return
}
if code == "" {
errChan <- fmt.Errorf("no code in callback")
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
codeChan <- code
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `
<html>
<head><title>ATCR Authorization</title></head>
<body>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
</body>
</html>
`)
})
server := &http.Server{
Addr: ":8888",
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
errChan <- fmt.Errorf("callback server error: %w", err)
}
}()
return server
}
// openBrowser opens the default browser to the given URL
func openBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform")
}
return cmd.Start()
}
// generateState generates a random state parameter
func generateState() string {
// Use the same UUID generation as we do elsewhere
return fmt.Sprintf("state_%d", time.Now().UnixNano())
}
-119
View File
@@ -1,119 +0,0 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"atcr.io/pkg/auth/oauth"
)
// tokenData holds the token information
type tokenData struct {
*oauth.TokenStore
IsExpired bool
}
// loadToken loads the token from disk
func loadToken(path string) (*tokenData, error) {
store, err := oauth.LoadTokenStore(path)
if err != nil {
return nil, err
}
return &tokenData{
TokenStore: store,
IsExpired: store.IsExpired(),
}, nil
}
// refreshToken refreshes an expired token
func refreshToken(token *tokenData) error {
// Create OAuth client
client, err := oauth.NewClient("http://localhost:8888/client-metadata.json", "http://localhost:8888/callback")
if err != nil {
return fmt.Errorf("failed to create OAuth client: %w", err)
}
// Load DPoP key
dpopKey, err := token.GetDPoPKey()
if err != nil {
return fmt.Errorf("failed to load DPoP key: %w", err)
}
client.SetDPoPKey(dpopKey)
// Initialize for the handle
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(ctx, token.Handle); err != nil {
return fmt.Errorf("failed to initialize client: %w", err)
}
// Refresh the token
newToken, err := client.RefreshToken(ctx, token.RefreshToken)
if err != nil {
return fmt.Errorf("failed to refresh token: %w", err)
}
// Update token store
token.AccessToken = newToken.AccessToken
token.RefreshToken = newToken.RefreshToken
token.ExpiresAt = newToken.Expiry
token.IsExpired = false
// Save updated token
return token.Save(getTokenPath())
}
// exchangeForRegistryToken exchanges the ATProto OAuth token for a registry JWT
func exchangeForRegistryToken(atprotoToken, registryURL string) (string, error) {
// Call the registry's /auth/exchange endpoint
// This endpoint validates the ATProto token and returns a registry JWT
exchangeURL := fmt.Sprintf("%s/auth/exchange", registryURL)
// Load token store to get DID/handle
store, err := loadToken(getTokenPath())
if err != nil {
return "", fmt.Errorf("failed to load token store: %w", err)
}
reqBody := map[string]any{
"access_token": atprotoToken,
"handle": store.Handle, // Required for PDS resolution and token validation
"scope": []string{"repository:*:pull,push"},
}
body, err := json.Marshal(reqBody)
if err != nil {
return "", fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := http.Post(exchangeURL, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("failed to call exchange endpoint: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("exchange failed with status %d", resp.StatusCode)
}
var result struct {
Token string `json:"token"`
AccessToken string `json:"access_token"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return "", fmt.Errorf("failed to decode response: %w", err)
}
if result.Token != "" {
return result.Token, nil
}
return result.AccessToken, nil
}
+58 -108
View File
@@ -67,12 +67,8 @@ type ServerConfig struct {
// HoldService provides presigned URLs for blob storage in a hold
type HoldService struct {
driver storagedriver.StorageDriver
config *Config
oauthCodeCh chan string
oauthErrCh chan error
oauthState string
codeVerifier string
driver storagedriver.StorageDriver
config *Config
}
// NewHoldService creates a new hold service
@@ -85,10 +81,8 @@ func NewHoldService(cfg *Config) (*HoldService, error) {
}
return &HoldService{
driver: driver,
config: cfg,
oauthCodeCh: make(chan string, 1),
oauthErrCh: make(chan error, 1),
driver: driver,
config: cfg,
}, nil
}
@@ -550,34 +544,6 @@ func (s *HoldService) HealthHandler(w http.ResponseWriter, r *http.Request) {
})
}
// HandleOAuthCallback handles OAuth callback from authorization server
func (s *HoldService) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
receivedState := r.URL.Query().Get("state")
if receivedState != s.oauthState {
s.oauthErrCh <- fmt.Errorf("invalid state parameter")
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
if code == "" {
s.oauthErrCh <- fmt.Errorf("no authorization code received")
http.Error(w, "No code", http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "text/html")
fmt.Fprintf(w, `<html><body><h1>Authorization Successful!</h1><p>You can close this window and return to the terminal.</p></body></html>`)
// Send code to registration flow
select {
case s.oauthCodeCh <- code:
default:
// Channel already has a value or nobody is listening
}
}
func main() {
// Load configuration from environment variables
cfg, err := loadConfigFromEnv()
@@ -597,7 +563,6 @@ func main() {
mux.HandleFunc("/register", service.HandleRegister)
mux.HandleFunc("/get-presigned-url", service.HandleGetPresignedURL)
mux.HandleFunc("/put-presigned-url", service.HandlePutPresignedURL)
mux.HandleFunc("/oauth/callback", service.HandleOAuthCallback) // OAuth callback on same port
// OAuth client metadata endpoint for ATProto OAuth
clientID := cfg.Server.PublicURL + "/client-metadata.json"
@@ -606,11 +571,12 @@ func main() {
clientMetadata.ApplicationType = "web" // Changed from "native" since this is a web service
mux.HandleFunc("/client-metadata.json", oauth.ServeMetadata(clientMetadata))
mux.HandleFunc("/blobs/", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead {
switch r.Method {
case http.MethodGet, http.MethodHead:
service.HandleProxyGet(w, r)
} else if r.Method == http.MethodPut {
case http.MethodPut:
service.HandleProxyPut(w, r)
} else {
default:
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
}
})
@@ -837,15 +803,18 @@ func (s *HoldService) AutoRegister() error {
// registerWithOAuth performs OAuth flow and registers the hold
func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint string) error {
// Extract port from publicURL for test mode
var redirectURI string
var clientID string
// Define the scopes we need for hold registration
// Need create and update permissions for hold and crew collections
scopes := fmt.Sprintf("atproto repo:%s?action=create repo:%s?action=update repo:%s?action=create repo:%s?action=update",
atproto.HoldCollection, atproto.HoldCollection,
atproto.HoldCrewCollection, atproto.HoldCrewCollection)
holdScopes := []string{
"atproto",
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
}
// Determine base URL and client ID based on mode
var baseURL, callbackPath string
callbackPath = "/oauth/callback"
if s.config.Server.TestMode {
// Test mode: Use localhost for OAuth (browser accessible) but store real URL in hold record
@@ -858,76 +827,57 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri
if port == "" {
port = "8080" // default
}
redirectURI = fmt.Sprintf("http://127.0.0.1:%s/oauth/callback", port)
clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
url.QueryEscape(redirectURI), url.QueryEscape(scopes))
} else if strings.Contains(publicURL, "127.0.0.1") || strings.Contains(publicURL, "localhost") {
// Localhost development mode per ATProto OAuth spec
redirectURI = publicURL + "/oauth/callback"
clientID = fmt.Sprintf("http://localhost?redirect_uri=%s&scope=%s",
url.QueryEscape(redirectURI), url.QueryEscape(scopes))
baseURL = fmt.Sprintf("http://127.0.0.1:%s", port)
} else {
// Production mode - use client metadata URL
redirectURI = publicURL + "/oauth/callback"
clientID = publicURL + "/client-metadata.json"
baseURL = publicURL
}
// Create OAuth client
oauthClient, err := oauth.NewClient(clientID, redirectURI)
if err != nil {
return fmt.Errorf("failed to create OAuth client: %w", err)
// Use shared helper to construct client ID
cfg := oauth.ClientIDConfig{
BaseURL: baseURL,
CallbackPath: callbackPath,
Scopes: holdScopes,
}
clientID, redirectURI := cfg.MakeClientID()
// Initialize for the user's handle
// Run interactive OAuth flow with persistent server
ctx := context.Background()
if err := oauthClient.InitializeForHandle(ctx, handle); err != nil {
return fmt.Errorf("failed to initialize OAuth: %w", err)
}
result, err := oauth.RunInteractiveFlow(
ctx,
oauth.InteractiveFlowConfig{
ClientID: clientID,
RedirectURI: redirectURI,
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)
return nil
}
// Set the scopes we need for hold registration (create and update)
oauthClient.SetScopes([]string{
"atproto",
fmt.Sprintf("repo:%s?action=create", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCollection),
fmt.Sprintf("repo:%s?action=create", atproto.HoldCrewCollection),
fmt.Sprintf("repo:%s?action=update", atproto.HoldCrewCollection),
})
// Second call (authURL populated): display URL
// Print the OAuth URL for user to visit (hold-specific formatting)
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH AUTHORIZATION REQUIRED")
log.Print(strings.Repeat("=", 80))
log.Printf("\nPlease visit this URL to authorize the hold service:\n")
log.Printf(" %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
// Generate authorization URL
s.oauthState = "hold-registration"
authURL, codeVerifier, err := oauthClient.AuthorizeURL(s.oauthState)
return nil
},
)
if err != nil {
return fmt.Errorf("failed to generate auth URL: %w", err)
}
s.codeVerifier = codeVerifier
// Print the OAuth URL for user to visit
log.Print("\n" + strings.Repeat("=", 80))
log.Printf("OAUTH AUTHORIZATION REQUIRED")
log.Print(strings.Repeat("=", 80))
log.Printf("\nPlease visit this URL to authorize the hold service:\n")
log.Printf(" %s\n", authURL)
log.Printf("Waiting for authorization...")
log.Print(strings.Repeat("=", 80) + "\n")
// Wait for callback or error (callback happens on main server)
var code string
select {
case code = <-s.oauthCodeCh:
// Got the code from callback
case err := <-s.oauthErrCh:
return err
case <-time.After(5 * time.Minute):
return fmt.Errorf("OAuth timeout - no response after 5 minutes")
}
log.Printf("Authorization received, exchanging code for token...")
// Exchange code for token
token, err := oauthClient.Exchange(ctx, code, s.codeVerifier)
if err != nil {
return fmt.Errorf("failed to exchange code: %w", err)
}
token := result.Token
log.Printf("OAuth token obtained successfully")
log.Printf("DID: %s", did)
@@ -935,7 +885,7 @@ func (s *HoldService) registerWithOAuth(publicURL, handle, did, pdsEndpoint stri
// Now register with the token using DPoP
// Create ATProto client with DPoP transport from OAuth client
dpopKey := oauthClient.DPoPKey()
dpopKey := result.Client.DPoPKey()
dpopTransport := oauth.NewDPoPTransport(http.DefaultTransport, dpopKey)
// Set the access token in the transport for "ath" claim computation
dpopTransport.SetAccessToken(token.AccessToken)
+1 -1
View File
@@ -11,8 +11,8 @@ import (
"path/filepath"
"strings"
atprotoAuth "atcr.io/pkg/auth/atproto"
"atcr.io/pkg/atproto"
atprotoAuth "atcr.io/pkg/auth/atproto"
)
// DockerConfig represents ~/.docker/config.json
+269
View File
@@ -0,0 +1,269 @@
package oauth
import (
"crypto/rand"
"encoding/base64"
"fmt"
"net"
"net/http"
"net/url"
"os/exec"
"runtime"
"strings"
"time"
)
// CallbackHandler manages OAuth callback handling
type CallbackHandler struct {
state string
codeChan chan string
errChan chan error
}
// NewCallbackHandler creates a new callback handler
func NewCallbackHandler(state string) *CallbackHandler {
return &CallbackHandler{
state: state,
codeChan: make(chan string, 1),
errChan: make(chan error, 1),
}
}
// ServeHTTP handles the OAuth callback request
func (h *CallbackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
receivedState := r.URL.Query().Get("state")
errorParam := r.URL.Query().Get("error")
// Validate state parameter
if receivedState != h.state {
h.errChan <- fmt.Errorf("invalid state parameter")
http.Error(w, "Invalid state", http.StatusBadRequest)
return
}
// Check for OAuth error
if errorParam != "" {
h.errChan <- fmt.Errorf("OAuth error: %s (%s)",
errorParam,
r.URL.Query().Get("error_description"))
http.Error(w, "Authorization failed", http.StatusBadRequest)
return
}
// Validate code is present
if code == "" {
h.errChan <- fmt.Errorf("no authorization code received")
http.Error(w, "No code provided", http.StatusBadRequest)
return
}
// Send success response to browser
RenderSuccessHTML(w)
// Send code to waiting goroutine
select {
case h.codeChan <- code:
default:
// Channel already has a value or nobody is listening
}
}
// WaitForCode waits for the OAuth callback to complete
func (h *CallbackHandler) WaitForCode(timeout time.Duration) (string, error) {
select {
case code := <-h.codeChan:
return code, nil
case err := <-h.errChan:
return "", err
case <-time.After(timeout):
return "", fmt.Errorf("OAuth timeout after %v", timeout)
}
}
// GenerateState generates a random state parameter for OAuth
func GenerateState() (string, error) {
// Generate 32 random bytes
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("failed to generate random state: %w", err)
}
return base64.RawURLEncoding.EncodeToString(bytes), nil
}
// OpenBrowser opens the default browser to the given URL
func OpenBrowser(url string) error {
var cmd *exec.Cmd
switch runtime.GOOS {
case "darwin":
cmd = exec.Command("open", url)
case "linux":
cmd = exec.Command("xdg-open", url)
case "windows":
cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
default:
return fmt.Errorf("unsupported platform: %s", runtime.GOOS)
}
return cmd.Start()
}
// RenderSuccessHTML renders the OAuth success page
func RenderSuccessHTML(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>ATCR Authorization</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%%, #764ba2 100%%);
}
.container {
background: white;
padding: 3rem;
border-radius: 1rem;
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
text-align: center;
max-width: 400px;
}
h1 {
color: #2d3748;
margin: 0 0 1rem 0;
font-size: 2rem;
}
p {
color: #718096;
margin: 0;
font-size: 1.1rem;
}
.checkmark {
font-size: 4rem;
color: #48bb78;
margin-bottom: 1rem;
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✓</div>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to the terminal.</p>
</div>
</body>
</html>`)
}
// StartCallbackServer creates an ephemeral HTTP server for OAuth callbacks
// This is useful for CLI tools that need a temporary OAuth endpoint
// Derives the listen address and paths from the metadata's ClientID and RedirectURIs
func StartCallbackServer(handler *CallbackHandler, metadata *ClientMetadata) (*http.Server, error) {
if len(metadata.RedirectURIs) == 0 {
return nil, fmt.Errorf("no redirect URIs in metadata")
}
// Parse redirect URI to extract listen address and callback path
redirectURI := metadata.RedirectURIs[0]
u, err := url.Parse(redirectURI)
if err != nil {
return nil, fmt.Errorf("failed to parse redirect URI: %w", err)
}
// Extract listen address (host:port)
addr := u.Host
callbackPath := u.Path
mux := http.NewServeMux()
// Check if this is a query-based client ID (localhost OAuth)
isQueryBased := strings.HasPrefix(metadata.ClientID, "http://localhost?")
var metadataPath string
if !isQueryBased {
// Metadata URL client ID - parse and serve metadata
clientIDURL := metadata.ClientID
if idx := strings.Index(clientIDURL, "?"); idx != -1 {
clientIDURL = clientIDURL[:idx]
}
clientURL, err := url.Parse(clientIDURL)
if err != nil {
return nil, fmt.Errorf("failed to parse client ID: %w", err)
}
metadataPath = clientURL.Path
// Serve client metadata at the path from ClientID
mux.Handle(metadataPath, ServeMetadata(metadata))
}
// Register OAuth callback handler at the path from RedirectURI
mux.Handle(callbackPath, handler)
server := &http.Server{
Addr: addr,
Handler: mux,
}
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
// Server error will be caught by WaitForCode timeout
}
}()
// Wait for server to be ready
if isQueryBased {
// For localhost/query-based, just check if port is listening
if !waitForPort(addr, 5*time.Second) {
return nil, fmt.Errorf("server failed to start within 5 seconds")
}
} else {
// For metadata URLs, check the metadata endpoint
checkURL := "http://" + addr + metadataPath
if !waitForServer(checkURL, 5*time.Second) {
return nil, fmt.Errorf("server failed to start within 5 seconds")
}
}
return server, nil
}
// waitForPort checks if a TCP port is listening
func waitForPort(addr string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
conn, err := net.DialTimeout("tcp", addr, 100*time.Millisecond)
if err == nil {
conn.Close()
return true
}
time.Sleep(10 * time.Millisecond)
}
return false
}
// waitForServer checks if the server is responding at the given URL
func waitForServer(url string, timeout time.Duration) bool {
deadline := time.Now().Add(timeout)
client := &http.Client{Timeout: 100 * time.Millisecond}
for time.Now().Before(deadline) {
resp, err := client.Get(url)
if err == nil {
resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return true
}
}
time.Sleep(10 * time.Millisecond)
}
return false
}
+52
View File
@@ -0,0 +1,52 @@
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
}
+2 -2
View File
@@ -10,8 +10,8 @@ import (
// ProtectedResourceMetadata represents the OAuth protected resource metadata
// as defined in ATProto OAuth spec
type ProtectedResourceMetadata struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
}
// AuthServerMetadata represents the OAuth authorization server metadata
+97
View File
@@ -0,0 +1,97 @@
package oauth
import (
"context"
"fmt"
"time"
"authelia.com/client/oauth2"
)
// InteractiveFlowConfig configures an interactive OAuth flow
type InteractiveFlowConfig struct {
ClientID string
RedirectURI string
Handle string
Scopes []string // optional, defaults to ["atproto"]
}
// FlowResult contains the result of a successful OAuth flow
type FlowResult struct {
Token *oauth2.Token
Client *Client // OAuth client with DPoP key set
}
// RunInteractiveFlow executes an interactive OAuth authorization code flow
// The setupCallback function is called TWICE:
// 1. First with authURL="" to start the server (before PAR)
// 2. Then with the actual authURL to display it to the user (after PAR)
// This two-phase approach ensures the server is running before PAR tries to fetch client metadata
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)
if err != nil {
return nil, fmt.Errorf("failed to create OAuth client: %w", err)
}
// Initialize for the given handle
initCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
if err := client.InitializeForHandle(initCtx, cfg.Handle); err != nil {
return nil, fmt.Errorf("failed to initialize client: %w", err)
}
// Set scopes if provided
if len(cfg.Scopes) > 0 {
client.SetScopes(cfg.Scopes)
}
// Generate state for OAuth flow
state, err := GenerateState()
if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err)
}
// Create callback handler and client metadata FIRST
callbackHandler := NewCallbackHandler(state)
metadata := NewClientMetadata(cfg.ClientID, []string{cfg.RedirectURI})
// Start server BEFORE generating auth URL (so PAR can fetch metadata)
if err := setupCallback("", callbackHandler, metadata); err != nil {
return nil, fmt.Errorf("callback setup failed: %w", err)
}
// NOW generate authorization URL with PKCE (PAR can succeed)
authURL, codeVerifier, err := client.AuthorizeURL(state)
if err != nil {
return nil, fmt.Errorf("failed to generate auth URL: %w", err)
}
// Display the auth URL (callback gets called again with URL)
if err := setupCallback(authURL, callbackHandler, metadata); err != nil {
return nil, fmt.Errorf("failed to display auth URL: %w", err)
}
// Wait for callback (5 minute timeout)
code, err := callbackHandler.WaitForCode(5 * time.Minute)
if err != nil {
return nil, err
}
// Exchange code for token
exchangeCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
token, err := client.Exchange(exchangeCtx, code, codeVerifier)
if err != nil {
return nil, fmt.Errorf("failed to exchange code: %w", err)
}
return &FlowResult{
Token: token,
Client: client,
}, nil
}