From d986a0b31a1e2a0f5034c54c5bf4d86926e6c07d Mon Sep 17 00:00:00 2001 From: Abel Luck Date: Thu, 26 Feb 2026 11:05:16 +0100 Subject: [PATCH] initial working version --- cmd/login.go | 174 ++++++++++++++++++++++++++++++++ cmd/logout.go | 63 ++++++++++++ cmd/refresh.go | 107 ++++++++++++++++++++ cmd/root.go | 46 +++++++++ cmd/serviceAccount.go | 78 +++++++++++++++ cmd/status.go | 74 ++++++++++++++ flake.nix | 28 ++++++ go.mod | 18 ++++ go.sum | 30 ++++++ internal/browser/browser.go | 27 +++++ internal/config/config.go | 64 ++++++++++++ internal/config/config_test.go | 177 +++++++++++++++++++++++++++++++++ internal/netrc/netrc.go | 136 +++++++++++++++++++++++++ internal/netrc/netrc_test.go | 175 ++++++++++++++++++++++++++++++++ internal/pkce/pkce.go | 25 +++++ internal/pkce/pkce_test.go | 63 ++++++++++++ internal/token/jwt.go | 46 +++++++++ internal/token/jwt_test.go | 92 +++++++++++++++++ main.go | 7 ++ 19 files changed, 1430 insertions(+) create mode 100644 cmd/login.go create mode 100644 cmd/logout.go create mode 100644 cmd/refresh.go create mode 100644 cmd/root.go create mode 100644 cmd/serviceAccount.go create mode 100644 cmd/status.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/browser/browser.go create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go create mode 100644 internal/netrc/netrc.go create mode 100644 internal/netrc/netrc_test.go create mode 100644 internal/pkce/pkce.go create mode 100644 internal/pkce/pkce_test.go create mode 100644 internal/token/jwt.go create mode 100644 internal/token/jwt_test.go create mode 100644 main.go diff --git a/cmd/login.go b/cmd/login.go new file mode 100644 index 0000000..c239f75 --- /dev/null +++ b/cmd/login.go @@ -0,0 +1,174 @@ +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 +} diff --git a/cmd/logout.go b/cmd/logout.go new file mode 100644 index 0000000..969150f --- /dev/null +++ b/cmd/logout.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "fmt" + "net/http" + "net/url" + "os" + "strings" + + "github.com/spf13/cobra" + + "guardianproject.dev/ops/nix-cache-login/internal/config" + "guardianproject.dev/ops/nix-cache-login/internal/netrc" +) + +var logoutCmd = &cobra.Command{ + Use: "logout", + Short: "Remove tokens and revoke session", + Long: `Removes the access token from the netrc file, deletes the stored +refresh token, and revokes the refresh token at Keycloak.`, + RunE: runLogout, +} + +func init() { + rootCmd.AddCommand(logoutCmd) +} + +func runLogout(cmd *cobra.Command, args []string) error { + // Read and revoke refresh token if it exists + rtPath := config.RefreshTokenPath() + if rtData, err := os.ReadFile(rtPath); err == nil && len(rtData) > 0 { + refreshToken := string(rtData) + + // Revoke at Keycloak + revokeURL := strings.TrimSuffix(cfg.Issuer, "/") + "/protocol/openid-connect/revoke" + data := url.Values{ + "token": {refreshToken}, + "token_type_hint": {"refresh_token"}, + "client_id": {cfg.ClientID}, + } + + resp, err := http.PostForm(revokeURL, data) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not revoke token at Keycloak: %v\n", err) + } else { + resp.Body.Close() + if resp.StatusCode >= 400 { + fmt.Fprintf(os.Stderr, "Warning: token revocation returned status %d\n", resp.StatusCode) + } + } + + // Delete refresh token file + os.Remove(rtPath) + } + + // Remove token from netrc + if err := netrc.Remove(cfg.NetrcPath, cfg.CacheHost); err != nil { + return fmt.Errorf("removing netrc entry: %w", err) + } + + fmt.Fprintln(os.Stderr, "Logged out.") + return nil +} diff --git a/cmd/refresh.go b/cmd/refresh.go new file mode 100644 index 0000000..4a40243 --- /dev/null +++ b/cmd/refresh.go @@ -0,0 +1,107 @@ +package cmd + +import ( + "context" + "fmt" + "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/config" + "guardianproject.dev/ops/nix-cache-login/internal/netrc" + "guardianproject.dev/ops/nix-cache-login/internal/token" +) + +var refreshCmd = &cobra.Command{ + Use: "refresh", + Short: "Refresh the access token using stored refresh token", + Long: `Reads the stored refresh token, exchanges it for a new access token, +and updates the netrc file. Exits 0 on success, 1 on failure. +Designed to be called from a systemd timer.`, + RunE: runRefresh, +} + +func init() { + rootCmd.AddCommand(refreshCmd) +} + +func runRefresh(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + // Read stored refresh token + rtPath := config.RefreshTokenPath() + rtData, err := os.ReadFile(rtPath) + if err != nil { + fmt.Fprintln(os.Stderr, "No refresh token found. Run `nix-cache-login login` first.") + os.Exit(1) + } + refreshToken := string(rtData) + + if refreshToken == "" { + fmt.Fprintln(os.Stderr, "Refresh token is empty. Run `nix-cache-login login` first.") + os.Exit(1) + } + + // OIDC discovery + provider, err := gooidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + fmt.Fprintf(os.Stderr, "OIDC discovery failed: %v\n", err) + os.Exit(1) + } + + oauthCfg := oauth2.Config{ + ClientID: cfg.ClientID, + Endpoint: provider.Endpoint(), + Scopes: []string{gooidc.ScopeOpenID}, + } + + // Create a token source with the refresh token + oldToken := &oauth2.Token{ + RefreshToken: refreshToken, + Expiry: time.Now().Add(-1 * time.Hour), // force refresh + } + + ts := oauthCfg.TokenSource(ctx, oldToken) + newToken, err := ts.Token() + if err != nil { + fmt.Fprintf(os.Stderr, "Session expired. Run `nix-cache-login login`.\n") + os.Exit(1) + } + + // Update netrc with new access token + if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, newToken.AccessToken); err != nil { + fmt.Fprintf(os.Stderr, "Failed to update netrc: %v\n", err) + os.Exit(1) + } + + // Update refresh token if rotated + if newToken.RefreshToken != "" && newToken.RefreshToken != refreshToken { + if err := os.MkdirAll(filepath.Dir(rtPath), 0700); err != nil { + fmt.Fprintf(os.Stderr, "Failed to create config directory: %v\n", err) + os.Exit(1) + } + if err := os.WriteFile(rtPath, []byte(newToken.RefreshToken), 0600); err != nil { + fmt.Fprintf(os.Stderr, "Failed to update refresh token: %v\n", err) + os.Exit(1) + } + } + + // Show expiry info + claims, err := token.DecodePayload(newToken.AccessToken) + if err == nil { + exp, remaining := token.ExpiryInfo(claims) + if !exp.IsZero() { + fmt.Fprintf(os.Stderr, "Token refreshed. Valid until %s (%s remaining).\n", + exp.Local().Format(time.RFC1123), + remaining.Round(time.Minute)) + } + } else { + fmt.Fprintln(os.Stderr, "Token refreshed successfully.") + } + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..6ca3e48 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + + "guardianproject.dev/ops/nix-cache-login/internal/config" +) + +var ( + cfgPath string + cfg *config.Config +) + +var rootCmd = &cobra.Command{ + Use: "nix-cache-login", + Short: "Authenticate with a Nix binary cache via OIDC", + Long: `nix-cache-login authenticates users and servers against a Keycloak OIDC +provider and writes access tokens to a netrc file so Nix can use them +when accessing an authenticated binary cache.`, + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + var err error + cfg, err = config.Load(cfgPath) + if err != nil { + return err + } + return nil + }, + // Default to login when no subcommand is given + RunE: func(cmd *cobra.Command, args []string) error { + return loginCmd.RunE(cmd, args) + }, +} + +func Execute() { + if err := rootCmd.Execute(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func init() { + rootCmd.PersistentFlags().StringVar(&cfgPath, "config", "", "path to config file (default: $XDG_CONFIG_HOME/nix-cache-login/config.toml)") +} diff --git a/cmd/serviceAccount.go b/cmd/serviceAccount.go new file mode 100644 index 0000000..87542cb --- /dev/null +++ b/cmd/serviceAccount.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "context" + "fmt" + "os" + "time" + + gooidc "github.com/coreos/go-oidc/v3/oidc" + "github.com/spf13/cobra" + "golang.org/x/oauth2/clientcredentials" + + "guardianproject.dev/ops/nix-cache-login/internal/netrc" + "guardianproject.dev/ops/nix-cache-login/internal/token" +) + +var serviceAccountCmd = &cobra.Command{ + Use: "service-account", + Short: "Authenticate using client credentials (for servers)", + Long: `Authenticates using the OAuth2 client credentials flow for headless +server environments. Requires client_secret in the config file. +Exits 0 on success, 1 on failure. Designed to be called from a systemd timer.`, + RunE: runServiceAccount, +} + +func init() { + rootCmd.AddCommand(serviceAccountCmd) +} + +func runServiceAccount(cmd *cobra.Command, args []string) error { + ctx := context.Background() + + if cfg.ClientSecret == "" { + fmt.Fprintln(os.Stderr, "Error: client_secret is required in config for service-account mode.") + os.Exit(1) + } + + // OIDC discovery + provider, err := gooidc.NewProvider(ctx, cfg.Issuer) + if err != nil { + fmt.Fprintf(os.Stderr, "OIDC discovery failed: %v\n", err) + os.Exit(1) + } + + ccCfg := clientcredentials.Config{ + ClientID: cfg.ClientID, + ClientSecret: cfg.ClientSecret, + TokenURL: provider.Endpoint().TokenURL, + Scopes: []string{gooidc.ScopeOpenID}, + } + + tok, err := ccCfg.Token(ctx) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to obtain token: %v\n", err) + os.Exit(1) + } + + // Write access token to netrc + if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, tok.AccessToken); err != nil { + fmt.Fprintf(os.Stderr, "Failed to update netrc: %v\n", err) + os.Exit(1) + } + + // Show expiry info + claims, err := token.DecodePayload(tok.AccessToken) + if err == nil { + exp, remaining := token.ExpiryInfo(claims) + if !exp.IsZero() { + fmt.Fprintf(os.Stderr, "Token obtained. Valid until %s (%s remaining).\n", + exp.Local().Format(time.RFC1123), + remaining.Round(time.Minute)) + } + } else { + fmt.Fprintln(os.Stderr, "Token obtained successfully.") + } + + return nil +} diff --git a/cmd/status.go b/cmd/status.go new file mode 100644 index 0000000..c4d7412 --- /dev/null +++ b/cmd/status.go @@ -0,0 +1,74 @@ +package cmd + +import ( + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + + "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 statusCmd = &cobra.Command{ + Use: "status", + Short: "Show current token status", + Long: `Decodes the JWT from the netrc file (without verification) and shows expiry information.`, + RunE: runStatus, +} + +func init() { + rootCmd.AddCommand(statusCmd) +} + +func runStatus(cmd *cobra.Command, args []string) error { + // Read token from netrc + pw, err := netrc.GetPassword(cfg.NetrcPath, cfg.CacheHost) + if err != nil { + return fmt.Errorf("reading netrc: %w", err) + } + + if pw == "" { + fmt.Fprintln(os.Stderr, "No token found.") + return nil + } + + // Decode JWT payload + claims, err := token.DecodePayload(pw) + if err != nil { + return fmt.Errorf("decoding token: %w", err) + } + + // Show claims + if iss, ok := claims["iss"].(string); ok { + fmt.Fprintf(os.Stdout, "Issuer: %s\n", iss) + } + if sub, ok := claims["sub"].(string); ok { + fmt.Fprintf(os.Stdout, "Subject: %s\n", sub) + } + if name, ok := claims["preferred_username"].(string); ok { + fmt.Fprintf(os.Stdout, "User: %s\n", name) + } + + exp, remaining := token.ExpiryInfo(claims) + if !exp.IsZero() { + fmt.Fprintf(os.Stdout, "Expires: %s\n", exp.Local().Format(time.RFC1123)) + if remaining > 0 { + fmt.Fprintf(os.Stdout, "Remaining: %s\n", remaining.Round(time.Second)) + } else { + fmt.Fprintf(os.Stdout, "Status: EXPIRED (%s ago)\n", (-remaining).Round(time.Second)) + } + } + + // Check refresh token + rtPath := config.RefreshTokenPath() + if _, err := os.Stat(rtPath); err == nil { + fmt.Fprintf(os.Stdout, "Refresh token: present\n") + } else { + fmt.Fprintf(os.Stdout, "Refresh token: not found\n") + } + + return nil +} diff --git a/flake.nix b/flake.nix index bd79f4a..94e030a 100644 --- a/flake.nix +++ b/flake.nix @@ -22,14 +22,42 @@ self', inputs', pkgs, + lib, system, ... }: { + packages.default = pkgs.buildGoModule { + pname = "nix-cache-login"; + version = "0.1.0"; + src = ./.; + vendorHash = "sha256-1s77IEGP7/6sgXSNdByRQqisLHSeJuRSsrnxUGfkxos="; + meta = { + description = "CLI tool for authenticating with a Nix binary cache via OIDC"; + mainProgram = "nix-cache-login"; + }; + }; + + apps.default = { + type = "app"; + program = "${self'.packages.default}/bin/nix-cache-login"; + }; + + checks.tests = self'.packages.default.overrideAttrs (old: { + pname = "nix-cache-login-tests"; + checkPhase = '' + runHook preCheck + go test ./... + runHook postCheck + ''; + doCheck = true; + }); + devShells = { default = pkgs.mkShell { packages = with pkgs; [ go + cobra-cli ]; }; }; diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..78ab0a1 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module guardianproject.dev/ops/nix-cache-login + +go 1.25.7 + +require ( + github.com/adrg/xdg v0.5.3 + github.com/coreos/go-oidc/v3 v3.17.0 + github.com/pelletier/go-toml/v2 v2.2.4 + github.com/spf13/cobra v1.10.2 + golang.org/x/oauth2 v0.35.0 +) + +require ( + github.com/go-jose/go-jose/v4 v4.1.3 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/sys v0.26.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fd54812 --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= +github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/browser/browser.go b/internal/browser/browser.go new file mode 100644 index 0000000..58a609f --- /dev/null +++ b/internal/browser/browser.go @@ -0,0 +1,27 @@ +package browser + +import ( + "fmt" + "os/exec" + "runtime" +) + +// Open opens the given URL in the user's default browser. +func Open(url string) error { + var cmd *exec.Cmd + + switch runtime.GOOS { + case "linux": + cmd = exec.Command("xdg-open", url) + case "darwin": + cmd = exec.Command("open", url) + default: + return fmt.Errorf("unsupported platform %s; open this URL manually:\n %s", runtime.GOOS, url) + } + + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser: %w\n Open this URL manually:\n %s", err, url) + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..df2804d --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,64 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/adrg/xdg" + toml "github.com/pelletier/go-toml/v2" +) + +type Config struct { + Issuer string `toml:"issuer"` + ClientID string `toml:"client_id"` + ClientSecret string `toml:"client_secret,omitempty"` + CacheHost string `toml:"cache_host"` + NetrcPath string `toml:"netrc_path"` +} + +// Load reads the config from the given path, or from the default XDG location. +func Load(path string) (*Config, error) { + if path == "" { + path = filepath.Join(xdg.ConfigHome, "nix-cache-login", "config.toml") + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config file: %w", err) + } + + var cfg Config + if err := toml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing config file: %w", err) + } + + cfg.NetrcPath = os.ExpandEnv(cfg.NetrcPath) + + if err := cfg.validate(); err != nil { + return nil, err + } + + return &cfg, nil +} + +func (c *Config) validate() error { + if c.Issuer == "" { + return fmt.Errorf("config: issuer is required") + } + if c.ClientID == "" { + return fmt.Errorf("config: client_id is required") + } + if c.CacheHost == "" { + return fmt.Errorf("config: cache_host is required") + } + if c.NetrcPath == "" { + return fmt.Errorf("config: netrc_path is required") + } + return nil +} + +// RefreshTokenPath returns the path to the stored refresh token. +func RefreshTokenPath() string { + return filepath.Join(xdg.ConfigHome, "nix-cache-login", "refresh-token") +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..1e8efc9 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,177 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadValidConfig(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.toml") + + content := ` +issuer = "https://id.example.com/realms/test" +client_id = "nix-cache" +cache_host = "cache.example.com" +netrc_path = "/home/user/.config/nix/netrc" +` + if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.Issuer != "https://id.example.com/realms/test" { + t.Errorf("issuer = %q, want %q", cfg.Issuer, "https://id.example.com/realms/test") + } + if cfg.ClientID != "nix-cache" { + t.Errorf("client_id = %q, want %q", cfg.ClientID, "nix-cache") + } + if cfg.CacheHost != "cache.example.com" { + t.Errorf("cache_host = %q, want %q", cfg.CacheHost, "cache.example.com") + } + if cfg.ClientSecret != "" { + t.Errorf("client_secret = %q, want empty", cfg.ClientSecret) + } +} + +func TestLoadConfigWithClientSecret(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.toml") + + content := ` +issuer = "https://id.example.com/realms/test" +client_id = "nix-cache-server" +client_secret = "super-secret" +cache_host = "cache.example.com" +netrc_path = "/tmp/netrc" +` + if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.ClientSecret != "super-secret" { + t.Errorf("client_secret = %q, want %q", cfg.ClientSecret, "super-secret") + } +} + +func TestEnvVarExpansionInNetrcPath(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.toml") + + t.Setenv("TEST_CONFIG_DIR", "/custom/config") + + content := ` +issuer = "https://id.example.com/realms/test" +client_id = "nix-cache" +cache_host = "cache.example.com" +netrc_path = "$TEST_CONFIG_DIR/nix/netrc" +` + if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.NetrcPath != "/custom/config/nix/netrc" { + t.Errorf("netrc_path = %q, want %q", cfg.NetrcPath, "/custom/config/nix/netrc") + } +} + +func TestEnvVarExpansionBraces(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.toml") + + t.Setenv("MY_HOME", "/home/testuser") + + content := ` +issuer = "https://id.example.com/realms/test" +client_id = "nix-cache" +cache_host = "cache.example.com" +netrc_path = "${MY_HOME}/.config/nix/netrc" +` + if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(cfgFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if cfg.NetrcPath != "/home/testuser/.config/nix/netrc" { + t.Errorf("netrc_path = %q, want %q", cfg.NetrcPath, "/home/testuser/.config/nix/netrc") + } +} + +func TestMissingRequiredFields(t *testing.T) { + tests := []struct { + name string + content string + errMsg string + }{ + { + name: "missing issuer", + content: `client_id = "x"` + "\n" + `cache_host = "x"` + "\n" + `netrc_path = "/tmp/x"`, + errMsg: "issuer is required", + }, + { + name: "missing client_id", + content: `issuer = "https://x"` + "\n" + `cache_host = "x"` + "\n" + `netrc_path = "/tmp/x"`, + errMsg: "client_id is required", + }, + { + name: "missing cache_host", + content: `issuer = "https://x"` + "\n" + `client_id = "x"` + "\n" + `netrc_path = "/tmp/x"`, + errMsg: "cache_host is required", + }, + { + name: "missing netrc_path", + content: `issuer = "https://x"` + "\n" + `client_id = "x"` + "\n" + `cache_host = "x"`, + errMsg: "netrc_path is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + cfgFile := filepath.Join(dir, "config.toml") + if err := os.WriteFile(cfgFile, []byte(tt.content), 0644); err != nil { + t.Fatal(err) + } + + _, err := Load(cfgFile) + if err == nil { + t.Fatal("expected error, got nil") + } + if !contains(err.Error(), tt.errMsg) { + t.Errorf("error = %q, want to contain %q", err.Error(), tt.errMsg) + } + }) + } +} + +func contains(s, substr string) bool { + return len(s) >= len(substr) && searchString(s, substr) +} + +func searchString(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/internal/netrc/netrc.go b/internal/netrc/netrc.go new file mode 100644 index 0000000..6dcb419 --- /dev/null +++ b/internal/netrc/netrc.go @@ -0,0 +1,136 @@ +package netrc + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// Upsert updates or inserts a machine entry in the netrc file. +// Only the password field is written (Nix uses password from netrc as auth). +func Upsert(path, machine, password string) error { + entries, err := parse(path) + if err != nil && !os.IsNotExist(err) { + return err + } + + found := false + for i, e := range entries { + if e.machine == machine { + entries[i].password = password + found = true + break + } + } + if !found { + entries = append(entries, entry{machine: machine, password: password}) + } + + return write(path, entries) +} + +// Remove removes the entry for the given machine from the netrc file. +func Remove(path, machine string) error { + entries, err := parse(path) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + + var filtered []entry + for _, e := range entries { + if e.machine != machine { + filtered = append(filtered, e) + } + } + + return write(path, filtered) +} + +// GetPassword returns the password for the given machine, or empty string if not found. +func GetPassword(path, machine string) (string, error) { + entries, err := parse(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + + for _, e := range entries { + if e.machine == machine { + return e.password, nil + } + } + return "", nil +} + +type entry struct { + machine string + password string +} + +func parse(path string) ([]entry, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + var entries []entry + var current *entry + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + + switch fields[0] { + case "machine": + if current != nil { + entries = append(entries, *current) + } + current = &entry{machine: fields[1]} + case "password": + if current != nil { + current.password = fields[1] + } + } + } + if current != nil { + entries = append(entries, *current) + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return entries, nil +} + +func write(path string, entries []entry) error { + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return fmt.Errorf("creating directory for %s: %w", path, err) + } + + var b strings.Builder + for i, e := range entries { + if i > 0 { + b.WriteString("\n") + } + fmt.Fprintf(&b, "machine %s\npassword %s\n", e.machine, e.password) + } + + return os.WriteFile(path, []byte(b.String()), 0600) +} diff --git a/internal/netrc/netrc_test.go b/internal/netrc/netrc_test.go new file mode 100644 index 0000000..49694d9 --- /dev/null +++ b/internal/netrc/netrc_test.go @@ -0,0 +1,175 @@ +package netrc + +import ( + "os" + "path/filepath" + "testing" +) + +func TestUpsertEmptyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + if err := Upsert(path, "cache.example.com", "token123"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pw, err := GetPassword(path, "cache.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "token123" { + t.Errorf("password = %q, want %q", pw, "token123") + } +} + +func TestUpsertUpdateExisting(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + initial := "machine other.host\npassword otherpass\n\nmachine cache.example.com\npassword oldtoken\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + if err := Upsert(path, "cache.example.com", "newtoken"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check updated entry + pw, err := GetPassword(path, "cache.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "newtoken" { + t.Errorf("password = %q, want %q", pw, "newtoken") + } + + // Check other entry preserved + pw, err = GetPassword(path, "other.host") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "otherpass" { + t.Errorf("other password = %q, want %q", pw, "otherpass") + } +} + +func TestUpsertAppend(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + initial := "machine existing.host\npassword existingpass\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + if err := Upsert(path, "cache.example.com", "newtoken"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pw, err := GetPassword(path, "cache.example.com") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "newtoken" { + t.Errorf("password = %q, want %q", pw, "newtoken") + } + + pw, err = GetPassword(path, "existing.host") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "existingpass" { + t.Errorf("existing password = %q, want %q", pw, "existingpass") + } +} + +func TestRemove(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + initial := "machine keep.host\npassword keeppass\n\nmachine remove.host\npassword removepass\n" + if err := os.WriteFile(path, []byte(initial), 0600); err != nil { + t.Fatal(err) + } + + if err := Remove(path, "remove.host"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + pw, err := GetPassword(path, "remove.host") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "" { + t.Errorf("removed entry still has password = %q", pw) + } + + pw, err = GetPassword(path, "keep.host") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "keeppass" { + t.Errorf("kept password = %q, want %q", pw, "keeppass") + } +} + +func TestRemoveNonexistentFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent") + + if err := Remove(path, "anything"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestGetPasswordNoFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "nonexistent") + + pw, err := GetPassword(path, "anything") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "" { + t.Errorf("password = %q, want empty", pw) + } +} + +func TestGetPasswordNotFound(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + content := "machine other.host\npassword otherpass\n" + if err := os.WriteFile(path, []byte(content), 0600); err != nil { + t.Fatal(err) + } + + pw, err := GetPassword(path, "missing.host") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pw != "" { + t.Errorf("password = %q, want empty", pw) + } +} + +func TestFilePermissions(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "netrc") + + if err := Upsert(path, "cache.example.com", "token"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat error: %v", err) + } + + perm := info.Mode().Perm() + if perm != 0600 { + t.Errorf("file permissions = %o, want 0600", perm) + } +} diff --git a/internal/pkce/pkce.go b/internal/pkce/pkce.go new file mode 100644 index 0000000..8e6901c --- /dev/null +++ b/internal/pkce/pkce.go @@ -0,0 +1,25 @@ +package pkce + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" +) + +const verifierLength = 43 + +// Generate creates a PKCE code verifier and its S256 challenge. +func Generate() (verifier, challenge string, err error) { + // Generate random bytes and encode to URL-safe base64 (no padding) + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", "", err + } + verifier = base64.RawURLEncoding.EncodeToString(buf) + + // Derive challenge: base64url(sha256(verifier)) + h := sha256.Sum256([]byte(verifier)) + challenge = base64.RawURLEncoding.EncodeToString(h[:]) + + return verifier, challenge, nil +} diff --git a/internal/pkce/pkce_test.go b/internal/pkce/pkce_test.go new file mode 100644 index 0000000..0ee0ed0 --- /dev/null +++ b/internal/pkce/pkce_test.go @@ -0,0 +1,63 @@ +package pkce + +import ( + "crypto/sha256" + "encoding/base64" + "regexp" + "testing" +) + +func TestGenerateVerifierLength(t *testing.T) { + verifier, _, err := Generate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(verifier) < 43 || len(verifier) > 128 { + t.Errorf("verifier length = %d, want 43-128", len(verifier)) + } +} + +func TestGenerateVerifierCharacterSet(t *testing.T) { + verifier, _, err := Generate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // RFC 7636: unreserved characters [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + valid := regexp.MustCompile(`^[A-Za-z0-9\-._~]+$`) + if !valid.MatchString(verifier) { + t.Errorf("verifier contains invalid characters: %q", verifier) + } +} + +func TestGenerateChallengeCorrectness(t *testing.T) { + verifier, challenge, err := Generate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Recompute challenge from verifier + h := sha256.Sum256([]byte(verifier)) + expected := base64.RawURLEncoding.EncodeToString(h[:]) + + if challenge != expected { + t.Errorf("challenge = %q, want %q", challenge, expected) + } +} + +func TestGenerateUniqueness(t *testing.T) { + v1, _, err := Generate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + v2, _, err := Generate() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if v1 == v2 { + t.Error("two Generate() calls produced identical verifiers") + } +} diff --git a/internal/token/jwt.go b/internal/token/jwt.go new file mode 100644 index 0000000..4bc7c00 --- /dev/null +++ b/internal/token/jwt.go @@ -0,0 +1,46 @@ +package token + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// DecodePayload decodes the payload (claims) of a JWT without verifying the signature. +func DecodePayload(tokenStr string) (map[string]interface{}, error) { + parts := strings.Split(tokenStr, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) + } + + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decoding JWT payload: %w", err) + } + + var claims map[string]interface{} + if err := json.Unmarshal(payload, &claims); err != nil { + return nil, fmt.Errorf("unmarshaling JWT payload: %w", err) + } + + return claims, nil +} + +// ExpiryInfo extracts the expiry time and remaining duration from JWT claims. +func ExpiryInfo(claims map[string]interface{}) (exp time.Time, remaining time.Duration) { + expVal, ok := claims["exp"] + if !ok { + return time.Time{}, 0 + } + + expFloat, ok := expVal.(float64) + if !ok { + return time.Time{}, 0 + } + + exp = time.Unix(int64(expFloat), 0) + remaining = time.Until(exp) + return exp, remaining +} diff --git a/internal/token/jwt_test.go b/internal/token/jwt_test.go new file mode 100644 index 0000000..8ed74e7 --- /dev/null +++ b/internal/token/jwt_test.go @@ -0,0 +1,92 @@ +package token + +import ( + "encoding/base64" + "encoding/json" + "testing" + "time" +) + +// buildTestJWT builds a JWT string with the given payload (no real signature). +func buildTestJWT(claims map[string]interface{}) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + payload, _ := json.Marshal(claims) + payloadEnc := base64.RawURLEncoding.EncodeToString(payload) + sig := base64.RawURLEncoding.EncodeToString([]byte("fakesig")) + return header + "." + payloadEnc + "." + sig +} + +func TestDecodePayload(t *testing.T) { + claims := map[string]interface{}{ + "iss": "https://id.example.com/realms/test", + "sub": "user123", + "exp": float64(1700000000), + "aud": "nix-cache", + } + + jwt := buildTestJWT(claims) + + decoded, err := DecodePayload(jwt) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if decoded["iss"] != "https://id.example.com/realms/test" { + t.Errorf("iss = %v, want %v", decoded["iss"], "https://id.example.com/realms/test") + } + if decoded["sub"] != "user123" { + t.Errorf("sub = %v, want %v", decoded["sub"], "user123") + } + if decoded["aud"] != "nix-cache" { + t.Errorf("aud = %v, want %v", decoded["aud"], "nix-cache") + } +} + +func TestExpiryInfo(t *testing.T) { + futureExp := time.Now().Add(1 * time.Hour).Unix() + claims := map[string]interface{}{ + "exp": float64(futureExp), + } + + exp, remaining := ExpiryInfo(claims) + + if exp.Unix() != futureExp { + t.Errorf("exp = %v, want %v", exp.Unix(), futureExp) + } + if remaining < 59*time.Minute || remaining > 61*time.Minute { + t.Errorf("remaining = %v, expected ~1 hour", remaining) + } +} + +func TestExpiryInfoPast(t *testing.T) { + pastExp := time.Now().Add(-1 * time.Hour).Unix() + claims := map[string]interface{}{ + "exp": float64(pastExp), + } + + _, remaining := ExpiryInfo(claims) + + if remaining >= 0 { + t.Errorf("remaining = %v, expected negative (expired)", remaining) + } +} + +func TestDecodePayloadMalformed(t *testing.T) { + tests := []struct { + name string + token string + }{ + {"no dots", "nodots"}, + {"one dot", "one.dot"}, + {"empty payload", "header..sig"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecodePayload(tt.token) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..4b46174 --- /dev/null +++ b/main.go @@ -0,0 +1,7 @@ +package main + +import "guardianproject.dev/ops/nix-cache-login/cmd" + +func main() { + cmd.Execute() +}