mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-08-30 20:57:01 +00:00
270 lines
6.7 KiB
Go
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
|
|
}
|