netbirdio/netbird · error

%s %s

Error message

%s

    %s

What it means

Raised in the management service's proxy OAuth callback handler (management/server/http/handlers/proxy/auth.go:92) when golang.org/x/oauth2's Config.Exchange fails while swapping the authorization code for tokens at the IdP token endpoint. The oauth2.Config here carries only ClientID, RedirectURL, and the provider endpoint (no ClientSecret), so Exchange is a public-client PKCE request; any non-2xx IdP response (invalid_grant, invalid_client, invalid_redirect_uri) or transport error surfaces here and the handler answers 500.

Source

Thrown at client/cmd/daemon_error.go:21

import (
	"errors"
	"fmt"
	"strings"

	"google.golang.org/genproto/googleapis/rpc/errdetails"
	gstatus "google.golang.org/grpc/status"

	"github.com/netbirdio/netbird/client/internal/ipcauth"
)

// daemonCallError prepares a daemon error for display. A refusal the daemon
// raised because the operation needs root/administrator is already guidance
// written for the user, so it is surfaced on its own instead of buried under the
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
// with context as usual.
func daemonCallError(context string, err error) error {
	if guidance, ok := privilegeGuidance(err); ok {
		return errors.New(guidance)
	}
	return fmt.Errorf("%s: %w", context, err)
}

// privilegeGuidance renders the daemon's privilege refusal as a summary and the
// command that performs the operation with the privileges it needs. It reports
// false for any other error.
func privilegeGuidance(err error) (string, bool) {
	info, ok := privilegeErrorInfo(err)
	if !ok {
		return "", false
	}

	summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
	command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
	if summary == "" {
		// Detail without a summary: fall back to the status message, which
		// carries the same text.

View on GitHub (pinned to 93e97f4bf1)

Solutions

  1. Read the wrapped error in the management log (log.WithError at the same line): 'invalid_grant' means expired/reused code (restart the login flow), 'invalid_client' means the IdP expects a confidential client or the ClientID is wrong, 'invalid_request/redirect_uri' means a URI mismatch.
  2. Make oidcConfig.CallbackURL identical, character for character, to the redirect URI registered in the IdP application and to the one used when the authorize URL was built.
  3. Verify egress from the management host: curl -v the IdP token endpoint from inside the management container/process environment.
  4. If the flow was interrupted (state older than the code TTL or page refresh), restart from the proxy domain so a fresh code + verifier pair is generated.

Example fix

// before: IdP app configured as confidential, exchange built without secret
token, err := (&oauth2.Config{
    ClientID:    oidcConfig.ClientID,
    Endpoint:    provider.Endpoint(),
    RedirectURL: oidcConfig.CallbackURL,
}).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier))

// after: register the IdP app as public/PKCE (no secret), or if confidential,
// send the secret and keep the callback URL byte-identical to the IdP registration:
token, err := (&oauth2.Config{
    ClientID:     oidcConfig.ClientID,
    ClientSecret: oidcConfig.ClientSecret, // only for confidential clients
    Endpoint:     provider.Endpoint(),
    RedirectURL:  oidcConfig.CallbackURL,   // must equal the registered redirect URI
}).Exchange(r.Context(), r.URL.Query().Get("code"), oauth2.VerifierOption(codeVerifier))
Defensive patterns

Strategy: validation

Validate before calling

// Preflight from the management host before driving users through the flow:
// 1) issuer discovery answers, 2) callback URL matches the registered one.
provider, err := oidc.NewProvider(ctx, cfg.Issuer)
if err != nil {
    return fmt.Errorf("issuer unreachable: %w", err)
}
_ = provider.Endpoint() // forces well-known fetch
if cfg.CallbackURL == "" || !strings.HasPrefix(cfg.CallbackURL, "https://") {
    return errors.New("proxy OIDC callback URL must be set and use https")
}
// Compare cfg.CallbackURL against the IdP app registration before starting login.

Try / catch

token, err := oauth2Cfg.Exchange(ctx, code, oauth2.VerifierOption(verifier))
if err != nil {
    var rErr *oauth2.RetrieveError
    if errors.As(err, &rErr) {
        // rErr.Code / body carry the IdP's error: invalid_grant, invalid_client...
        log.Printf("idp rejected exchange: %s %s", rErr.Code, rErr.Body)
    }
    return fmt.Errorf("exchange code: %w", err)
}

Prevention

When it happens

Trigger: GET on types.ProxyCallbackEndpoint after ValidateState succeeded, but the IdP rejects the exchange: authorization code expired or already redeemed (callback URL reloaded), PKCE code_verifier from ValidateState does not match the challenge sent at authorize time, RedirectURL (oidcConfig.CallbackURL) differs from the URI used in the authorize request or registered in the IdP app, the IdP app is confidential and demands a client_secret this config never sends, or the management host cannot reach the token endpoint (TLS, DNS, egress firewall).

Common situations: management.json proxy OIDC callback URL not matching the IdP-registered redirect exactly (scheme, port, path); user double-visits or refreshes the callback link; management container has no egress to the IdP; IdP rotated client credentials while management still holds the old ClientID; reverse proxy in front of management rewrites the callback path.

Related errors


AI-assisted analysis of netbirdio/netbird@93e97f4bf1 (2026-08-16). Data as JSON: /api/errors/18b33a63b735dcc2. Report an issue: GitHub.