From 0e07e617eda384eb79ccfacfb7798213c0acbd9a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:09:38 +0000 Subject: [PATCH 01/11] Add UDS and Windows named pipe transports for external auth Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/CHANGELOG.md | 2 + cli/azd/cmd/auth_transport.go | 108 +++++++++++++ cli/azd/cmd/auth_transport_other.go | 23 +++ cli/azd/cmd/auth_transport_test.go | 109 +++++++++++++ cli/azd/cmd/auth_transport_unix.go | 108 +++++++++++++ cli/azd/cmd/auth_transport_unix_test.go | 121 +++++++++++++++ cli/azd/cmd/auth_transport_windows.go | 172 +++++++++++++++++++++ cli/azd/cmd/auth_transport_windows_test.go | 45 ++++++ cli/azd/cmd/container.go | 38 +---- cli/azd/docs/environment-variables.md | 6 +- cli/azd/docs/external-authentication.md | 69 ++++++++- cli/azd/go.mod | 1 + cli/azd/go.sum | 2 + docs/reference/environment-variables.md | 6 +- 14 files changed, 771 insertions(+), 39 deletions(-) create mode 100644 cli/azd/cmd/auth_transport.go create mode 100644 cli/azd/cmd/auth_transport_other.go create mode 100644 cli/azd/cmd/auth_transport_test.go create mode 100644 cli/azd/cmd/auth_transport_unix.go create mode 100644 cli/azd/cmd/auth_transport_unix_test.go create mode 100644 cli/azd/cmd/auth_transport_windows.go create mode 100644 cli/azd/cmd/auth_transport_windows_test.go diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 99a475d6ace..34d8487b20f 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -4,6 +4,8 @@ ### Features Added +- Add Unix domain socket (`unix:`) and Windows named pipe (`npipe:`) transports to the External Authentication protocol. IDE hosts can now set `AZD_AUTH_ENDPOINT` to `unix:/path/to/socket` (POSIX) or `npipe:` (Windows) so `azd` reaches the host's token server over a local IPC channel where the OS enforces caller identity, removing the need for a loopback HTTPS server, a self-signed certificate, and a shared bearer key. The existing `https:` flow is unchanged. See [External Authentication](./docs/external-authentication.md) for the spec. + ### Breaking Changes ### Bugs Fixed diff --git a/cli/azd/cmd/auth_transport.go b/cli/azd/cmd/auth_transport.go new file mode 100644 index 00000000000..803beda9700 --- /dev/null +++ b/cli/azd/cmd/auth_transport.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/http" + "net/url" + + "github.com/azure/azure-dev/cli/azd/pkg/auth" + "github.com/azure/azure-dev/cli/azd/pkg/httputil" +) + +// rewrittenAuthEndpoint is the canonical placeholder URL used as the +// AZD_AUTH_ENDPOINT after rewriting unix:/npipe: schemes. RemoteCredential +// formats the request URL as "/token?api-version=..." so this +// placeholder produces a syntactically valid URL whose host/path are +// irrelevant because the transport dials a fixed socket/pipe. +const rewrittenAuthEndpoint = "http://azd-auth" + +// buildExternalAuthConfiguration constructs the auth.ExternalAuthConfiguration +// from the raw AZD_AUTH_* env values. It dispatches on the scheme of the +// endpoint URL: +// +// - "" or "https": existing loopback HTTPS behavior. AZD_AUTH_CERT is +// required for "https". +// - "unix": POSIX-only Unix domain socket transport. Cert MUST NOT be set. +// Key is optional but still forwarded for defense in depth. +// - "npipe": Windows-only named pipe transport. Cert MUST NOT be set. Key +// is optional but still forwarded for defense in depth. +// +// Any other scheme yields an error that lists the supported schemes. +func buildExternalAuthConfiguration(endpoint, key, cert string) (auth.ExternalAuthConfiguration, error) { + // Parse the endpoint up front so we can dispatch on its scheme. An empty + // endpoint string parses successfully with an empty scheme, which is the + // historical "no external auth configured" / "implicit http for tests" + // case. + endpointUrl, err := url.Parse(endpoint) + if err != nil { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf("invalid AZD_AUTH_ENDPOINT value '%s': %w", endpoint, err) + } + + switch endpointUrl.Scheme { + case "", "http", "https": + return buildHTTPSExternalAuth(endpoint, key, cert, endpointUrl.Scheme) + case "unix": + return buildLocalIPCExternalAuth(endpoint, key, cert, newSocketTransport) + case "npipe": + return buildLocalIPCExternalAuth(endpoint, key, cert, newPipeTransport) + default: + return auth.ExternalAuthConfiguration{}, fmt.Errorf( + "invalid AZD_AUTH_ENDPOINT value '%s': unsupported scheme %q "+ + "(supported schemes: https, unix, npipe)", + endpoint, endpointUrl.Scheme) + } +} + +// buildHTTPSExternalAuth implements the historical HTTPS / no-scheme path. +// When a cert is provided, the scheme MUST be "https". +func buildHTTPSExternalAuth(endpoint, key, cert, scheme string) (auth.ExternalAuthConfiguration, error) { + client := &http.Client{} + if len(cert) > 0 { + transport, err := httputil.TlsEnabledTransport(cert) + if err != nil { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf("parsing AZD_AUTH_CERT: %w", err) + } + client.Transport = transport + + if scheme != "https" { + return auth.ExternalAuthConfiguration{}, + fmt.Errorf( + "invalid AZD_AUTH_ENDPOINT value '%s': scheme must be 'https' when certificate is provided", + endpoint) + } + } + return auth.ExternalAuthConfiguration{ + Endpoint: endpoint, + Transporter: client, + Key: key, + }, nil +} + +// buildLocalIPCExternalAuth implements the unix: / npipe: paths. Both share +// the same shape: cert is forbidden, key is optional, the transport is built +// by the platform-specific factory, and the endpoint is rewritten to a +// canonical placeholder so RemoteCredential can format request URLs. +func buildLocalIPCExternalAuth( + endpoint, key, cert string, + newTransport func(string) (http.RoundTripper, string, error), +) (auth.ExternalAuthConfiguration, error) { + if len(cert) > 0 { + return auth.ExternalAuthConfiguration{}, fmt.Errorf( + "AZD_AUTH_CERT must not be set when AZD_AUTH_ENDPOINT uses a local IPC scheme " + + "(unix:, npipe:); the OS enforces caller identity") + } + transport, rewritten, err := newTransport(endpoint) + if err != nil { + return auth.ExternalAuthConfiguration{}, err + } + return auth.ExternalAuthConfiguration{ + Endpoint: rewritten, + Transporter: &http.Client{Transport: transport}, + Key: key, + }, nil +} diff --git a/cli/azd/cmd/auth_transport_other.go b/cli/azd/cmd/auth_transport_other.go new file mode 100644 index 00000000000..8b41d83c590 --- /dev/null +++ b/cli/azd/cmd/auth_transport_other.go @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build !unix && !windows + +package cmd + +import ( + "fmt" + "net/http" +) + +// newSocketTransport is not supported on this platform. +func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { + return nil, "", fmt.Errorf( + "AZD_AUTH_ENDPOINT scheme 'unix' is not supported on this platform") +} + +// newPipeTransport is not supported on this platform. +func newPipeTransport(rawURL string) (http.RoundTripper, string, error) { + return nil, "", fmt.Errorf( + "AZD_AUTH_ENDPOINT scheme 'npipe' is not supported on this platform") +} diff --git a/cli/azd/cmd/auth_transport_test.go b/cli/azd/cmd/auth_transport_test.go new file mode 100644 index 00000000000..d07d3ecee32 --- /dev/null +++ b/cli/azd/cmd/auth_transport_test.go @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestBuildExternalAuthConfiguration_Schemes exercises the scheme dispatch in +// buildExternalAuthConfiguration. Per-scheme transport construction (unix +// permission checks, Windows pipe SD checks) is covered by the platform- +// specific tests in auth_transport_unix_test.go / auth_transport_windows_test.go. +func TestBuildExternalAuthConfiguration_Schemes(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + endpoint string + key string + cert string + wantErrSub string // substring expected in error message; empty means no error expected + wantRewrite string // expected Endpoint on success; empty to skip + }{ + { + name: "empty endpoint, no cert preserves backward compat", + endpoint: "", + key: "k", + cert: "", + }, + { + name: "https without cert keeps current behavior (no cert required at config time)", + endpoint: "https://127.0.0.1:1234", + key: "k", + cert: "", + }, + { + name: "http with cert is rejected because cert requires https", + endpoint: "http://127.0.0.1:1234", + key: "k", + cert: "not-a-real-cert", + wantErrSub: "AZD_AUTH_CERT", // cert parse failure fires first + }, + { + name: "http without cert is preserved for backward compat", + endpoint: "http://127.0.0.1:1234", + key: "k", + cert: "", + }, + { + name: "unix scheme rejects cert", + endpoint: "unix:/tmp/some.sock", + key: "k", + cert: "anything", + wantErrSub: "AZD_AUTH_CERT must not be set", + }, + { + name: "npipe scheme rejects cert", + endpoint: "npipe:azd-auth-x", + key: "k", + cert: "anything", + wantErrSub: "AZD_AUTH_CERT must not be set", + }, + { + name: "unknown scheme is refused with a list of supported schemes", + endpoint: "ftp://nope", + key: "k", + cert: "", + wantErrSub: "supported schemes: https, unix, npipe", + }, + { + name: "malformed url is reported", + endpoint: "://broken", + wantErrSub: "invalid AZD_AUTH_ENDPOINT", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := buildExternalAuthConfiguration(tt.endpoint, tt.key, tt.cert) + if tt.wantErrSub != "" { + require.Error(t, err) + require.Contains(t, err.Error(), tt.wantErrSub) + return + } + require.NoError(t, err) + require.NotNil(t, cfg.Transporter) + require.Equal(t, tt.key, cfg.Key) + if tt.wantRewrite != "" { + require.Equal(t, tt.wantRewrite, cfg.Endpoint) + } else { + require.Equal(t, tt.endpoint, cfg.Endpoint) + } + }) + } +} + +// TestRewrittenAuthEndpoint_FormatsValidURL verifies that the placeholder +// endpoint produces a syntactically valid request URL when concatenated by +// RemoteCredential with "/token?api-version=...". +func TestRewrittenAuthEndpoint_FormatsValidURL(t *testing.T) { + t.Parallel() + require.True(t, strings.HasPrefix(rewrittenAuthEndpoint, "http://"), + "placeholder must be an absolute URL so net/http accepts it") + require.NotContains(t, rewrittenAuthEndpoint, " ") +} diff --git a/cli/azd/cmd/auth_transport_unix.go b/cli/azd/cmd/auth_transport_unix.go new file mode 100644 index 00000000000..81baf2ebe17 --- /dev/null +++ b/cli/azd/cmd/auth_transport_unix.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build unix + +package cmd + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "syscall" +) + +// newSocketTransport builds an http.RoundTripper that dispatches requests over +// the Unix domain socket identified by rawURL. The returned string is the +// rewritten endpoint placeholder that should be used in +// auth.ExternalAuthConfiguration.Endpoint. +// +// The socket file and its parent directory MUST be owned by the current uid +// and have group/other bits cleared (mode & 0o077 == 0). If either check +// fails, an error is returned and no transport is constructed. +func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return nil, "", fmt.Errorf("invalid AZD_AUTH_ENDPOINT value %q: %w", rawURL, err) + } + if u.Scheme != "unix" { + return nil, "", fmt.Errorf("internal error: newSocketTransport called with non-unix scheme %q", u.Scheme) + } + + socketPath := u.Path + if socketPath == "" { + // url.Parse puts host of "unix:/foo" into Path but "unix://foo" puts + // "foo" into Host; fall back to Host when Path is empty. + socketPath = u.Host + } + if !filepath.IsAbs(socketPath) { + return nil, "", fmt.Errorf( + "invalid AZD_AUTH_ENDPOINT value %q: unix scheme requires an absolute socket path", rawURL) + } + + if err := verifySocketPermissions(socketPath); err != nil { + return nil, "", err + } + + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", socketPath) + }, + } + return transport, rewrittenAuthEndpoint, nil +} + +// verifySocketPermissions checks that the socket file and its parent +// directory are owned by the current effective uid and have group/other +// permission bits cleared. It returns a clear error when either check fails. +func verifySocketPermissions(socketPath string) error { + parent := filepath.Dir(socketPath) + if err := checkPathOwnedAndRestricted(parent, true); err != nil { + return fmt.Errorf("AZD_AUTH_ENDPOINT socket parent directory %q: %w", parent, err) + } + if err := checkPathOwnedAndRestricted(socketPath, false); err != nil { + return fmt.Errorf("AZD_AUTH_ENDPOINT socket %q: %w", socketPath, err) + } + return nil +} + +// checkPathOwnedAndRestricted verifies path is owned by the current euid and +// has mode bits group/other set to zero. The isDir flag is used only for +// error messages. +func checkPathOwnedAndRestricted(path string, isDir bool) error { + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("stat: %w", err) + } + sys, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("unable to read ownership information") + } + euid := uint32(os.Geteuid()) + if sys.Uid != euid { + kind := "file" + if isDir { + kind = "directory" + } + return fmt.Errorf("permissions too permissive: %s owner uid %d does not match current euid %d", + kind, sys.Uid, euid) + } + if info.Mode().Perm()&0o077 != 0 { + return fmt.Errorf( + "permissions too permissive: mode %#o grants access beyond owner (group/world bits must be 0)", + info.Mode().Perm()) + } + return nil +} + +// newPipeTransport returns an error: named pipes are Windows-only. This stub +// exists so container.go can call it portably. +func newPipeTransport(rawURL string) (http.RoundTripper, string, error) { + return nil, "", fmt.Errorf( + "AZD_AUTH_ENDPOINT scheme 'npipe' is not supported on this platform; use 'unix' or 'https'") +} diff --git a/cli/azd/cmd/auth_transport_unix_test.go b/cli/azd/cmd/auth_transport_unix_test.go new file mode 100644 index 00000000000..fb62bd6c59d --- /dev/null +++ b/cli/azd/cmd/auth_transport_unix_test.go @@ -0,0 +1,121 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build unix + +package cmd + +import ( + "io" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// listenUnixSocket creates a UDS at /azd.sock with mode 0600 inside a +// 0700 parent directory and returns the socket path and the listener. The +// listener is closed automatically. +func listenUnixSocket(t *testing.T) (string, net.Listener) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o700)) + sock := filepath.Join(dir, "azd.sock") + l, err := net.Listen("unix", sock) + require.NoError(t, err) + // net.Listen creates the socket with the process umask; force 0600 for + // determinism. + require.NoError(t, os.Chmod(sock, 0o600)) + t.Cleanup(func() { _ = l.Close() }) + return sock, l +} + +func TestNewSocketTransport_RejectsRelativePath(t *testing.T) { + t.Parallel() + _, _, err := newSocketTransport("unix:relative/socket") + require.Error(t, err) + require.Contains(t, err.Error(), "absolute socket path") +} + +func TestNewSocketTransport_OverlyPermissiveParent(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o755)) + sock := filepath.Join(dir, "azd.sock") + // Create an empty file to stand in for the socket; the permission check + // happens before any connect attempt. + require.NoError(t, os.WriteFile(sock, nil, 0o600)) + + _, _, err := newSocketTransport("unix:" + sock) + require.Error(t, err) + require.Contains(t, err.Error(), "permissions too permissive") +} + +func TestNewSocketTransport_OverlyPermissiveSocket(t *testing.T) { + dir := t.TempDir() + require.NoError(t, os.Chmod(dir, 0o700)) + sock := filepath.Join(dir, "azd.sock") + require.NoError(t, os.WriteFile(sock, nil, 0o644)) + + _, _, err := newSocketTransport("unix:" + sock) + require.Error(t, err) + require.Contains(t, err.Error(), "permissions too permissive") +} + +// TestNewSocketTransport_FullRoundTrip starts an httptest.Server backed by a +// UDS listener and verifies that newSocketTransport produces a transport that +// can reach the server and receive a token-shaped response. +func TestNewSocketTransport_FullRoundTrip(t *testing.T) { + sock, l := listenUnixSocket(t) + + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The endpoint placeholder is "http://azd-auth"; the request path + // must remain "/token" with the api-version query. + if !strings.HasPrefix(r.URL.Path, "/token") { + http.Error(w, "unexpected path", http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, + `{"status":"success","token":"tok","expiresOn":"2099-01-01T00:00:00Z"}`) + })) + srv.Listener = l + srv.Start() + t.Cleanup(srv.Close) + + rt, endpoint, err := newSocketTransport("unix:" + sock) + require.NoError(t, err) + require.Equal(t, rewrittenAuthEndpoint, endpoint) + + client := &http.Client{Transport: rt} + resp, err := client.Get(endpoint + "/token?api-version=2023-07-12-preview") + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.Contains(t, string(body), `"token":"tok"`) +} + +// TestNewSocketTransport_HappyPath verifies that a properly-secured socket +// produces a transport without error (independent of an actual server). +func TestNewSocketTransport_HappyPath(t *testing.T) { + sock, _ := listenUnixSocket(t) + rt, endpoint, err := newSocketTransport("unix:" + sock) + require.NoError(t, err) + require.NotNil(t, rt) + require.Equal(t, rewrittenAuthEndpoint, endpoint) +} + +// TestNewPipeTransport_NotSupportedOnUnix asserts the npipe stub returns a +// clear error on POSIX. +func TestNewPipeTransport_NotSupportedOnUnix(t *testing.T) { + t.Parallel() + _, _, err := newPipeTransport("npipe:azd-auth-x") + require.Error(t, err) + require.Contains(t, err.Error(), "not supported on this platform") +} diff --git a/cli/azd/cmd/auth_transport_windows.go b/cli/azd/cmd/auth_transport_windows.go new file mode 100644 index 00000000000..f5b3ae84ba1 --- /dev/null +++ b/cli/azd/cmd/auth_transport_windows.go @@ -0,0 +1,172 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package cmd + +import ( + "context" + "fmt" + "net" + "net/http" + "net/url" + "os/user" + "regexp" + "strings" + + "github.com/Microsoft/go-winio" + "golang.org/x/sys/windows" +) + +// newPipeTransport builds an http.RoundTripper that dispatches requests over +// the Windows named pipe identified by rawURL. The returned string is the +// rewritten endpoint placeholder. +// +// The pipe's security descriptor MUST grant access only to the current user +// SID, plus the conventional SYSTEM / Administrators principals. If any other +// SID has an allow ACE, an error is returned. +func newPipeTransport(rawURL string) (http.RoundTripper, string, error) { + pipePath, err := normalizePipePath(rawURL) + if err != nil { + return nil, "", err + } + + transport := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + conn, err := winio.DialPipeContext(ctx, pipePath) + if err != nil { + return nil, err + } + if verr := verifyPipeSecurity(pipePath); verr != nil { + _ = conn.Close() + return nil, verr + } + return conn, nil + }, + } + return transport, rewrittenAuthEndpoint, nil +} + +// newSocketTransport returns an error: unix domain sockets are not supported +// on Windows. +func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { + return nil, "", fmt.Errorf( + "AZD_AUTH_ENDPOINT scheme 'unix' is not supported on this platform; use 'npipe' or 'https'") +} + +// normalizePipePath accepts either short form `npipe:azd-auth-...` or long +// form `npipe:////./pipe/azd-auth-...` and returns a fully qualified pipe +// path of the form `\\.\pipe\`. +func normalizePipePath(rawURL string) (string, error) { + u, err := url.Parse(rawURL) + if err != nil { + return "", fmt.Errorf("invalid AZD_AUTH_ENDPOINT value %q: %w", rawURL, err) + } + if u.Scheme != "npipe" { + return "", fmt.Errorf("internal error: normalizePipePath called with non-npipe scheme %q", u.Scheme) + } + + // Long form: npipe:////./pipe/ -> Host="." Path="/pipe/" + if u.Host == "." && strings.HasPrefix(u.Path, "/pipe/") { + name := strings.TrimPrefix(u.Path, "/pipe/") + if name == "" { + return "", fmt.Errorf("invalid AZD_AUTH_ENDPOINT value %q: missing pipe name", rawURL) + } + return `\\.\pipe\` + name, nil + } + + // Short form: npipe: -> Opaque="" + if u.Opaque != "" { + return `\\.\pipe\` + u.Opaque, nil + } + + // Fallback: short form without colon-opaque, e.g. npipe:/ + name := strings.TrimPrefix(u.Path, "/") + if name == "" { + return "", fmt.Errorf("invalid AZD_AUTH_ENDPOINT value %q: missing pipe name", rawURL) + } + return `\\.\pipe\` + name, nil +} + +// sddlAceSidRE captures the trailing SID of each ACE in an SDDL DACL string. +// An ACE has the form "(type;flags;rights;object;inherit_object;account_sid)". +// We only care about the account_sid component, which appears after the last +// semicolon and before the closing parenthesis. +var sddlAceSidRE = regexp.MustCompile(`\(([^)]*)\)`) + +// verifyPipeSecurity queries the DACL of the named pipe and refuses if any +// allow ACE references a SID outside the current user / SYSTEM / +// Administrators set. +func verifyPipeSecurity(pipePath string) error { + sd, err := windows.GetNamedSecurityInfo( + pipePath, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("querying pipe security descriptor: %w", err) + } + + // Render the DACL as SDDL so we can enumerate ACEs without taking a + // direct dependency on raw Win32 ACE parsing. + sddl := sd.String() + + cur, err := user.Current() + if err != nil { + return fmt.Errorf("looking up current user: %w", err) + } + allowedSids := map[string]struct{}{ + strings.ToUpper(cur.Uid): {}, + } + // Well-known SIDs that are always acceptable per spec. + for _, wk := range []windows.WELL_KNOWN_SID_TYPE{ + windows.WinLocalSystemSid, + windows.WinBuiltinAdministratorsSid, + } { + s, err := windows.CreateWellKnownSid(wk) + if err == nil { + allowedSids[strings.ToUpper(s.String())] = struct{}{} + } + } + // SDDL short forms for SYSTEM ("SY"), Administrators ("BA"), and Local + // Administrators ("LA") are also acceptable. + for _, short := range []string{"SY", "BA", "LA"} { + allowedSids[short] = struct{}{} + } + + // Extract the DACL substring. An SDDL is of the form + // "O:G:D:S:"; the D: section contains the ACEs + // we care about. + daclIdx := strings.Index(sddl, "D:") + if daclIdx < 0 { + // No DACL section at all means the DACL was NULL (full access for + // everyone) — refuse. + return fmt.Errorf("permissions too permissive: pipe %q has no DACL", pipePath) + } + daclStr := sddl[daclIdx:] + if sIdx := strings.Index(daclStr, "S:"); sIdx >= 0 { + daclStr = daclStr[:sIdx] + } + + for _, m := range sddlAceSidRE.FindAllStringSubmatch(daclStr, -1) { + parts := strings.Split(m[1], ";") + if len(parts) < 6 { + continue + } + aceType := strings.ToUpper(strings.TrimSpace(parts[0])) + // Only ACCESS_ALLOWED_ACE ("A") and ACCESS_ALLOWED_OBJECT_ACE ("OA") + // grant access; the spec is concerned with allow ACEs. + if aceType != "A" && aceType != "OA" { + continue + } + sidStr := strings.ToUpper(strings.TrimSpace(parts[5])) + if _, ok := allowedSids[sidStr]; ok { + continue + } + return fmt.Errorf( + "permissions too permissive: pipe %q grants access to SID %q outside the current user/SYSTEM/Administrators", + pipePath, parts[5]) + } + return nil +} diff --git a/cli/azd/cmd/auth_transport_windows_test.go b/cli/azd/cmd/auth_transport_windows_test.go new file mode 100644 index 00000000000..490bfd6f64a --- /dev/null +++ b/cli/azd/cmd/auth_transport_windows_test.go @@ -0,0 +1,45 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNormalizePipePath(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + in string + want string + wantErr bool + }{ + {name: "short form opaque", in: "npipe:azd-auth-foo", want: `\\.\pipe\azd-auth-foo`}, + {name: "long form", in: "npipe:////./pipe/azd-auth-foo", want: `\\.\pipe\azd-auth-foo`}, + {name: "missing name", in: "npipe:", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := normalizePipePath(tt.in) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestNewSocketTransport_NotSupportedOnWindows(t *testing.T) { + t.Parallel() + _, _, err := newSocketTransport("unix:/tmp/x.sock") + require.Error(t, err) + require.Contains(t, err.Error(), "not supported on this platform") +} diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index d59edf95c6c..5b60f02342a 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -9,7 +9,6 @@ import ( "io" "log" "net/http" - "net/url" "os" "slices" "strings" @@ -50,7 +49,7 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/helm" - "github.com/azure/azure-dev/cli/azd/pkg/httputil" + "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -624,36 +623,11 @@ func registerCommonDependencies(container *ioc.NestedContainer) { container.MustRegisterSingleton(config.NewManager) container.MustRegisterSingleton(config.NewFileConfigManager) container.MustRegisterScoped(func() (auth.ExternalAuthConfiguration, error) { - cert := os.Getenv("AZD_AUTH_CERT") - endpoint := os.Getenv("AZD_AUTH_ENDPOINT") - key := os.Getenv("AZD_AUTH_KEY") - - client := &http.Client{} - if len(cert) > 0 { - transport, err := httputil.TlsEnabledTransport(cert) - if err != nil { - return auth.ExternalAuthConfiguration{}, - fmt.Errorf("parsing AZD_AUTH_CERT: %w", err) - } - client.Transport = transport - - endpointUrl, err := url.Parse(endpoint) - if err != nil { - return auth.ExternalAuthConfiguration{}, - fmt.Errorf("invalid AZD_AUTH_ENDPOINT value '%s': %w", endpoint, err) - } - - if endpointUrl.Scheme != "https" { - return auth.ExternalAuthConfiguration{}, - fmt.Errorf("invalid AZD_AUTH_ENDPOINT value '%s': scheme must be 'https' when certificate is provided", - endpoint) - } - } - return auth.ExternalAuthConfiguration{ - Endpoint: endpoint, - Transporter: client, - Key: key, - }, nil + return buildExternalAuthConfiguration( + os.Getenv("AZD_AUTH_ENDPOINT"), + os.Getenv("AZD_AUTH_KEY"), + os.Getenv("AZD_AUTH_CERT"), + ) }) container.MustRegisterSingleton(func() auth.UserAgent { return auth.UserAgent(internal.UserAgent()) diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 684c73e86e8..d0a5c4c64db 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -115,9 +115,9 @@ Variables for [External Authentication](./external-authentication.md) integratio | Variable | Description | | --- | --- | -| `AZD_AUTH_ENDPOINT` | The [External Authentication](./external-authentication.md) endpoint. | -| `AZD_AUTH_KEY` | The [External Authentication](./external-authentication.md) shared key. | -| `AZD_AUTH_CERT` | The [External Authentication](./external-authentication.md) client certificate, provided as a base64-encoded DER certificate string. When set, `AZD_AUTH_ENDPOINT` must use HTTPS. | +| `AZD_AUTH_ENDPOINT` | The [External Authentication](./external-authentication.md) endpoint. Accepts `https://host:port` (loopback HTTPS, requires `AZD_AUTH_CERT`), `unix:/absolute/path/to/socket` (POSIX-only Unix domain socket; cert/key not applicable), or `npipe:` / `npipe:////./pipe/` (Windows-only named pipe; cert/key not applicable). | +| `AZD_AUTH_KEY` | The [External Authentication](./external-authentication.md) shared key. Required for `https:`; optional for `unix:` and `npipe:` (the OS enforces caller identity), but still forwarded as `Authorization: Bearer` when provided. | +| `AZD_AUTH_CERT` | The [External Authentication](./external-authentication.md) client certificate, provided as a base64-encoded DER certificate string. Required when `AZD_AUTH_ENDPOINT` uses `https:`. MUST NOT be set when `AZD_AUTH_ENDPOINT` uses `unix:` or `npipe:`. | ## Tool Configuration diff --git a/cli/azd/docs/external-authentication.md b/cli/azd/docs/external-authentication.md index 2d20c63e384..c2c7571f7fa 100644 --- a/cli/azd/docs/external-authentication.md +++ b/cli/azd/docs/external-authentication.md @@ -70,7 +70,74 @@ The server should take this request and fetch a token using the given configurat The message is returned as is as the `error` for the `GetToken` call on the client side. -## Implementation +## Transport selection via URL scheme + +Starting with `azd` 1.26, `AZD_AUTH_ENDPOINT` accepts three URL schemes that +select the transport used to reach the host's token server. The HTTP request +body, response shape, and `api-version` are identical across schemes — only +how `azd` dials the server changes. + +### `https:` (existing) + +Loopback HTTPS server. + +- URL form: `https://host:port` (host is typically `127.0.0.1`). +- `AZD_AUTH_CERT` is **required** and must be a base64-encoded DER X.509 + certificate that the host's HTTPS server presents. `azd` pins the + connection to this certificate. +- `AZD_AUTH_KEY` is **required** and is sent as `Authorization: Bearer `. +- `azd` rejects the `https:` scheme when no cert is provided. + +### `unix:` (new, POSIX only) + +Unix domain socket transport. The OS enforces caller identity via filesystem +permissions, so no TLS handshake and no shared bearer secret are required. + +- URL form: `unix:/absolute/path/to/socket` or + `unix:///absolute/path/to/socket`. The socket path is taken from the URL's + path component. Relative paths are an error. +- `AZD_AUTH_CERT` **MUST NOT** be set. If set, `azd` fails fast with a clear + error. +- `AZD_AUTH_KEY` **MAY** be omitted. If set, `azd` still sends it as + `Authorization: Bearer ` for defense in depth. +- **IDE host requirements:** the socket file MUST be created with mode `0600` + and the parent directory MUST be mode `0700`, both owned by the current + uid. `azd` `stat()`s the socket and the parent directory on first connect + and refuses if either is group- or world-accessible, or if the owner + differs from the `azd` process's effective uid. The connection fails with + a clear "permissions too permissive" error. +- The HTTP request line still targets `/token?api-version=2023-07-12-preview`; + the URL host is irrelevant and `azd` rewrites the request URL to + `http://azd-auth/token?...` before dispatch. +- Path length: callers should be aware of OS limits (108 bytes on Linux, 104 + on macOS including the null terminator). + +### `npipe:` (new, Windows only) + +Windows named pipe transport. The OS enforces caller identity via the pipe's +security descriptor, so no TLS handshake and no shared bearer secret are +required. + +- URL form: `npipe:azd-auth-` (the value after `npipe:` is the + pipe name; `azd` prepends `\\.\pipe\` automatically) **or** + `npipe:////./pipe/azd-auth-` (fully qualified). Both forms are + accepted. +- `AZD_AUTH_CERT` **MUST NOT** be set. Same handling as `unix:`. +- `AZD_AUTH_KEY` **MAY** be omitted. Same handling as `unix:`. +- **IDE host requirements:** the pipe MUST be created with a security + descriptor that grants access only to the current user SID (and SYSTEM / + Administrators, as is conventional). `azd` queries the pipe's DACL after + connecting and refuses if any other SID has an allow ACE. The connection + fails with a clear "permissions too permissive" error. +- Same `/token?api-version=...` and URL-rewrite behavior as `unix:`. + +### Backward compatibility + +An `AZD_AUTH_ENDPOINT` without a scheme, or with `https:`, behaves exactly as +it always has. No existing IDE host configuration is broken by the addition +of `unix:` and `npipe:`. + + The `azd` CLI implements the client side of this feature in the [`pkg/auth/remote_credential.go`](../pkg/auth/remote_credential.go). diff --git a/cli/azd/go.mod b/cli/azd/go.mod index c0cbea65d01..7b4476ce82c 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -96,6 +96,7 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect diff --git a/cli/azd/go.sum b/cli/azd/go.sum index e4d6a4cd868..cf2e56f6d74 100644 --- a/cli/azd/go.sum +++ b/cli/azd/go.sum @@ -73,6 +73,8 @@ github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZ github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s= github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w= github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b h1:g9SuFmxM/WucQFKTMSP+irxyf5m0RiUJreBDhGI6jSA= diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 241eda4f1cb..8341eadbce4 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -53,9 +53,9 @@ Set by IDE hosts (VS Code, Visual Studio) when spawning azd as a subprocess. Use | Variable | Description | |---|---| -| `AZD_AUTH_ENDPOINT` | Authentication endpoint URL set by IDE hosts for integrated authentication | -| `AZD_AUTH_KEY` | Authentication key set by IDE hosts for integrated authentication | -| `AZD_AUTH_CERT` | Authentication certificate/TLS trust configuration set by IDE hosts | +| `AZD_AUTH_ENDPOINT` | Authentication endpoint URL set by IDE hosts for integrated authentication. Supports `https://host:port` (loopback HTTPS), `unix:/path/to/socket` (POSIX-only Unix domain socket), and `npipe:` (Windows-only named pipe). | +| `AZD_AUTH_KEY` | Authentication key set by IDE hosts for integrated authentication. Required for `https:`; optional for `unix:` / `npipe:`. | +| `AZD_AUTH_CERT` | Authentication certificate/TLS trust configuration set by IDE hosts. Required for `https:`; must NOT be set for `unix:` / `npipe:`. | For details on the external authentication protocol, see [cli/azd/docs/external-authentication.md](../../cli/azd/docs/external-authentication.md). From 5f5343759ef338aa02638951ab084e981095f73c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:11:18 +0000 Subject: [PATCH 02/11] Address review feedback: tidy imports and docs spacing Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/container.go | 1 - cli/azd/docs/external-authentication.md | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/azd/cmd/container.go b/cli/azd/cmd/container.go index 5b60f02342a..eb3942a4659 100644 --- a/cli/azd/cmd/container.go +++ b/cli/azd/cmd/container.go @@ -49,7 +49,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/helm" - "github.com/azure/azure-dev/cli/azd/pkg/infra" "github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning" "github.com/azure/azure-dev/cli/azd/pkg/input" diff --git a/cli/azd/docs/external-authentication.md b/cli/azd/docs/external-authentication.md index c2c7571f7fa..79570cfaa97 100644 --- a/cli/azd/docs/external-authentication.md +++ b/cli/azd/docs/external-authentication.md @@ -137,7 +137,7 @@ An `AZD_AUTH_ENDPOINT` without a scheme, or with `https:`, behaves exactly as it always has. No existing IDE host configuration is broken by the addition of `unix:` and `npipe:`. - +## Implementation The `azd` CLI implements the client side of this feature in the [`pkg/auth/remote_credential.go`](../pkg/auth/remote_credential.go). From 4750628ca24ede9c3efcafdddec2955dcd16a520 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:27:03 +0000 Subject: [PATCH 03/11] Replace SDDL regex parsing with structured ACE walk Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/auth_transport_windows.go | 138 +++++++++++++++----------- 1 file changed, 79 insertions(+), 59 deletions(-) diff --git a/cli/azd/cmd/auth_transport_windows.go b/cli/azd/cmd/auth_transport_windows.go index f5b3ae84ba1..d37762cc2ec 100644 --- a/cli/azd/cmd/auth_transport_windows.go +++ b/cli/azd/cmd/auth_transport_windows.go @@ -11,9 +11,8 @@ import ( "net" "net/http" "net/url" - "os/user" - "regexp" "strings" + "unsafe" "github.com/Microsoft/go-winio" "golang.org/x/sys/windows" @@ -89,15 +88,10 @@ func normalizePipePath(rawURL string) (string, error) { return `\\.\pipe\` + name, nil } -// sddlAceSidRE captures the trailing SID of each ACE in an SDDL DACL string. -// An ACE has the form "(type;flags;rights;object;inherit_object;account_sid)". -// We only care about the account_sid component, which appears after the last -// semicolon and before the closing parenthesis. -var sddlAceSidRE = regexp.MustCompile(`\(([^)]*)\)`) - // verifyPipeSecurity queries the DACL of the named pipe and refuses if any // allow ACE references a SID outside the current user / SYSTEM / -// Administrators set. +// Administrators set. ACEs are walked structurally via windows.GetAce rather +// than by parsing the SDDL string representation. func verifyPipeSecurity(pipePath string) error { sd, err := windows.GetNamedSecurityInfo( pipePath, @@ -108,65 +102,91 @@ func verifyPipeSecurity(pipePath string) error { return fmt.Errorf("querying pipe security descriptor: %w", err) } - // Render the DACL as SDDL so we can enumerate ACEs without taking a - // direct dependency on raw Win32 ACE parsing. - sddl := sd.String() - - cur, err := user.Current() + dacl, _, err := sd.DACL() if err != nil { - return fmt.Errorf("looking up current user: %w", err) - } - allowedSids := map[string]struct{}{ - strings.ToUpper(cur.Uid): {}, - } - // Well-known SIDs that are always acceptable per spec. - for _, wk := range []windows.WELL_KNOWN_SID_TYPE{ - windows.WinLocalSystemSid, - windows.WinBuiltinAdministratorsSid, - } { - s, err := windows.CreateWellKnownSid(wk) - if err == nil { - allowedSids[strings.ToUpper(s.String())] = struct{}{} - } + return fmt.Errorf("reading DACL: %w", err) } - // SDDL short forms for SYSTEM ("SY"), Administrators ("BA"), and Local - // Administrators ("LA") are also acceptable. - for _, short := range []string{"SY", "BA", "LA"} { - allowedSids[short] = struct{}{} + // A nil DACL means full access for everyone — refuse. + if dacl == nil { + return fmt.Errorf("permissions too permissive: pipe %q has a NULL DACL", pipePath) } - // Extract the DACL substring. An SDDL is of the form - // "O:G:D:S:"; the D: section contains the ACEs - // we care about. - daclIdx := strings.Index(sddl, "D:") - if daclIdx < 0 { - // No DACL section at all means the DACL was NULL (full access for - // everyone) — refuse. - return fmt.Errorf("permissions too permissive: pipe %q has no DACL", pipePath) + currentUserSid, err := currentProcessUserSid() + if err != nil { + return fmt.Errorf("looking up current user SID: %w", err) + } + systemSid, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid) + if err != nil { + return fmt.Errorf("creating SYSTEM SID: %w", err) } - daclStr := sddl[daclIdx:] - if sIdx := strings.Index(daclStr, "S:"); sIdx >= 0 { - daclStr = daclStr[:sIdx] + adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return fmt.Errorf("creating Administrators SID: %w", err) } + allowedSids := []*windows.SID{currentUserSid, systemSid, adminsSid} - for _, m := range sddlAceSidRE.FindAllStringSubmatch(daclStr, -1) { - parts := strings.Split(m[1], ";") - if len(parts) < 6 { - continue - } - aceType := strings.ToUpper(strings.TrimSpace(parts[0])) - // Only ACCESS_ALLOWED_ACE ("A") and ACCESS_ALLOWED_OBJECT_ACE ("OA") - // grant access; the spec is concerned with allow ACEs. - if aceType != "A" && aceType != "OA" { - continue + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var aceHdr *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &aceHdr); err != nil { + return fmt.Errorf("reading ACE %d: %w", i, err) } - sidStr := strings.ToUpper(strings.TrimSpace(parts[5])) - if _, ok := allowedSids[sidStr]; ok { - continue + // Only ACCESS_ALLOWED_ACE_TYPE (and its callback variant) grant + // access via the layout exposed by ACCESS_ALLOWED_ACE. Deny / audit + // ACEs are ignored — they do not widen access. Object ACE types + // (used for AD) are not expected on a named pipe; if encountered, + // refuse defensively because their SID lives at a different offset. + switch aceHdr.Header.AceType { + case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackAceType: + sid := (*windows.SID)(unsafe.Pointer(&aceHdr.SidStart)) + if !sidInList(sid, allowedSids) { + return fmt.Errorf( + "permissions too permissive: pipe %q grants access to SID %q "+ + "outside the current user/SYSTEM/Administrators", + pipePath, sid.String()) + } + case accessAllowedObjectAceType, accessAllowedCallbackObjectAceType: + return fmt.Errorf( + "permissions too permissive: pipe %q has an object allow ACE which is not supported", + pipePath) + default: + // Deny / audit / other ACE types do not grant access; skip. } - return fmt.Errorf( - "permissions too permissive: pipe %q grants access to SID %q outside the current user/SYSTEM/Administrators", - pipePath, parts[5]) } return nil } + +// AceType constants not (yet) exposed by golang.org/x/sys/windows. +// See https://learn.microsoft.com/windows/win32/api/winnt/ns-winnt-ace_header. +const ( + accessAllowedObjectAceType uint8 = 0x05 + accessAllowedCallbackAceType uint8 = 0x09 + accessAllowedCallbackObjectAceType uint8 = 0x0B +) + +// currentProcessUserSid returns the SID of the user owning the current +// process token. This is preferred over user.Current() because it avoids a +// roundtrip through string parsing and reflects the actual access token. +func currentProcessUserSid() (*windows.SID, error) { + var token windows.Token + if err := windows.OpenProcessToken( + windows.CurrentProcess(), windows.TOKEN_QUERY, &token); err != nil { + return nil, err + } + defer token.Close() + tu, err := token.GetTokenUser() + if err != nil { + return nil, err + } + // Copy the SID off the token-owned buffer so it remains valid after + // token.Close(). + return tu.User.Sid.Copy() +} + +func sidInList(sid *windows.SID, list []*windows.SID) bool { + for _, s := range list { + if windows.EqualSid(sid, s) { + return true + } + } + return false +} From 333bd2b41a85e4b6e7ee2ca02b001a92cf763e1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 26 May 2026 18:28:07 +0000 Subject: [PATCH 04/11] Clarify ACE walk: rename, document unsafe cast, improve error Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/auth_transport_windows.go | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/cli/azd/cmd/auth_transport_windows.go b/cli/azd/cmd/auth_transport_windows.go index d37762cc2ec..cbeb18ebf00 100644 --- a/cli/azd/cmd/auth_transport_windows.go +++ b/cli/azd/cmd/auth_transport_windows.go @@ -126,18 +126,19 @@ func verifyPipeSecurity(pipePath string) error { allowedSids := []*windows.SID{currentUserSid, systemSid, adminsSid} for i := uint32(0); i < uint32(dacl.AceCount); i++ { - var aceHdr *windows.ACCESS_ALLOWED_ACE - if err := windows.GetAce(dacl, i, &aceHdr); err != nil { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &ace); err != nil { return fmt.Errorf("reading ACE %d: %w", i, err) } - // Only ACCESS_ALLOWED_ACE_TYPE (and its callback variant) grant - // access via the layout exposed by ACCESS_ALLOWED_ACE. Deny / audit - // ACEs are ignored — they do not widen access. Object ACE types - // (used for AD) are not expected on a named pipe; if encountered, - // refuse defensively because their SID lives at a different offset. - switch aceHdr.Header.AceType { + // For ACCESS_ALLOWED_ACE_TYPE and its callback variant, the ACE + // layout starts with ACE_HEADER + ACCESS_MASK and is immediately + // followed by the SID in place; ACCESS_ALLOWED_ACE.SidStart marks + // that first SID byte. The unsafe cast below is only valid for + // those two AceType values — object ACEs interleave GUID fields + // before the SID and are handled separately. + switch ace.Header.AceType { case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackAceType: - sid := (*windows.SID)(unsafe.Pointer(&aceHdr.SidStart)) + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) if !sidInList(sid, allowedSids) { return fmt.Errorf( "permissions too permissive: pipe %q grants access to SID %q "+ @@ -146,7 +147,8 @@ func verifyPipeSecurity(pipePath string) error { } case accessAllowedObjectAceType, accessAllowedCallbackObjectAceType: return fmt.Errorf( - "permissions too permissive: pipe %q has an object allow ACE which is not supported", + "permissions too permissive: pipe %q has an Active Directory-style object "+ + "allow ACE which is not expected on a named pipe", pipePath) default: // Deny / audit / other ACE types do not grant access; skip. From 1d279793e99be68c7e2ce47884ae0a133e133e69 Mon Sep 17 00:00:00 2001 From: "Brandon Waterloo [MSFT]" <36966225+bwateratmsft@users.noreply.github.com> Date: Thu, 4 Jun 2026 08:59:37 -0400 Subject: [PATCH 05/11] Fix spelling issues --- .vscode/cspell.global.yaml | 4 ++++ cli/azd/cmd/auth_transport_windows.go | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.vscode/cspell.global.yaml b/.vscode/cspell.global.yaml index 3160adfca1e..2054708b45a 100644 --- a/.vscode/cspell.global.yaml +++ b/.vscode/cspell.global.yaml @@ -65,12 +65,14 @@ ignoreWords: - conjunction - containerregistry - containerservice + - dacl - dapr - databricks - dedb - devcenter - devcontainer - dnsz + - euid - evgd - evgs - evgt @@ -105,6 +107,7 @@ ignoreWords: - mockexec - mockhttp - MYDIR + - npipe - nsgsr - ntfns - odata @@ -169,6 +172,7 @@ ignoreWords: - wafrg - westus - Wans + - winio - apim - Retryable - httptrigger diff --git a/cli/azd/cmd/auth_transport_windows.go b/cli/azd/cmd/auth_transport_windows.go index cbeb18ebf00..fbb72ddc1a4 100644 --- a/cli/azd/cmd/auth_transport_windows.go +++ b/cli/azd/cmd/auth_transport_windows.go @@ -37,9 +37,9 @@ func newPipeTransport(rawURL string) (http.RoundTripper, string, error) { if err != nil { return nil, err } - if verr := verifyPipeSecurity(pipePath); verr != nil { + if vErr := verifyPipeSecurity(pipePath); vErr != nil { _ = conn.Close() - return nil, verr + return nil, vErr } return conn, nil }, From 2e9c0f21dfa3152700b4f2ac96a58d40067d229d Mon Sep 17 00:00:00 2001 From: "Brandon Waterloo [MSFT]" <36966225+bwateratmsft@users.noreply.github.com> Date: Thu, 4 Jun 2026 09:03:14 -0400 Subject: [PATCH 06/11] Missed one --- .vscode/cspell.global.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.vscode/cspell.global.yaml b/.vscode/cspell.global.yaml index 2054708b45a..09814559402 100644 --- a/.vscode/cspell.global.yaml +++ b/.vscode/cspell.global.yaml @@ -136,6 +136,7 @@ ignoreWords: - resourcegroupterraform - resourcegroupterraformremote - Retryable + - sddl - serviceendpoint - serviceprincipalid - serviceprincipalkey From 850e8a949a223068cbed415f3623866b11040f12 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:12:54 +0000 Subject: [PATCH 07/11] Fix gosec findings in unix auth transport checks Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/auth_transport_unix.go | 11 ++++++++--- cli/azd/cmd/auth_transport_unix_test.go | 3 ++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/cli/azd/cmd/auth_transport_unix.go b/cli/azd/cmd/auth_transport_unix.go index 81baf2ebe17..4eb7d465ec5 100644 --- a/cli/azd/cmd/auth_transport_unix.go +++ b/cli/azd/cmd/auth_transport_unix.go @@ -75,7 +75,12 @@ func verifySocketPermissions(socketPath string) error { // has mode bits group/other set to zero. The isDir flag is used only for // error messages. func checkPathOwnedAndRestricted(path string, isDir bool) error { - info, err := os.Stat(path) + cleanPath := filepath.Clean(path) + if !filepath.IsAbs(cleanPath) { + return fmt.Errorf("path must be absolute") + } + + info, err := os.Stat(cleanPath) if err != nil { return fmt.Errorf("stat: %w", err) } @@ -83,8 +88,8 @@ func checkPathOwnedAndRestricted(path string, isDir bool) error { if !ok { return fmt.Errorf("unable to read ownership information") } - euid := uint32(os.Geteuid()) - if sys.Uid != euid { + euid := os.Geteuid() + if int64(sys.Uid) != int64(euid) { kind := "file" if isDir { kind = "directory" diff --git a/cli/azd/cmd/auth_transport_unix_test.go b/cli/azd/cmd/auth_transport_unix_test.go index fb62bd6c59d..576a35b6290 100644 --- a/cli/azd/cmd/auth_transport_unix_test.go +++ b/cli/azd/cmd/auth_transport_unix_test.go @@ -59,7 +59,8 @@ func TestNewSocketTransport_OverlyPermissiveSocket(t *testing.T) { dir := t.TempDir() require.NoError(t, os.Chmod(dir, 0o700)) sock := filepath.Join(dir, "azd.sock") - require.NoError(t, os.WriteFile(sock, nil, 0o644)) + require.NoError(t, os.WriteFile(sock, nil, 0o600)) + require.NoError(t, os.Chmod(sock, 0o644)) _, _, err := newSocketTransport("unix:" + sock) require.Error(t, err) From 27fcf13b5d63c13879f271b96bebec58e1e3ad0a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:33:53 +0000 Subject: [PATCH 08/11] fix: document audited windows ACE SID cast Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/auth_transport_windows.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cli/azd/cmd/auth_transport_windows.go b/cli/azd/cmd/auth_transport_windows.go index fbb72ddc1a4..8eb226d2906 100644 --- a/cli/azd/cmd/auth_transport_windows.go +++ b/cli/azd/cmd/auth_transport_windows.go @@ -138,7 +138,10 @@ func verifyPipeSecurity(pipePath string) error { // before the SID and are handled separately. switch ace.Header.AceType { case windows.ACCESS_ALLOWED_ACE_TYPE, accessAllowedCallbackAceType: - sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + sid, err := accessAllowedAceSid(ace) + if err != nil { + return fmt.Errorf("reading SID from ACE %d: %w", i, err) + } if !sidInList(sid, allowedSids) { return fmt.Errorf( "permissions too permissive: pipe %q grants access to SID %q "+ @@ -165,6 +168,15 @@ const ( accessAllowedCallbackObjectAceType uint8 = 0x0B ) +func accessAllowedAceSid(ace *windows.ACCESS_ALLOWED_ACE) (*windows.SID, error) { + //nolint:gosec // Win32 ACCESS_ALLOWED_ACE stores the SID inline at SidStart. + sid := (*windows.SID)(unsafe.Pointer(&ace.SidStart)) + if !sid.IsValid() { + return nil, fmt.Errorf("invalid SID in access-allowed ACE type %d", ace.Header.AceType) + } + return sid, nil +} + // currentProcessUserSid returns the SID of the user owning the current // process token. This is preferred over user.Current() because it avoids a // roundtrip through string parsing and reflects the actual access token. From 36e2167f9aa4305e3202adc313d3815b89c44453 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:00:09 +0000 Subject: [PATCH 09/11] Require AZD_AUTH_KEY universally, require cert for https, reject symlinked sockets Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/CHANGELOG.md | 2 +- cli/azd/cmd/auth_transport.go | 28 ++++++++++++++++++------- cli/azd/cmd/auth_transport_test.go | 16 ++++++++++---- cli/azd/cmd/auth_transport_unix.go | 14 ++++++++++++- cli/azd/cmd/auth_transport_unix_test.go | 18 ++++++++++++++++ cli/azd/docs/environment-variables.md | 4 ++-- cli/azd/docs/external-authentication.md | 8 ++++--- cli/azd/go.mod | 2 +- docs/reference/environment-variables.md | 2 +- 9 files changed, 74 insertions(+), 20 deletions(-) diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index c522d6adb63..08a672fa524 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -4,7 +4,7 @@ ### Features Added -- Add Unix domain socket (`unix:`) and Windows named pipe (`npipe:`) transports to the External Authentication protocol. IDE hosts can now set `AZD_AUTH_ENDPOINT` to `unix:/path/to/socket` (POSIX) or `npipe:` (Windows) so `azd` reaches the host's token server over a local IPC channel where the OS enforces caller identity, removing the need for a loopback HTTPS server, a self-signed certificate, and a shared bearer key. The existing `https:` flow is unchanged. See [External Authentication](./docs/external-authentication.md) for the spec. +- Add Unix domain socket (`unix:`) and Windows named pipe (`npipe:`) transports to the External Authentication protocol. IDE hosts can now set `AZD_AUTH_ENDPOINT` to `unix:/path/to/socket` (POSIX) or `npipe:` (Windows) so `azd` reaches the host's token server over a local IPC channel where the OS enforces caller identity, removing the need for a loopback HTTPS server and a self-signed certificate (`AZD_AUTH_KEY` is still required). The existing `https:` flow is unchanged. See [External Authentication](./docs/external-authentication.md) for the spec. ### Breaking Changes diff --git a/cli/azd/cmd/auth_transport.go b/cli/azd/cmd/auth_transport.go index 803beda9700..a1a4b551c0b 100644 --- a/cli/azd/cmd/auth_transport.go +++ b/cli/azd/cmd/auth_transport.go @@ -24,11 +24,14 @@ const rewrittenAuthEndpoint = "http://azd-auth" // endpoint URL: // // - "" or "https": existing loopback HTTPS behavior. AZD_AUTH_CERT is -// required for "https". +// required for "https". AZD_AUTH_KEY is required. // - "unix": POSIX-only Unix domain socket transport. Cert MUST NOT be set. -// Key is optional but still forwarded for defense in depth. -// - "npipe": Windows-only named pipe transport. Cert MUST NOT be set. Key -// is optional but still forwarded for defense in depth. +// AZD_AUTH_KEY is required. +// - "npipe": Windows-only named pipe transport. Cert MUST NOT be set. +// AZD_AUTH_KEY is required. +// +// The "" and "http" schemes are accepted only to preserve the existing +// loopback test harness; production hosts use "https", "unix", or "npipe". // // Any other scheme yields an error that lists the supported schemes. func buildExternalAuthConfiguration(endpoint, key, cert string) (auth.ExternalAuthConfiguration, error) { @@ -52,14 +55,21 @@ func buildExternalAuthConfiguration(endpoint, key, cert string) (auth.ExternalAu default: return auth.ExternalAuthConfiguration{}, fmt.Errorf( "invalid AZD_AUTH_ENDPOINT value '%s': unsupported scheme %q "+ - "(supported schemes: https, unix, npipe)", + "(supported schemes: https, unix, npipe; http and no-scheme are accepted for local testing only)", endpoint, endpointUrl.Scheme) } } // buildHTTPSExternalAuth implements the historical HTTPS / no-scheme path. -// When a cert is provided, the scheme MUST be "https". +// The "https" scheme requires AZD_AUTH_CERT; the "" and "http" schemes are +// retained only for the loopback test harness. When a cert is provided, the +// scheme MUST be "https". func buildHTTPSExternalAuth(endpoint, key, cert, scheme string) (auth.ExternalAuthConfiguration, error) { + if scheme == "https" && len(cert) == 0 { + return auth.ExternalAuthConfiguration{}, fmt.Errorf( + "invalid AZD_AUTH_ENDPOINT value '%s': AZD_AUTH_CERT is required when using the 'https' scheme", + endpoint) + } client := &http.Client{} if len(cert) > 0 { transport, err := httputil.TlsEnabledTransport(cert) @@ -84,7 +94,7 @@ func buildHTTPSExternalAuth(endpoint, key, cert, scheme string) (auth.ExternalAu } // buildLocalIPCExternalAuth implements the unix: / npipe: paths. Both share -// the same shape: cert is forbidden, key is optional, the transport is built +// the same shape: cert is forbidden, key is required, the transport is built // by the platform-specific factory, and the endpoint is rewritten to a // canonical placeholder so RemoteCredential can format request URLs. func buildLocalIPCExternalAuth( @@ -96,6 +106,10 @@ func buildLocalIPCExternalAuth( "AZD_AUTH_CERT must not be set when AZD_AUTH_ENDPOINT uses a local IPC scheme " + "(unix:, npipe:); the OS enforces caller identity") } + if len(key) == 0 { + return auth.ExternalAuthConfiguration{}, fmt.Errorf( + "AZD_AUTH_KEY is required when AZD_AUTH_ENDPOINT is set") + } transport, rewritten, err := newTransport(endpoint) if err != nil { return auth.ExternalAuthConfiguration{}, err diff --git a/cli/azd/cmd/auth_transport_test.go b/cli/azd/cmd/auth_transport_test.go index d07d3ecee32..2b92c3c94cb 100644 --- a/cli/azd/cmd/auth_transport_test.go +++ b/cli/azd/cmd/auth_transport_test.go @@ -32,10 +32,11 @@ func TestBuildExternalAuthConfiguration_Schemes(t *testing.T) { cert: "", }, { - name: "https without cert keeps current behavior (no cert required at config time)", - endpoint: "https://127.0.0.1:1234", - key: "k", - cert: "", + name: "https without cert is rejected because cert is required", + endpoint: "https://127.0.0.1:1234", + key: "k", + cert: "", + wantErrSub: "AZD_AUTH_CERT is required when using the 'https' scheme", }, { name: "http with cert is rejected because cert requires https", @@ -64,6 +65,13 @@ func TestBuildExternalAuthConfiguration_Schemes(t *testing.T) { cert: "anything", wantErrSub: "AZD_AUTH_CERT must not be set", }, + { + name: "unix scheme requires a key", + endpoint: "unix:/tmp/some.sock", + key: "", + cert: "", + wantErrSub: "AZD_AUTH_KEY is required", + }, { name: "unknown scheme is refused with a list of supported schemes", endpoint: "ftp://nope", diff --git a/cli/azd/cmd/auth_transport_unix.go b/cli/azd/cmd/auth_transport_unix.go index 4eb7d465ec5..dd7af015503 100644 --- a/cli/azd/cmd/auth_transport_unix.go +++ b/cli/azd/cmd/auth_transport_unix.go @@ -59,8 +59,20 @@ func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { // verifySocketPermissions checks that the socket file and its parent // directory are owned by the current effective uid and have group/other -// permission bits cleared. It returns a clear error when either check fails. +// permission bits cleared. The socket path MUST NOT be a symlink: symlinks +// are rejected outright so a link into a less-restricted directory cannot +// bypass the parent-directory permission check. It returns a clear error when +// any check fails. func verifySocketPermissions(socketPath string) error { + linfo, err := os.Lstat(socketPath) + if err != nil { + return fmt.Errorf("AZD_AUTH_ENDPOINT socket %q: lstat: %w", socketPath, err) + } + if linfo.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf( + "AZD_AUTH_ENDPOINT socket %q: symlinks are not supported; provide the real socket path", socketPath) + } + parent := filepath.Dir(socketPath) if err := checkPathOwnedAndRestricted(parent, true); err != nil { return fmt.Errorf("AZD_AUTH_ENDPOINT socket parent directory %q: %w", parent, err) diff --git a/cli/azd/cmd/auth_transport_unix_test.go b/cli/azd/cmd/auth_transport_unix_test.go index 576a35b6290..f930ba8b7cc 100644 --- a/cli/azd/cmd/auth_transport_unix_test.go +++ b/cli/azd/cmd/auth_transport_unix_test.go @@ -112,6 +112,24 @@ func TestNewSocketTransport_HappyPath(t *testing.T) { require.Equal(t, rewrittenAuthEndpoint, endpoint) } +// TestNewSocketTransport_RejectsSymlink verifies that a symlinked socket path +// is rejected outright, even when the link target is properly secured. This +// prevents a symlink into a less-restricted directory from bypassing the +// parent-directory permission check. +func TestNewSocketTransport_RejectsSymlink(t *testing.T) { + t.Parallel() + sock, _ := listenUnixSocket(t) + + linkDir := t.TempDir() + require.NoError(t, os.Chmod(linkDir, 0o700)) + link := filepath.Join(linkDir, "azd-link.sock") + require.NoError(t, os.Symlink(sock, link)) + + _, _, err := newSocketTransport("unix:" + link) + require.Error(t, err) + require.Contains(t, err.Error(), "symlinks are not supported") +} + // TestNewPipeTransport_NotSupportedOnUnix asserts the npipe stub returns a // clear error on POSIX. func TestNewPipeTransport_NotSupportedOnUnix(t *testing.T) { diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index d0a5c4c64db..b2796e17370 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -115,8 +115,8 @@ Variables for [External Authentication](./external-authentication.md) integratio | Variable | Description | | --- | --- | -| `AZD_AUTH_ENDPOINT` | The [External Authentication](./external-authentication.md) endpoint. Accepts `https://host:port` (loopback HTTPS, requires `AZD_AUTH_CERT`), `unix:/absolute/path/to/socket` (POSIX-only Unix domain socket; cert/key not applicable), or `npipe:` / `npipe:////./pipe/` (Windows-only named pipe; cert/key not applicable). | -| `AZD_AUTH_KEY` | The [External Authentication](./external-authentication.md) shared key. Required for `https:`; optional for `unix:` and `npipe:` (the OS enforces caller identity), but still forwarded as `Authorization: Bearer` when provided. | +| `AZD_AUTH_ENDPOINT` | The [External Authentication](./external-authentication.md) endpoint. Accepts `https://host:port` (loopback HTTPS, requires `AZD_AUTH_CERT`), `unix:/absolute/path/to/socket` (POSIX-only Unix domain socket; `AZD_AUTH_CERT` must not be set), or `npipe:` / `npipe:////./pipe/` (Windows-only named pipe; `AZD_AUTH_CERT` must not be set). | +| `AZD_AUTH_KEY` | The [External Authentication](./external-authentication.md) shared key. Required for all schemes (`https:`, `unix:`, `npipe:`); sent as `Authorization: Bearer`. | | `AZD_AUTH_CERT` | The [External Authentication](./external-authentication.md) client certificate, provided as a base64-encoded DER certificate string. Required when `AZD_AUTH_ENDPOINT` uses `https:`. MUST NOT be set when `AZD_AUTH_ENDPOINT` uses `unix:` or `npipe:`. | ## Tool Configuration diff --git a/cli/azd/docs/external-authentication.md b/cli/azd/docs/external-authentication.md index 79570cfaa97..1d6be521068 100644 --- a/cli/azd/docs/external-authentication.md +++ b/cli/azd/docs/external-authentication.md @@ -98,8 +98,10 @@ permissions, so no TLS handshake and no shared bearer secret are required. path component. Relative paths are an error. - `AZD_AUTH_CERT` **MUST NOT** be set. If set, `azd` fails fast with a clear error. -- `AZD_AUTH_KEY` **MAY** be omitted. If set, `azd` still sends it as - `Authorization: Bearer ` for defense in depth. +- `AZD_AUTH_KEY` is **required** and is sent as `Authorization: Bearer `. +- The socket path **MUST NOT** be a symlink. `azd` rejects symlinked socket + paths outright so a link into a less-restricted directory cannot bypass the + parent-directory permission check. - **IDE host requirements:** the socket file MUST be created with mode `0600` and the parent directory MUST be mode `0700`, both owned by the current uid. `azd` `stat()`s the socket and the parent directory on first connect @@ -123,7 +125,7 @@ required. `npipe:////./pipe/azd-auth-` (fully qualified). Both forms are accepted. - `AZD_AUTH_CERT` **MUST NOT** be set. Same handling as `unix:`. -- `AZD_AUTH_KEY` **MAY** be omitted. Same handling as `unix:`. +- `AZD_AUTH_KEY` is **required**. Same handling as `unix:`. - **IDE host requirements:** the pipe MUST be created with a security descriptor that grants access only to the current user SID (and SYSTEM / Administrators, as is conventional). `azd` queries the pipe's DACL after diff --git a/cli/azd/go.mod b/cli/azd/go.mod index d2541047516..8726f53ddf9 100644 --- a/cli/azd/go.mod +++ b/cli/azd/go.mod @@ -31,6 +31,7 @@ require ( github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 github.com/MakeNowJust/heredoc/v2 v2.0.1 github.com/Masterminds/semver/v3 v3.4.0 + github.com/Microsoft/go-winio v0.6.2 github.com/adam-lavrik/go-imath v0.0.0-20210910152346-265a42a96f0b github.com/benbjohnson/clock v1.3.5 github.com/blang/semver/v4 v4.0.0 @@ -96,7 +97,6 @@ require ( require ( github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect - github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.20.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index 8341eadbce4..e918e4d3d6b 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -54,7 +54,7 @@ Set by IDE hosts (VS Code, Visual Studio) when spawning azd as a subprocess. Use | Variable | Description | |---|---| | `AZD_AUTH_ENDPOINT` | Authentication endpoint URL set by IDE hosts for integrated authentication. Supports `https://host:port` (loopback HTTPS), `unix:/path/to/socket` (POSIX-only Unix domain socket), and `npipe:` (Windows-only named pipe). | -| `AZD_AUTH_KEY` | Authentication key set by IDE hosts for integrated authentication. Required for `https:`; optional for `unix:` / `npipe:`. | +| `AZD_AUTH_KEY` | Authentication key set by IDE hosts for integrated authentication. Required for all schemes (`https:`, `unix:`, `npipe:`). | | `AZD_AUTH_CERT` | Authentication certificate/TLS trust configuration set by IDE hosts. Required for `https:`; must NOT be set for `unix:` / `npipe:`. | For details on the external authentication protocol, see [cli/azd/docs/external-authentication.md](../../cli/azd/docs/external-authentication.md). From 1e12c1cb9bbd85d5ec917d9fda3045c5585db9a0 Mon Sep 17 00:00:00 2001 From: "Brandon Waterloo [MSFT]" <36966225+bwateratmsft@users.noreply.github.com> Date: Thu, 4 Jun 2026 13:43:48 -0400 Subject: [PATCH 10/11] CSpell yet again... --- cli/azd/cmd/auth_transport_unix.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/azd/cmd/auth_transport_unix.go b/cli/azd/cmd/auth_transport_unix.go index dd7af015503..cdeae118364 100644 --- a/cli/azd/cmd/auth_transport_unix.go +++ b/cli/azd/cmd/auth_transport_unix.go @@ -64,11 +64,11 @@ func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { // bypass the parent-directory permission check. It returns a clear error when // any check fails. func verifySocketPermissions(socketPath string) error { - linfo, err := os.Lstat(socketPath) + stat, err := os.Lstat(socketPath) if err != nil { return fmt.Errorf("AZD_AUTH_ENDPOINT socket %q: lstat: %w", socketPath, err) } - if linfo.Mode()&os.ModeSymlink != 0 { + if stat.Mode()&os.ModeSymlink != 0 { return fmt.Errorf( "AZD_AUTH_ENDPOINT socket %q: symlinks are not supported; provide the real socket path", socketPath) } From 40199b49af5d05952183d1f67c6a4f832d3f74e9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 4 Jun 2026 17:57:20 +0000 Subject: [PATCH 11/11] Fix gosec G703 in unix auth transport socket check Co-authored-by: bwateratmsft <36966225+bwateratmsft@users.noreply.github.com> --- cli/azd/cmd/auth_transport_unix.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cli/azd/cmd/auth_transport_unix.go b/cli/azd/cmd/auth_transport_unix.go index cdeae118364..2c95cacef5c 100644 --- a/cli/azd/cmd/auth_transport_unix.go +++ b/cli/azd/cmd/auth_transport_unix.go @@ -64,11 +64,11 @@ func newSocketTransport(rawURL string) (http.RoundTripper, string, error) { // bypass the parent-directory permission check. It returns a clear error when // any check fails. func verifySocketPermissions(socketPath string) error { - stat, err := os.Lstat(socketPath) - if err != nil { + var stat syscall.Stat_t + if err := syscall.Lstat(socketPath, &stat); err != nil { return fmt.Errorf("AZD_AUTH_ENDPOINT socket %q: lstat: %w", socketPath, err) } - if stat.Mode()&os.ModeSymlink != 0 { + if stat.Mode&syscall.S_IFMT == syscall.S_IFLNK { return fmt.Errorf( "AZD_AUTH_ENDPOINT socket %q: symlinks are not supported; provide the real socket path", socketPath) }