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..cbeb18ebf00 --- /dev/null +++ b/cli/azd/cmd/auth_transport_windows.go @@ -0,0 +1,194 @@ +// 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" + "strings" + "unsafe" + + "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 +} + +// verifyPipeSecurity queries the DACL of the named pipe and refuses if any +// allow ACE references a SID outside the current user / SYSTEM / +// 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, + windows.SE_FILE_OBJECT, + windows.DACL_SECURITY_INFORMATION, + ) + if err != nil { + return fmt.Errorf("querying pipe security descriptor: %w", err) + } + + dacl, _, err := sd.DACL() + if err != nil { + return fmt.Errorf("reading DACL: %w", err) + } + // 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) + } + + 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) + } + adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid) + if err != nil { + return fmt.Errorf("creating Administrators SID: %w", err) + } + allowedSids := []*windows.SID{currentUserSid, systemSid, adminsSid} + + for i := uint32(0); i < uint32(dacl.AceCount); i++ { + var ace *windows.ACCESS_ALLOWED_ACE + if err := windows.GetAce(dacl, i, &ace); err != nil { + return fmt.Errorf("reading ACE %d: %w", i, err) + } + // 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(&ace.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 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. + } + } + 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 +} 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..eb3942a4659 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,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/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 +622,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..79570cfaa97 100644 --- a/cli/azd/docs/external-authentication.md +++ b/cli/azd/docs/external-authentication.md @@ -70,6 +70,73 @@ 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. +## 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:`. + ## Implementation 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).