mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-25 03:34:14 +00:00
trying to consolidate oauth logic. trying to get credential helper working
This commit is contained in:
+235
-20
@@ -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
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user