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 }