Edit: New Solution
Edit: I have found a solution that works for me (as mentioned in the comments):
Use an iterative approach. Start with a reference frame at the start of the bezier. Then, for each new point, use the orientation of the previous frame to orient the next one:
forwardOnCurve = DeriveCubic(p[0], p[1], p[2], p[3], Mathf.Clamp01(t)).normalized;
normalOnCurve = Vector3.Cross(forwardOnCurve, Vector3.Cross(normalOnCurve, forwardOnCurve)).normalized;
As you can see, I calculate the right vector from the new forwards vector and the previous normal. Then I calculate the new normal.
The problem with this approach is that while it is locally continuous, it ignores the orientation of all control points but the first.
To solve this, I rotate all points along the forwards vector between each control point, linearly interpolating the error angle to be correctly oriented at the next control point. Also, I treat each segment individually, always starting correctly oriented at each control point.
The correction looks something like this:
segmentLength += Vector3.Distance(previousPointOnCurve, p[3]);
lineLength += segmentLength;
forwardOnCurve = DeriveCubic(p[0], p[1], p[2], p[3], 1).normalized;
normalOnCurve = Vector3.Cross(forwardOnCurve, Vector3.Cross(normalOnCurve, forwardOnCurve)).normalized;
float angleError = Vector3.SignedAngle(normalOnCurve, _normals[segment + 1], forwardOnCurve);
// Iterate over evenly spaced points in this segment, and gradually correct angle error
float tStep = spacing / segmentLength;
float tStart = Vector3.Distance(esp[startIndex].position, p[0]) / segmentLength;
for (int i = startIndex; i < endIndexExclusive; ++i)
{
float t_ = (i - startIndex) * tStep + tStart;
// TODO: make weight non-linear, depending on handle lengths
float correction = t_ * angleError;
esp[i].normal = Quaternion.AngleAxis(correction, esp[i].forward) * esp[i].normal;
}
The full code is this: https://pastebin.com/t5LFLhL9
This can also be found in context in my unity package called "MB Road System"
Screenshot

My Old solution