diff --git a/.envrc b/.envrc deleted file mode 100644 index a47b790..0000000 --- a/.envrc +++ /dev/null @@ -1,6 +0,0 @@ -#!/usr/bin/env bash -if [[ $(type -t use_flake) != function ]]; then - echo "ERROR: direnv's use_flake function missing. update direnv to v2.30.0 or later." && exit 1 -fi -use flake -dotenv diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 2bbdbfe..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.direnv -result diff --git a/CHANGELOG.md b/CHANGELOG.md deleted file mode 100644 index b728640..0000000 --- a/CHANGELOG.md +++ /dev/null @@ -1,18 +0,0 @@ -# Changelog - -## [Unreleased] - -Changes yet to be released are documented here. - -## v0.1.0 - -Initial release. - -- `login` — browser-based Authorization Code + PKCE flow -- `refresh` — refresh access token using stored refresh token -- `service-account` — headless client credentials flow for servers -- `status` — decode JWT from netrc, show expiry info -- `logout` — revoke tokens and clean up netrc -- TOML config with environment variable expansion -- netrc read/write preserving other entries -- Nix flake with package, app, checks, and devShell diff --git a/README.md b/README.md deleted file mode 100644 index 55a9174..0000000 --- a/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# nix-cache-login - -CLI tool for authenticating with a Nix binary cache via Keycloak OIDC. Obtains -access tokens and writes them to a netrc file so Nix can use them -transparently. - -Canonical Repository: https://guardianproject.dev/ops/nix-cache-login - -## Overview - -Nix binary caches can be protected with OIDC-based authentication backed by -Keycloak. This tool handles the token lifecycle: - -- Workstation users: authenticate via browser (Authorization Code + PKCE), get a 1-hour access token and a 24-hour refresh token -- Servers: authenticate headlessly via client credentials, get a short-lived access token refreshed on a timer - -The access token is written to a netrc file, which Nix reads automatically when -fetching from the cache. - -## Installation - -```bash -# run directly -nix run guardianproject.dev/ops/nix-cache-login -``` - -Or add as a flake input: - -```nix -{ - inputs.nix-cache-login.url = "git+https://guardianproject.dev/ops/nix-cache-login"; - - # use the package - # nix-cache-login.packages.${system}.default -} -``` - -## Configuration - -Create `$XDG_CONFIG_HOME/nix-cache-login/config.toml` (default `~/.config/nix-cache-login/config.toml`): - -**Workstation:** -```toml -issuer = "https://id.guardianproject.info/realms/gp" -client_id = "nix-cache" -cache_host = "cache.guardianproject.dev" -netrc_path = "$XDG_CONFIG_HOME/nix/netrc" -``` - -**Server (service account):** -```toml -issuer = "https://id.guardianproject.info/realms/gp" -client_id = "nix-cache-server" -client_secret_file = "/run/secrets/nix-cache-client-secret" -cache_host = "cache.guardianproject.dev" -netrc_path = "$XDG_CONFIG_HOME/nix/netrc" -``` - -Path values support environment variable expansion (`$VAR` and `${VAR}`). - -## Usage - -```bash -nix-cache-login login # authenticate via browser (default command) -nix-cache-login refresh # refresh token without browser -nix-cache-login service-account # headless client credentials flow -nix-cache-login status # show token expiry info -nix-cache-login logout # revoke tokens and clean up -``` - -## Maintenance - -This tool is actively maintained by [Guardian Project](https://guardianproject.info). - -### Issues - -For bug reports and feature requests, please use the [Issues][issues] page. - -### Security - -For security-related issues, please contact us through our [security policy][sec]. - -[issues]: https://guardianproject.dev/ops/nix-cache-login/issues -[sec]: https://guardianproject.info/contact/ - -## License - -Copyright (c) 2026 Abel Luck - -This project is licensed under the GNU General Public License v3.0 or later - see the [LICENSE](LICENSE) file for details. diff --git a/cmd/login.go b/cmd/login.go deleted file mode 100644 index c239f75..0000000 --- a/cmd/login.go +++ /dev/null @@ -1,174 +0,0 @@ -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 deleted file mode 100644 index 969150f..0000000 --- a/cmd/logout.go +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 4a40243..0000000 --- a/cmd/refresh.go +++ /dev/null @@ -1,107 +0,0 @@ -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 deleted file mode 100644 index 6ca3e48..0000000 --- a/cmd/root.go +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 4aa11bc..0000000 --- a/cmd/serviceAccount.go +++ /dev/null @@ -1,78 +0,0 @@ -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_file 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_file 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 deleted file mode 100644 index c4d7412..0000000 --- a/cmd/status.go +++ /dev/null @@ -1,74 +0,0 @@ -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.lock b/flake.lock index d37a53a..4cd654f 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,23 @@ { "nodes": { + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1769996383, + "narHash": "sha256-AnYjnFWgS49RlqX7LrC4uA+sCCDBj0Ry/WOJ5XWAsa0=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "57928607ea566b5db3ad13af0e57e921e6b12381", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, "nixpkgs": { "locked": { "lastModified": 1771848320, @@ -14,8 +32,24 @@ "url": "https://flakehub.com/f/NixOS/nixpkgs/0.1" } }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1769909678, + "narHash": "sha256-cBEymOf4/o3FD5AZnzC3J9hLbiZ+QDT/KDuyHXVJOpM=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "72716169fe93074c333e8d0173151350670b824c", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, "root": { "inputs": { + "flake-parts": "flake-parts", "nixpkgs": "nixpkgs" } } diff --git a/flake.nix b/flake.nix index e1c0ef9..3fa48d6 100644 --- a/flake.nix +++ b/flake.nix @@ -1,48 +1,37 @@ { - inputs.nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; + inputs = { + nixpkgs.url = "https://flakehub.com/f/NixOS/nixpkgs/0.1"; # tracks nixpkgs unstable branch + flake-parts.url = "github:hercules-ci/flake-parts"; + }; outputs = - { self, nixpkgs }: - let + { self, flake-parts, ... }: + flake-parts.lib.mkFlake { inherit self; } { + flake = { + # Put your original flake attributes here. + }; systems = [ + # systems for which you want to build the `perSystem` attributes "x86_64-linux" "aarch64-darwin" ]; - forAllSystems = fn: nixpkgs.lib.genAttrs systems (system: fn nixpkgs.legacyPackages.${system}); - in - { - packages = forAllSystems (pkgs: { - default = pkgs.callPackage ./package.nix { }; - }); - - apps = forAllSystems (pkgs: { - default = { - type = "app"; - program = "${self.packages.${pkgs.stdenv.hostPlatform.system}.default}/bin/nix-cache-login"; - meta.description = "CLI tool for authenticating with a Nix binary cache via OIDC"; + perSystem = + { + config, + self', + inputs', + pkgs, + system, + ... + }: + { + devShells = { + default = pkgs.mkShell { + packages = with pkgs; [ + go + ]; + }; + }; }; - }); - - checks = forAllSystems (pkgs: { - tests = self.packages.${pkgs.stdenv.hostPlatform.system}.default.overrideAttrs (_: { - pname = "nix-cache-login-tests"; - checkPhase = '' - runHook preCheck - go test ./... - runHook postCheck - ''; - doCheck = true; - }); - devShell = self.devShells.${pkgs.stdenv.hostPlatform.system}.default; - }); - - devShells = forAllSystems (pkgs: { - default = pkgs.mkShell { - packages = with pkgs; [ - go - cobra-cli - ]; - }; - }); }; } diff --git a/go.mod b/go.mod deleted file mode 100644 index 78ab0a1..0000000 --- a/go.mod +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index fd54812..0000000 --- a/go.sum +++ /dev/null @@ -1,30 +0,0 @@ -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 deleted file mode 100644 index 58a609f..0000000 --- a/internal/browser/browser.go +++ /dev/null @@ -1,27 +0,0 @@ -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 deleted file mode 100644 index 77faaa1..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,77 +0,0 @@ -package config - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/adrg/xdg" - toml "github.com/pelletier/go-toml/v2" -) - -type Config struct { - Issuer string `toml:"issuer"` - ClientID string `toml:"client_id"` - ClientSecretFile string `toml:"client_secret_file,omitempty"` - CacheHost string `toml:"cache_host"` - NetrcPath string `toml:"netrc_path"` - - // ClientSecret is populated at load time by reading ClientSecretFile. - ClientSecret string `toml:"-"` -} - -// 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) - cfg.ClientSecretFile = os.ExpandEnv(cfg.ClientSecretFile) - - if cfg.ClientSecretFile != "" { - secret, err := os.ReadFile(cfg.ClientSecretFile) - if err != nil { - return nil, fmt.Errorf("reading client_secret_file: %w", err) - } - cfg.ClientSecret = strings.TrimSpace(string(secret)) - } - - 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 deleted file mode 100644 index b17bb0f..0000000 --- a/internal/config/config_test.go +++ /dev/null @@ -1,206 +0,0 @@ -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 TestLoadConfigWithClientSecretFile(t *testing.T) { - dir := t.TempDir() - cfgFile := filepath.Join(dir, "config.toml") - secretFile := filepath.Join(dir, "secret") - - if err := os.WriteFile(secretFile, []byte("super-secret\n"), 0600); err != nil { - t.Fatal(err) - } - - content := ` -issuer = "https://id.example.com/realms/test" -client_id = "nix-cache-server" -client_secret_file = "` + secretFile + `" -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 TestLoadConfigClientSecretFileMissing(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_file = "/nonexistent/secret" -cache_host = "cache.example.com" -netrc_path = "/tmp/netrc" -` - if err := os.WriteFile(cfgFile, []byte(content), 0644); err != nil { - t.Fatal(err) - } - - _, err := Load(cfgFile) - if err == nil { - t.Fatal("expected error, got nil") - } - if !contains(err.Error(), "client_secret_file") { - t.Errorf("error = %q, want to contain %q", err.Error(), "client_secret_file") - } -} - -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 deleted file mode 100644 index f020a4d..0000000 --- a/internal/netrc/netrc.go +++ /dev/null @@ -1,139 +0,0 @@ -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) - } - - if err := os.WriteFile(path, []byte(b.String()), 0600); err != nil { - return err - } - return os.Chmod(path, 0600) -} diff --git a/internal/netrc/netrc_test.go b/internal/netrc/netrc_test.go deleted file mode 100644 index 611c8c4..0000000 --- a/internal/netrc/netrc_test.go +++ /dev/null @@ -1,199 +0,0 @@ -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) - } -} - -func TestFilePermissionsCorrected(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "netrc") - - // Create file with overly permissive mode - if err := os.WriteFile(path, []byte("machine old.host\npassword oldpass\n"), 0644); err != nil { - t.Fatal(err) - } - - 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 deleted file mode 100644 index 8e6901c..0000000 --- a/internal/pkce/pkce.go +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 0ee0ed0..0000000 --- a/internal/pkce/pkce_test.go +++ /dev/null @@ -1,63 +0,0 @@ -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 deleted file mode 100644 index 4bc7c00..0000000 --- a/internal/token/jwt.go +++ /dev/null @@ -1,46 +0,0 @@ -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 deleted file mode 100644 index 8ed74e7..0000000 --- a/internal/token/jwt_test.go +++ /dev/null @@ -1,92 +0,0 @@ -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 deleted file mode 100644 index 4b46174..0000000 --- a/main.go +++ /dev/null @@ -1,7 +0,0 @@ -package main - -import "guardianproject.dev/ops/nix-cache-login/cmd" - -func main() { - cmd.Execute() -} diff --git a/package.nix b/package.nix deleted file mode 100644 index c5344f1..0000000 --- a/package.nix +++ /dev/null @@ -1,22 +0,0 @@ -{ - lib, - buildGoModule, - fetchgit, -}: - -buildGoModule { - pname = "nix-cache-login"; - version = "0.1.0"; - src = ./.; - # src = fetchgit { - # url = "https://guardianproject.dev/ops/nix-cache-login.git"; - # rev = "v0.1.0"; - # hash = ""; - # }; - vendorHash = "sha256-1s77IEGP7/6sgXSNdByRQqisLHSeJuRSsrnxUGfkxos="; - meta = { - description = "CLI tool for authenticating with a Nix binary cache via OIDC"; - mainProgram = "nix-cache-login"; - license = lib.licenses.gpl3Plus; - }; -}