Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<pipe-name>` (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
Expand Down
108 changes: 108 additions & 0 deletions cli/azd/cmd/auth_transport.go
Original file line number Diff line number Diff line change
@@ -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 "<endpoint>/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
}
23 changes: 23 additions & 0 deletions cli/azd/cmd/auth_transport_other.go
Original file line number Diff line number Diff line change
@@ -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")
}
109 changes: 109 additions & 0 deletions cli/azd/cmd/auth_transport_test.go
Original file line number Diff line number Diff line change
@@ -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, " ")
}
108 changes: 108 additions & 0 deletions cli/azd/cmd/auth_transport_unix.go
Original file line number Diff line number Diff line change
@@ -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'")
}
Loading
Loading