mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 17:24:16 +00:00
trying to consolidate oauth logic. trying to get credential helper working
This commit is contained in:
@@ -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