trying to consolidate oauth logic. trying to get credential helper working

This commit is contained in:
Evan Jarrett
2025-10-03 22:28:59 -05:00
parent 371b681f81
commit 528bac80de
9 changed files with 714 additions and 426 deletions
+269
View File
@@ -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
}
+52
View File
@@ -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
}
+2 -2
View File
@@ -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
+97
View File
@@ -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
}