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, "

Authentication successful!

You can close this window.

") 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 }