Browse Source

OIDC: add support for custom fields

These fields can be used in the pre-login hook to implement custom
logics

Fixes #787

Signed-off-by: Nicola Murino <nicola.murino@gmail.com>
Nicola Murino 3 years ago
parent
commit
cacfffc5bf
15 changed files with 152 additions and 69 deletions
  1. 2 6
      README.md
  2. 7 0
      config/config.go
  3. 5 0
      config/config_test.go
  4. 15 13
      dataprovider/dataprovider.go
  5. 1 0
      docs/full-configuration.md
  6. 28 1
      docs/oidc.md
  7. 10 10
      go.mod
  8. 16 12
      go.sum
  9. 4 0
      httpd/httpd_test.go
  10. 36 18
      httpd/oidc.go
  11. 3 2
      httpd/oidc_test.go
  12. 4 0
      openapi/openapi.yaml
  13. 2 1
      sftpgo.json
  14. 8 4
      vfs/cryptfs.go
  15. 11 2
      vfs/osfs.go

+ 2 - 6
README.md

@@ -61,7 +61,7 @@ Several storage backends are supported: local filesystem, encrypted local filesy
 
 
 ## Platforms
 ## Platforms
 
 
-SFTPGo is developed and tested on Linux. After each commit, the code is automatically built and tested on Linux, macOS and Windows using a [GitHub Action](./.github/workflows/development.yml). The test cases are regularly manually executed and passed on FreeBSD. Other *BSD variants should work too.
+SFTPGo is developed and tested on Linux. After each commit, the code is automatically built and tested on Linux, macOS and Windows using [GitHub Actions](./.github/workflows/development.yml). The test cases are regularly manually executed and passed on FreeBSD. Other *BSD variants should work too.
 
 
 ## Requirements
 ## Requirements
 
 
@@ -91,7 +91,7 @@ An official Docker image is available. Documentation is [here](./docker/README.m
 
 
 SFTPGo is also available on [AWS Marketplace](https://aws.amazon.com/marketplace/seller-profile?id=6e849ab8-70a6-47de-9a43-13c3fa849335) and [Azure Marketplace](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/prasselsrl1645470739547.sftpgo_linux), purchasing from there will help keep SFTPGo a long-term sustainable project.
 SFTPGo is also available on [AWS Marketplace](https://aws.amazon.com/marketplace/seller-profile?id=6e849ab8-70a6-47de-9a43-13c3fa849335) and [Azure Marketplace](https://azuremarketplace.microsoft.com/en-us/marketplace/apps/prasselsrl1645470739547.sftpgo_linux), purchasing from there will help keep SFTPGo a long-term sustainable project.
 
 
-<details><summary>On Windows you can use</summary>
+<details><summary>Windows packages</summary>
 
 
 - The Windows installer to install and run SFTPGo as a Windows service.
 - The Windows installer to install and run SFTPGo as a Windows service.
 - The portable package to start SFTPGo on demand.
 - The portable package to start SFTPGo on demand.
@@ -177,8 +177,6 @@ Loading data from a provider independent JSON dump is supported from the previou
 
 
 ## Downgrading
 ## Downgrading
 
 
-<details>
-
 If for some reason you want to downgrade SFTPGo, you may need to downgrade your data provider schema and data as well. You can use the `revertprovider` command for this task.
 If for some reason you want to downgrade SFTPGo, you may need to downgrade your data provider schema and data as well. You can use the `revertprovider` command for this task.
 
 
 As for upgrading, SFTPGo supports downgrading from the previous release branch to the current one.
 As for upgrading, SFTPGo supports downgrading from the previous release branch to the current one.
@@ -199,8 +197,6 @@ The `revertprovider` command is not supported for the memory provider.
 
 
 Please note that we only support the current release branch and the current main branch, if you find a bug it is better to report it rather than downgrading to an older unsupported version.
 Please note that we only support the current release branch and the current main branch, if you find a bug it is better to report it rather than downgrading to an older unsupported version.
 
 
-</details>
-
 ## Users and folders management
 ## Users and folders management
 
 
 After starting SFTPGo you can manage users and folders using:
 After starting SFTPGo you can manage users and folders using:

+ 7 - 0
config/config.go

@@ -92,6 +92,7 @@ var (
 			RedirectBaseURL: "",
 			RedirectBaseURL: "",
 			UsernameField:   "",
 			UsernameField:   "",
 			RoleField:       "",
 			RoleField:       "",
+			CustomFields:    []string{},
 		},
 		},
 		Security: httpd.SecurityConf{
 		Security: httpd.SecurityConf{
 			Enabled:                 false,
 			Enabled:                 false,
@@ -1283,6 +1284,12 @@ func getHTTPDOIDCFromEnv(idx int) (httpd.OIDC, bool) {
 		isSet = true
 		isSet = true
 	}
 	}
 
 
+	customFields, ok := lookupStringListFromEnv(fmt.Sprintf("SFTPGO_HTTPD__BINDINGS__%v__OIDC__CUSTOM_FIELDS", idx))
+	if ok {
+		result.CustomFields = customFields
+		isSet = true
+	}
+
 	return result, isSet
 	return result, isSet
 }
 }
 
 

+ 5 - 0
config/config_test.go

@@ -850,6 +850,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL", "redirect base url")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD", "preferred_username")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD", "sftpgo_role")
+	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS", "field1,field2")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED", "true")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED", "true")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS", "*.example.com,*.example.net")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS", "*.example.com,*.example.net")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX", "1")
 	os.Setenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX", "1")
@@ -898,6 +899,7 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__REDIRECT_BASE_URL")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__USERNAME_FIELD")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__ROLE_FIELD")
+		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__OIDC__CUSTOM_FIELDS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ENABLED")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX")
 		os.Unsetenv("SFTPGO_HTTPD__BINDINGS__2__SECURITY__ALLOWED_HOSTS_ARE_REGEX")
@@ -972,6 +974,9 @@ func TestHTTPDBindingsFromEnv(t *testing.T) {
 	require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL)
 	require.Equal(t, "redirect base url", bindings[2].OIDC.RedirectBaseURL)
 	require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField)
 	require.Equal(t, "preferred_username", bindings[2].OIDC.UsernameField)
 	require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField)
 	require.Equal(t, "sftpgo_role", bindings[2].OIDC.RoleField)
+	require.Len(t, bindings[2].OIDC.CustomFields, 2)
+	require.Equal(t, "field1", bindings[2].OIDC.CustomFields[0])
+	require.Equal(t, "field2", bindings[2].OIDC.CustomFields[1])
 	require.True(t, bindings[2].Security.Enabled)
 	require.True(t, bindings[2].Security.Enabled)
 	require.Len(t, bindings[2].Security.AllowedHosts, 2)
 	require.Len(t, bindings[2].Security.AllowedHosts, 2)
 	require.Equal(t, "*.example.com", bindings[2].Security.AllowedHosts[0])
 	require.Equal(t, "*.example.com", bindings[2].Security.AllowedHosts[0])

+ 15 - 13
dataprovider/dataprovider.go

@@ -977,7 +977,7 @@ func CheckCompositeCredentials(username, password, ip, loginMethod, protocol str
 		} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
 		} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&1 != 0) {
 			user, err = doExternalAuth(username, password, nil, "", ip, protocol, nil)
 			user, err = doExternalAuth(username, password, nil, "", ip, protocol, nil)
 		} else if config.PreLoginHook != "" {
 		} else if config.PreLoginHook != "" {
-			user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol)
+			user, err = executePreLoginHook(username, LoginMethodPassword, ip, protocol, nil)
 		}
 		}
 		if err != nil {
 		if err != nil {
 			return user, loginMethod, err
 			return user, loginMethod, err
@@ -997,7 +997,7 @@ func CheckUserBeforeTLSAuth(username, ip, protocol string, tlsCert *x509.Certifi
 		return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert)
 		return doExternalAuth(username, "", nil, "", ip, protocol, tlsCert)
 	}
 	}
 	if config.PreLoginHook != "" {
 	if config.PreLoginHook != "" {
-		return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol)
+		return executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil)
 	}
 	}
 	return UserExists(username)
 	return UserExists(username)
 }
 }
@@ -1021,7 +1021,7 @@ func CheckUserAndTLSCert(username, ip, protocol string, tlsCert *x509.Certificat
 		return checkUserAndTLSCertificate(&user, protocol, tlsCert)
 		return checkUserAndTLSCertificate(&user, protocol, tlsCert)
 	}
 	}
 	if config.PreLoginHook != "" {
 	if config.PreLoginHook != "" {
-		user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol)
+		user, err := executePreLoginHook(username, LoginMethodTLSCertificate, ip, protocol, nil)
 		if err != nil {
 		if err != nil {
 			return user, err
 			return user, err
 		}
 		}
@@ -1048,7 +1048,7 @@ func CheckUserAndPass(username, password, ip, protocol string) (User, error) {
 		return checkUserAndPass(&user, password, ip, protocol)
 		return checkUserAndPass(&user, password, ip, protocol)
 	}
 	}
 	if config.PreLoginHook != "" {
 	if config.PreLoginHook != "" {
-		user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol)
+		user, err := executePreLoginHook(username, LoginMethodPassword, ip, protocol, nil)
 		if err != nil {
 		if err != nil {
 			return user, err
 			return user, err
 		}
 		}
@@ -1075,7 +1075,7 @@ func CheckUserAndPubKey(username string, pubKey []byte, ip, protocol string, isS
 		return checkUserAndPubKey(&user, pubKey, isSSHCert)
 		return checkUserAndPubKey(&user, pubKey, isSSHCert)
 	}
 	}
 	if config.PreLoginHook != "" {
 	if config.PreLoginHook != "" {
-		user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip, protocol)
+		user, err := executePreLoginHook(username, SSHLoginMethodPublicKey, ip, protocol, nil)
 		if err != nil {
 		if err != nil {
 			return user, "", err
 			return user, "", err
 		}
 		}
@@ -1095,7 +1095,7 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
 	} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
 	} else if config.ExternalAuthHook != "" && (config.ExternalAuthScope == 0 || config.ExternalAuthScope&4 != 0) {
 		user, err = doExternalAuth(username, "", nil, "1", ip, protocol, nil)
 		user, err = doExternalAuth(username, "", nil, "1", ip, protocol, nil)
 	} else if config.PreLoginHook != "" {
 	} else if config.PreLoginHook != "" {
-		user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol)
+		user, err = executePreLoginHook(username, SSHLoginMethodKeyboardInteractive, ip, protocol, nil)
 	} else {
 	} else {
 		user, err = provider.userExists(username)
 		user, err = provider.userExists(username)
 	}
 	}
@@ -1109,9 +1109,9 @@ func CheckKeyboardInteractiveAuth(username, authHook string, client ssh.Keyboard
 // after a successful authentication with an external identity provider.
 // after a successful authentication with an external identity provider.
 // If a pre-login hook is defined it will be executed so the SFTPGo user
 // If a pre-login hook is defined it will be executed so the SFTPGo user
 // can be created if it does not exist
 // can be created if it does not exist
-func GetUserAfterIDPAuth(username, ip, protocol string) (User, error) {
+func GetUserAfterIDPAuth(username, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) {
 	if config.PreLoginHook != "" {
 	if config.PreLoginHook != "" {
-		return executePreLoginHook(username, LoginMethodIDP, ip, protocol)
+		return executePreLoginHook(username, LoginMethodIDP, ip, protocol, oidcTokenFields)
 	}
 	}
 	return UserExists(username)
 	return UserExists(username)
 }
 }
@@ -2232,6 +2232,7 @@ func ValidateFolder(folder *vfs.BaseVirtualFolder) error {
 // ValidateUser returns an error if the user is not valid
 // ValidateUser returns an error if the user is not valid
 // FIXME: this should be defined as User struct method
 // FIXME: this should be defined as User struct method
 func ValidateUser(user *User) error {
 func ValidateUser(user *User) error {
+	user.OIDCCustomFields = nil
 	user.SetEmptySecretsIfNil()
 	user.SetEmptySecretsIfNil()
 	buildUserHomeDir(user)
 	buildUserHomeDir(user)
 	if err := validateBaseParams(user); err != nil {
 	if err := validateBaseParams(user); err != nil {
@@ -3004,8 +3005,8 @@ func getPreLoginHookResponse(loginMethod, ip, protocol string, userAsJSON []byte
 	return cmd.Output()
 	return cmd.Output()
 }
 }
 
 
-func executePreLoginHook(username, loginMethod, ip, protocol string) (User, error) {
-	u, userAsJSON, err := getUserAndJSONForHook(username)
+func executePreLoginHook(username, loginMethod, ip, protocol string, oidcTokenFields *map[string]interface{}) (User, error) {
+	u, userAsJSON, err := getUserAndJSONForHook(username, oidcTokenFields)
 	if err != nil {
 	if err != nil {
 		return u, err
 		return u, err
 	}
 	}
@@ -3210,7 +3211,7 @@ func doExternalAuth(username, password string, pubKey []byte, keyboardInteractiv
 ) (User, error) {
 ) (User, error) {
 	var user User
 	var user User
 
 
-	u, userAsJSON, err := getUserAndJSONForHook(username)
+	u, userAsJSON, err := getUserAndJSONForHook(username, nil)
 	if err != nil {
 	if err != nil {
 		return user, err
 		return user, err
 	}
 	}
@@ -3290,7 +3291,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
 ) (User, error) {
 ) (User, error) {
 	var user User
 	var user User
 
 
-	u, userAsJSON, err := getUserAndJSONForHook(username)
+	u, userAsJSON, err := getUserAndJSONForHook(username, nil)
 	if err != nil {
 	if err != nil {
 		return user, err
 		return user, err
 	}
 	}
@@ -3355,7 +3356,7 @@ func doPluginAuth(username, password string, pubKey []byte, ip, protocol string,
 	return provider.userExists(user.Username)
 	return provider.userExists(user.Username)
 }
 }
 
 
-func getUserAndJSONForHook(username string) (User, []byte, error) {
+func getUserAndJSONForHook(username string, oidcTokenFields *map[string]interface{}) (User, []byte, error) {
 	var userAsJSON []byte
 	var userAsJSON []byte
 	u, err := provider.userExists(username)
 	u, err := provider.userExists(username)
 	if err != nil {
 	if err != nil {
@@ -3369,6 +3370,7 @@ func getUserAndJSONForHook(username string) (User, []byte, error) {
 			},
 			},
 		}
 		}
 	}
 	}
+	u.OIDCCustomFields = oidcTokenFields
 	userAsJSON, err = json.Marshal(u)
 	userAsJSON, err = json.Marshal(u)
 	if err != nil {
 	if err != nil {
 		return u, userAsJSON, err
 		return u, userAsJSON, err

+ 1 - 0
docs/full-configuration.md

@@ -254,6 +254,7 @@ The configuration file contains the following sections:
       - `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank.
       - `redirect_base_url`, string. Defines the base URL to redirect to after OpenID authentication. The suffix `/web/oidc/redirect` will be added to this base URL, adding also the `web_root` if configured. Default: blank.
       - `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank.
       - `username_field`, string. Defines the ID token claims field to map to the SFTPGo username. Default: blank.
       - `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank.
       - `role_field`, string. Defines the optional ID token claims field to map to a SFTPGo role. If the defined ID token claims field is set to `admin` the authenticated user is mapped to an SFTPGo admin. You don't need to specify this field if you want to use OpenID only for the Web Client UI. Default: blank.
+      - `custom_fields`, list of strings. Custom token claims fields to pass to the pre-login hook. Default: empty.
     - `security`, struct. Defines security headers to add to HTTP responses and allows to restrict allowed hosts. The following parameters are supported:
     - `security`, struct. Defines security headers to add to HTTP responses and allows to restrict allowed hosts. The following parameters are supported:
       - `enabled`, boolean. Set to `true` to enable security configurations. Default: `false`.
       - `enabled`, boolean. Set to `true` to enable security configurations. Default: `false`.
       - `allowed_hosts`, list of strings. Fully qualified domain names that are allowed. An empty list allows any and all host names. Default: empty.
       - `allowed_hosts`, list of strings. Fully qualified domain names that are allowed. An empty list allows any and all host names. Default: empty.

+ 28 - 1
docs/oidc.md

@@ -42,7 +42,8 @@ Add the following configuration parameters to the SFTPGo configuration file (or
       "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
       "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
       "redirect_base_url": "http://192.168.1.50:8080",
       "redirect_base_url": "http://192.168.1.50:8080",
       "username_field": "preferred_username",
       "username_field": "preferred_username",
-      "role_field": "sftpgo_role"
+      "role_field": "sftpgo_role",
+      "custom_fields": []
     }
     }
 ...
 ...
 ```
 ```
@@ -99,3 +100,29 @@ And the following is an example ID token which allows the SFTPGo user `user1` to
 ```
 ```
 
 
 SFTPGo users (not admins) can be created/updated after successful OpenID authentication by defining a [pre-login hook](./dynamic-user-mod.md).
 SFTPGo users (not admins) can be created/updated after successful OpenID authentication by defining a [pre-login hook](./dynamic-user-mod.md).
+You can use the `custom_fields` configuration parameter to define the token claims field names to pass to the pre-login hook, these fields are useful for implementing custom logic when creating/updating the SFTPGo user within the hook.
+For example you can set the field `sftpgo_home_dir` in your identity provider and add it to the `custom_fields` in the SFTPGo configuration like this:
+
+```json
+...
+    "oidc": {
+      "client_id": "sftpgo-client",
+      "client_secret": "jRsmE0SWnuZjP7djBqNq0mrf8QN77j2c",
+      "config_url": "http://192.168.1.12:8086/auth/realms/sftpgo",
+      "redirect_base_url": "http://192.168.1.50:8080",
+      "username_field": "preferred_username",
+      "role_field": "sftpgo_role",
+      "custom_fields": ["sftpgo_home_dir"]
+    }
+...
+```
+
+The pre-login hook will receive a JSON serialized user with the following field:
+
+```json
+...
+  "oidc_custom_fields": {
+    "sftpgo_home_dir": "configured value"
+  },
+...
+```

+ 10 - 10
go.mod

@@ -50,7 +50,7 @@ require (
 	github.com/rs/cors v1.8.2
 	github.com/rs/cors v1.8.2
 	github.com/rs/xid v1.4.0
 	github.com/rs/xid v1.4.0
 	github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
 	github.com/rs/zerolog v1.26.2-0.20220312163309-e9344a8c507b
-	github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37
+	github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7
 	github.com/shirou/gopsutil/v3 v3.22.3
 	github.com/shirou/gopsutil/v3 v3.22.3
 	github.com/spf13/afero v1.8.2
 	github.com/spf13/afero v1.8.2
 	github.com/spf13/cobra v1.4.0
 	github.com/spf13/cobra v1.4.0
@@ -64,11 +64,11 @@ require (
 	go.etcd.io/bbolt v1.3.6
 	go.etcd.io/bbolt v1.3.6
 	go.uber.org/automaxprocs v1.5.1
 	go.uber.org/automaxprocs v1.5.1
 	gocloud.dev v0.25.0
 	gocloud.dev v0.25.0
-	golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29
-	golang.org/x/net v0.0.0-20220401154927-543a649e0bdd
-	golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
-	golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f
-	golang.org/x/time v0.0.0-20220224211638-0e9765cccd65
+	golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4
+	golang.org/x/net v0.0.0-20220412020605-290c469a71a5
+	golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5
+	golang.org/x/sys v0.0.0-20220412071739-889880a91fd5
+	golang.org/x/time v0.0.0-20220411224347-583f2d630306
 	google.golang.org/api v0.74.0
 	google.golang.org/api v0.74.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 )
 )
@@ -105,7 +105,7 @@ require (
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/golang/protobuf v1.5.2 // indirect
 	github.com/google/go-cmp v0.5.7 // indirect
 	github.com/google/go-cmp v0.5.7 // indirect
-	github.com/googleapis/gax-go/v2 v2.2.0 // indirect
+	github.com/googleapis/gax-go/v2 v2.3.0 // indirect
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/googleapis/go-type-adapters v1.0.0 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
 	github.com/hashicorp/hcl v1.0.0 // indirect
@@ -149,7 +149,7 @@ require (
 	golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
 	golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/text v0.3.7 // indirect
 	golang.org/x/tools v0.1.10 // indirect
 	golang.org/x/tools v0.1.10 // indirect
-	golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
+	golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 	google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac // indirect
 	google.golang.org/genproto v0.0.0-20220407144326-9054f6ed7bac // indirect
 	google.golang.org/grpc v1.45.0 // indirect
 	google.golang.org/grpc v1.45.0 // indirect
@@ -162,6 +162,6 @@ require (
 
 
 replace (
 replace (
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
 	github.com/jlaffaye/ftp => github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9
-	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42
-	golang.org/x/net => github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d
+	golang.org/x/crypto => github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2
+	golang.org/x/net => github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325
 )
 )

+ 16 - 12
go.sum

@@ -232,12 +232,12 @@ github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/
 github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
 github.com/dnaeon/go-vcr v1.1.0/go.mod h1:M7tiix8f0r6mKKJ3Yq/kqU1OYf3MnfmBWVbPx/yU9ko=
 github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
 github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI=
 github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
 github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ=
-github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42 h1:zEfdtweSYZtEJqrUuezFPsfuvsSdNlbcpi3jlPUQqBc=
-github.com/drakkan/crypto v0.0.0-20220404114519-57edd90b9b42/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
+github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2 h1:5XmEywX1u5gPgOC+MJuTwLtjMd6gC6t40W+dBgbriSE=
+github.com/drakkan/crypto v0.0.0-20220412172350-e76a61f8e7d2/go.mod h1:SiM6ypd8Xu1xldObYtbDztuUU7xUzMnUULfphXFZmro=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9 h1:LPH1dEblAOO/LoG7yHPMtBLXhQmjaga91/DDjWk9jWA=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
 github.com/drakkan/ftp v0.0.0-20201114075148-9b9adce499a9/go.mod h1:2lmrmq866uF2tnje75wQHzmPXhmSWUt7Gyx2vgK1RCU=
-github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d h1:pFFnOn4oQ+0Ewvn4/K41uW6troPfOwMeEHN7RBgIaA4=
-github.com/drakkan/net v0.0.0-20220403065115-db06d28f9f8d/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325 h1:yPXArUzpptMAZ95OJHLh4rAG/VMpSqU7R63Tw7Ui22Y=
+github.com/drakkan/net v0.0.0-20220412172245-b4d0d6443325/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001 h1:/ZshrfQzayqRSBDodmp3rhNCHJCff+utvgBuWRbiqu4=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/eikenb/pipeat v0.0.0-20210730190139-06b3e6902001/go.mod h1:kltMsfRMTHSFdMbK66XdS8mfMW77+FZA1fGY1xYMF84=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
 github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
@@ -406,8 +406,9 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 github.com/googleapis/gax-go/v2 v2.1.0/go.mod h1:Q3nei7sK6ybPYH7twZdmQpAd1MKb7pfu6SK+H1/DsU0=
 github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
 github.com/googleapis/gax-go/v2 v2.1.1/go.mod h1:hddJymUZASv3XPyGkUpKj8pPO47Rmb0eJc8R6ouapiM=
-github.com/googleapis/gax-go/v2 v2.2.0 h1:s7jOdKSaksJVOxE0Y/S32otcfiP+UQ0cL8/GTKaONwE=
 github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
 github.com/googleapis/gax-go/v2 v2.2.0/go.mod h1:as02EH8zWkzwUoLbBaFeQ+arQaj/OthfcblKl4IGNaM=
+github.com/googleapis/gax-go/v2 v2.3.0 h1:nRJtk3y8Fm770D42QV6T90ZnvFZyk7agSo3Q+Z9p3WI=
+github.com/googleapis/gax-go/v2 v2.3.0/go.mod h1:b8LNqSzNabLiUpXKkY7HAR5jr6bIT99EXz9pXxye9YM=
 github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
 github.com/googleapis/go-type-adapters v1.0.0 h1:9XdMn+d/G57qq1s8dNc5IesGCXHf6V2HZ2JwRxfA2tA=
 github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/googleapis/go-type-adapters v1.0.0/go.mod h1:zHW75FOG2aur7gAO2B+MLby+cLsWGBF62rFAi7WjWO4=
 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
 github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
@@ -685,8 +686,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
 github.com/secsy/goftp v0.0.0-20200609142545-aa2de14babf4 h1:PT+ElG/UUFMfqy5HrxJxNzj3QBOf7dZwupeVC+mG1Lo=
-github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37 h1:ESruo35Pb9cCgaGslAmw6leGhzeL0pLzD6o+z9gsZeQ=
-github.com/sftpgo/sdk v0.1.1-0.20220327080604-3c0f878c8c37/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
+github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7 h1:y1N2hPOqO1sTCwvtlKWrAiLBLOfThPuE17Tvju1wohs=
+github.com/sftpgo/sdk v0.1.1-0.20220412171743-453880fab5f7/go.mod h1:m5J7DH8unhD5RUsREFRiidP8zgBjup0+iQaxQnYHJOM=
 github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
 github.com/shirou/gopsutil/v3 v3.22.3 h1:UebRzEomgMpv61e3hgD1tGooqX5trFbdU/ehphbHd00=
 github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
 github.com/shirou/gopsutil/v3 v3.22.3/go.mod h1:D01hZJ4pVHPpCTZ3m3T2+wDF2YAGfd+H4ifUguaQzHM=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
 github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
@@ -838,8 +839,9 @@ golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f/go.mod h1:KelEdhl1UZF7XfJ
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211005180243-6b3c2da341f1/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
-golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a h1:qfl7ob3DIEs3Ml9oLuPwY2N04gymzAW04WsUQHIClgM=
 golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5 h1:OSnWWcOd/CtWQC2cYSBgbTSJv3ciqd8r54ySIW2y3RE=
+golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -932,8 +934,8 @@ golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220328115105-d36c6a25d886/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220330033206-e17cdc41300f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f h1:8w7RhxzTVgUzw/AH/9mUV5q0vMgy40SQRursCcfmkCw=
-golang.org/x/sys v0.0.0-20220408201424-a24fb2fb8a0f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.0.0-20220412071739-889880a91fd5 h1:NubxfvTRuNb4RVzWrIDAUzUvREH1HkCD4JjyQTSG9As=
+golang.org/x/sys v0.0.0-20220412071739-889880a91fd5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -951,8 +953,9 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
-golang.org/x/time v0.0.0-20220224211638-0e9765cccd65 h1:M73Iuj3xbbb9Uk1DYhzydthsj6oOd6l9bpuFcNoUvTs=
 golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/time v0.0.0-20220224211638-0e9765cccd65/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306 h1:+gHMid33q6pen7kv9xvT+JRinntgeXO2AeZVd0AWD3w=
+golang.org/x/time v0.0.0-20220411224347-583f2d630306/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
 golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1022,8 +1025,9 @@ golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8T
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f h1:GGU+dLjvlC3qDwqYgL6UgRmHXhOOgns0bZu2Ty5mm6U=
+golang.org/x/xerrors v0.0.0-20220411194840-2f41105eb62f/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
 google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=

+ 4 - 0
httpd/httpd_test.go

@@ -511,6 +511,9 @@ func TestBasicUserHandling(t *testing.T) {
 	user.AdditionalInfo = "some free text"
 	user.AdditionalInfo = "some free text"
 	user.Filters.TLSUsername = sdk.TLSUsernameCN
 	user.Filters.TLSUsername = sdk.TLSUsernameCN
 	user.Email = "user@example.net"
 	user.Email = "user@example.net"
+	user.OIDCCustomFields = &map[string]interface{}{
+		"field1": "value1",
+	}
 	user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled,
 	user.Filters.WebClient = append(user.Filters.WebClient, sdk.WebClientPubKeyChangeDisabled,
 		sdk.WebClientWriteDisabled)
 		sdk.WebClientWriteDisabled)
 	originalUser := user
 	originalUser := user
@@ -520,6 +523,7 @@ func TestBasicUserHandling(t *testing.T) {
 
 
 	user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
 	user, _, err = httpdtest.GetUserByUsername(defaultUsername, http.StatusOK)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
+	assert.Nil(t, user.OIDCCustomFields)
 
 
 	user.Email = "invalid@email"
 	user.Email = "invalid@email"
 	_, body, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")
 	_, body, err := httpdtest.UpdateUser(user, http.StatusBadRequest, "")

+ 36 - 18
httpd/oidc.go

@@ -75,7 +75,9 @@ type OIDC struct {
 	// is mapped to an SFTPGo admin.
 	// is mapped to an SFTPGo admin.
 	// You don't need to specify this field if you want to use OpenID only for the
 	// You don't need to specify this field if you want to use OpenID only for the
 	// Web Client UI
 	// Web Client UI
-	RoleField         string `json:"role_field" mapstructure:"role_field"`
+	RoleField string `json:"role_field" mapstructure:"role_field"`
+	// Custom token claims fields to pass to the pre-login hook
+	CustomFields      []string `json:"custom_fields" mapstructure:"custom_fields"`
 	provider          *oidc.Provider
 	provider          *oidc.Provider
 	verifier          OIDCTokenVerifier
 	verifier          OIDCTokenVerifier
 	providerLogoutURL string
 	providerLogoutURL string
@@ -160,21 +162,22 @@ func newOIDCPendingAuth(audience tokenAudience) oidcPendingAuth {
 }
 }
 
 
 type oidcToken struct {
 type oidcToken struct {
-	AccessToken  string      `json:"access_token"`
-	TokenType    string      `json:"token_type,omitempty"`
-	RefreshToken string      `json:"refresh_token,omitempty"`
-	ExpiresAt    int64       `json:"expires_at,omitempty"`
-	SessionID    string      `json:"session_id"`
-	IDToken      string      `json:"id_token"`
-	Nonce        string      `json:"nonce"`
-	Username     string      `json:"username"`
-	Permissions  []string    `json:"permissions"`
-	Role         interface{} `json:"role"`
-	Cookie       string      `json:"cookie"`
-	UsedAt       int64       `json:"used_at"`
-}
-
-func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, roleField string) error {
+	AccessToken  string                  `json:"access_token"`
+	TokenType    string                  `json:"token_type,omitempty"`
+	RefreshToken string                  `json:"refresh_token,omitempty"`
+	ExpiresAt    int64                   `json:"expires_at,omitempty"`
+	SessionID    string                  `json:"session_id"`
+	IDToken      string                  `json:"id_token"`
+	Nonce        string                  `json:"nonce"`
+	Username     string                  `json:"username"`
+	Permissions  []string                `json:"permissions"`
+	Role         interface{}             `json:"role"`
+	CustomFields *map[string]interface{} `json:"custom_fields,omitempty"`
+	Cookie       string                  `json:"cookie"`
+	UsedAt       int64                   `json:"used_at"`
+}
+
+func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, roleField string, customFields []string) error {
 	getClaimsFields := func() []string {
 	getClaimsFields := func() []string {
 		keys := make([]string, 0, len(claims))
 		keys := make([]string, 0, len(claims))
 		for k := range claims {
 		for k := range claims {
@@ -195,6 +198,21 @@ func (t *oidcToken) parseClaims(claims map[string]interface{}, usernameField, ro
 			t.Role = role
 			t.Role = role
 		}
 		}
 	}
 	}
+	t.CustomFields = nil
+	if len(customFields) > 0 {
+		for _, field := range customFields {
+			if val, ok := claims[field]; ok {
+				if t.CustomFields == nil {
+					customFields := make(map[string]interface{})
+					t.CustomFields = &customFields
+				}
+				logger.Debug(logSender, "", "custom field %#v found in token claims", field)
+				(*t.CustomFields)[field] = val
+			} else {
+				logger.Info(logSender, "", "custom field %#v not found in token claims", field)
+			}
+		}
+	}
 	sid, ok := claims["sid"].(string)
 	sid, ok := claims["sid"].(string)
 	if ok {
 	if ok {
 		t.SessionID = sid
 		t.SessionID = sid
@@ -300,7 +318,7 @@ func (t *oidcToken) getUser(r *http.Request) error {
 		return nil
 		return nil
 	}
 	}
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
 	ipAddr := util.GetIPFromRemoteAddress(r.RemoteAddr)
-	user, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC)
+	user, err := dataprovider.GetUserAfterIDPAuth(t.Username, ipAddr, common.ProtocolOIDC, t.CustomFields)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
 	}
 	}
@@ -616,7 +634,7 @@ func (s *httpdServer) handleOIDCRedirect(w http.ResponseWriter, r *http.Request)
 	if !oauth2Token.Expiry.IsZero() {
 	if !oauth2Token.Expiry.IsZero() {
 		token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
 		token.ExpiresAt = util.GetTimeAsMsSinceEpoch(oauth2Token.Expiry)
 	}
 	}
-	if err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField); err != nil {
+	if err = token.parseClaims(claims, s.binding.OIDC.UsernameField, s.binding.OIDC.RoleField, s.binding.OIDC.CustomFields); err != nil {
 		logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
 		logger.Debug(logSender, "", "unable to parse oidc token claims: %v", err)
 		setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
 		setFlashMessage(w, r, fmt.Sprintf("Unable to parse OpenID token claims: %v", err))
 		doRedirect()
 		doRedirect()

+ 3 - 2
httpd/oidc_test.go

@@ -878,7 +878,6 @@ func TestOIDCPreLoginHook(t *testing.T) {
 	u := dataprovider.User{
 	u := dataprovider.User{
 		BaseUser: sdk.BaseUser{
 		BaseUser: sdk.BaseUser{
 			Username: username,
 			Username: username,
-			Password: "unused",
 			HomeDir:  filepath.Join(os.TempDir(), username),
 			HomeDir:  filepath.Join(os.TempDir(), username),
 			Status:   1,
 			Status:   1,
 			Permissions: map[string][]string{
 			Permissions: map[string][]string{
@@ -897,6 +896,7 @@ func TestOIDCPreLoginHook(t *testing.T) {
 	err = dataprovider.Initialize(newProviderConf, "..", true)
 	err = dataprovider.Initialize(newProviderConf, "..", true)
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	server := getTestOIDCServer()
 	server := getTestOIDCServer()
+	server.binding.OIDC.CustomFields = []string{"field1", "field2"}
 	err = server.binding.OIDC.initialize()
 	err = server.binding.OIDC.initialize()
 	assert.NoError(t, err)
 	assert.NoError(t, err)
 	server.initializeRouter()
 	server.initializeRouter()
@@ -949,7 +949,7 @@ func TestOIDCPreLoginHook(t *testing.T) {
 		Nonce:  authReq.Nonce,
 		Nonce:  authReq.Nonce,
 		Expiry: time.Now().Add(5 * time.Minute),
 		Expiry: time.Now().Add(5 * time.Minute),
 	}
 	}
-	setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`"}`))
+	setIDTokenClaims(idToken, []byte(`{"preferred_username":"`+username+`","field1":"value1","field2":"value2","field3":"value3"}`))
 	server.binding.OIDC.verifier = &mockOIDCVerifier{
 	server.binding.OIDC.verifier = &mockOIDCVerifier{
 		err:   nil,
 		err:   nil,
 		token: idToken,
 		token: idToken,
@@ -989,6 +989,7 @@ func getTestOIDCServer() *httpdServer {
 				RedirectBaseURL: "http://127.0.0.1:8081/",
 				RedirectBaseURL: "http://127.0.0.1:8081/",
 				UsernameField:   "preferred_username",
 				UsernameField:   "preferred_username",
 				RoleField:       "sftpgo_role",
 				RoleField:       "sftpgo_role",
+				CustomFields:    nil,
 			},
 			},
 		},
 		},
 		enableWebAdmin:  true,
 		enableWebAdmin:  true,

+ 4 - 0
openapi/openapi.yaml

@@ -5070,6 +5070,10 @@ components:
         additional_info:
         additional_info:
           type: string
           type: string
           description: Free form text field for external systems
           description: Free form text field for external systems
+        oidc_custom_fields:
+          type: object
+          additionalProperties: true
+          description: 'This field is passed to the pre-login hook if custom OIDC token fields have been configured. Field values can be of any type (this is a free form object) and depend on the type of the configured OIDC token fields'
     AdminFilters:
     AdminFilters:
       type: object
       type: object
       properties:
       properties:

+ 2 - 1
sftpgo.json

@@ -237,7 +237,8 @@
           "config_url": "",
           "config_url": "",
           "redirect_base_url": "",
           "redirect_base_url": "",
           "username_field": "",
           "username_field": "",
-          "role_field": ""
+          "role_field": "",
+          "custom_fields": []
         },
         },
         "security": {
         "security": {
           "enabled": false,
           "enabled": false,

+ 8 - 4
vfs/cryptfs.go

@@ -203,14 +203,18 @@ func (fs *CryptFs) ReadDir(dirname string) ([]os.FileInfo, error) {
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	list, err := f.Readdir(-1)
+	entries, err := f.ReadDir(-1)
 	f.Close()
 	f.Close()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	result := make([]os.FileInfo, 0, len(list))
-	for _, info := range list {
-		result = append(result, fs.ConvertFileInfo(info))
+	result := make([]os.FileInfo, len(entries))
+	for idx, entry := range entries {
+		info, err := entry.Info()
+		if err != nil {
+			return nil, err
+		}
+		result[idx] = fs.ConvertFileInfo(info)
 	}
 	}
 	return result, nil
 	return result, nil
 }
 }

+ 11 - 2
vfs/osfs.go

@@ -3,6 +3,7 @@ package vfs
 import (
 import (
 	"fmt"
 	"fmt"
 	"io"
 	"io"
+	"io/fs"
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"path"
 	"path"
@@ -170,12 +171,20 @@ func (*OsFs) ReadDir(dirname string) ([]os.FileInfo, error) {
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	list, err := f.Readdir(-1)
+	entries, err := f.ReadDir(-1)
 	f.Close()
 	f.Close()
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	return list, nil
+	result := make([]fs.FileInfo, len(entries))
+	for idx, entry := range entries {
+		info, err := entry.Info()
+		if err != nil {
+			return nil, err
+		}
+		result[idx] = info
+	}
+	return result, nil
 }
 }
 
 
 // IsUploadResumeSupported returns true if resuming uploads is supported
 // IsUploadResumeSupported returns true if resuming uploads is supported