175 lines
4.8 KiB
Go
175 lines
4.8 KiB
Go
|
|
package cmd
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"encoding/hex"
|
||
|
|
"fmt"
|
||
|
|
"net"
|
||
|
|
"net/http"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||
|
|
"github.com/spf13/cobra"
|
||
|
|
"golang.org/x/oauth2"
|
||
|
|
|
||
|
|
"guardianproject.dev/ops/nix-cache-login/internal/browser"
|
||
|
|
"guardianproject.dev/ops/nix-cache-login/internal/config"
|
||
|
|
"guardianproject.dev/ops/nix-cache-login/internal/netrc"
|
||
|
|
"guardianproject.dev/ops/nix-cache-login/internal/token"
|
||
|
|
)
|
||
|
|
|
||
|
|
var loginCmd = &cobra.Command{
|
||
|
|
Use: "login",
|
||
|
|
Short: "Authenticate via browser (Authorization Code + PKCE)",
|
||
|
|
Long: `Opens a browser for OIDC authentication with Keycloak.
|
||
|
|
Uses Authorization Code flow with PKCE (S256). The access token
|
||
|
|
is written to the netrc file for Nix to use.`,
|
||
|
|
RunE: runLogin,
|
||
|
|
}
|
||
|
|
|
||
|
|
func init() {
|
||
|
|
rootCmd.AddCommand(loginCmd)
|
||
|
|
}
|
||
|
|
|
||
|
|
func runLogin(cmd *cobra.Command, args []string) error {
|
||
|
|
ctx := context.Background()
|
||
|
|
|
||
|
|
// OIDC discovery
|
||
|
|
provider, err := gooidc.NewProvider(ctx, cfg.Issuer)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("OIDC discovery failed: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Start listener on random port
|
||
|
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("starting callback server: %w", err)
|
||
|
|
}
|
||
|
|
port := listener.Addr().(*net.TCPAddr).Port
|
||
|
|
redirectURL := fmt.Sprintf("http://127.0.0.1:%d", port)
|
||
|
|
|
||
|
|
oauthCfg := oauth2.Config{
|
||
|
|
ClientID: cfg.ClientID,
|
||
|
|
Endpoint: provider.Endpoint(),
|
||
|
|
RedirectURL: redirectURL,
|
||
|
|
Scopes: []string{gooidc.ScopeOpenID},
|
||
|
|
}
|
||
|
|
|
||
|
|
// Generate PKCE verifier
|
||
|
|
verifier := oauth2.GenerateVerifier()
|
||
|
|
|
||
|
|
// Generate random state
|
||
|
|
stateBytes := make([]byte, 16)
|
||
|
|
if _, err := rand.Read(stateBytes); err != nil {
|
||
|
|
return fmt.Errorf("generating state: %w", err)
|
||
|
|
}
|
||
|
|
state := hex.EncodeToString(stateBytes)
|
||
|
|
|
||
|
|
// Build auth URL
|
||
|
|
authURL := oauthCfg.AuthCodeURL(state, oauth2.S256ChallengeOption(verifier))
|
||
|
|
|
||
|
|
// Channel to receive the token
|
||
|
|
type result struct {
|
||
|
|
token *oauth2.Token
|
||
|
|
err error
|
||
|
|
}
|
||
|
|
resultCh := make(chan result, 1)
|
||
|
|
|
||
|
|
// HTTP handler for callback
|
||
|
|
mux := http.NewServeMux()
|
||
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||
|
|
if r.URL.Query().Get("state") != state {
|
||
|
|
http.Error(w, "Invalid state parameter", http.StatusBadRequest)
|
||
|
|
resultCh <- result{err: fmt.Errorf("state mismatch")}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
if errParam := r.URL.Query().Get("error"); errParam != "" {
|
||
|
|
desc := r.URL.Query().Get("error_description")
|
||
|
|
http.Error(w, fmt.Sprintf("Authentication failed: %s", desc), http.StatusBadRequest)
|
||
|
|
resultCh <- result{err: fmt.Errorf("auth error: %s (%s)", errParam, desc)}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
code := r.URL.Query().Get("code")
|
||
|
|
if code == "" {
|
||
|
|
http.Error(w, "Missing code parameter", http.StatusBadRequest)
|
||
|
|
resultCh <- result{err: fmt.Errorf("missing code parameter")}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
tok, err := oauthCfg.Exchange(ctx, code, oauth2.VerifierOption(verifier))
|
||
|
|
if err != nil {
|
||
|
|
http.Error(w, "Token exchange failed", http.StatusInternalServerError)
|
||
|
|
resultCh <- result{err: fmt.Errorf("token exchange: %w", err)}
|
||
|
|
return
|
||
|
|
}
|
||
|
|
|
||
|
|
fmt.Fprintf(w, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
|
||
|
|
resultCh <- result{token: tok}
|
||
|
|
})
|
||
|
|
|
||
|
|
server := &http.Server{Handler: mux}
|
||
|
|
|
||
|
|
// Start server in background
|
||
|
|
go func() {
|
||
|
|
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
|
||
|
|
resultCh <- result{err: fmt.Errorf("callback server: %w", err)}
|
||
|
|
}
|
||
|
|
}()
|
||
|
|
|
||
|
|
// Open browser
|
||
|
|
fmt.Fprintln(os.Stderr, "Opening browser for authentication...")
|
||
|
|
if err := browser.Open(authURL); err != nil {
|
||
|
|
fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
|
||
|
|
}
|
||
|
|
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", authURL)
|
||
|
|
|
||
|
|
// Wait for callback
|
||
|
|
res := <-resultCh
|
||
|
|
|
||
|
|
// Shut down server
|
||
|
|
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
|
|
defer cancel()
|
||
|
|
server.Shutdown(shutdownCtx)
|
||
|
|
|
||
|
|
if res.err != nil {
|
||
|
|
return res.err
|
||
|
|
}
|
||
|
|
|
||
|
|
// Write access token to netrc
|
||
|
|
if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, res.token.AccessToken); err != nil {
|
||
|
|
return fmt.Errorf("writing netrc: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Store refresh token
|
||
|
|
if res.token.RefreshToken != "" {
|
||
|
|
rtPath := config.RefreshTokenPath()
|
||
|
|
if err := os.MkdirAll(filepath.Dir(rtPath), 0700); err != nil {
|
||
|
|
return fmt.Errorf("creating config directory: %w", err)
|
||
|
|
}
|
||
|
|
if err := os.WriteFile(rtPath, []byte(res.token.RefreshToken), 0600); err != nil {
|
||
|
|
return fmt.Errorf("writing refresh token: %w", err)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode JWT to show expiry
|
||
|
|
claims, err := token.DecodePayload(res.token.AccessToken)
|
||
|
|
if err != nil {
|
||
|
|
fmt.Fprintln(os.Stderr, "Authenticated successfully (could not decode token expiry).")
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
|
||
|
|
exp, _ := token.ExpiryInfo(claims)
|
||
|
|
if !exp.IsZero() {
|
||
|
|
fmt.Fprintf(os.Stderr, "Authenticated. Token valid until %s.\n", exp.Local().Format(time.RFC1123))
|
||
|
|
} else {
|
||
|
|
fmt.Fprintln(os.Stderr, "Authenticated successfully.")
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|