initial working version

This commit is contained in:
Abel Luck 2026-02-26 11:05:16 +01:00
parent db6b90134d
commit d986a0b31a
19 changed files with 1430 additions and 0 deletions

174
cmd/login.go Normal file
View file

@ -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, "<html><body><h2>Authentication successful!</h2><p>You can close this window.</p></body></html>")
resultCh <- result{token: tok}
})
server := &http.Server{Handler: mux}
// Start server in background
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
resultCh <- result{err: fmt.Errorf("callback server: %w", err)}
}
}()
// Open browser
fmt.Fprintln(os.Stderr, "Opening browser for authentication...")
if err := browser.Open(authURL); err != nil {
fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err)
}
fmt.Fprintf(os.Stderr, "If the browser didn't open, visit:\n %s\n", authURL)
// Wait for callback
res := <-resultCh
// Shut down server
shutdownCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
server.Shutdown(shutdownCtx)
if res.err != nil {
return res.err
}
// Write access token to netrc
if err := netrc.Upsert(cfg.NetrcPath, cfg.CacheHost, res.token.AccessToken); err != nil {
return fmt.Errorf("writing netrc: %w", err)
}
// Store refresh token
if res.token.RefreshToken != "" {
rtPath := config.RefreshTokenPath()
if err := os.MkdirAll(filepath.Dir(rtPath), 0700); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
if err := os.WriteFile(rtPath, []byte(res.token.RefreshToken), 0600); err != nil {
return fmt.Errorf("writing refresh token: %w", err)
}
}
// Decode JWT to show expiry
claims, err := token.DecodePayload(res.token.AccessToken)
if err != nil {
fmt.Fprintln(os.Stderr, "Authenticated successfully (could not decode token expiry).")
return nil
}
exp, _ := token.ExpiryInfo(claims)
if !exp.IsZero() {
fmt.Fprintf(os.Stderr, "Authenticated. Token valid until %s.\n", exp.Local().Format(time.RFC1123))
} else {
fmt.Fprintln(os.Stderr, "Authenticated successfully.")
}
return nil
}

63
cmd/logout.go Normal file
View file

@ -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
}

107
cmd/refresh.go Normal file
View file

@ -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
}

46
cmd/root.go Normal file
View file

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

78
cmd/serviceAccount.go Normal file
View file

@ -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
}

74
cmd/status.go Normal file
View file

@ -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
}

View file

@ -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
];
};
};

18
go.mod Normal file
View file

@ -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
)

30
go.sum Normal file
View file

@ -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=

View file

@ -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
}

64
internal/config/config.go Normal file
View file

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

View file

@ -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
}

136
internal/netrc/netrc.go Normal file
View file

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

View file

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

25
internal/pkce/pkce.go Normal file
View file

@ -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
}

View file

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

46
internal/token/jwt.go Normal file
View file

@ -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
}

View file

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

7
main.go Normal file
View file

@ -0,0 +1,7 @@
package main
import "guardianproject.dev/ops/nix-cache-login/cmd"
func main() {
cmd.Execute()
}