Skip to content

fix(cli): refresh token lost on reconnect#2253

Open
danilrwx wants to merge 3 commits intomainfrom
fix/cli/refresh-token-lose-on-reconnect
Open

fix(cli): refresh token lost on reconnect#2253
danilrwx wants to merge 3 commits intomainfrom
fix/cli/refresh-token-lose-on-reconnect

Conversation

@danilrwx
Copy link
Copy Markdown
Contributor

@danilrwx danilrwx commented Apr 21, 2026

Description

Refresh CLI client config before reconnecting long-lived VM access sessions.

This PR updates d8 v vnc and d8 v console so they recreate the Kubernetes client from the current context before each reconnect attempt instead of reusing the client created at command start.

Also adds unit tests covering reconnect behavior for both commands.

Why do we need it, and what problem does it solve?

When d8 v vnc or d8 v console stays alive for a long time, the process keeps the old in-memory OIDC config. If the ID token expires and another client (kubectl, another d8, Lens, k9s, IDE) refreshes the token first, Dex rotates the refresh token and the old CLI process can no longer use its stale refresh token on reconnect.

As a result, after VNC/console reconnect scenarios the command may fail with an error like:

failed to refresh token: oauth2: "invalid_request" "Refresh token is invalid or has already been claimed by another client."

This change makes reconnect logic reread kubeconfig/client config before each new connection attempt, so the command uses the latest valid tokens.

What is the expected result?

Reproduction steps for the original problem:

  1. Use an OIDC kubeconfig generated by Deckhouse user-authn / Dex.
  2. Reduce user-authn.settings.idTokenTTL, for example to 1m.
  3. Start a long-lived session:
    • d8 v vnc <vm> -n <ns>, or
    • d8 v console <vm> -n <ns>.
  4. Wait until the ID token expires.
  5. In another terminal, run kubectl get ns to refresh tokens and rotate the refresh token.
  6. Trigger reconnect of the original session:
    • reboot the guest OS,
    • or restart the VM,
    • or cause a temporary connection loss.

Expected result after this PR:

  • d8 v vnc reconnects successfully.
  • d8 v console reconnects successfully.
  • The command does not fail with the stale refresh token error.

Checklist

  • The code is covered by unit tests.
  • e2e tests passed.
  • Documentation updated according to the changes.
  • Changes were tested in the Kubernetes cluster manually.

Changelog entries

section: core
type: fix
summary: "Reconnect logic in d8 v vnc and d8 v console now refreshes client config before reconnect, avoiding stale OIDC refresh token failures."
impact_level: low

Signed-off-by: Daniil Antoshin <daniil.antoshin@flant.com>
Signed-off-by: Daniil Antoshin <daniil.antoshin@flant.com>
@danilrwx danilrwx marked this pull request as ready for review April 21, 2026 16:07
@danilrwx danilrwx requested a review from Isteb4k as a code owner April 21, 2026 16:07
Signed-off-by: Daniil Antoshin <daniil.antoshin@flant.com>
@danilrwx danilrwx added this to the v1.8.0 milestone Apr 21, 2026
Comment on lines +137 to +145
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
}
if clientCalls != 2 {
t.Fatalf("expected client to be refreshed before each reconnect, got %d calls", clientCalls)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Comment on lines +37 to +87
func (f *fakeKubeclient) ClusterVirtualImages() virtualizationv1alpha2.ClusterVirtualImageInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface {
return nil
}

func (f *fakeKubeclient) VirtualImages(namespace string) virtualizationv1alpha2.VirtualImageInterface {
return nil
}

func (f *fakeKubeclient) VirtualDisks(namespace string) virtualizationv1alpha2.VirtualDiskInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineBlockDeviceAttachments(namespace string) virtualizationv1alpha2.VirtualMachineBlockDeviceAttachmentInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineIPAddresses(namespace string) virtualizationv1alpha2.VirtualMachineIPAddressInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineIPAddressLeases() virtualizationv1alpha2.VirtualMachineIPAddressLeaseInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineOperations(namespace string) virtualizationv1alpha2.VirtualMachineOperationInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineClasses() virtualizationv1alpha2.VirtualMachineClassInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineMACAddresses(namespace string) virtualizationv1alpha2.VirtualMachineMACAddressInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineMACAddressLeases() virtualizationv1alpha2.VirtualMachineMACAddressLeaseInterface {
return nil
}

func (f *fakeKubeclient) NodeUSBDevices() virtualizationv1alpha2.NodeUSBDeviceInterface {
return nil
}

func (f *fakeKubeclient) USBDevices(namespace string) virtualizationv1alpha2.USBDeviceInterface {
return nil
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t we have a generated fake client?

Comment on lines +116 to +124
if ln == nil {
t.Fatal("listener must not be nil")
}
if namespace != "default" {
t.Fatalf("unexpected namespace: %s", namespace)
}
if vmName != "test-vm" {
t.Fatalf("unexpected vm name: %s", vmName)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Comment on lines +138 to +146
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
if connectCalls != 2 {
t.Fatalf("expected 2 connect attempts, got %d", connectCalls)
}
if clientCalls != 2 {
t.Fatalf("expected client to be refreshed before each reconnect, got %d calls", clientCalls)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Comment on lines +38 to +88
func (f *fakeKubeclient) ClusterVirtualImages() virtualizationv1alpha2.ClusterVirtualImageInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachines(namespace string) virtualizationv1alpha2.VirtualMachineInterface {
return nil
}

func (f *fakeKubeclient) VirtualImages(namespace string) virtualizationv1alpha2.VirtualImageInterface {
return nil
}

func (f *fakeKubeclient) VirtualDisks(namespace string) virtualizationv1alpha2.VirtualDiskInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineBlockDeviceAttachments(namespace string) virtualizationv1alpha2.VirtualMachineBlockDeviceAttachmentInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineIPAddresses(namespace string) virtualizationv1alpha2.VirtualMachineIPAddressInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineIPAddressLeases() virtualizationv1alpha2.VirtualMachineIPAddressLeaseInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineOperations(namespace string) virtualizationv1alpha2.VirtualMachineOperationInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineClasses() virtualizationv1alpha2.VirtualMachineClassInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineMACAddresses(namespace string) virtualizationv1alpha2.VirtualMachineMACAddressInterface {
return nil
}

func (f *fakeKubeclient) VirtualMachineMACAddressLeases() virtualizationv1alpha2.VirtualMachineMACAddressLeaseInterface {
return nil
}

func (f *fakeKubeclient) NodeUSBDevices() virtualizationv1alpha2.NodeUSBDeviceInterface {
return nil
}

func (f *fakeKubeclient) USBDevices(namespace string) virtualizationv1alpha2.USBDeviceInterface {
return nil
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t we have a generated fake client?

Comment on lines +101 to +103
if err != nil {
t.Fatalf("create stdin pipe: %v", err)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Comment on lines +117 to +122
if namespace != "default" {
t.Fatalf("unexpected namespace: %s", namespace)
}
if name != "test-vm" {
t.Fatalf("unexpected vm name: %s", name)
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

assert

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants