Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
77 changes: 58 additions & 19 deletions authd-oidc-brokers/internal/broker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"errors"
"fmt"
"net"
"os"
"path/filepath"
"slices"
"strings"
Expand Down Expand Up @@ -155,6 +156,45 @@ func New(cfg Config, args ...Option) (b *Broker, err error) {
return b, nil
}

// normalizedIssuer converts an issuer URL into a filesystem-safe directory name
// by stripping the scheme and replacing path/port separators with underscores.
func normalizedIssuer(issuerURL string) string {
_, issuer, found := strings.Cut(issuerURL, "://")
if !found {
// If the issuer URL does not contain a scheme, use the whole issuer URL as the issuer.
issuer = issuerURL
}
issuer = strings.ReplaceAll(issuer, "/", "_")
issuer = strings.ReplaceAll(issuer, ":", "_")

return issuer
}

// userDataDir returns the path to the broker's data directory for the given user.
// If the issuer URL or the username contains path traversal characters, an error is returned.
func (b *Broker) userDataDir(username string) (string, error) {
if username == "" {
return "", errors.New("username cannot be empty")
}

issuer := normalizedIssuer(b.cfg.issuerURL)
issuerDataDir := filepath.Join(b.cfg.DataDir, issuer)
// Check that the issuer does not contain path traversal characters by verifying that the resulting path is within
// the data directory and the basename matches the issuer.
if !strings.HasPrefix(issuerDataDir, b.cfg.DataDir) || filepath.Base(issuerDataDir) != issuer {
return "", fmt.Errorf("invalid issuer URL %q: path traversal detected", b.cfg.issuerURL)
}

dir := filepath.Join(issuerDataDir, username)
// Check that the username does not contain path traversal characters by verifying that the resulting path is within
// the issuer data directory and the basename matches the username.
if !strings.HasPrefix(dir, issuerDataDir) || filepath.Base(dir) != username {
Comment thread
adombeck marked this conversation as resolved.
return "", fmt.Errorf("invalid username %q: path traversal detected", username)
}

return dir, nil
}
Comment thread
adombeck marked this conversation as resolved.

// NewSession creates a new session for the user.
func (b *Broker) NewSession(username, lang, mode string) (sessionID, encryptionKey string, err error) {
if username == "" {
Expand All @@ -175,27 +215,11 @@ func (b *Broker) NewSession(username, lang, mode string) (sessionID, encryptionK
return "", "", fmt.Errorf("failed to marshal broker public key: %v", err)
}

_, issuer, found := strings.Cut(b.cfg.issuerURL, "://")
if !found {
// If the issuer URL does not contain a scheme, use the whole issuer URL as the issuer.
issuer = b.cfg.issuerURL
}
issuer = strings.ReplaceAll(issuer, "/", "_")
issuer = strings.ReplaceAll(issuer, ":", "_")

issuerDataDir := filepath.Join(b.cfg.DataDir, issuer)
// Check that the issuer does not contain path traversal characters by verifying that the resulting path is within
// the data directory and the basename matches the issuer.
if !strings.HasPrefix(issuerDataDir, b.cfg.DataDir) || filepath.Base(issuerDataDir) != issuer {
return "", "", fmt.Errorf("invalid issuer URL %q: path traversal detected", b.cfg.issuerURL)
s.userDataDir, err = b.userDataDir(username)
if err != nil {
return "", "", err
}

s.userDataDir = filepath.Join(issuerDataDir, username)
// Check that the username does not contain path traversal characters by verifying that the resulting path is within
// the issuer data directory and the basename matches the username.
if !strings.HasPrefix(s.userDataDir, issuerDataDir) || filepath.Base(s.userDataDir) != username {
return "", "", fmt.Errorf("invalid username %q: path traversal detected", username)
}
// The token is stored in $DATA_DIR/$ISSUER/$USERNAME/token.json.
s.tokenPath = filepath.Join(s.userDataDir, "token.json")
// The password is stored in $DATA_DIR/$ISSUER/$USERNAME/password.
Expand Down Expand Up @@ -1032,6 +1056,21 @@ func (b *Broker) CancelIsAuthenticated(sessionID string) {
}
}

// DeleteUser removes all broker side data stored for the given user
// from the broker's data directory.
func (b *Broker) DeleteUser(username string) error {
userDataDir, err := b.userDataDir(username)
if err != nil {
return err
}

if err := os.RemoveAll(userDataDir); err != nil {
return fmt.Errorf("could not remove user data directory %q: %w", userDataDir, err)
}
log.Infof(context.Background(), "Deleted broker data for user %q at %q", username, userDataDir)
return nil
}

// UserPreCheck checks if the user is valid and can be allowed to authenticate.
// It returns the user info in JSON format if the user is valid, or an empty string if the user is not allowed.
func (b *Broker) UserPreCheck(username string) (string, error) {
Expand Down
164 changes: 154 additions & 10 deletions authd-oidc-brokers/internal/broker/broker_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,18 +121,10 @@ func TestNewSession(t *testing.T) {
emptyUsername: true,
wantErr: true,
},
"Error_when_username_contains_path_traversal": {
username: "../test",
wantErr: true,
},
"Error_when_username_contains_path_traversal_but_does_not_leave_the_parent_directory": {
username: "test/../other-user",
"Error_when_user_directory_path_could_not_be_derived": {
username: "invalid/../user",
wantErr: true,
},
"Error_when_issuer_contains_path_traversal": {
issuerURL: "https://..",
wantErr: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
Expand Down Expand Up @@ -1446,6 +1438,158 @@ func TestUserPreCheck(t *testing.T) {
}
}

func TestNormalizedIssuer(t *testing.T) {
t.Parallel()

tests := map[string]struct {
issuerURL string
want string
}{
"HTTP_issuerURL": {issuerURL: "http://example.com", want: "example.com"},
"HTTPS_issuerURL": {issuerURL: "https://example.com", want: "example.com"},
"IssuerURL_with_path": {issuerURL: "https://example.com/tenant/v2.0", want: "example.com_tenant_v2.0"},
"IssuerURL_with_port": {issuerURL: "https://example.com:8080", want: "example.com_8080"},
"IssuerURL_with_port_and_path": {issuerURL: "https://example.com:8080/path", want: "example.com_8080_path"},
"IssuerURL_with_IP_address": {issuerURL: "https://127.0.0.1", want: "127.0.0.1"},
"IssuerURL_with_IP_address_and_port": {issuerURL: "https://127.0.0.1:8080", want: "127.0.0.1_8080"},
"IssuerURL_without_scheme": {issuerURL: "example.com", want: "example.com"},
}

for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()

b := newBrokerForTests(t, &brokerForTestConfig{
issuerURL: tc.issuerURL,
})

got := b.NormalizedIssuer(tc.issuerURL)
require.Equal(t, tc.want, got, "NormalizedIssuer returned unexpected result")
})
}
}

func TestUserDataDir(t *testing.T) {
t.Parallel()

tests := map[string]struct {
issuerURL string
username string
want string
wantErr bool
}{
"Successfully_return_user_data_dir_for_simple_username_and_issuer": {
issuerURL: "https://example.com",
username: "user@example.com",
want: "example.com/user@example.com",
},
"Successfully_return_user_data_dir_for_issuer_url_without_scheme": {
issuerURL: "example.com",
username: "user@example.com",
want: "example.com/user@example.com",
},
"Error_when_username_is_empty": {
issuerURL: "https://example.com",
username: "",
wantErr: true,
},
"Error_when_username_contains_path_traversal": {
issuerURL: "https://example.com",
username: "../test",
wantErr: true,
},
"Error_when_username_contains_path_traversal_but_does_not_leave_the_parent_directory": {
issuerURL: "https://example.com",
username: "test/../other-user",
wantErr: true,
},
"Error_when_issuer_contains_path_traversal": {
issuerURL: "https://..",
username: "validuser",
wantErr: true,
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()

b := newBrokerForTests(t, &brokerForTestConfig{
issuerURL: tc.issuerURL,
})

got, err := b.UserDataDir(tc.username)
if tc.wantErr {
require.Error(t, err, "UserDataDir should return an error, but did not")
return
}
require.NoError(t, err, "UserDataDir should not return an error")
require.Equal(t, filepath.Join(b.DataDir(), tc.want), got, "UserDataDir returned unexpected result")
})
}
}

func TestDeleteUser(t *testing.T) {
t.Parallel()

tests := map[string]struct {
username string
createUserDir bool
readOnlyDataDir bool

wantErr bool
}{
"Successfully_delete_existing_user": {username: "user@example.com", createUserDir: true},
"Successfully_delete_unknown_user_is_noop": {username: "unknown@example.com"},

"Error_when_user_data_dir_cannot_be_removed": {username: "user@example.com", createUserDir: true, readOnlyDataDir: true, wantErr: true},
"Error_when_userDataDir_could_not_be_determined": {username: "", wantErr: true},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
t.Parallel()

b := newBrokerForTests(t, &brokerForTestConfig{
issuerURL: defaultIssuerURL,
})

// Derive the path where DeleteUser will look for the user's data
userDataDir, err := b.UserDataDir(tc.username)
if tc.username == "" {
require.Error(t, err, "Setup: UserDataDir should have returned an error for empty username")
return
}
require.NoError(t, err, "Setup: UserDataDir should not have returned an error for valid username")

if tc.createUserDir {
err := os.MkdirAll(userDataDir, 0700)
require.NoError(t, err, "Setup: could not create user data directory")

// Write a dummy token file so the directory is non-empty
err = os.WriteFile(filepath.Join(userDataDir, "token.json"), []byte(`{}`), 0600)
require.NoError(t, err, "Setup: could not write dummy token file")
}

if tc.readOnlyDataDir {
// Make the issuer directory read-only so RemoveAll fails on the user subdir
issuerDir := filepath.Dir(userDataDir)
err := os.Chmod(issuerDir, 0500) //nolint:gosec // Intentional read-only permission for testing
require.NoError(t, err, "Setup: could not make issuer directory read-only")
t.Cleanup(func() { _ = os.Chmod(issuerDir, 0700) }) //nolint:gosec // Restore full permissions after test
}

err = b.DeleteUser(tc.username)
if tc.wantErr {
require.Error(t, err, "DeleteUser should return an error, but did not")
return
}
require.NoError(t, err, "DeleteUser should not return an error, but did")

// Verify the user data directory no longer exists
require.NoDirExists(t, userDataDir, "User data directory should have been removed")
})
}
}

func TestMain(m *testing.M) {
log.SetLevel(log.DebugLevel)

Expand Down
10 changes: 10 additions & 0 deletions authd-oidc-brokers/internal/broker/export_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,16 @@ func (b *Broker) DataDir() string {
return b.cfg.DataDir
}

// UserDataDir exposes the broker's userDataDir method for tests.
func (b *Broker) UserDataDir(username string) (string, error) {
return b.userDataDir(username)
}

// NormalizedIssuer exposes the broker's normalizedIssuer method for tests.
func (b *Broker) NormalizedIssuer(issuerURL string) string {
return normalizedIssuer(issuerURL)
}

// GetNextAuthModes returns the next auth mode of the specified session.
func (b *Broker) GetNextAuthModes(sessionID string) []string {
b.currentSessionsMu.Lock()
Expand Down
3 changes: 3 additions & 0 deletions authd-oidc-brokers/internal/dbusservice/dbusservice.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ const intro = `
<arg type="s" direction="in" name="username"/>
<arg type="s" direction="out" name="userInfo"/>
</method>
<method name="DeleteUser">
<arg type="s" direction="in" name="username"/>
</method>
</interface>` + introspect.IntrospectDataString + `</node> `

// Service is the handler exposing our broker methods on the system bus.
Expand Down
9 changes: 9 additions & 0 deletions authd-oidc-brokers/internal/dbusservice/methods.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,15 @@ func (s *Service) UserPreCheck(username string) (userinfo string, dbusErr *dbus.
return userinfo, nil
}

// DeleteUser is the method through which the broker and the daemon will communicate once dbusInterface.DeleteUser is called.
func (s *Service) DeleteUser(username string) (dbusErr *dbus.Error) {
log.Debugf(context.Background(), "DeleteUser: %s", username)
if err := s.broker.DeleteUser(username); err != nil {
return dbus.MakeFailedError(err)
}
return nil
}

// makeCanceledError creates a dbus.Error for a canceled operation.
func makeCanceledError() *dbus.Error {
return &dbus.Error{Name: "com.ubuntu.authd.Canceled"}
Expand Down
15 changes: 15 additions & 0 deletions examplebroker/broker.go
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,21 @@ func (b *Broker) UserPreCheck(ctx context.Context, username string) (string, err
return userInfoFromName(username), nil
}

// DeleteUser removes any broker side data associated with the user.
func (b *Broker) DeleteUser(ctx context.Context, username string) error {
exampleUsersMu.Lock()
defer exampleUsersMu.Unlock()

if _, exists := exampleUsers[username]; !exists {
// Nothing to delete.
return nil
}

delete(exampleUsers, username)
log.Infof(ctx, "Broker: deleted user data for %q", username)
return nil
}

// decryptAES is just here to illustrate the encryption and decryption
// and in no way the right way to perform a secure encryption
//
Expand Down
3 changes: 3 additions & 0 deletions examplebroker/com.ubuntu.auth.ExampleBroker.xml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@
<method name="CancelIsAuthenticated">
<arg type="s" direction="in" name="sessionID"/>
</method>
<method name="DeleteUser">
<arg type="s" direction="in" name="username"/>
</method>
</interface>
<interface name="org.freedesktop.DBus.Introspectable">
<method name="Introspect">
Expand Down
8 changes: 8 additions & 0 deletions examplebroker/dbus.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,11 @@ func (b *Bus) UserPreCheck(username string) (userinfo string, dbusErr *dbus.Erro
}
return userinfo, nil
}

// DeleteUser is the method through which the broker and the daemon will communicate once dbusInterface.DeleteUser is called.
func (b *Bus) DeleteUser(username string) (dbusErr *dbus.Error) {
if err := b.broker.DeleteUser(context.Background(), username); err != nil {
return dbus.MakeFailedError(err)
}
return nil
}
Loading