Files
at-container-registry/pkg/auth/oauth/callback.go
T

270 lines
6.7 KiB
Go

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
}