mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-29 20:27:16 +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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user